Durable Object のアラームのハンドラーを、ローカルのランタイムで7回続けて失敗させました。実行されたのは +0、2、6、14.5、32、68、145 秒です。その後、オブジェクトを再び起こすものは何もなく、失効させるはずだったリースは期限切れのまま表に残りました。

これはアラームの文書が3つの文で述べているとおりの動作です。オブジェクトのアラームは1つ。アラームは少なくとも1回実行される。失敗するハンドラーの再試行には上限がある。オブジェクトがリース、セッション、予約のような時間制限つきのものを持ち、期限が来たら片付けなければならないとき、アラームはそのためにあるように見えますが、この3つがハンドラーの書き方を変えます。以下では、3つに耐えるよう作った小さな失効台帳に注釈をつけ、各部分が実際にどう動いたかを示します。

TL;DR

  • オブジェクトごとにアラームは1つ。 setAlarm は、設定済みのアラームを置き換えます。1つのオブジェクトが多数の失効を持つなら、アラームは最も早いものへの呼び出しにすぎません。失効の一覧は表に持ちます。
  • 少なくとも1回。 外部の作業をしたあとでハンドラーが例外を投げると、最初からもう一度実行されます。作業は繰り返しに耐える必要があります。僕のテストでは、予想どおり2回実行されました。
  • 再試行は有限。 文書では、2秒の遅延から始まる指数バックオフで、最大6回の再試行です。ローカルの workerd では7回の試行(最初の1回と再試行6回)を観測し、間隔はおよそ2、4、8、18、36、77秒でした。その後アラームは消え、getAlarm() は null を返し、期限を過ぎたリースは表に残ったままでした。
  • 起きたときの再設定。 アラームが未設定で、保留中の行があるときだけアラームを設定するコンストラクターは、ランタイムの再起動のあとで失効処理を復活させました。無条件にアラームを設定するコンストラクターは、すでに設定されていたアラームに干渉することがあると、文書が警告しています。
  • これらはローカルでの結果です。Cloudflare のネットワーク上では動かしていません。

文書が約束していること

アラームのページから(執筆当日に確認)。

  • Durable Object が同時に持てるアラームは1つです。すでに設定されているときに setAlarm() を呼ぶと上書きされます。
  • アラームは「少なくとも1回の実行が保証」され、alarm() が例外を投げると自動的に再試行されます。2秒の遅延から始まる指数バックオフで、最大6回です。これは直近の setAlarm() にだけ適用されます。
  • ハンドラーは retryCount と isRetry を受け取ります。同時に動く alarm() はオブジェクトごとに1つです。オブジェクトが予期せず終了した場合、alarm() は別のマシンで最初から再実行されることがあります。
  • alarm() の中では、ハンドラーが始まってから setAlarm() を呼んでいない限り、getAlarm() は null を返します。
  • オブジェクトが起きるとき、コンストラクターが alarm() より先に実行されます。コンストラクターでの setAlarm() は、すでに設定されているアラームに干渉しうるので、先に確認するよう文書は警告しています。
  • 組み込みの再試行以上が必要なら、alarm() で例外を捕まえて再設定するよう、文書は勧めています。

ストレージ API のページからは次のとおりです。現在以前の時刻で setAlarm() を呼ぶと、アラームは直近に実行されるよう予定されます。通常は数ミリ秒で始まりますが、「メンテナンスやフェイルオーバー中の障害で、最大1分遅れることがあります」。

ストレージ API(ctx.storage.sql)は SQLite ベースで、間に await を挟まない書き込みはまとめてコミットされます。下のコードでは、その点にはあまり頼っていません。

台帳に注釈をつける

オブジェクト全体は約35行です。実行される順に見ていきます。

export class LeaseLedger extends DurableObject {
  constructor(ctx, env) {
    super(ctx, env);
    this.sql = ctx.storage.sql;
    this.sql.exec(`CREATE TABLE IF NOT EXISTS leases (id TEXT PRIMARY KEY, expire_at INTEGER NOT NULL)`);
    this.sql.exec(`CREATE INDEX IF NOT EXISTS leases_by_expiry ON leases (expire_at)`);
    ctx.blockConcurrencyWhile(() => this.ensureAlarm());      // (1)
  }

(1) オブジェクトが作られるたび(最初のリクエスト、退避のあと、再起動のあと)に、起こすための仕掛けが設定されているかを確かめます。blockConcurrencyWhile は、終わるまで入ってくるイベントを保留するので、初期化が途中のオブジェクトをリクエストが見ることはありません。

  async ensureAlarm() {
    const next = this.sql.exec(`SELECT MIN(expire_at) AS t FROM leases`).one().t;   // (2)
    if (next === null) return;
    const current = await this.ctx.storage.getAlarm();
    if (current === null || next < current) await this.ctx.storage.setAlarm(next);  // (3)
  }

(2) 真実は表にあり、アラームはそこから導きます。(3) アラームは早い方向にだけ動き、遅い方向には動きません。すでに正しい時刻なら、触りません。文書が求めている確認はこれです。関数は冪等なので、コンストラクター、grant、alarm のどこから呼んでも安全です。

  async grant(id, ttlMs) {
    this.sql.exec(`INSERT OR REPLACE INTO leases (id, expire_at) VALUES (?, ?)`, id, Date.now() + ttlMs);
    await this.ensureAlarm();
  }

新しいリースは、1回の挿入と1回の ensureAlarm です。新しいリースが保留中のアラームより早く切れるなら、アラームが前に動きます。

  async alarm() {
    const due = this.sql.exec(`SELECT id FROM leases WHERE expire_at <= ? ORDER BY expire_at LIMIT 100`, Date.now()).toArray();  // (4)
    for (const { id } of due) {
      await this.revoke(id);                                         // (5)
      this.sql.exec(`DELETE FROM leases WHERE id = ?`, id);          // (6)
    }
    await this.ensureAlarm();                                        // (7)
  }
  async revoke(_id) { /* 外部への副作用 */ }
}

(4) 呼ばれた回数を信用せず、今期限が来ているものを読みます。toArray() は最初の await の前にカーソルを読み切ります。(5) と (6) は順序が重要です。先に副作用、次に行の削除です。その間にプロセスが落ちれば、行は残り、次の実行が副作用を繰り返します。逆順では、クラッシュで副作用が失われます。「少なくとも1回」とは、損失より重複を選び、重複を無害にするということです。(7) 次の失効、または LIMIT を超えた行へつなぎます。ハンドラーの中では、設定しない限り getAlarm() が null なので、ensureAlarm は単に設定します。

workerd での動き

wrangler dev 4.147.0(ローカルの workerd、compatibility date 2026-09-01)と、revoke を上書きして呼び出し回数を数え、要求に応じて例外を投げるラボ用のサブクラスを使いました。以下の数値はすべてそのローカルのランタイムのものです。

通常の経路。 寿命が300ミリ秒と1500ミリ秒の2つのリース。アラームはそれぞれに1回ずつ発火し、2回目は1回目の1.2秒後でした。表は空になり、getAlarm() は null でした。

副作用の後のクラッシュ。 revoke が、呼び出しを記録した後で1回だけ例外を投げるようにしました。最初の試行は副作用を実行し、DELETE の前に失敗しました。プラットフォームは約2秒後に retryCount=1 で再試行し、副作用をもう一度実行して、行を削除しました。1つのリースに対して、副作用が2回実行されました。

アラームは早い方向にだけ動く。 5秒のリースを付与し、次に60秒のリース(アラームは動かない)、次に1秒のリース(アラームが前に動く)を付与しました。

再試行を使い切る。 ハンドラーが7回例外を投げるようにしました。ジャーナルには、+0、2.0、6.1、14.5、32.1、67.9、144.6秒の試行が残り、retryCount は0から6でした。その後、100秒ほど様子を見ても試行は起きず、getAlarm() は null を返し、リースは期限切れのまま表に残っていました。文書のとおりで、再試行を使い切ると、次の setAlarm まで何もアラームを実行しません。

復活。 失敗の注入を解除し、同じ永続化状態で wrangler dev を止めて再起動し、リクエストを1回だけ送りました。オブジェクトのコンストラクターが ensureAlarm を実行し、期限切れの行があってアラームがないのを見つけて、設定しました。アラームは約1秒以内に発火し、リースは削除されました。

ラボのオブジェクトには、再試行を使い切るテストの間、コンストラクターによる再設定を止める切り替えがあります。これがなければ、状態を問い合わせるリクエストだけでもオブジェクトが復活してしまいます。上の結果は、切り替えが効いた状態で得たものです。

副作用を繰り返しに耐える形にする

台帳は、繰り返しをまれにしますが、不可能にはできません。副作用は、繰り返しに耐える形である必要があります。2つの形が使えます。

  • もともと冪等: 「資格情報 X を失効させる」や「状態を expired にする」は、2回実行しても同じ状態になります。僕のテストは呼び出しを数えていますが、観測できる状態は1回でも2回でも同じです。
  • キーつき: 副作用が別のものへの呼び出し(送信、課金、作成)なら、リースの id をキーとして渡し、相手が繰り返しを認識できるようにします。

ラボでは副作用をスタブのままにしたので、この節は形の説明で、端から端まで試したものではありません。

workerd で動かす

mkdir alarm-lab && cd alarm-lab
# ラボの wrangler.jsonc、src/ledger.js、src/index.js、lab.mjs、probe.mjs をここに置く
npm i -D [email protected]        # Node.js 22 が必要
npx wrangler dev --port 8799 --persist-to ./state &
node lab.mjs main                 # 通常の経路、副作用後のクラッシュ、アラームは早い方向にだけ動く
node probe.mjs                    # 7回の失敗。約4分かかる

probe は、リースがまだ一覧にある間、attempts=7 と alarm=null を出力します。復活を見るには、wrangler を止める前にそのオブジェクトで /noboot?on=0 を呼んでラボの切り替えを解除し、止めて、同じ --persist-to で再起動し、node lab.mjs revive <object> を実行します。

アラームのハンドラーが黙って止まる場面

  • アラームをデータとして扱うこと。 症状: 失効が2つあってアラームは1つです。あとの setAlarm が前のものを置き換え、片方が失われます。対処: 失効は表に持ち、アラームは最小値に設定します。
  • 失敗するハンドラーが永遠に再試行されると思い込むこと。 症状: 一時的な障害や数分続くバグのあとに、期限切れの行が処理されないまま残り、あとからエラーも出ません。対処: 上のように起きたときに再設定し、外からの定期的な確認(期限切れの作業があるオブジェクトに触れる cron 型のジョブ)も足します。例外を捕まえて自分で再設定する方法は文書が挙げるもう1つの選択肢ですが、僕は試しておらず、行ごとの試行回数のカウンターがないと、不良な行が残りを止めます。
  • コンストラクターで無条件にアラームを設定すること。 症状: オブジェクトが起きるたびにアラームが後ろに押され、一部の失効が遅れます。対処: getAlarm() を読み、早い方向にだけ動かします。ここは文書の警告に従った形で、害のある版を再現してはいません。
  • 副作用の前に削除すること。 症状: クラッシュで副作用が永久に失われます。対処: 副作用が先、行の削除が後にします。副作用は繰り返しに耐える形にします。
  • 表ではなく時計を読むこと。 症状: アラームが早く、または遅く発火し、ハンドラーが何もしない、あるいは違う行を処理します。対処: 毎回 expire_at <= now で期限の来た行を検索します。文書によればアラームは遅れることがありますが、問い合わせで決めていれば、早い実行や重複した実行は無害です。
  • alarm() の中で setAlarm を呼び、getAlarm() に古いアラームが見えると期待すること。 症状: 再設定の判断が null に基づいてしまいます。対処: 文書のとおり、ハンドラーの中では、新しく設定しない限り getAlarm() が null だと覚えておきます。

アラームが合う場面

1つのオブジェクトが多数の時間依存の義務を持ち、「いずれ、少なくとも1回、1分程度の幅で」で足りるなら、この形を使います。台帳とアラームの組は小さく、すべてがオブジェクト自身のストレージに入ります。

正確な時刻に起きなければならない仕事には使いません。見逃しても1日は誰も気づかない後片付けにも、外からの確認を足さない限り使いません。副作用を繰り返しに耐える形にできないなら、それができるまで、アラームは適切な道具ではありません。

workerd で分かったことと、Cloudflare にしか分からないこと

確認したことです。ローカルの wrangler dev 4.147.0(workerd)と Node.js 22.23.3 で、通常の経路、クラッシュ後の2回実行、アラームが早い方向にだけ動くこと、上の間隔の7回の試行のあとに null と期限切れの行が残ること、再起動後のコンストラクターによる復活を確認しました。文書の記述は、上に挙げた Cloudflare のページで読みました。

確認していないことです。Cloudflare の本番ネットワーク。上の再試行の間隔はローカルのランタイムのもので、本番では違うかもしれません。「最大1分」の遅延、オブジェクトが別のマシンに移るときの挙動、ハンドラー内の deleteAlarm() の効果は、僕ではなく文書の記述です。例外を捕まえて再設定する方法、無条件にアラームを設定するコンストラクター、外部のポーラーも試していません。

アラームが約束するもの

アラームは、呼び出しの約束であって、仕事を終わらせる約束ではありません。仕事は表に持ち、起きるたびに再確認し、2回呼ばれても何も失わない形で副作用を書きます。