サブスクライブ直後のメッセージがスキップされるレースコンディションを修正

rails/solid_cable

@last_id の遅延初期化が引き起こすレースコンディションを修正し、サブスクライブ直後にブロードキャストされたメッセージが欠落する問題を解消しました。

背景

Solid Cable のリスナーには、サブスクライブとメッセージポーリングの間にブロードキャストが割り込むと、そのメッセージが永続的に失われるレースコンディションが存在していました。問題の根本は @last_id の遅延初期化にあります。

@last_id はリスナースレッドが初めて broadcast_messages を呼び出したタイミングで MAX(id) として評価されていました。サブスクライブ後、リスナースレッドが最初のポーリングサイクルに到達するまでの間にブロードキャストが発生すると、そのメッセージの idMAX(id) の評価結果に含まれてしまいます。その結果、後続のクエリ WHERE id > last_id がそのメッセージを対象外として除外し、メッセージが完全に失われます。

PRに示されている再現シナリオは以下のとおりです:

Main thread                           Listener thread
──────────                            ───────────────
subscribe()
  Listener.new → starts thread →      thread starts, enters listen loop
  add_channel:
    channels["test"] = MAX(id) = 5
    event_loop.post(on_success)
                                      interruptible { executor.run! }
on_success fires → subscribed.set
subscribed.wait returns
                                      ↑ still hasn't reached broadcast_messages
broadcast("hello") → inserts id=6
                                      broadcast_messages:
                                        @last_id ||= MAX(id) → 6  ← INCLUDES THE MESSAGE
                                        broadcastable(["test"], 6)
                                          → WHERE id > 6 → nothing returned!
                                        message 6 is SKIPPED

チャネルごとのカーソル(channels["test"] = 5)はこの問題を補えません。このカーソルはクエリ結果のループ内でセカンダリフィルタとして機能するにすぎず、WHERE id > last_id で既に除外されたメッセージには作用しないためです。

技術的な変更

@last_id の初期化をリスナーのコンストラクタに移動することで、ポーリング開始前に確定した値を保持するよう変更されました。

変更前:

attr_writer :last_id
attr_accessor :reconnect_attempt

def last_id
  @last_id ||= last_message_id
end

変更後:

def initialize(event_loop)
  # ...
  @reconnect_attempt = 0
  @last_id = last_message_id  # ← コンストラクタで即時評価

  @thread = Thread.new do
    # ...
  end
end

# private section
attr_accessor :last_id, :reconnect_attempt

||= による遅延評価を廃止し、initialize 内で last_message_id(= SolidCable::Message.maximum(:id) || 0)を直接代入します。attr_writer :last_id と遅延評価のラッパーメソッド last_id は不要になるため削除され、attr_accessor :last_id に一本化されています。

この変更に対応して、テスト側でも subscribe 直後の sleep が削除されました。この sleep はリスナースレッドが最初のポーリングサイクルを完了するまで待機させるためのものでしたが、コンストラクタで @last_id が確定するようになったことで不要になっています。

subscribed.wait(WAIT_WHEN_EXPECTING_EVENT)
# sleep WAIT_WHEN_EXPECTING_EVENT  ← 削除
assert_predicate subscribed, :set?

設計判断

コンストラクタでの DB 読み取りというトレードオフを受け入れ、正確性を優先する設計が選択されました。

PR著者自身が言及しているように、コンストラクタ内に DB 読み取りを置くことはやや異例です。ただし、Listener はサブスクライブ時に遅延初期化されるため起動時のオーバーヘッドにはならず、リスナースレッドおよび #subscribe の呼び出し元はいずれも DB への読み書きをすぐに行う前提であるため、接続は既に確立済みです。

代替案として @last_id の評価タイミングを subscribe 側で制御する方法も考えられますが、それではリスナースレッドの初期化とチャネル登録の間のウィンドウを塞ぐことができず、根本的な解決にはなりません。コンストラクタで評価することで、スレッドが起動する以前に last_id の基準点が確定し、その後のブロードキャストはいかなるタイミングであっても取りこぼされない保証が得られます。

まとめ

遅延評価から即時評価への一行の変更で、サブスクライブとポーリング開始の間に生じるレースコンディションが根本解決されました。テストから sleep が除去されたことは、この修正が競合状態を正しく排除できていることの直接的な証左です。

記事メタデータ

Generated by:
Claude Sonnet 4.6 for DiffDaily
LLM Trace:
0fc18edd

この記事はAIによって自動生成されています。内容の正確性については、必ずソースコードやPRを確認してください。

品質レビュー結果

Review Status:
承認済み
Review Count:
1回
Reviewed by:
Gemini 2.5 Pro for DiffDaily

Review Criteria:

記事構成 ✓ PASS

Title, Context, Technical Detailの存在と明確さ

「リード文(総論)→背景・技術的な変更・設計判断(各論)→まとめ(結論)」という3部構成が明確に適用されており、ガイドラインに完全に準拠しています。

カスタムMarkdown構文 ✓ PASS

シンタックスハイライト・GitHubリンク記法の正確性

ファイル名付きシンタックスハイライト(```ruby:filepath)とGitHubのPRリンク記法([#78](URL))が正しく使用されています。

対象読者への適合性 ✓ PASS

エンジニア向けの適切な技術レベルと表現

「レースコンディション」「リスナースレッド」「遅延初期化」といった専門用語を前提としており、対象読者であるエンジニアに適した技術レベルと表現です。

パラグラフ・ライティング ✓ PASS

トピックセンテンス・1段落1トピック・段落長

各セクションが総論から各論へと展開され、各段落がトピックセンテンスで始まるなど、パラグラフ・ライティングの原則が遵守されており、非常に高い可読性を実現しています。

Diff内容との照合 ✓ PASS

コードブロックとDiff内容の一致

記事で引用されているコード変更は、提供されたDiffの内容(コードの追加・削除箇所、ファイル名)と完全に一致しています。

技術用語の正確性 ✓ PASS

技術用語の正確な使用

「遅延初期化」「コンストラクタ」「ポーリング」などの技術用語が、PRの文脈に沿って正確かつ適切に使用されています。

説明の技術的正確性 ✓ PASS

技術的主張の正確性と論理性

レースコンディションの発生メカニズムと、`@last_id`の初期化タイミングを変更することによる解決策の説明は、技術的に正確で論理的です。

事実の突合 ✓ PASS

PR情報による主張の裏付け(ハルシネーション検出)

記事内の主張はすべてPRのDescriptionやDiffで裏付けられており、根拠のない推測や憶測(ハルシネーション)は一切含まれていません。

数値・固有名詞の確認 ✓ PASS

PR番号・コミットID・バージョン等の正確性

PR番号(#78)がフッター部分で正確に参照されています。

タイトル・説明との一致 ✓ PASS

記事タイトル・説明とPR内容の一致

記事のタイトルは、PRの主題である「メッセージがスキップされる問題の修正」を的確に表現しており、内容と完全に一致しています。

外部知識の正確性 ✓ PASS

PRに記載のない外部知識(LTS、サポート状況など)の不使用

PRに記載のないバージョン情報やリリース予定などの外部知識は含まれておらず、提供された情報のみに基づいて記述されています。

時間表現の正確性 ✓ PASS

時間表現がPR情報と一致しているか

「存在していました」「評価されていました」といった時間表現が、PRで説明されている変更前の挙動と正確に一致しています。