Server#each_connection のイテレーションをスナップショット配列へ変更
ActionCable の Server#each_connection が、ハートビートやサーバ再起動時に発生していた RuntimeError を回避するため、ライブハッシュではなく connections が返す配列スナップショットを走査するよう変更されました。これにより、接続の追加・削除が同時に行われても安全にイテレーションでき、呼び出し側のロジックはそのまま利用可能です。
背景
each_connection は従来、connections_map.each_value でハッシュを直接走査していましたが、ワーカースレッドが同時に add_connection/remove_connection を実行すると、Ruby のハッシュはイテレーション中のキー追加を検出して RuntimeError: can't add a new key into hash during iteration を送出します(PR #57691)。この例外は、3 秒間隔のハートビート (executor.post { each_connection(&:beat) })、サーバ再起動時の全接続クローズ (each_connection { |c| c.close(...) })、および接続統計取得 (each_connection.map(&:statistics)) の三つの主要呼び出しで顕在化しました。
この振る舞いは、以前は Array を使用していた実装からハッシュへ切り替えたコミット 8ed78693c5 に起因する回帰です。Array への要素追加はイテレーション中でも安全でしたが、ハッシュへのキー追加は Ruby の仕様上例外を投げるため、ハートビートが途中で中断し、古い接続がクリーンアップされずに残る、あるいはコードリロード時に再起動処理が失敗するといった実運用上の問題が発生していました。
技術的な変更
each_connection の実装を、ハッシュ走査から connections が返す配列走査へ置き換えました。connections メソッドは既に connections_map.values を返すため、イテレーション開始時にハッシュの内容を配列にコピーし、以降の走査はそのスナップショット上で行われます。これにより、走査中にハッシュが変更されても安全です。
@@
- connections_map.each_value(...)
+ # Iterate a snapshot: the heartbeat, #restart and statistics all walk the
+ # live connections while worker threads concurrently add/remove entries,
+ # and mutating a Hash mid-iteration raises a RuntimeError.
+ connections.each(...)
さらに、イテレーション中に接続が追加されても例外が起きないことを検証するテストが追加されました。テストは beat メソッド内で @server.add_connection(Object.new) を呼び出す擬似接続オブジェクトを作り、assert_nothing_raised で @server.each_connection(&:beat) が例外を送出しないことを確認します。
@@
- @server.each_connection(&:beat)
+ assert_nothing_raised do
+ @server.each_connection(&:beat)
+ end
この変更は each_connection の呼び出し側コードを変更せずに安全性を向上させ、既存の API 互換性を保ちます。
設計判断
イテレーション対象を snapshot 配列へ切り替える選択は、既存の connections ヘルパーを再利用し、追加のロックや専用イテレーションメソッドを導入しないことで実装コストとリスクを最小化した点が特徴です。コードベースへの侵入度を抑えることで、他コンポーネントへの副作用を防ぎながら安全性を確保しています。
代償としてイテレーション毎にハッシュの値配列が生成されますが、ActionCable の主要シナリオで求められる安全性と可読性の向上が、わずかなメモリ割り当て増加を上回ると判断された点が設計上のトレードオフです。
まとめ
Server#each_connection が connections のスナップショット配列を走査するようになったことで、ハートビート、サーバ再起動、統計取得の三つの重要シナリオが同時接続変更に対して耐性を持ちました。結果として RuntimeError が抑止され、接続のクリーンアップや再起動が確実に実行されるようになり、ActionCable の信頼性が向上します。