package contract import ( "context" "time" ) // RunReader is the HTTP lookup boundary. Fall back to archive only when the // live reader returns ErrNotFound, not on transient live storage failures. type RunReader interface { GetRun(ctx context.Context, runID string) (RunView, error) } type RunListFilter struct { SyncKind SyncKind Limit int } type RunList struct { Items []RunView `json:"items"` Total int `json:"total"` } type RunLister interface { ListRuns(ctx context.Context, filter RunListFilter) (RunList, error) } type LiveRunReader interface { GetRunMeta(ctx context.Context, runID string) (RunMeta, error) } type ArchivedRunReader interface { GetArchivedRun(ctx context.Context, runID string) (RunArchive, error) } type RoundStatsReader interface { GetRoundStats(ctx context.Context, runID string) ([]RoundStats, error) } // FailurePager returns live details only. An archived summary without live // details returns ErrFailureDetailsUnavailable, never a misleading empty page. // Items must serialize as [] on empty pages; NextCursor alone signals EOF. type FailurePager interface { ListFailures(ctx context.Context, runID string, page PageRequest) (FailurePage, error) } type ReplayRequester interface { RequestReplay(ctx context.Context, request ReplayRequest) (ReplayAccepted, error) } type ActionReader interface { GetReplayAction(ctx context.Context, actionID string) (ReplayAction, error) } // ArchiveWorkDiscovery returns pending/retryable work from a durable index. // Terminal run registration must not depend on an in-memory notification. type ArchiveWorkDiscovery interface { ListArchiveWork(ctx context.Context, page PageRequest) (ArchiveWorkPage, error) } // RegisterArchiveWork idempotently schedules a terminal run/version. Since the // index can occupy another Redis slot, callers must reconcile registration // after a crash between completing the run and updating the index. type ArchiveWorkRegistrar interface { RegisterArchiveWork(ctx context.Context, work ArchiveWork) error } type ArchiveWorkLoader interface { LoadArchiveWork(ctx context.Context, work ArchiveWork) (RunArchive, error) } // ArchiveRegistrar conditionally records a durable publication against the // work version. Repeating the same receipt is a no-op; a different version or // conflicting receipt returns ErrConflict. Failure retains discoverable work. type ArchiveRegistrar interface { RegisterArchive(ctx context.Context, work ArchiveWork, receipt ArchiveReceipt) error RegisterArchiveFailure(ctx context.Context, work ArchiveWork, message string) error } // ReplayActionCreator atomically reserves (sourceRunId, idempotencyKey) with // its fingerprint and action. Same key/request returns the original action // regardless of candidate IDs; different request returns ErrConflict. The // candidate starts accepted and fixes the target ID before any target writes. type ReplayActionCreator interface { GetOrCreateReplayAction(ctx context.Context, request ReplayRequest, candidate ReplayAction) (ReplayActionCreation, error) } // Discovery includes accepted, processing, cleanup_pending and receipt_pending // actions after crashes. Completed/needs_attention actions require no automatic // work. Lease acquisition, not listing, chooses an exclusive owner. type ReplayWorkDiscovery interface { ListReplayWork(ctx context.Context, page PageRequest) (ReplayActionPage, error) } type ReplayLeaseStore interface { AcquireReplayLease(ctx context.Context, actionID, owner string, ttl time.Duration) (ReplayLease, error) RenewReplayLease(ctx context.Context, lease ReplayLease, ttl time.Duration) (ReplayLease, error) ReleaseReplayLease(ctx context.Context, lease ReplayLease) error } // All updates are fenced and compare ExpectedRevision. Expired/replaced leases // return ErrLeaseLost; revision conflicts return ErrConflict. Implementations // reject progress regression and preserve immutable action identity. type ReplayProgressStore interface { UpdateReplayAction(ctx context.Context, update ReplayActionUpdate) (ReplayAction, error) } // The implementation must preserve scan continuity while source entries are // cleaned. This can use a durable selection/snapshot; deleting records must not // make a later cursor skip an unprocessed failure. Tokens are bound to actionID. type ReplayFailurePager interface { ListReplayFailures(ctx context.Context, actionID string, page PageRequest) (FailureRecordPage, error) } // TargetRunCreator is idempotent: retrying the same target preserves counters; // incompatible source/operation/month/cycle returns ErrConflict. Replay targets // are distinct runs and must not mutate the source's historical FailedCount. type TargetRunCreator interface { EnsureTargetRun(ctx context.Context, meta RunMeta) (RunMeta, error) } // TargetEnqueueResult distinguishes a durable duplicate from a rejected write. // A zero NewlyEnqueued count alone is never sufficient evidence for cleanup. // Every input TaskID must be durably present on nil error; otherwise return an // error and let the caller safely retry the identical batch. Both counts are // unique task counts, and their sum equals the number of distinct input IDs. type TargetEnqueueResult struct { NewlyEnqueued int64 AlreadyPresent int64 } type TargetRunEnqueuer interface { // Tasks must match the target run and channel and use stable logical IDs. EnqueueTargetBatch(ctx context.Context, lease ReplayLease, runID, chanKey string, tasks []Task, nextExecuteAt time.Time) (TargetEnqueueResult, error) MarkTargetProducerDone(ctx context.Context, lease ReplayLease, runID string) error } // Cleanup is idempotent and conditional on the lease, exact source versions, // and durable evidence of target acceptance. It changes pending failures only, // never historical failedCount. Conflicts must remain visible for attention. // Source keys and action/target keys may occupy different Redis Cluster slots; // implementations need recoverable receipts, not cross-slot Lua assumptions. type ReplayFailureCleaner interface { CleanupReplayFailures(ctx context.Context, cleanup ReplayCleanup) (ReplayCleanupResult, error) }