Skip to content

Deduplicate repeated statements in bulk import - #1754

Merged
bplatz merged 3 commits into
mainfrom
fix/import-dedup-1651
Sep 3, 2026
Merged

bplatz merged 3 commits into
mainfrom
fix/import-dedup-1651

Conversation

@bplatz

@bplatz bplatz commented Aug 31, 2026 •

Copy link
Copy Markdown
Contributor

Summary

Bulk import stored every input statement verbatim — no deduplication existed on either import path (the merge ran with skip_dedup: true), so duplicated input lines became duplicate index rows, violating RDF set semantics and silently inflating COUNT, aggregates, and join cardinalities (#1651). This PR makes the index the set view while the commit blobs keep the raw ops: duplicates are squashed where the data is already sorted, at no measurable cost to clean imports.

Closes #1651

Design

Two dedup lanes, both O(1) memory, both operating on the existing five-field identity (s_id, p_id, o_type, o_key, o_i) — so language tags (folded into o_type), datatypes, and list positions all keep distinct facts distinct:

  • Chunk sort: an in-place dedup_by pass immediately after the per-chunk graph-prefixed SPOT sort collapses within-chunk copies before the sorted-commit artifact is written — upstream of the stats hooks and every downstream artifact. Guarded (same t, both asserts) so anything unexpected is kept rather than collapsed.
  • K-way merges: a new KWayMerge::next_unique_assert() collapses cross-chunk copies adjacently in the SPOT and secondary-order merges. Earliest t wins (a fact is present from its first assertion; later copies are no-op re-asserts). Retracts pass through verbatim; cascade intermediates stay lossless; the rebuild arm (next_deduped_with_history) is untouched. The now-misleading skip_dedup flag is renamed import_unique_asserts.

Stats stay tallied in the parallel remap workers exactly where they are today (no pipeline restructuring): the chunk-lane dedup lands upstream of the hooks so within-chunk copies never get counted, and the SPOT merge tallies cross-chunk squashes into a lazily-allocated (g, p, o_type) map — never touched on clean data — applied as signed-delta discounts at stats finalize via the hook's existing delta machinery. HLL sketches are duplicate-insensitive and need no correction. Per-property counts and stats.flakes now describe the set view.

Counters: flake_count / cumulative_flakes / total_asserts / fuel keep describing raw commit ops (the commit chain deliberately retains every op). A new ImportResult.duplicates_removed reports the collapsed copies, and the CLI prints both when non-zero:

Imported 49.2M flakes in 33.77s (1.46 M flakes/s) across 9 commits (t=9)
Collapsed 18,938 duplicate statement(s); the index holds 49,156,066 distinct flakes

Latent language-remap ordering bug (fixed here; adjacency dedup depends on it)

Adjacent-identity dedup requires remapped chunk streams to stay sorted, which exposed a pre-existing hazard: the unified language dictionary was built in first-seen order across chunks while chunk-local ids were parse-order, so a local→global lang remap could be non-monotone — un-sorting a remapped stream within (s, p) (lang_id is the langString o_type payload, part of the sort key) so equal langString records never meet in the merge. Both sides now assign language ids lexically (normalized): the chunk sort reassigns local ids and rewrites records, build_lang_remap_from_vocabs builds the unified dict lexically, making every remap monotone. Cost: O(distinct tags).

Semantics preserved by construction

  • Anonymous blank nodes and anonymous reifiers mint per-occurrence ids and intentionally never collapse (RDF-correct: two reification events of one fact stay two reifiers, annotating the single deduped triple).
  • Labeled blank nodes are document-scoped, so duplicated labeled-bnode lines produce identical records and collapse.
  • Edge-annotation (f:reifies*) bundles are separate records keyed by the reifier subject — base-triple dedup cannot drop annotation data.
  • Vector literals are intentionally out of scope: the vector arena appends per occurrence, so copies carry distinct handles and are invisible to identity dedup. Status quo is preserved (nothing deduped them before either). Deduping them would require value interning in the arena; vectors are a Fluree extension rather than RDF set semantics, so this is a deliberate non-goal, not a deferred follow-up.

Benchmarks

DBLP N-Triples, first 50,000,000 lines of the 2026-06-01 dump (6.0 GB), fresh ledger per run, interleaved runs, Apple M4 Max 16c/48GB. Three binaries: A = released v4.1.6 (homebrew), C = origin/main unpatched, B = this branch (C + this patch, isolating its cost):

Wall (runs) Throughput Peak RSS Rows stored
A · released 4.1.6 34.65s, 33.96s 1.42–1.45 M fl/s 11.0–11.6 GB 49,175,004
C · main unpatched 33.97s, 34.70s 1.42–1.45 M fl/s 10.6–12.3 GB —
B · main + patch 33.77s, 33.95s, 34.36s 1.43–1.46 M fl/s 10.6–12.1 GB 49,156,066

Speed and peak memory are identical within ±1% run noise across all three (B's mean is nominally lowest). Correctness reconciles to the digit: the prefix holds 49,175,004 triple statements (plus 824,996 blank lines) of which 49,156,066 are distinct; A/C store all 49,175,004, B collapses exactly 18,938 and stores exactly the distinct count, and the A−B row delta equals duplicates_removed.

Two findings from the ground-truthing worth recording:

  • The issue's claim that the chunked path already dedups IRI-only lines was an artifact of blank-line counting in its input analysis — empirically, the pre-patch import dedups nothing (A and C store every duplicate).
  • The published DBLP-core report's "561,544,658 distinct triples" is the duplicate-inflated count; true distinct is 561,477,456. A post-patch full-dump import should report duplicates_removed ≈ 67,202 and store the true count.

Testing

Every dedup lane is pinned by a test proven to fail with its fix reverted (mutation-tested):

  • Chunk lane: spool unit test (sorted_commit_collapses_duplicate_records).
  • Merge lane: merge unit tests (min-t winner, dropped counting, o_i near-miss, retract passthrough) + cross-chunk e2e — including the issue's 6-line repro asserting COUNT(*) = 3, and a chunked variant asserting per-property stats reflect the set view.
  • Lang monotonicity: remap unit test + an adversarial e2e where chunk 2 introduces a lexically-smaller tag before a duplicated @en statement under one (s, p).

Additional coverage: near-miss e2e (same literal under @en / @de / plain / xsd:byte / integer all preserved, duplicates_removed == 0); one pre-existing overlap fixture updated whose seam records — identical-comparing by design — now correctly collapse (spot_hierarchical_matches_flat, CID flat/hierarchical parity assertion intact).

Green: indexer lib + integration (389+), grp_import (105), grp_misc (263), grp_transact (190); clippy clean on touched crates; cargo fmt --all --check clean.

An RDF graph is a set of triples, but the bulk-import pipeline stored
every input line verbatim: no dedup stage existed on either the
single-chunk or the chunked path (the merge ran with skip_dedup: true),
so duplicated input lines became duplicate index rows and inflated every
COUNT and aggregate over the affected predicates.

The index now squashes duplicates while the commit blobs keep the raw
ops (the event log stays faithful; the index is the set view):

- Chunk sort: an in-place dedup_by pass over the already-sorted chunk
  buffer collapses within-chunk copies before the sorted commit is
  written, upstream of the stats hooks and every downstream artifact.
- K-way merges: a new next_unique_assert() collapses cross-chunk copies
  adjacently in the SPOT and secondary-order merges (earliest t wins —
  a fact is present from its first assertion). Retracts pass through
  verbatim; cascade intermediates stay lossless. The ambiguous
  skip_dedup flag is renamed import_unique_asserts.
- Stats: worker hooks are fed per chunk, so the SPOT merge tallies the
  copies it collapsed by (g, p, o_type) and discounts the exact counters
  at stats finalize; HLL sketches are duplicate-insensitive and stay
  untouched. Per-property counts and stats.flakes describe the set view.
- Counters: flake_count / cumulative_flakes / total_asserts / fuel keep
  describing raw commit ops; ImportResult.duplicates_removed reports the
  collapsed copies and the CLI prints both when non-zero.

Dedup identity is the existing five-field key (s, p, o_type, o_key,
o_i), so language tags, datatypes, and list positions all keep distinct
facts distinct; anonymous blank nodes and reifiers mint per-occurrence
ids and intentionally never collapse. Vector literals are excluded (the
arena appends per occurrence, so copies carry distinct handles).

Adjacent-identity dedup requires remapped chunk streams to stay sorted,
which exposed a latent ordering bug: the unified language dictionary was
built in first-seen order across chunks while chunk-local ids were parse
-order, making local-to-global lang remaps non-monotone — a remapped
stream could un-sort within (s, p) and equal langString records would
never meet. Both sides now assign language ids lexically (normalized),
making every remap monotone; a chunk-level reassignment rewrites record
lang_ids before the sort.

Each lane is pinned by a test proven to fail with its fix reverted:
chunk dedup (spool unit test), merge dedup (merge unit test + cross-
chunk e2e including the six-line issue repro asserting COUNT(*) = 3),
and lang monotonicity (remap unit test + adversarial tag-order e2e).
Near-miss e2e coverage pins lang/datatype/plain variants as distinct.
@bplatz bplatz added bug Something isn't working as expected area:transact Staging, commit, import/bulk-import, novelty, retraction semantics labels Aug 31, 2026
@bplatz
bplatz requested review from aaj3f and zonotope August 31, 2026 20:54
@bplatz bplatz added the area:transact Staging, commit, import/bulk-import, novelty, retraction semantics label Aug 31, 2026

@zonotope zonotope left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks good besides one style thing.

Comment thread fluree-db-indexer/src/run_index/build/build_from_commits.rs Outdated
…e_complexity

build_spot_index_from_commits returned a bare three-tuple whose third field
(the cross-chunk duplicate tally) read as an opaque FxHashMap<(u32, u16), u64>
at every call site. A named SpotBuild struct documents the map's key and drops
the clippy allow.
@bplatz
bplatz merged commit fdad220 into main Sep 3, 2026
15 checks passed
@bplatz
bplatz deleted the fix/import-dedup-1651 branch September 3, 2026 02:57
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area:transact Staging, commit, import/bulk-import, novelty, retraction semantics bug Something isn't working as expected

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Bulk import stores duplicate triples — RDF set semantics violated on both import paths

2 participants