ceo-api/src/sagas/sagas.service.ts
valentinbvro 765ec37c3a feat: Saga Manager (Sprint 4-6)
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)
2026-07-29 15:02:56 +02:00

144 lines
4.8 KiB
TypeScript

import { Injectable, NotFoundException } from '@nestjs/common';
import { and, asc, count, desc, eq, lt, sql } from 'drizzle-orm';
import { db } from '../db/client';
import { sagaInstances, sagaSteps } from '../db/schema';
import type { AuthenticatedSession } from '../auth/tenant.guard';
import { allSagas } from './saga-registry';
/** O saga blocata mai mult de atat merita atentie umana. */
const STUCK_AFTER_MINUTES = 30;
@Injectable()
export class SagasService {
/**
* Monitorul raspunde la o singura intrebare: ce nu merge?
* De aceea numara pe status si scoate separat instantele blocate, in loc sa
* intoarca o lista plata pe care ar trebui sa o citeasca cineva manual.
*/
async overview(session: AuthenticatedSession) {
const stuckBefore = new Date(Date.now() - STUCK_AFTER_MINUTES * 60_000);
const [byStatus, stuck] = await Promise.all([
db
.select({ status: sagaInstances.status, n: count() })
.from(sagaInstances)
.where(eq(sagaInstances.tenantId, session.tenantId))
.groupBy(sagaInstances.status),
db
.select()
.from(sagaInstances)
.where(
and(
eq(sagaInstances.tenantId, session.tenantId),
sql`${sagaInstances.status} IN ('RUNNING','COMPENSATING')`,
lt(sagaInstances.updatedAt, stuckBefore),
),
)
.orderBy(asc(sagaInstances.updatedAt))
.limit(20),
]);
return {
definitions: allSagas().map((s) => ({
name: s.name,
version: s.version,
triggerEvent: s.triggerEvent,
steps: s.steps.map((step) => step.name),
timeoutMinutes: s.timeoutMinutes,
})),
counts: Object.fromEntries(byStatus.map((r) => [r.status, r.n])),
stuck: stuck.map((row) => this.summarize(row)),
stuckThresholdMinutes: STUCK_AFTER_MINUTES,
};
}
async list(session: AuthenticatedSession, status?: string, limit = 50) {
const where = status
? and(eq(sagaInstances.tenantId, session.tenantId), sql`${sagaInstances.status} = ${status}`)
: eq(sagaInstances.tenantId, session.tenantId);
const rows = await db
.select()
.from(sagaInstances)
.where(where)
.orderBy(desc(sagaInstances.createdAt))
.limit(limit);
return { instances: rows.map((row) => this.summarize(row)) };
}
/** Detaliul include pasii: fara ei nu se poate spune unde s-a oprit. */
async detail(session: AuthenticatedSession, id: string) {
const instance = await db.query.sagaInstances.findFirst({
where: and(eq(sagaInstances.id, id), eq(sagaInstances.tenantId, session.tenantId)),
});
if (!instance) throw new NotFoundException('Saga instance not found');
const steps = await db
.select()
.from(sagaSteps)
.where(eq(sagaSteps.sagaInstanceId, instance.id))
.orderBy(asc(sagaSteps.sequence));
return {
...this.summarize(instance),
context: instance.context,
steps: steps.map((step) => ({
sequence: step.sequence,
name: step.stepName,
status: step.status,
attempts: step.attempts,
output: step.output,
lastError: step.lastError,
startedAt: step.startedAt,
completedAt: step.completedAt,
compensatedAt: step.compensatedAt,
})),
};
}
/**
* Reia o saga blocata: reseteaza backoff-ul si elibereaza lease-ul.
* NU schimba statusul si nu sare peste pasi -- reincercarea trebuie sa treaca
* prin aceeasi logica, altfel operatorul ar putea "repara" o saga sarind
* exact pasul care esua.
*/
async retry(session: AuthenticatedSession, id: string) {
const instance = await db.query.sagaInstances.findFirst({
where: and(eq(sagaInstances.id, id), eq(sagaInstances.tenantId, session.tenantId)),
});
if (!instance) throw new NotFoundException('Saga instance not found');
if (instance.status === 'COMPLETED' || instance.status === 'COMPENSATED') {
return { retried: false, reason: 'Saga s-a incheiat deja' };
}
await db
.update(sagaInstances)
.set({
status: instance.status === 'FAILED' ? 'RUNNING' : instance.status,
attempt: 0,
nextAttemptAt: new Date(),
lockedUntil: null,
updatedAt: new Date(),
})
.where(eq(sagaInstances.id, instance.id));
return { retried: true };
}
private summarize(row: typeof sagaInstances.$inferSelect) {
return {
id: row.id,
sagaName: row.sagaName,
sagaVersion: row.sagaVersion,
status: row.status,
currentStep: row.currentStep,
attempt: row.attempt,
correlationId: row.correlationId,
lastError: row.lastError,
nextAttemptAt: row.nextAttemptAt,
timeoutAt: row.timeoutAt,
createdAt: row.createdAt,
updatedAt: row.updatedAt,
completedAt: row.completedAt,
};
}
}