Initial Commit
This commit is contained in:
76
packages/api-stack/api/api-saga/src/recovery/recovery.ts
Normal file
76
packages/api-stack/api/api-saga/src/recovery/recovery.ts
Normal file
@@ -0,0 +1,76 @@
|
||||
import { SAGA_CODES, SagaError } from "../error-codes/error-codes.js";
|
||||
import type { SagaOrchestrator } from "../orchestrator/orchestrator.types.js";
|
||||
import type { SagaPersistence } from "../persistence/persistence.js";
|
||||
import type {
|
||||
SagaInstanceListFilter,
|
||||
SagaInstanceRow,
|
||||
} from "../persistence/persistence.types.js";
|
||||
import { isTerminalInstance } from "../state/state-machine.js";
|
||||
|
||||
/** Outcome of a recovery sweep. */
|
||||
export interface RecoveryResult {
|
||||
readonly scanned: number;
|
||||
readonly resumed: number;
|
||||
readonly skipped: number;
|
||||
readonly failures: ReadonlyArray<{
|
||||
readonly sagaId: string;
|
||||
readonly cause: Error;
|
||||
}>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Walk every non-terminal saga in the persistence layer and ask the
|
||||
* orchestrator to resume each. Designed to be invoked at host boot
|
||||
* before the host starts serving requests so a saga that was
|
||||
* `running` at the previous host's death continues from its
|
||||
* persisted state.
|
||||
*
|
||||
* Failures during resume are collected into `result.failures` rather
|
||||
* than thrown — a single broken saga shouldn't prevent the host from
|
||||
* starting; the operator can inspect the failure list and take
|
||||
* targeted action.
|
||||
*/
|
||||
export async function recoverInflightSagas(args: {
|
||||
readonly orchestrator: SagaOrchestrator;
|
||||
readonly persistence: SagaPersistence;
|
||||
readonly filter?: SagaInstanceListFilter;
|
||||
}): Promise<RecoveryResult> {
|
||||
const { orchestrator, persistence, filter } = args;
|
||||
const all = persistence.listInstances(filter ?? {});
|
||||
let resumed = 0;
|
||||
let skipped = 0;
|
||||
const failures: Array<{ sagaId: string; cause: Error }> = [];
|
||||
for (const inst of all) {
|
||||
if (isTerminalInstance(inst.state)) {
|
||||
skipped += 1;
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
await orchestrator.resume(inst.id);
|
||||
resumed += 1;
|
||||
} catch (cause) {
|
||||
failures.push({
|
||||
sagaId: inst.id,
|
||||
cause:
|
||||
cause instanceof Error ? cause : new Error(String(cause)),
|
||||
});
|
||||
}
|
||||
}
|
||||
return { scanned: all.length, resumed, skipped, failures };
|
||||
}
|
||||
|
||||
/**
|
||||
* Convenience guard that raises a `SAGA.PERSISTENCE_FAILED` error
|
||||
* when an attempted recovery target is not in a resumable state.
|
||||
* Callers that want strict semantics (admin-triggered "resume this
|
||||
* specific saga") call this before delegating to `orchestrator.resume`.
|
||||
*/
|
||||
export function assertResumable(instance: SagaInstanceRow): void {
|
||||
if (isTerminalInstance(instance.state)) {
|
||||
throw new SagaError(
|
||||
SAGA_CODES.PERSISTENCE_FAILED,
|
||||
`saga: instance '${instance.id}' is already in terminal state '${instance.state}'`,
|
||||
{ context: { sagaId: instance.id, state: instance.state } }
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user