Repository navigation
feat(tools): drop-orphan-inline-tables — remove inlined-data tables of dropped DuckLake tables - #147
Merged
Merged
Conversation
…f dropped DuckLake tables
DuckLake creates one Postgres table per (table, schema_version) for data
inlining and registers it in ducklake_inlined_data_tables. Its GC only
drops superseded-and-empty ones, so DROP+CREATE churn leaks one Postgres
table per dropped DuckLake table. At ~230k leaked tables the DuckLake
ATTACH (a full pg_class/pg_attribute/pg_type scan) OOMs, so no
ATTACH-based recipe can clean it up.
The new subcommand runs on the direct libpq connection
(_DIRECT_PG_COMMANDS) and never touches duckdb. It uses the range-overlap
predicate of the ducklake_unreachable_inline_tables metric, re-evaluated
per batch, and per batch in one transaction locks each orphan table,
skips non-empty ones, drops the rest and deletes their registry rows.
Batches are keyset-paged, capped at 2000 for lock-table pressure, and the
run holds the maintenance advisory lock. No VACUUM FULL; it logs a hint.
Adds just recipes drop-orphan-inline-tables{,-dry-run} plus the
chain-safe drop-orphan-inline-tables-default, and integration tests
against a throwaway real Postgres.
…internal information
quay.io now refuses anonymous pulls of minio/minio and minio/mc (401), which fails the e2e and hoglake integration stacks on every branch. Run the server from cgr.dev/chainguard/minio (same entrypoint, ships mc for the healthcheck) and replace the mc init container with the public awscli image.
stack.py pins a hoglake-server digest but never passed it to docker compose, so compose fell back to hoglake-server:latest. The 1.3.7 server release changed the table info response and broke both hoglake suites without any millpond change. Pass HOGLAKE_SERVER_IMAGE to every compose call so the pin actually applies.
bill-ph
approved these changes
Oct 1, 2026
Contributor
There was a problem hiding this comment.
Reviewed the new drop-orphan-inline-tables subcommand in tools/ducklake_maintenance.py, which runs directly on Postgres without ATTACH. I checked:
- the orphan predicate against the
ducklake_unreachable_inline_tablesmetric SQL - keyset paging with
FOR UPDATE OF idt SKIP LOCKED - the per-table
LOCK→ emptiness check →DROP, then the registryDELETE, all in one transaction psycopg.sql.Identifierquoting plus thefullmatchname guard- the empty-snapshot refusal
- the advisory lock through
_lock_refresh - batch-size caps and
main()dispatch, which skipsconnect() - the justfile recipes and the integration fixture
The safety design holds up:
- Orphanhood is monotonic, because table_ids are never reused and the retained range only shrinks. So re-checking the predicate in each batch is enough.
- DROP and the registry DELETE commit together.
- A non-empty orphan can never be dropped, because the emptiness check runs under ACCESS EXCLUSIVE.
I found nothing blocking.
P2
- A failed execute run pushes zeroed gauges. On an execute run the three unlabeled inline gauges are registered on the push registry.
main()'sfinallyalways pushes. If the run raises (advisory lock contended, retries exhausted, connection lost),maintenance_inline_orphans_remaining,..._dropped_totaland..._skipped_nonempty_totalgo out as 0. That overwrites the last real value and looks like a clean catalog, which defeats the stated purpose of registering them only for execute runs. Fix: set them only on success, or register them only after the run completes. - Connection loss is not retried. The batch retry loop only retries
_DROP_INLINE_RETRYABLE_SQLSTATES. It does not retryOperationalError/InterfaceError, and it does not reconnect and re-take the advisory lock the way_drop_leaf_with_retriesdoes. A failover during a long first cleanup (~460 batches at the default size on the affected tenant) aborts the run. The next run resumes because the operation is idempotent, so this is low impact. It is worth a mention given thatdrop-partitionshandles it. - Each batch re-scans the whole registry. The registry table probably has no index on
(table_id, schema_version), so every keyset batch scans and sorts the full registry and re-runs the anti-join againstducklake_table. That is O(N²/batch) over the first cleanup. It is fine at 230k rows under the 120sstatement_timeout. If it gets slow, the cheap fix is to collect the orphan key list once and page through it, still re-checking the predicate per batch. - Gauge naming. The
_totalsuffix (maintenance_inline_orphans_dropped_total,..._skipped_nonempty_total) is reserved for counters by Prometheus/OpenMetrics convention, but these are per-run gauges. Something likemaintenance_inline_orphans_dropped_last_runavoids confusingrate()users. - Unrelated changes in the PR. The PR also carries unrelated CI and infra changes: the minio image moves to Chainguard, minio-init moves to awscli, the hoglake server image is pinned in
stack.py, and AGENT.md gains a public-repo section. They look fine, but splitting them out would make the feature easier to review and to revert.
Inline comments
tools/ducklake_maintenance.py:4400— P2: These gauges are registered for every execute run, butmain()'sfinallypushes even when the op raised. A run that fails (advisory lock contended, retries exhausted, connection lost) therefore pushesmaintenance_inline_orphans_remaining=0and 0 for dropped/skipped. That overwrites the last real values and looks like a clean catalog. Set them only on success, or register them only after the run succeeds.tools/ducklake_maintenance.py:3935— P2: Only the listed SQLSTATEs are retried. Connection loss (OperationalError/InterfaceError) aborts the whole run with no reconnect or advisory-lock re-acquire, unlike_drop_leaf_with_retries. The op is idempotent, so the next cron resumes it, but a long first cleanup is exactly where a failover would hit.tools/ducklake_maintenance.py:3762— P2: The registry probably has no index on(table_id, schema_version), so each keyset page re-scans and sorts the whole registry and re-runs the anti-join. That is fine at 230k rows under the 120s timeout, but it is O(N²/batch). If it's slow, collect the orphan keys once and page through that list, still re-checking the predicate per batch.tools/ducklake_maintenance.py:4405— P2: The_totalsuffix is reserved for counters by Prometheus/OpenMetrics convention, but this is a per-run gauge. Consider..._dropped_last_run(and the same forskipped_nonempty).
— Robo Bill v2 (opus, high reasoning)
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Problem
DuckLake on a Postgres catalog creates one Postgres table per (DuckLake table, schema version) for data inlining:
public.ducklake_inlined_data_<table_id>_<schema_version>. Each one is registered inpublic.ducklake_inlined_data_tables. DuckLake's GC (DropEmptySupersededInlinedTables) only drops superseded-and-empty ones. It does not drop the inlined tables of a dropped parent table. Tenants whose data-import syncs DROP+CREATE tables on every run accumulate these tables without bound. The existingducklake_unreachable_inline_tablesmetric counts them.A prod tenant reached ~230k inlined tables, all but a handful orphaned and empty, with ~18.7M
pg_attributerows. DuckDB's DuckLake ATTACH does a full Postgres system-catalog scan (pg_class⋈pg_attribute⋈pg_type⋈pg_description). On that catalog the scan spilled ~1.5GB of temp per session, ran for ~10 min and OOMed 4Gi DuckDB processes, which took the tenant down. Every maintenance recipe failed at ATTACH, so none of them could clean up the leak.Design
New subcommand
ducklake_maintenance.py drop-orphan-inline-tables [--dry-run] [--batch-size N] [--max-batches N]:Direct Postgres, no ATTACH. The command is in
_DIRECT_PG_COMMANDS, so it runs on_pg_direct_connect()(psycopg) likedrop-partitions, andmain()never callsconnect(). A test asserts thatconnect()is not called.Predicate shared with the metric. A registry row is an orphan when its
table_idhas noducklake_tablerow reachable from a retained snapshot. This uses the metric's range-overlap test:begin_snapshot <= max(snapshot_id)and (end_snapshot IS NULLorend_snapshot > min(snapshot_id)). It lives in_inline_orphan_predicate. An integration test runs the metric's own SQL against the same catalog and asserts both give the same count. The predicate is re-evaluated for every batch. The command refuses to run ifducklake_snapshotis empty, because NULL bounds would mark every table as an orphan.Batching and locks. Each batch is one transaction:
ORDER BY table_id, schema_version,FOR UPDATE OF idt SKIP LOCKED). Keyset paging means skipped rows are not selected again in every batch.LOCK TABLE … IN ACCESS EXCLUSIVE MODE, check emptiness, thenDROP TABLE IF EXISTS. Identifiers are quoted withpsycopg.sql.Identifier.DELETEthe matching registry rows, then commit.--batch-sizedefaults to 500 and is capped at 2000. Each DROP holds several shared lock-table entries until commit (table, TOAST table and index, types), and those entries count againstmax_locks_per_transaction. Each batch setsSET LOCAL statement_timeout=120sandlock_timeout=5s. Retryable SQLSTATEs (drop-partitions' set plus42P01for a table that disappears mid-batch) are retried up to 5 times.Advisory lock. Execute mode takes
hashtext('millpond-ducklake-maintenance')through the existing direct-pg_lock_refresh, so this command cannot race expire, cleanup or compaction. Dry-run does not take the lock, likelist-droppable-partitions.Guards. A name that does not fullmatch
ducklake_inlined_data_<n>_<n>is skipped with a WARN, and its registry row is kept. A non-empty orphan is skipped with a WARN and counted. The emptiness check isSELECT 1 FROM t LIMIT 1, run after the ACCESS EXCLUSIVE lock, so no writer can insert between the check and the DROP. It does not usereltuples, which is a planner estimate (-1before the first vacuum, stale after later inserts). On an empty heap the check is O(1).Dry run. Reports registry rows, orphaned rows, orphaned tables present in
pg_class, and orphaned non-empty tables. It walks the same batches and does no DDL or DML.No VACUUM FULL. VACUUM FULL takes ACCESS EXCLUSIVE locks on the system catalogs. After a run that drops tables, the command logs a hint that
pg_class/pg_attribute/pg_typestay bloated until autovacuum runs (plain autovacuum reuses the space) or until someone runs a manual VACUUM FULL in a quiet window.Metrics. The command logs one line per batch and a final summary. It adds the gauges
maintenance_inline_orphans_dropped_total,maintenance_inline_orphans_skipped_nonempty_totalandmaintenance_inline_orphans_remaining. They are unlabeled, so they are registered only for an execute run of this command. Otherwise every other cron'spushadd, and any dry-run, would overwrite them with 0. This follows the drop-partitions rule that a dry-run leaves no metric footprint.tools/justfile(lifecycle group):drop-orphan-inline-tables-dry-run batch_size="500",drop-orphan-inline-tables batch_size="500" max_batches=""(with_confirm-target), and the chain-safe no-arg wrapperdrop-orphan-inline-tables-default.Docs:
tools/README.md,README.mdandAGENT.mdnow describe the command. Theducklake_unreachable_inline_tableshelp text names this command as the fix and drops a reference to anINCIDENT.mdthat does not exist.Tests
New
tests/integration/test_drop_orphan_inline_tables.py, run against a throwaway real Postgres. The fixture usesMILLPOND_TEST_PG_DSNif it is set, otherwise localinitdb/pg_ctl(including/usr/lib/postgresql/*/binon the ubuntu runner), otherwise a dockerpostgres:17. If none is available it skips, or fails whenMILLPOND_REQUIRE_DOCKER_STACKis set. Covered cases:ducklake_tablerow is treated as an orphanend_snapshot = lo+1boundary;end_snapshot = lois dropped--max-batches, with a non-empty orphan stepped over and not reselectedmain()dispatches without calling the duckdbconnect()New unit tests: parser defaults and
_DIRECT_PG_COMMANDSmembership, the batch-size cap, and the name guard (including a trailing-newline case, which is why it usesfullmatchand not^…$).I also mutation-tested the guard: with the non-empty check disabled, 3 of the integration tests fail.
Checks run locally:
just lint,just fmt-check, andruff check/ruff format --checkontools/andtests/: passjust test: 1278 passed, 1 xfailedjust test-integration: 76 passed. The new suite ran against a local Postgres 18 throughinitdb/pg_ctl.just drop-orphan-inline-tables-dry-runandjust drop-orphan-inline-tables-defaultagainst a local Postgres-backed DuckLake catalogNot run:
just test-e2eand the hoglake suites, because no docker is available locally. CI runs them.I made one small change outside the feature: the generic
--batch-size <= 10000check inmain()now applies only toexpire-snapshots, which is the only command it was written for.Follow-up
After this merges and the release workflow publishes the new image, charts will add
drop-orphan-inline-tables-defaultto the default tenant maintenance chain (the crossplane composition$cArgs). The tenant maintenance CronJobs pullghcr.io/posthog/millpond:prod, so that change has to wait until this release is retagged:prodthrough this repo's Promote to prod workflow. Until then the recipe does not exist in the image and the chain would fail. For the affected tenant: rundrop-orphan-inline-tables-dry-runfirst, then the execute recipe, from a maintenance pod.🤖 Generated with Claude Code