Redisリスナーが最後のチャンネル解除で停止しないよう内部サブスクリプションを恒久化

rails/rails

Redis クライアントのリライトに伴い、サブスクリプション数が 0 になると listen ループが break し、@subscribed_clientnil になるというサイレント障害が発生していました。この状態では新たな subscribe がキューに溜まり続け、ブロードキャストが行われません。従来の redis-gem 実装では内部チャンネル _action_cable_internal に永続的に購読していたため、0 になることはなく安全でした。リライトでそのサブスクリプションが削除されたことが根本原因です。今回の PR はこの根本原因を御し、リスナーの死活を保証します。

背景

サブスクリプションが全て解除された際にリスナースレッドが死滅する のが本問題の核心です。redis-client のリライト (ef812c2652) により、listen ループはサブスクリプション数が 0 になると即座に break し、@subscribed_clientnil に設定します。結果として、プロセス内で最後のユーザーチャンネルがアンサブスクライブされるたびにリスナースレッドが停止し、以降の subscribe@when_connected キューに溜まり続けるだけでブロードキャストが無視されます。以前の実装は _action_cable_internal という内部チャンネルを常時購読していたため、明示的なシャットダウン時以外はサブスクリプション数が 0 になることはありませんでした。この差異が低トラフィック環境やデプロイ時のノード排出で障害を顕在化させました。

技術的な変更

内部チャンネルへの恒久的サブスクライブを復元 することで問題を解消します。Listener クラスに INTERNAL_CHANNEL = "_action_cable_internal" 定数を追加し、listen メソッドで @subscribed_client が設定された直後に pubsub_client.call("subscribe", INTERNAL_CHANNEL) を呼び出すよう変更しました。これによりユーザー側のチャンネルが全て解除されても、内部チャンネルが残りサブスクリプション数は 1 のまま維持され、listen ループは継続します。

変更前後のコード比較 は以下の通りです。

@@
- def listen(conn)
-   @reconnect_attempt = 0
-   @subscribed_client = pubsub_client
+ def listen(conn)
+   @reconnect_attempt = 0
+   @subscribed_client = pubsub_client
+   pubsub_client.call("subscribe", INTERNAL_CHANNEL)
@@
-          INTERNAL_CHANNEL = "_action_cable_internal"
+          # A permanent internal subscription keeps the Redis subscription count
+          # above zero while any user channel comes and goes, so removing the
+          # last user channel doesn't end the listen loop. Only an explicit
+          # shutdown (which unsubscribes from everything) drives the count to
+          # zero and stops the listener.
+          INTERNAL_CHANNEL = "_action_cable_internal"

テストケースも追加 され、最初のチャンネル解除後にリスナーが生き続けることを検証しています。test_resubscribe_after_removing_the_last_channel は二度目の subscribe_as_queue 呼び出しで再びブロードキャストが受信できることを確認し、回帰テストとして全アダプタースイートに組み込まれました。

設計判断

内部サブスクリプションを sentinel として利用 する選択は、既存の API 変更を最小限に抑える設計です。代替案として新たな設定キーやオプションを導入することも考えられましたが、_action_cable_internal は実際にメッセージを受信しないため副作用がなく、SubscriberMap にエントリを持たない点で安全です。定数と即時サブスクライブの追加だけで動作を保証でき、既存の redis-gem 挙動を忠実に再現しています。結果として、既存アプリケーションへの影響はなく、内部的な安定性のみが向上しました。

まとめ

この PR は、Redis のサブスクリプションカウントが 0 になることでリスナースレッドが停止し、ブロードキャストが失われる問題を、内部チャンネル _action_cable_internal への恒久的サブスクライブで防止しています。実装は定数と一行のサブスクライブ追加のみで、既存コードとの互換性を保ちつつ、低トラフィックやデプロイ時のシャットダウン以外でリスナーが死なない保証を提供します。

記事メタデータ

Generated by:
gpt-oss-120b for DiffDaily
LLM Trace:
595b5681

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

品質レビュー結果

Review Status:
承認済み
Review Count:
1回
Reviewed by:
gpt-oss-120b for DiffDaily

Review Criteria:

記事構成 ✓ PASS

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

リード文、背景、技術的な変更、設計判断、まとめの5つの要素が揃っており、総論→各論→結論の流れが明確です。

カスタムMarkdown構文 ⚠ WARNING

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

コードブロックのファイル名付きシンタックスハイライトは正しい形式ですが、PRリンクが `[PR #57690]` となっており、推奨の `[#57690]` 形式ではありません。

対象読者への適合性 ✓ PASS

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

専門的な用語や実装詳細に焦点が当たり、エンジニア向けとして適切です。初心者向けの余計な説明はありません。

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

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

各セクションは総論・各論・結論の段落構成になっており、トピックセンテンスが冒頭に配置されています。段落は適度な長さで空行で区切られています。

Diff内容との照合 ⚠ WARNING

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

`listen` メソッドへの `pubsub_client.call` 追加はDiffと一致していますが、`INTERNAL_CHANNEL` 定数の差分が「削除→追加」になっており、実際のDiffでは追加のみです。表現をDiffに合わせる必要があります。

技術用語の正確性 ✓ PASS

技術用語の正確な使用

用語はPRで使用されているものと一致しており、誤用はありません。

説明の技術的正確性 ✓ PASS

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

技術的な説明はPRの記述と合致しており、因果関係も正しく述べられています。

事実の突合 ✓ PASS

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

すべての主張はPR本文やDiffで裏付けられており、推測や捏造はありません。

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

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

PR番号やファイルパス、コード行の内容は正確です。数値的な記述はありません。

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

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

記事タイトルはPRタイトルの意味を日本語で適切に置き換えており、一致しています。

外部知識の正確性 ✓ PASS

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

バージョン情報やリリーススケジュール等、PRに記載されていない外部知識は含まれていません。

時間表現の正確性 ✓ PASS

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

時間表現の歪曲はなく、PRの記述と整合しています。