콘텐츠로 이동

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_jobsstatus=completed, result_summary 채워진 row
  • events.outboxRestoreStarted, RestoreCompleted 이벤트
  • 사용자에게 완료 DM + 대시보드 표시
  • Live Sync buffer clear 후 resume

Failure

  • Discord 상태는 부분 적용 상태 (자동 롤백 없음)
  • recovery.restore_jobsstatus=failed, error_message
  • RestoreFailed 이벤트
  • 사용자에게 실패 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:

{
  "snapshot_id": "018f...",
  "scope": ["roles", "channels", "permission_overrides"]
}

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 &currentProgress, 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 후 승인 전에 길드 상태 변경됨
  • Detectionapproved_diff_hash 불일치
  • Response — 409 + "길드 상태가 변경되었습니다. 다시 미리보기를 실행해주세요"
  • User experience — Preview 재실행

동시 Restore 요청

  • When — 이미 running 인 restore 있는데 새 요청
  • DetectionUNIQUE(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
  • DetectionValidateSnapshot activity
  • 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.md
  • adr/0024-restore-mode-sync.md — Sync mode
  • guides/temporal-workflow.md — Workflow 작성 가이드