Yoke.sh
Runtime

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.

LayerType / moduleResponsibility
Runtime lifecycleRuntimeLifecycleServiceStart/stop tenant executor sandboxes from ingress signals
Queue drainQueueDrainSchedulerClaim actor_queues rows and invoke process()
Timer dispatchtimer_dispatchFire 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

TaskDescription
Drain loopsPer-instance (or per-group) loops that claim and dispatch
ConcurrencyRespect queue.concurrency per group_id
Worker ownershipOnly drain instances assigned to this worker
RPC dispatchCall dispatch_process → Deno process(ctx, event)
DequeueDelete 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:

  1. List pending events for group
  2. Deno runs queueSelectFromConfig(config) or inline select
  3. Claim chosen event_id specifically

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:

MechanismBehavior
YOKE_WORKER_IDStable worker identity (default {hostname}-{uuid})
Create pathNew instances get creating worker's worker_id
Boot claimWorker claims actor_instances where worker_id IS NULL
Drain scopeScheduler 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 varDefaultPurpose
YOKE_HOT_RECONCILE_INTERVAL_SECS5Extend lease / stop idle hot tenants
YOKE_COLD_RECONCILE_INTERVAL_SECS30Cold sweep — stuck starting, pending queues
YOKE_SCHEDULE_WAKE_POLL_SECS10Wake cold tenants with overdue scheduled_events
YOKE_NOTIFY_POLL_BACKUP_SECS15Postgres 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

MetricTypeDescription
yoke_actor_process_totalcounterprocess() invocations
yoke_actor_process_errors_totalcounterFailed process() calls
yoke_queue_depthgaugePer-instance queue depth
yoke_runtime_workersgaugeWorkers with recent heartbeat
yoke_executor_boot_phase_secondshistogramPer-phase executor cold-start duration

Failure behavior

FailureEffect
process() throwsEvent requeued, error metric incremented
Deno worker crashIn-flight events revert to pending on restart
Worker diesOwned instances idle until another worker claims them
Postgres unavailableScheduler blocks; stream appends fail

On this page