Redisリスナーが最後のチャンネル解除で停止しないよう内部サブスクリプションを恒久化
Redis クライアントのリライトに伴い、サブスクリプション数が 0 になると listen ループが break し、@subscribed_client が nil になるというサイレント障害が発生していました。この状態では新たな subscribe がキューに溜まり続け、ブロードキャストが行われません。従来の redis-gem 実装では内部チャンネル _action_cable_internal に永続的に購読していたため、0 になることはなく安全でした。リライトでそのサブスクリプションが削除されたことが根本原因です。今回の PR はこの根本原因を御し、リスナーの死活を保証します。
背景
サブスクリプションが全て解除された際にリスナースレッドが死滅する のが本問題の核心です。redis-client のリライト (ef812c2652) により、listen ループはサブスクリプション数が 0 になると即座に break し、@subscribed_client を nil に設定します。結果として、プロセス内で最後のユーザーチャンネルがアンサブスクライブされるたびにリスナースレッドが停止し、以降の 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 への恒久的サブスクライブで防止しています。実装は定数と一行のサブスクライブ追加のみで、既存コードとの互換性を保ちつつ、低トラフィックやデプロイ時のシャットダウン以外でリスナーが死なない保証を提供します。