Skip to content

fix(qam): P7 -- DB_INORDER + DB_CONSUME spins forever on a deleted record - #205

Merged
gburd merged 2 commits into
masterfrom
fix/p7-inorder-consume
Sep 21, 2026
Merged

gburd merged 2 commits into
masterfrom
fix/p7-inorder-consume

Conversation

@gburd

@gburd gburd commented Sep 21, 2026

Copy link
Copy Markdown
Collaborator

What

A queue opened with DB_INORDER and drained with DB_CONSUME hangs at 100% CPU as soon as the record at the head was deleted rather than consumed. Single thread, no concurrency: 20 appends, one del, then consume.

Root cause

Two notions of "the head" disagree, and the is_first guard demanded they match:

  • the local first, which advances past records confirmed gone
  • the persistent meta->first_recno, which only advances when a consume succeeds

Skipping a deleted record advances the first but not the second, so they legitimately differ. The guard compared only against the persistent value and jumped to retry, which re-enters DB_FIRST and resets cp->recno to the hole just skipped:

P7 retry=11 recno=10 first=11 is_first=1 inorder=1
P7 retry=12 recno=11 first=11 is_first=1 inorder=1
P7 retry=13 recno=10 first=11 is_first=1 inorder=1
P7 retry=14 recno=11 first=11 is_first=1 inorder=1

DB_INORDER is needed to reach it: the non-inorder path leaves the switch before the test.

Fix

Accept either notion of the head. When nothing has been skipped the two are equal and this is exactly the original test, so the restart-on-moved-head behaviour the guard exists for is preserved.

Test

test/db/run_qam_inorder_consume.sh, registered in test/MANIFEST.

The defect is a hang, so rc cannot judge it — the driver emits exactly one VERDICT line and the runner treats its absence as failure.

Teeth verified both ways on rebuilt-and-verified libraries (the first attempt was a false pass from a stale build, caught and redone):

build inorder=1 inorder=0 control
reverted HUNG, no verdict, killed at 60s PASS
fixed PASS, consumed=19 PASS, consumed=19

The control staying green in the must-fail arm is what shows the test isolates this defect rather than the whole consume path.

run_all.sh: 18 PASS / 0 FAIL. run_qam_extent_vrfy and run_qam_readpath_bound still pass.

…record

A queue opened with DB_INORDER, drained with DB_CONSUME, hangs at 100% CPU the
moment the record at the head has been DELETED rather than consumed. No
concurrency is required: a single thread, 20 appends, one del, then consume.

Root cause is a disagreement between two notions of "the head":

  * the LOCAL `first, which advances past records confirmed gone
@github-actions

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);

@github-actions

Copy link
Copy Markdown
Contributor

ABI diff vs v2026.09.11 (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.

The first version of this runner called timeout(1) directly and hard-coded a
`ls libdb.a .libs/libdb-*.so | head -1` library search. That broke four macOS CI
jobs with rc=127: stock macOS has no timeout(1), so the driver never ran, and the
runner reported "no VERDICT line" -- correctly refusing to call it a pass, but
for the wrong reason.

Rewritten to follow test/db/run_qam_readpath_bound.sh instead of reinventing it:
library discovery with .dylib candidates, static-lib preference, and the
pkg-config liburing dependency list (a static libdb.a otherwise fails to link
with undefined io_uring_* symbols -- hit locally in the nix shell).

The timeout handling cannot be copied wholesale, though. The other runners fall
back to running UNTIMED when neither timeout nor gtimeout exists, which is safe
for a test that merely takes a while. It is not safe here: the defect under test
is an infinite loop, so an unbounded run would wedge CI instead of reporting.
run_limited() therefore falls back to backgrounding the driver and polling with
kill -0, returning 124 like timeout(1), using only shell builtins.

Both paths verified with teeth in both directions, on rebuilt-and-checked
libraries:

  timeout path,  fix present  -> PASS  (both arms, consumed=19)
  timeout path,  fix reverted -> inorder=1 HUNG, inorder=0 control PASS -> FAIL
  poll fallback, fix present  -> PASS  (both arms)
  poll fallback, fix reverted -> inorder=1 HUNG, inorder=0 control PASS -> FAIL

The fallback mattering is the point: without proving the poll path can still
SEE the hang, macOS would have had a gate that could only ever pass.

Also added an explicit `test -x` on the freshly compiled binary, since a stale
one from an earlier build would otherwise give a false pass.
@gburd
gburd merged commit 1bc5999 into master Sep 21, 2026
57 of 59 checks passed
@gburd
gburd deleted the fix/p7-inorder-consume branch September 21, 2026 14:56
gburd added a commit that referenced this pull request Sep 21, 2026
The tracker is the index a reader checks first, so a fix that lands in code but
leaves the tracker saying 'open' is only two-thirds done.
gburd added a commit that referenced this pull request Sep 21, 2026
…tion

The flagapi teeth step asserts that FLAGAPI_STRICT=1 FAILS, and requires each
refused XFAIL to appear BY NAME so that a build break cannot be misread as
teeth. P7 is now fixed (PR #205), so inorder@inorder reports PASS and no longer
appears in that list -- which made the step fail. That is the gate working
correctly: it refuses to let a fixed defect stay recorded as broken, and the
step's own error text names this exact remedy ("either a fix landed -- remove
the XFAIL branch, this step's expectation and the MANIFEST note").

Changes: the expected refused-XFAIL list drops inorder@inorder (P6 and P8
remain), and the step name and messages say P6/P8. The MANIFEST note records P7
as fixed.

The DRIVER needs no edit, which was the design intent: the in-driver watchdog
emits XFAIL only if the drain does not return, so when the defect goes away the
mode reports PASS by itself. Verified against a build carrying the fix:

  --- backup_nologs:   FAIL (XFAIL refused)   <- P6, still open
  --- inorder@default: PASS
  --- inorder@inorder: PASS                   <- retired itself, no edit
  --- noflush@default: PASS
  --- noflush@noflush: FAIL (XFAIL refused)   <- P8, still open

Worth recording how nearly this went wrong: the first two strict runs showed
inorder@inorder STILL refusing its XFAIL, which looked like the P7 fix being
incomplete. It was a stale build -- flagapi-run.sh takes the build directory as
$1, not from $BUILDDIR, so both runs silently measured build_unix from before
the fix. Same shape as the false pass caught earlier in this session, and the
reason the rule is to verify the artifact rather than the invocation.
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