Guarantees

Understanding what the engine promises (and what it does not) helps you build reliable systems without over-engineering.


Delivery Semantics at a Glance

The engine provides three tiers of guarantee, each building on the last:

TierGuaranteeMechanism
EnqueueExactly-onceIdempotency keys + unique index
DispatchExactly-onceLease-based claiming with heartbeat
ExecutionAt-least-onceRetry on crash/timeout; use ctx.effect() for effectively-once side effects

In practice: an event is admitted once, dispatched to one worker at a time, but the handler may run more than once if the process crashes or times out before completion.


Exactly-Once Enqueue

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

// Safe to call multiple times -- only one run is created
const runs = engine.emit('payment:charge', { amount: 99 }, {
  idempotencyKey: 'charge-order-42',
})

Duplicate calls return the existing runs for that key rather than creating new ones. The caller gets a deterministic response on every attempt, making retries safe and observable. With the SQLite store, this guarantee survives restarts because the key is stored durably alongside the run.


Exactly-Once Dispatch

The dispatcher uses lease-based claiming to ensure that only one worker executes a given run at any point in time.

  1. A run is claimed by acquiring a time-limited lease (default 30 seconds).
  2. A heartbeat extends the lease periodically while the handler runs.
  3. If the worker dies, heartbeats stop and the lease expires, allowing another worker to reclaim the run.

This prevents two workers from executing the same run concurrently. Combined with the retry mechanism, it provides exactly-once dispatch with at-least-once execution.


At-Least-Once Handler Execution

Every event that matches a registered handler will be executed at least once. If a handler crashes, the engine retries it according to the configured retry policy.

This means your handlers may run more than once after a crash or a timeout. If that is a problem, wrap side effects in ctx.effect() (see below).

engine.register('send-invoice', 'order:completed', async (ctx) => {
  const payload = ctx.payload as { orderId: string }
  // This handler will run at least once.
  // If the process crashes after the email is sent but before
  // the run is marked completed, it will run again on recovery.
  await sendInvoiceEmail(payload.orderId)
  return { success: true }
})

Effectively-Once Side Effects

ctx.effect(key, fn) gives you effectively-once execution for side effects. This is the tool you reach for when a side effect must not be repeated:

engine.register('charge', 'order:placed', async (ctx) => {
  const payload = ctx.payload as { total: number }
  // Stripe is only charged once, even if this handler runs twice
  const charge = await ctx.effect('stripe-charge', () =>
    stripe.charges.create({ amount: payload.total })
  )
  return { success: true, payload: { chargeId: charge.id } }
})

How it works:

  1. First call: The engine records the effect as started, executes fn, and stores the result as completed.
  2. Subsequent attempt: The engine finds the stored completed result and returns it immediately, fn never re-executes.
  3. Crash mid-effect (marked started but never completed): The engine re-executes fn on the next attempt, because a partial effect cannot be trusted.

Use ctx.effect() for anything that talks to the outside world: payment charges, transactional emails, webhook calls, writes to external databases.


Crash Recovery

The engine uses lease-based heartbeating to detect and recover from crashes.

  1. When a run is claimed for execution, the dispatcher acquires a time-limited lease (default 30 seconds).
  2. A heartbeat extends the lease periodically (default every 10 seconds, or leaseTimeoutMs / 3).
  3. If the worker process dies, heartbeats stop and the lease expires.
  4. On the next engine startup, expired-lease runs are reclaimed back to idle state.
  5. If a retry policy is configured and the retry budget is exhausted, the run transitions to errored, gets the derived dead-letter status, and the onDead hook fires.

This means that no run is silently lost. Every run either completes, errors out after exhausting retries, or is recovered and retried.

const engine = createEngine({
  store: { type: 'sqlite', path: './app.db' },
  leaseTimeoutMs: 60_000,         // 60s lease window
  heartbeatIntervalMs: 20_000,    // heartbeat every 20s
  retry: { maxRetries: 3, delay: 5000 },
  onDead: (run, error) => {
    alertOps(`Run ${run.id} is dead: ${error}`)
  },
})

What the Engine Does NOT Guarantee

Not exactly-once handler execution

Handlers run at least once, not exactly once. After a crash, the same handler may be invoked again for the same event. Use ctx.effect() to protect side effects that must not repeat.

Not a distributed queue

The engine is designed as a single embedded runtime using one local SQLite database. It is not a replacement for distributed message brokers like RabbitMQ or Kafka. If you need horizontally scaled workers or multi-region coordination, use a different class of tool.

Not strict ordering

Runs are claimed in approximate insertion order, but under concurrency no strict FIFO ordering is guaranteed. If you need sequential processing of related events, use concurrency: 1 or chain events through triggerEvent to enforce ordering within a correlation.