Fan-out
Async event dispatch from the global stream to per-instance actor queues — two-tier filter evaluation.
Fan-out is the mechanism that delivers stream events to actor instance queues. It runs asynchronously after every append — the stream never blocks on delivery.
Append → fan-out flow
1. ctx.append() or kernel append
2. INSERT stream_events (assign seq)
3. Return to caller immediately
4. Async fan-out task:
a. Claim fan-out idempotency slot (released on failure)
b. Load fan-out index for event type
c. Evaluate declarative filters (fast path)
d. Evaluate function filters (slow path via Deno)
e. INSERT actor_queues rows for matches
f. Advance per-tenant fan-out cursor on successA slow or saturated actor affects only its own queue. The append path and other actors continue normally.
Two-tier filter evaluation
| Tier | Filter type | Path | Persisted |
|---|---|---|---|
| Fast | Declarative | Indexed by type prefix | Yes (in Postgres) |
| Slow | Function | Evaluated on every event | Config only |
Fast path — declarative filters
Declarative filters are stored in Postgres and indexed by event type prefix:
{ type: "work.assigned", where: { "payload.lang": "typescript" } }
{ type: "work.*" }
{ type: "slack.message.received" }The fan-out index maps type prefixes to instance ids. On append, the kernel looks up candidates by event.type without scanning all instances.
Slow path — function filters
Function filters cannot be serialized. They are rebuilt at boot from filterFromConfig:
filterFromConfig: (config) => (event) =>
event.type === "work.assigned" && event.payload.lang === config.lang,For each append, instances with function filters are evaluated via Deno RPC eval_filter. Optional typeHint on the instance narrows which events reach the slow path (optimization, not a guarantee).
Without filterFromConfig, function-filter instances survive restart in Postgres but receive no events until recreated.
Idempotent delivery
fanout_delivered records (tenant_id, stream_seq, instance_id) tuples. Re-running fan-out for the same event (crash recovery, catch-up) does not double-enqueue.
Per-tenant fan-out cursor in runtime_metadata tracks catch-up position for workers joining late.
Enqueue semantics
Matched events insert into actor_queues:
| Column | Purpose |
|---|---|
instance_id | Target actor instance |
event_id | Source stream event id |
event | Full serialized event JSON |
status | pending or processing |
priority | From event (default 0) |
group_id | Concurrency group (default default) |
created_at | Enqueue timestamp |
tenant_id | Multi-tenant isolation |
Claim order depends on the instance queue policy (fifo, lifo, or random). See Queues.
Control plane fan-out
The multi-tenant control plane HTTP API appends only (ControlPlaneStreamService + NoOpFanout). Fan-out to actor_queues runs on the tenant executor via stream catch-up (LISTEN yoke_stream_append while hot, full catch-up on boot). The scheduler is signaled to wake executors after control-plane appends.
Fan-out vs scheduler
| Component | Role |
|---|---|
| Fan-out | Write path — stream event → queue row |
| Scheduler | Read path — queue row → dispatch_process |
Fan-out is append-triggered. The scheduler polls or is notified when queue depth changes.
Catch-up on boot
Boot sequence step 7 rebuilds the fan-out index:
- Load all
actor_instanceswith declarative filters - Rebuild type-prefix index
- Register function-filter instances for slow-path evaluation
- Resume fan-out cursor from
runtime_metadata
Events appended while the runtime was down are in stream_events — catch-up fan-out processes any seq after the stored cursor.
Metrics
| Metric | Description |
|---|---|
yoke_fanout_enqueues_total | Events enqueued via fan-out |
yoke_stream_appends_total | Stream append operations |
See Observability.
Multi-tenant fan-out
All fan-out operations are scoped by tenant_id. Stream seq is per-tenant (via tenant_stream_counters). Cross-tenant delivery is impossible at the storage layer.