Repository navigation
btree: equal-key put over a fully-deleted duplicate set (R2) - #244
Conversation
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.
ABI diff vs
|
Coccinelle convention checksNo new violations. ✅ Resolved since baseline (3) -- update dist/cocci/baseline.txt to lock these in. |
|
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:
The reproducer is |
… 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.
|
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).
Built and tested |
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.
|
Lead re-verification of v2: merge-ready.
|
What
In a btree whose
bt_compareequates distinct byte strings (e.g. acase-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 butdeliberately 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 storedbytes 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 aDB_AM_DUPdatabase where__bam_dup_alldeletedreports the set entirely deleted with differing storedbytes, calls
__bam_dup_rekeyand falls through to the normal duplicate path:__bam_ritemreplaces the shared item andre-aims every
inp[]offset that referenced the moved region — includingeach sharing member — so all members follow. Its
__bam_repllog record isposition-indexed and replays through the same
__bam_ritem, so recoveryis 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 andre-drives.
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_KEYFIRSTinsert next to thestill-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->verifyrejects ("non-dup dup key");
DBC->countread 1 instead of 2 and a reversewalk dropped a record. That was structural corruption (lead-reproduced with
r2_walk_corruption.c). Equal-comparing keys must form one set, so theexisting 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->verifyclean,DBC->countcorrect), identical tomaster'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.mdR2.Tests (fail before, pass after)
test/db/r2_dup_revive.c(run_r2_dup_revive.sh): every case does twoequal-key puts and asserts, always —
DB->verifyclean,DBC->count= liveset 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.dbremoved). A sabotage arm(
-DR2_REVIVE_SABOTAGE) disables the rewrite and must FAIL.test/db/r2_fuzz.c(run_r2_fuzz.sh): randomised — a case-insensitiveDB_DUP/DB_DUPSORT btree driven with random pins, equal-comparing puts and
deletes; after every op,
DB->verifypasses, the per-folded-key live countmatches 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+ wrongcount; 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-diagnostictest/dbtier: 65 PASS, 4 SKIP, 0 FAIL, includingrun_r2_dup_revive(normal PASS, sabotage FAIL 26 = teeth).r2_walk_corruption.con the fix:verify 0,count 2, both walks complete,key
ABC.No EC2 of my own (shared dev box only).