Scheduler
Three scheduling layers — runtime lifecycle, queue drain, and timer dispatch.
Yoke has three schedulers with distinct jobs. Naming them separately avoids conflating tenant cold-start with per-actor queue draining.
| Layer | Type / module | Responsibility |
|---|---|---|
| Runtime lifecycle | RuntimeLifecycleService | Start/stop tenant executor sandboxes from ingress signals |
| Queue drain | QueueDrainScheduler | Claim actor_queues rows and invoke process() |
| Timer dispatch | timer_dispatch | Fire due scheduled_events rows into the stream |
Legacy names TenantSchedulerService and ActorScheduler remain as type aliases.
Queue drain
The queue drain scheduler (QueueDrainScheduler) drives actor processing. It watches per-instance queues, claims events according to queue policy, and invokes process() in the embedded Deno worker.
Responsibilities
| Task | Description |
|---|---|
| Drain loops | Per-instance (or per-group) loops that claim and dispatch |
| Concurrency | Respect queue.concurrency per group_id |
| Worker ownership | Only drain instances assigned to this worker |
| RPC dispatch | Call dispatch_process → Deno process(ctx, event) |
| Dequeue | Delete queue row on success; requeue on throw |
Dispatch flow
Scheduler loop
→ list_active_groups(instance_id)
→ for each group under concurrency limit:
→ claim_next(instance_id, group_id, policy)
→ worker_rpc.dispatch_process(instance_id, event)
→ Deno: await definition.process(ctx, event)
→ success: complete(event_id)
→ throw: requeue(event_id)Custom queue mode inserts eval_queue_select between list_pending and claim_next:
- List pending events for group
- Deno runs
queueSelectFromConfig(config)or inlineselect - Claim chosen
event_idspecifically
Concurrency model
queue: { mode: "fifo", concurrency: 3 }Up to three concurrent process() calls per instance, scoped by group_id. Each invocation gets its own ActorCtx with the same instanceId but independent call stack.
Shared mutable state must go through ctx.state.update() — not module-level variables. Concurrent process() calls can interleave.
Worker assignment
Horizontal scaling partitions instances across workers:
| Mechanism | Behavior |
|---|---|
YOKE_WORKER_ID | Stable worker identity (default {hostname}-{uuid}) |
| Create path | New instances get creating worker's worker_id |
| Boot claim | Worker claims actor_instances where worker_id IS NULL |
| Drain scope | Scheduler only processes locally-owned instances |
Workers register in runtime_workers and heartbeat every 15 seconds.
Stale workers (no heartbeat for 60s) are excluded from yoke_runtime_workers gauge. Instance ownership is not automatically migrated yet — orphan instances are claimed on next worker boot.
Interaction with fan-out
Fan-out enqueues asynchronously. The scheduler is notified (or polls) when:
- New rows appear in
actor_queues - A
process()completes and frees a concurrency slot - Boot finishes and instances become active
There is no global "tick" — drain is event-driven per instance.
Portal and multi-tenant scheduling
Portals are not scheduled — start() loops run independently.
Multi-tenant hosted deployments run RuntimeLifecycleService (alias TenantSchedulerService) on the control plane. It decides when to start/stop tenant executor sandboxes from stream appends, portal HTTP, queue depth, cron due rows, and periodic reconcile sweeps. See Multi-tenant.
| Env var | Default | Purpose |
|---|---|---|
YOKE_HOT_RECONCILE_INTERVAL_SECS | 5 | Extend lease / stop idle hot tenants |
YOKE_COLD_RECONCILE_INTERVAL_SECS | 30 | Cold sweep — stuck starting, pending queues |
YOKE_SCHEDULE_WAKE_POLL_SECS | 10 | Wake cold tenants with overdue scheduled_events |
YOKE_NOTIFY_POLL_BACKUP_SECS | 15 | Postgres poll when NOTIFY was missed |
Stop rule for tenant containers: keep running while pending_events > 0 OR active_dispatch > 0.
Timer dispatch
The timer dispatch loop (spawned from YokeRuntime) fires due rows from scheduled_events by appending to the stream. It listens on LISTEN yoke_schedule_change and sleeps until the next fire_at_ms.
Metrics
| Metric | Type | Description |
|---|---|---|
yoke_actor_process_total | counter | process() invocations |
yoke_actor_process_errors_total | counter | Failed process() calls |
yoke_queue_depth | gauge | Per-instance queue depth |
yoke_runtime_workers | gauge | Workers with recent heartbeat |
yoke_executor_boot_phase_seconds | histogram | Per-phase executor cold-start duration |
Failure behavior
| Failure | Effect |
|---|---|
process() throws | Event requeued, error metric incremented |
| Deno worker crash | In-flight events revert to pending on restart |
| Worker dies | Owned instances idle until another worker claims them |
| Postgres unavailable | Scheduler blocks; stream appends fail |