Userspace facet keepalive: how a loaded facet reaches the parent's alarm proxy
Shipped (Option A1, in the StreamProcessorDurableObject-base PR)#
Chosen: Option A, sub-variant A1 — the three ProcessorFacetAlarmProxy
verbs (proxySetAlarm/proxyDeleteAlarm/proxyGetAlarm) live directly on the
itx Stream capability (StreamRpcTarget), forwarding to the Stream DO's
existing proxy methods. No auth gate beyond project access (a fire only replays
handleAlarm into the stream's own facets — a harmless self-wake). This mirrors
the stateful-worker path exactly (workers.get(ref).setAlarm), which the
boot-reconcile timing argument showed is the only channel a loaded worker
(env.ITX-only) can use.
What landed:
StreamRpcTarget.proxy{Set,Delete,Get}Alarm+pnpm generate:itx-api.StreamProcessorFacetbase initerate/sdk:parentAlarmsdialsstreams.get(path).proxySetAlarmper call (theitxProjectStreamsession pattern);createProcessor+recoveryare the only authoring seam.StreamDurableObject.#dialProcessorFacetuserspace branch:loadStatefulClass(ref)→ctx.facets.get(name, …)→ the same configure/wake/handleAlarm protocol as built-in facets, with a source-cacheKey abort on config-repo changes.- E2e proof:
userspace-facet-processor-revival.e2e.test.ts— a slow recovery-backed userspace facet, killed mid-work, revived by the parent alarm to completion.
Open questions below are resolved: A1 (not A2); ungated (project access); phased
into one PR (loader + alarm proxy together); proxyDeleteAlarm stays a no-op.
The design rationale is preserved below for the record.
Follow-up to dual-mode-stream-processors,
bullet 2 (the facet-processor{userspace} loader). The loader itself is easy
(worker-runner.loadStatefulClass(ref) → ctx.facets.get(name, () => ({class})),
exactly as StatefulWorkerDurableObject already does). This doc is about the
one genuinely hard sub-problem: keepalive alarms for a loaded facet.
The mechanism today (built-in facets)#
A facet may not own a native alarm (ProcessorFacet throws if you define
alarm() — workerd#6810: a failed setAlarm poisons the facet's output gate).
So the runner's keepalive/obligation adapter goes through a proxy to the
parent Stream DO, which owns the one real platform alarm and replays each fire
into the facet's handleAlarm.
- The registry runs against a
DurableObjectStatefacade (facetProcessorDurableObjectState,processor-facet.ts:126) whosestorage.setAlarm/deleteAlarm/getAlarmare overridden to dialparentAlarms().proxy{Set,Delete,Get}Alarm(...). ProcessorFacetAlarmProxy(processor-facet.ts:66):export type ProcessorFacetAlarmProxy = { proxySetAlarm(scheduledTimeMs: number): MaybePromise<unknown>; proxyDeleteAlarm(): MaybePromise<unknown>; proxyGetAlarm(): MaybePromise<number | null>; };parentAlarms(identity)is abstract and resolved per call (a stub must not outlive its RPC turn). The built-in returns the parent stub:// processor-facet-durable-object.ts protected parentAlarms(identity): ProcessorFacetAlarmProxy { return this.env.STREAM.getByName(identity.parentName) as unknown as ParentStreamStub; }- The parent implements the three verbs over its shared facet-alarm slot
(
stream-durable-object.ts):proxySetAlarm(1298, merges into the earliest-time slot),proxyDeleteAlarm(1309, no-op today),proxyGetAlarm(1312). They must tolerate reentrancy — the keepalive re-arms from inside the parent's ownhandleAlarmreplay.
The constraint that makes userspace hard#
The built-in facet is the OS worker's own class, so it has env.STREAM. A
loaded userspace class runs in the config-repo isolate via
DynamicWorkerRunner, whose env is { ITX: <scoped> } (+ a project-egress
fetcher). It has no env.STREAM — so parentAlarms has nothing to dial.
What it does have: env.ITX. The facet's stream handle already dials back to
the parent through itx (ProcessorFacetHost.stream is a colocated itx stream).
So the alarm proxy wants to ride the same channel.
Everything below only matters for userspace processors that owe background
work (the blockProcessorWhile/runInBackground/keepalive obligation
pattern). A pure reducer (the guestbook shape) never arms an alarm, so it
works today with a parentAlarms that throws. We can ship pure-first and pick
an option below for the alarm-owing case — that phasing is orthogonal to which
option we choose.
Option A — expose the alarm proxy on the stream's itx surface (Jonas's preference)#
Put proxySetAlarm/proxyDeleteAlarm/proxyGetAlarm on the itx Stream
capability, and have the userspace facet's parentAlarms() dial itx:
// The userspace facet base (published from iterate/processors/cloudflare):
protected parentAlarms(identity: ProcessorFacetIdentity): ProcessorFacetAlarmProxy {
const at = identity.path;
const env = this.env;
// Resolved per call (matches the built-in): each verb opens, uses, disposes
// its own itx session, like withProject / itxProjectStream.
return {
async proxySetAlarm(ms) { using p = await env.ITX.get(); return p.streams.get(at).proxySetAlarm(ms); },
async proxyDeleteAlarm() { using p = await env.ITX.get(); return p.streams.get(at).proxyDeleteAlarm(); },
async proxyGetAlarm() { using p = await env.ITX.get(); return p.streams.get(at).proxyGetAlarm(); },
};
}On the OS side, the itx Stream RPC target (StreamRpcTarget in
rpc-targets.ts) forwards to the Stream DO's existing proxySetAlarm et al.
Two sub-variants for where the verbs live:
- A1 — directly on
Stream:streams.get(path).proxySetAlarm(ms). Simplest; smallest diff. But it grows the publicStreamsurface with an internal-looking verb, and it's discoverable/callable by any project code. - A2 — a dedicated gated sub-node:
streams.get(path).facetAlarms.setAlarm(ms)(or fold onto the existing.processornode). Keeps the top-levelStreamsurface clean and gives one obvious place to put the authority guard. Lean.
Pros
- Uses the channel the loaded worker already has; no new binding, no loader plumbing. Symmetric with the facet's stream handle, which already rides itx.
- Capability-native: the proxy is "just another itx verb," typed in the public contract and available to any future off-DO processor host, not only facets.
Cons / must-resolve
- Authority. A public
proxySetAlarmlets any itx caller arm the stream DO's facet-alarm. Blast radius is bounded (a stream is per-(projectId, path), and the caller already owns the project), and the fire only replayshandleAlarminto facets (idempotent-ish) — so worst case is a self-inflicted wakeup, not data loss. Still, decide: (a) accept it as low-risk and leave it ungated; (b) gate to loopback / the hosting facet identity; (c) put it behind an admin/internal authority tier. A2 makes (b)/(c) localizable. - Reentrancy / no deadlock. The keepalive re-arms from inside the parent's
handleAlarmreplay. With the built-in that's a direct in-isolate call; over itx it'sfacet → ITX → project routing → parent.proxySetAlarmwhile the parent is still awaitinghandleAlarmon that facet. The parent'sproxySetAlarmis a synchronous kv write that returns without awaiting the in-flighthandleAlarm, so it should not deadlock — but this needs an explicit test (a userspace facet that re-arms during handleAlarm) before we trust it. - Per-call session cost. Every
setAlarmopens+disposes an itx session. Fine for keepalive cadence (seconds+), not for hot paths.
Option B — parent hands a scoped alarm-proxy capability at configure()#
#dialProcessorFacet already calls facet.configure({parentName, projectId, path}). Add an alarmProxy capability (an RpcTarget the parent mints that
forwards to its own proxySetAlarm), and store it for parentAlarms().
- Pro: tightest scoping — only the hosted facet ever gets the capability; no public itx surface.
- Con: fights the lifecycle model.
parentAlarmsis contractually resolved per call precisely because "a stub captured once would outlive its RPC session" (processor-facet.ts:122). A configure-time stub retained for the facet's lifetime pins it and risks the disposed-stub failure mode. Would need re-delivery every incarnation (like__stashSelfRef) and careful retention — more moving parts than A for the same result.
Option C — give the loaded worker a scoped parent-alarm binding#
Have DynamicWorkerRunner inject a per-facet binding (e.g. env.PARENT_ALARMS)
that resolves to the parent Stream DO's proxy verbs, so parentAlarms() mirrors
the built-in (env.PARENT_ALARMS.proxySetAlarm(...)).
- Pro: mirrors the built-in exactly; no public itx surface; scoped to the parent only.
- Con: new loader machinery — the binding must be minted per-facet with the
parent identity baked in and threaded through
loadStatefulClass/the runner's env construction. More surface than A, and it special-cases the facet loader vs the ordinary stateful-worker loader.
Option D — sidestep the proxy: parent-driven revival from committed state#
Record the keepalive obligation as a committed stream event instead of the runner's private progress store, so the parent can arm its own alarm from committed state and revive the facet on fire — no facet→parent alarm call at all.
- Pro: no alarm channel for userspace at all; the parent already fires every
facet's
handleAlarm. - Con: changes the obligation model itself (private runner keepalive → public committed obligation). Largest semantic change; affects built-ins too; probably out of scope for "make userspace facets work."
Recommendation#
Go with A (Jonas's preference), sub-variant A2, and phase it:
- Now: land the loader for pure userspace facets —
parentAlarms()throws a clear "keepalive alarms not available for userspace facets yet." The guestbook-shape processor works end to end. - Then: add
streams.get(path).facetAlarms.{setAlarm,deleteAlarm,getAlarm}to the itx contract, forward to the Stream DO's existing proxy verbs, and point the userspace facet base'sparentAlarms()at it. Add the reentrancy test (re-arm duringhandleAlarm) and decide the authority gate.
Why A over B/C: it reuses the channel the loaded worker already has, adds no binding/loader machinery, and generalizes to any off-DO processor host — the same reason facet stream handles already ride itx. B and C both buy tighter scoping at the cost of lifecycle/loader complexity, and the scoping win is small because the blast radius is one already-owned project stream.
Open questions (Jonas)#
- A1 vs A2 — verbs directly on
Stream, or a dedicatedfacetAlarmssub-node? (I lean A2.) - Authority on
proxySetAlarm— leave ungated (accept the self-wakeup blast radius), gate to loopback/facet identity, or an internal tier? - Phasing — OK to land pure-userspace-facets first (alarm-owing throws), with the itx alarm proxy as the immediate follow-up? Or do you want the alarm proxy in the same PR as the loader?
proxyDeleteAlarmis a no-op today — leave it a no-op over itx too, or make delete real as part of this?