Ereignisse erst nach dem Commit ausliefern

Benachrichtigungs- und Mailversand lief im Handler noch in der offenen Transaktion und
zählte gegen deren Zeitlimit; unter Volllast brachen dadurch wechselnde Tests ab
(test-einsatz-field 31 s, test-planung-recommend). withDeferredEvents puffert Ereignisse
innerhalb von inTransaction und stellt sie nach dem Commit zu, bei Rollback gar nicht —
analog zu withDeferredAudit und passend zum dokumentierten Vertrag in events.ts.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-16 11:05:49 +02:00
co-authored by Claude Opus 5
parent 1e1c154a8a
commit a4a61dc31f
3 changed files with 107 additions and 2 deletions
+32
View File
@@ -1,12 +1,44 @@
import { AsyncLocalStorage } from "node:async_hooks";
import type { DomainEvent } from "@/lib/events";
import type { ServiceCtx } from "@/server/services/context";
type Buffered = { ctx: ServiceCtx; event: DomainEvent };
const deferredEvents = new AsyncLocalStorage<{ items: Buffered[] }>();
/**
* Collect events raised inside a transaction and deliver them AFTER the commit
* (services/context.ts#inTransaction). Handlers write notifications and queue mails; inside the
* transaction that work counted against the transaction timeout and could exceed it under load.
* On rollback the buffered events are dropped — nothing happened, so nothing is announced.
* Nested calls join the outer buffer.
*/
export async function withDeferredEvents<T>(ctx: ServiceCtx, fn: () => Promise<T>): Promise<T> {
if (deferredEvents.getStore()) return fn();
const buffer: { items: Buffered[] } = { items: [] };
const result = await deferredEvents.run(buffer, fn);
for (const { ctx: eventCtx, event } of buffer.items) {
// the transaction client is closed after the commit → deliver with the caller's client
await deliver({ ...eventCtx, db: ctx.db }, event);
}
return result;
}
/**
* Emit a domain event AFTER the mutation succeeded. Never throws: a failing notification
* must not roll back business data — failures are logged and surfaced via sync/notification
* monitoring. Lanes call ONLY this function; the notifications lane owns the handler.
* Inside `inTransaction` the event is buffered and delivered after the commit.
*/
export async function emitEvent(ctx: ServiceCtx, event: DomainEvent): Promise<void> {
const buffer = deferredEvents.getStore();
if (buffer) {
buffer.items.push({ ctx, event });
return;
}
await deliver(ctx, event);
}
async function deliver(ctx: ServiceCtx, event: DomainEvent): Promise<void> {
try {
const { handleEvent } = await import("@/server/services/notifications/handle-event");
await handleEvent(ctx, event);
+5 -2
View File
@@ -1,6 +1,7 @@
import type { Session } from "next-auth";
import { tenantTransaction, type TenantDb } from "@/server/db";
import { withDeferredAudit } from "@/server/audit";
import { withDeferredEvents } from "@/server/events";
/**
* Context passed to every domain service. Created by server actions (from moduleGuard)
@@ -29,10 +30,12 @@ export function ctxFromGuard(g: { session: Session; db: TenantDb; permissions: R
* is bound to the transaction; nested calls join the outer transaction.
* Never use `ctx.db.$transaction(...)` directly — it is not atomic with RLS_ENFORCED=true.
* Audit entries written inside `fn` are flushed after the commit and dropped on rollback
* (except `denied`), see audit.ts#withDeferredAudit.
* (except `denied`), see audit.ts#withDeferredAudit. Domain events raised inside `fn` are delivered
* after the commit as well (events.ts#withDeferredEvents): notification and mail work must not
* count against the transaction timeout.
*/
export function inTransaction<T>(ctx: ServiceCtx, fn: (ctx: ServiceCtx) => Promise<T>): Promise<T> {
return withDeferredAudit(() => tenantTransaction(ctx.db, ctx.tenantId, (tx) => fn({ ...ctx, db: tx })));
return withDeferredEvents(ctx, () => withDeferredAudit(() => tenantTransaction(ctx.db, ctx.tenantId, (tx) => fn({ ...ctx, db: tx }))));
}
export function can(ctx: ServiceCtx, permission: string): boolean {