Skip to content

Apply accumulated deletes during compaction and sync - #23

Merged
edsu merged 15 commits into
mainfrom
compact-rewrite-deletes
Aug 12, 2026
Merged

edsu merged 15 commits into
mainfrom
compact-rewrite-deletes

Conversation

@edsu

@edsu edsu commented Aug 12, 2026

Copy link
Copy Markdown
Collaborator

The text below was written by Claude. This was a very gnarly problem that I spent days on. During a delete of one record from the lake the memory usage on a 32 GB machine maxed out and started swapping. The solution turned out to be compacting our data in a particular way, on a particular cadence during the synchronization process. I'm adding this to the top just in case future me (or someone else) goes looking for what to do.


DuckLake records deletes as tombstones and only clears them when a data file is
rewritten. merge_adjacent_files skips files already at the target size, and
ducklake_rewrite_data_files — which does clear them — defaults to a 0.95
threshold, so podlake never applied any. 1.5B tombstoned rows accumulated
against ~5B live, while compact reported "nothing to reclaim."

That backlog is what made deltas fail. Every DELETE loads the delete history of
each data file it opens, off-pool and not bounded by memory_limit, so its cost
tracked the backlog rather than the rows being deleted — a 15k-id delete and a
300k-id delete behaved identically, and Harvard's deltas OOM-killed a 32GB
machine regardless of batch size.

Changes

  • compact applies deletes by default (--delete-threshold,
    --no-rewrite-deletes, --max-files), repeats the expire → merge → cleanup
    cycle to a fixed point (one pass never fully compacts), and always reports the
    pending backlog.
  • sync / sync-all clear the backlog before each resource, sized to that
    resource, escalating the rewrite threshold rather than tolerating a growing
    backlog (--max-pending-deletes).
  • --verbose logs per-statement apply timings — how the DELETE was originally
    isolated from the INSERT. --log writes progress to a file and keeps the
    console quiet, so a cron run only mails on failure.
  • README documents the delete backlog and the sync → compact → publish cycle.

Validation

Loaded the full consortium: 13 orgs, ~84.5M records, ~71GB of source MARCXML,
status at 100%. Stepping the threshold down cleared 93% of the existing
backlog in ~8.5 minutes, and the identical DELETE that had exhausted RAM and
swapped then completed — same ids, same partition, same memory_limit.

Reported upstream as duckdb/ducklake#1371.

Known, deliberately deferred

  • delete_state is per-org, but the backlog is table-wide, so each org in
    sync-all rediscovers the same floor.
  • Mid-sync compaction runs days=0, expiring snapshots — sync now implicitly
    discards rollback points.

🤖 Generated with Claude Code

edsu and others added 15 commits August 10, 2026 20:17
Tombstoned rows were never being physically removed from large data files.
`ducklake_merge_adjacent_files` skips any file already at the target size, so it
only ever clears deletes from small files, and DuckLake's `rewrite_data_files`
— the function that actually applies them — was never called at all. Its own
default threshold is 0.95 (rewrite only when a file is 95% deleted), so even
calling it naively would be close to a no-op.

The backlog matters for more than disk: on every DELETE, DuckLake loads the full
delete history of each data file it opens and holds it for the statement
(ducklake_multi_file_reader.cpp), so accumulated deletes make later DELETEs
progressively more expensive in memory regardless of how few rows they remove.

- Add `pending_deletes()` reporting live delete files / tombstoned rows.
- Add `compact(rewrite_deletes=<fraction>, max_files=N)` →
  `ducklake_rewrite_data_files`, run before the cleanup cycle so the superseded
  files get collected. `max_files` bounds one run, since rewriting real data is
  slow and a large backlog needs to be worked through incrementally.
- Repeat the expire → merge → cleanup cycle to a fixed point: merging creates
  new snapshots and freshly-superseded files that only a later expire pass can
  collect, so one pass never fully compacts.
- `podlake compact` gains --rewrite-deletes / --max-files and always reports the
  pending-delete backlog before and after.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
ducklake_rewrite_data_files only gained max_compacted_files in newer DuckLake
versions; on older ones passing it is a BinderException. Retry without the
bound and warn that the whole backlog will be rewritten in one pass.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
ducklake_rewrite_data_files returns one row per table/compaction group, so
counting rows reported a constant (e.g. '14 files') regardless of the work done.
Sum files_processed instead.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Leaving the delete-rewrite opt-in reproduces the footgun it was added to fix: a
`compact` that reports success while silently leaving tombstoned rows, which
make every later DELETE load more off-pool memory until deltas fail outright.

- `compact` now applies deletes by default (--delete-threshold, default 0.1),
  with --no-rewrite-deletes for a quick disk-only pass. It warns before starting
  an unbounded rewrite of a large backlog, since that can't be interrupted for
  partial credit.
- `sync` / `sync-all` gain --max-pending-deletes (default 150M rows, 0 disables)
  and apply the backlog mid-sync when it crosses that line. A long delta chain —
  a first-time load of ~300 resources, or duke's 136-delta catch-up — otherwise
  grows its own memory use until it dies partway through. The default is
  calibrated from a 32GB box (~105M fine, ~558M peaked at 95%), hence the flag.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The mid-sync trigger fired on every single resource: the default (150M) sat just
above the floor the rewrite can actually reach, so each check found the backlog
over the line, ran a full compaction cycle that reclaimed ~nothing (the residue
lives in files under the 0.1 delete threshold), and left it over the line again.

- Raise the default to 300M — clear of the ~150M floor observed after a cleanup,
  still well under the ~558M danger zone.
- Add hysteresis: if a rewrite leaves the backlog above 75% of the trigger, raise
  the trigger to 1.5x what it actually left, so the next check has room to be
  useful instead of repeating a no-op cycle. Reported, not silent.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The backoff raised the trigger 1.5x each time a rewrite came up short, so a
backlog the rewrite can't keep up with would ratchet it past the range where
deltas stop fitting in memory. Cap it at 2x the configured limit, and when it
gets there say plainly that the backlog isn't being reclaimed and a lower
--delete-threshold is needed — rather than silently tolerating a growing backlog.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
A 1.1M-record delta was OOM-killed at 153M pending rows, on the same lake where
a 1.8M-record one had succeeded at 76M — so cost per pending row is far steeper
than assumed, and the previous defaults were unsafe:

- 300M trigger was well above the level that actually kills a large delta.
- The mid-sync rewrite was pinned at threshold 0.1, whose floor on this lake was
  ~157M — above the OOM point, so it could never have made the sync safe.
- The backoff raised the trigger when a rewrite came up short, which defers the
  failure to a later, larger resource instead of preventing it.

Now: trigger defaults to 75M; the mid-sync rewrite starts at 0.05 and halves
down to 0.01 while the backlog stays over the limit; only once the threshold
bottoms out is the backlog treated as irreducible, and that is reported plainly.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…floor

A 0.05 rewrite bottomed out at ~76M on the production lake, so a 75M trigger
sits below what the rewrite can achieve and fires on every resource without
ever getting under the line. The usable window there is ~76M-153M; 100M sits
inside it. Document the tension so the number can be tuned per lake.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
What kills a sync is a large resource meeting a moderate backlog, not a high
backlog on its own — the delete pays for the tombstones it creates *and* every
tombstone already on the files it opens. Checking after each apply couldn't see
what was coming next.

Move the check before the apply, where the resource's size is already known from
the ResourceSync manifest, and hold resources over 100MB to half the normal
limit. Small deltas keep the looser limit, so the long tail costs nothing.

Also record the irreducible floor once the rewrite bottoms out at the minimum
threshold, so later resources stop paying for compactions that cannot help.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The old text framed compaction as a disk-space chore, which is how a 1.5B-row
backlog accumulated unnoticed until deltas stopped fitting in memory. Give it
its own section explaining what tombstones cost at write time, why merging small
files doesn't clear them, how to read the reported backlog, and how sync keeps it
in check. Also note that memory_limit bounds the buffer pool only, and make the
routine cycle sync -> compact -> publish.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
apply_resource runs delete/insert/commit in one transaction with no progress of
its own, so a stall or memory spike can't be attributed to a step. Log a marker
before and after each statement at INFO, enabled by --verbose/-v: the last
unmatched "→" names the culprit. This is how the DELETE was isolated from the
INSERT during the memory investigation, and it is the fastest way back to a
diagnosis if loading breaks again.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Correctness:
- pending_deletes() no longer swallows every duckdb.Error as an empty backlog.
  It guarded against an unbuilt lake, but also hid real failures — reporting
  "0 rows" would silently disable the safeguard that depends on it. Gate on the
  records table existing and let other errors surface.
- Never demand a backlog below one a rewrite has already failed to reach. The
  big-resource limit (half of --max-pending-deletes, 50M by default) sits below
  the ~76M floor observed in production, so it was unreachable by construction
  and guaranteed a full escalation ladder on every large resource.
- Escalate the rewrite threshold within a single trigger instead of across
  resources. The old state ratcheted down and never recovered, so one org that
  escalated to 0.01 ran every later resource at the most expensive setting.
- Warn before an unbounded mid-sync rewrite, as the compact command already did.

Cleanup:
- Share the --max-pending-deletes/--verbose option definitions between sync and
  sync-all rather than duplicating ~20 lines each.
- Fold run_rewrite() into run() with a measure argument.
- Trim the constant/docstring narrative now that the README covers it.

Also: --log writes progress to a file, disables progress bars and keeps the
console quiet (cron only mails on failure), and sync logs a line per download —
the download bar auto-hides off a TTY, so piping through tee lost it entirely.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- --log claimed a quiet console but did not silence DuckDB's own progress bar:
  connect() enables it for any TTY, so an interactive run stayed noisy despite
  the log file. The original test could not catch this because CliRunner is not
  a TTY, so the new test asserts the setting directly.
- compact had no --log, though it is the long-running half of the documented
  sync -> compact -> publish cron cycle, and its summaries bypassed the helper.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Mutating the escalation loop's exit condition (<= to <) hung instead of failing:
max(MIN, t/2) clamps at exactly MIN, so the comparison never fires and the loop
calls compact forever. Replace the while/break with a for over a precomputed
ladder, so the escalation is bounded by construction rather than by getting a
comparison right.

Add direct tests for the ladder, the floor it records, and the disable switch;
both a truncated ladder and a dropped floor now fail them. Previously the most
intricate code on the branch was only covered incidentally.

Downloads were announced twice in a terminal (our line plus the bar's own
label). Emit the line only when the bar will be invisible, and give
resourcesync.download a `quiet` flag so --log actually silences it — disable=None
only auto-hides off a TTY, so --log from a terminal still drew the bar.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Both raise the root logger to INFO, so httpx logged a line per request. On a
300-resource org that buries the progress lines the options exist to surface.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@edsu
edsu merged commit 19e45a6 into main Aug 12, 2026
1 check passed
@edsu edsu mentioned this pull request Aug 12, 2026
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