@socket.io/redis-adapterのACK待ちリクエストMapは、ACKが来てもtimer発火まで解放されないが、timerはすぐ発火するので問題ない
node_modules/@socket.io/redis-adapter/dist/index.js:739-755(broadcastWithAck)では、各クラスタノードに ACK を待つリクエスト ID を保存し、ackRequests Map に登録しています。
code:node_modules/@socket.io/redis-adapter/dist/index.js
this.ackRequests.set(requestId, { clientCountCallback, ack });
setTimeout(() => {
this.ackRequests.delete(requestId);
}, opts.flags.timeout);
ところが ACK が正常にそろった場合でも、この setTimeout はクリアされません。結果として
1. ackRequests からはハンドラが削除されない
2. タイマーとクロージャ(callbacks への参照)が timeout 発火まで保持される
2. タイマーとクロージャ(callbacks への参照)が timeout 発火まで保持されるつまり、opts.flags.timeout(デフォルト 0 → setTimeout は即実行にはならず Node 側で保持)ごとに少なくとも 1 つのタイマーが積み上がり、常にクラスタ全体の ACK を待っている状態と同じメモリを食い続けます。クライアント数・イベント数に比例して増えるため、長時間稼働すると「HTTP リクエスト数とは無関係」に常に増加していく、という報告と合致します。
アプリ側への影響
src/server/sockets/index.js では Redis adapter を常に有効化しています。クラスタ構成で io.timeout(...).emit(...) や socket.timeout(...).emit(...) を使うと、ACK を取るたびに前述のタイマーが追加され、クリアされないままサーバー生存期間にわたりメモリを保持します。
ACK を使っていなくても、BroadcastOperator.emit() は adapter.broadcastWithAck() を呼び出す経路(期待応答数の取得)を持つため、io.emit() だけでもこのパスを通る場合があります。
方針案
1. 簡易対策:broadcastWithAck 内で ACK を受け取ったタイミング(this.ackRequests.set(...); 直後の callback)で clearTimeout() し、即座に ackRequests.delete(requestId) するようローカルパッチを当てる。
2. あるいは upstream(@socket.io/redis-adapter)へ issue/PR を送りバグ修正を取り込む。
3. 暫定的には ACK を伴う emit をクラスタでは使わない/timeout フラグを付けない、といった運用回避もあるが、根本解決にはならない。
これは修正不要shokai.icon
setTimeoutで解放されるのでメモリリークしていない
setTimeoutに渡しているopts.flags.timeoutとは何か?
io.timeout(1000).emit(...)と実行した場合、1000がopts.flags.timeoutになる
io.timeoutを指定しない場合undefinedになるが、setTimeout(func, undefined)は即時発火なので問題ない