Restore Execution¶
사용자가 스냅샷을 기준으로 길드 상태를 되돌리는 end-to-end 흐름. Preview 로 diff 를 계산해 사용자에게 확인 받고, 승인 시 Temporal Workflow 를 시작하여 Discord API 호출을 단계별로 수행한다. 복구 중 Live Sync 는 pause 되고 완료 후 buffer clear + resume.
Scenario¶
사용자가 대시보드에서 특정 스냅샷을 선택하고 복구 범위(roles / channels / permission overrides / guild settings)를 체크. "미리보기" 를 클릭해 diff 를 확인하고 "복구 실행" 으로 승인. 서버가 Temporal workflow 를 시작하여 삭제(역순) → 생성/업데이트(정순) 단계별로 Discord API 호출. 진행 중 사용자는 실시간 진행 상황 조회 가능. 완료 시 요약 알림.
Actors¶
- User (Guild Admin) —
MANAGE_GUILD권한 - umbra-web — 대시보드 UI
- umbra-api — Preview/Start 엔드포인트
- Restore sub-context — 서비스 로직
- Snapshot sub-context — 스냅샷 read + pre_restore 생성
- Sync sub-context — pause/resume
- Discord API — 실제 상태 fetch + 복구 작업
- Temporal Workflow — 단계별 실행 orchestration
- Notification / Audit
Preconditions¶
- User 의
MANAGE_GUILD권한 - Guild active (bot_installed_at NOT NULL)
- License feature
RECOVERY_RESTORE포함 (Pro+) - 대상 스냅샷이 해당 guild_id 의 것
- 같은 Guild 에 이미 진행 중인 Restore (pending/running) 없음
Postconditions¶
Success¶
- Discord 길드 상태가 스냅샷 시점과 동일
recovery.restore_jobs에status=completed,result_summary채워진 rowevents.outbox에RestoreStarted,RestoreCompleted이벤트- 사용자에게 완료 DM + 대시보드 표시
- Live Sync buffer clear 후 resume
Failure¶
- Discord 상태는 부분 적용 상태 (자동 롤백 없음)
recovery.restore_jobs에status=failed,error_messageRestoreFailed이벤트- 사용자에게 실패 DM + 상세 오류 + Pre-restore snapshot 안내
Cancel¶
- Discord 상태 부분 적용 (취소 시점까지)
- Live Sync resume
status=canceled
Sequence¶
sequenceDiagram
participant User
participant Web
participant API
participant RestoreSvc
participant Discord
participant SnapSvc
participant Temporal
participant Workflow as Restore
Workflow
participant Activity as Activities
participant SyncSvc
participant Outbox
participant Poller
participant Notification
User->>Web: Open Restore tab, select snapshot
User->>Web: Select scope (roles, channels, overrides, settings)
Web->>API: POST /api/v1/restore/preview
{ snapshot_id, scope }
API->>RestoreSvc: Preview(snapshotID, scope)
RestoreSvc->>RestoreSvc: Load snapshot payload
RestoreSvc->>Discord: FetchCurrentState(guildID)
Discord-->>RestoreSvc: current state
RestoreSvc->>RestoreSvc: Compute diff
(to_create, to_update, to_delete)
RestoreSvc-->>API: RestoreDiff
API-->>Web: diff summary
Web->>User: Show diff preview UI
User->>Web: Confirm restore
(create_pre_restore_snapshot: true)
Web->>API: POST /api/v1/restore/start
{ snapshot_id, scope, approved_diff, options }
API->>RestoreSvc: Start(input)
RestoreSvc->>RestoreSvc: Validate no active job
(UNIQUE constraint)
RestoreSvc->>RestoreSvc: BEGIN TX
RestoreSvc->>RestoreSvc: INSERT restore_jobs (status=pending,
preview_diff, scope, options)
RestoreSvc->>Outbox: INSERT RestoreStarted event
RestoreSvc->>RestoreSvc: COMMIT
RestoreSvc->>Temporal: StartRestoreWorkflow(input)
Temporal-->>RestoreSvc: workflow_id
RestoreSvc->>RestoreSvc: UPDATE restore_jobs
temporal_workflow_id, status=running, started_at
RestoreSvc-->>API: job{id, temporal_workflow_id}
API-->>Web: {job_id}
Web->>User: "복구 진행 중..." + progress bar
Note over Temporal,Workflow: Workflow 실행 시작
Workflow->>Activity: ValidateSnapshot
Workflow->>Activity: CreatePreRestoreSnapshot
(if opts.create_pre_restore)
Activity->>SnapSvc: Create(trigger=pre_restore)
Workflow->>Activity: PauseLiveSync
Activity->>SyncSvc: PauseBuffer(guildID)
Note over Workflow: Phase 1: Delete (역순)
Workflow->>Activity: DeleteRemovedOverrides
Workflow->>Activity: DeleteRemovedChannels
Workflow->>Activity: DeleteRemovedRoles
Activity->>Discord: DELETE api calls
Note over Workflow: Phase 2: Create/Update (정순)
Workflow->>Activity: CreateOrUpdateRoles
Workflow->>Activity: CreateOrUpdateChannels
Workflow->>Activity: ApplyPermissionOverrides
Workflow->>Activity: UpdateGuildSettings
Activity->>Discord: CREATE/PATCH api calls
Workflow->>Activity: ResumeLiveSync (clear buffer)
Activity->>SyncSvc: ResumeBuffer(clearBuffer=true)
Workflow->>Activity: PublishRestoreCompleted
Activity->>Outbox: INSERT RestoreCompleted event
Activity->>Activity: UPDATE restore_jobs
status=completed, result_summary, completed_at
Poller->>Notification: OnRestoreCompleted
Notification->>User: DM "복구 완료: X개 역할 생성, Y개 채널 복원..."
Step-by-step¶
1. Preview 계산¶
API endpoint: POST /api/v1/restore/preview
Request:
Service:
func (s *restoreSvc) Preview(ctx, snapshotID, scope) (*RestoreDiff, error) {
// 1. Load snapshot
snap, _ := s.snapRepo.GetByID(ctx, snapshotID)
if snap == nil { return nil, ErrSnapshotNotFound }
// 2. Feature check
if ok, _ := s.licensing.Can(ctx, snap.GuildID, FeatureRecoveryRestore); !ok {
return nil, ErrPermissionDenied
}
// 3. Parse payload (version check)
payload, err := ParsePayload(snap.Payload)
if err != nil { return nil, err }
// 4. Fetch current state from Discord
guild, _ := s.guildRepo.GetByID(ctx, snap.GuildID)
current, err := s.discord.FetchCurrentState(ctx, guild.DiscordGuildID)
if err != nil { return nil, err }
// 5. Compute diff for each scope category
diff := &RestoreDiff{}
if containsScope(scope, "roles") {
diff.RolesToCreate, diff.RolesToUpdate, diff.RolesToDelete =
diffRoles(payload.Roles, current.Roles)
}
// channels, overrides, guild_settings 동일
diff.EstimatedDuration = estimateDuration(diff) // 대략 계산
return diff, nil
}
Diff 계산 로직 (roles 예시):
func diffRoles(snapRoles, currRoles []Role) (create, update, delete []RoleDiffItem) {
snapMap := toMap(snapRoles, r => r.DiscordID)
currMap := toMap(currRoles, r => r.DiscordID)
for id, snapR := range snapMap {
if currR, exists := currMap[id]; exists {
if !equalRole(snapR, currR) {
update = append(update, RoleDiffItem{...})
}
} else {
create = append(create, RoleDiffItem{...})
}
}
for id, currR := range currMap {
if _, exists := snapMap[id]; !exists {
delete = append(delete, RoleDiffItem{...})
}
}
return
}
Response:
{
"roles_to_create": [{"name": "Admin", "color": "#FF0000", ...}],
"roles_to_update": [{"current": {...}, "target": {...}, "changed_fields": ["color"]}],
"roles_to_delete": [{"name": "Spammer", "id": "..."}],
"channels_to_create": [...],
"channels_to_update": [...],
"channels_to_delete": [...],
"overrides_to_apply": [...],
"overrides_to_remove": [...],
"guild_settings_diff": {"name": {"current": "Old", "target": "New"}},
"estimated_duration": "~30 seconds"
}
대시보드가 이를 시각적으로 표시:
복구 변경 사항:
역할:
✓ 생성: "Moderator"
⟳ 업데이트: "Admin" (색상 변경)
✗ 삭제: "Spammer" (현재만 존재)
채널:
✗ 삭제: "#nuke-channel"
✓ 생성: "#announcements"
...
예상 소요 시간: 약 30초
2. User confirm¶
사용자가 "복구 실행" 클릭 시 POST /api/v1/restore/start:
{
"snapshot_id": "018f...",
"scope": ["roles", "channels", "permission_overrides"],
"approved_diff_hash": "sha256:...", // preview 무결성 체크
"options": {
"create_pre_restore_snapshot": true
}
}
3. RestoreJob 생성¶
func (s *restoreSvc) Start(ctx, input) (*RestoreJob, error) {
// 1. Pre-conditions
snap, _ := s.snapRepo.GetByID(ctx, input.SnapshotID)
if ok, _ := s.licensing.Can(ctx, snap.GuildID, FeatureRecoveryRestore); !ok {
return nil, ErrPermissionDenied
}
// 2. 동시 실행 방지
active, _ := s.jobRepo.GetActiveByGuild(ctx, snap.GuildID)
if active != nil {
return nil, ErrRestoreAlreadyInProgress
}
// 3. Diff 재계산 (preview 와 비교)
diff, _ := s.Preview(ctx, input.SnapshotID, input.Scope)
if hashDiff(diff) != input.ApprovedDiffHash {
return nil, ErrDiffChanged // 미리보기 이후 길드가 변경됨
}
// 4. Job INSERT + Outbox
job := &RestoreJob{
ID: uuid.NewV7(),
GuildID: snap.GuildID,
SnapshotID: snap.ID,
RequestedByUser: input.RequestedByUser,
Scope: input.Scope,
Options: input.Options,
Status: "pending",
PreviewDiff: diff,
CreatedAt: time.Now(),
}
err := s.tx.WithTx(ctx, func(tx TxRepos) error {
tx.Jobs.Insert(ctx, job)
return tx.Events.Publish(ctx, RestoreStartedEvent{
RestoreJobID: job.ID,
SnapshotID: snap.ID,
GuildID: snap.GuildID,
Scope: input.Scope,
})
})
if err != nil { return nil, err }
// 5. Temporal workflow 시작
workflowID, err := s.temporal.StartRestoreWorkflow(ctx, RestoreWorkflowInput{
JobID: job.ID,
SnapshotID: snap.ID,
GuildID: snap.GuildID,
Scope: input.Scope,
Options: input.Options,
})
if err != nil {
// Workflow 시작 실패 — job 을 failed 로
job.Status = "failed"
job.ErrorMessage = ptr(err.Error())
s.jobRepo.Update(ctx, job)
return nil, err
}
// 6. Job 업데이트
job.TemporalWorkflowID = &workflowID
job.Status = "running"
job.StartedAt = ptr(time.Now())
s.jobRepo.Update(ctx, job)
return job, nil
}
4. Temporal Workflow 실행¶
Workflow 의사 코드는 domain/recovery/restore.md 에 있음. 핵심 흐름:
func RestoreWorkflow(ctx workflow.Context, input RestoreInput) error {
// Activity retry policy
ao := workflow.ActivityOptions{
StartToCloseTimeout: 5 * time.Minute,
RetryPolicy: &temporal.RetryPolicy{
InitialInterval: time.Second,
BackoffCoefficient: 2.0,
MaximumInterval: time.Minute,
MaximumAttempts: 10,
NonRetryableErrorTypes: []string{"PermissionDenied", "GuildNotFound"},
},
}
ctx = workflow.WithActivityOptions(ctx, ao)
// 1. Validate
var snap SnapshotData
workflow.ExecuteActivity(ctx, ValidateSnapshot, input.SnapshotID).Get(ctx, &snap)
// 2. Pre-restore snapshot (옵션)
if input.Options.CreatePreRestoreSnapshot {
workflow.ExecuteActivity(ctx, CreatePreRestoreSnapshot, input.GuildID).Get(ctx, nil)
}
// 3. Pause Live Sync (defer resume)
workflow.ExecuteActivity(ctx, PauseLiveSync, input.GuildID).Get(ctx, nil)
defer func() {
disconnCtx, _ := workflow.NewDisconnectedContext(ctx)
workflow.ExecuteActivity(disconnCtx, ResumeLiveSync, input.GuildID, true).Get(disconnCtx, nil)
}()
// 4. Phase 1: Delete (reverse order)
if contains(input.Scope, "permission_overrides") {
workflow.ExecuteActivity(ctx, DeleteRemovedOverrides, input).Get(ctx, nil)
}
if contains(input.Scope, "channels") {
workflow.ExecuteActivity(ctx, DeleteRemovedChannels, input).Get(ctx, nil)
}
if contains(input.Scope, "roles") {
workflow.ExecuteActivity(ctx, DeleteRemovedRoles, input).Get(ctx, nil)
}
// 5. Phase 2: Create/Update
if contains(input.Scope, "roles") {
workflow.ExecuteActivity(ctx, CreateOrUpdateRoles, input).Get(ctx, nil)
}
if contains(input.Scope, "channels") {
workflow.ExecuteActivity(ctx, CreateOrUpdateChannels, input).Get(ctx, nil)
}
if contains(input.Scope, "permission_overrides") {
workflow.ExecuteActivity(ctx, ApplyPermissionOverrides, input).Get(ctx, nil)
}
if contains(input.Scope, "guild_settings") {
workflow.ExecuteActivity(ctx, UpdateGuildSettings, input).Get(ctx, nil)
}
// 6. Finalize
workflow.ExecuteActivity(ctx, FinalizeRestoreJob, input.JobID, resultSummary).Get(ctx, nil)
return nil
}
5. Activity 의 Discord 호출¶
각 Activity 는 idempotent. Discord 429 rate limit 은 Temporal retry policy 로 자동 처리.
CreateOrUpdateRoles 예시:
func CreateOrUpdateRolesActivity(ctx, input) error {
snap := loadSnapshot(input.SnapshotID)
current := fetchCurrentRoles(input.GuildID)
snapMap := toMap(snap.Roles)
currMap := toMap(current)
// Create
for id, role := range snapMap {
if _, exists := currMap[id]; !exists {
_, err := discord.CreateRole(ctx, input.GuildID, role.ToSpec())
if err != nil { return err } // Temporal retries
}
}
// Update
for id, role := range snapMap {
if curr, exists := currMap[id]; exists {
if !equalRole(role, curr) {
err := discord.UpdateRole(ctx, input.GuildID, id, role.ToSpec())
if err != nil { return err }
}
}
}
return nil
}
6. 실시간 진행 조회¶
대시보드가 GET /api/v1/restore/{job_id} 로 현재 상태 조회:
func (s *restoreSvc) GetJob(ctx, jobID) (*RestoreJob, error) {
job, _ := s.jobRepo.GetByID(ctx, jobID)
// Running 이면 Temporal 에서 현재 phase 쿼리
if job.Status == "running" && job.TemporalWorkflowID != nil {
progress, err := s.temporal.QueryWorkflow(ctx, *job.TemporalWorkflowID, "getProgress")
if err == nil {
job.Progress = progress.(*ProgressSummary)
}
}
return job, nil
}
Workflow 내부의 query handler:
workflow.SetQueryHandler(ctx, "getProgress", func() (*ProgressSummary, error) {
return ¤tProgress, nil
})
7. 완료 알림¶
RestoreCompleted 이벤트 수신 → Notification:
[복구 완료]
스냅샷: 2026-04-10 12:00
복구 범위: 역할, 채널, 권한 오버라이드
변경 사항:
✓ 역할 생성: 3
⟳ 역할 업데이트: 5
✗ 역할 삭제: 2
✓ 채널 생성: 1
...
소요 시간: 28초
복구 전 스냅샷: 018f... (24시간 보관)
Failure cases¶
Preview — Discord fetch 실패¶
- When — Discord API 장애
- Response — 500 + "현재 길드 상태를 가져올 수 없습니다. 잠시 후 재시도"
- User experience — Retry 버튼
Preview vs Start 간 diff 변경¶
- When — 사용자가 preview 후 승인 전에 길드 상태 변경됨
- Detection —
approved_diff_hash불일치 - Response — 409 + "길드 상태가 변경되었습니다. 다시 미리보기를 실행해주세요"
- User experience — Preview 재실행
동시 Restore 요청¶
- When — 이미 running 인 restore 있는데 새 요청
- Detection —
UNIQUE(guild_id) WHERE status IN ('pending', 'running') - Response — 409 + 기존 Job 정보 반환
Temporal workflow 시작 실패¶
- When — Temporal Server 장애
- Detection — StartWorkflow error
- Response — Job
status=failed, 사용자에게 에러 - User experience — Temporal 복구 후 재시도 요청
Activity max retries 초과¶
- When — Discord API 가 지속 실패
- Detection — Workflow 에서 activity error
- Response —
- Workflow
Failed로 종료 - Job
status=failed,error_message기록 - defer 의
ResumeLiveSync는 실행됨 (Disconnected context) - Pre-restore snapshot 이 있다면 사용자에게 "되돌리기" 옵션 안내
- User experience — "복구 도중 실패: {phase}. 부분 적용 상태. {pre_restore_snapshot_id} 로 되돌리시겠습니까?"
Guild deleted during restore¶
- When — 복구 중 길드가 삭제됨
- Detection — Activity 에서 Discord 404
- Response — Workflow cancel, Job
status=canceled,error_message="guild deleted" - User experience — 알림
User cancel during restore¶
- When — 대시보드에서 "취소" 버튼
- Detection — API 가
Temporal.CancelWorkflow호출 - Response —
- 진행 중 activity 는 완료 허용 (롤백 없음)
- 이후 단계 skip
- Job
status=canceled - defer 의 ResumeLiveSync 실행
- User experience — "복구 취소됨. 부분 적용 상태. 필요 시 pre-restore 스냅샷으로 되돌리세요"
Pre-restore snapshot 생성 실패¶
- When — 시작 직전 pre_restore 실패
- Detection — Activity error
- Response — Workflow 는 계속 진행 (옵션이 best-effort)
- Logger warn — "pre-restore snapshot failed, continuing"
- User experience — 사용자에게는 진행 상태에서 경고 표시
Edge cases¶
스냅샷 payload 버전 불일치¶
- When — 오래된 스냅샷이 새 code 가 모르는 version
- Detection —
ValidateSnapshotactivity - Response — 즉시 실패, 사용자에게 "이 스냅샷은 지원되지 않습니다"
길드 설정만 복구¶
- Scope =
["guild_settings"]만 선택 - Phase 1 skip (아무것도 삭제 안 함), Phase 2 에서 guild_settings 만 업데이트
매우 큰 diff (수백 변경)¶
- Discord API rate limit 으로 시간이 오래 걸림 (수 분~수십 분)
- Temporal retry 가 자동 처리
- 진행 표시에서 "X/Y 완료" 형태로 업데이트
Restore 중 새 snapshot 생성 불가¶
- Live Sync 가 paused 상태면 길드 상태는 "복구 시작 시점" 그대로
- Snapshot create 시도 시 현재 상태를 정확히 반영하는지 모호 → Restore 진행 중에는 Manual snapshot 차단 (409)
Cancel 후 immediate restart¶
- Cancel →
status=canceled - 같은 스냅샷으로 다시 Start 가능 (partial apply 상태에서)
- Preview 는 현재 (partially restored) 상태 기준으로 새로 계산
Discord rate limit 지속¶
- 10회 retry 소진
- Activity 가
NonRetryableError로 마감 - Workflow 가 Failed
Self-recovery (pre_restore 로 되돌리기)¶
- Restore 실패 후 사용자가 pre_restore 스냅샷으로 새 복구 요청
- 동일 플로우 재진입
- pre_restore 의 pre_restore 도 생성될 수 있음 (중첩 OK)
Involved domains¶
| Domain | Role |
|---|---|
| Recovery (Restore) | Job lifecycle, Workflow orchestration (writer) |
| Recovery (Snapshot) | Snapshot read + pre_restore 생성 |
| Recovery (Sync) | pause/resume signal 수신 |
| Licensing | Feature 체크 |
| Temporal | Workflow 실행 |
| Notification | 시작/완료/실패 DM |
| Audit | 이벤트 기록 |
See also¶
domain/recovery/restore.md— Workflow 상세domain/recovery/snapshot.md— 스냅샷 소스domain/recovery/sync.md— pause/resume 메커니즘flows/snapshot-creation.md— 스냅샷 생성adr/0014-recovery-temporal-workflow.mdadr/0024-restore-mode-sync.md— Sync modeguides/temporal-workflow.md— Workflow 작성 가이드