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_SCHEDULEDfeature 접근 가능 (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.outbox에SnapshotCreated이벤트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 Web —
POST /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¶
ReadFullState 는 recovery.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:
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
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 읽기 실패¶
- When —
recovery.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.mddomain/recovery/sync.mdflows/restore-execution.md— 스냅샷 소비flows/antinuke-trigger.md— 긴급 스냅샷adr/0025-snapshot-jsonb-storage.mdadr/0027-message-content-exclusion.md— 스냅샷 범위