Skip to content

fix: lossy reconstruction of duplicate-key secondary indexes on reload - #175

Merged
pathscale merged 2 commits into
masterfrom
tests/duplicate-key-index-reload-repro
Jul 27, 2026
Merged

fix: lossy reconstruction of duplicate-key secondary indexes on reload#175
pathscale merged 2 commits into
masterfrom
tests/duplicate-key-index-reload-repro

Conversation

@pathscale

@pathscale pathscale commented Jul 27, 2026

Copy link
Copy Markdown
Owner

Summary

Root-causes and fixes the ~12% loss of duplicate-key secondary-index entries across persist+reload, and turns the repro into a permanent regression test (now un-ignored, asserting exact per-key counts).

Root cause

Index pages store entries in event-arrival order — only bootstrap-written pages happen to be sorted with the node maximum last. from_persisted assumed "last entry == node maximum": it popped the last entry of every page and re-appended it as the node id with discriminator u64::MAX - 1. For CDC-updated pages (any bulk load) that promoted an arbitrary entry to node maximum, so:

  • the reconstructed in-memory node index registered wrong maxima, making every entry that sorts above one unreachable through select_by_* (select_all still returned every row — the files always contained all entries, verified by parsing pages directly);
  • reloaded tables desynced from the on-disk TOC, which addresses nodes by their maximum — post-reload CDC events could reference node ids the TOC never had (the "page should be available in table of contents" failure class).

Fix

Reconstruction now sorts every page's entries, forces the page's persisted node_id entry to sort last (so the in-memory node maximum is exactly the entry the TOC knows the node by), attaches nodes in ascending node-id order, and assigns discriminators that keep growing across node boundaries within one key — restoring the B-tree ordering invariant when one key's duplicates straddle nodes. Unique indexes get the page sort too (same latent assumption, no duplicates to lose).

Verification

  • The regression test fails on master (secondary index lost 968 of 10000 entries) and passes with the fix, asserting exact per-key counts after reload.
  • Full suite: 317 passed / 0 failed / 4 ignored, 5/5 consecutive runs; fmt and clippy clean.
  • Retroactive check against pre-fix data: files written by published 0.9.0 read back 100% complete with the fixed loader (loss was reconstruction-only). Files written by 0.9.0-beta recover only partially — they have separate write-side damage.
  • The previously-hanging string_re_read reload tests (TOC desync surfaced while iterating on the fix) pass — the node-id alignment is what makes post-reload writes safe.

…ex persistence

Problem
-------
A plain bulk insert of 10_000 rows spread over 97 distinct values of a
non-unique secondary index (~103 duplicates per key) is fully visible
through select_by_* while the table is in memory, but after wait_for_ops
and a reopen via load() roughly 12% of the entries are no longer
reachable through the index. select_all still returns every row, so the
data pages are intact; only the persisted index is incomplete.

The loss is baked into the index file at write time: re-reading the same
files gives the same wrong counts, while separate write runs lose
different amounts. An extra wait_for_ops plus a sleep before shutdown
does not help, so this is not the known wait_for_ops race. Unique-valued
secondary indexes survive the identical round trip intact.

The existing assertions that would have caught this are commented out in
tests/persistence/sync/string_secondary_index.rs (lines 449-452).

Reproduction
------------
The test is #[ignore]d so CI stays green until the fix lands. Run with:

    cargo test --test mod test_duplicate_key_secondary_index_survives_reload -- --ignored

Current failure on master:

    secondary index lost 968 of 10000 entries across persist+reload
@pathscale
pathscale force-pushed the tests/duplicate-key-index-reload-repro branch from 975c4b7 to cd8d8c6 Compare July 27, 2026 11:27
Problem
-------
Index pages store entries in event-arrival order: only bootstrap-written
pages happen to be sorted with the node maximum last, while pages that
were incrementally updated through CDC events (any bulk load) are
arbitrary. from_persisted assumed "last entry == node maximum" for
non-unique indexes: it popped the last entry of every page and
re-appended it with discriminator u64::MAX - 1 as the node id. For
CDC-updated pages that promoted an arbitrary entry to node maximum, so
the reconstructed in-memory node index registered wrong maxima and every
entry sorting above one became unreachable through select_by_* (~12% of
entries in the regression workload; ~62% under 0.9.0-beta), even though
select_all still returned every row. The wrong in-memory maximum also
desynced reloaded tables from the on-disk table of contents, which
addresses nodes by their maximum: CDC events emitted after a reload
could reference node ids the TOC never had.

Fix
---
Reconstruction now:
- sorts every page's entries (a no-op for bootstrap-written pages),
- forces the page's persisted node_id entry to sort last within its key,
  so the reconstructed node maximum is exactly the entry the TOC knows
  the node by,
- attaches nodes in ascending order of their node id, and
- hands out discriminators that keep growing across node boundaries
  within one key, so a key's duplicates straddling nodes keep the B-tree
  ordering invariant (every entry <= its node's registered maximum).
  Discriminator 0 stays reserved for the range infimum and u64::MAX for
  the supremum.

Unique indexes get the page sort as well: their reconstruction assumed
sorted pages the same way, it just never had duplicate keys to lose.

The reload fix is retroactive for 0.9.0-written files: the entries were
always all present in the files (verified by parsing pages directly),
only reconstruction lost them, so existing tables read back complete
without a rewrite. Files written by 0.9.0-beta had additional
write-side damage and recover only partially.

The repro test is un-ignored, asserts exact per-key counts after
reload, and passes 5/5 full-suite runs (317 passed / 4 ignored).
@pathscale pathscale changed the title tests: add ignored reproduction for lossy duplicate-key secondary index persistence fix: lossy reconstruction of duplicate-key secondary indexes on reload Jul 27, 2026
@pathscale
pathscale merged commit b171183 into master Jul 27, 2026
3 checks passed
@pathscale
pathscale deleted the tests/duplicate-key-index-reload-repro branch July 27, 2026 12:11
pathscale pushed a commit to pathscale/api.support.cafe that referenced this pull request Jul 27, 2026
Picks up the duplicate-key secondary-index reload fix (pathscale/WorkTable#175):
reloaded tables now reconstruct non-unique indexes completely instead of losing
entries whose key straddles index-node boundaries, and reloads stay aligned with
the on-disk table of contents so post-reload persistence cannot address missing
nodes. Also returns to the worktable_codegen crate name (worktable_macros was a
one-release workaround for 0.9.0).

Verified locally: default and s3-sync feature builds, migration crate build,
fresh start plus reload-from-disk run with no errors.
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