Durable Hooks
Persist side effects in the same transaction and run them after commit with retries
Durable hooks solve a common problem:
What if your database transaction commits, but your side effect (email, webhook, API call) fails?
With durable hooks, you register a hook trigger during the transaction. The trigger is persisted in the database as part of the same commit. After commit, Fragno emits a notify signal; dispatchers perform the actual hook execution with retries. If execution fails, it's retried with an exponential backoff policy.
Defining hooks
Hooks are defined on the fragment definition:
Use defineHook() + function syntax
Hook implementations must be created via defineHook(...) and written with function (...) { ... }
(or async function (...) { ... }) so the hook this context is available and typed. Arrow
functions (() => {}) don't have their own this, so you won't be able to access this.idempotencyKey.
export const fragmentDef = defineFragment<Config>("my-fragment")
.extend(withDatabase(mySchema))
.provideHooks(({ defineHook, config }) => ({
onSubscribe: defineHook(async function (payload: { email: string }) {
// Hook functions run outside the transaction, after commit.
// `this.idempotencyKey` is a unique idempotency key for this transaction.
await config.onSubscribe?.({ idempotencyKey: this.idempotencyKey, email: payload.email });
}),
}))
.build();Hook context (this)
Hook functions receive a this context that includes:
idempotencyKey: a unique idempotency key for the originating transaction (use for idempotency)hookId: the persisted hook event identifierattemptsandmaxAttempts: retry information for the eventpropagationContext: the propagation carrier captured when the hook was enqueuedcapturePropagationContext(): captures the currently active attempt context for an outbound callhandlerTx(): starts a transaction from the hook handler
When a Fragment exposes its own hook callback configuration, pass the complete context through rather than projecting a smaller context type:
onSubscribe: defineHook(async function (payload) {
await config.onSubscribe?.(payload, this);
});This keeps retry metadata, transaction access, and future hook capabilities available without
requiring every Fragment to maintain a parallel context type. Convert values such as hookId to
strings only at serialization or transport boundaries.
Triggering hooks from services
Within a service method, trigger a hook using uow.triggerHook() inside the mutate callback:
subscribe: function (email: string) {
return this.serviceTx(mySchema)
.mutate(({ uow }) => {
const id = uow.create("subscriber", { email, subscribedAt: new Date() });
// Register side effect to run after commit (and retry on failure)
uow.triggerHook("onSubscribe", { email });
return { id, email };
})
.build();
}The key point is that the hook trigger is part of your transaction: if the transaction rolls back, the hook trigger is not recorded, so the side effect won't run.
Scheduling hooks (processAt)
You can schedule the first hook attempt for a specific time using processAt:
uow.triggerHook("onSubscribe", { email }, { processAt: new Date(Date.now() + 60_000) });- If
processAtis in the future, the hook is stored as pending until that time. - If
processAtis in the past (or omitted), the hook is eligible immediately. - Retries still follow the retry policy;
processAtonly affects the first attempt.
Running the transaction in a route handler
Hooks are recorded when the handler executes the transaction:
defineRoute({
method: "POST",
path: "/subscribe",
handler: async function ({ input }, { json }) {
const { email } = await input.valid();
const result = await this.handlerTx()
.withServiceCalls(() => [services.subscribe(email)] as const)
.transform(({ serviceResult: [result] }) => result)
.execute();
return json(result);
},
});Propagating trace context
Durable hooks preserve tracing context across the database boundary, background processing, and retries. Fragno treats the context as an opaque text-map carrier, following the same propagation model used by OpenTelemetry.
For an HTTP route, the flow is:
HTTP request
→ request context
→ handlerTx
→ persisted hook carrier
→ hook attempt
→ nested handlerTx
→ child hook carrierAutomatic W3C context capture
When fragment.handler(request) handles an HTTP request, Fragno copies the W3C traceparent header
and, when present, tracestate into the request context. Only these tracing headers are captured
automatically; baggage is not copied.
When handlerTx() persists a triggered hook, it resolves the carrier in this order:
triggerHook(..., { propagationContext }), including explicitnullsuppression.- The configured durable-hooks instrumentation's
captureContext()result. - The carrier inherited from the current request or hook attempt.
The resulting carrier is stored with the hook in the same transaction as the application mutation.
Hook attempts and child hooks
Before a persisted hook runs, Fragno restores its carrier into the same request-context mechanism
used by routes. It then invokes instrumentation.runAttempt() around the hook implementation.
If the instrumentation creates and activates a span for the attempt, its captureContext() method
must also export that active span into a carrier for child hooks to use it. A nested handlerTx()
asks captureContext() for the current carrier when it persists each child hook. When that method
returns the attempt span's carrier, propagation produces a chain such as:
request span
→ hook A attempt span
→ hook B attempt spanIf captureContext() returns null, Fragno falls back to the ambient carrier restored for the
attempt. This is normally the hook's original persisted carrier: the child hook remains connected to
the originating trace, but it is not parented to the active attempt span.
Each retry invokes runAttempt() separately with the hook's original persisted carrier. An
instrumentation can therefore create one attempt span per retry without changing the stored hook
context.
When a hook calls another process, Durable Object, queue, or fragment that does not share its request storage, capture the active carrier at the outbound boundary:
await remoteObject.ingestEvent(event, {
propagationContext: this.capturePropagationContext(),
});capturePropagationContext() asks the configured instrumentation's captureContext() method to
export its currently active carrier. If captureContext() returns null, it falls back to the
carrier persisted with the hook. This means integrations preserve the exact attempt parent only when
the instrumentation exports it, while still preserving the originating trace otherwise.
On the receiving side, seed the carrier through callServices() or inContext():
await fragment.callServices(() => fragment.services.ingestEvent(event), {
propagationContext: rpcContext.propagationContext,
});Both methods also accept { propagationContext: null } for explicit suppression. Transporting the
carrier is host-owned: HTTP integrations use headers, RPC integrations use a serializable argument,
and queues use message metadata or payload fields.
Supplying or suppressing request context
W3C headers are captured automatically, but adapters can provide a carrier explicitly through the request lifecycle context:
await fragment.handler(request, {
propagationContext: {
traceparent: incomingTraceparent,
tracestate: incomingTracestate,
},
});Use null to prevent the request headers from being propagated:
await fragment.handler(request, {
propagationContext: null,
});A specific hook can also override or suppress the current context:
uow.triggerHook(
"onSubscribe",
{ email },
{
propagationContext: { traceparent },
},
);
uow.triggerHook(
"onUntracedCleanup",
{},
{
propagationContext: null,
},
);Instrumenting hook attempts
Fragno does not depend on an observability SDK. It provides the propagation and attempt boundaries; the host application connects those boundaries to OpenTelemetry or another tracing system.
Configure instrumentation when instantiating the fragment:
import type { DurableHooksInstrumentation } from "@fragno-dev/db/durable-hooks";
const instrumentation: DurableHooksInstrumentation = {
captureContext(info) {
// Inject the current tracing context into a string-to-string carrier.
return captureActiveTraceCarrier(info);
},
async runAttempt(attempt, execute) {
// Extract attempt.propagationContext, activate an attempt span, and run the hook.
return runWithHookAttemptSpan(attempt, execute);
},
};
const fragment = instantiate(fragmentDef)
.withConfig(config)
.withOptions({
databaseAdapter,
durableHooks: { instrumentation },
})
.build();runAttempt() must call execute() exactly once and preserve its result or error. Fragno enforces
this contract: skipping or repeatedly calling execute() fails the attempt through its normal retry
policy. A typical OpenTelemetry adapter extracts attempt.propagationContext, starts a consumer
span, makes that span active while execute() runs, records failures, and ends the span.
Propagation context is telemetry metadata, not an idempotency or authorization mechanism. Continue
using this.idempotencyKey for idempotency and validate any application-specific values received
from clients.
Dispatching hooks outside requests
After mutations, Fragno emits notify-only signals. You should run a background dispatcher so retries and scheduled hooks fire even when no new requests arrive.
Node (polling)
import { createDurableHooksProcessor } from "@fragno-dev/db/dispatchers/node";
const dispatcher = createDurableHooksProcessor([fragment], {
pollIntervalMs: 2000,
});
if (dispatcher) {
dispatcher.startPolling();
}Cloudflare Durable Objects (alarms)
import { createDurableHooksProcessor } from "@fragno-dev/db/dispatchers/cloudflare-do";
import { createMyFragment, type MyFragment } from "@/fragno/my-fragment";
export class DurableHooksDispatcher {
state: DurableObjectState;
fragment: MyFragment;
handler: ReturnType<ReturnType<NonNullable<typeof createDurableHooksProcessor>>>;
constructor(state: DurableObjectState, env: Env) {
this.state = state;
this.fragment = createMyFragment({ env, state: this.state });
const dispatcher = createDurableHooksProcessor([this.fragment]);
this.handler = dispatcher(state, env);
}
fetch(request: Request) {
return this.fragment.handler(request, {
waitUntil: this.state.waitUntil.bind(this.state),
});
}
alarm() {
return this.handler.alarm?.();
}
}Recovering stuck hooks
If a worker crashes after marking a hook as processing, that hook can remain stuck forever. Fragno
automatically re-queues hooks that have been in processing for too long (default: 10 minutes).
You can configure or disable this behavior when instantiating the fragment:
const fragment = instantiate(fragmentDef)
.withConfig(config)
.withOptions({
databaseAdapter,
durableHooks: {
// Minutes a hook may stay in `processing` before it is re-queued.
// Use `false` to disable stuck-processing recovery entirely.
stuckProcessingTimeoutMinutes: 10,
onStuckProcessingHooks: ({ namespace, timeoutMinutes, events }) => {
console.warn(
`Re-queued ${events.length} stuck hooks in ${namespace} after ${timeoutMinutes} minutes`,
events,
);
},
},
})
.build();When hooks are re-queued they may run again, so hook implementations must remain idempotent. If
stuckProcessingTimeoutMinutes is set to false, no stuck-processing checks run and the callback
will not fire.
Retry behavior
If a hook execution fails, it will be retried with an exponential backoff policy. This makes hooks safe for "at least once" delivery, as long as your hook implementation is idempotent.
Use the idempotencyKey to make idempotency easy:
- store processed idempotency keys in your external system
- or pass the idempotencyKey as an idempotency key to third-party APIs that support it