Repository navigation
Description
Motivation
Cloudflare Workers can cancel a promise that is neither awaited nor passed to ctx.waitUntil() when the invocation ends. Fedify awaits its own enqueues, but work can still outlive the response: FEP-ef61 portable forwarding after its deadline, application code that drops a promise returned by Context, and exporters that write after span.end().
Applications currently work around this by wrapping their MessageQueue or by carrying a callback through contextData. Both approaches are application-specific, and neither covers work that Fedify starts internally. LogTape 2.4.0 solved the same problem for async sinks with a waitUntil option that the sink calls synchronously for each record.
Proposed API
Add a callback type and an execution-options interface:
/**
* Keeps work Fedify started alive after the current invocation ends.
* Fedify calls this synchronously with a promise that never rejects and
* settles when the work finishes or fails. The platform's own limits
* still apply.
* @since 2.5.0
*/
export type WaitUntilCallback = (promise: Promise<void>) => void;
export interface FederationExecutionOptions {
waitUntil?: WaitUntilCallback;
}FederationOptions and FederationFetchOptions extend FederationExecutionOptions. FederationFetchOptions.waitUntil overrides the federation-level callback for that request. Federation.processQueuedTask() takes an optional third FederationExecutionOptions argument, and createContext() accepts the same options.
Context also gets a method for application code:
/**
* Hands a promise to Fedify so it outlives the current invocation where the
* platform allows it. Its rejection is logged rather than left unhandled.
* A no-op beyond logging when no waitUntil callback is configured.
* @since 2.5.0
*/
waitUntil(promise: Promise<unknown>): void;The callback takes Promise<void> rather than Promise<unknown> because Fedify only passes a never-rejecting settlement wrapper. ctx.waitUntil is typed (promise: Promise<any>) => void and remains assignable.
Behavior
Fedify calls the callback synchronously at the point it creates the work, with a derived promise that resolves when the original settles. The original promise is returned unchanged, so a caller that awaits it still observes the original rejection. A caller that drops it gets the work kept alive and the failure logged.
Register the promises returned by Context.sendActivity(), forwardActivity(), enqueueTask() and enqueueTaskMany() at the public boundary. Register internal detached work such as the portable forwarding chain after its deadline. Do not register _startQueueInternal(): a real listen() promise never settles, and passing it to waitUntil() would hold every Workers invocation open until the 30-second limit.
Callback errors are logged and ignored. The callback must not log through a LogTape async sink that uses the same hook, because that would recurse. Without a callback, automatic registration is a no-op and existing rejection behavior is unchanged.
The option takes a function, never an ExecutionContext. On Workers, import { waitUntil } from "cloudflare:workers" resolves the current invocation itself. A federation singleton on a long-lived runtime may hand promises from concurrent requests to one callback, so the callback must be request-aware, for example through AsyncLocalStorage, or the federation must be built per request. Passing a bound ctx.waitUntil to a shared singleton's federation-level option is unsafe; pass it per request through FederationFetchOptions instead, as shown below.
Usage
On Cloudflare Workers:
import { waitUntil } from "cloudflare:workers";
const federation = await builder.build({
kv: new WorkersKvStore(env.FEDIFY_KV),
queue: new WorkersMessageQueue(env.FEDIFY_QUEUE),
manuallyStartQueue: true,
waitUntil,
});For a singleton with a per-request execution context:
export default {
fetch(request: Request, env: Env, ctx: ExecutionContext) {
return federation.fetch(request, {
contextData: env,
waitUntil: ctx.waitUntil.bind(ctx),
});
},
};Application code can opt in explicitly:
ctx.waitUntil(ctx.sendActivity({ identifier: "alice" }, recipients, activity));Scope
- packages/fedify/src/federation/federation.ts, middleware.ts, context.ts and builder.ts.
- Thread the callback through
ContextOptionsandclone()for request, inbox, outbox and task contexts. - Add
waitUntiltoFedifySpanExporterOptionsand use it for span exports (see FlushFedifySpanExporterexports before the invocation ends #1382). - Document Cloudflare Workers, Hono (
c.executionCtx.waitUntil), Vercel and long-lived Node.js. - Test synchronous registration, awaited and dropped rejections, a throwing callback, concurrent scopes and queue consumers.
The callback only asks the platform for time. A durable queue enqueue still has to be awaited, and work that can exceed the platform limit belongs in a queue consumer.
Relationship to 2.0.x
The 2.0.x patch cannot add a public option. It fixes the paths Fedify owns directly (see #1380 and #1381) and leaves user-dropped promises to this hook.
References
- Cloudflare Workers
waitUntil: https://developers.cloudflare.com/workers/runtime-apis/context/#waituntil - LogTape
AsyncSinkOptions.waitUntil: https://logtape.org/manual/sinks#keeping-serverless-requests-alive - Cloudflare Workers: sendActivity() fanout path does not await fanoutQueue.enqueue(), risking dropped fanout messages without waitUntil #661, the fanout enqueue bug fixed in 1.9.8, 1.10.7 and 2.0.11.
Metadata
Metadata
Assignees
Labels
Type
Fields
Priority
Effort
Activity
FedifySpanExporterexports before the invocation ends #1382outbox-listener-delivery-not-awaitedcoverage to inbox listeners and task handlers #1384WorkersMessageQueuesends alive with awaitUntiloption #1386