Skip to content

feat(memory): dest-connection age sweep + leak-2 probes - #84

Merged
jghoman merged 5 commits into
mainfrom
jakob/leak2-dest-sweep
Sep 2, 2026
Merged

feat(memory): dest-connection age sweep + leak-2 probes#84
jghoman merged 5 commits into
mainfrom
jakob/leak2-dest-sweep

Conversation

@jghoman

@jghoman jghoman commented Sep 2, 2026

Copy link
Copy Markdown
Collaborator

What this is

The dest-pool age sweep — the first intervention that measurably moves Leak-2 (persistent_oom.md). Accumulation lives in destination DuckDB instances and is freed at connection close; closing pooled connections past an age is the mitigation.

What's in it

Piece File Note
memory.dest_conn_max_age_seconds (default 600) config.py 0 disables; <60 rejected (connect-storm guard)
DestinationPool.evict_older_than() destination.py budget-capped (2/cycle), skips pinned, actual-eviction count
Sweep call site main.py poll-thread, per cycle, before dispatch
viaduck_dest_conn_sweeps_total metrics.py sweep firing is a metric, not just a log line
SIGUSR1 tracemalloc dump main.py flag-only handler (no logging in-signal); opt-in via VIADUCK_TRACEMALLOC=1 or PYTHONTRACEMALLOC=25
Arrow pool gauges metrics.py the channel that caught the mimalloc differential
Source-conn recycle probe (C3) main.py kept — the falsified experiment is part of the record
VIADUCK_CDC_SPLIT_READ split-read source.py workaround for the upstream ducklake_table_changes executor stall (wedge-table-changes.md, issue forthcoming)
create_table_if_not_exists retry fix destination.py was or, retried every exception into a bare assert; now and, real error surfaces
Tests tests/unit/ sweep + create-retry + config validation; 1066 pass; ruff clean

What it is not

  • Not the root cause. The 60s-sweep falsifier (running now) shows the slope doesn't zero — the dest pool is part of it, not all of it. Comments in the patch say exactly what was measured and no more.
  • Not a watermark replacement yet. self_recycle_enabled stays default-on; flipping it is a follow-up once the sweep proves out in prod.

Validation

  • Local: first sweep dropped RSS 0.96 GiB instantly; steady-state floor ~½ baseline on the sweep arm.
  • N-scale fit: RSS(N) ≈ 0.92 + 0.062·N GiB (R²=0.87) — per-connection resident cost; not claimed as leak-rate proof (see review history in the branch).
  • Adversarially reviewed (lead QE + principal SWE); convergent P1s all fixed here.

…ine projection

- prepare_threshold=None + idle_in_transaction_session_timeout on the
  catalog connection (pgbouncer transaction-pooling safe)
- in-transaction floor guard: refuse to plan below MIN(snapshot_id)-1
  (closes the clamp's check-then-plan TOCTOU)
- _read_inline projects per-store with additive NULL-fill; the missing+extra
  rename signature still raises (a boot-after-ADD-COLUMN reader previously
  wedged on UndefinedColumn against pre-ADD inline stores)
- drop unused row_id_start from the plan SELECT
One process = one source table + one destination + one team filter,
millpond posture: single loop, 3-attempts-then-crash, cursor strictly
after commit, at-least-once with downstream uuid dedup. Reuses feed.py;
the delete-assertion quartet (cursor-scoped) enforces the append-only
contract operationally. Design + review history: per-destination-duckling.md.
- TestVariantFeed: pinned projection over a VARIANT-bearing table; read
  across a compaction merge of variant-bearing files
- TestInline::test_variant_inline_plane: VARIANT in the inline store,
  mixed-plane read
- duckling e2e: boot/deliver with a VARIANT companion; VARIANT added
  mid-stream (running process unaffected, restart re-excludes)
Leak-2 (persistent_oom.md) is accumulation inside destination DuckDB
instances, freed at connection close — hypothesis-2's Leak-B family.
The source-connection recycle (C3) was falsified: RSS slope unchanged.
This ships the working mitigation plus the instrumentation that found it.

- memory.dest_conn_max_age_seconds (default 600, 0 disables, <60
  rejected): DestinationPool.evict_older_than() closes aged pooled
  connections; budget-capped per poll cycle so a mass event can't storm
  the destination catalog; pinned entries skip (no mid-apply tear).
- Config wiring + validation; viaduck_dest_conn_sweeps_total counter.
- Probes (default-off, for the hunt): PYTHONTRACEMALLOC=25-compatible
  SIGUSR1 tracemalloc dump (flag-only handler; the poll loop does the
  work — no logging inside the signal handler); arrow pool gauges;
  the source-conn recycle probe (kept for the record of C3's falsify);
  VIADUCK_CDC_SPLIT_READ split-read workaround for the upstream
  ducklake_table_changes executor-stall wedge (wedge-table-changes.md).
- destination.py create-table race retry: 'and' not 'or' — non-race
  errors raise immediately; exhaustion surfaces the real error, never
  a bare AssertionError.
- Tests: sweep noop/disabled/aged-only/pinned-skip/budget-cap/eviction
  count; create-retry race-text + exhaustion; config validation for the
  new knob. 1066 tests pass; ruff clean.
@jghoman
jghoman merged commit bcceb04 into main Sep 2, 2026
16 checks passed
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