Skip to content

k8s workers: bound pre-session memory (explicit memory_limit, GOMEMLIMIT, threads at spawn)#895

Merged
benben merged 1 commit into
mainfrom
ben/worker-memory-hygiene
Jul 6, 2026
Merged

k8s workers: bound pre-session memory (explicit memory_limit, GOMEMLIMIT, threads at spawn)#895
benben merged 1 commit into
mainfrom
ben/worker-memory-hygiene

Conversation

@benben

@benben benben commented Jul 6, 2026

Copy link
Copy Markdown
Member

Why

Investigating worker OOMKills (64Gi workers dying mid-pg_catalog metadata query over a ~3k-table DuckLake catalog; 16Gi default workers dying reproducibly ~150 statements into a CREATE TABLE stream) surfaced a gap in the worker memory model:

  • Worker pods get no memory configuration at spawn. ConfigureMainDB falls back to sysinfo.AutoMemoryLimit() = 75% of the node's /proc/meminfo — so the whole pre-session window (DuckLake ATTACH, tenant activation, warmup, controlDB) runs with a memory_limit sized to the node, not the pod cgroup. The per-session SET memory_limit (75% of pod) only lands once a client session exists.
  • The Go runtime has no GOMEMLIMIT anywhere, and DuckDB's memory_limit doesn't govern the allocations that actually killed these pods (C++ catalog objects, postgres_scanner/libpq buffering full metadata result sets client-side) — so the implicit 25% headroom absorbs unbounded, non-spillable growth until the cgroup kills the pod.

This PR closes the first two gaps (pod-correct sizing from process start + a Go-side ceiling). The untracked catalog/scanner memory itself is upstream work (PostHog/ducklake#25).

What

spawnWorker stamps three env vars derived from the pod's resolved resource requests (workerMemoryHygieneEnv):

env value consumer
DUCKGRES_MEMORY_LIMIT 75% of pod memory — same value the per-session SET uses, via the shared duckdbMemoryLimitForPodMemory helper extracted from workerDuckDBLimits, so spawn-time and session-time sizing cannot disagree existing configresolve env surface → ConfigureMainDB
GOMEMLIMIT 1/8 of pod memory Go runtime (soft ceiling; GC pushes back before the cgroup OOM-kill)
DUCKGRES_THREADS pod CPU request (fractional cores round up) existing configresolve env surface

No change to session-time sizing or the one-session-per-worker contract.

Tests

  • TestWorkerMemoryHygieneEnv — table test over prod-like/dev/sub-GB/no-request shapes (incl. MB formatting and empty-requests fallback).
  • TestK8sPool_SpawnWorkerCreatesCorrectPod — extended with the three env assertions on the default pool shape.
  • e2e (assert_worker_pod in tests/e2e-mw-dev/harness.sh) — asserts the spawned worker pod carries all three, pinned to the e2e pool default (1536Mi → 1GB / 192MiB / 1).

Follow-ups (not this PR)

🤖 Generated with Claude Code

…MIT, THREADS at spawn)

Worker pods were spawned with no memory configuration, so the worker
process sized its base DuckDB off the NODE's /proc/meminfo
(sysinfo.AutoMemoryLimit = 75% of node RAM): all pre-session work —
DuckLake ATTACH, tenant activation, warmup, the controlDB side
connection — ran with a memory_limit potentially far above the pod's
cgroup limit, and the Go runtime had no ceiling at all. Combined with
memory the DuckDB buffer manager does not track (C++ catalog objects
for large DuckLake catalogs, postgres_scanner/libpq full-result
buffering of metadata reads), this OOM-killed workers that were
designed to run slow but never die (observed twice on 64Gi dev workers
during pg_catalog metadata queries over a ~3k-table catalog, and
reproducibly during CREATE TABLE streams on 16Gi default workers).

spawnWorker now stamps three env vars derived from the pod's resolved
resource requests, via workerMemoryHygieneEnv:

- DUCKGRES_MEMORY_LIMIT: the same 75%-of-pod value the per-session
  SET uses (shared helper duckdbMemoryLimitForPodMemory extracted from
  workerDuckDBLimits so the two cannot disagree), bounding the base DB
  from process start;
- GOMEMLIMIT: 1/8 of the pod, a soft ceiling so Go GC pushes back
  before the cgroup kill;
- DUCKGRES_THREADS: the pod's CPU request (fractional cores round up).

The worker consumes the first and third via the existing
configresolve env surface; GOMEMLIMIT is read by the Go runtime.

Tests: TestWorkerMemoryHygieneEnv (shapes incl. sub-GB formatting and
the no-requests fallback), extended
TestK8sPool_SpawnWorkerCreatesCorrectPod, and new assert_worker_pod
checks in tests/e2e-mw-dev/harness.sh pinned to the e2e pool default
(1536Mi -> 1GB / 192MiB / 1).
@github-actions

github-actions Bot commented Jul 6, 2026

Copy link
Copy Markdown

Test Impact Plan

Deterministic summary of how this PR changes tests, CI runners, and coverage-risk signals.

Summary

Area Added Changed Deleted
Test files 0 2 0
E2E/journey files 0 1 0
Workflow files 0 0 0

Signals

  • Test cases: +1 / -0
  • Assertions: +8 / -0
  • Skips or known failures added: 0
  • Workflow continue-on-error added: 0
  • Workflow path filters added: 0
  • Test commands removed from justfile: 0
  • E2E/journey retry lines added: 0

Coverage risk: needs review

Warnings

  • E2E or journey files changed (needs review)
    • tests/e2e-mw-dev/harness.sh

@benben benben merged commit 14309bc into main Jul 6, 2026
28 checks passed
@benben benben deleted the ben/worker-memory-hygiene branch July 6, 2026 15:05
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant