Durable Delays:時間を分散状態として管理する
SchemaBridge Team · 2026-01-10 · Time, Scheduling, Orchestration
メモリリークなしで30日間の待機を扱う。`sleep()` が分散システムのアンチパターンである理由。
sleep() の嘘:なぜ時間は最も難しい分散プリミティブなのか
どのプログラミング言語にも待機のための命令があります。Python なら time.sleep()、Node.js なら setTimeout()、Java なら Thread.sleep() です。これらの命令はシンプルで直感的ですが、数秒を超える用途では 本番の分散システムではまったく役に立ちません。
ささやかな sleep() が嘘なのは、環境が安定していることを前提にしているからです。コードを実行するマシンが生き続け、ロードバランサーにプロセスを殺されず、OS にメモリを回収されないことを前提にしています。現代のクラウド環境では、これらの前提は成り立ちません。標準的な Kubernetes の Pod で sleep(24 60 60)(1日)を実行すれば、その Pod は1日が終わる前にローテーション、再スケジュール、または再デプロイされる可能性が非常に高いのです。
プロセスが死ねば、あなたの「sleep」も一緒に死にます。ビジネスロジックは虚空に消えます。こうして「忘れっぽいソフトウェア」が生まれます。顧客のトライアルを見失い、サブスクリプションの更新を逃し、重要なフォローアップメールを送り損ねるシステムです。
時間の階層:秒から月まで
時間を正しく管理するには、まず分類する必要があります。すべての遅延が同じではありません。
1. 一時的な遅延(ミリ秒〜秒):通常はネットワークのバックオフや、すぐに解放されるデータベースロックの待機です。50ミリ秒の間にクラッシュするリスクは低いため、ここでは sleep() が許容される場合もあります。
2. 短い遅延(分):ここから sleep() は破綻し始めます。何もせずにワーカースレッドやコンテナのリソースを数分間占有することになります。お金の無駄であり、コネクションプールにとってのリスクです。
3. Durable Delays(時間〜月):ここは ビジネスライフサイクル の領域です。14日間の無料トライアル、30日の支払期限、6か月ごとのメンテナンススケジュールなど。これらはコードの中には置けません。インフラストラクチャ に置く必要があります。
スリープするワークフローのアーキテクチャ
SchemaBridge では、時間を 永続的な状態 として扱います。ワークフローが DELAY vertex に到達しても、スレッドはブロックされません。ステップは SUSPENDED として保留され、起床の意図がデータベースに書き込まれ、ワーカーはすぐに別の処理に取りかかれます。
DB ポーリングモデル(低精度・高耐久性)
古典的な自前実装では「起床時刻」をデータベースのテーブルに保存し、バックグラウンドワーカー(「ポーラー」)が数秒ごとにテーブルをクエリします:SELECT * FROM timers WHERE wake_up < NOW()。DB レコードは再起動を生き延びるので耐久性はありますが、その代償として空振りのクエリが絶え間なく発生し、精度の下限はポーリング間隔に等しくなります。
SchemaBridge のアプローチ
SchemaBridge はデータベースレコードの耐久性を維持しつつ、固定間隔のポーリングを廃止しています。各起床は、PostgreSQL の scheduled_tasks テーブル(ScheduledTaskRepository の背後)に絶対時刻 execute_at を持つ行として永続化されます。クロックワーカーは保留中の最も早い execute_at までスリープし、タスクが実行可能になるたびに PostgreSQL の LISTEN/NOTIFY によるプッシュで早めに起こされます。期限の来たタスクは FOR UPDATE SKIP LOCKED を使った単一のステートメントで取得するため、複数のクロックワーカーのレプリカが衝突せずにバックログを分担できます。緩やかなアイドル上限は、通知を取りこぼした場合の保険としてのみ存在します。
-- クレームの簡略化した形:期限到来行を古い順に取得し、レプリカ間で分担する
UPDATE scheduled_tasks SET status = 'PROCESSING'
WHERE task_id IN (
SELECT task_id FROM scheduled_tasks
WHERE status = 'PENDING' AND execute_at <= :now
ORDER BY execute_at
FOR UPDATE SKIP LOCKED
LIMIT 100
)
RETURNING task_id;
キャンセルと早期終了:未来を変える
Durable Delay は話の半分にすぎません。ユーザーが「支払いを3日間待つ」ステップにいて、2時間後に支払った場合、何が起こるべきでしょうか。
第一に、キャンセルです。SchemaBridge で実行を停止すると、その実行がキャンセルされ、続いてその実行の保留中の遅延タイマーと承認タイマーが解除されます。そのため、停止したジャーニーが数日後に目を覚まして先に進むことはありません。この順序(まずキャンセル、次に解除)は意図的なもので、最悪のケースでもタイマーがすでにキャンセル済みの実行に対して発火するだけであり、終端状態のガードによって無害な no-op になります。
第二に、早期起床です。SchemaBridge には「外部イベントが届いたらこの遅延を起こす」という組み込みのプリミティブは ありません。DELAY vertex は設定された時間だけ待機します。早期終了を正直にモデル化する方法は2つあります。
- 起床時に再確認する:遅延が終わったら、Gateway ステップが現在の状態(例:「この請求書は支払われたか?」)を照会し、条件付きエッジが「処理済み」パスかフォローアップパスへルーティングします。遅延を中断する必要は一切なく、判断は最新のデータに基づいて行われます。
- イベントで分割する:イベントそのもの(支払い Webhook)を独自のエントリーポイントまたはワークフローとしてモデル化し、それが届いたら待機中の実行を停止します。
どちらのパターンも、エンジンにタイマーとシグナルを競争させる必要がありません。そうした競合状態は、うまくいくと信じて任せるのではなく、設計によって排除すべきものです。
クロックドリフトと精度
どの分散システムでも、時計が完全に同期することはありません。これが クロックドリフト という現象です。あるマシンの時計が別のマシンより50ミリ秒進んでいれば、「1秒待つ」の挙動はタスクがどこで取得されるかによって変わる可能性があります。
SchemaBridge は、起床を 1か所に置かれた絶対タイムスタンプ、つまり PostgreSQL の execute_at カラムにすることで、この問題を小さく抑えています。遅延を設定する際に now + delaySeconds を一度だけ計算して保存し、それ以降はどのワーカーもメモリ上にカウントダウンを保持しません。そのため、ドリフトしたり再起動で失われたりするワーカーごとのタイマーは存在しません。マシン間の通常の時計のずれは依然としてありますが、時間や日単位で測るビジネス上の遅延にとって、結果には影響しません。
例示シナリオ:30日間のドリップキャンペーンの自動化
「ウェルカムジャーニー」のロジックに苦戦しているマーケティングオートメーションプラットフォームを考えてみましょう。
課題
ジャーニーは次のような内容です:
- メール1を送信(1日目)。
- 3日間待つ。
- ユーザーがクリックしていなければ、メール2を送信。
- 7日間待つ。
- ユーザーがアップグレードしていなければ、プロモーションコードを送信。
Cron ジョブと Postgres テーブルによる独自ソリューションの上に構築すると、こうしたジャーニーは脆くなります。データベースのメンテナンス時間帯やデプロイ中に、一部のユーザーが待機状態で「スタック」し、次のメールを受け取れなくなることがあります。
SchemaBridge のやり方
同じジャーニーを SchemaBridge のワークフローとして作り直したところを想像してください。
1. ビジュアルな遅延:DELAY vertex をグラフにドラッグし、delaySeconds を 259200(3日)および 604800(7日)に設定します。ビルダーはこれらを「3d」のようなコンパクトな期間表示で示します。
2. 永続的な再開:デプロイ中、待機中の実行はただデータベースに保留されたままです。クロックワーカーが復帰すると、ダウンタイム中に期限を迎えたタイマーを古い順に取得し、再開します。
3. 起床時に再確認:各遅延の後、Gateway ステップがメールまたは課金のプロバイダーに、ユーザーがクリックまたはアップグレードしたかを問い合わせ、条件付きエッジが次のステップを選びます。
期待される効果
- 信頼性:一時停止中のジャーニーは再起動やデプロイを生き延びるため、「待機状態でスタックする」類のバグはおおむね解消されるはずです。
- 開発者の満足度:独自の Cron ロジックや「Job Poller」サービスは不要になります。
- 設計時に考慮すべき注意点:長い遅延の後に再開し、前のステップの
steps.<alias>.outputを読むステップは、その出力がまだプランの保持期間内にあることに依存します。数か月にわたるジャーニーでは、過去にさかのぼって参照するのではなく、必要な値をペイロードで引き継いでください。
比較:既存のスケジューリング戦略
| 機能 | setTimeout() | Cron ジョブ / Quartz | SchemaBridge Durable Delays |
| :--- | :--- | :--- | :--- |
| 永続性 | なし(揮発性) | 手動(DB ベース) | ネイティブ(PostgreSQL scheduled_tasks) |
| スケーラビリティ | 低(RAM に依存) | 中(DB がボトルネック) | 高(レプリカ間で SKIP LOCKED) |
| キャンセル | 複雑(手動のハンドル) | 手動での DB クリーンアップ | 実行を停止するとタイマーが解除される |
| 可視性 | なし | SQL クエリ | 待機中(一時停止)のステップを実行ビューで確認可能 |
| 分解能 | ミリ秒 | 秒/分 | プッシュで起床(LISTEN/NOTIFY)、固定ポーリングなし |
長時間実行されるビジネスロジックのためのエキスパートチェックリスト
5分以上待機するシステムを設計するなら、次のヒューリスティックに従ってください:
1. スレッドを止める:待機中にワーカーをブロックしてはいけません。ワーカーはステートレスで、いつ停止されても構わない状態にしておくべきです。
2. 時計を外部化する:起床時刻の保持には、ローカルマシンのインメモリタイマーではなく、中央の永続ストアを使います。
3. 早期の結果を想定して設計する:待っているアクションを、ユーザーが遅延の満了 前 に行う可能性を常に想定してください。何も変わっていないと決めつけず、待機が終わったときに状態を再確認します。
4. 待合室を監査する:タイマーで待機中のステップを持つ実行がいくつあるかを監視してください。これはビジネスの健全性を示す重要な指標です。
5. 相対的な遅延を優先する:待機は固定のカレンダー日付ではなく、前のステップからの期間(delaySeconds)として表現します。そうすれば、前のステップが遅れても、ステップ間の相対的な間隔は一定に保たれます。
結論:時間はインフラストラクチャの問題である
忘れるソフトウェアは失敗するソフトウェアです。時間を第一級の永続的な状態として扱うことで、リアルタイムのイベントと、レイテンシの大きい人間の行動との間に橋を架けます。SchemaBridge なら、数日から数週間にわたるジャーニーも、スリープ中のスレッドなら殺されてしまう再起動やデプロイを生き延びます。ただし、早期終了を設計し、後続のステップが必要とするデータを引き継いでおくことが前提です。
インタラクションのタイミングを確実に管理できることは、真の競争優位です。私たちは永続的な時計を提供し、あなたはジャーニーを提供します。
「Building the Bridge」シリーズの一部です。あわせて読む:オブザーバビリティ・ギャップ — 長時間・多段階の実行を、ログの中で途方に暮れることなくデバッグするために。