Skip to content

btree: equal-key put over a fully-deleted duplicate set (R2) - #244

Merged
gburd merged 10 commits into
masterfrom
fix/r2-dup-revive
Nov 4, 2026
Merged

gburd merged 10 commits into
masterfrom
fix/r2-dup-revive

Conversation

@gburd

@gburd gburd commented Nov 3, 2026 •

Copy link
Copy Markdown
Collaborator

What

In a btree whose bt_compare equates distinct byte strings (e.g. a
case-insensitive comparator, "abc" == "ABC"), a duplicate set whose members
are all deleted while a cursor still pins one of them, followed by a put of
an equal-comparing key, revived the set under the deleted key's bytes
instead of the caller's. R1 (50ef9c73f) fixed the non-duplicate slot but
deliberately skipped a key item shared by a duplicate set. This finishes
R1 for every duplicate shape (KNOWN-ISSUES R2, RFC 0014 §8.3).

The fix: rewrite the set's shared key item in place

The members of a duplicate set share one physical key item (every member
slot's inp[] points at it). So when the whole set is deleted and its stored
bytes differ from the caller's, a single in-place rewrite of that one key item
changes the key for the entire set at once, and the new member then joins by
the ordinary duplicate path. The set stays one set.

__bamc_put, on an exact match in a DB_AM_DUP database where
__bam_dup_alldeleted reports the set entirely deleted with differing stored
bytes, calls __bam_dup_rekey and falls through to the normal duplicate path:

  • on-page, non-overflow key: __bam_ritem replaces the shared item and
    re-aims every inp[] offset that referenced the moved region — including
    each sharing member — so all members follow. Its __bam_repl log record is
    position-indexed and replays through the same __bam_ritem, so recovery
    is correct with no new or changed log record
    . A growing key that will not
    fit returns DB_NEEDSPLIT, and __bamc_put's existing retry splits and
    re-drives.
  • off-page duplicate tree: the key is a lone (unshared) parent-leaf slot,
    rewritten by delete-and-re-add as R1 does for a non-dup slot (covering
    overflow keys and type transitions there).

While any member is live, the set's key does not move (the documented
shared-key semantics). No on-disk / log / region format change; no public API
or ABI change (the three new helpers are static).

Why the first version was replaced

The first fix redirected the put to a fresh DB_KEYFIRST insert next to the
still-pinned dead set. A second equal-key put then found the dead set
again and redirected again, producing a second standalone key item — two
equal-comparing keys as two separate non-dup key items, which DB->verify
rejects ("non-dup dup key"); DBC->count read 1 instead of 2 and a reverse
walk dropped a record. That was structural corruption (lead-reproduced with
r2_walk_corruption.c). Equal-comparing keys must form one set, so the
existing set's key must move — an insert next to it cannot be correct.

Documented residual (not corruption)

An on-page set whose shared key is an overflow item is left under its old
key bytes: there is no logged in-place primitive that replaces a leaf item with
an arbitrary overflow header while keeping the shared on-page offset. It stays
a single valid set (DB->verify clean, DBC->count correct), identical to
master's behaviour, differing only in the key bytes. Narrow exposure (a dup set
of keys each large enough to be an overflow key, all deleted while pinned).
Recorded in test/KNOWN-ISSUES.md R2.

Tests (fail before, pass after)

  • test/db/r2_dup_revive.c (run_r2_dup_revive.sh): every case does two
    equal-key puts and asserts, always — DB->verify clean, DBC->count = live
    set size, forward (DB_NEXT) and reverse (DB_LAST+DB_PREV) walks returning the
    same mirrored complete set, two new records under one key — and the key bytes
    ("ABC", or the documented "abc" for the overflow residual). Matrix: DB_DUP /
    DB_DUPSORT, on-page / off-page, pin / no-pin, no-env / txn, a tall off-page
    tree, overflow keys on-page (residual) and off-page (rewritten), a growing
    key that forces a split, the live-member control, and recovery from the log
    alone (DB_RECOVER_FATAL, the .db removed). A sabotage arm
    (-DR2_REVIVE_SABOTAGE) disables the rewrite and must FAIL.
  • test/db/r2_fuzz.c (run_r2_fuzz.sh): randomised — a case-insensitive
    DB_DUP/DB_DUPSORT btree driven with random pins, equal-comparing puts and
    deletes; after every op, DB->verify passes, the per-folded-key live count
    matches a model, and every live record of a folded key carries one spelling
    (one set). 60 seeds × 300 ops (~30 s).

Teeth, three libraries: the first R2 fix FAILs (DB_VERIFY_BAD + wrong
count; fuzz 5/60 seeds), master FAILs the key-bytes check, the new fix PASSes
(fuzz 60/60). Master passes the fuzz too — it keeps a valid single set, so the
fuzz isolates the structural corruption from the benign key-bytes difference.

Evidence

  • --enable-diagnostic test/db tier: 65 PASS, 4 SKIP, 0 FAIL, including
    run_r2_dup_revive (normal PASS, sabotage FAIL 26 = teeth).
  • r2_walk_corruption.c on the fix: verify 0, count 2, both walks complete,
    key ABC.

No EC2 of my own (shared dev box only).

libdb agent added 4 commits November 2, 2026 18:03
The R1 fix (50ef9c7) left a gap (KNOWN-ISSUES R2): a duplicate set whose
members are all deleted while a cursor pins one, revived by an equal-key put
under a non-identity comparator, kept the deleted key's bytes. This note
records the four affected paths and the chosen fix (redirect to a fresh
insert when the whole set is deleted), with the weighed alternative.
…ld key (R2)

The R1 fix (50ef9c7) left a gap recorded as KNOWN-ISSUES R2: in a btree
whose bt_compare equates distinct byte strings, a duplicate set under key
"abc" whose members are ALL deleted while a cursor still pins one, followed
by a put of an equal-comparing key "ABC", revived the set under the DELETED
key's bytes.  R1 rewrites the key item in place for a non-duplicate slot, but
deliberately skipped a key item shared by an on-page duplicate set, and did
not cover off-page duplicate trees at all.

The residual is every duplicate shape: on-page DB_DUP (the KEYLAST append
copies the shared key), on-page DB_DUPSORT (the DB_CURRENT overwrite keeps
the shared key), and the off-page tree (the data goes into the dead tree
whose key is the unchanged parent-leaf slot).  Measured identical on master
2026.10.8 and LTS 2026.10.7.1.

The rule: while any member of the set is live the set's key stays (the
documented shared-key semantics); when every member is deleted the set is
logically gone, so an equal-key put must store the caller's key bytes,
exactly as it does when no cursor pins the set -- the set is then reaped
before the search, so the put already inserts a fresh pair.

__bamc_put now detects, on an exact key match in a DB_AM_DUP database whose
stored key bytes differ from the caller's, a duplicate set all of whose
members are deleted (__bam_dup_alldeleted walks the on-page shared-key run or
the off-page tree, short-circuiting on the first live member), and redirects
to a fresh insert at that slot (iiop = DB_KEYFIRST).  Case 1 of __bam_iitem
completes it with the caller's key, reusing its overflow-key, split/retry and
cursor-adjustment handling; the dead set stays pinned until it is reaped.  No
shared-key surgery, no off-page key rewrite, no new or changed log record, no
on-disk/log/region format change.
r2_dup_revive.c drives the full matrix: {DB_DUP, DB_DUPSORT} x {on-page,
off-page} x {pin, no pin} x {no env, txn env}, deleting every member of the
set then putting an equal-comparing key, and requires the new record's stored
key to be the caller's bytes with the dead set gone (one live record).  It
adds overflow keys (asserted real overflow items), a near-full-leaf split
(asserted through bt_leaf_pg), recovery from the log alone (DB_RECOVER_FATAL
with the .db removed), DB->verify before and after, and the live-member
control (delete 1 of 3, 2 remain) which must keep the set's key "abc".

run_r2_dup_revive.sh runs two arms: the build's library (must PASS) and
bt_cursor.o rebuilt with -DR2_REVIVE_SABOTAGE, the R2 redirect disabled (must
FAIL -- the teeth check).  FAILs on master 2026.10.8, LTS 2026.10.7.1 and the
LTS+R1 build (28 cases); PASSes with the fix.  MANIFEST row added so the db
CI tier runs it; the sabotage guard is a one-line #ifdef in __bamc_put.
R2 (the equal-key revive for duplicate sets) is now fixed in __bamc_put, so
the shared-key bail in __bam_revive_chk only ever sees a set with a live
member whose key must not move.  The KNOWN-ISSUES R2 row records the full
scope (every duplicate shape, not just on-page DUPSORT) and the fix.
__bam_opd_alldel descended an off-page duplicate tree's internal pages with
GET_BINTERNAL for both P_IBTREE and P_IRECNO.  Both structs begin with the
child pgno so it read the right bytes, but a recno dup tree (unsorted DB_DUP
off-page) is P_IRECNO/RINTERNAL, so read it with GET_RINTERNAL.  The test now
adds a tall off-page tree (3000 members, several dup pages) for DB_DUP and
DB_DUPSORT so the internal-page descent is actually exercised, and gives each
member a unique 7-byte value so DB_DUPSORT does not reject it.
@github-actions

github-actions Bot commented Nov 3, 2026 •

Copy link
Copy Markdown
Contributor

ABI diff vs v2026.10.8 (libabigail — authoritative)

abidiff exit bitmask: 0

Removed exported symbols (nm -D, _NNNN version suffix normalized)

None.


Advisory: libabigail/nm is the authoritative binary-ABI check; Coccinelle is complementary source-level early warning. See dist/cocci/README.md.

@github-actions

github-actions Bot commented Nov 3, 2026

Copy link
Copy Markdown
Contributor

Coccinelle convention checks

No new violations. ✅

Resolved since baseline (3) -- update dist/cocci/baseline.txt to lock these in.
rule_mutex_unbalanced|MUTEX_UNBALANCED|src/btree/bt_curadj.c|return (ret);
rule_mutex_unbalanced|MUTEX_UNBALANCED|src/crypto/mersenne/mt19937db.c|return (ret);
rule_mutex_unbalanced|MUTEX_UNBALANCED|src/mp/mp_register.c|return (ret);

@gburd

gburd commented Nov 3, 2026

Copy link
Copy Markdown
Collaborator Author

Do not merge (lead review). With this change, two equal-key puts over an all-deleted, pinned DUPSORT set create two separate key items instead of one duplicate set:

  • DB->verify returns DB_VERIFY_BAD (BDB1067 Page 1: non-dup dup key at entry 4);
  • DBC->count is 1 where it should be 2;
  • a DB_PREV walk skips the following key.

The reproducer is /scratch/libdb/r2-work/r2_walk_corruption.c. Without this change the tree stays valid (verify 0, count 2); only the R2 key-bytes bug remains. A fix is being reworked.

gburd added 4 commits November 2, 2026 20:49
… v2)

The first R2 fix redirected an equal-key put over a fully-deleted duplicate
set to a fresh DB_KEYFIRST insert.  That inserted a NEW standalone key item
next to the still-pinned dead set; a second equal-key put then found the dead
set again (an exact match, still all-deleted) and redirected again, producing
a SECOND lone key item.  Equal-comparing keys then formed two separate non-dup
key items, which DB->verify rejects ("non-dup dup key"), DBC->count reported 1
instead of 2, and a reverse walk dropped a trailing record.  The tree was
structurally corrupt.  (Lead-reproduced with r2_walk_corruption.c.)

Fix: the members of a duplicate set share ONE physical key item -- every
member slot's inp[] points at it -- so a single in-place rewrite of that item
changes the key for the whole set at once, and the new member then joins by
the ordinary duplicate path, keeping the set a single set.  A second equal-key
put now finds the set already carrying the caller's key bytes, so it simply
joins; no second key item is created.

  - on-page, non-overflow key: __bam_ritem replaces the shared item and
    re-aims every inp[] offset that referenced it (its __bam_repl log record
    replays the same way, so recovery is correct with no new log record);
    a growing key that will not fit returns DB_NEEDSPLIT, which __bamc_put's
    existing retry splits and re-drives, exactly as for __bam_iitem;
  - off-page duplicate tree: the key is a lone (unshared) slot in the parent
    leaf, rewritten by delete-and-re-add as R1 does for a non-dup slot, which
    also covers overflow keys and type changes on that lone slot.

A deleted set whose SHARED on-page key is an overflow item is left under its
old key bytes (there is no logged in-place rewrite that keeps the shared
on-page offset for an overflow item).  That is still one valid duplicate set
-- DB->verify clean, count correct -- identical to master, differing only in
the key bytes; it is not corruption.  KNOWN-ISSUES records the residual.

The pinning cursor still sees the dead members as deleted: a key item is never
B_DISSET (only its data slots are), and __bam_ritem preserves that state.
… fix

Rework the R2 regression for the corruption the first fix caused.  Every case
now does TWO equal-comparing puts (data "NEW" then "NE2") -- the second put is
what exposed the first fix's bug, where the redirect made each put a fresh
standalone key item so two "equal" keys ended up as two non-dup key items --
and then asserts, for every case:

  - DB->verify is clean (the first fix produced "non-dup dup key");
  - DBC->count on the key equals the live set size (the first fix reported 1);
  - a forward DB_NEXT walk and a reverse DB_LAST+DB_PREV walk return the same
    complete, mirrored set of live records (the first fix dropped a record on
    the reverse walk);
  - the two NEW records share ONE key item, not two;
  - the key bytes are the caller's "ABC" for the shapes the fix rewrites, and
    stay "abc" (a valid single set, not corruption) for the one documented
    residual: an on-page duplicate set whose SHARED key is an overflow item.

DB->verify runs after every case (txn and no-env), and again after recovery
from the log alone (DB_RECOVER_FATAL, the .db removed) for every txn case.
Coverage: DB_DUP and DB_DUPSORT; on-page and off-page; pin and no pin; a tall
off-page tree; overflow keys on-page (residual) and off-page (rewritten); a
growing key that forces the in-place rewrite to split; the live-member control
(delete 1 of 3 -- the key must not move).

Teeth confirmed on three libraries: the first R2 fix FAILs (DB_VERIFY_BAD +
wrong count), master FAILs (wrong key bytes), the new fix PASSes.  The sabotage
arm (bt_cursor.c -DR2_REVIVE_SABOTAGE, rekey disabled) reverts to the master
behaviour and FAILs the key-bytes check.
The first R2 fix (redirect an equal-key put over a fully-deleted dup set to a
fresh DB_KEYFIRST insert) corrupted the tree: a second equal-key put made a
second standalone key item, so two equal-comparing keys became two non-dup key
items and DB->verify failed.  Update the design note (rfc/0014/R2-DUP-REVIVE.md)
and KNOWN-ISSUES to describe the replacement -- rewrite the set's one shared
key item in place so the whole set adopts the caller's bytes and the new member
joins the single set -- and the narrow documented residual (an on-page set
whose shared key is an overflow item keeps its old bytes, still a valid single
set).  Refresh the __bam_revive_chk comment, which referred to the redirect.
A case-insensitive DB_DUP/DB_DUPSORT btree driven with random pins, puts of
equal-comparing spellings, and deletes.  After every op it requires DB->verify
to pass, the live record count per case-folded key to match a model, and every
live record of one folded key to carry a single spelling (one duplicate set).

This directly catches the first R2 fix, whose redirect made two equal-comparing
keys into two separate non-dup key items: 5 of 60 seeds (x300 ops) fail with
DB_VERIFY_BAD "non-dup dup key" or "two spellings in one walk".  The v2 fix
passes 60/60; master passes too (it keeps a valid single set, only the key
bytes differ, which the case-folding model tolerates), so the fuzz isolates the
structural corruption rather than the benign key-bytes difference.

run_r2_fuzz.sh drives 60 seeds x 300 ops (~30 s); MANIFEST entry added.
@gburd gburd changed the title btree: an equal-key put over a fully-deleted duplicate set kept the old key (R2) btree: equal-key put over a fully-deleted duplicate set (R2) Nov 3, 2026
@gburd

gburd commented Nov 3, 2026

Copy link
Copy Markdown
Collaborator Author

Ready for re-review. The corrupting redirect is replaced by an in-place rewrite of the duplicate set's shared key item (commits 07a240a fix, 2d24d21 multi-put test, 1711687 docs, d816880 fuzz).

  • r2_walk_corruption.c (the lead's reproducer) on the fix: verify 0, count 2, both walks complete, key ABC. On master: valid tree but key abc. On the first R2 fix: DB_VERIFY_BAD "non-dup dup key".
  • --enable-diagnostic test/db tier: 65 PASS, 4 SKIP, 0 FAIL, including run_r2_dup_revive (normal PASS, sabotage FAIL 26 = teeth).
  • Fuzz teeth (60 seeds × 300 ops): new fix 0 FAIL, first R2 fix 5 FAIL (DB_VERIFY_BAD / "two spellings in one walk"), master 0 FAIL (valid single set — the fuzz isolates the structural corruption from the benign key-bytes difference).
  • No on-disk / log / region / ABI change; the three new helpers are static. One documented residual: an on-page set whose shared key is an overflow item keeps its old bytes — still one valid set (verify clean, count correct), recorded in KNOWN-ISSUES R2.

Built and tested --enable-diagnostic on Debian 12.

A leftover from an earlier shape of the helper; it was always 1 and COMPQUIET'd.
This is the version built and tested on the diagnostic + TCL builds.
@gburd

gburd commented Nov 3, 2026

Copy link
Copy Markdown
Collaborator Author

Lead re-verification of v2: merge-ready.

  • r2_walk_corruption.c: verify 0, count 2, both walks complete.
  • My randomised fuzz: 601/601 seeds × 500 ops pass on v2. The same fuzz fails v1 on 20 of 301 seeds.
  • Extra edge test, the shared key item at the lowest page offset. This hits __bam_ritem's p == t fast path, which re-aims only inp[indx]. Keys of a different length (3→1 and 3→8 bytes), with the set inserted first and last: 6/6 verify 0, one set with the new spelling, and the pinned deleted cursor returns DB_KEYEMPTY. The fast path is safe because every member shares the one offset.
  • Documented residual (shared key that is an overflow item keeps the old bytes, valid set, same as master): accepted.

@gburd
gburd marked this pull request as ready for review November 4, 2026 01:57
@gburd
gburd merged commit 03b3b9b into master Nov 4, 2026
63 checks passed
@gburd
gburd deleted the fix/r2-dup-revive branch November 4, 2026 01:57
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