콘텐츠로 이동

Snapshot Creation

길드 상태의 동결본을 생성하는 흐름. 수동(manual), 일일 자동(scheduled), 이상 감지(antinuke), 복구 직전(pre_restore) 네 가지 trigger 경로가 있으며 모두 동일한 SnapshotService.Create() 로 수렴한다.

Scenario

네 가지 경로로 스냅샷 생성이 요청된다:

  • Manual — 사용자가 대시보드 또는 슬래시 커맨드(/snapshot create)로 요청
  • Scheduled — asynq cron 이 매일 00:00 (±15분 jitter) 자동 실행
  • AntiNuke — Temporal workflow 가 이상 감지 시 긴급 생성
  • Pre-restore — Restore workflow 가 시작 직전 safety 용으로 생성

모두 내부적으로 같은 서비스 함수를 호출한다. 차이점은 호출 주체와 option payload, 그리고 Plan/limit 체크 로직.

Actors

  • User (Guild Admin) — manual trigger 시
  • umbra-bot / umbra-web — manual 진입점
  • asynq cron — scheduled trigger
  • AntiNuke workflow (Temporal) — antinuke trigger
  • Restore workflow (Temporal) — pre_restore trigger
  • Snapshot sub-context — 실제 생성 로직
  • Sync sub-context — 길드 상태 read
  • Licensing — 권한/한도 체크
  • Notification — (manual 만) 생성 완료 DM

Preconditions

Common

  • Guild 가 active (bot_removed_at NULL, deleted_at NULL)
  • RECOVERY_SNAPSHOT_MANUAL 또는 RECOVERY_SNAPSHOT_SCHEDULED feature 접근 가능 (Plan 별)
  • 길드 상태가 Sync 로부터 유효한 상태여야 함 (recovery.guild_* 테이블에 데이터 존재)

Per trigger

Trigger Extra precondition
manual User 의 MANAGE_GUILD 권한, manual 슬롯 한도 여유 (Pro 1개, Enterprise 3개)
scheduled Plan 이 PRO 또는 ENTERPRISE
antinuke AntiNuke workflow 활성 (Pro 이상)
pre_restore Restore 실행 요청 중

Postconditions

  • recovery.snapshots 에 새 row (payload JSONB 포함)
  • events.outboxSnapshotCreated 이벤트
  • audit.events 기록
  • (manual 만) 사용자에게 DM "스냅샷 생성 완료" + 대시보드 표시
  • (scheduled) 이전 scheduled 스냅샷이 retention 초과 시 cron 에서 별도 삭제 (이 flow 범위 아님)

Sequence — Manual

sequenceDiagram
    participant User
    participant Web as umbra-web
    participant API
    participant SnapSvc as SnapshotService
    participant Licensing
    participant Sync as GuildStateReader
    participant DB
    participant Outbox
    participant Poller
    participant Notification

    User->>Web: Click "스냅샷 생성"
    Web->>API: POST /api/v1/guilds/{id}/snapshots
{ description?, trigger: "manual" } API->>API: Authz: MANAGE_GUILD API->>SnapSvc: Create(guildID, trigger=manual, opts={user, description}) SnapSvc->>Licensing: Can(guildID, RECOVERY_SNAPSHOT_MANUAL) Licensing-->>SnapSvc: true SnapSvc->>Licensing: GetPlanLimits(guildID) Licensing-->>SnapSvc: {snapshot_manual_max: 1} SnapSvc->>DB: CountManualByGuild(guildID) DB-->>SnapSvc: current=1 alt Limit exceeded SnapSvc-->>API: ErrPlanLimitExceeded API-->>Web: 409 "수동 스냅샷 한도를 초과했습니다" end SnapSvc->>Sync: ReadFullState(guildID) Sync-->>SnapSvc: {roles, channels, overrides, settings} SnapSvc->>SnapSvc: Build payload (version=1) SnapSvc->>SnapSvc: Check size <= 5MB alt Size overflow SnapSvc-->>API: ErrSnapshotTooLarge end SnapSvc->>DB: BEGIN TX SnapSvc->>DB: INSERT recovery.snapshots SnapSvc->>Outbox: INSERT SnapshotCreated event SnapSvc->>DB: COMMIT SnapSvc-->>API: snapshot{id, size, created_at} API-->>Web: 200 success Web->>User: "스냅샷 생성 완료" Note over Poller: ~2s Poller->>Notification: OnSnapshotCreated
(manual only) Notification->>User: DM "{snapshot_id} 생성 완료"

Sequence — Scheduled

sequenceDiagram
    participant Cron as asynq cron
daily 00:00 + jitter participant Repo as License Repo participant Worker as snapshot worker participant SnapSvc participant Sync participant DB participant Outbox Cron->>Repo: ListGuildsWithFeature("RECOVERY_SNAPSHOT_SCHEDULED") Repo-->>Cron: [guild1, guild2, ...] loop each guild Cron->>Worker: Enqueue ScheduledSnapshot(guildID) end Worker->>SnapSvc: Create(guildID, trigger=scheduled) SnapSvc->>Sync: ReadFullState(guildID) SnapSvc->>SnapSvc: Build payload SnapSvc->>DB: INSERT snapshot (trigger=scheduled, created_by_user=NULL) SnapSvc->>Outbox: SnapshotCreated (trigger=scheduled) Note over Outbox: Notification 은 scheduled trigger 에 대해 DM 하지 않음

Sequence — AntiNuke (긴급)

sequenceDiagram
    participant WF as AntiNuke
Workflow participant Act as CreateAntiNukeSnapshot
Activity participant SnapSvc participant Sync participant DB participant Outbox Note over WF: Pattern detected WF->>Act: executeActivity(CreateAntiNukeSnapshot, guildID) Act->>SnapSvc: Create(guildID, trigger=antinuke) SnapSvc->>SnapSvc: Skip plan limit check
(긴급 보존, 플랜 한도 초과해도 생성) SnapSvc->>Sync: ReadFullState(guildID) SnapSvc->>DB: INSERT snapshot (trigger=antinuke) SnapSvc->>Outbox: SnapshotCreated alt Creation fails Note over SnapSvc: 실패해도 AntiNuke 알림은 계속
(snapshot_id = NULL) end SnapSvc-->>Act: snapshot{id} or error Act-->>WF: result

Sequence — Pre-restore

sequenceDiagram
    participant RWF as Restore
Workflow participant Act as CreatePreRestoreSnapshot
Activity participant SnapSvc Note over RWF: opts.CreatePreRestoreSnapshot == true RWF->>Act: executeActivity(CreatePreRestoreSnapshot, guildID) Act->>SnapSvc: Create(guildID, trigger=pre_restore) SnapSvc->>SnapSvc: Skip plan limit check (안전장치) SnapSvc->>SnapSvc: Build payload & INSERT alt Success SnapSvc-->>Act: snapshot{id} else Failure Act-->>RWF: warning (restore 계속 진행) end

Step-by-step (공통 경로)

1. Trigger entry

각 진입점:

  • Manual via WebPOST /api/v1/guilds/{id}/snapshots
  • Manual via slash command/snapshot create [description]
  • Scheduled — asynq cron 의 daily job
  • AntiNuke — Temporal Activity CreateAntiNukeSnapshot
  • Pre-restore — Temporal Activity CreatePreRestoreSnapshot

모두 SnapshotService.Create(ctx, guildID, trigger, opts) 호출로 수렴.

2. Permission & limit check

Trigger 별 분기:

func (s *serviceImpl) Create(ctx, guildID, trigger, opts) (*Snapshot, error) {
    // 1. Feature gating
    var requiredFeature Feature
    switch trigger {
    case Manual:
        requiredFeature = FeatureRecoverySnapshotManual
    case Scheduled:
        requiredFeature = FeatureRecoverySnapshotScheduled
    case AntiNuke, PreRestore:
        requiredFeature = FeatureRecoveryLiveSync  // 최소 활성 feature
    }
    if ok, _ := s.licensing.Can(ctx, guildID, requiredFeature); !ok {
        return nil, ErrPermissionDenied
    }

    // 2. Plan limit (manual 만)
    if trigger == Manual {
        limits, _ := s.licensing.GetPlanLimits(ctx, guildID)
        count, _ := s.repo.CountManualByGuild(ctx, guildID)
        if count >= limits.SnapshotManualMax {
            return nil, ErrPlanLimitExceeded
        }
    }
    // scheduled/antinuke/pre_restore 는 한도 체크 skip

    // 3. Read state and build payload
    state, err := s.state.ReadFullState(ctx, guildID)
    if err != nil { return nil, err }

    payload := BuildPayload(state)  // version=1
    size := estimateSize(payload)
    if size > 5*1024*1024 {
        return nil, ErrSnapshotTooLarge
    }

    // 4. Transactional insert + event
    snap := Snapshot{
        ID: uuid.NewV7(),
        GuildID: guildID,
        Payload: payload,
        PayloadSize: size,
        Trigger: trigger,
        CreatedByUser: opts.CreatedByUser,
        Description: opts.Description,
        CreatedAt: time.Now(),
    }

    return snap, s.tx.WithTx(ctx, func(tx TxRepos) error {
        if err := tx.Snapshots.Insert(ctx, snap); err != nil { return err }
        return tx.Events.Publish(ctx, SnapshotCreatedEvent{
            SnapshotID: snap.ID,
            GuildID: guildID,
            Trigger: trigger,
            CreatedByUser: opts.CreatedByUser,
            Size: size,
        })
    })
}

3. Payload construction

ReadFullStaterecovery.guild_* 네 테이블을 읽어 단일 struct 로 반환:

state := GuildStateSnapshot{
    Guild:               s.repo.ReadGuildSettings(ctx, guildID),
    Roles:               s.repo.ReadAllRoles(ctx, guildID),
    Channels:            s.repo.ReadAllChannels(ctx, guildID),
    PermissionOverrides: s.repo.ReadAllOverrides(ctx, guildID),
}

BuildPayload 가 이를 JSONB 표준 형식으로 변환:

func BuildPayload(state GuildStateSnapshot) SnapshotPayload {
    return SnapshotPayload{
        Version:    1,
        SnapshotAt: time.Now(),
        Guild:      guildToJSON(state.Guild),
        Roles:      rolesToJSON(state.Roles),
        Channels:   channelsToJSON(state.Channels),
        PermissionOverrides: overridesToJSON(state.PermissionOverrides),
    }
}

domain/recovery/snapshot.md 의 payload schema 참조.

4. Size enforcement

estimateSize 가 JSON 직렬화 후 바이트 측정. 5MB 초과 시 생성 거부 (ADR-0025).

MVP 규모 길드에서 초과는 거의 없음. 단 10k+ 멤버 + 수백 채널 길드는 해당 가능 → 사용자 지원 필요.

5. Transaction + Outbox

snapshots INSERT 와 events.outbox INSERT 는 같은 트랜잭션. 원자성 보장.

Step-by-step (per-trigger specifics)

Manual

Entry: POST /api/v1/guilds/{id}/snapshots, body:

{
  "description": "Pre-event backup before migration"
}

Permission: API 핸들러가 Discord MANAGE_GUILD 체크 (세션의 User 가 해당 guild 에 대해).

Slot check: Plan 한도 초과 시 409 + "기존 수동 스냅샷을 삭제해주세요".

Notification: Outbox → Notification consumer 가 trigger=manual 일 때만 DM.

Scheduled

Entry: daily cron

asynq.RegisterCronFunc("0 0 * * *", func(ctx, t) error {
    // jitter: 00:00 ± 15min
    time.Sleep(time.Duration(rand.Intn(1800)-900) * time.Second)

    guilds, _ := licensingSvc.ListGuildsWithFeature(ctx, FeatureRecoverySnapshotScheduled)
    for _, g := range guilds {
        _, _ = client.Enqueue(asynq.NewTask("recovery:scheduled_snapshot", []byte(g.ID.String())))
    }
    return nil
})

Worker handler:

func HandleScheduledSnapshot(ctx, t) error {
    guildID, _ := uuid.Parse(string(t.Payload()))
    _, err := snapshotSvc.Create(ctx, guildID, Scheduled, CreateOptions{})
    return err
}

Notification: Scheduled 는 DM 하지 않음 (매일 보내면 스팸). 대시보드에서 목록 확인.

Retention: 별도 cron (04:00) 이 Plan 별 retention 초과 스냅샷 삭제.

AntiNuke (긴급)

Entry: AntiNuke Temporal workflow 의 respondToDetection

snapID, _ := workflow.ExecuteActivity(ctx, CreateAntiNukeSnapshot, guildID).Get(ctx, &snapID)

Activity:

func CreateAntiNukeSnapshotActivity(ctx, guildID) (uuid.UUID, error) {
    snap, err := snapshotSvc.Create(ctx, guildID, AntiNuke, CreateOptions{})
    if err != nil {
        // AntiNuke 알림은 계속 진행. snapshot_id = nil 로 incident 기록
        return uuid.Nil, err
    }
    return snap.ID, nil
}

Plan 한도 skip: 긴급 보존 목적. Pro 가 이미 수동 스냅샷 1개 가져도 antinuke 는 추가 생성.

Retention: 7일 보관 (플랜 무관).

Pre-restore

Entry: Restore Temporal workflow 의 초기 단계

if input.Options.CreatePreRestoreSnapshot {
    preID, err := workflow.ExecuteActivity(ctx, CreatePreRestoreSnapshot, input.GuildID).Get(ctx, &preID)
    if err != nil {
        workflow.GetLogger(ctx).Warn("pre-restore snapshot failed, continuing", "error", err)
    }
}

Failure tolerance: 생성 실패해도 restore 는 계속. 사용자에게는 경고 메시지.

Retention: 24시간 (rollback 용도로만 단기 보관).

Failure cases

Plan limit exceeded (manual)

  • When — Pro 가 이미 수동 스냅샷 1개, 추가 요청
  • Response — 409 + "기존 수동 스냅샷을 먼저 삭제하세요"
  • User experience — 대시보드에서 삭제 UI 제공

Size overflow (5MB)

  • When — 초대형 길드
  • Response — 생성 거부 + 운영자 alert
  • Mitigation — 현재는 support 안내. Phase 2 에서 chunking 또는 S3 저장 검토

Sync state 읽기 실패

  • Whenrecovery.guild_* 테이블 접근 불가 (DB 장애)
  • Response — 에러 반환, scheduled 는 asynq retry, manual 은 사용자에게 에러 표시
  • User experience — "일시적 오류, 잠시 후 재시도"

Transaction 실패

  • When — INSERT 도중 DB 접속 끊김
  • Response — 트랜잭션 롤백, 전체 실패
  • Partial state: 없음 (atomic)

AntiNuke snapshot 실패 (긴급)

  • When — DB 장애 등으로 INSERT 실패
  • Response
  • AntiNuke workflow 는 snapshot_id NULL 로 incident 기록하고 계속 진행
  • Owner DM 은 발송 (중요도 우선)
  • operator alert (긴급 스냅샷 실패는 운영 문제)
  • User experience — AntiNuke 알림은 받지만 복구 소스 없음. 이전 scheduled 스냅샷으로 복구 가능

Pre-restore snapshot 실패

  • When — 복구 직전에 스냅샷 생성 실패
  • Response — Restore workflow 는 계속 (옵션이 선택적). 사용자에게 "Pre-restore 스냅샷 생성에 실패했습니다. 계속 진행하시겠습니까?" 확인
  • User experience — 안전장치 없이 복구 진행 시 경고

Scheduled snapshot 이 이전 스냅샷과 완전 동일

  • When — 길드에 변경 없이 하루 경과
  • Response — 그래도 새 row 생성 (retention 으로 관리). 중복 제거 최적화는 Phase 2 에서 payload 해시 비교로 검토.

Edge cases

Snapshot 생성 중 Bot kicked

  • Detection — ReadFullState 는 성공 (DB 기준). INSERT 도 완료.
  • Result: 스냅샷은 정상 생성. Bot 없는 길드의 스냅샷이 되지만 데이터 무결성은 유지. 이후 복구 시 봇 재설치 필요.

동시 Manual 요청

  • 두 Admin 이 동시에 "스냅샷 생성" 클릭
  • Plan 한도가 1이면 두 요청 모두 count=0 으로 읽고 INSERT → 한도 초과 발생 가능
  • 해결: INSERT ... WHERE (SELECT COUNT(*) ...) < limit 같은 조건부 INSERT 또는 advisory lock
  • 실무 영향 낮음 (동시 요청 드뭄), Phase 2 에서 strict 하게

Plan downgrade 직후 scheduled snapshot

  • Enterprise 였는데 Pro 로 downgrade → daily cron 은 여전히 scheduled snapshot 생성 (Pro 도 scheduled 기능 있음)
  • 다만 retention 은 7일로 단축
  • 기존 30일 보관 스냅샷은 retention cron 에서 점진 삭제

AntiNuke 가 여러 번 연속 trigger

  • AntiNuke workflow 는 clear 후 재 accumulate
  • 연속 trigger 시 짧은 시간 내 여러 스냅샷 생성 가능
  • 용량 부담: 7일 retention 으로 자연 해소

Payload schema 변경 후 읽기

  • Version 필드로 관리
  • 새 버전 INSERT 은 자연스럽게
  • 읽기 (restore 시) 시점에 version 체크 후 처리
  • 미지원 version 이면 에러

Involved domains

Domain Role
Recovery (Snapshot) 스냅샷 생성 (writer)
Recovery (Sync) 길드 상태 제공 (reader)
Licensing Feature + limit 체크
Notification (manual only) 생성 완료 DM
Audit 이벤트 기록

See also

  • domain/recovery/snapshot.md
  • domain/recovery/sync.md
  • flows/restore-execution.md — 스냅샷 소비
  • flows/antinuke-trigger.md — 긴급 스냅샷
  • adr/0025-snapshot-jsonb-storage.md
  • adr/0027-message-content-exclusion.md — 스냅샷 범위