Old-generation growth from large-object births does not arm a full collection
A workload that repeatedly allocates and drops a large pointer-bearing object grows old-gen without bound. Every minor promotes essentially the whole nursery, copies nothing and frees nothing, and the full collection that would reclaim it never arrives.
records_array_wide_1m:parse — a flat object of 50 000 integer keys, parsed in a loop:
old_in_use=6578096 survival_permille=997 copied_objects=0 freed_bytes=0
old_in_use=28272432 survival_permille=997 copied_objects=0 freed_bytes=0
old_in_use=59873112 survival_permille=997 copied_objects=0 freed_bytes=0
old_in_use=112274032 survival_permille=997 copied_objects=0 freed_bytes=0
Peak RSS 228 MiB against Node's 121 and Bun's 95.
The same signature on records_array_16k:scan before #10098's lazy-array work, with the trigger state visible:
old_in_use=48385432 old_threshold=50331648 old_pending=false
[gc-arena-rebaseline] step=134217728 -> 268435456 -> 536870912 -> 1073741824
An old-gen threshold of 48 MB exists and old_in_use reaches 48.3 MB with old_pending=false; meanwhile the arena trigger rebaselines itself upward exponentially. So the arm that would notice old-gen filling keeps stepping out of the way of the arm that is filling it.
Why this is one cause, not two
Each object is individually reasonable. arena_alloc_gc puts a pointer-bearing object over LARGE_POINTER_BEARING_OBJECT_THRESHOLD_BYTES (128 KB) directly into non-moving old-gen, and the reasoning in its own comment is sound: for a pointer-bearing object the cost of tenuring "is not its own bytes, it is every object it can reach, held live through the remembered set by a container nothing refers to any more".
That reasoning assumes a full collection eventually runs. When it does not, the policy inverts: the objects chosen for old-gen because they are expensive to hold are exactly the ones nothing reclaims.
Two of these are now fixed by moving the allocation rather than the policy — #10098 made the lazy JSON array cluster nursery-resident and took records_array_16k:scan from 205 MiB to 51 MiB and records_array_1m:scan from 159 to 75, both now below Node and Bun. That works because a lazy cluster's cache is small enough to sit under the line. A 50 000-key object's field storage is ~400 KB and cannot.
What to look at
- Whether old-gen growth from large-object births feeds the full-collection trigger at all, or only growth observed through promotion.
- Whether the arena trigger's exponential rebaseline should be bounded while
old_in_use is climbing — the two arms currently move in opposite directions.
old_pending=false at old_in_use ≈ old_threshold is the specific observation to explain first.
Scope note
This is GC policy, which CLAUDE.md routes separately and warns about, so it wants its own change with the ratchet corpus run before and after rather than riding along with JSON work. Filing it with the evidence rather than attempting it inside a JSON performance pass.
Rows currently affected: wide_1m:parse RSS 228 vs 95 MiB (2.4x), and the residual records_array_8m:scan 187 vs 134 and records_array_20m:scan 256 vs 226, whose clusters are genuinely over the pointer-bearing line.
Refs #10098, #793.
Old-generation growth from large-object births does not arm a full collection
A workload that repeatedly allocates and drops a large pointer-bearing object grows old-gen without bound. Every minor promotes essentially the whole nursery, copies nothing and frees nothing, and the full collection that would reclaim it never arrives.
records_array_wide_1m:parse— a flat object of 50 000 integer keys, parsed in a loop:Peak RSS 228 MiB against Node's 121 and Bun's 95.
The same signature on
records_array_16k:scanbefore #10098's lazy-array work, with the trigger state visible:An old-gen threshold of 48 MB exists and
old_in_usereaches 48.3 MB withold_pending=false; meanwhile the arena trigger rebaselines itself upward exponentially. So the arm that would notice old-gen filling keeps stepping out of the way of the arm that is filling it.Why this is one cause, not two
Each object is individually reasonable.
arena_alloc_gcputs a pointer-bearing object overLARGE_POINTER_BEARING_OBJECT_THRESHOLD_BYTES(128 KB) directly into non-moving old-gen, and the reasoning in its own comment is sound: for a pointer-bearing object the cost of tenuring "is not its own bytes, it is every object it can reach, held live through the remembered set by a container nothing refers to any more".That reasoning assumes a full collection eventually runs. When it does not, the policy inverts: the objects chosen for old-gen because they are expensive to hold are exactly the ones nothing reclaims.
Two of these are now fixed by moving the allocation rather than the policy — #10098 made the lazy JSON array cluster nursery-resident and took
records_array_16k:scanfrom 205 MiB to 51 MiB andrecords_array_1m:scanfrom 159 to 75, both now below Node and Bun. That works because a lazy cluster's cache is small enough to sit under the line. A 50 000-key object's field storage is ~400 KB and cannot.What to look at
old_in_useis climbing — the two arms currently move in opposite directions.old_pending=falseatold_in_use≈old_thresholdis the specific observation to explain first.Scope note
This is GC policy, which CLAUDE.md routes separately and warns about, so it wants its own change with the ratchet corpus run before and after rather than riding along with JSON work. Filing it with the evidence rather than attempting it inside a JSON performance pass.
Rows currently affected:
wide_1m:parseRSS 228 vs 95 MiB (2.4x), and the residualrecords_array_8m:scan187 vs 134 andrecords_array_20m:scan256 vs 226, whose clusters are genuinely over the pointer-bearing line.Refs #10098, #793.