Repository navigation
[fix](inverted index) Count only visible rows in BM25 statistics of merge-on-write tables - #69438
Conversation
…erge-on-write tables A merge-on-write segment keeps rows that a later load overwrote or deleted, marked only in the delete bitmap, and rows written with the delete sign, which queries filter out by predicate. BM25 statistics counted both until compaction removed them, and each replica compacts on its own schedule, so the replicas of a tablet scored the same row differently and a query that picked a different replica returned different scores and orders. When the scan reads a delete bitmap, collect statistics over the rows it returns only: per segment, leave the rows marked at the read version and the rows with the delete sign out of the document count and of each term's document frequency, counting the hidden rows a term's postings hold by leaping between them. Exact token counts of hidden rows are not stored, and norms hold lengths only approximately, so take the token count as the sum of the lengths the norms of the returned rows stand for, which every replica computes alike whether or not it has compacted.
|
Thank you for your contribution to Apache Doris. Please clearly describe your PR:
|
…eplica suite outside cloud mode Cloud mode keeps one shared copy of each tablet, so there are no replicas to compact apart, and its compaction is driven differently. The comparison with a freshly loaded table still runs in every mode.
When every row of a merge-on-write tablet is overwritten or deleted but not yet compacted, the visible-row statistics hold no documents. Scoring then treated them as missing and failed the query with "No data available for SimilarityCollector". Such a tablet has no row a query can match, so record that its segments were collected and report zero documents instead.
…ablet id The delete bitmap's aggregation cache is keyed by tablet, rowset, segment and version. The mock rowsets share their ids, so the second collection in HiddenRowsAreCollectedLikeCompactedAway read the first one's cached hidden rows.
…merge-on-write tables A NULL row has no tokens, but its norm stands for one, so summing the norms of the returned rows counted one token per NULL row. avgdl then grew with the share of NULL rows, and a merge-on-write table scored its rows differently from a duplicate table with the same data. Leave the rows of the index's null bitmap out of the token count; they still count as documents, as before. The sum of the non-NULL norm lengths of a CLucene index never changes, so keep it with the cached searcher instead of summing every row's norm on every query. Each query now reads only the norms of its hidden rows. Read the delete sign with the query's segment, page and file cache settings, and also read it when the zone map cannot vouch for its bounds or shows a non-zero minimum, since any non-zero sign deletes the row. Document the read modes whose returned rows differ from the counted ones. Follow the regression test conventions in the replica suite, and check it against a duplicate table with NULL rows.
…e options The statistics read postings, norms and null bitmaps before the scan does, so they are the reads that fill the file cache, yet their I/O context ignored disable_file_cache and inverted_index_snii_read_no_write_file_cache. Set both for a query's reads, as the rowset reader does; the delete sign read copies the context and follows them too. Document that a row indexed without tokens but not NULL keeps a norm that cannot be told from a one-token row's, and test that the non-NULL norm length sum is computed once per field of a cached searcher.
…stics searchers A CLucene searcher that BM25 statistics open is put into the searcher cache, but its index stream kept the opening query's I/O context, with that query's file cache statistics pointer, and stayed marked as reading index meta. Later reads without their own context went through the finished query's context, and the statistics' own reads ignored the query's file cache options. Reset the stream once the meta is read, as the query path's searcher opener does.
When a CLucene index holds several segments, its reader builds and keeps one more copy of a field's norms for all of them, filled without the segments' own norm caches, so the statistics read the norms a second time and held them twice for the cached searcher's lifetime, uncharged. Read the norms of each segment instead, as scoring does; a segment without the field's norms reads as zero lengths, as the combined norms do. Add a regression test for the statistics searcher's stream reset: with the CSIndexInput.readInternal debug point a cold score() query failed while the stream still read postings as index meta.
…a CLucene index The BM25 statistics opened a segment's CLucene index without catching CLuceneError, so an error reading the index, such as a failed remote read of its header, escaped the scanner thread and aborted the backend. Report it as the query's error, as the query path does. Keep the searcher reset test from passing on searchers the write path caches, test a hidden row whose length the norm holds only approximately (CLucene's norms are exact up to 40 tokens), and correct the norm comment.
|
/review |
|
run buildall |
There was a problem hiding this comment.
Request changes for two resource regressions: eager CLucene norms remain outside searcher-cache accounting (P1), and sparse hidden-row intersections repeatedly allocate segment-sized bitmaps (P2). Both are detailed inline. No existing inline findings were supplied.
Static review covered all 21 changed files, the scanner/read-source chain, both index formats, and the exact pinned CLucene norm implementation. The read flow is: captured rowsets + bitmap/version → scanner/shared tablet statistics → per-segment hidden IDs → document/term/norm totals → BM25 scoring.
| Required checkpoint | Conclusion |
|---|---|
| Goal and proof | Bitmap/delete-sign-hidden MoW rows are removed consistently from document counts and term frequencies; norm-based lengths address replica compaction differences. New unit/regression sources exercise this goal; execution was not performed. |
| Scope, clarity and reuse | Changes stay within BM25 statistics and reuse common IndexSource helpers with backend-specific overrides. |
| Concurrency and locks | Scanner-local results and shared first-collector locking are consistent. The new sum mutex serializes its initial I/O; CLucene reader/norm locks have no identified reverse acquisition path. No new thread or deadlock found. |
| Lifecycle and static initialization | Cached searchers retain readers and captured rowsets remain protected during collection. Borrowed opening I/O context is cleared. Retained norm accounting is the P1 finding; no new static-initialization dependency found. |
| Configuration and dynamic changes | No configuration added. Existing query file-cache, segment-cache and page-cache choices are forwarded on the changed paths; CLucene norms retain the existing context-free API behavior. |
| Symbol/storage compatibility | Internal signatures/default virtual methods change; no persisted index or wire format changes. Existing missing-norm rejection and no-bitmap behavior remain. |
| Thrift/protobuf IDs | Not applicable: no generated definitions changed. |
| Parallel and special paths | CLucene/SNII, split scanners, physical segment IDs, reserved scoring terms, NULLs, no-norm indexes and all-hidden tablets checked. No additional correctness issue confirmed. |
| Conditional checks | Empty inputs, invalid row IDs, zone-map sign pruning and all-hidden handling follow concrete cases and propagate failures. |
| Functional/negative/unit coverage | New tests cover compaction equivalence, NULL/hidden/all-hidden rows, sparse/dense cursors, invalid IDs, cache computation errors, multiple readers and I/O-context reset. The new checked-in end-to-end suites do not explicitly select SNII; its hidden-row behavior was reviewed statically. |
| Changed test results | Updated BM25 score and new golden outputs are consistent with the input rows and BM25 arithmetic. Their generation provenance and runtime reproducibility were not verified. |
| Observability | Existing collection VLOG and error propagation remain available. Local delete-sign read counters are not merged into the parent profile; retained norms also escape cache metrics as described inline. |
| Transactions/persistence/failover | No transaction, EditLog or metadata-write changes. Read-version and aggregate delete-bitmap keys match the normal rowset reader. |
| Writes/atomicity/crashes | No new data writes. I/O/cursor failures clear partial statistics and the shared collector returns the same failure to all callers; memory exhaustion risk is P1. |
| FE/BE variable propagation | No new transmitted variables. FE selected-index schema includes hidden delete-sign metadata; existing cache options reach the new reader context. |
| CPU/memory/performance | One-time norm totals and tablet sharing avoid redundant collection; the two inline findings identify remaining unbounded retained memory and repeated sparse-intersection work. |
| Other issues | Final static recheck found no additional substantiated defect. |
No additional user focus was supplied. Disclosed scope limits remain: light-delete predicates, merge-on-read/duplicate-key delete visibility, skip_delete_sign/show_hidden_columns, non-NULL zero-token encodings, and statistics remaining per tablet. These were considered and are not duplicate inline findings. No builds, tests, format checks or runtime experiments were run, as this invocation permits static review only.
Review converged after 2 round(s).
| #ifdef BE_TEST | ||
| RETURN_IF_ERROR(sum_lengths(&non_null_length_sum)); | ||
| #else | ||
| RETURN_IF_ERROR(inverted_index_cache_handle.get<CluceneSearcherCacheValue>() |
There was a problem hiding this comment.
[P1] Account for the norms retained by statistics collection
This callback now loads norms for every segment in a MoW tablet's captured read set before normal key/zone-map pruning, even when its hidden-row set is empty. CLucene's SegmentReader::norms() allocates and retains one byte per document per scored field, but these searchers were inserted using only getTermInfosRAMUsed() and neither their cache charge nor tracked bytes are updated afterward. A selective query over a large, low-vocabulary tablet can therefore leave gigabytes of norms resident while the searcher cache accounts for only small dictionaries; repeated queries across tablets can exhaust BE memory despite the configured cache limit. Previously such pruned segments only contributed dictionary statistics and did not load these norms. Please reserve/track the added buffers and update the cache charge, including for searchers already in the cache, or compute the sum without retaining unaccounted norm buffers.
There was a problem hiding this comment.
Fixed in 96a0b49: the statistics no longer ask CLucene readers for their retained norms. They read each segment's norms into their own buffer via IndexReader::norms(field, bytes) and free it after use. Only small sums are cached with the searcher: the per-field non-NULL norm-length sum, and the hidden rows' length sum for the last few sets of hidden rows per field (queries at one read version reuse it and read no norms). Measured on a 10M-row tablet with 4 scored fields and a key-pruned query: memory growth went from about +51 MB to +12–15 MB, the same as before the PR. Covered by HiddenNormLengthSumIsComputedOncePerRowSet and NormLengthSumsReadEachSegment.
| // Decodes every posting once and tests it against a bitset of `docs`. | ||
| Status count_listed_docs_by_scan(PostingsCursor& cursor, std::span<const uint32_t> docs, | ||
| uint64_t* out) { | ||
| std::vector<uint64_t> bits(docs.back() / 64 + 1, 0); |
There was a problem hiding this comment.
[P2] Avoid a segment-sized bitmap for sparse term intersections
The scan branch is selected from docs.size() and doc_freq(), but this allocation scales with the largest hidden row ID. For example, hidden_docs = {9999999} and a term with one posting select this branch and zero 1,250,000 bytes just to compare two IDs. CLucene also returns a non-null cursor for an absent term, so df = 0 incurs the same allocation before discovering EOF. Collection statistics repeat this for every query term and every segment with hidden rows on every query, making selective or many-term searches perform substantial segment-sized allocation/zeroing even with tiny or empty posting lists. Please use a sparse/sorted intersection for this case, account for the maximum ID when choosing a dense bitset, and short-circuit zero-frequency terms.
There was a problem hiding this comment.
Fixed in 96a0b49: count_listed_docs no longer allocates a bitset. The scan path walks the hidden rows and the postings together (both ascending) and stops once every hidden row is passed, so the cost no longer depends on the largest row ID. A term with doc_freq() == 0 returns before opening anything. Covered by CountListedDocsScansFarDocsWithoutWalkingEveryBlock (IDs up to 9,999,999) and CountListedDocsOfNothing.
Asking a CLucene segment reader for a field's norms makes it keep them for the cached searcher's lifetime, uncharged to the searcher cache, and the merge-on-write statistics visit every segment of the read set, including the segments the scan prunes and never scores. Read the norms into a buffer of the statistics' own, one segment at a time, and keep only sums with the searcher: the field's non-NULL norm lengths, and the hidden rows' lengths for the last few sets of hidden rows, so queries at one read version read no norms. Count the hidden rows a term's postings list by walking both ascending lists together instead of zeroing a bitset as large as the largest hidden row, and skip terms without postings.
TPC-H: Total hot run time: 28796 ms |
TPC-DS: Total hot run time: 150742 ms |
ClickBench: Total hot run time: 24.39 s |
…w BM25 statistics The merge-on-write cases overwrite and delete rows, which no longer count in the BM25 statistics, so their scores are those of the visible rows. The same rows are returned; only their scores change.
BE Regression && UT Coverage ReportIncrement line coverage Increment coverage report
|
|
run buildall |
|
/review |
There was a problem hiding this comment.
One new P2 finding: temporary hidden/NULL row-ID arrays bypass query memory accounting before scan pruning. It is detailed inline. No P0/P1 issue was independently confirmed for this head.
Both existing threads were revalidated: retained CLucene norms (4365847990) is fixed for the reviewed Doris writer/reader paths by using caller-owned norm buffers and bounded scalar caches; sparse intersection allocation (4365847994) is fixed by sorted intersection and zero-frequency exits. The new finding concerns temporary uint32 row-ID arrays, distinct from retained norm buffers. No existing blocker is carried forward.
Static coverage includes all 22 changed files and their scan, reader, writer, cache and scoring interactions at a690a60. The invocation chain is:
captured rowsets + bitmap/read version
-> scanner/shared tablet statistics
-> per-segment bitmap/delete-sign hidden IDs
-> CLucene or SNII visible document/term/norm totals
-> scanner-local BM25 statistics and matched-row scores
| Required checkpoint | Conclusion |
|---|---|
| Goal and tests | Bitmap/delete-sign-hidden MoW rows are removed from document counts, term frequencies and decoded norm totals. Replica/fresh-table, NULL and all-hidden fixtures support compaction invariance statically; the new memory issue remains. |
| Scope, clarity and reuse | Changes stay within BM25 collection, source helpers, cache sums and associated tests; existing captured read sources and segment/column readers are reused. |
| Concurrency and locks | Scanner tasks and queries can collect concurrently. TabletCollectionStatistics locks once-only collection and copies results into scanner-local maps; one searcher mutex protects sum caches. Its initial I/O is serialized intentionally, with no verified reverse lock order or deadlock. |
| Lifecycle and static initialization | Rowset readers and cache handles preserve reader lifetimes. Synchronous callbacks retain no query references after return; local resources release on errors. No new static initialization dependency or ownership cycle was found. Caller-owned norms fix the historical retention path; untracked ID buffers are the inline finding. |
| Configuration and dynamic changes | No configuration is added. Existing segment/page/file-cache options are forwarded where the read APIs accept them; the existing context-free CLucene norm API remains a limitation. |
| Symbols, formats and rolling compatibility | Internal collection signatures and default virtual helpers have matching callers. No persisted index or wire format changes; old/default delete-sign columns resolve by UID, and existing missing-norm scoring rejection remains. |
| Thrift/protobuf field IDs | Not applicable: no generated definition changed. |
| Parallel and special paths | Serial/shared split scanners, CLucene/SNII, physical cloud segment IDs, reserved scoring terms, NULL/empty inputs, no-norm keyword indexes and all-hidden collections were traced. Explicit deletion/debug exceptions are disclosed below. |
| Conditional checks | Bitmap presence, zero frequency, row bounds, zone-map pruning and all-hidden handling have concrete scenarios. Roaring supplies sorted distinct IDs; bounds/frequency failures return errors instead of silently continuing. |
| Functional, negative and unit coverage | New tests cover hidden/compacted equivalence, NULL and hidden NULL, all-hidden/refill, sparse/dense postings, distant IDs, invalid row IDs, cache callback errors, multiple readers and I/O flags. Memory-limit and concurrent-cache coverage is absent; dedicated reserved-term and schema/segment-ID edge fixtures are limited, with those paths reviewed statically. |
| Changed test results | Every new/modified numeric expectation is consistent with the input corpus and BM25 arithmetic, including .8025915, .60996956/.8713851 and 1.1823694. Output-generation provenance and execution were not verified. |
| Observability | Existing collection timing, query/tablet/term VLOG and error propagation remain available. Temporary raw arrays escape query memory metrics as described inline; context-free CLucene norms also retain the existing I/O-accounting limitation. |
| Transactions, persistence and failover | Read-only collection uses the captured rowsets and the same versioned physical-segment bitmap keys as ordinary reads. No transaction, EditLog, persistence or failover protocol changes. |
| Writes, atomicity and crash behavior | No data-write path changes. Failed collection clears partial statistics and shares the error with other scanners; cache sums are inserted only after successful computation. The verified resource-accounting risk is inline. |
| FE/BE variable propagation | No transmitted variables are added. Existing cache options reach the new scanner context. |
| CPU, memory and performance | Tablet sharing, zone-map delete-sign pruning, bounded batches and cached norm sums reduce repeated work. Sparse posting intersection no longer allocates by maximum ID. Full-cardinality untracked ID buffers remain the new P2 issue. |
| Further issues | Final rechecks of cursor progress, norm ownership/overloads, NULL writer metadata, TopN masks, cached-reader lifetimes and parallel paths found no additional substantiated defect. |
No additional user focus was supplied. Declared scope limits remain: enable_mow_light_delete predicates are still counted until compaction; skip_delete_sign/show_hidden_columns do not change the exclusion of delete-sign rows; skip_delete_bitmap and non-MoW tables retain their existing statistics behavior; non-NULL zero-token encodings remain approximate; statistics remain per tablet. CLucene caller-buffer behavior was checked against the pinned SegmentReader implementation, with Doris NULL-writer metadata traced to exclude a synthetic missing-field branch.
No builds, tests, format checks or Doris runtime experiments were run under this static-only review contract.
Review converged after 2 round(s).
TPC-H: Total hot run time: 28525 ms |
TPC-DS: Total hot run time: 150260 ms |
|
PR approved by anyone and no changes requested. |
1 similar comment
|
PR approved by anyone and no changes requested. |
|
PR approved by at least one committer and no changes requested. |
1 similar comment
|
PR approved by at least one committer and no changes requested. |
…tistics of merge-on-write tables #69438 (#69752) ### What problem does this PR solve? Issue Number: None Related PR: #69438 (this is its backport to branch-4.2), #69352 and its branch-4.2 pick #69440 (BM25 statistics shared across the parallel scanners of a tablet) Problem Summary: Backport of #69438 to branch-4.2. On a merge-on-write table, BM25 `score()` gives the same row a different score on each replica of its tablet. A query picks one replica per tablet, so with `replication_num = 3` a row has up to three scores, and pagination over `ORDER BY score()` repeats and skips rows from one page to the next. `CollectionStatistics::process_segment()` takes the document count, the token count and each term's document frequency straight from every segment's inverted index. Rows a later load overwrote or deleted (marked only in the delete bitmap) and rows written with the delete sign (CDC deletes, `DELETE`) stay in the index and are counted until compaction removes them, and each replica compacts on its own schedule. On branch-4.2 (3 BEs, `replication_num = 3`, 8 buckets, CDC-style upserts and delete-sign deletes, the replicas on one BE fully compacted), all 1,731 matching rows of a `SEARCH(... OR ...)` query got more than one score over 15 runs and differed between `use_fix_replica = 0/1/2`. Fix: the same change as #69438. When the scan reads a delete bitmap (merge-on-write), `CollectionStatistics` describes only the rows the scan returns: - `OlapScanner` passes the scan's delete bitmap and read version (`MowHiddenRows`) to `collect()`, or to `TabletCollectionStatistics::copy_to()` for a split tablet (#69440). - Per segment, the hidden rows are the rows the delete bitmap marks at the read version plus the rows whose `__DORIS_DELETE_SIGN__` is set. The delete-sign column is read only for segments whose zone map shows a non-zero sign (or cannot tell), following the query's `enable_segment_cache` and `enable_page_cache`. - Document count: the index's document count minus the hidden rows. Term document frequency: `IndexSource::scoring_doc_freq_among()` subtracts the hidden rows a term's postings list, leaping between them when they are much rarer than the postings, otherwise walking them alongside the decoded postings. - Token count: the sum of the document lengths the norms of the returned non-NULL rows stand for (NULL rows left out through the index's null bitmap), or the returned row count for an index without norms. Every replica counts the same, compacted or not. - CLucene norms are read one segment at a time into a buffer of the statistics' own and not kept; the non-NULL norm length sum of a field and the length sums of its last four sets of hidden rows are kept with the cached searcher (`CluceneSearcherCacheValue`). - A tablet whose rows are all hidden reports zero documents instead of failing the query. Tables without a delete bitmap collect the same statistics as before. For every `score()` query, the statistics' I/O context follows `disable_file_cache` and `inverted_index_snii_read_no_write_file_cache`, and a CLucene searcher the statistics open drops the query's I/O context once its meta is read; a CLucene error while opening it fails the query. Differences from #69438, for branch-4.2: segments are identified by their id instead of `RowsetSegmentView`, and the delete-sign column reader comes from `Segment::get_column_reader()` (branch-4.2 has no `get_column_reader_for_pruning`), a segment without the column reading as no deletes. The tests are adapted the same way. Not covered (as in #69438): merge-on-read unique tables and duplicate tables with `DELETE` predicates; rows deleted by a delete predicate (`enable_mow_light_delete`); rows written with the delete sign that the query returns (`skip_delete_sign`, `show_hidden_columns`) are still left out; an analyzed empty string, and on SNII any text that yields no token, counts as one token on a merge-on-write table; SNII norm length sums are not cached. Statistics stay per tablet. ### Release note Fix BM25 `score()` on merge-on-write tables differing between the replicas of a tablet, and before and after compaction. Statistics now count only the rows a query can return, and the average document length comes from the indexed norms. ### Check List (For Author) - Test - [x] Regression test: - New: `inverted_index_p0/test_bm25_score_replica_statistics` and `fault_injection_p0/test_bm25_score_statistics_searcher_io_context` (same suites as #69438). Updated `.out`: `test_bm25_score.out`, `test_score_filter_matrix.out` (V2, V3 and SNII merge-on-write cases), same values as #69438. - All 26 branch-4.2 suites that use `score()` (`inverted_index_p0`, `search`, `ann_index_p0`, `variant_p0`, `nereids_rules_p0`, `fault_injection_p0`) pass with this change on a 3-BE cluster. Without it, the replica suite, the fault-injection suite (the query fails with `[E-6002] The 'is_index_data' flag should be false for non-meta inverted index files.`) and the two updated suites fail. - [x] Unit Test: the tests of #69438 (`CollectionStatisticsTest.HiddenRowsAreCollectedLikeCompactedAway`, `AllRowsHiddenCollectsEmptyStatistics`, `NullRowsCountAsDocumentsWithoutTokens`, `CollectionStatisticsHiddenRowsTest.*` for `count_listed_docs`, `exclude_hidden_docs` and `exclude_hidden_term_docs`, `CluceneSearcherCacheValueTest.*`, `MultiSegmentCollectorTest.NormLengthSumsReadEachSegment`, `OlapScannerTest.BuildScoreRuntimeCollectionIoContextFollowsQueryFileCacheOptions`), with the existing collection statistics, BM25, searcher cache, multi-segment, query engine, SNII and `SEARCH` function tests: 427 tests in 35 suites pass in an ASAN build on macOS arm64. - [x] Manual test (3 BEs, macOS arm64, `replication_num = 3`, auto compaction off, `enable_sql_cache = false`), base = branch-4.2 without this change, same data and compaction state for both builds: | Check | branch-4.2 | with this change | |---|---|---| | CDC-style table (8 buckets, upserts, delete-sign deletes, NULLs, 4 fields), one BE's replicas fully compacted with inverted index compaction off: rows with more than one score over 15 runs / differing between replicas 0, 1, 2 (of 1,731) | 1,731 / 1,731 | 0 / 0 | | Same, scores differing from a freshly loaded copy of the visible rows | — | 0 of 1,731 | | Partial-column updates, sequence column, stream load with the delete sign and `merge_type: DELETE`, group commit, keyword and lowercase-normalizer indexes, partitions, variant, SNII: rows differing between replicas after one replica compacted | 84–790 per table (8 of 9 checks) | 0 in every check, all equal to a fresh copy | | Merge-on-write vs duplicate table with 50 % NULL rows, V3 and SNII | 0 of 250 differ | 0 of 250 differ | Cost, query `SEARCH('content:jobs OR content:try') ORDER BY score() DESC LIMIT 100`, one bucket, median of 15 warm runs; `StatisticsCollectTime` with one scanner, query total of `SEARCH('content:jobs')` with the default parallel scan: | Table | Statistics | Query total | |---|---|---| | V2, 8 M rows + 200 k overwritten + 50 k delete-sign rows | 0.3 ms → 6.7 ms | 68 ms → 76 ms | | V3, 6 M rows + 2 M overwritten + 200 k delete-sign rows (2.2 M hidden, 6 % NULL) | 0.3 ms → 55 ms | 61 ms → 113 ms | | V2, 6 M rows + 2 M overwritten, no NULL rows | 0.3 ms → 7.2 ms | 56 ms → 61 ms | | SNII, 2 M rows + 500 k overwritten + 200 k delete-sign rows | 0.2 ms → 23 ms | 49 ms → 71 ms | The cost grows with the rows compaction has not removed yet. With NULL rows, most of it is leaving the NULL rows out of the hidden rows on every query (`exclude_hidden_docs`), as in #69438. Memory (BE allocated bytes, sum of 3 BEs, a 4-field merge-on-write table of 41 segments): no growth beyond the noise of the measurement (after 4 queries +26 MB → +25 MB with key pruning; +142 MB → below the starting value scoring all segments). - Behavior changed: - [x] Yes. On merge-on-write tables, BM25 statistics leave out rows hidden by the delete bitmap and rows with the delete sign, and avgdl is computed from the norms of the visible rows, so scores no longer depend on the replica or on compaction. Scores of merge-on-write tables shift once, also without deletes, because norms store long lengths approximately. - Does this need documentation? - [x] No. ### Check List (For Reviewer who merge this PR) - [ ] Confirm the release note - [ ] Confirm test cases - [ ] Confirm document - [ ] Add branch pick label Co-authored-by: Shivam Gupta <[email protected]>
…tistics of merge-on-write tables #69438 (#69751) ### What problem does this PR solve? Issue Number: None Related PR: #69438 (this is its backport to branch-4.1), #69352 and its branch-4.1 pick #69418 (BM25 statistics shared across the parallel scanners of a tablet) Problem Summary: Backport of #69438 to branch-4.1. On a merge-on-write table, BM25 `score()` gives the same row a different score on each replica of its tablet. A query picks one replica per tablet, so with `replication_num = 3` a row has up to three scores, and pagination over `ORDER BY score()` repeats and skips rows from one page to the next. `CollectionStatistics::process_segment()` takes the document count, the token count and each term's document frequency straight from every segment's inverted index. Rows a later load overwrote or deleted (marked only in the delete bitmap) and rows written with the delete sign (CDC deletes, `DELETE`) stay in the index and are counted until compaction removes them, and each replica compacts on its own schedule. On branch-4.1 (3 BEs, `replication_num = 3`, 8 buckets, CDC-style upserts and delete-sign deletes, the replicas on one BE fully compacted), the 1,731 matching rows of a `SEARCH(... OR ...)` query had 1,294 rows with more than one score over 15 runs, and all 1,731 differed between `use_fix_replica = 0/1/2`. Fix (same as #69438): When the scan reads a delete bitmap (merge-on-write), `CollectionStatistics` describes only the rows the scan returns: - `OlapScanner` passes the scan's delete bitmap and read version (`MowHiddenRows`) to `collect()`, or to `TabletCollectionStatistics::copy_to()` for a split tablet (#69418). Every scanner of a tablet reads with the same bitmap and version. - For each segment, the hidden rows are the rows the delete bitmap marks at the read version (`get_agg`), plus the rows whose `__DORIS_DELETE_SIGN__` is set. The delete-sign column is read only for segments whose zone map shows a non-zero sign (or cannot tell), following the query's `enable_segment_cache` and `enable_page_cache`. - Document count: the index's document count minus the hidden rows. - Term document frequency: the hidden rows a term's postings list are subtracted. When the hidden rows are much rarer than the postings (32 times), the collector leaps from hidden row to hidden row; otherwise it decodes the postings once and walks the hidden rows alongside, without a bitset. - Token count: the sum of the document lengths the norms of the returned non-NULL rows stand for, or the returned row count for an index without norms. A row's norm is the same in every segment it is written to, so every replica counts the same, compacted or not. NULL rows are left out of the token count through the index's null bitmap and still count as documents. - The statistics read CLucene norms one CLucene segment at a time into a buffer of their own (`IndexReader::norms(field, bytes)`) and keep none. What they keep is small and lives with the cached searcher: a field's non-NULL norm length sum, and the length sums of the last four sets of hidden rows of a field. The hidden and NULL row ID lists are charged to the query's memory. - A tablet whose rows are all hidden reports zero documents instead of failing the query with "No data available for SimilarityCollector". Tables without a delete bitmap (duplicate key, merge-on-read, `skip_delete_bitmap`) collect the same statistics as before. For every `score()` query: the statistics' I/O context follows the query's `disable_file_cache`, and a CLucene searcher the statistics open drops the query's I/O context once its meta is read, as the query path does. A CLucene error while opening it now fails the query; before, it escaped the scanner thread and aborted the backend (reproduced on branch-4.1 by `fault_injection_p0/test_bm25_score_statistics_searcher_io_context`). Differences from #69438, for branch-4.1: - branch-4.1 has no SNII format and no `IndexSource` layer. The hidden-row counting works on `TermIterator` (`read_range` / `advance`) and the norm reading on the CLucene reader directly; both are helpers in `storage/compaction/collection_statistics.cpp` (`collection_statistics_detail`). - The norm length sums are kept on `InvertedIndexSearcherCache::CacheValue`; branch-4.1 has no `CluceneSearcherCacheValue`. - `InvertedIndexReader::read_null_bitmap` is split so that the statistics can read the null bitmap through the query cache without an `InvertedIndexReader` (`read_inverted_index_null_bitmap()`). - `inverted_index_snii_read_no_write_file_cache` does not exist on branch-4.1, so only `disable_file_cache` is followed. Not covered (as in #69438): merge-on-read unique tables and duplicate tables with `DELETE` predicates; rows deleted by a delete predicate (`enable_mow_light_delete`); rows written with the delete sign that the query returns (`skip_delete_sign`, `show_hidden_columns`) are still left out of the statistics; an analyzed empty string counts as one token on a merge-on-write table. Statistics stay per tablet. ### Release note Fix BM25 `score()` on merge-on-write tables differing between the replicas of a tablet, and before and after compaction. Statistics now count only the rows a query can return, and the average document length comes from the indexed norms. ### Check List (For Author) - Test - [x] Regression test: - New: `inverted_index_p0/test_bm25_score_replica_statistics` (3 replicas, overwrites, `DELETE`, delete-sign rows, one replica fully compacted, scores compared with a freshly loaded table; NULL rows vs a duplicate table; all rows deleted) and `fault_injection_p0/test_bm25_score_statistics_searcher_io_context` (same suites as #69438). - Updated `.out`: `test_bm25_score.out` and the V2/V3 merge-on-write cases of `test_score_filter_matrix.out`, to the same values as #69438. - All 23 branch-4.1 suites that use `score()` (`inverted_index_p0`, `search`, `ann_index_p0`, `variant_p0`, `nereids_rules_p0`, `fault_injection_p0`) pass with this change on a 3-BE cluster. Without it, the two new suites and the two updated ones fail; the fault-injection suite aborts two of the three backends with an uncaught `CLuceneError`. - [x] Unit Test: `CollectionStatisticsTest.HiddenRowsAreCollectedLikeCompactedAway` (real CLucene segments: a segment with a hidden 45-token row collects the same doc count, token count, df, avgdl and idf as the segment without it), `CollectionStatisticsTest.AllRowsHiddenCollectsEmptyStatistics`, `CollectionStatisticsTest.NullRowsCountAsDocumentsWithoutTokens` (real segment with NULL rows, with and without a delete bitmap), `CollectionStatisticsTest.CountListedDocsLeapsOrScansOverHiddenRows` (both counting paths, dense blocks, norm length sums), `CollectionStatisticsHiddenRowsTest.*` (`exclude_hidden_docs` with and without norms and NULL rows, rows beyond the index), `InvertedIndexSearcherCacheValueTest.*` (norm length sums computed once per field and per set of hidden rows, failures not kept, oldest set evicted), `MultiSegmentCollectorTest.NormLengthSumsReadEachSegment` (an index of three CLucene segments sums its norms per segment, equal to the combined norms), `OlapScannerTest.BuildScoreRuntimeCollectionIoContextFollowsQueryFileCacheOptions`. With the existing collection statistics, collection similarity, BM25, searcher cache, searcher builder, olap scanner, multi-segment, boolean query, null predicate and `SEARCH` function tests: 233 tests in 26 suites pass in an ASAN build on macOS arm64. - [x] Manual test (3 BEs, macOS arm64, `replication_num = 3`, auto compaction off, `enable_sql_cache = false`), base = branch-4.1 without this change, same data and compaction state for both builds: | Check | branch-4.1 | with this change | |---|---|---| | CDC-style table (8 buckets, upserts, delete-sign deletes, NULLs, 4 fields), one BE's replicas fully compacted with inverted index compaction off: rows with more than one score over 15 runs / differing between replicas 0, 1, 2 (of 1,731) | 1,294 / 1,731 | 0 / 0 | | Same, scores differing from a freshly loaded copy of the visible rows | — | 0 of 1,731 | | Partial-column updates, sequence column, stream load with the delete sign and `merge_type: DELETE`, group commit, keyword and lowercase-normalizer indexes, partitions, variant: rows differing between replicas after one replica compacted | 84–790 per table (7 of 8 checks) | 0 in every check, all equal to a fresh copy | | Merge-on-write vs duplicate table with 50 % NULL rows (V3) | 0 of 250 differ | 0 of 250 differ | Cost, query `SEARCH('content:jobs OR content:try') ORDER BY score() DESC LIMIT 100`, one bucket, median of 15 warm runs: | Table | `StatisticsCollectTime`, one scanner | Query total, default parallel scan | |---|---|---| | V2, 8 M rows + 200 k overwritten + 50 k delete-sign rows | 0.3 ms → 7.7 ms | 78 ms → 82 ms | | V3, 6 M rows + 2 M overwritten + 200 k delete-sign rows (2.2 M hidden) | 0.3 ms → 62 ms | 71 ms → 125 ms | The first query after a restart also sums each segment's norms (8 M rows: 5 ms → 32 ms; 2.2 M hidden: 6 ms → 99 ms). The cost grows with the rows compaction has not removed yet. Memory (BE allocated bytes, sum of 3 BEs, a 4-field merge-on-write table of 41 segments): after 4 queries +16 MB → +22 MB with key pruning and +109 MB → +75 MB scoring all segments, within the noise of the measurement. - Behavior changed: - [x] Yes. On merge-on-write tables, BM25 statistics leave out rows hidden by the delete bitmap and rows with the delete sign, and avgdl is computed from the norms of the visible rows, so scores no longer depend on the replica or on compaction. Scores of merge-on-write tables shift once, also without deletes, because norms store lengths above 40 tokens approximately. - Does this need documentation? - [x] No. ### Check List (For Reviewer who merge this PR) - [ ] Confirm the release note - [ ] Confirm test cases - [ ] Confirm document - [ ] Add branch pick label --------- Co-authored-by: Shivam Gupta <[email protected]>
…blet idf of the terms the prefix expands to #69698 (#69801) ### What problem does this PR solve? Issue Number: None Related PR: #69698, #69752 Problem Summary: Backport of #69698 to branch-4.2. score() over MATCH_PHRASE_PREFIX ignored the prefix and differed between inverted index storage formats: 1. The idf of a phrase prefix now counts every term the prefix expands to, as Lucene's multi-phrase query and Elasticsearch do, and all idfs come from the tablet statistics. The statistics expand the prefix in every segment and keep the `inverted_index_max_expansions` smallest terms of the tablet that a returned row holds (a session limit of 0 keeps 50), so a term's document frequency is counted in every segment that holds it and merge-on-write hidden rows do not change the scores after a compaction. A term whose bytes are not valid UTF-8 is not scored. 2. A phrase prefix of one token scores 1 on every storage format; on V2/V3 it read 0, so `score() > 0` dropped every row it matched. See #69698 for the full description and the review rounds. This change builds on #69438 (BM25 statistics count only visible rows of merge-on-write tables), whose branch-4.2 backport #69752 is merged. The PR is rebased onto the current branch-4.2 and contains only the backport commit; its patch is identical to the one validated below. ### Branch adaptations - No production code adaptation. The automatic pick conflicted only because #69438 was not on branch-4.2 yet; on top of #69752 the change applies with one conflict, in the context of `collection_statistics_test.cpp`: branch-4.2 already has `CollectConvertsAnalyzerFailureToStatus` elsewhere in the file, so the copy that master shows next to the renamed test is not added again. ### Release note score() over MATCH_PHRASE_PREFIX counts the terms the prefix expands to, and a phrase prefix of one token scores 1 on every inverted index storage format. ### Check List (For Author) - Test: Regression test, Unit Test - Validated locally on branch-4.2 at 711db43, the commit before #69625, with the same two commits on top: the newest branch-4.2 calls a lance-c API that no thirdparty build on the machine used here provides. The PR branch itself is on the current branch-4.2 and is built by CI. - test_match_phrase_prefix_score passes with the `.out` of master unchanged (V2, V3, SNII and the capped two-segment case). - test_bm25_score, test_bm25_score_parallel_scan_statistics, test_bm25_score_range_filter, test_bm25_score_replica_statistics, test_bm25_score_variant, test_score_filter_matrix, test_score_filter_usage and test_search_score_topn_predicates pass. - 1644 related BE unit tests pass (PhrasePrefixQueryV2Test, CollectionStatistics*, PrefixExpansionSetTest, SNII, InvertedIndexReaderTest, MultiSegmentCollectorTest, BM25SimilarityTest). - Behavior changed: Yes (scores of MATCH_PHRASE_PREFIX change) - Does this need documentation: No 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Opus 5.5 <[email protected]>
### What problem does this PR solve? Issue Number: None Related PR: apache#69352, apache#69438, apache#69403 Problem Summary: MoW light deletes filter rows through versioned DELETE predicates, but BM25 statistics only excluded delete-bitmap and delete-sign rows. Reuse DeleteHandler with the captured read version and historical column identities to collect the additional hidden row IDs. Read only predicate columns in bounded batches and merge disjoint, tracked row-ID lists into the existing statistics path. Keep the upstream tablet sharing, per-scanner caches and norms-based avgdl. ### Release note Correct BM25 statistics after merge-on-write light deletes without waiting for compaction. ### Check List (For Author) - Test: 93 selected ASAN tests passed, including three real-segment predicate tests. Full-master SQL passed V2/V3 with phrase true/false and SNII with phrase true, including mutation phases and forced scanner splitting. Existing parallel scan and replica-statistics suites passed. The unfixed query path fails the new regression's initial score comparison. Format, hygiene and changed-line source and test clang-tidy passed. - Behavior changed: Yes. Light-deleted rows no longer affect MoW BM25 statistics. - Does this need documentation: No new configuration or syntax.
… for the delete-sign filter ### What problem does this PR solve? Issue Number: None Related PR: apache#69438 Problem Summary: The BM25 statistics of a merge-on-write table leave out the rows written with the delete sign when the scan filters them. The scan told that by any conjunct that reads `__DORIS_DELETE_SIGN__`. With `show_hidden_columns = true`, a query's own predicate on the column, such as `WHERE __DORIS_DELETE_SIGN__ = 1`, then counted as the filter: the query returned and scored only the delete-sign rows, while the statistics left exactly those rows out, so their scores differed from the scores of the same rows without the predicate. The scan now treats as the filter only a conjunct that is `__DORIS_DELETE_SIGN__ = 0`, the one the FE adds unless the query sets `skip_delete_sign` or `show_hidden_columns`. Any other predicate on the column is a predicate of the query, which the statistics follow no more than a predicate on another column, so the delete-sign rows stay counted. A query that writes `= 0` itself keeps exactly the rows the FE's conjunct keeps and scores as the default. ### Release note None ### Check List (For Author) - Test: Unit Test / Regression test - OlapScanOperatorDeleteSignTest.OtherPredicateOnDeleteSignDoesNotFilterIt: `= 1`, `!= 0` and `>= 0` on the delete sign do not filter it, `= 0` does. - test_bm25_score_delete_sign_statistics: with `show_hidden_columns = true` and `__DORIS_DELETE_SIGN__ = 1`, every returned row scores as in a duplicate-key table loaded with the rows the query returns without the predicate, on V2, V3 and SNII. Without this change the case fails on V2 at once (80 rows score differently); the .out was regenerated with -forceGenOut and adds only the `__DORIS_DELETE_SIGN__ = 1` block, whose scores equal those of the same rows under `show_hidden_columns` alone. - All 29 regression suites that use score() pass on a 3-BE cluster (release build), and the BM25, statistics, scanner and scan-operator unit tests pass on ASAN (257 tests). - Behavior changed: Yes. With `show_hidden_columns = true`, a query that filters `__DORIS_DELETE_SIGN__` other than by `= 0` scores on statistics that count the delete-sign rows. - Does this need documentation: No
…m once for concurrent queries ### What problem does this PR solve? Issue Number: None Related PR: apache#69438 Problem Summary: The norm length sums that BM25 statistics of merge-on-write tables keep with a cached index reader were computed outside the lock, so queries that missed the same sum at the same time each read the norms (and the NULL rows) to compute it. That happens after every load: the load hides older rows, so queries at the new version need a new hidden-row sum for every segment it touched, and a burst of concurrent queries read the same norms many times. Holding the lock while computing, as the CLucene sums did before, would instead make queries of other fields and other hidden rows of the index wait. A sum is now computed by the first query that needs it. A query that needs a sum being computed waits for it and reads the kept sum; queries of other fields or other sets of hidden rows go on. If computing a sum fails or throws, the key is given up and a waiting query computes it itself, so one query's failure never fails another. ### Release note None ### Check List (For Author) - Test: Unit Test - NormLengthSumsTest.ConcurrentQueriesComputeASumOnce: while one query computes a sum, four queries of the same sum wait and get it without computing, a query of another set of rows is not blocked, and after a failed computation a waiting query computes the sum. It passes 50 repeated runs on ASAN, with the other 256 BM25, statistics and scanner unit tests. - Manual test on a 3-BE cluster (release build): 16 clients starting together on cold caches, 5 rounds, on V3 and SNII tables with overwritten and deleted rows: every row has one score and paging with a tiebreaker repeats or misses no row. 90 s of 16 clients with cancelled and timed-out queries and cache evictions: no other errors, no stuck queries, no BE failure. - Behavior changed: No - Does this need documentation: No
…69510) Related PR: #69469 Problem Summary: A single transient GitHub response currently aborts the review pipeline. [Input resolution for #69322](https://github.com/apache/doris/actions/runs/39794013905/job/119876748768) stopped on a GET 503 despite `Retry-After: 2`; [aggregate resolution for #69438](https://github.com/apache/doris/actions/runs/39760934956/job/119764632659) stopped on a paginated GET 504 after emitting partial output. The shared helper now retries recognized body-free REST GETs on 502/503/504, for at most three attempts. Each attempt has a 30-second limit; attempts and waits share a 90-second budget or the caller's shorter timeout. It respects Retry-After, records every failed attempt, discards failed pagination output, and returns only a successful attempt's complete output. Exhaustion still fails. Writes, GraphQL, unknown argument forms, other HTTP errors, transport/timeouts and JSON parsing errors are not retried. Input resolution can also fail before publishing its PR-number output. Failure notices now fall back to the authorized trigger's numeric PR identity, explicitly select the repository, and name the input-resolution failure in both the notice and terminal error. This fallback does not permit status publication for an unresolved head/base. This change can merge independently. It includes the same explicit `--repo` argument as #69469; a local combination with #69469 and #69504 merged cleanly and passed the macOS suite.
What problem does this PR solve?
Issue Number: None
Related PR: #69352 (BM25 statistics shared across the parallel scanners of a tablet; this PR is its follow-up for replicas), #69313 (BM25 token totals after CLucene compaction)
Problem Summary:
On a merge-on-write table, BM25
score()gives the same row a different score on each replica of its tablet. A query picks one replica per tablet, so withreplication_num = 3a row has up to three scores. Pagination overORDER BY score()then repeats and skips rows from one page to the next. Each replica is stable on its own (use_fix_replicapins the scores), but the replicas disagree.CollectionStatistics::process_segment()takes the document count, the token count and each term's document frequency straight from every segment's inverted index. Under merge-on-write, a row overwritten or deleted by a later load stays in its segment's index, marked only in the delete bitmap. A row written with the delete sign, which CDC deletes andDELETEproduce, stays as a normal row that queries filter out by predicate. Both kinds are counted until compaction removes them, and each replica compacts on its own schedule. So each replica counts a different set of rows nobody can see.Reproduction (3 BEs,
replication_num = 3, 8 buckets, auto compaction off): 8 loads, 3 rounds of overwriting a quarter of the rows, aDELETE, and full rows inserted with__DORIS_DELETE_SIGN__ = 1. Then a full compaction (POST /api/compaction/run?compact_type=full) of the tablets on one BE only. On master with #69352, the query below changed the score of all 3,799 matching rows from run to run: 1,900 distinct scores instead of 950. Withuse_fix_replica = 0/1/2each replica was stable, but the three disagreed.The token total has a second, replica-independent problem: CLucene index compaction (
inverted_index_compaction_enable, on by default) copies the source token totals even when it drops rows, which #69313 addresses. On master, the same rows scored differently in a compacted table and in a table freshly loaded with its visible rows.Fix:
When the scan reads a delete bitmap (merge-on-write),
CollectionStatisticsdescribes only the rows the scan returns:OlapScannerpasses the scan's delete bitmap and read version (MowHiddenRows) tocollect(), or toTabletCollectionStatistics::copy_to()for a split tablet. Every scanner of a tablet reads with the same bitmap and version, so the statistics shared by [fix](inverted index) Share BM25 statistics across parallel scanners of a tablet #69352 stay correct.get_agg, the same call the rowset reader uses), plus the rows whose hidden__DORIS_DELETE_SIGN__column is set. Every unique-key table has that column, andDELETEon a merge-on-write table writes such rows unlessenable_mow_light_deleteis on. The delete-sign column is read only for segments whose zone map shows a non-zero sign (or cannot tell), so a segment without CDC deletes costs nothing extra. The read follows the query'senable_segment_cacheandenable_page_cache.IndexSource::scoring_doc_freq_among()counts how many hidden rows a term's postings list. When the hidden rows are much rarer than the term's postings, it leaps between them withseek_block. Otherwise it decodes the postings once and walks the hidden rows alongside them, with no allocation, and a term without postings costs nothing. SNII overrides it so that reserved-namespace scoring terms resolve asscoring_doc_freqresolves them.sumTotalTermFreqwould still leave replicas apart. A row's norm is the same in every segment it is written to, so summing norms gives every replica the same count. It is also the same quantity the BM25 tf normalization already divides,dl / avgdl, withdltaken from the same norm.encode_norm(0)). Its length is left out, using the index's null bitmap, and it still counts as a document, as before. So on CLucene a table whose texts all have at most 40 tokens, and none of which is an empty string, scores exactly as a duplicate-key table with the same rows.Why the token count has to change too: a variant that left out hidden rows only from the document count and the document frequencies, keeping today's token total, made the replicas agree only while every replica compacted through inverted index compaction, which carries dropped rows' token counts forward. With one replica compacted with
inverted_index_compaction_enable = false(the index rebuilt from column data), every row again scored differently on that replica. Its scores were also 12–31 % off a fresh load of the visible rows on average, because its token total still counted the hidden rows.A tablet whose rows are all hidden (every row deleted or overwritten, not yet compacted) has no document a query can match. Its statistics report zero documents instead of failing the query with "No data available for SimilarityCollector".
Tables without a delete bitmap (duplicate key, merge-on-read, or
skip_delete_bitmap) collect the same statistics as before.For every
score()query, with or without a delete bitmap:disable_file_cacheandinverted_index_snii_read_no_write_file_cache, as the scan's reads do. They run before the scan, so they are the reads that fill the file cache. CLucene reads norms without a per-read context, as scoring does.create_index_searcher), and a CLucene error while opening it fails the query; before, it escaped the scanner thread uncaught and aborted the backend. Before, a searcher the statistics put into the searcher cache kept the opening query's I/O context, including its file cache statistics pointer, for later queries to read through.Not covered:
DELETEpredicates also count rows that compaction later drops. They need merge or predicate evaluation to tell which rows are visible, and are left for a follow-up.skip_delete_sign = trueorshow_hidden_columns = true, rows written with the delete sign are returned but still left out of the statistics. Rows deleted by a delete predicate (enable_mow_light_delete) are still counted, as the delete bitmap does not mark them.'!!!', or text without ASCII letters or digits under theenglishparser), whose norm SNII stores as that of one token. On CLucene such a text has a zero norm and counts as zero tokens. Telling these rows apart would mean reading the column.Interaction with #69313: that PR makes the token total of a CLucene index built by compaction exact. With this PR, merge-on-write scans no longer read that total, so their scores do not depend on it. Scans of other tables still do. The two changes are independent.
Release note
Fix BM25
score()on merge-on-write tables differing between the replicas of a tablet, and before and after compaction. Statistics now count only rows visible to the query, and the average document length comes from the indexed norms.Check List (For Author)
Test
Regression test:
inverted_index_p0/test_bm25_score_replica_statistics. It loads overwrites,DELETEs and delete-sign rows, then expects every row to score exactly as in a table freshly loaded with the visible rows. It checks before compaction, with one replica compacted (≥ 3 BEs: 10 runs anduse_fix_replica = 0/1/2), and after compacting all replicas (the compaction steps are skipped in cloud mode, which keeps one shared copy of each tablet). It also checks that a merge-on-write table with NULL rows scores exactly as a duplicate-key table with the same rows, and that a bucket whose rows are all deleted returns no rows instead of failing. Without this change it fails before any compaction (1352 rows score differently from a freshly loaded table); with only the first version of this change it failed the NULL check (250 rows score differently). With it, it passes on a 3-BE cluster.Regression test:
fault_injection_p0/test_bm25_score_statistics_searcher_io_context. With theCSIndexInput.readInternaldebug point, which fails a postings read that still reads as index meta, a coldscore()query on a merge-on-write table failed before the searcher reset ("The 'is_index_data' flag should be false for non-meta inverted index files") and passes with it.Existing suites: all 23 suites that use
score()(ininverted_index_p0,search,ann_index_p0,variant_p0andnereids_rules_p0) pass on a 3-BE cluster. One expected value changes: intest_bm25_score, tablet2is a merge-on-write table whose rowa = 3was updated, so the old version of that row no longer counts. Two documents count instead of three, and the score ofhello worldgoes from 1.0925692 to 0.8025915.Unit Test:
CollectionStatisticsTest.HiddenRowsAreCollectedLikeCompactedAway(real CLucene segments: a segment with a hidden 45-token row collects the same doc count, token count, df, avgdl and idf as the segment without the row),CollectionStatisticsTest.AllRowsHiddenCollectsEmptyStatistics,CollectionStatisticsTest.NullRowsCountAsDocumentsWithoutTokens(real CLucene segment with NULL rows: the same doc count and token count with and without the delete bitmap, and with a hidden NULL row),CollectionStatisticsHiddenRowsTest.*(counting hidden rows in postings by scanning and by leaping, over sparse and dense blocks; norm sums with and without NULL rows; rows beyond the index; indexes without norms),CluceneSearcherCacheValueTest.NonNullNormLengthSumIsComputedOncePerFieldandOlapScannerTest.BuildScoreRuntimeCollectionIoContextFollowsQueryFileCacheOptions. Run locally on macOS (ASAN build): all 243 BM25, collection-statistics, scanner, searcher cache, inverted index reader and null bitmap tests pass. A wider set of 2,732 inverted index, SNII, BM25, search and scan tests also passes, except 6 that fail on macOS in code this PR does not touch (5 SNII writer memory-accounting formulas and 1ScoreRuntimeTestdeath-test message).Manual test: master with [fix](inverted index) Share BM25 statistics across parallel scanners of a tablet #69352, with and without this change, macOS arm64, 3 BEs on one host, the reproduction above.
use_fix_replicapins one replica; "random" lets the FE pick a replica per tablet per query.use_fix_replica = 0/1/2comparedSame result for a 2-replica table with
BUCKETS AUTO, theunicodeparser withlower_case, 4 indexed text columns queried bySEARCH('a:x OR b:x OR c:x OR d:x')plus a non-index filter, auto compaction on, updates byINSERT INTO ... SELECTand deletes byDELETE FROM. Before: every row differed between the replicas (e.g. 3.30 vs 2.69). After: identical on both and equal to a fresh load. The same holds with 1–10 MB text values.Each of these also scored identically on every replica (one replica fully compacted) and matched a fresh load of its visible rows: partial-column updates, a sequence column, stream loads with
__DORIS_DELETE_SIGN__and withmerge_type: DELETE, group commit, keyword and lowercase-normalizer indexes, a range-partitioned table, the SNII storage format, a variant column,MATCH_ANY/MATCH_ALL/MATCH_PHRASE, and a tablet split across scanners (enable_parallel_scanandoptimize_index_scan_parallelism). A 90-second run of concurrent upserts, deletes, compactions and score queries finished without errors and left the replicas identical.Overhead, measured from query profiles with a 4-field
SEARCH(... OR ...)and a non-index filter. Each number is the median of 15 runs on the same data and query:On one 8 M-row tablet (20 % NULL, 80 k overwritten rows),
StatisticsCollectTimewent from 0.6 ms to 4.7 ms per query (median of 9), and the query from 27 ms to 22 ms (noise). The first query on a segment also sums its norms (15 ms here).The cost grows with the rows compaction has not removed yet. To count the hidden rows that hold a term, the collector leaps from hidden row to hidden row when they are much rarer than the term's postings (32 times rarer). Otherwise it decodes the postings once and walks the hidden rows alongside. The norms of a segment's hidden rows are read once per set of hidden rows.
No need to test or manual test.
Behavior changed:
Yes. On merge-on-write tables, BM25 statistics leave out rows hidden by the delete bitmap or the delete sign, and avgdl is computed from the norms of the visible rows. Scores no longer depend on the replica or on compaction. They also shift once on tables without deletes, because norms store long lengths only approximately. Measured on freshly loaded tables:
Does this need documentation?
Check List (For Reviewer who merge this PR)