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?

TypeScriptでDead Letter Queueを実装する:失敗したイベントを観測可能な状態にする

0
Posted at

背景となる面接テーマの原文: リアルタイムチャットシステムの設計

この記事では、その中の「メッセージ処理が失敗したらどうするか」を取り出し、DLQ(Dead Letter Queue)の実装に絞って再構成します。元記事の文章は再利用していません。

キュー、ワーカー、成功、DLQ、再試行と再投入の関係を示す図

結論

DLQは「失敗したメッセージを捨てる場所」ではありません。通常キューの進行を止めないために、原因調査が必要な失敗を隔離する場所です。

面接予定のリマインダ送信を例にすると、次の三つを混ぜない設計が要点になります。

  • 一時失敗: タイムアウトや一過性の 5xx。間隔を空けて再試行します。
  • 恒久失敗: 宛先不正や壊れたペイロード。再試行せず DLQ に送ります。
  • 上限到達: 一時失敗でも、決めた回数を超えたら DLQ に送ります。

この分離がないと、壊れた1件が無限再試行でワーカーを占有し、正常な面接通知まで遅延します。

一時失敗は遅延再試行、恒久失敗と上限到達はDLQへ隔離する分岐図

まず守る不変条件

実装の前に、状態遷移を三つに固定します。

  1. attempt は配送失敗ごとに一度だけ増える。
  2. 再試行してよいのは一時失敗だけで、最大回数を超えたメッセージは通常キューへ戻さない。
  3. DLQ から再投入するときは、元のイベントIDを残しつつ attempt とエラー情報を明示的に初期化する。

3番目は見落とされがちです。イベントIDを変えると重複排除が壊れ、失敗状態のまま戻すと再投入直後にまた上限へ到達します。

最小の TypeScript 実装

ここではキュー製品のAPIを隠し、配送結果から次の行き先を決める部分だけを実装します。now を注入しているため、バックオフも決定的に確認できます。

type InterviewReminder = {
  id: string;
  attempt: number;
  payload: { interviewId: string };
  lastError?: string;
};

type DeliveryResult =
  | { kind: "ok" }
  | { kind: "transient"; reason: string }
  | { kind: "permanent"; reason: string };

type Route =
  | { kind: "done" }
  | { kind: "retry"; message: InterviewReminder; retryAt: number }
  | { kind: "dead-letter"; message: InterviewReminder };

class DeadLetterRouter {
  constructor(
    private readonly maxAttempts: number,
    private readonly now: () => number,
  ) {}

  route(message: InterviewReminder, result: DeliveryResult): Route {
    if (result.kind === "ok") return { kind: "done" };

    const failed = {
      ...message,
      attempt: message.attempt + 1,
      lastError: result.reason,
    };

    if (result.kind === "permanent" || failed.attempt >= this.maxAttempts) {
      return { kind: "dead-letter", message: failed };
    }

    return {
      kind: "retry",
      message: failed,
      retryAt: this.now() + this.backoffMs(failed.attempt),
    };
  }

  redrive(message: InterviewReminder): InterviewReminder {
    return { ...message, attempt: 0, lastError: undefined };
  }

  private backoffMs(attempt: number): number {
    return Math.min(30_000, 1_000 * 2 ** attempt);
  }
}

このコードで、3回目の一時失敗はDLQへ、宛先不正は1回目でもDLQへ進みます。指数バックオフの上限を置く理由は、長時間障害で待機時刻が非現実的に伸びるのを防ぐためです。実運用ではここにジッターも加え、同時復旧時の集中再試行を避けます。

実行して状態遷移を確認する

次の短い検証を末尾に足すと、ルータの契約をそのまま実行できます。

const assert = (condition: unknown, message: string): asserts condition => {
  if (!condition) throw new Error(message);
};

const router = new DeadLetterRouter(3, () => 10_000);
const base: InterviewReminder = {
  id: "evt-42",
  attempt: 0,
  payload: { interviewId: "i-7" },
};

const first = router.route(base, { kind: "transient", reason: "gateway timeout" });
assert(first.kind === "retry", "first transient failure must be retried");
assert(first.message.attempt === 1 && first.retryAt === 12_000, "retry state must advance once");

const exhausted = router.route(
  { ...base, attempt: 2 },
  { kind: "transient", reason: "gateway timeout" },
);
assert(exhausted.kind === "dead-letter" && exhausted.message.attempt === 3, "attempt limit must go to DLQ");

const invalid = router.route(base, { kind: "permanent", reason: "invalid destination" });
assert(invalid.kind === "dead-letter" && invalid.message.attempt === 1, "permanent failure must skip retry");

const replay = router.redrive(exhausted.message);
assert(replay.id === base.id && replay.attempt === 0 && replay.lastError === undefined, "redrive must preserve identity and reset retry state");

console.log("ok: retry, DLQ, permanent failure, and redrive invariants hold");

Bunで実行すると、四つの状態遷移を通って次のように終わります。

ok: retry, DLQ, permanent failure, and redrive invariants hold

DLQに入れれば終わり、ではない

DLQに置くメッセージには、少なくともイベントID、失敗回数、最後のエラー、初回受信時刻を残します。これがないと、再投入の対象を絞れず、障害の原因も追えません。

再投入は「DLQの全件を元のキューへ戻す」操作にしない方が安全です。まず恒久失敗の原因を直し、対象を選び、イベントIDを保ったまま attempt をリセットしてから戻します。消費側は同じIDを重複実行しない設計にしておく必要があります。

システム設計面接では、DLQを答えとして置くだけでは足りません。何を再試行し、いつ隔離し、どの情報を残し、どう再投入するかまで状態遷移として説明できると、障害時の運用まで含めた設計になります。

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?