// Package outboxreplay provides the process-level hard bound used by manual // owner-outbox repairs. Normal workers intentionally keep polling; an operator // replay must instead stop after an exact maximum even while the partition // remains non-empty. package outboxreplay import ( "context" "errors" "fmt" "strings" "hyapp/pkg/appcode" ) const ExecuteConfirmation = "REPLAY_EXACT_APP_OUTBOX_BATCH" // Scope is the immutable tenant and total-record bound for one process run. // Limit is not a worker batch size: Run enforces it across every claim/publish // iteration and returns once that many rows have been handled. type Scope struct { AppCode string Limit int } // Result reports whether the selected App partition became empty before the // hard limit. Processed counts rows claimed by processOne, including a row that // was released as retryable after a publish failure. type Result struct { Processed int Drained bool } // NewScope rejects an implicit tenant or an unbounded run before any database // or MQ client is opened. maxLimit is command-specific so a service may impose // a smaller operational envelope without weakening this shared contract. func NewScope(rawAppCode string, limit int, maxLimit int) (Scope, error) { if strings.TrimSpace(rawAppCode) == "" { // appcode.Normalize intentionally preserves a legacy empty=>lalu default // for request compatibility. An operator command must reject omission // before normalization or a missing flag would target a real tenant. return Scope{}, errors.New("app-code is required") } appCode := appcode.Normalize(rawAppCode) if maxLimit <= 0 { return Scope{}, errors.New("max limit must be positive") } if limit <= 0 || limit > maxLimit { return Scope{}, fmt.Errorf("limit must be between 1 and %d", maxLimit) } return Scope{AppCode: appCode, Limit: limit}, nil } // Run calls processOne at most Scope.Limit times. processOne must use the // owner's ordinary indexed claim path with an effective batch size of one; // accepting a larger count here would let an implementation silently strand // already-claimed tail rows after this process reaches its advertised limit. func Run(ctx context.Context, scope Scope, processOne func(context.Context) (int, error)) (Result, error) { if ctx == nil { ctx = context.Background() } if strings.TrimSpace(scope.AppCode) == "" || scope.Limit <= 0 { return Result{}, errors.New("valid replay scope is required") } if processOne == nil { return Result{}, errors.New("processOne is required") } result := Result{} for result.Processed < scope.Limit { if err := ctx.Err(); err != nil { return result, err } processed, err := processOne(ctx) if processed < 0 || processed > 1 { return result, fmt.Errorf("single-record replay processed %d rows", processed) } result.Processed += processed if err != nil { return result, err } if processed == 0 { result.Drained = true return result, nil } } return result, nil }