Motor generic pentru procese cu mai multi pasi care nu incap intr-o tranzactie. Garantia nu e atomicitatea, ci compensarea in ordine inversa. - saga-registry: definitii versionate in cod, backoff exponential plafonat - saga-runner: corelator outbox->instanta, step runner, timeout, retry, compensare; revendicare cu FOR UPDATE SKIP LOCKED ca doua procese sa nu avanseze aceeasi saga simultan - idempotenta la pornire prin index unic (saga, versiune, trigger_event), nu prin SELECT-apoi-INSERT care ar avea race - compensarea are propriul retry: o compensare esuata lasa sistemul mai rau decat esecul original - monitor /v1/sagas cu instante blocate si retry manual care NU sare pasi - prima saga reala: imbogatire research brief, fara pas AI (un apel AI automat per brief ar schimba profilul de cost)
108 lines
3.5 KiB
TypeScript
108 lines
3.5 KiB
TypeScript
/**
|
|
* Definitiile de saga stau in cod, versionate, ca la notification-rules si
|
|
* projection-registry. Motivul e acelasi: un proces cu mai multi pasi e logica
|
|
* de business care merita review si teste, nu configuratie editabila la runtime.
|
|
*
|
|
* Contractul unui pas:
|
|
* - run() trebuie sa fie IDEMPOTENT. Poate fi apelat de mai multe ori pentru
|
|
* acelasi pas (retry dupa timeout de retea, restart de proces). Daca a scris
|
|
* deja ceva, a doua rulare trebuie sa observe asta si sa nu dubleze.
|
|
* - compensate() anuleaza efectul lui run(). Trebuie sa fie idempotent SI
|
|
* tolerant la faptul ca run() poate sa fi esuat la jumatate -- compenseaza
|
|
* ce gaseste, nu presupune ca totul a fost creat.
|
|
*/
|
|
|
|
export interface SagaEvent {
|
|
id: string;
|
|
tenantId: string;
|
|
workspaceId: string | null;
|
|
eventType: string;
|
|
actorId: string | null;
|
|
subjectId: string | null;
|
|
correlationId: string | null;
|
|
payload: Record<string, unknown>;
|
|
}
|
|
|
|
export type SagaContext = Record<string, unknown>;
|
|
|
|
export interface SagaStepResult {
|
|
/** Se uneste in contextul sagai si e vizibil pasilor urmatori. */
|
|
context?: SagaContext;
|
|
output?: Record<string, unknown>;
|
|
}
|
|
|
|
export interface SagaStepDefinition {
|
|
name: string;
|
|
run(ctx: SagaContext, event: SagaEvent): Promise<SagaStepResult | void>;
|
|
/**
|
|
* Lipsa lui compensate inseamna ca pasul nu are efecte de anulat (o citire,
|
|
* de exemplu). Nu inseamna "nu stim cum sa compensam".
|
|
*/
|
|
compensate?(ctx: SagaContext, event: SagaEvent): Promise<void>;
|
|
/** Cate incercari inainte de a declara pasul esuat. */
|
|
maxAttempts?: number;
|
|
}
|
|
|
|
export interface SagaDefinition {
|
|
name: string;
|
|
version: number;
|
|
/** Ce eveniment porneste saga. */
|
|
triggerEvent: string;
|
|
/** Termen absolut pentru toata saga; depasirea duce la compensare. */
|
|
timeoutMinutes: number;
|
|
steps: SagaStepDefinition[];
|
|
/**
|
|
* Filtru optional: chiar daca evenimentul se potriveste, saga poate decide
|
|
* ca nu o priveste (ex. lipseste un camp din payload).
|
|
*/
|
|
shouldStart?(event: SagaEvent): boolean;
|
|
}
|
|
|
|
const DEFAULT_MAX_ATTEMPTS = 3;
|
|
const BASE_BACKOFF_MS = 30_000;
|
|
const MAX_BACKOFF_MS = 30 * 60_000;
|
|
|
|
export function maxAttemptsFor(step: SagaStepDefinition): number {
|
|
return step.maxAttempts ?? DEFAULT_MAX_ATTEMPTS;
|
|
}
|
|
|
|
/**
|
|
* Backoff exponential plafonat. Fara plafon, a 10-a incercare ar fi programata
|
|
* peste zile, ceea ce in practica inseamna "niciodata".
|
|
*/
|
|
export function backoffDelayMs(attempt: number): number {
|
|
return Math.min(BASE_BACKOFF_MS * 2 ** Math.max(0, attempt - 1), MAX_BACKOFF_MS);
|
|
}
|
|
|
|
const REGISTRY: SagaDefinition[] = [];
|
|
|
|
export function registerSaga(definition: SagaDefinition): void {
|
|
const duplicate = REGISTRY.find(
|
|
(s) => s.name === definition.name && s.version === definition.version,
|
|
);
|
|
if (duplicate) {
|
|
throw new Error(`Saga ${definition.name}@v${definition.version} este deja inregistrata`);
|
|
}
|
|
REGISTRY.push(definition);
|
|
}
|
|
|
|
export function allSagas(): SagaDefinition[] {
|
|
return [...REGISTRY];
|
|
}
|
|
|
|
export function sagasForEvent(eventType: string): SagaDefinition[] {
|
|
return REGISTRY.filter((s) => s.triggerEvent === eventType);
|
|
}
|
|
|
|
export function findSaga(name: string, version: number): SagaDefinition | undefined {
|
|
return REGISTRY.find((s) => s.name === name && s.version === version);
|
|
}
|
|
|
|
export function triggerEventTypes(): string[] {
|
|
return [...new Set(REGISTRY.map((s) => s.triggerEvent))];
|
|
}
|
|
|
|
/** Doar pentru teste: goleste registrul intre cazuri. */
|
|
export function resetRegistry(): void {
|
|
REGISTRY.length = 0;
|
|
}
|