ActionCable の未登録チャンネル削除時に UNLISTEN を送信しないように修正
ActionCable の内部で使用されている SubscriberMap#remove_subscriber が、存在しないチャネルに対しても remove_channel を呼び出し、Redis へ不要な UNLISTEN を送信していた問題を、キーの有無を確認して早期リターンするだけのシンプルな修正で解消しました。
背景
このセクションでは、問題が発生した具体的な状況とその影響を整理します。
SubscriberMap の @subscribers は Hash.new { |h, k| h[k] = [] } というデフォルトプロックで構成されており、remove_subscriber が @subscribers[channel] へキー確認なしでアクセスすると、存在しないチャネルでも空配列が自動生成(autovivification)されます。その結果、空配列が削除対象となり remove_channel が実行され、実際にはリスンされていないチャネルに対して UNLISTEN が送られるというスパリウスな動作が起こっていました。
さらに、非同期アダプター SubscriberMap::Async では add_subscriber と remove_subscriber が別タスクとしてキューイングされるため、購読登録が完了する前に解除が走ると、同様に未登録チャネルが自動生成されてしまうケースがありました。
この問題は、同様のガードロジックを持つ broadcast メソッドや add_subscriber とは対照的に、唯一 remove_subscriber だけがキーの有無をチェックしていなかったことが根本原因です。修正により、無駄なサーバー呼び出しを防ぎ、ActionCable のリソース管理を正確に保ちます。
技術的な変更
このセクションでは、実装上の具体的な変更点とテスト追加について解説します。
remove_subscriber に対し、キーが存在しない場合は即座にリターン する guard を挿入しました。これにより、@subscribers[channel] へのアクセスが回避され、autovivification が防止されます。実装は broadcast が既に採用している @subscribers.key?(channel) チェックと同一です。
@@
- @subscribers[channel].delete(subscriber)
+ return if !@subscribers.key?(channel)
+ return unless @subscribers[channel].delete(subscriber)
上記変更は、呼び出し側が返り値を利用しない前提(redis、postgresql、inline すべて同様)であるため、既存のフローに影響を与えません。また、remove_subscriber が空になったチャネルを削除し remove_channel を呼び出すロジックはそのまま保持されています。
テスト面では SubscriberMapTest に2つのケースを追加し、未登録チャネル と 既存チャネルだが購読者が不在 の状況で remove_channel が呼び出されないことを明示的に検証しています。テストはすべて成功し、回帰リスクが低減されたことが確認できます。
設計判断
このセクションでは、変更に込められた設計上の意図とトレードオフを考察します。
修正は 最小侵入 を原則とし、既存の公開インターフェースや動作仕様を変えないようにしました。新たにキー確認を追加するだけで、ロジック全体の複雑性は増加せず、既存コードベースへの影響範囲も限定的です。
return の値は呼び出し側で使用されていないため、早期リターン の選択は副作用の防止に特化したシンプルな手段となります。これにより、add_subscriber と broadcast と同様のガードパターンが統一され、コードの可読性と保守性が向上しました。
結果として、ActionCable の内部で発生していた不要な UNLISTEN が排除され、Redis など外部サービスへの無駄なネットワーク呼び出しが減少します。今後も同様の guard パターンを他のメソッドへ拡張する際の指針として活用できるでしょう。