Skip to content

dedupe: streaming pipeline with a persistent pool (Stage 2) - #116

Merged
martinus merged 7 commits into
masterfrom
feat/dedupe-streaming-pool
Jul 24, 2026
Merged

dedupe: streaming pipeline with a persistent pool (Stage 2)#116
martinus merged 7 commits into
masterfrom
feat/dedupe-streaming-pool

Conversation

@martinus

Copy link
Copy Markdown
Owner

Replaces #113, which GitHub closed when #112's branch was deleted on merge (a closed PR can't change base). Rebased onto master; the tree is byte-identical to the reviewed and tested #113 tip (git diff e73b2b7 HEAD is empty).

See #113 for the full description, review discussion, benchmarks and the field-regression fixes (pushed-dext UAF, NULL-pool path, partial-mode drain, sanity guards). Gates on this exact tree: make check 112/112, make integration-valgrind clean, cold bench.py realistic/many no regression, field-tested on the 3.5M-file hashfile.

🤖 Generated with Claude Code

martinus and others added 7 commits July 24, 2026 09:38
Replace the per-batch, per-pass thread pool (created and drained inside every
dedupe_results() call) with one pool for the whole dedupe phase and a bounded
streaming producer. The main thread loads generation batch i+1 while batch i
dedupes, so the pool never drains between generation batches or between the
whole-file and extent passes - removing the barriers that left workers idle at
every 1024-file boundary and while the next batch loaded.

- run_dedupe.c: phase-global pool + batch/work-item model. Each batch owns its
  two results trees (whole-file + extent) and the producer admits at most
  DEDUPE_MAX_INFLIGHT (2) batches (RAM double buffer). Batches are reaped in
  generation (FIFO) order; dedupe_seq only advances to a batch's seq_hi once it
  and all earlier batches complete (dedupe_advance_seq), preserving the Ctrl+C
  invariant. New API: dedupe_phase_begin/end, dedupe_await_slot,
  dedupe_begin_batch, dedupe_push, dedupe_seal_batch.

- Filerec lifetime via batch-held refs (filerec_get/put): each batch holds one
  ref per filerec it loaded and drops them at completion. All get/put/new/find
  run on the single producer thread, so the filerec registry needs no lock and
  workers never touch it - they only read filerec fields and mutate their own
  batch's results tree (under the existing global mutex). No free_all_filerecs()
  between batches. This is a deliberate, safer alternative to per-extent refs
  released on workers (same lifetime guarantee, far less lock surface).

- oans.c: process_duplicates splits into the streaming producer
  (stream_duplicates) for -d and the unchanged sequential report path
  (report_duplicates) for reporting. The producer loads on a separate read
  connection (WAL readers don't block the writer); the in-memory shared-cache db
  reuses the global handle and serializes loads with dbfile_lock().

Builds on Stage 2.1 (static whole-file exclusion in GET_DUPLICATE_EXTENTS),
which makes the extent load order-independent so both passes can stream without
a barrier. No schema change.

Tests: test_streaming_dedupe.py (many groups across generations, cross-window
convergence, whole-file+extent streaming, in-memory path). make check (111) and
make integration-valgrind both clean. Isolated multi-batch dedupe A/B vs master:
~3-4% faster, peak RSS unchanged.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The streaming phase frees filerecs per batch in load order (not head-first),
so the singly-linked filerec_head made each filerec_free()'s SLIST_REMOVE an
O(n) search -> O(n^2) teardown. On the larger-than-RAM Linux-tree benchmark
(189k filerecs) that was ~11 s of extra user CPU, profiling as 37% self in
filerec_free, and it wiped out the streaming win (median 22 s vs master 13 s).

Switch filerec_head to the kernel doubly-linked list_head (O(1) list_del), so
teardown is O(n log n) (rb_erase-bound). Warm dedupe user CPU drops from ~11 s
back to ~2.9 s, matching master. make check (111) + valgrind clean.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- push_results: fold the ref-holding walk and the group-collection walk into
  one rb-tree traversal (both read the same stable tree).
- stream_load_batch: replace the six repeated 'if (inmem) dbfile_lock()' lines
  with load_lock()/load_unlock() helpers that name the shared-handle policy.
- dedupe_begin_batch: drop redundant re-zeroing of calloc'd fields.
- push_results: abort_on(!sorted) instead of if (!sorted) abort_on(1).

No behavior change. make check (111) + integration-valgrind clean.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
pdedupe_add_pushed_work(dext_work(sorted[i])) ran AFTER
g_thread_pool_push(): the moment the item is queued a worker owns the
group and can free it - an already-shared group is cleaned and freed in
microseconds, so on a rerun-heavy tree the producer raced it reliably.
Reading the freed dext fed len * (0 - 1) into the pushed-work total,
inflating the progress denominator to ~2^59: the bar froze at its
monotone high-water mark and the ETA read tens of millions of hours
(field report: 8% / ETA ~38449277h28m).

Latent on master since the byte-weighted bar (same ordering in
push_extents); the streaming pipeline's tighter overlap made it fire.
Valgrind's serialization never caught it - a fast worker almost never
wins under memcheck.

Capture the group's work before the push, assert it is sane (a single
group cannot verify an exbibyte), and stop rendering an ETA beyond a
year - by then the inputs are wrong, and the string is long enough to
wrap the bar line and desync the live block redraw (the doubled idle
slot lists in the same report).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
A failed g_thread_pool_new() left dedupe_pool NULL but the phase kept
going: every push would hit the NULL pool, no item would ever run, and
dedupe_phase_end() would wait forever on batches that cannot complete.
Abort instead - there is nothing to salvage without a pool.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
find_additional_dedupe() walks the GLOBAL filerec list. Pre-streaming,
free_all_filerecs() between batches kept that list scoped to the
current window; with batches in flight it also held the previous
window's filerecs, so --dedupe-options=partial would rescan the prior
window's files every batch (stale block trees, duplicated discovery
racing the previous batch's workers).

Reap all earlier batches (dedupe_drain) before the search - partial
mode gives up cross-batch pipelining, the default mode keeps the full
overlap. Pinned by a new multi-window partial-mode streaming test.

Also stop dropping the batch loaders' return codes on the floor (a
failed load now says which window it lost instead of silently deduping
a partial tree), and record the new producer/worker lifetime rules in
CLAUDE.md.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The corrupt value the guard exists to catch is len * (0 - 1) from a
freed dext: 128 MiB * (2^32 - 1) ~= 2^59, which slipped under the 2^60
cap. One group cannot verify a pebibyte; use 2^50.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@martinus
martinus merged commit c20c50a into master Jul 24, 2026
6 checks passed
@martinus
martinus deleted the feat/dedupe-streaming-pool branch July 24, 2026 07:39
martinus added a commit that referenced this pull request Jul 26, 2026
…gure (#151)

The README was last substantively updated in #118, two releases ago, so it
was missing everything shipped since and carried one number that no longer
matched the benchmark doc.

Fix a wrong figure: the path-hash row claimed "41 vs 73 MiB on the benchmark
tree". Neither number appears in docs/benchmarks.md, which measures 39.7 vs
70.9 MiB -- and that figure comes from the larger-than-RAM tree, not the
2.07M-file tree the surrounding table describes. Correct both the numbers and
the attribution, and reword the table intro, which named only one of the two
benchmarks the rows actually draw from.

Add the missing user-facing work:
  - paths beyond PATH_MAX are hashed and deduped (#117/#124/#128)
  - the streaming dedupe pipeline (#116), which had no bullet at all
  - the O(extents^2) fragmented-file scan fix (#134)
  - the two dedupe-phase races (#123, #129)
  - clang ASAN/UBSAN/TSAN CI legs, warnings-as-errors, make check-all
  - --cpu-threads, absent from both CLI lists despite being in --help
  - progress polish: scan-phase throughput (#120), idle workers (#143)

Tighten for readability: drop the standalone larger-than-RAM NOTE, which
stated the same ~13x claim a third time; its unique content (RSS, hashfile
size) moves into the speedups table where the reader is already comparing
figures. Mark upstream issue references as "upstream #NNN" throughout -- bare
markfasheh#331/markfasheh#374/markfasheh#376/markfasheh#387 now read as oans issues, since oans has its own numbers
in that range.

Docs-only; no code touched.

Co-authored-by: Claude <noreply@anthropic.com>
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