Skip to content

Handle NRT FixedBits live docs in the delegation handle - #23215

Open
aasom143 wants to merge 1 commit into
opensearch-project:mainfrom
aasom143:livedocs-fixedbits-fix
Open

aasom143 wants to merge 1 commit into
opensearch-project:mainfrom
aasom143:livedocs-fixedbits-fix

Conversation

@aasom143

@aasom143 aasom143 commented Oct 1, 2026

Copy link
Copy Markdown
Contributor

fillLiveDocsWords cast every non-null liveDocs to LiveDocs. That holds only for readers opened from a commit, where the codec returns DenseLiveDocs or SparseLiveDocs. An NRT reader whose segment has deletes since the last flush gets PendingDeletes' read-only FixedBits view, which is a Bits but not a LiveDocs, so the first match-all query after a delete failed with ClassCastException until the node was restarted.

Match-all path: route every non-LiveDocs shape through Bits#applyMask on an all-alive slice; FixedBits and FixedBitSet implement it word-level. The codec LiveDocs branches are unchanged.

Predicate path: liveIntersection previously led the conjunction only for DenseLiveDocs, so an NRT segment fell back to the raw scorer with a per-doc liveDocs.get regardless of how many docs were deleted. It now also leads for a non-LiveDocs Bits when the leaf's deletion rate is above the codec's own sparse/dense threshold (delCount / maxDoc > 0.01), materialising the live set once per collector with FixedBitSet.copyOf, a word clone for FixedBits. Below the threshold the raw-scorer path is kept, matching what SparseLiveDocs gets.

Tests: buildIndex gains an nrt flag that leaves deletes uncommitted and opens the reader on the writer; NRT cases cover dense (25%), sparse (0.3%) and exactly-1% deletes for both match-all and predicate. A document-level-security style reader wrapper covers live docs replaced by a FixedBitSet and by a SparseFixedBitSet on both paths.

Description

[Describe what this change achieves]

Related Issues

Resolves #[Issue number to be closed when this PR is merged]

Check List

  • Functionality includes testing.
  • API changes companion pull request created, if applicable.
  • Public documentation issue/PR created, if applicable.

By submitting this pull request, I confirm that my contribution is made under the terms of the Apache 2.0 license.
For more information on following Developer Certificate of Origin and signing off your commits, please check here.

fillLiveDocsWords cast every non-null liveDocs to LiveDocs. That holds
only for readers opened from a commit, where the codec returns
DenseLiveDocs or SparseLiveDocs. An NRT reader whose segment has deletes
since the last flush gets PendingDeletes' read-only FixedBits view, which
is a Bits but not a LiveDocs, so the first match-all query after a delete
failed with ClassCastException until the node was restarted.

Match-all path: route every non-LiveDocs shape through Bits#applyMask on
an all-alive slice; FixedBits and FixedBitSet implement it word-level.
The codec LiveDocs branches are unchanged.

Predicate path: liveIntersection previously led the conjunction only for
DenseLiveDocs, so an NRT segment fell back to the raw scorer with a
per-doc liveDocs.get regardless of how many docs were deleted. It now
also leads for a non-LiveDocs Bits when the leaf's deletion rate is
above the codec's own sparse/dense threshold (delCount / maxDoc > 0.01),
materialising the live set once per collector with FixedBitSet.copyOf,
a word clone for FixedBits. Below the threshold the raw-scorer path is
kept, matching what SparseLiveDocs gets.

Tests: buildIndex gains an nrt flag that leaves deletes uncommitted and
opens the reader on the writer; NRT cases cover dense (25%), sparse
(0.3%) and exactly-1% deletes for both match-all and predicate. A
document-level-security style reader wrapper covers live docs replaced
by a FixedBitSet and by a SparseFixedBitSet on both paths.

Signed-off-by: Somesh Gupta <someshgupta987@gmail.com>
@aasom143
aasom143 requested a review from a team as a code owner October 1, 2026 14:23
@github-actions

github-actions Bot commented Oct 1, 2026

Copy link
Copy Markdown
Contributor

PR Reviewer Guide 🔍

Here are some key observations to aid the review process:

🧪 PR contains tests
🔒 No security concerns identified
✅ No TODO sections
🔀 No multiple PR themes
⚡ Recommended focus areas for review

Potential IndexOutOfBoundsException

In fillLiveDocsWords, when liveDocs is a non-LiveDocs Bits, the code computes Math.min(span, liveDocs.length() - minDoc) to bound the set call. If liveDocs.length() < minDoc (e.g. a wrapper whose Bits reports a shorter length than maxDoc), this yields a negative value and live.set(0, negative) throws. Additionally, applyMask's contract typically requires that liveDocs cover [minDoc, minDoc+span); passing a bitset at offset minDoc without verifying liveDocs.length() >= minDoc + span may fail or silently include deleted docs. Consider validating / clamping length and asserting the invariant.

if (liveDocs instanceof LiveDocs == false) {
    // Not a codec LiveDocs: start all-alive and let the Bits clear the deleted docs.
    FixedBitSet live = new FixedBitSet(span);
    live.set(0, Math.min(span, liveDocs.length() - minDoc));
    liveDocs.applyMask(live, minDoc);
    MemorySegment.copy(live.getBits(), 0, out, ValueLayout.JAVA_LONG, 0L, wordCount);
    return;
}

@github-actions

github-actions Bot commented Oct 1, 2026

Copy link
Copy Markdown
Contributor

PR Code Suggestions ✨

Explore these optional code suggestions:

CategorySuggestion                                                                                                                                    Impact
Possible issue
Clamp live-docs slice range to Bits length

If minDoc >= liveDocs.length(), liveDocs.length() - minDoc becomes non-positive,
which FixedBitSet.set(0, end) rejects with an exception. Also, when minDoc + span
extends past liveDocs.length(), docs beyond the Bits length should remain unset
(treated as not-live) rather than be set live. Clamp the range and guard against a
non-positive end.

sandbox/plugins/analytics-backend-lucene/src/main/java/org/opensearch/be/lucene/LuceneFilterDelegationHandle.java [400-407]

 if (liveDocs instanceof LiveDocs == false) {
     // Not a codec LiveDocs: start all-alive and let the Bits clear the deleted docs.
     FixedBitSet live = new FixedBitSet(span);
-    live.set(0, Math.min(span, liveDocs.length() - minDoc));
-    liveDocs.applyMask(live, minDoc);
+    int end = Math.min(span, Math.max(0, liveDocs.length() - minDoc));
+    if (end > 0) {
+        live.set(0, end);
+        liveDocs.applyMask(live, minDoc);
+    }
     MemorySegment.copy(live.getBits(), 0, out, ValueLayout.JAVA_LONG, 0L, wordCount);
     return;
 }
Suggestion importance[1-10]: 5

__

Why: The concern about liveDocs.length() - minDoc becoming non-positive is valid in theory, though in practice the caller likely ensures minDoc < maxDoc. The clamping adds defensive safety but may not address a real bug given the invariants.

Low
General
Guard against divide-by-zero on empty leaves

maxDoc() could theoretically be 0 for an empty leaf, producing a divide-by-zero NaN
comparison. Guard against zero maxDoc to make the threshold check robust.

sandbox/plugins/analytics-backend-lucene/src/main/java/org/opensearch/be/lucene/LuceneFilterDelegationHandle.java [272-274]

-} else if ((double) leaf.reader().numDeletedDocs() / leaf.reader().maxDoc() > SPARSE_DENSE_THRESHOLD) {
-    liveDocsBitSet = FixedBitSet.copyOf(liveDocs);
+} else {
+    int maxDoc = leaf.reader().maxDoc();
+    if (maxDoc > 0 && (double) leaf.reader().numDeletedDocs() / maxDoc > SPARSE_DENSE_THRESHOLD) {
+        liveDocsBitSet = FixedBitSet.copyOf(liveDocs);
+    }
 }
Suggestion importance[1-10]: 2

__

Why: An empty leaf with maxDoc == 0 is extremely unlikely in practice (Lucene doesn't produce empty segments typically), and NaN comparison would just evaluate to false, which is actually the desired behavior. The guard is a very minor defensive improvement.

Low

@github-actions

github-actions Bot commented Oct 1, 2026

Copy link
Copy Markdown
Contributor

✅ Gradle check result for c5ddf62: SUCCESS

@codecov

codecov Bot commented Oct 1, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 71.84%. Comparing base (3934026) to head (c5ddf62).
⚠️ Report is 1 commits behind head on main.

Additional details and impacted files
@@             Coverage Diff              @@
##               main   #23215      +/-   ##
============================================
+ Coverage     71.79%   71.84%   +0.05%     
- Complexity    77987    78026      +39     
============================================
  Files          6185     6185              
  Lines        361374   361374              
  Branches      52599    52599              
============================================
+ Hits         259458   259647     +189     
+ Misses        81284    81179     -105     
+ Partials      20632    20548      -84     

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

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