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:
| State | Meaning |
|---|---|
| started | The function is executing. No result stored yet. |
| completed | The function returned. The result is persisted. |
| failed | The function threw. The error is persisted. |
Decision Logic
When ctx.effect(key, fn) is called, the engine checks the ledger:
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:
- run_id + effect_key, the composite primary key
- state,
started,completed, orfailed - output, the JSON-serialized return value (only for
completed) - error, the error message (only for
failed) - timestamps, when it started and when it finished
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:
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.
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:
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.
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
Three columns on the runs table drive this:
| Column | Purpose |
|---|---|
| lease_owner | Identifies which worker holds the run |
| lease_expires_at | When the lease expires if not renewed |
| heartbeat_at | Last heartbeat timestamp |
Reclaim on Startup
When a new engine instance starts (or periodically during operation), it scans for expired leases:
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:
- Readers never block writers. Reads happen against a snapshot of the database at the time the read started.
- Writers never block readers. New data goes into a separate WAL file, and readers continue seeing the pre-write state until they start a new transaction.
- Writers still block other writers. Only one write transaction can be active at a time.
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:
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:
| Concurrency | Heartbeat writes/sec | Typical overhead | Good for |
|---|---|---|---|
| 1–10 | 0.3–3/s | Negligible | Most applications |
| 10–50 | 3–17/s | Low, sub-millisecond per write | I/O-bound handlers (API calls, emails) |
| 50–100 | 17–33/s | Noticeable if handlers also write frequently | Careful tuning needed |
| 100+ | 33+/s | Write contention likely | Not 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:
- Local disk: Works. This is the intended deployment.
- Network filesystems (NFS, EFS, GlusterFS): Broken. File locking over NFS is unreliable. Writes may silently corrupt the database, or WAL mode may fail entirely.
- Docker with a shared volume: A bind-mounted host directory is fine when one process owns the file. A shared volume mounted by multiple containers is outside the intended model.
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:
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.