Symptom
warehouse-duckdb (the labordata DuckDB deployment) leaks ~5–13 MB/min of RSS and gets OOM-killed every ~2–3 hours (exit_code=137, oom_killed=true) on its 2 GB machine.
It's a native leak, not Python objects
Instrumented the live process (/proc + an in-process gc probe over 12 min):
- RSS climbed 301 → 451 MB, but tracked Python object count was flat (49,994 → 51,276, +1,282). So the growth is in native (C++) allocations gc can't see.
- Threads (10), FDs (74), and duckdb connections (56) were all flat — not a thread/FD/connection-count leak.
- The Python types that grew monotonically each sample were
Statement (~45/min) and the weakrefs (ReferenceType) tracking them — i.e. DuckDB prepared statements, each a thin wrapper pinning a large native allocation (~280 KB × ~540 ≈ the ~150 MB).
Root cause
DuckDBBackend.execute_query does cursor = conn.cursor() per query and never closes it — the finally only cancels the time-limit watchdog:
cursor = conn.cursor()
...
finally:
if timer is not None:
timer.cancel()
# cursor never closed
conn is a long-lived pooled read connection. DuckDB tracks child cursors on the parent connection, so each unclosed cursor's prepared statement + native result buffers stay alive for the process lifetime → unbounded native RSS growth. (stream_query's cursor was effectively cleaned because Database.execute_stream closes the whole dedicated connection, but execute_query reuses the pooled connection — so that path is the leak.)
It only manifests under sustained real traffic; synthetic loads (even on the exact prod image, all 14 dbs, 2 GB cap — repeated SQL, unique SQL, streams) plateau and don't reproduce it.
Fix
cursor.close() in execute_query's finally (rows are already materialised into raw before it, so it's safe), and defensively in stream_query's finally (closes on generator exhaustion or client-disconnect GeneratorExit). Smoke-tested: queries / CSV streaming / row pages unaffected.
Fixed in cc1705c6 (duckdb-deploy). Deploying to verify the live RSS slope flattens.
Symptom
warehouse-duckdb(the labordata DuckDB deployment) leaks ~5–13 MB/min of RSS and gets OOM-killed every ~2–3 hours (exit_code=137, oom_killed=true) on its 2 GB machine.It's a native leak, not Python objects
Instrumented the live process (
/proc+ an in-processgcprobe over 12 min):Statement(~45/min) and the weakrefs (ReferenceType) tracking them — i.e. DuckDB prepared statements, each a thin wrapper pinning a large native allocation (~280 KB × ~540 ≈ the ~150 MB).Root cause
DuckDBBackend.execute_querydoescursor = conn.cursor()per query and never closes it — thefinallyonly cancels the time-limit watchdog:connis a long-lived pooled read connection. DuckDB tracks child cursors on the parent connection, so each unclosed cursor's prepared statement + native result buffers stay alive for the process lifetime → unbounded native RSS growth. (stream_query's cursor was effectively cleaned becauseDatabase.execute_streamcloses the whole dedicated connection, butexecute_queryreuses the pooled connection — so that path is the leak.)It only manifests under sustained real traffic; synthetic loads (even on the exact prod image, all 14 dbs, 2 GB cap — repeated SQL, unique SQL, streams) plateau and don't reproduce it.
Fix
cursor.close()inexecute_query'sfinally(rows are already materialised intorawbefore it, so it's safe), and defensively instream_query'sfinally(closes on generator exhaustion or client-disconnectGeneratorExit). Smoke-tested: queries / CSV streaming / row pages unaffected.Fixed in cc1705c6 (duckdb-deploy). Deploying to verify the live RSS slope flattens.