Skip to content

fix(db): SIGSEGV when fetch/query race a segment switch; unblock reader concurrency - #715

Open
YongqiYin wants to merge 10 commits into
alibaba:mainfrom
YongqiYin:fix/segment-list-race-read-scaling
Open

fix(db): SIGSEGV when fetch/query race a segment switch; unblock reader concurrency#715
YongqiYin wants to merge 10 commits into
alibaba:mainfrom
YongqiYin:fix/segment-list-race-read-scaling

Conversation

@YongqiYin

@YongqiYin YongqiYin commented Aug 31, 2026

Copy link
Copy Markdown
Collaborator

Closes: #714

What

Both read entry points crash with SIGSEGV on Linux when a writer crosses a
segment switch (details in #714). fetch() faults inside the in-memory table
rebuild; query() faults on the same store, reached through the planner's
fan-out (VectorRecallNode::collect_batch → SegmentImpl::fetch → fetch_normal → convertToTable). Two races and two read-path changes:

  1. Segment listget_all_segments() takes write_mtx_ shared
    (collection.cc), as stats() already does; _unsafe variant added for
    callers already holding the lock. Probe on main: 99.7% of doc_ids_
    push_backs executed while a reader was inside this function.
  2. Segment teardownfinish_memory_components() /
    init_memory_components() take seg_col_mtx_ exclusively, and the two
    combined-vector-indexer accessors take it shared; dump() takes seg_mtx_
    exclusively. Readers used to reach memory_store_ / persist_stores_ /
    the indexer maps while flush() was republishing them under a different
    lock, so a reader landing between memory_store_->close() and
    persist_stores_.push_back() found neither and dropped that block's rows
    with no error and no log. Locking the rebuild covers all three flush()
    entry points (buffer-full insert, segment switch, close).
  3. Point-read fast pathMemForwardStore::convertToTable() materializes
    only the batch holding the requested rows instead of rebuilding the whole
    in-memory store: O(store) → O(1), and it no longer walks the stack the
    crash faults in.
  4. Reader locksseg_mtx_ and cache_mtx_ become shared_mutex;
    read-only accessors take them shared (verified member by member).
    close() now takes cache_mtx_ exclusively, with flush_locked()
    extracted so it does not lock twice.

Also from review: the three convertToTable helpers moved to snake_case, and
tests/db/concurrent_fetch_test.cc was renamed concurrent_read_test.cc now
that it covers both read paths.

Test plan

tests/db/concurrent_read_test.cc, one writer crossing frequent segment
switches throughout:

  • fetch: 4/8 readers comparing every field of every fetched doc against
    the deterministic generator output. 3/3 SIGSEGV on unfixed main, passes
    with the fix (2.4M field comparisons on Linux, 1.6M on macOS, 0 errors).
  • query: 4 queriers running KNN with an output field, asserting no errors,
    no empty results, no missing field values, and that each value matches the
    pk it came back with. 3/3 SIGSEGV on unfixed main, passes with the fix
    (19.5k queries on Linux, 16.5k on macOS, 0 errors).

Two settings are load-bearing: max_doc_count_per_segment = 4,000
(schema-validated ≥1,000) so switches happen inside the run window, and the
writer is capped at 50k docs and asserted to be alive (writer_errors plus
a minimum docs_written) — otherwise a starved writer silently turns the run
into a no-writer one and the test passes without exercising the crash path.

Full suites: 222 tests on macOS arm64, 224 on Linux x86_64.

Measured impact (64-core Linux; same-binary repeat variance <4%)

metric before after
point-read latency (500→4,000 docs in the in-memory store) 472→1,982µs, grows with doc count (O(N)) flat ~180µs at any doc count (O(1))
fetch throughput 1→8 threads, no writer flat (no reader-reader parallelism) 5.8x scaling (5.6k→32k reads/s)
fetch throughput 1→8 threads, with writer crashes 6.4x scaling (8.7k→56k reads/s)
query throughput 1→8 threads, no writer 1.15x (597→685 q/s) — materialization serialized under the exclusive lock 3.4x (600→2,038 q/s)
writer throughput as readers 0→8 starves (290 docs/s, macOS) / crashes (Linux) flat (−2.4%)

Known trade-offs

  • Reads now wait for the in-flight write batch. Before this PR, readers
    fetched the segment list with no lock at all — that unlocked read is exactly
    the race being fixed. After it, get_all_segments() takes write_mtx_
    shared, so a reader waits for the current write_impl batch. This is the
    only newly-blocked path at the collection layer: create_index /
    add_column already blocked readers for the whole DDL task (they hold the
    schema lock exclusively), and per-insert exclusion already existed at the
    segment layer.
  • Reads now wait for a segment teardown or rebuild. Item 2 blocks readers
    for the duration of dump() (avg 16ms, max 24ms, 26 occurrences over an
    8s window in the regression test) and of a memory-component rebuild.
    Previously they were not blocked — they read state that was being torn down.
    Measured cost is inside the ±1% run-to-run noise on every workload above.
  • Writers can be passed by concurrent readers. With std::shared_mutex, a
    writer waiting for a segment lock must wait out the readers currently inside
    it. The wait stays bounded: new readers cannot arrive (the writer holds the
    collection's exclusive write_mtx_) and each in-flight read is now an O(1)
    lookup. Writer throughput stays flat as readers go 0→8 (−2.4%).

Fix a real data race: readers calling get_all_segments() (query / fetch /
group_by_query / delete_by_filter / stats / prepare_iterate) read the
writing_segment_ shared_ptr and doc_ids_ with no lock held in common with
writers, which reassign writing_segment_ and push_back into doc_ids_ under
exclusive write_mtx_. On Linux glibc this crashes under concurrent
read+write load (7/7 runs: reader faults inside arrow::Table::FromRecord-
Batches while the writer tears the same segment down via dump()); on macOS
the same race silently corrupts results (probe hit rate 98.5%). Fix: take
write_mtx_ shared inside get_all_segments() and add
get_all_segments_unsafe() for callers that already hold it.

Also remove the bottlenecks this exposed on the read path, so the fix does
not trade correctness for throughput:
- SegmentImpl::seg_mtx_ -> shared_mutex; Fetch()/get_global_doc_id()
  take it shared (read-only accessors)
- MemForwardStore::cache_mtx_ -> shared_mutex; the 4 read-only
  accessors take it shared
- MemForwardStore::convertToTable(): single-source fast path; a point
  fetch no longer rebuilds the whole in-memory store into an Arrow
  table (O(N) -> O(1), 500us -> 69us with 4k docs in buffer)

Verification: 218 gtests green; 1.4M (macOS) + 2.1M (Linux glibc)
field-by-field content-verified concurrent fetches, 0 errors; reader
scaling 5.8-6.4x (1->8 readers, 64-core Linux); baseline crashes 7/7
vs fixed 3/3 green under the same load.
Guards the segment-list race fix and the newly-shared reader locks:
one writer thread inserts (crossing frequent segment switches) while
N reader threads fetch preloaded docs and compare every returned doc
field by field against the deterministic expectation. Two load shapes
are covered: with a writer the sealed segments serve reads from the
persisted (mmap) path; without one every doc stays in
MemForwardStore::cache_.

max_doc_count_per_segment is deliberately small (4k): on the unfixed
baseline reader contention starves the writer to ~2.5k docs/s, so a
40k threshold would produce no segment switch at all inside the run
window - and the switch (dump(), which takes no seg_mtx_) is exactly
where the baseline crashes.

Verified on 64-core Linux glibc: crashes 3/3 on the pre-fix baseline
(SIGSEGV while the writer switches segments); passes with the fix
(774k field-by-field verified fetches, 0 errors). Also passes on
macOS arm64 (489k fetches, 0 errors).
Copilot AI lite review requested due to automatic review settings August 31, 2026 12:45
@YongqiYin
YongqiYin requested a review from zhourrr as a code owner August 31, 2026 12:45

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

Comment thread src/db/index/segment/segment.cc Outdated
Comment thread src/db/index/storage/memory_forward_store.cc Outdated
Comment thread src/db/index/segment/segment.cc
Comment thread src/db/index/storage/memory_forward_store.cc
Comment thread tests/db/concurrent_fetch_test.cc Outdated
finish_memory_components() and init_memory_components() rewrite
memory_store_, persist_stores_ and the vector indexer maps under the
exclusive seg_mtx_, but the query read path reaches the same state under a
different lock: fetch_normal()/scan() hold seg_col_mtx_ shared, and
get_combined_vector_indexer() held no lock at all.

Between memory_store_->close() and persist_stores_.push_back() neither
branch in fetch_normal applies, so it skips the block and the query loses
those rows -- no crash, no error, no log. With the window widened to 100ms,
12 of 16 queries dropped rows (88 total).

Take seg_col_mtx_ exclusively in both rebuild paths and shared in the two
combined-indexer accessors. The order stays seg_mtx_ -> seg_col_mtx_:
flush() has three call sites (close, internal_insert, dump) and none of them
holds seg_col_mtx_. After the fix, 0 rows dropped under the same conditions.

From review, also in this commit:
- dump() takes the exclusive seg_mtx_, covering Fetch(doc) and
  get_global_doc_id() against the same teardown.
- MemForwardStore::close() takes the exclusive cache_mtx_, with flush_locked()
  split out so close() does not lock it twice.
- The three convertToTable helpers renamed to snake_case.
- New QuerySucceedsUnderConcurrentWrites test, asserting field completeness:
  the usual "no error / non-empty" checks stayed green through all 12
  dropped-row events.

Throughput stays within the +-1% noise floor (fetch -0.95%/+0.57%, query
-0.53%, writer flat). Regression: 222 tests on macOS, 224 on Linux.
The file now covers both read entry points -- fetch() under seg_mtx_ and
query() fanning out under seg_col_mtx_ -- so "fetch" in the name, the
fixture and the header comment described only half of it.

While renaming, two things in the query case were off:

- It only checked that the requested field was present. Every field is
  derived from the doc id and the pk is "pk_<id>", so the value can be
  checked against the pk it came back with; that also catches rows
  stitched together across blocks, not just dropped ones.
- Errors matching the known Insert/Query transient were skipped without
  a trace. They are now counted and printed separately, which shows the
  transient still fires on macOS (4 in 16k queries) but not on Linux.
@YongqiYin YongqiYin changed the title fix(db): SIGSEGV under concurrent fetch + segment switch; unlock concurrent point reads fix(db): SIGSEGV when fetch/query race a segment switch; unblock reader concurrency Sep 7, 2026
Collapse seg_col_mtx_ into seg_mtx_ so overlapping segment state (memory_store_, persist_stores_, block metadata, indexer maps) is guarded by a single lock. Public entries lock; init/finish_memory_components and fetch_exec_unsafe run lock-free under the caller's lock; close() now takes the exclusive lock.
Keep this branch's single-lock segment design and short-lived collection
lock: upstream alibaba#722 holds write_mtx_ shared for the whole query, which starves
the exclusive writer on glibc (measured 6250 -> 12 docs/s with 4 readers,
reproduced 3/3 on pure upstream code). Port the two indexer accessor locks
added by upstream alibaba#726 (get_vector_indexer / get_quant_vector_indexer) so the
merged lock covers everything seg_col_mtx_ did.

Verified on Linux and macOS: 224 / 222 tests green, upstream's stricter
Optimize concurrency test passes 5/5, query transients are zero, and fetch
throughput is ~17x upstream with the writer running at full speed.
@YongqiYin

Copy link
Copy Markdown
Collaborator Author

评审期间上游合入了 #722(synchronize reads of writing segment)和 #726(synchronize flat cache and
collection block switching),修的是本 PR 同一批竞态,但方向相反:

  • 上游:query/fetch 全程持 shared write_mtx_,相当于查询期间冻结整个集合。语义简单、一致性强,
    但读取进行期间写者无法推进。
  • 本 PR:仅在取段列表时短暂持 shared write_mtx_,之后由段级 shared 锁保证段内容一致,读写并发。

把两套方案在同一台机器上做了对照实测。

测试条件

tests/db/concurrent_read_test.cc,(Linux,64 核),每组 3 次,8 秒窗口:

  • 写侧:1 个线程按 100 条/批持续 insert,上限 50,000 条;max_doc_count_per_segment=4000
    运行期间会反复跨段切换
  • 读侧:4 个线程做点查(逐字段校验内容),或 4 个线程做 KNN topk=10
  • 读写同时进行,下表三个指标取自同一次运行

结果

指标 上游锁方案 本 PR 倍数
fetch 吞吐(4 读者 + 1 写者) 5,135–5,352/s 88,564–89,380/s ~17x
query 吞吐(4 查询者 + 1 写者) 1,672–1,718/s 2,441–2,455/s ~1.4x
写者吞吐(与上面读负载并发) 12 docs/s——8 秒内只完成 1 批 100 条 6,250 docs/s——写满 50,000 条上限 ~520x

写者停滞的原因是读者饥饿:glibc 的 rwlock 读者优先,读者全程持 shared write_mtx_ 时,持续的读流量
会让排他写者长时间拿不到锁。macOS 的 libc++ 写者优先,因此只在 Linux 复现。该现象已用纯上游代码
(不含本 PR 任何改动)独立复现 3/3。

正确性对齐

#722 移除了 collection_test.cc 中对 fetch table failed 瞬态的容忍,断言更严格。本 PR 在该测试下
Linux 5/5、macOS 8/8 通过;新增的高压并发测试约 5.7 万次 query 也是 tolerated=0——段锁合并后
internal_insert 的元数据发布与读者严格互斥,顺带消除了这个瞬态。

合并的处理

保留本 PR 的锁设计,并吸收上游的覆盖面:补上 get_vector_indexer() /
get_quant_vector_indexer() 的 shared 锁。

After replacing whole-query write_mtx_ with segment-level
locking, meta reads outside seg_mtx_ were left racing Insert/flush:

- add Segment::doc_id_range()/doc_count_snapshot() locked accessors; route
  the fetch segment search and DocFilter's brute-force threshold through them
- make DeleteStore::empty_ atomic (get_filter() reads it lock-free per query)
- document flush()/init/finish caller-held lock preconditions incl. the
  collection-level flush() path; fix stale comments and trim long ones
@YongqiYin
YongqiYin requested a review from egolearner September 8, 2026 11:20
Comment thread src/db/index/segment/segment.h Outdated
Comment thread src/db/collection.cc Outdated
…consistency

- Make Segment::doc_id_range()/doc_count_snapshot() pure virtual; the base
  default dereferenced meta() unconditionally, which is null for MockSegment.
  MockSegment now implements them explicitly.
- Restore the shared write_mtx_ across delete_by_filter's scan so the matched
  set reflects one collection state (via get_all_segments_unsafe() to avoid
  recursively locking write_mtx_). Plain queries keep the lighter view.
Comment thread src/db/collection.cc
std::vector<Segment::Ptr> get_all_segments() const;

//! Same as get_all_segments(), for callers that already hold write_mtx_.
std::vector<Segment::Ptr> get_all_segments_unsafe() const;

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

private?

std::shared_lock<std::shared_mutex> lock(seg_col_mtx_);
// Shared: finish_memory_components() migrates entries between these maps,
// so an unlocked read can miss an indexer or count it twice.
std::shared_lock<std::shared_mutex> lock(seg_mtx_);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P1] The reader lock does not cover all writers of the segment metadata. In CollectionImpl::switch_to_new_segment_for_writing(), remove_writing_forward_block() (collection.cc:1730) runs after dump() has released seg_mtx_. A concurrent query can therefore pass has_writing_forward_block() in the CombinedVectorColumnIndexer constructor and race with the optional reset before value() or the BlockMeta copy. This is a data race with potential exceptions/crashes, not merely a weakly consistent read. Could we move this metadata removal into a Segment operation protected by the exclusive seg_mtx_?

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.

[Bug]: SIGSEGV under concurrent fetch + insert when the writer crosses a segment switch

4 participants