製品
ソリューション
会社情報
エンタープライズ
サインインネットワークを作成
Node.js · Streams · Backpressure · Mechanisms · Theorem 3

バックプレッシャーの真偽値こそが機構であり、ストリームではない

Node.js のストリームは上限付きメモリを保証しない。.write() の真偽値が機構であり、それを読むことがチームが省略する測定である。Theorem 3 が直接当てはまる。

バックプレッシャーの真偽値こそが機構であり、ストリームそのものではない

Node.js のストリームはバックプレッシャーのシグナル、つまり .write() の真偽値の戻り値を備えているが、本番コードのほとんどはそれを一度も読まない。Node.js のストリームのメモリリークに関する Master.dev の記事は、その帰結をたどる。ある transform がすべての行で .write() を呼び出し、減速を求める false を無視したせいで、ポッドが 3.8GB まで上昇して OOM で kill される(Master.dev, "Your Node.js Streams Aren't Backpressuring. They're Silently Eating Your Memory.", 2026)。これはランタイムのバグではない。正しい挙動を、プロパティを成立させる唯一のシグナルを一度も測らなかったコードが実行しただけだ。

これがすべての物語であり、Everythink で私たちが繰り返し語っている物語でもある。あるプロパティは、その機構が実装されかつ測定されているときに限り保証される。バックプレッシャーの真偽値が機構である。それを決して検査しない for await ループが、欠落した測定である。上限付きメモリはストリームの機能ではない。誰かが尊重しなければならないプロトコルから立ち上がるプロパティである。

シグナルは存在する。誰も読まない。

Master.dev の記事は、このリークを二つのメンタルモデルの隙間として位置づける。チュートリアル版(「ストリームはデータをチャンクごとに処理するので、ファイル全体をメモリに載せることはない」)と運用上の現実(「ストリームは自分を守る道具を与えるだけで、守ってはくれない」)である。著者は、ランタイムが何をし何をしないかについて正確だ。生産者がバックプレッシャーを無視しても、Node.js は例外を投げず、一時停止せず、ストリームを殺さない。データを受け続け、ヒープを割り当て続け、V8 が空間を使い果たすまで進み続ける。

これは抽象を経てストリームにたどり着いたエンジニアを驚かせる部分である。抽象は一つの保証、「ファイル全体をメモリに載せることはない」をうたいながら、それを維持する作業を、API が読むことを強制しない一つの真偽値に黙って押し付ける。highWaterMark は上限ではない。.write()false を返す閾値である。例外も、自動の一時停止も、ブレーカーもない。記事が修正として示す四行、戻り値を検査し false なら await once(writable, "drain") する、それがパターンのすべてであり、その不在がリークのすべてである。

[PERSONAL EXPERIENCE] 過去二年間に、私は三つの異なるコードベースでこの全く同じバグを読んだ。三つとも、著者はきれいな for await...of ループを書き、コードレビューを通し、本番に出荷していた。構文がモダンだったのでコードは正しく見えた。リークはどの行にもなかった。二つの行の隙間にあった。false を返した .write() と、決して待たなかった次の反復の間である。

Theorem 3、一つの真偽値に適用する

Everythink は the 21 papers を通じて一つの形式的な主張を運ぶ。そしてそれこそ、このリークがもっとも鮮やかに示す主張である。あるプロパティは、その機構が実装されかつ測定されているときに限り保証される。「かつ測定されている」が支える半分である。紙の上には存在するがループ内で一度も観測されない機構は、保証の目的では不在である。

バックプレッシャーは典型である。機構は Node.js コアに存在する。.write() は真偽値を返し、drain が発火し、writableNeedDrain が反転する。プロトコルのすべての部品が実装されている。欠けているのは測定、真偽値を読んで待つことを決める分岐である。その分岐がなければ、「ストリーミング負荷下の上限付きメモリ」というプロパティは保証されない。コンテナが許容できる量を負荷が超えるまで、単に耐えられているだけである。

だから私たちは、機構を名指ししても測定を名指ししない能力の主張をすべて疑う。「ストリームがある」は上限付きメモリについての主張ではない。「すべての書き込み経路で真偽値を検査し drain を待つ」がそうである。HAI Engine ✅ はまさにこの規律の上で 2016 年から本番で動いている。どの Sister が想像し、どの Oracle が統合し、foresight がどの room に着地するかを決めるルーティング層は、測定された機構であり、アーキテクチャ図ではない。The space is the router。network → community → room であり、ルーティングは何かが応答する前に起き、各ホップにバックプレッシャー型の規律がある。

Node.js 22 の乗数と、デフォルトが安全ではない理由

Master.dev の記事は、静かなリークを速めた変更を指し示す。Node.js 22 で、デフォルトの highWaterMark が 16KB から 64KB に引き上げられた(Robert Nagy による PR #52037)。この変更は擁護できる。コンテキストスイッチが減り、大きなペイロードでスループットが向上する。同時に、最初のバックプレッシャーのシグナルが発火する前に蓄積するバッファを 4 倍にする変更でもある。256MB や 512MB のコンテナでは、この乗数が、ガベージコレクタがほぼ追いつくゆっくりした上昇と追いつかない急上昇の差になる。

[UNIQUE INSIGHT] スループットを改善するデフォルトは、あなたに知らせることなく有限の資源、あなたのコンテナのメモリを静かに再配分している。happy path を速くする同じリリースが、unhappy path をより早く墜落させる。「あなたの OOM の閾値は今 4 倍近づいた」と書いたリリースノートはない。ランタイムはあなたのコンテナの上限を知らないからだ。運用者は知っている。これは一般的なパターンであって、Node.js の特異点ではない。あなたの代わりにバッファする抽象はすべて、見えない資源を費やしている。

記事の修復は、大雑把な道具、setDefaultHighWaterMark(false, 16 * 1024) で全体を戻すことと、外科的な道具、重要なストリームに highWaterMark を設定することの二つである。どちらも正しい。どちらも要点ではない。要点は、デフォルトが動き、ほとんど誰もリリースノートを自分のコンテナ予算と照らし合わせて読まず、常にそこにあったリークが誰かに気づかれる前に 4 倍の成長空間を得たことである。デフォルトは安全ではない。測定された機構が安全である。

objectMode と二面の Transform

Master.dev の記事の中に、強調に値する二つのひだがある。開発者の残る直感を壊すからである。

第一に、objectMode のストリームはバイトを数えない。オブジェクトを数える。highWaterMark が 16 なら 16 個のオブジェクトがバッファされることを意味し、各オブジェクトが 50KB の結合された JSON 行なら、「16」というラベルは最初のシグナルが発火する前にストリームあたり 800KB を担っている。ダイヤルの数字はヒープの数字ではない。

第二に、Transform ストリームは二つの独立した highWaterMark を持つ。書き込み側と読み取り側で、それらは一致しなくてよい。Transform は読み取り側でバックプレッシャーを完全に尊重しながら(HTTP レスポンスが drain するのを待ちながら)、書き込み側では独自の内部オブジェクトキューが限界に達していないために盲目にデータを受け入れることがある。記事はこれを「圧力を吸収するために膨らみ、自分のバッファが爆発するまで問題を隠すアコーディオン」と呼ぶ。修正は、非対称の上限を明示的に設定することである。下流のバッファが満ちた瞬間にバックプレッシャーを上流へ押し返すため、小さな書き込み側の highWaterMark を使う。

これこそ Theorem 3 が繰り返し教える同じ教訓である。半分だけ配線された機構は半分不在である。片側だけでバックプレッシャーを尊重する Transform はプロトコルの半分しか持たない。プロパティ、エンドツーエンドの上限付きメモリは、データがシステム内を通るすべての経路で両方の半分が測定されることを要求する。

pipe() は構文であり、pipeline() は機構である

.pipe()pipeline() に関する記事の章は、流暢な抽象と本当の機構の差を最も澄んで述べたものである。.pipe() はエラーを伝播しない。パイプチェーンの途中の transform が例外を投げると、ソースは読み続け、あて先は開いたままになり、ファイル記述子がリークし、ソケットが吊り下がり、何かが壊れた表示は一切ない。連鎖構文、readStream.pipe(transformStream).pipe(writeStream) は Unix パイプのように読め、美的な構文の背後に致命的な欠陥を隠す。

node:stream/promisespipeline() は、任意の失敗でチェーン内のすべてのストリームを破棄し、エラーを拒否された promise として伝播する。五年以上にわたる標準である。記事の規則は鋭い。.pipe() チェーンが三つ以上のストリームを持つか、任意のストリームがエラーを投げうるなら、あなたは pipeline() が無料で取り除くリスクを背負っている。

これは私たち自身のスタックで適用するより広い習慣に対応する。リポジトリの不変条件は明示的である。永続化は常にポート(トレイト)を経由して到達し、具体的な Pg* アダプタを経由しない。AppState のリポジトリは Arc<dyn Trait> であり、テストがモックを差し込める。Sisters は決して Postgres に書かず、SisterOutput を返し、Loom が永続化する。それぞれは pipeline() 型の規則である。失敗モードを構造的に到達不能にする機構であり、開発者に思い出を頼む慣習ではない。私たちは開発者がファイル記述子の片付けを思い出すことに頼らない。型システムに、永続化する唯一の道が片付けを扱うポートを通ることを強要させる。

async/await は読み取りを刻み、書き込みは刻まない

記事で最も危険な、と私が思うパターンは、最もモダンに見えるものである。

for await (const chunk of readable) {
  writable.write(chunk);
}

非同期イテレータは読み取りの速さを制御する。書き込みの速さについては何もしない。writable.write()false を返しても、ループは一時停止しない。次のチャンクをつかみ、すでに満杯のバッファに押し込む。修正は同じ四行である。真偽値を検査し、false なら await once(writable, "drain")。その単一の await は二つのことをする。ループを一時停止し、イテレータを一時停止し、readable がデータを引くのを止める。そして実行をイベントループに譲り、それが I/O コールバックが発火してやがて drain を emit することを可能にする。この譲渡がなければ、for ループはティックを独占し、drain は決して発火しない。

記事の要約行は正確である。「Promises manage when your code runs. Backpressure manages how much data accumulates. async/await only solves one of them.」Theorem 3 の系を付け加えよう。プロトコルの半分を刻むモダンな構文は、半分だけ測定された機構である。残りの半分は依然として手で配線されなければならず、モダンな構文は配線が欠けていることを忘れやすくする。

一時停止の隠れたコスト:接続の飢餓

記事の最後の動きは、ほとんどの「バックプレッシャーは解決済み」の記事が飛ばすものである。真偽値を尊重し drain を待つと、メモリは平らになり、圧力は上流のデータベース接続プールへ移動する。一時停止した Node.js ストリームはデータベースカーソルを開いたままにする。下流のクライアントが不安定な 3G にいて、drain に五分かかれば、プール内のワーカーが五分縛られる。20 のプールは 20 の遅い大規模エクスポートで飽和する。メモリは平らである。アプリケーションは新しい要求に応じるのをやめる。ヘルスチェックが失敗する。ロードバランサーが他のポッドへトラフィックを振り分け、それらは自身のプール限界に達する。連鎖的障害はメモリとは無関係で、あなたが守るのを忘れた有限の資源とすべて関係している。

記事が提示する修復は、コードの変更ではなく三つのアーキテクチャ上の動きである。厳格なクエリタイムアウト、重型エクスポート用の専用ワーカープール、署名付き URL でオブジェクトストレージへキュー経由でオフロードすること。要点は、局所の機構(真偽値を検査する)を直すと、スタックの上流の次の機構(プール)が露出し、それもまた測定され上限を付けられなければならないということである。バックプレッシャーは局所のプロパティではない。それは一つの鎖であり、各輪に独自の計器がある。

これが Everythink がスタック全体で適用する規律である。World Monitor ✅(Atlas)ゲートウェイは、固定スケジュール上の制限付きポーラー、ソースごとのトークンバケット予算で、上流の各 geo フィードをルーティングし、GeoSignal に正規化し、耐久性のある Postgres キャッシュにアップサートする。クライアントはキャッシュを読み、上流は決して読まない。上流の呼び出し量は私たちのスケジュールで制限され、クライアント数ではない。同じ形である。有限の資源(上流 API 予算)が測定された機構(ポーラーのスケジュールと予算)で守られているのであって、クライアントが礼儀正しいという望みで守られているのではない。Sisters → Oracle ✅ の経路もまた同じ形である。各 Sister の imagine() の出力は Loom が強制するプロトコルで上限付き。Oracle の merge() はただ一箇所で確率を正規化し、消費者が sum(probability) ≈ 1.0 に依拠できるようにする。機構、測定、一箇所で。

[ORIGINAL DATA] 私たちが審査した、ストリーミングエクスポートを含むすべてのインシデント事後分析において、根本原因が「ストリームを持っていなかった」ことは一度もなかった。「真偽値を読む分岐が書き込み経路上にない」であった。機構は存在した。測定が欠けていた。それが、80MB で稼働するサービスと 3.8GB まで上昇して kill されるサービスとの差のすべてである。

主要な要点

  • ストリームは上限付きメモリの保証ではない。 それは消費者が読まなければならないシグナル、.write() の真偽値を持つ協調プロトコルである。Master.dev の記事は本番の OOM kill を、それを一度も読まなかったコードにまで遡る。
  • Theorem 3 は直接当てはまる。 プロパティは、その機構が実装されかつ測定されているときに限り保証される。バックプレッシャーの機構は Node.js コアに実装されている。測定、真偽値を検査する分岐こそ、チームが省略する部分である。測定がなければ、プロパティは保証されず、許容される。
  • デフォルトはあなたに知らせずに動く。 Node.js 22 の highWaterMark の 4 倍化はスループットの勝利であり、より静かでより速いリークである。あなたの代わりにバッファするデフォルトは、見えない資源(あなたのコンテナのメモリ)を費やす。
  • プロトコルの半分は半分不在。 片側だけでバックプレッシャーを尊重する Transform や、読み取りを刻むが書き込みを刻まない async ループは、半分だけ配線された機構である。プロパティは両方の半分が測定されることを要求する。
  • 局所のリークを直すと次が露出する。 バックプレッシャーを尊重すると圧力は上流の接続プールへ動く。バックプレッシャーは鎖である。各輪は独自の計器を必要とする。記事の三つの修復(タイムアウト、専用プール、キューオフロード)はアーキテクチャ的であって、構文的ではない。

よくある質問

.pipe() を使えば Node.js が自動的にバックプレッシャーを扱ってくれるのでは? 単純な二つのストリームのパイプでは、ほぼそうである。writable のバッファが満ちると .pipe() は readable を一時停止する。Master.dev の記事の要点は、transform、ネットワークソケット、任意のエラー経路を一つでも加えた瞬間に .pipe() はエラーの伝播をやめ、ファイル記述子のリークを始めることである。node:stream/promisespipeline() を使いなさい。五年以上の標準である。

highWaterMark はメモリの上限ですか? いいえ。それは助言的な閾値である。バッファがそこに達すると .write()false を返す。例外も、自動の一時停止も、ブレーカーもない。false を無視すれば、Node.js はプロセスが死ぬまでバッファし続ける。objectMode では、その数はバイトでなくオブジェクトを数える。16 は 800KB の結合された JSON になりうる。

for await...of を使えば安全ですか? 読み取り側のみ。非同期イテレータは readable から引く速さを刻む。書き込みの速さについては何もしない。.write() の戻り値を検査し、false のとき await once(writable, "drain") する必要は残る。記事の行、「Promises manage when your code runs. Backpressure manages how much data accumulates.」

これが予測インフラと何の関係があるのか? 同じ規律である。プロパティ、上限付きメモリ、較正された確率、隔離されたルーティングは、その機構が実装され測定されたときに限り保証される。Everythink では、「the space is the router」は network → community → room のトポロジーが何かが応答する前にルーティングし、各ホップに独自の計器があることを意味する。HAI Engine は 2016 年からこの規律で稼働している。

接続の飢餓は本当にメモリリークと別物ですか? はい。そして記事はそのトレードに正直である。バックプレッシャーを尊重するとメモリは平らになり、圧力はデータベースプールへ動く。20 のプールは 20 の遅いエクスポートで飽和する。修復はアーキテクチャ的である。タイムアウト、専用プール、キューオフロード。ストリーム内のコード変更ではない。

the 21 papers を読もう。このシリーズは Theorem 3 と、このリークが示す測定された機構の規律を形式化する。

Sources

関連

自らの主張を証明するエンジンの上に、あなたの世界を築く。

2016 年から稼働し続けるエンジンの上に、あなた自身のネットワークを作る——あるいは 21 本の論文を書いたチームに話しかける。