Yoke.sh
Runtime

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 success

A slow or saturated actor affects only its own queue. The append path and other actors continue normally.

Two-tier filter evaluation

TierFilter typePathPersisted
FastDeclarativeIndexed by type prefixYes (in Postgres)
SlowFunctionEvaluated on every eventConfig 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:

ColumnPurpose
instance_idTarget actor instance
event_idSource stream event id
eventFull serialized event JSON
statuspending or processing
priorityFrom event (default 0)
group_idConcurrency group (default default)
created_atEnqueue timestamp
tenant_idMulti-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

ComponentRole
Fan-outWrite path — stream event → queue row
SchedulerRead 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:

  1. Load all actor_instances with declarative filters
  2. Rebuild type-prefix index
  3. Register function-filter instances for slow-path evaluation
  4. 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

MetricDescription
yoke_fanout_enqueues_totalEvents enqueued via fan-out
yoke_stream_appends_totalStream 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.

On this page