Repository navigation
Memory leak / OOM in codec decode path on large result sets (segfault on second 1k×20 query, first 10k×20) #34
Description
Summary
Decoding moderately-large query results on Perry-native segfaults reproducibly with no error output. The boundary is small enough to hit in a normal benchmark loop:
- A single 1000-row × 20-column query: works (~150 ms)
- A second 1000-row × 20-column query in the same process: segfault (exit 139)
- A single 10000-row × 20-column query: segfault (exit 139)
Tiny queries (SELECT 1, single-row extended-protocol) can be looped 50+ times without issue. The crash is purely a function of how much wrapper-object allocation happens in one (or two) calls.
Repro
In @perry/postgres driver, a benchmark loop:
const sql = 'SELECT (n*1)::int8, (\'row-\'||n||\'-c\')::text, ' +
'(n::numeric/2)::numeric(20,5), (n%2=0)::bool ' +
'FROM generate_series(1, 1000) n';
for (let i = 0; i < 2; i++) {
const r = await conn.query(sql); // ← second call segfaults
console.log('iter ' + i + ': rows=' + r.rows.length);
}Output:
iter 0: rows=1000
[ exit 139 ]
The same query as a single call works. The single-call 10k version crashes immediately on the first attempt.
Likely cause
Each row-decode allocates wrapper objects for some types (Decimal for numeric, PgDate/PgTimestamp/etc. for date/time, plus the per-row Record<string, unknown> and the parallel unknown[]). For 1000 rows × 20 columns that's ≥ 20k allocations; for 10k rows it's 200k. The bigint fix from #33 added new BigInt allocations to the int8 decode path too. Suspect either:
- The arena/bump allocator never frees between queries — each subsequent
query()keeps growing. - The young-gen GC isn't running during the synchronous decode loop, so transient allocations stay live until something explicit triggers a collection.
- A specific codec (Decimal? PgDate?) leaks a heap reference per cell that holds the rest of the row alive.
Either way the working-memory cost grows linearly with cells decoded across the process lifetime, not per-query.
Why this matters
- Database drivers cycle through queries continuously — a 1-call ceiling per result-set-size makes the AOT target unusable for any real workload.
- The failure mode is silent (no stack, no `Error`, just exit 139). Hard to diagnose without instrumenting the call site.
- The boundary is small: 20k cells already fails. Real reporting / dashboard queries are routinely 100k+ cells.
Suggested investigation
- Run a release build of the smoke under a memory profiler (or just
MallocStackLogging=1on macOS) on the second call — see what arena/heap is growing. - Check whether
js_object_allocresults from one query are reachable from any global / module-level pin after the query promise resolves. (E.g. is the per-columnkeys_arrayaccidentally rooted?) - Check whether the bigint pool from the BigInt arithmetic and BigInt(string) constructor are unsupported #33 fix releases entries when the wrapping
bigintvalue goes out of scope.
Environment
- Perry 0.5.24 (after Module-level state is duplicated per importer (breaks plugin / type registry / DI patterns) #32 + BigInt arithmetic and BigInt(string) constructor are unsupported #33 fixes)
- macOS 26 / Apple Silicon
- Repro project:
@perry/postgresdriver —bench/bench-this.tstriggers it on themedium-1k-x-20workload's second iteration;examples/perry-smoke-bulk.tsdoes NOT (it only runs 2 iterations of medium and exits before the leak compounds).
Activity
Benchmark suite: @perry/postgres on Node/Bun/Perry vs pg + postgres.js
bench: tokio-postgres (Rust) added to the comparison
fix: trigger GC from gc_malloc to bound malloc-heavy workloads (closes …
fix(transform): #34 — `return yield*` delegation drives the iterator …