Skip to content

Transactions + Two-Phase Hooks

Kernentscheidung

Jeder /api/write und /api/batch laeuft in einer echten DB-Transaktion. Hooks sind in zwei Phasen unterteilt: inTransaction (rollt mit zurueck) und afterCommit (feuert erst nach Commit).

Warum zwei Phasen

Das Dual-Write-Problem: Nicht alle Nebeneffekte sind gleichwertig.

Side-EffectPhaseWarum
Audit-Row in DBinTransactionMuss atomar mit der Aenderung sein — kein Audit ohne Daten, keine Daten ohne Audit
Counter/Inventar-UpdateinTransactionDB-Write, soll konsistent rollen
Abhaengige Entity-WritesinTransactionGleiche Atomicitaets-Garantie
SSE-BroadcastafterCommitExternes System, nicht zurueckrollbar
Search-Index (Meilisearch)afterCommitExternes System, Inkonsistenz sonst
Email-VersandafterCommitEmail ist weg wenn gesendet
BullMQ Job-TriggerafterCommitJob darf nicht fuer rolled-back Entity laufen
Event-Log (Redis Stream)afterCommitStream-Event soll committed State reflektieren

Vorbild: Rails (after_save vs after_commit), Django (post_save vs transaction.on_commit).

API

// Default: afterCommit (externes System, best-effort, Fehler werden geloggt)
r.hook("postSave", handler, async (result, ctx) => { /* ... */ });
// Opt-in: inTransaction (DB-Writes, atomar, Fehler rollen zurueck)
r.hook("postSave", handler, async (result, ctx) => {
await ctx.db.insert(auditTable).values({...});
}, { phase: HookPhases.inTransaction });

preDelete hat keine Phase-Option — es laeuft immer inTransaction (sonst waere der Pre-Hook sinnlos).

Fehler-Semantik

PhaseFehler in HookEffekt
inTransactionwird rethrownTransaktion rollt zurueck, Batch failed, afterCommit feuert nicht
afterCommitwird geloggtNachfolgende Hooks laufen weiter, Batch bleibt erfolgreich

Batch-Endpoint

POST /api/batch
{
"commands": [
{ "type": "user:write:user:create", "payload": { ... } },
{ "type": "order:write:order:create", "payload": { ... } }
],
"requestId": "uuid-for-retry-idempotency"
}

Alle Commands laufen in einer Transaktion. Schlaegt Command 3 fehl, rollen 1+2 zurueck. Bei Erfolg feuern alle afterCommit-Hooks aller Commands nacheinander.

Idempotency: Gleiche requestId im Retry → gecachtes BatchResult kommt zurueck, Commands laufen nicht erneut. Sowohl Erfolge als auch Failures werden gecached (TTL 300s).

Konsequenzen fuer Handler-Dev

  • ctx.db ist innerhalb von Handlern und inTransaction-Hooks immer die tx-scoped TenantDb. Keine Sonderfaelle.
  • Handler duerfen throw — das rollt die Transaktion korrekt zurueck.
  • Nested Writes (Handler A ruft dispatcher.write() intern) → Postgres Savepoints via Drizzle’s tx.transaction(). Funktioniert, aber sparsam nutzen.
  • afterCommit-Hooks sehen zwar ctx.db, sollten aber keine DB-Writes darueber machen — das wuerde ausserhalb der Transaktion laufen und widerspricht dem Design.

Performance-Tradeoff

Jeder Write = BEGIN + COMMIT extra. Bei Postgres sind das ~0.1ms pro Write (lokal). Fuer die Atomic-Audit-Garantie ist das akzeptabel. Wer massenhaft writes braucht, nutzt POST /api/batch mit mehreren Commands in einer Transaktion.

Implementation

WasWo
HookPhase Type + Defaultsengine/types/hooks.ts
Phase-filtering in Registryengine/registry.ts (filterByPhase)
Phase-Routing in Lifecycle-Pipelinepipeline/lifecycle-pipeline.ts
BatchResult, BatchRollback, runBatchpipeline/dispatcher.ts
Routeapi/routes.ts (POST /api/batch)
Testsapi/__tests__/batch.integration.ts, engine/__tests__/hook-phases.test.ts, pipeline/__tests__/lifecycle-pipeline.test.ts

Referenz-Saetze (nicht verwechseln)

  • Hook-Phase (HookPhase): “Wann feuert dieser Hook — drin in der Transaktion oder danach?”
  • Handler-Typ (write/query/command): “Was ist die Intent der Operation?”
  • Lifecycle-Event (preSave/postSave/…): “An welchem Punkt im Handler-Lifecycle?”

Die drei sind orthogonal.