Skip to content

Fix heap corruption in FTS query path when scans race committing writers - #845

Merged
adsharma merged 1 commit into
mainfrom
fix-fts-query-heap-corruption
Aug 28, 2026
Merged

Fix heap corruption in FTS query path when scans race committing writers#845
adsharma merged 1 commit into
mainfrom
fix-fts-query-heap-corruption

Conversation

@adsharma

@adsharma adsharma commented Aug 28, 2026

Copy link
Copy Markdown
Contributor

Summary

Fixes three memory-safety bugs hit when concurrent QUERY_FTS_INDEX scans race committing writers and periodic CHECKPOINTs (investigated via the repro from #840 — heap corruption manifesting as malloc(): invalid next size (unsorted) at arbitrary allocation sites, e.g. SparseFrontier::addNode under the FTS tableFunc).

All three were confirmed with an ASAN build (BM_MALLOC buffer manager) running the repro workload (3 scanner threads + writer + checkpointer on an on-disk DB with an FTS index):

1. DenseFrontier out-of-bounds write

ASAN: heap-buffer-overflow WRITE of size 2 in DenseFrontier::addNode from DenseFrontierInitVertexCompute.

DenseFrontier buffers are sized from getMaxOffsetMap()NodeTable::getNumTotalRows(), which returns the current committed row count (NodeGroupCollection::numTotalRows) and is not snapshot-isolated. The parallel init vertex compute re-fetches a fresh max offset; a concurrent commit (the FTS index appends to the docs/terms tables on every CREATE) grows the table between the two reads, so the morsel range exceeds the allocation and the init writes past the end of the atomic<iteration_t> array.

Fix: clamp the init/visited ranges to the frontier's allocated size.

2. Checkpoint write gate released before the storage phase

ASAN: InternalException from UpdateInfo::getUpdateNode during writer commit, and a null-UpdateNode deref in UpdateInfo::rollback (updates[vectorIdx] wiped) — heap corruption in release builds.

TransactionManager::checkpointNoLock released the write gate after WAL rotation but before checkpointStoragePhase(). New write transactions could then mutate persistent chunks' UpdateInfo (the FTS insert path updates the terms-table df column) while the checkpointer was concurrently scanning and resetVersionAndUpdateInfo()-ing those same chunks.

Fix: hold the write gate for the entire checkpoint. (This also removes the acknowledged hash-index snapshot limitation noted in the old comment, since writers can no longer start mid-checkpoint.)

3. Unsynchronized unordered_map mutations in the GDS frontier

SparseFrontier/SparseFrontierReference::addNode mutate the per-table map with no lock while parallel frontier tasks can reach the sparse write path; concurrent rehash corrupts the heap. Fix: serialize via a mutex in GDSSpareObjectManager (shared between a sparse frontier and its references). Dense frontiers keep lock-free atomic stores.

Build fix

The vendored zstd declares the ASan poison functions without extern "C"; compiled as C++ it emits an unresolvable mangled __asan_unpoison_memory_region symbol, so ASan builds of liblbug.so fail to dlopen. Add proper C-linkage guards.

Extensions submodule

Bumped to include LadybugDB/extensions#69QFTSEdgeCompute copies (one per worker thread) share the scores map by reference and mutated it without synchronization; now guarded by a shared mutex mirroring the existing MatchTermsVertexCompute::resDfsMutex pattern.

Testing

  • ASAN repro run: the frontier overflow and UpdateInfo corruption no longer reproduce; the original release-build crash signature (malloc(): invalid next size (unsorted) inside SparseFrontier::addNode) is resolved.
  • Known remaining issue (needs a design follow-up, tracked as the core of SIGSEGV: null-pointer memmove under NodeTableScanState::scanNext on a background parallel scan (0.19.1, Python, darwin-arm64) #840): concurrent read-transaction scans can still race the checkpointer's storage phase when it rewrites/reclaims node-group chunk structures that in-flight scans reference (the original NodeTableScanState::scanNext SIGSEGV family). Repro crash rate on release builds dropped from ~always to ~1/3 with these fixes; the residual crashes are this remaining race.

TODO before merge: run make test and make extension-test.

Fixes three distinct memory-safety bugs hit by concurrent
QUERY_FTS_INDEX scans, committing writers, and periodic CHECKPOINTs
(reported in #840, heap corruption manifesting as
"malloc(): invalid next size (unsorted)" at arbitrary allocation
sites, e.g. SparseFrontier::addNode under fts tableFunc):

1. DenseFrontier out-of-bounds write (confirmed by ASAN):
   DenseFrontier buffers are sized from getMaxOffsetMap() ->
   NodeTable::getNumTotalRows(), which reads the current committed row
   count and is not snapshot-isolated. The parallel init vertex compute
   re-fetches a fresh max offset; a concurrent commit that grows the
   table between the two reads makes the morsel range exceed the
   allocated frontier, and DenseFrontierInitVertexCompute writes 2
   bytes past the end of the atomic<iteration_t> array. Clamp the init
   ranges to the allocated size.

2. Checkpoint write gate released before the storage phase:
   TransactionManager::checkpointNoLock released the write gate after
   WAL rotation but before checkpointStoragePhase(), letting new write
   transactions mutate persistent chunks' UpdateInfo (e.g. the FTS
   terms-table df update on every insert) while the checkpointer was
   concurrently scanning and resetVersionAndUpdateInfo()-ing those
   same chunks. Writers then committed or rolled back against a wiped
   UpdateInfo (InternalException in getUpdateNode, null-UpdateNode
   SEGV in rollback, and heap corruption in release builds). Hold the
   write gate for the entire checkpoint.

3. Unsynchronized unordered_map mutations in the GDS frontier:
   SparseFrontier / SparseFrontierReference::addNode mutate the
   per-table map with no lock while parallel frontier tasks can reach
   the sparse write path; serialize them with a mutex inside
   GDSSpareObjectManager. Dense frontiers keep lock-free atomic
   stores.

Also fixes the vendored zstd ASan poison declarations to have C
linkage, which is required now that the zstd sources are compiled as
C++ (otherwise ASan builds fail to load liblbug with an undefined
mangled __asan_unpoison_memory_region symbol).

Bumps the extensions submodule to include the matching FTS fix that
serializes QFTSEdgeCompute's shared scores map.

Remaining known issue (needs a design follow-up): concurrent
read-transaction scans can still race the checkpointer's storage phase
when it rewrites/reclaims node-group chunk structures that in-flight
scans reference (issue #840's original SIGSEGV family).
@adsharma
adsharma merged commit 1a233d2 into main Aug 28, 2026
4 checks passed
@adsharma
adsharma deleted the fix-fts-query-heap-corruption branch August 28, 2026 04:32
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