콘텐츠로 이동

Recovery: Restore

스냅샷 기준으로 길드 상태를 되돌리는 sub-context. Temporal Workflow 로 실행되며, Sync mode (Git reset 스타일) 으로 동작한다. 실행 전 diff 미리보기를 제공하고, 사용자 승인 후 단계별 Activity 로 Discord API 호출을 수행한다.

Sub-context position

  • Parent — Recovery Core domain
  • Locationengine/recovery/subdomain/restore/
  • Sibling sub-contexts — Snapshot (소스), Sync (복구 중 pause 대상), AntiNuke (자동 트리거)

Why this sub-context exists

복구는 Umbra 가 고객에게 한 핵심 약속이다. "nuke 당했을 때 되돌린다" 가 실제로 동작하지 않으면 제품 자체가 무의미하다. 따라서 Restore 는 다음 요구를 모두 만족해야 한다.

  • 신뢰성 — 프로세스 장애에도 완주. 중간에 멈춘 복구는 사용자 신뢰를 깬다.
  • Rate limit 대응 — Discord API 의 초당 제한을 자동 처리.
  • 예측 가능성 — 사용자가 무엇이 바뀌는지 미리 알고 승인.
  • 관측성 — 진행 상황을 실시간 조회 가능.
  • 재시작 안전 — 특정 단계 실패 시 해당 단계부터 재실행.

이 요구는 Temporal Workflow 의 강점과 정확히 일치한다 (ADR-0014). asynq 나 자체 구현으로는 수동 상태 관리의 복잡도가 폭증한다.

Domain model

RestoreJob (aggregate)

RestoreJob
├─ id                   UUID v7       PK
├─ guild_id             UUID          → guild.guilds.id
├─ snapshot_id          UUID          → recovery.snapshots.id
├─ requested_by_user    UUID          → identity.users.id
├─ scope                TEXT[]        ['roles', 'channels', 'permission_overrides', 'guild_settings']
├─ options              JSONB         pre_restore_snapshot, etc.
├─ status               TEXT          'pending' | 'running' | 'completed' | 'failed' | 'canceled'
├─ temporal_workflow_id TEXT          UNIQUE
├─ preview_diff         JSONB         승인 시점 diff (immutable)
├─ result_summary       JSONB         완료 후 결과 (counts, errors)
├─ error_message        TEXT
├─ started_at           TIMESTAMPTZ
├─ completed_at         TIMESTAMPTZ
├─ created_at           TIMESTAMPTZ
└─ updated_at           TIMESTAMPTZ

RestoreDiff (value object, preview 결과)

RestoreDiff
├─ roles_to_create      []RoleDiffItem
├─ roles_to_update      []RoleDiffItem
├─ roles_to_delete      []RoleDiffItem
├─ channels_to_create   []ChannelDiffItem
├─ channels_to_update   []ChannelDiffItem
├─ channels_to_delete   []ChannelDiffItem
├─ overrides_to_apply   []OverrideDiffItem
├─ overrides_to_remove  []OverrideDiffItem
├─ guild_settings_diff  GuildSettingsDiff
└─ estimated_duration   time.Duration  (대략)

RestoreScope (enum set)

RestoreScope
  roles
  channels
  permission_overrides
  guild_settings

의존성 강제:

  • permission_overrides ⊂ (roleschannels)
  • guild_settings 는 독립적 선택 가능

Invariants

  • Sync mode 만 지원 (MVP) — Add-only / Merge 는 out of scope
  • Preview 필수 — Preview 없이 실행 불가 (UX 강제)
  • Temporal workflow 1:1 — RestoreJob 당 하나의 workflow
  • scope 는 의존성 준수 — 위반 조합은 생성 거부
  • pre_restore snapshot 기본 ON — 옵션으로 off 가능하지만 경고 표시
  • 동시 Restore 금지 — 한 Guild 에 status IN ('pending', 'running') 유일
  • Preview diff immutable — 승인 시점 diff 를 기록, 실행 중 길드 상태 변경이 결과에 반영되어도 preview 는 수정 안 함

State machine

stateDiagram-v2
    [*] --> Pending : Preview created & user approved
    Pending --> Running : Workflow started
    Running --> Completed : All phases success
    Running --> Failed : Activity max retries exceeded
    Running --> Canceled : User canceled / guild deleted
    Pending --> Canceled : User canceled before start
    Completed --> [*]
    Failed --> [*]
    Canceled --> [*]

Domain events

Published

Event Trigger Payload Subscribers
RestoreStarted Workflow 시작 restore_job_id, snapshot_id, guild_id, scope Notification, Audit
RestoreCompleted Workflow 성공 완료 restore_job_id, result_summary Notification, Audit
RestoreFailed Workflow 실패 restore_job_id, error_summary, last_successful_phase Notification, Audit
RestoreCanceled 사용자 또는 시스템 취소 restore_job_id, reason Notification, Audit
MemberRestored (별도 플로우) 멤버 수동 복원 member_id, guild_id, restored_roles Audit

Consumed

Source Action
AntiNuke Temporal signal (Enterprise auto-action) 자동 Restore 트리거
사용자 요청 (대시보드) Preview 생성 → 승인 → StartRestore

Restore Workflow (Temporal)

Temporal Workflow 의 의사 코드:

func RestoreWorkflow(ctx workflow.Context, input RestoreInput) (*RestoreResult, error) {
    // 1. Validate
    state, err := executeActivity(ValidateSnapshot, input.SnapshotID)
    if err != nil { return nil, err }

    // 2. Optional pre-restore snapshot
    if input.Options.CreatePreRestoreSnapshot {
        _, err := executeActivity(CreatePreRestoreSnapshot, input.GuildID)
        if err != nil { /* warn, continue */ }
    }

    // 3. Pause Live Sync
    err = executeActivity(PauseLiveSync, input.GuildID)
    if err != nil { return nil, err }
    defer executeActivity(ResumeLiveSync, input.GuildID, true)  // clear buffer

    // 4. Phase: Delete (역순 — overrides → channels → roles)
    if contains(input.Scope, "permission_overrides") {
        executeActivity(DeleteRemovedOverrides, input.SnapshotID, input.GuildID)
    }
    if contains(input.Scope, "channels") {
        executeActivity(DeleteRemovedChannels, input.SnapshotID, input.GuildID)
    }
    if contains(input.Scope, "roles") {
        executeActivity(DeleteRemovedRoles, input.SnapshotID, input.GuildID)
    }

    // 5. Phase: Create & Update (정순)
    if contains(input.Scope, "roles") {
        executeActivity(CreateOrUpdateRoles, input.SnapshotID, input.GuildID)
    }
    if contains(input.Scope, "channels") {
        executeActivity(CreateOrUpdateChannels, input.SnapshotID, input.GuildID)
    }
    if contains(input.Scope, "permission_overrides") {
        executeActivity(ApplyPermissionOverrides, input.SnapshotID, input.GuildID)
    }
    if contains(input.Scope, "guild_settings") {
        executeActivity(UpdateGuildSettings, input.SnapshotID, input.GuildID)
    }

    // 6. Event
    executeActivity(PublishRestoreCompleted, input.JobID)

    return result, nil
}

Activity retry policy

각 Activity 는 Temporal retry policy:

InitialInterval: 1s
BackoffCoefficient: 2.0
MaximumInterval: 60s
MaximumAttempts: 10
NonRetryableErrors: [PermissionDenied, GuildNotFound]

Discord 429 (rate limit) 은 retryable. Discord 가 반환한 Retry-After 헤더를 Activity 가 context 에 반영.

Cancellation

  • 사용자가 "Cancel" 클릭 → Temporal CancelWorkflow 호출
  • Workflow 는 cancellation context 를 체크, 진행 중 Activity 는 완료 허용 (롤백 안 함)
  • defer ResumeLiveSync 는 cancellation 에도 실행됨 (Sync 재개 보장)

No automatic rollback

복구 실행 중 실패 시 자동 롤백 하지 않는다. 이유:

  • Discord API 는 원자적 multi-action 미제공
  • 롤백 자체가 실패할 수 있음 (더 복잡한 상태 유발)
  • Pre-restore snapshot 으로 사용자가 수동 "되돌리기" 가능

Diff calculation (Preview)

Input:
  snapshot_payload  (스냅샷 시점 상태)
  current_state     (Discord API 로부터 지금 fetch)
  scope             (복구 대상 카테고리)

For each scope category:
  snap_items = snapshot_payload[category] (keyed by discord_id)
  curr_items = current_state[category]   (keyed by discord_id)

  to_create = snap_items - curr_items          (스냅샷에만 있음)
  to_delete = curr_items - snap_items          (현재에만 있음)
  to_update = {k: snap_items[k] for k in (snap_items ∩ curr_items)
               if attrs_differ(snap_items[k], curr_items[k])}

Return RestoreDiff

Preview 는 실행 시점과 실제 Restore 실행 시점 사이의 길드 변경을 반영하지 않는다 (Preview 이후 새 역할이 생겼다면 Restore 시 삭제됨). 이는 명시적 — "복구는 스냅샷 시점으로 되돌리기" 정의에 충실.

Ports

Inbound

// engine/recovery/subdomain/restore/port/service.go
type RestoreService interface {
    Preview(ctx, snapshotID, scope []string) (*RestoreDiff, error)
    Start(ctx, input StartRestoreInput) (*RestoreJob, error)
    GetJob(ctx, jobID) (*RestoreJob, error)
    ListJobs(ctx, guildID, opts) ([]*RestoreJob, error)
    Cancel(ctx, jobID, reason string) error

    // 별도 member 복원
    RestoreMember(ctx, memberID, snapshotMemberState) error
}

type StartRestoreInput struct {
    SnapshotID              uuid.UUID
    Scope                   []string
    RequestedByUser         uuid.UUID
    CreatePreRestoreSnapshot bool
    ApprovedDiff            *RestoreDiff  // preview 결과 재첨부 (감사)
}

Outbound

type RestoreJobRepository interface {
    Insert(ctx, job RestoreJob) error
    Update(ctx, job RestoreJob) error
    GetByID(ctx, id) (*RestoreJob, error)
    GetActiveByGuild(ctx, guildID) (*RestoreJob, error)  // pending/running
    ListByGuild(ctx, guildID, opts) ([]*RestoreJob, error)
}

type SnapshotReader interface {
    GetByID(ctx, snapshotID) (*Snapshot, error)
    CreatePreRestore(ctx, guildID) (*Snapshot, error)  // trigger=pre_restore
}

type DiscordClient interface {
    FetchCurrentState(ctx, discordGuildID) (*DiscordGuildState, error)
    CreateRole(ctx, guildID, spec RoleSpec) (roleID string, err error)
    UpdateRole(ctx, guildID, roleID, spec RoleSpec) error
    DeleteRole(ctx, guildID, roleID) error
    // channels, overrides, guild settings 동일
}

type TemporalClient interface {
    StartRestoreWorkflow(ctx, input RestoreWorkflowInput) (workflowID string, err error)
    CancelWorkflow(ctx, workflowID, reason string) error
    QueryWorkflow(ctx, workflowID, queryName) (any, error)
}

type SyncSignaler interface {
    PauseBuffer(ctx, guildID) error
    ResumeBuffer(ctx, guildID, clearBuffer bool) error
}

type LicensingReader interface {
    Can(ctx, guildID, feature) (bool, error)
}

type EventPublisher interface {
    Publish(ctx, event DomainEvent) error
}

Adapters

  • Persistenceengine/recovery/subdomain/restore/adapter/persistence/
  • Discordengine/recovery/subdomain/restore/adapter/discord/ — Activity 구현 포함
  • Temporalengine/recovery/subdomain/restore/adapter/temporal/ — client + workflow + activities
  • Sync signalerengine/recovery/subdomain/restore/adapter/sync/ — 같은 도메인이라 직접 호출
  • Eventengine/recovery/adapter/event/

Sync mode execution order

삭제 → 생성 → 업데이트 순이 맞다고 생각하기 쉽지만, Discord 의존성 때문에 다음 순서:

Delete phase (역순, 의존성 역방향)

  1. Delete removed permission overrides (채널 삭제 전 정리)
  2. Delete removed channels (역할 삭제 전 — 권한 참조 제거)
  3. Delete removed roles

Create & Update phase (정순, 의존성 정방향)

  1. Create / Update roles (먼저 생성해야 채널이 참조 가능)
  2. Create / Update channels
  3. Apply permission overrides (role/channel 존재 후)
  4. Update guild settings

각 phase 안에서는 여러 항목을 병렬 처리 가능 (Temporal Activity 병렬 실행), 단 rate limit 주의.

Permission model

  • Preview — Pro 이상, Guild 의 MANAGE_GUILD
  • Start restore — Pro 이상, Guild 의 MANAGE_GUILD
  • Cancel — 본인이 시작한 job 또는 Guild owner
  • Member 수동 복원 — Pro 이상, Guild 의 MANAGE_GUILD

Feature: RECOVERY_RESTORE. Multiple restore points 는 RECOVERY_RESTORE_POINTS_MULTIPLE (Enterprise).

AntiNuke auto-trigger

Enterprise + opt-in 시 AntiNuke 감지 → 자동 Restore:

  • AntiNuke workflow 가 StartRestore 직접 호출 (preview 없이)
  • trigger = antinuke_auto 기록
  • pre_restore snapshot 은 항상 ON (자동 복구의 안전장치)

Failure modes

  • Activity max retries 초과 — Workflow Failed 로 종료, error_summary 에 실패 phase 기록
  • Discord API 전체 다운 — 모든 Activity retry 한계 도달 → Failed. 사용자에게 Discord 복구 후 재시작 안내
  • Snapshot payload 손상ValidateSnapshot 에서 감지 → 즉시 실패
  • Version mismatch — Workflow 가 지원 안 하는 payload version → 실패, manual intervention
  • Cancellation 이후 Live Sync resume 실패 — Alert operator, 수동 resume
  • Pre-restore snapshot 생성 실패 — 경고 후 진행 (결정은 사용자에게)
  • 동시 Restore 시도 — DB 체크 + 에러 반환

Observability

  • Temporal Web UI 로 workflow 진행 조회
  • RestoreJob.result_summary 에 카운트 기록 (roles_created, channels_deleted, ...)
  • 대시보드에서 실시간 진행 (workflow.QueryWorkflow 로 현재 phase 조회)

See also

  • domain/recovery/overview.md — Recovery 전체
  • domain/recovery/snapshot.md — 소스
  • domain/recovery/sync.md — 복구 중 pause
  • flows/restore-execution.md — 전체 흐름
  • data/recovery-schema.md — DB 스키마
  • adr/0014-recovery-temporal-workflow.md — Temporal 채택
  • adr/0024-restore-mode-sync.md — Sync mode
  • guides/temporal-workflow.md — 워크플로우 작성 가이드