How Durable Object Alarms Work in the celld Runtime: Complete Technical Guide

Durable Object alarms in the celld runtime use a hybrid in-memory bucket and SQLite persistence layer to guarantee at-least-once delivery of scheduled callbacks, even across crashes or node migrations.

Durable Object (DO) alarms enable serverless applications to schedule future work within a DO instance. In the denoland/celld runtime, this mechanism combines Rust-based in-memory state management with SQLite durability to ensure reliable alarm delivery. This article breaks down the complete alarm lifecycle, from JavaScript API calls through the underlying persistence layer.

Alarm Storage Architecture

The alarm state resides in two locations: an in-memory bucket for fast scanning and SQLite for crash recovery.

In-Memory State: The Cell and AlarmState

Each DO instance is represented by a Cell struct defined in crates/logic/lib.rs. The cell tracks alarm state through an optional field:

alarm: Option<AlarmState>

The AlarmState enum has two variants:

  • Armed { op, next_alarm_ms } — alarm scheduled for future execution
  • Firing — alarm currently executing

SQLite Persistence Layer

Every cell maintains a private SQLite database for durability. The alarm timestamp is stored in the alarm column of the celld_cell table, with schema and helper functions implemented in crates/logic/sqlite.rs. This ensures alarms survive:

  • Process crashes
  • Node restarts
  • Cell migration between nodes

When a cell is evicted from memory, the Alarm::covered method (see next section) verifies that pending alarms have matching bucket entries before allowing eviction.

Arming an Alarm: The Alarm::arm Method

The core logic for scheduling alarms lives in crates/logic/alarm.rs. The Alarm::arm method signature is:

pub fn arm(&mut self, cell: &str, next_alarm_ms: Ms) -> Result<()>

This method performs three critical operations:

  1. Creates a bucket entry with key entry_key(next_alarm_ms, cell) — the bucket is a global structure that maps alarm times to cells
  2. Updates the cell's AlarmState to Armed with the operation ID and target time
  3. Reschedules the global alarm timer if this alarm is earlier than any existing one

The bucket entry key combines timestamp and cell ID, ensuring distinct deadlines for the same cell occupy separate entries.

Example: Low-Level Rust Alarm Arming

use celld::logic::{Alarm, Ms};

fn schedule_periodic_task(cell_id: &str, delay_ms: i64) {
    let mut alarm = Alarm::new();
    let now = chrono::Utc::now().timestamp_millis();
    // arm() creates bucket entry + persists to SQLite
    alarm.arm(cell_id, now + delay_ms).expect("alarm arm failed");
}

This corresponds to the implementation in crates/logic/alarm.rs (lines 127–144).

The Global Alarm Scanner

Celld runs a single background task that discovers and fires due alarms. This scanner is implemented in crates/logic/lib.rs as cell_alarm_timer.

Scanner Operation

The timer task executes once per minute and:

  • Scans the bucket for the smallest next_alarm_ms value
  • Pulls the earliest alarm entry
  • Resolves the target cell
  • Injects a Firing operation into that cell's phase

The scanner uses wall-clock milliseconds (Ms) for comparison against now_ms from the monotonic clock. Clock skew is naturally reconciled on the next scan iteration.

Timer Rescheduling

When a new alarm is armed earlier than existing ones, schedule_alarm_timer (also in crates/logic/lib.rs) recalculates the next wake time. This avoids busy-waiting and minimizes latency for imminent alarms.

Firing and Completing Alarms

When the scanner identifies a due alarm, the runtime transitions through distinct phases:

Phase 1: Injection

The scanner sets cell.alarm = Some(AlarmState::Firing) and prepares the JavaScript callback.

Phase 2: JavaScript Execution

The DO's alarm() handler executes. This runs in the JS/TypeScript environment with access to full DO storage. From crates/celld/js/node_timers.js, the shim exposes the handler invocation.

Phase 3: Completion

After the handler finishes, alarm_finished (in crates/logic/lib.rs) performs cleanup:

// Pseudocode representation of alarm_finished logic
fn alarm_finished(cell: &mut Cell) {
    // Reset alarm state
    cell.alarm = None;
    
    // If handler re-armed, new state already set via setAlarm
    // Clean up bucket entry
    // Update SQLite persistence
}

The at-least-once guarantee stems from this persistence: if a crash occurs mid-execution, the alarm remains in SQLite. On restart, the cell restores from RestoredAlarm state and re-arms the alarm.

JavaScript API: setAlarm and alarm Handler

User code interacts with alarms through the DurableObjectStorage interface.

Setting an Alarm

export default class MyDurableObject {
  async fetch(request) {
    // Schedule alarm 5 seconds from now
    await this.storage.setAlarm(Date.now() + 5000);
    return new Response("Alarm scheduled");
  }
}

The setAlarm(delayMs) call flows:

  1. JavaScript shim in crates/celld/js/node_timers.js
  2. Rust FFI boundary
  3. Alarm::arm in crates/logic/alarm.rs
  4. Bucket entry creation + SQLite persistence

The Alarm Handler

async alarm() {
  console.log("Alarm fired");
  
  // Perform periodic work...
  
  // Re-arm for next interval
  await this.storage.setAlarm(60 * 1000); // 1 minute
}

The handler executes exactly once per scheduled time, with the runtime managing all concurrency and persistence concerns.

Edge Cases and Durability Guarantees

Scenario celld Behavior
Cancellation (setAlarm(-1)) Removes bucket entry, sets AlarmState::None, clears SQLite column
Node loses ownership Alarm entry persists in bucket; new owner restores from SQLite and re-arms
Rapid re-arming Bucket key includes timestamp + cell ID; old entry deleted safely, new entry created
Handler crash Alarm remains in SQLite; restored as RestoredAlarm on restart, re-fired
Cell eviction Alarm::covered checks SQLite/bucket consistency; blocks eviction until alarm resolved

The Alarm::covered method in crates/logic/alarm.rs implements the eviction blocker. It verifies that any persisted alarm in SQLite has a corresponding bucket entry, preventing lost alarms during memory pressure.

Debugging and Inspection

Query SQLite Directly

let conn = cell.open_sqlite()?;
let alarm_ts: Option<i64> = conn.query_row(
    "SELECT alarm FROM celld_cell WHERE id = ?1",
    [&cell_id],
    |r| r.get(0),
).ok().flatten();

match alarm_ts {
    Some(ts) => println!("Next alarm at: {}", ts),
    None => println!("No alarm scheduled"),
}

This uses the SQLite helpers from crates/logic/sqlite.rs.

Check Bucket State

Monitor the per-minute scanner logs or instrument cell_alarm_timer in crates/logic/lib.rs to observe:

  • Scan frequency
  • Earliest alarm timestamp
  • Firing latency

Key Source Files

File Responsibility
crates/logic/alarm.rs Core Alarm struct, arm(), covered() logic
crates/logic/lib.rs Cell struct, cell_alarm_timer, alarm_finished, state machine
crates/logic/sqlite.rs Per-cell SQLite schema, alarm persistence helpers
crates/logic/wake.rs Bucket wake entry creation for alarm scheduling
crates/celld/js/node_timers.js JavaScript setAlarm/clearAlarm shim
docs/cloudflare-compat.md DO API compatibility documentation
docs/testing.md Alarm test scenarios: firing, cancellation, migration

All files are available at github.com/denoland/celld.

Summary

  • Durable Object alarms combine in-memory buckets with SQLite for reliability
  • Alarm::arm in crates/logic/alarm.rs creates bucket entries and persists state
  • Global scanner (cell_alarm_timer) fires due alarms once per minute
  • alarm_finished cleans up state and handles re-arming
  • JavaScript API (storage.setAlarm, alarm() handler) wraps the Rust implementation
  • At-least-once semantics are guaranteed through SQLite persistence and RestoredAlarm recovery

Frequently Asked Questions

How does celld guarantee alarm delivery after a crash?

The alarm timestamp is written to SQLite before the bucket entry is created. On restart, any cell with a persisted alarm but no matching bucket entry enters RestoredAlarm state and re-arms itself. This ensures the alarm is observed again even if the previous firing never completed.

What happens if I call setAlarm multiple times rapidly?

Each call creates a new bucket entry with key entry_key(timestamp, cell_id). The cell's AlarmState is overwritten to the latest arm operation, and the old bucket entry is deleted safely. Only the most recent alarm timestamp remains active.

Can I cancel a scheduled alarm?

Yes. Call storage.setAlarm(-1) or storage.deleteAlarm(). This removes the bucket entry, sets AlarmState::None, and clears the alarm column in SQLite. The global scanner automatically adjusts if this was the earliest pending alarm.

How precise is the alarm timing?

Alarms are accurate to within the scanner's one-minute polling interval plus any execution queue latency. For sub-minute precision, the timer reschedules immediately when an earlier alarm is armed, but the minimum practical granularity is limited by the coordination between bucket scan and injection phases.

Have a question about this repo?

These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:

Share the following with your agent to get started:
curl -s "https://instagit.com/install.md"

Works with
Claude Codex Cursor VS Code OpenClaw Any MCP Client

Maintain an open-source project? Get it listed too →