通知を「条件を検知した瞬間にそのまま送る」と、検知から送信までの短い間に状況が変わって誤報になります。在庫が切れる、値が戻る、ウォッチが外れる——数十秒〜数分後には、その通知はもう当てはまらないことがある。
個人で作った価格ウォッチャー(Cloudflare Workers + D1)で、ここを outbox パターンで解きました。検知は outbox に積むだけ、送信は別工程が送信直前に再確認してから、という分離です。冪等な投入・送信直前の再確認・再送を D1 の状態機械として組んだ要点をまとめます。
検知は outbox に「積むだけ」(冪等 enqueue)
検知側は、条件成立を outbox に1行 INSERT して終わり。送信は別工程に任せます。同じウォッチの同じトリガーが1日に何度検知されても通知は1件にしたいので、SQLite の式インデックス+ON CONFLICT DO NOTHING で冪等にします。
CREATE UNIQUE INDEX uq_outbox_dedup
ON outbox (watch_id, trigger, date(detected_at), channel);
export async function enqueueOutbox(db: D1Database, input: EnqueueInput): Promise<boolean> {
const res = await db.prepare(
`INSERT INTO outbox (watch_id, user_id, channel, trigger, status, detected_at, detected_payload_json, retry_count, created_at)
VALUES (?, ?, ?, ?, 'pending', ?, ?, 0, ?)
ON CONFLICT (watch_id, trigger, date(detected_at), channel) DO NOTHING`
).bind(/* ... */).run();
return (res.meta?.changes ?? 0) > 0; // 実挿入なら1、dedup 無視なら0
}
date(detected_at) を鍵に含めるのがポイントで、「同じ日の同じ検知」を1件に畳みつつ、翌日は別行として通します。
状態は5つ、一方向
各行は pending → sending → sent(成立)、pending → dropped(崩れた)、sending → retry → failed(一時失敗→上限)を一方向に進みます。
dropped は異常ではなく正常系の終端(「送るべきでないと分かったので送らなかった」)。エラーと分けておくと、あとで正しく見送った件数を数えられます。
送信直前に再確認して、崩れていたら送らない
送信工程は1件ずつ「再照会 → 検知条件が今も成立するか再判定 → 成立分だけ送る」を回します。
async function processOutbox(o, now, deps) {
// (0) 事前ガード: ウォッチ解除 / 対象停止 → dropped
if (watchRemoved || listingPaused) { await markDropped(db, o.id, ...); return 'dropped'; }
// (1) 送信直前に現在値を取り直す
const r = await adapter.getOne(itemCode);
if (!r.ok) {
if (r.error.kind === 'empty') { await markDropped(db, o.id, 'out_of_stock'); return 'dropped'; }
await scheduleRetry(db, o, `refetch_${r.error.kind}`); return 'retry';
}
// (2) 検知条件が「今も成立」か(中身はドメイン依存)
if (!recheckTrigger(/* 現在値 */).ok) { await markDropped(db, o.id, 'condition_lost'); return 'dropped'; }
// (3) 成立分だけ、最新値で文面を作って配信
await markSending(db, o.id, o.channel);
const result = await deps.deliver(o.userId, buildPayload(/* 再照会後の最新値 */));
// ... sent / retry / dropped
}
再判定の中身は扱うサービスによりますが、共通するのは 検知時の値で送らず、送信直前に取り直した最新値で断定すること。文面に出す数字と、送っていいと判断した根拠を、同じ最新値にそろえます。ズレると「本文の価格と実際が違う通知」になります。
「通知済み」は sent のときだけ進める
冪等性でもう一つ効くのが、last_notified(最後に通知した基準)を sent のときだけ更新するルールです。dropped / retry では触りません。drop や retry で基準を動かすと、次の正当な検知を「もう通知済み」と誤って弾くからです。送れたときだけ前に進めれば、「送信は一度きり、送れなかったものは次につながる」が両立します。
一時失敗は指数バックオフで retry
再照会のレート制限・ネットワーク・配信の一時失敗は retry に落とし、retry_count を増やして指数バックオフで待ち、上限で failed。
export function backoffMs(retryCount: number): number {
return Math.min(2 ** retryCount * 30_000, 30 * 60_000); // 30s, 60s, 120s … 最大30分
}
スキーマに列を足さず再送予約を payload JSON に内包した妥協や、この outbox を毎分どう回すか(Workers の cron 集約)、送信チャネル(ログイン不要の Web Push)の実装は Aulvem 本家にまとめました → Aulvem|通知を outbox で冪等に届ける。動いているものはヤスゴロで触れます。