Yoke.sh
Concepts

Durability

What persists in Yoke, when writes land, crash-safe dequeue, and storage backend guarantees.

Yoke provides durability by default — no opt-in checkpoints, no explicit flush calls. Understanding what persists and when helps you reason about failure modes and design reliable systems.

Three durable layers per actor

LayerStorageSurvives restart
QueuePostgres actor_queuesYes
SQLite statePostgres actor_sqlite (BYTEA) or S3Yes
Instance metadataPostgres actor_instancesYes

Plus the global stream in stream_events — append-only audit log for all events.

When writes persist

OperationPersisted when
ctx.append()Immediately — row in stream_events before return
ctx.state.get()Read — no write
ctx.state.set()Immediately — SQLite write + backend flush
ctx.state.update()Immediately — transaction + state_cas optimistic lock
ctx.sqlite.exec()Immediately — direct DB write
ctx.conversations.postMessage()Atomically — Postgres INSERT + stream append
Queue dequeueAfter process() returns successfully
Schedule upsertImmediately — Postgres schedules row

ctx.state is not an in-memory object. It is an async handle backed by SQLite — every get(), set(), and update() round-trips to the storage backend.

Crash-safe processing

The dequeue semantics are the durability contract for event handling:

1. Event enqueued to actor_queues (status: pending)
2. Scheduler claims event (status: processing)
3. Deno runs process(ctx, event)
4. SUCCESS → row deleted from queue
5. THROW   → status reverted to pending, retry later

There is no early-ack. If process() runs for five minutes, the event occupies a concurrency slot for five minutes. Structure long work as:

  • Multiple events (chained via correlation)
  • Schedules for delayed continuation
  • Child instances for parallel work

Atomic state updates

ctx.state.update() uses optimistic concurrency:

await ctx.state.update((s) => ({
  ...s,
  jobsCompleted: s.jobsCompleted + 1,
}));

Under the hood: read version → apply updater in Deno → state_cas op with expected version → SQLite transaction. Concurrent updaters serialize; no lost writes.

Direct ctx.state.set() overwrites without version check — use update() for read-modify-write.

Storage backends

Per-instance SQLite persistence is controlled by YOKE_STORAGE:

ValueBehavior
pg_sqliteSerialized SQLite databases stored as BYTEA in Postgres actor_sqlite table
s3SQLite files in S3 (YOKE_S3_BUCKET); local cache dir for rusqlite execution only

pg_sqlite stores BYTEA blobs in Postgres — it is not the PostgreSQL pg_sqlite extension. SQLite runs locally in the worker process; the serialized file is persisted to Postgres.

The SDK surface (ctx.state, ctx.sqlite) is identical in both modes.

S3 sync modes

YOKE_S3_SYNCBehavior
lazy (default)Upload after writes; flush_all() on shutdown
strictfsync local state.db before each S3 upload

With strict, a successful state_set / sqlite_exec / state_cas implies the local file is durable before upload begins. A crash between local write and upload may lose data in lazy mode.

Stream durability

Stream appends are transactional in Postgres. Once ctx.append() resolves, the event has a seq and will fan-out (possibly after a short async delay).

Fan-out delivery is idempotent — fanout_delivered prevents duplicate enqueues for the same (tenant_id, stream_seq, instance_id).

Conversation durability

Messages are canonical in Postgres. conversation.message.posted stream events carry ids only — handlers read full content from the conversation store when needed.

Kernel ops perform INSERT + stream_append atomically — user code cannot post to a conversation without persisting it.

What is not durable

EphemeralWhy
In-flight process() local variablesLost on crash — recover from queue
Deno module scope without ctx.stateWorker restart clears memory
Un-awaited promises in process()May not complete before crash

Always persist important data via ctx.state, ctx.sqlite, or ctx.append() before returning from process().

Failure recovery

On restart:

  1. Load instance records from Postgres
  2. Rebuild fan-out index from persisted filters + filterFromConfig
  3. Provision SQLite from actor_sqlite or S3
  4. Resume drain loops — pending queue events reprocessed
  5. Portal start() loops restart

No stream replay. Actors pick up where their queues left off.

On this page