Featured image of post コード探偵ロックの事件簿【Dead Letter Queue】差し戻し地獄〜リトライの檻に囚われたメッセージ〜

コード探偵ロックの事件簿【Dead Letter Queue】差し戻し地獄〜リトライの檻に囚われたメッセージ〜

失敗時にメッセージをキューへ戻し続ける無限リトライのアンチパターンを分解し、QueueMessage・DeadLetterQueue・MessageProcessorによるDLQ設計をPerl/Mooで実装。

止まらないキューと、糸くずを並べる男

雑居ビルの3階に着いたのは、朝の10時過ぎだった。

エレベーターの扉が開く前から、どことなく来てよかったのかどうか半信半疑だった。先輩から聞いた話では「怪しいが腕は確か」だという。今日の段階では「腕が確か」の方に全力で賭けるしかない状況だった。

扉には「レガシー・コード・インベスティゲーション」と書かれたプレートがかかっていた。先輩は嘘をついていなかった。本当に怪しかった。

ノックして、返事を待たずにドアを開けた。

室内はデスクトップPCの排熱でじんわりと暑く、飲みかけのエナジードリンクの缶が三本並んでいた。そのうち一本はキャップが半開きのままだった。机の向こうに座っていた男は、缶にも僕にも目を向けず、机の上に糸くずを一本ずつ、等間隔で並べていた。

「あの——」

「今日は出社前に直行したのかね、ワトソン君」

顔も上げずに言った。

「……えっ?」

「問い合わせは増え続けているが、ログを見る人間が誰もいないから飛び込んだ。違うかね」

まだ何も話していない。なのに「問い合わせ」という言葉が出た。

「どこで——」と言いかけたとき、男はようやくこちらを見た。

「ロックだ。座りたまえ、ワトソン君。君のキューが止まっているのは7時間と14分前からだ」

糸くずはまだ並べ続けていた。

「……ロックさん。名前、今教えてもらいました。それと、ワトソンは僕の名前じゃないんですが」

「そうかね。では用件を聞こう」

返答がなかった。それより、「7時間14分前」の方が気になった。まだ何も話していないのに、どうしてそれが分かるのか。

まあ、今はそれどころじゃない。

「注文確認メールの送信キューが詰まってます。826件、止まってます。問い合わせが100件を超えてきていて……先週のデータ移行で何か壊れたと思うんですが、どこが悪いのかは——」

「止まれ」

「は?」

「一度に全部話そうとするな。まず確認する。コンシューマは正常に起動しているか」

「はい、動いてます」

「最後に成功した処理はいつだ」

「ログを——」スマートフォンで確認した。「7時間前です」

「その7時間で、コンシューマは何回処理を試みた」

「……4800回以上です」

ロックさんは机の上の糸くずを一本だけ、指先でつまんだ。

「4800回試みて、一件も成功していない。——それがどういう意味か、分かるかね」

「失敗し続けた、ということです」

「もう少し正確に言いたまえ」

正確に。分かっているようで、分かっていなかった。

826通の足止め——1通の決定論

「コードを見せたまえ」

ラップトップを取り出して、テーブルに置いた。キューのコンシューマのコードを開く。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
package MessageQueue;
use v5.36;
use Moo;
use Types::Standard qw(ArrayRef CodeRef);

has _queue    => (is => 'rw', default => sub { [] });
has processor => (is => 'ro', isa => CodeRef, required => 1);

sub enqueue ($self, $payload) {
    push $self->_queue->@*, { payload => $payload };
}

sub process_next ($self) {
    my $queue = $self->_queue;
    return 0 unless @$queue;

    my $item = shift @$queue;
    my $ok   = eval { $self->processor->($item->{payload}); 1 };

    unless ($ok) {
        warn "Failed: $@. Retrying...";
        push @$queue, $item;    # キューの末尾に戻す
    }

    return $ok // 0;
}

ロックさんは画面を一度だけ見て、すぐに視線を上げた。

「先週の移行で、SKUコードに特殊文字が混ざったのだろう。パーセントエンコードが展開された状態のまま入った」

「そうです。%2F が含まれたSKUの注文データが何件か——」

「そのメッセージはJSONシリアライズで毎回例外を投げる。ここを見たまえ」

指先が、push @$queue, $item の行を指した。

「……はい」

「失敗したメッセージを、キューの末尾に戻している。次の処理で再び取り出され、再び失敗し、再び末尾に戻る。——SKUコードの特殊文字が消えることはあるかね」

「ありません。コードを直さない限り」

「つまり、このメッセージは何回処理しても、同じ例外を投げ続ける。決定論的に失敗する。そういうエラーを非一時的エラーと呼ぶ」

非一時的エラー。

「リトライは、治る見込みのある問題にしか効かないんですか」

「ネットワークの瞬断、DBの一時的な過負荷——それは数回の猶予を与えれば回復する可能性がある。しかし特殊文字が混ざったペイロードは、君のコードが直らない限り何回試みても同じ結果を返す。リトライは一時的な傷に貼る絆創膏だ。骨折には効かない」

「じゃあ、このメッセージを……どうすればいいんですか。捨てますか」

ロックさんは、机の上に置いていた小さな紙箱を指先でこちらへ滑らせた。

「捨てるのか。証拠を壊す探偵がいるかね、ワトソン君」

「……比喩ですか、それ」

「隔離だ。ゴミ箱ではない。処理できなかったメッセージを、調査できる場所へ移す。失敗の理由とともに。後で読めるように。そして、問題を修正した後に、もう一度処理できるように」

隔離、調査、再処理。

僕はメモに三つだけ書いた。

隔離室の設計——差し戻しではなく、保全する

「まず、メッセージ自身に何回試みたかを持たせる」

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
package QueueMessage;
use v5.36;
use Moo;
use Types::Standard qw(HashRef Int Str);

has message_id    => (is => 'ro', isa => Str, required => 1);
has payload       => (is => 'ro', isa => HashRef, required => 1);
has attempt_count => (is => 'rw', isa => Int, default => sub { 0 });
has enqueued_at   => (is => 'ro', isa => Str, required => 1);

sub increment_attempt ($self) {
    $self->attempt_count( $self->attempt_count + 1 );
    return;
}

attempt_count をメッセージ側に持たせるのは——今まで何回失敗したかを、処理系ではなくメッセージ自身が知っているべきだからです」

「その通りだ」

次に隔離先を作る。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
package DeadLetterQueue;
use v5.36;
use Moo;
use Types::Standard qw(ArrayRef CodeRef);

has _store         => (is => 'rw', default => sub { [] });
has on_dead_letter => (is => 'ro', isa => CodeRef, default => sub { sub { } });

sub push_message ($self, $message, $reason) {
    push $self->_store->@*, {
        message    => $message,
        reason     => $reason,
        created_at => time(),
    };
    $self->on_dead_letter->($message, $reason);
    return;
}

sub all_messages ($self) { return $self->_store->@* }
sub count ($self)        { return scalar $self->_store->@* }

on_dead_letter は何ですか」

ロックさんは紙箱の蓋を指でたたいた。

「通報口だ。メッセージが隔離室に入った瞬間に呼ばれる。通知先は君が決める。Slackでも、監視基盤でも」

「——隔離しても、誰かが気づかなければ意味がない」

「ようやく先が見えてきたな、ワトソン君」

先が見えてきた、というより、怖くなってきた。もしこのDLQを設定せずに運用を続けていたら、826件だけでは済まなかった。隔離もなく、通知もなく、メッセージは音もなく消えていったはずだ。

最後に処理系を作る。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
package MessageProcessor;
use v5.36;
use Moo;
use Types::Standard qw(ArrayRef CodeRef Int InstanceOf);

has _queue            => (is => 'rw', default => sub { [] });
has handler           => (is => 'ro', isa => CodeRef, required => 1);
has dead_letter_queue => (is => 'ro', isa => InstanceOf['DeadLetterQueue'], required => 1);
has max_attempts      => (is => 'ro', isa => Int, default => sub { 3 });

sub enqueue ($self, $message) {
    push $self->_queue->@*, $message;
    return;
}

sub process_next ($self) {
    my $queue = $self->_queue;
    return 0 unless @$queue;

    my $msg = shift @$queue;
    $msg->increment_attempt;

    my $ok = eval { $self->handler->( $msg->payload ); 1 };
    if ($ok) {
        return 1;
    }

    my $err = $@;
    if ( $msg->attempt_count >= $self->max_attempts ) {
        $self->dead_letter_queue->push_message( $msg, $err );
        return 0;
    }

    push @$queue, $msg;    # max_attempts 未満ならリトライ
    return 0;
}

max_attempts を3にしたのは、根拠があるんですか」

「3は出発点だ。一時的なエラーが回復するのに十分な猶予が必要で、かつ非一時的なエラーが永遠に残らないために上限が必要だ。0は論外——それは隔離ではなく即廃棄になる」

「0に設定したら、一回でも失敗したら全部DLQに……」

「そういうことだ。一時的なネットワークエラーまで隔離することになる。要件によって2でも5でも変える。しかし、決して無制限にはしない」

「なぜDLQにメッセージが積まれたとき、自動で知らせる仕組みが必要なんですか。積まれたときにログを見ればいいんじゃないですか」

「今日の826件は、ログを見ていたから発覚したのか」

違う。問い合わせが来たから気づいた。

「……先に通知があれば、7時間前に対処できていた」

「だからon_dead_letterがある。DLQは隔離するだけでは不完全だ。隔離した事実を知らせる仕組みが要る。調べずに捨てれば、次の826件を防ぐ手がかりも一緒に捨てる」

826通が動き出す

スマートフォンが鳴った。開発チームから「SKUコードの処理修正が入りました」という連絡だった。

「——修正が入りました。DLQの826件、どうやって元に戻しますか」

ロックさんはredriveというメソッドのコードを示した。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
sub redrive ($self) {
    my @entries = $self->_store->@*;
    $self->_store( [] );

    my @messages;
    for my $entry (@entries) {
        my $msg = $entry->{message};
        $msg->attempt_count(0);
        push @messages, $msg;
    }

    return @messages;
}

attempt_countをゼロにリセットして返す。修復された証拠を、再審理のために送り返す——ということですね」

「そうだ。DLQは一方通行の廃棄場ではない。問題が修正された後に戻ってくる経路がある」

redrive を呼ぶと、826件のメッセージが attempt_count = 0 の状態で戻ってきた。それを一件ずつ enqueue に流した。

1
2
my @redriven = $dlq->redrive;
$processor->enqueue($_) for @redriven;

ラップトップの監視画面を開いた。キューのメッセージ件数が、少しずつ減り始めた。

826 → 801 → 774 → ……

「動いてます」

ロックさんは返事をしなかった。また糸くずを並べていた。

「……さっきの糸くず、何のためだったんですか」

「さあ」

「DLQの比喩、でしたか」

「こちらへ来たとき、君は扉のドアノブを握っては離すを3回繰り返した。私はその動作を3回見た。4回目は開けた」

「……それ、max_attemptsの実演ですか」

「どうだろうな」

数字が減り続けていた。399 → 352 → ……

「ありがとうございました」

ロックさんは、糸くずを一本だけ小さな紙箱に入れた。

「ワトソン君。最初のメッセージが隔離室に入ったのは、何時間前だ」

ログを確認した。「7時間14分前です」

「では君のボーナス申請は7時間14分後に受理されるとしよう。それまでにon_dead_letterのアラート先を監視基盤に繋いでおきたまえ。次の事件を7時間14分以内に検知できるように」

「……報酬の概念が違います」

返事はなかった。手はすでに設定ファイルを開いていた。


探偵の調査報告書

容疑(アンチパターン)真実(パターン)証拠(効果)
失敗時にメッセージをキューへ戻し続ける無限リトライDead Letter Queue による隔離非一時的エラーのメッセージがキューを占拠しなくなる
attempt_count を持たないメッセージQueueMessageattempt_count を持たせるどのメッセージが何回試みられたかを追跡できる
隔離後のメッセージを誰も知らない設計on_dead_letter アラートフックDLQ にメッセージが積まれた瞬間に通知が届く
DLQ からの回復経路がない(廃棄のみ)redriveattempt_count リセット後に再投入問題修正後、隔離メッセージを一括で再処理できる

推理のステップ

  1. キューの失敗が「一時的エラー」か「非一時的エラー」かを見極める——後者は何回リトライしても同じ結果になる
  2. QueueMessagemessage_idattempt_countenqueued_at を持たせる
  3. DeadLetterQueue に隔離ストアと on_dead_letter アラートフックを用意する
  4. MessageProcessorprocess_nextmax_attempts 超過時に DLQ へルーティングする
  5. on_dead_letter に通知処理を渡し、DLQ に積まれた瞬間にアラートを出す
  6. 問題修正後は redrive を呼び、attempt_count をリセットしたメッセージを元キューに返す

ロックより

リトライは一時的な傷に貼る絆創膏だ。骨折に絆創膏を貼り続けた結果が、今日の826件だった。

隔離とは見捨てることではない。後で読める形で保存することだ。失敗の理由とともに、調べられる場所へ移す。調べずに捨てれば、次の826件を防ぐ手がかりも一緒に捨てる。

DLQ を設定した今日から、君のキューは止まらない。止まるとしたら、それは次の事件の始まりだ。

comments powered by Disqus
Hugo で構築されています。
テーマ StackJimmy によって設計されています。