Summary
A pre-existing use-after-free in the cc-entry lock lifetime. Under a multi-node cluster with the data store + aggressive eviction (kickout_data_for_test=true, checkpointer_interval=1) plus concurrent scans/writes, a PostReadCc can dereference a NonBlockingLock that has already been recycled, aborting the node.
Crash signature
eloqkv: .../abseil-cpp/absl/container/internal/raw_hash_set.h:325:
probe_seq<16>::probe_seq(size_t, size_t): Assertion `((mask + 1) & mask) == 0 && "not a mask"' failed. (SIGABRT)
Crashing thread (core + gdb):
#3 absl::container_internal::probe_seq<16>::probe_seq (corrupt capacity)
#6 raw_hash_set<FlatHashMapPolicy<uint64,uint32>>::find_non_soo
#8 txservice::NonBlockingLock::ReleaseReadIntent non_blocking_lock.cpp:503
#9 txservice::NonBlockingLock::ClearTx non_blocking_lock.cpp:562
#10 txservice::CcMap::ReleaseCceLock cc_map.cpp:412 (lk_type=NoLock, recycle_lock=true)
#11 TemplateCcMap<EloqKey,RedisEloqObject>::Execute template_cc_map.h:1314
#12 PostReadCc::Execute cc_request.h:262
#13 CcShard::ProcessRequests -> TxProcessor::RunOneRound (is_ext_proc=true)
In the core, the NonBlockingLock's read_intentions_ (absl::flat_hash_map<TxNumber,uint32_t>) has garbage capacity_ / size_ / slot pointer — the lock object was recycled/clobbered while still referenced.
Root cause
PostReadCc::Execute (template_cc_map.h:1243) obtains the entry from the cached cce_addr (cce_addr.ExtractCce(), a KeyGapLockAndExtraData* captured at read time), then calls cc_entry.GetKeyLock()->ReleaseReadIntent(). If the cce's read-intent is released before that PostReadCc runs, the entry becomes IsFree() (cc_lock_and_extra_ == nullptr && IsPersistent()), the CleanDataForTest kickout (LocalCcShards::KickoutDataForTest → KickoutCcEntryCc with CleanType::CleanDataForTest) recycles it, and the later PostReadCc dereferences the recycled lock → UAF.
What is already correct (so the gap is the early intent release)
- Read-set insertion already guards
lock_type != LockType::NoLock at every site (point read tx_execution.cpp:2206; scan sites tx_execution.cpp:3007 / 3148 / 3369 / 5886). ReadCommitted reads map to NoLock (LockTypeUtil::DeduceLockType) and are not tracked; scans AcquireReadIntent per tuple.
- The kickout gate already skips locked entries:
CleanDataForTest → CcPageCleanGuardWithoutKickoutCc::CanBeCleaned = IsFree() && !GetBeingCkpt().
So the entry was IsFree() at kickout time, i.e. its read-intent had been released before the post-read. Suspected early-release sites (a read-intent refcount interaction): scan DrainScanner (PostReadType::Release, tx_execution.cpp ~3651), the transient last-cce pin DecrReadIntent (template_cc_map.h:3884 / ~3891), or a DedupRead edge (tx_execution.cpp:6370).
Dead code noticed nearby
The "record-and-don't-free a still-locked cce" defense is not wired up: CcPageCleanGuard::MarkClean(idx, delay_free) ignores its delay_free argument, and LruEntry::ClearLocks(ccs, ng, invalidate_owner_term) never references invalidate_owner_term (no caller passes true) — vestigial since #168 ("Restructure for eloqconverge"). For the genuine stale-ex-leader post-read, the only active guard is the term check (PostReadCc::CheckLeaderTerm + ReadWriteSet::ClearDataReadSet read-lock-term validation), which fires only on a term change (failover) and does not cover the same-term kickout case above.
Relation to PR #491 (bthread-mutex CC-request fix)
Not a regression from #491. Verified by A/B (built fix @1448c93 vs pre-fix @f62f0fa): both pass scan.tcl alone 15× and both crash identically under concurrent-writer stress with the same NonBlockingLock assertion. The CI failure on the eloqkv PR (eloqkv-pr-ent/compile-test build #285, config 7 = 3-node cluster + eloqstore + kickout_data_for_test, scan.tcl first flushdb → I/O error reading reply) is an intermittent manifestation of this pre-existing race, not a new bug introduced by #491.
Reproduction (flaky / timing-sensitive)
- Debug build,
WITH_DATA_STORE=ELOQDSS_ELOQSTORE, EXT_TX_PROC_ENABLED=ON, WITH_LOG_SERVICE=ON, OPEN_LOG_SERVICE=OFF, FORK_HM_PROCESS=ON.
- 3-node local cluster, shared eloqstore via minio,
--core_number=2 --checkpointer_interval=1 --kickout_data_for_test=true --max_processing_time_microseconds=1000.
- Concurrent writers (
SET churn) + scan.tcl loop → a data node SIGABRTs intermittently (usually within 1–2 scan iterations under light load; heavier load tends to hang instead).
- A deterministic repro / regression test is best done with
CODE_FAULT_INJECTOR (force a kickout/recycle of a read-set cce right before its PostReadCc).
Proposed fix direction
Ensure a cce referenced by an in-flight PostReadCc (i.e. in a tx's data/scan read-set) retains its read-intent until the post-read releases it, so the kickout's IsFree() gate skips it. Audit the read-intent refcount accounting across the scan drain / transient last-cce pin / dedup paths. Optionally remove (or properly wire up) the dead delay_free / invalidate_owner_term machinery.
Summary
A pre-existing use-after-free in the cc-entry lock lifetime. Under a multi-node cluster with the data store + aggressive eviction (
kickout_data_for_test=true,checkpointer_interval=1) plus concurrent scans/writes, aPostReadCccan dereference aNonBlockingLockthat has already been recycled, aborting the node.Crash signature
Crashing thread (core + gdb):
In the core, the
NonBlockingLock'sread_intentions_(absl::flat_hash_map<TxNumber,uint32_t>) has garbagecapacity_/size_/ slot pointer — the lock object was recycled/clobbered while still referenced.Root cause
PostReadCc::Execute(template_cc_map.h:1243) obtains the entry from the cachedcce_addr(cce_addr.ExtractCce(), aKeyGapLockAndExtraData*captured at read time), then callscc_entry.GetKeyLock()->ReleaseReadIntent(). If the cce's read-intent is released before thatPostReadCcruns, the entry becomesIsFree()(cc_lock_and_extra_ == nullptr && IsPersistent()), theCleanDataForTestkickout (LocalCcShards::KickoutDataForTest→KickoutCcEntryCcwithCleanType::CleanDataForTest) recycles it, and the laterPostReadCcdereferences the recycled lock → UAF.What is already correct (so the gap is the early intent release)
lock_type != LockType::NoLockat every site (point readtx_execution.cpp:2206; scan sitestx_execution.cpp:3007 / 3148 / 3369 / 5886).ReadCommittedreads map toNoLock(LockTypeUtil::DeduceLockType) and are not tracked; scansAcquireReadIntentper tuple.CleanDataForTest→CcPageCleanGuardWithoutKickoutCc::CanBeCleaned=IsFree() && !GetBeingCkpt().So the entry was
IsFree()at kickout time, i.e. its read-intent had been released before the post-read. Suspected early-release sites (a read-intent refcount interaction): scanDrainScanner(PostReadType::Release,tx_execution.cpp ~3651), the transient last-cce pinDecrReadIntent(template_cc_map.h:3884/~3891), or aDedupReadedge (tx_execution.cpp:6370).Dead code noticed nearby
The "record-and-don't-free a still-locked cce" defense is not wired up:
CcPageCleanGuard::MarkClean(idx, delay_free)ignores itsdelay_freeargument, andLruEntry::ClearLocks(ccs, ng, invalidate_owner_term)never referencesinvalidate_owner_term(no caller passestrue) — vestigial since #168 ("Restructure for eloqconverge"). For the genuine stale-ex-leader post-read, the only active guard is the term check (PostReadCc::CheckLeaderTerm+ReadWriteSet::ClearDataReadSetread-lock-term validation), which fires only on a term change (failover) and does not cover the same-term kickout case above.Relation to PR #491 (bthread-mutex CC-request fix)
Not a regression from #491. Verified by A/B (built fix
@1448c93vs pre-fix@f62f0fa): both passscan.tclalone 15× and both crash identically under concurrent-writer stress with the sameNonBlockingLockassertion. The CI failure on the eloqkv PR (eloqkv-pr-ent/compile-testbuild #285, config 7 = 3-node cluster + eloqstore +kickout_data_for_test,scan.tclfirstflushdb→I/O error reading reply) is an intermittent manifestation of this pre-existing race, not a new bug introduced by #491.Reproduction (flaky / timing-sensitive)
WITH_DATA_STORE=ELOQDSS_ELOQSTORE,EXT_TX_PROC_ENABLED=ON,WITH_LOG_SERVICE=ON,OPEN_LOG_SERVICE=OFF,FORK_HM_PROCESS=ON.--core_number=2 --checkpointer_interval=1 --kickout_data_for_test=true --max_processing_time_microseconds=1000.SETchurn) +scan.tclloop → a data node SIGABRTs intermittently (usually within 1–2 scan iterations under light load; heavier load tends to hang instead).CODE_FAULT_INJECTOR(force a kickout/recycle of a read-set cce right before itsPostReadCc).Proposed fix direction
Ensure a cce referenced by an in-flight
PostReadCc(i.e. in a tx's data/scan read-set) retains its read-intent until the post-read releases it, so the kickout'sIsFree()gate skips it. Audit the read-intent refcount accounting across the scan drain / transient last-cce pin / dedup paths. Optionally remove (or properly wire up) the deaddelay_free/invalidate_owner_termmachinery.