Stream processors as Durable Object facets
Owner direction from the itx address-unification review (2026-06-11): the in-class processor hosting pattern —
class AgentDurableObject extends DurableObject {
// every processor's progress lands in the HOST's own kv under
// `stream-processor:<slug>:progress` (durable-object-processor-durability.ts)
#registry = createStreamProcessorRegistry(this.ctx, { ... });
}— shares the host DO's storage between host and processors (key-prefix discipline; the DO classes are 80–150-line shells around the registry). Replace it with facets: each processor (or a per-host processor-composition subclass) runs as a facet, with its OWN private SQLite.
Decision (2026-08-03): endeavour to use facets#
Owner call after full platform verification (workerd source dbc16042d
plus a deployed probe worker — evidence below). Direction sharpened: the
processor host should be ONE class that runs in either placement —
- its own Durable Object (today's topology), or
- a facet of the Stream DO itself —
ctx.facets.get(name, () => ({ class: ctx.exports.<HostClass> }))—
chosen per processor, not per architecture. Placement policy:
| Processor kind | Placement | Why |
|---|---|---|
| Light projections (device, secret, collection, notification) | facet of their stream | small state, no long-running effects; crash isolation suffices |
| Heavy/effectful (agent, capability host) | own DO | ctx.exports facets share the parent's isolate (OOM couples) and a live facet keeps the parent resident — transcript-sized state and multi-minute keepalive work stay off the stream |
| Userspace/repo code | worker-loader facet (own isolate), nested under a runner facet | real memory isolation; buildable/killable/deletable without touching the Stream DO (nesting depth allows stream → runner facet → dynamic facet) |
Delivery to a colocated facet becomes a direct parent→facet call —
replacing the retained-callback wake leg for that placement only; the
transport remains for own-DO, userspace-remote, and browser hosts.
durableObjectProgressStore runs verbatim on a facet's private
ctx.storage; the only adapter that differs between placements is the
keepalive alarm (facet mode uses the parent-owned alarm proxy already
shipped for stateful workers).
Platform verification (2026-08-03)#
workerd source (dbc16042d) + deployed probe (dev/preview account,
worker deleted after; ramp tests with control loops):
- API surface (deployed):
ctx.facets=get / abort / delete / clone.delete(name)aborts + permanently deletes the facet's SQLite DB, recursively including descendants; parent unaffected; nextget()starts empty.abort(name, reason)kills only the incarnation — storage survives, existing stubs throwreasonverbatim. A running facet is reused WITHOUT invoking the startup callback, so class/id swaps requireabort()first (the version dancestateful-worker-durable-object.tsalready does). - Wiring constraint: the startup callback must return
ctx.exports.<Class>(loopback namespace) or a WorkerLoaderDurableObjectClass— a raw JS class throws a TypeError. - Subrequests: facet calls are metered in the in-house "API
requests" pool, identically to DO-stub RPC (probe: hard stop at
exactly 10,000 default;
limits.subrequestsgoverns the pool — a 20,000 override was honored). The OS worker'slimits.subrequests: 10_000_000(#2378) therefore covers facet delivery. Each facet gets its own IoContext and thus its own outbound budget; the parent pays one in-house subrequest per hop. - Perf: 10,000 facet pings completed in 0–8 ms observed wall time (in-process loopback) vs ~8 s for 10,000 same-script DO-stub pings (~0.8 ms/hop).
- Crash isolation: downward only. A broken/aborted facet detaches
from the parent's facet map — no upward propagation; the next
get()restarts it fresh. Parent abort/eviction kills the whole subtree. A thrown exception in a facet method is an ordinary rejected promise. BUT no memory isolation forctx.exportsclasses: same V8 isolate and heap as the parent (facets also guarantee co-residence on the parent's machine). Separate isolates require a different service binding or a worker-loader class. - Old Q1 answered: facets CAN own stream subscriptions (proven
PR #2073) but CANNOT have alarms — structural in workerd
(
server.c++:1211-1214TODO(someday); the runtime error is the generic "alarms are not yet implemented for SQLite-backed Durable Objects", maximally confusing).getAlarm()reads fine; a persisted facet alarm would never fire. The parent-owned alarm proxy (tasks/facet-alarms-for-userspace-processors.md, done) is the pattern. - Nesting: max tree depth 4 including the root DO; a facet's
ctx.facetsis full-featured (grandchild proven on the deployed probe, including from cross-isolate dynamic facets). - Old Q4 answered: parent
deleteAll()recursively wipes descendant facet storage (transactional in production per a workerd source comment) — lifecycle on host deletion is automatic. - Facet stubs are not serializable — a facet can never hand a callback to a remote DO; colocated delivery must be parent→facet direct.
- Caveats: open beta, Workers Paid plan only, no GA date; workerd#6800 (open, unanswered): SQLite facets prevent parent hibernation, so facet activity bills duration through the parent (moot for already-pinned Stream DOs, real for quiet hosts); 65,536 facet-name lifetime cap per DO.
Target shape#
// generic base lives with the streams package; per-host composition is a
// SUBCLASS (constructor args live where the storage lives — the host can
// never pass arguments to a facet, and shouldn't need to):
export class AgentProcessors extends ProcessorFacet {
processors = [new AgentProcessor({ ...derived from this.ctx/this.env }), ...];
}
class AgentDurableObject extends DurableObject {
processors() {
return this.ctx.facets.get("processors", () => ({ class: this.ctx.exports.AgentProcessors }));
}
}- Facet classes are EXPORTED ENTRYPOINTS (the class-level address) — same rule as everything else post-unification.
- Identity derives from names (host's structured name + facet name); anything names can't carry arrives per the creation-is-an-event doctrine (docs/domain-objects-and-stream-processors.md) — no initialize RPC, no idempotency keys.
- Checkpoints + projection tables live in the facet's private storage:
zero collisions with the host, per-processor GC (delete the facet,
the projection dies), and
readState/writeStatewiring is written once inside ProcessorFacet instead of per host. - Processors become addressable through the uniform door scheme
(
{ binding, name, path: ["processors", …] }) since StreamProcessor already extends RpcTarget. - This is the same supervisor pattern as ItxDurableObject hosting
stateful source capabilities — one doctrine, two instances. It should
converge with / replace
StreamProcessorRunner(packages/iterate/src/processors/stream-processor-runner.ts).
Still open (Q1/Q4 answered above; migration still none needed)#
- Per-facet checkpoints diverge by design (each has its own offset) —
the stream's
subscription_cursorsrows already track per-subscriber positions, so divergence is the normal case, but confirm replay-per-facet on wake. - The Phase-2 gate from docs/stream-processor-runner-redesign.md applies to any stream-side hosting: benchmark that facet delivery leaves the Stream DO's synchronous append turn untouched.
- workerd#6800 pinning cost if facets ever land on quiet (non-stream) parent DOs.
Relation to in-flight work#
The itx finishing wave makes the context host (ItxDurableObject) the
runner for stateful source capabilities; this task generalizes the same
move to ALL processor hosting. Sequencing: after the itx unification PR
lands and the facets API has been exercised by it.
Companion design (2026-08-03):
Stream subscription model redesign
— the other half of the same rebuild. This task decides where processors
run; that doc decides how anything attaches to a stream and how it is
identified (subscription names as the one identity — name = facet name =
progress key — the delivered/confirmed cursor split, the parked state,
and readers as a first-class pull surface). Its naming and cursor changes
can land before facet placement; the facet placement of the wake arm
lands here.