How It Works

This page explains the internal mechanics behind the engine’s reliability guarantees. No code, just the data flow and decision logic.


Durable Effects

ctx.effect(key, fn) is how the engine makes side effects safe across retries. The mechanism is a three-state ledger backed by a run_effects table.

The Effect Ledger

Every effect is a row in a table keyed by (run_id, effect_key). It moves through three states:

stateDiagram-v2 [*] --> started: ctx.effect() called started --> completed: fn() returned started --> failed: fn() threw started --> started: crash mid-effect (re-execute)
StateMeaning
startedThe function is executing. No result stored yet.
completedThe function returned. The result is persisted.
failedThe function threw. The error is persisted.

Decision Logic

When ctx.effect(key, fn) is called, the engine checks the ledger:

flowchart TD A["ctx.effect(key, fn)"] --> B{Ledger has this key?} B -->|No record| D[Mark 'started'] B -->|started| D B -->|completed| C[Return stored result] B -->|failed| D D --> E[Execute fn] E -->|returns| F[Mark 'completed', persist result] E -->|throws| G[Mark 'failed', persist error] F --> H[Return result] G --> I[Re-throw error]

The key insight: started and failed both mean “re-execute.” A started record means the process crashed between calling fn() and receiving its result, the effect can’t be trusted. A failed record means the function threw last time and should be retried.

Only completed skips execution. The result is already there. Why would you redo it?

What Gets Stored

Each effect record persists:

The output is stored as JSON, so anything serializable works: objects, arrays, strings, numbers, null. The same value comes back on subsequent calls.

Multiple Effects Per Handler

Effects are keyed by string, not by execution order. A handler can have as many effects as it needs:

sequenceDiagram participant H as Handler participant L as Effect Ledger H->>L: ctx.effect('charge-card', fn1) Note over L: No record → execute → completed L-->>H: { id: 'ch_123' } H->>L: ctx.effect('send-receipt', fn2) Note over L: No record → execute → completed L-->>H: { messageId: 'msg_456' } Note over H: Crash before handler completes Note over H: Retry, handler runs fresh H->>L: ctx.effect('charge-card', fn1) Note over L: completed → return stored result L-->>H: { id: 'ch_123' } H->>L: ctx.effect('send-receipt', fn2) Note over L: completed → return stored result L-->>H: { messageId: 'msg_456' } Note over H: Handler completes ✓

Because keys are strings (not execution position), you can use conditional logic, loops, or any control flow around effects. The engine doesn’t care what path your code took to reach the ctx.effect() call, it only cares whether a result already exists for that key in that run.

Not a Replay Engine

This is the critical distinction. Frameworks like Temporal record every step of your handler’s execution and replay the entire history to reconstruct state. This forces you to write deterministic code, no Math.random(), no new Date(), no branching on external state.

Yella doesn’t replay anything. Your handler runs fresh on every attempt. The only thing that persists across retries is effect results, and those are looked up by key, not by execution order.

flowchart LR subgraph Replay Engine direction TB R1[Step 1: replay] --> R2[Step 2: replay] --> R3[Step 3: replay] --> R4[Step 4: execute] end subgraph Yella direction TB Y1["Handler runs fresh"] --> Y2["effect('a'): stored ✓"] Y1 --> Y3["effect('b'): stored ✓"] Y1 --> Y4["effect('c'): execute"] end

Write whatever code you want. ctx.effect() handles the parts that talk to the outside world.


Idempotent Event Admission

When you call engine.emit() with an idempotencyKey, the engine guarantees that only one set of runs is created for that key, no matter how many times you call it.

Two-Layer Check

The idempotency guarantee works at two levels:

flowchart TD A["emit('order:paid', payload, { idempotencyKey: 'charge-42' })"] --> B{In-memory check} B -->|Key exists| C[Return empty, already seen] B -->|Key not found| D[Create runs with key] D --> E{SQLite INSERT} E -->|Success| F[Runs created] E -->|UNIQUE constraint failed| G[Return empty, race condition caught]

Layer 1, In-memory lookup: Before creating any runs, the engine queries the run store: “does any run already exist with this idempotency key?” If yes, it returns immediately. This is the fast path that catches most duplicates.

Layer 2, Database constraint: The runs table has a composite unique index on (idempotency_key, process_name) with a partial index filter (WHERE idempotency_key IS NOT NULL). If a duplicate slips past the fast path, the INSERT hits a UNIQUE constraint failed error, which the engine catches and treats as a no-op.

Per-Process Scoping

The unique index is on (idempotency_key, process_name), not just idempotency_key. This means a single emit with an idempotency key can still create multiple runs, one per registered handler, but a duplicate emit won’t create any.

sequenceDiagram participant C as Caller participant E as Engine participant DB as SQLite Note over C: First emit C->>E: emit('user:signup', data, { idempotencyKey: 'signup-alice' }) E->>DB: INSERT run (welcome, signup-alice) DB-->>E: OK E->>DB: INSERT run (create-profile, signup-alice) DB-->>E: OK E-->>C: [run1, run2] Note over C: Duplicate emit C->>E: emit('user:signup', data, { idempotencyKey: 'signup-alice' }) E->>DB: SELECT WHERE idempotency_key = 'signup-alice' DB-->>E: Found E-->>C: [] (no new runs created)

In-Memory vs SQLite

With the in-memory store, idempotency works within a single process lifetime. Restart the process, and the keys are gone.

With SQLite, idempotency keys survive restarts. That makes duplicate admission safe across crashes and redeploys, which is the intended deployment model for the engine.


Crash Recovery

The engine uses lease-based heartbeating to detect crashes and reclaim orphaned runs on the next startup. This requires SQLite storage.

The Lease Protocol

sequenceDiagram participant D as Dispatcher participant DB as SQLite participant H as Heartbeat Loop D->>DB: Claim idle run (SET state='running', lease 30s) Note over D: Handler starts executing loop Every 10s H->>DB: UPDATE lease_expires_at = now + 30s Note over DB: Lease extended end Note over D: Process crashes Note over H: Heartbeat stops Note over DB: 30 seconds pass... Note over DB: lease_expires_at < now

Three columns on the runs table drive this:

ColumnPurpose
lease_ownerIdentifies which worker holds the run
lease_expires_atWhen the lease expires if not renewed
heartbeat_atLast heartbeat timestamp

Reclaim on Startup

When a new engine instance starts (or periodically during operation), it scans for expired leases:

flowchart TD A[Engine starts] --> B[Scan: state='running' AND lease_expires_at < now] B --> C{Stale runs found?} C -->|Yes| D[Transition each to 'idle'] C -->|No| E[Normal operation] D --> F[Increment attempt counter] F --> G[Clear lease columns] G --> H[Run re-enters dispatch queue] H --> I[Dispatcher claims and retries] I --> J{Effects from previous attempt?} J -->|completed| K[Return stored results] J -->|started / none| L[Execute fresh]

The reclaimed run goes back to idle with its attempt counter incremented. When the dispatcher picks it up and the handler runs again, any effects that completed before the crash return their stored results. Effects that were mid-execution (state started) are re-executed.

This is why effects and crash recovery work together: the lease system detects the crash, and the effect ledger prevents duplicate side effects in the common retry path.

Single Process Recovery

The recovery model is deliberately simple. There is no consensus algorithm, no leader election, and no distributed lock manager. The intended shape is one embedded engine runtime using one local SQLite file, then reclaiming stale work on restart after a crash or deployment.

This is not a distributed scheduler. If you need horizontally scaled workers or broker-style throughput, use a different tool. Yella is the single-process, low-infrastructure option.


SQLite Performance

The engine uses better-sqlite3, a synchronous SQLite driver for Node.js. Understanding its performance characteristics helps you tune the engine for your workload.

WAL Mode

The engine enables Write-Ahead Logging on every database connection:

PRAGMA journal_mode = WAL

In default (rollback journal) mode, SQLite locks the entire database for writes, readers block writers and writers block readers. WAL mode changes this:

This is ideal for the engine’s workload: the dispatcher reads idle runs frequently, while handlers write state transitions and effect results. WAL mode lets these happen concurrently.

Write Serialization

The most important constraint: all writes are serialized. SQLite allows only one writer at a time, and better-sqlite3 is synchronous, every write blocks the Node.js event loop until it completes.

This affects the engine in several places:

flowchart TD subgraph Writes that serialize A[Run state transitions] B[Effect markStarted / markCompleted] C[Heartbeat updates] D[Run creation on emit] E[Claim idle runs] end A --> Q[SQLite write lock] B --> Q C --> Q D --> Q E --> Q Q --> R[One at a time]

Each of these operations is fast (typically under 1ms on modern hardware), but they add up. With concurrency: 50, you might have 50 handlers writing state transitions, plus ~17 heartbeat writes per second (50 runs / 3s interval), plus effect writes for each side effect.

Practical Concurrency Limits

The concurrency option controls how many handlers run in parallel. Higher concurrency means more overlapping writes:

ConcurrencyHeartbeat writes/secTypical overheadGood for
1–100.3–3/sNegligibleMost applications
10–503–17/sLow, sub-millisecond per writeI/O-bound handlers (API calls, emails)
50–10017–33/sNoticeable if handlers also write frequentlyCareful tuning needed
100+33+/sWrite contention likelyNot recommended without benchmarking

The sweet spot for most workloads is concurrency 5–20. If your handlers are I/O-bound (waiting on HTTP responses, database queries, email delivery), the Node event loop is mostly idle between writes and SQLite has no trouble keeping up.

If your handlers are CPU-bound or write-heavy (many effects per handler), keep concurrency lower.

Single Process Design

Yella runs in a single Node.js process. The concurrency option controls parallelism within that process, multiple handlers executing concurrently on the event loop, all sharing one database connection.

The SQLite file must live on a local filesystem. SQLite relies on POSIX file locking for correctness, which means:

If your deployment requires multiple containers sharing state across machines, this is the wrong architecture. Use a system built for distributed coordination instead of stretching SQLite past the product boundary.

Duplicate Execution Windows

The engine provides at-least-once execution, not exactly-once. There are timing windows where a side effect can execute more than once:

sequenceDiagram participant H as Handler participant S as Stripe API participant E as Effect Ledger H->>E: ctx.effect('charge', fn) E->>E: Mark 'started' E->>S: Execute fn() S-->>E: { id: 'ch_123' } Note over E: Crash here, after Stripe charged,
before writing 'completed' Note over E: On retry, ledger says 'started' E->>S: Execute fn() again Note over S: Customer charged twice

This is inherent to any system that talks to external services without two-phase commit. ctx.effect() makes this window as small as possible, it’s the gap between the external call returning and the result being written to SQLite, but it cannot eliminate it entirely.

For operations where even rare duplicates are unacceptable (payment charges, account creation), use the external service’s own idempotency mechanism:

await ctx.effect('charge', () =>
  stripe.charges.create({
    amount: 2000,
    idempotency_key: ctx.runId,  // Stripe deduplicates on their end
  })
)

This gives you two layers of protection: ctx.effect() prevents re-execution in the common case (crash after the effect completed), and the external idempotency key catches the rare case where the crash happens in the gap.

What This Means In Practice

For the vast majority of background job workloads, sending emails, processing webhooks, syncing data, SQLite with WAL mode in a single process is more than sufficient.

The engine is designed for reliability over throughput. If you need to process 10,000 events per second, you need a distributed queue. If you need to process 10,000 events per minute with durable effect replay and zero infrastructure, this is what Yella is built for.