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 executionFiring— 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:
- Creates a bucket entry with key
entry_key(next_alarm_ms, cell)— the bucket is a global structure that maps alarm times to cells - Updates the cell's
AlarmStatetoArmedwith the operation ID and target time - 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_msvalue - Pulls the earliest alarm entry
- Resolves the target cell
- Injects a
Firingoperation 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:
- JavaScript shim in
crates/celld/js/node_timers.js - Rust FFI boundary
Alarm::armincrates/logic/alarm.rs- 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::armincrates/logic/alarm.rscreates bucket entries and persists state- Global scanner (
cell_alarm_timer) fires due alarms once per minute alarm_finishedcleans 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
RestoredAlarmrecovery
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:
curl -s "https://instagit.com/install.md" Maintain an open-source project? Get it listed too →