0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

通知を outbox で冪等に届ける — 検知と送信を分け、送信直前に再確認する

0
Posted at

通知を「条件を検知した瞬間にそのまま送る」と、検知から送信までの短い間に状況が変わって誤報になります。在庫が切れる、値が戻る、ウォッチが外れる——数十秒〜数分後には、その通知はもう当てはまらないことがある。

個人で作った価格ウォッチャー(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 で冪等に届ける。動いているものはヤスゴロで触れます。

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?