OqronKitOqronKit

Crash Safety

Heartbeat locks, stall detection, and graceful shutdown

Crash Safety

OqronKit guarantees that no job is ever lost — even if your process is killed mid-execution. The guaranteedWorker flag is the foundation of this guarantee and is available across every module in OqronKit.

guaranteedWorker — Universal Guarantee

The guaranteedWorker option enables heartbeat-based crash-safe execution. When enabled, a HeartbeatWorker atomically claims each job and continuously renews a distributed lock while the handler runs. If the process dies, the lock expires and the job is automatically recovered.

Available in Every Module

ModuleOptionDefault
Queue()guaranteedWorkertrue — enabled unless explicitly set to false
Worker()guaranteedWorkertrue — enabled unless explicitly set to false
Cron()guaranteedWorkerfalse — opt-in for critical crons
Schedule()guaranteedWorkerfalse — opt-in for critical schedules
Webhook()guaranteedWorkertrue — delivery guarantees

Usage Across Modules

import { Queue, Worker, Cron, Schedule, Webhook } from "oqronkit";

// Queue — enabled by default, disable for fast fire-and-forget tasks
const fastQueue = Queue({
  name: "analytics-events",
  guaranteedWorker: false, // No heartbeat needed for lightweight tasks
  handler: async (ctx) => {
    /* ... */
  },
});

// Queue — default is true, critical financial processing
const billingQueue = Queue({
  name: "billing",
  heartbeatMs: 3_000, // Renew lock every 3s
  lockTtlMs: 15_000, // Lock expires after 15s if heartbeat stops
  handler: async (ctx) => {
    /* ... */
  },
});

// Worker — default is true, heavy compute
const videoWorker = Worker({
  topic: "video-encode",
  guaranteedWorker: true,
  heartbeatMs: 5_000,
  lockTtlMs: 30_000,
  handler: async (ctx) => {
    /* ... */
  },
});

// Cron — opt-in for critical scheduled jobs
const billingCron = Cron({
  name: "monthly-billing",
  expression: "0 0 1 * *",
  guaranteedWorker: true, // Critical — must survive crashes
  heartbeatMs: 5_000,
  lockTtlMs: 20_000,
  handler: async (ctx) => {
    /* ... */
  },
});

// Schedule — opt-in for critical one-off tasks
const migration = Schedule({
  name: "data-migration",
  runAt: new Date("2026-12-01"),
  guaranteedWorker: true, // Must complete — no lost migrations
  handler: async (ctx) => {
    /* ... */
  },
});

How It Works

  1. Worker atomically claims a job → writes workerId + TTL to the Lock adapter
  2. A heartbeat loop renews the lock every heartbeatMs while processing
  3. If the process crashes (SIGKILL / OOM), the heartbeat stops
  4. The lock expires in Redis/Postgres after lockTtlMs
  5. The internal StallDetector finds the expired lock → marks the run as stalled
  6. The job is re-queued and routed to a healthy worker within ~15 seconds
┌─ Worker Node A ────────────────────────────────────────┐
│  1. Claim job → lock(key, workerId, ttl=30s)          │
│  2. Start heartbeat → renew lock every 5s              │
│  3. Execute handler...                                 │
│  💥 CRASH (SIGKILL / OOM)                              │
└────────────────────────────────────────────────────────┘
                        ⏱️ Lock expires after 30s
┌─ Stall Detector ──────────────────────────────────────┐
│  4. Scan for expired locks                            │
│  5. Mark job as stalled                               │
│  6. Re-queue to broker                                │
└───────────────────────────────────────────────────────┘
┌─ Worker Node B ────────────────────────────────────────┐
│  7. Claim re-queued job → execute handler              │
│  8. Job completes successfully ✅                      │
└────────────────────────────────────────────────────────┘

Tuning Guide

ScenarioheartbeatMslockTtlMsWhy
Fast tasks (< 30s)500030000Standard protection
Heavy compute (minutes)1000060000Longer TTL prevents premature stall
Critical financial300015000Aggressive detection, fast recovery
Spot instances500020000AWS/GCP can kill instances anytime

Rule of thumb: lockTtlMs should be at least 3 × heartbeatMs to tolerate temporary network delays.

Graceful Shutdown

When SIGINT or SIGTERM is received, OqronKit automatically captures it and triggers a graceful stop sequence:

  1. Stops accepting new jobs from all modules (queues pause, schedulers stop ticking)
  2. Waits for active jobs to drain (up to a configurable timeout)
  3. Releases all held locks
  4. Shuts down and closes all persistence adapter connections

You can also trigger this manually:

await oqron.stop();

AbortController Support

Handlers receive an AbortSignal via ctx.signal (and shorthand ctx.aborted). This signal is automatically aborted during a graceful shutdown sequence.

Handlers should check ctx.signal.aborted periodically or pass it down to outbound HTTP and database clients:

handler: async (ctx) => {
  for (const chunk of chunks) {
    if (ctx.signal.aborted) {
      throw new Error("Cancelled due to shutdown");
    }
    await processChunk(chunk);
    ctx.progress((i / chunks.length) * 100);
  }
};

Idempotency

Handlers will run more than once during crash scenarios. Use jobId as an idempotency key:

await emailQueue.add(
  { to: "user@example.com" },
  { jobId: "welcome-user@example.com" }, // Prevents duplicate processing
);

Disabled Behavior Engine

All modules support disabledBehavior for controlled pausing:

BehaviorWhen DisabledBest For
'hold'Accepts jobs in paused state, resumes on re-enableBilling, order processing
'skip'Silently drops jobsCache purges, analytics
'reject'Throws error on .add()API rate limiting feedback

The At-Least-Once Pipeline

Queues, workers, and webhooks share one crash-safety pipeline. Each mechanism closes a specific failure window:

MechanismFailure it closes
Durable-first writes — the job is persisted before the broker sees itProducer crashes right after .add() returns
Atomic claims with TTL — one consumer owns a job at a timeTwo nodes processing the same job
Heartbeat claim extension (guaranteedWorker)Long jobs losing their claim mid-run
Owner-fenced ack/nack — a stale owner can't ack a re-claimed jobA partitioned node deleting work another node picked up
Boot reconciler — re-enqueues orphaned durable jobs on startupNode dies between persist and process
Dead-letter queue — retries exhausted → queue_dlq:<name>, resendablePoison messages retrying forever
add() ──► durable write ──► broker enqueue

   poll ──► atomic claim (TTL) ──► run ──► heartbeat extends claim

                        ok ──► fenced ack ──► retention
                        err ──► retry w/ backoff … exhausted ──► DLQ

Because delivery is at-least-once, a crash after side effects but before ack re-delivers the job — handlers should be idempotent (use idempotencyKey on producers and idempotent writes in handlers).

Scheduled work

Cron/Schedule handlers default to in-process, at-most-once execution — a crash mid-handler loses that fire (the schedule and history survive). Opt into the full pipeline per definition with durable: true: each fire becomes a deduplicated, slot-keyed job with retries, reclaim, and dead-lettering.

Next Steps

  • Architecture — runtime, modules, and the two-plane design
  • Adapters — backends, capabilities, and storage modes
  • Storage Model — where jobs, runs, and dead letters live

On this page