Skip to content

Add a waitUntil hook for serverless request lifetimes #1383

Description

@dahlia

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 ContextOptions and clone() for request, inbox, outbox and task contexts.
  • Add waitUntil to FedifySpanExporterOptions and use it for span exports (see Flush FedifySpanExporter exports 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

Metadata

Metadata

Assignees

No one assigned

    Labels

    Fields

    Priority

    None yet

    Effort

    None yet

    Projects

    No projects

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions