Skip to content

Conversation

@wjones127
Copy link
Contributor

@wjones127 wjones127 commented Nov 18, 2025

BREAKING CHANGE: The SearchResult struct returned by ScalarIndex::search() now wraps a NullableRowIdSet instead of a RowIdTreeMap. Scalar indices must now provide the set of row ids where the expression value is null instead of just where it is true. Additionally, the RowIdMask is now an enum instead of a struct.

This PR fixes correctness bugs that show up when (a) running a filter with NOT, (b) the column you are filtering on contains nulls, and (c) we are using a scalar index (such as btree, or bitmap). Previously, this would give the wrong answer:

import pyarrow as pa
import lance

data = pa.table({"value": [1, 5, None]})
ds = lance.write_dataset(data, "memory://")
ds.create_scalar_index("value", "BTREE")
ds.to_table(filter="NOT (value < 2)")
pyarrow.Table
value: int64
----
value: [[5,null]]

It should not include null. The reason it did is that our RowIdMask (which is output by a scalar index query) was not aware of nulls. So when it processed value < 2, it would select just row index 0. Then NOT would invert that to [1, 2], selecting both [false, null].

This PR makes RowIdMask aware of nulls. When it processes value < 2, it records selected: [0] and nulls: [2]. Then, when you invert that and then drop, you get selected: [1], giving the correct final answer of just [5].

As part of this, we also refactor RowIdMask to make allow list and deny list mutually exclusive, which simplifies some of the logic.

Fixes #4756

wjones127 and others added 2 commits November 17, 2025 17:36
Scalar indexes currently treat NULL values as FALSE when evaluating
filters, which violates Kleene three-valued logic. This causes bugs
like `x != 5` incorrectly including rows where x is NULL.

This commit adds the foundation for proper null handling:
- Extended RowIdMask with null_list field to track NULL rows
- Implemented Kleene logic for NOT, AND, OR operations
- Updated SearchResult to carry null row information
- Modified all index implementations for backward compatibility

The infrastructure is complete but indexes don't yet track nulls.
Follow-up commits will implement actual null tracking in BTree and
Bitmap indexes.

Fixes lance-format#4756

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <[email protected]>
@wjones127 wjones127 added the critical-fix Bugs that cause crashes, security vulnerabilities, or incorrect data. label Nov 18, 2025
@github-actions github-actions bot added bug Something isn't working python labels Nov 18, 2025
wjones127 and others added 13 commits November 18, 2025 11:02
Added unit tests to verify Kleene three-valued logic operations work correctly:
- RowIdMask AND/OR/NOT operations with nulls
- also_block and also_allow preserve null_list
- Serialization/deserialization of null_list
- Bitmap index returns null_list in SearchResult

These tests verify the null tracking infrastructure works correctly at the Rust level.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <[email protected]>
Adds comprehensive tests to verify that bitmap and btree indexes correctly
track and return null row IDs in query results.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <[email protected]>
The [tool.ruff] section had a duplicate lint key which caused maturin
to fail parsing the file.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <[email protected]>
After adding null_list to RowIdMask, the serialization now produces 3
elements (block_list, allow_list, null_list) instead of 2. Updated
serialize_to_arrow and try_from_arrow to handle 3 elements correctly.

This fixes the "all columns in a record batch must have the same length"
error when using scalar indexes with null tracking.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <[email protected]>
The iter_ids() method was only filtering out block_list entries but not
null_list entries. This caused nulls to be included in query results when
they should be filtered out according to Kleene three-valued logic.

Updated iter_ids() to filter out both block_list and null_list entries,
ensuring that null values are never returned in iteration.

Added test_iter_ids_with_nulls() to verify the fix.

Note: The Python integration test still fails, indicating there may be
another code path that needs fixing.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <[email protected]>
@wjones127 wjones127 changed the title fix: null handling with scalar indices fix: null handling when using NOT with scalar indices Nov 19, 2025
@wjones127 wjones127 changed the title fix: null handling when using NOT with scalar indices fix!: null handling when using NOT with scalar indices Nov 20, 2025
@wjones127 wjones127 marked this pull request as ready for review November 21, 2025 16:43
@wjones127 wjones127 requested a review from westonpace November 21, 2025 16:44
Copy link

@chatgpt-codex-connector chatgpt-codex-connector bot left a comment

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Previously, NOT operations on inexact index results (AtMost/AtLeast)
would keep the same certainty variant while negating the mask. This
was incorrect because:

- NOT(AtMost(x)) should return AtLeast(!x), not AtMost(!x)
  (complement of superset is subset)
- NOT(AtLeast(x)) should return AtMost(!x), not AtLeast(!x)
  (complement of subset is superset)

This caused incorrect query results when using inexact indices like
bloom filters and zonemaps with NOT operators.

Changes:
- Fixed NOT implementation to flip certainty variants
- Removed unused `map` method that was replaced by explicit match
- Added unit tests for certainty flipping
- Added integration tests for NOT with nulls

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <[email protected]>
Copy link
Member

@westonpace westonpace left a comment

Choose a reason for hiding this comment

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

This is great work!

Here is a first pass of questions before looking at the other files.

Comment on lines +11 to +14
* The `SearchResult` returned by scalar indices must now output information about null values.
Instead of containing a `RowIdTreeMap`, it now contains a `NullableRowIdSet`. Expressions that
resolve to null values must be included in search results in the null set. This ensures that
`NOT` can be applied to index search results correctly.
Copy link
Member

Choose a reason for hiding this comment

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

It's not clear why this is in the migration guide? Are we considering SearchResult and RowIdTreeMap to be part of our public API?

Or are you trying to explain that filter results may have changed? If it's the latter I think this needs to be reworded to be more clear that this means query results will be different.

Comment on lines +44 to +52
/// Check if a row_id is selected (TRUE)
pub fn selected(&self, row_id: u64) -> bool {
self.selected.contains(row_id) && !self.nulls.contains(row_id)
}

/// Get the selected rows (TRUE or NULL)
pub fn selected_rows(&self) -> &RowIdTreeMap {
&self.selected
}
Copy link
Member

Choose a reason for hiding this comment

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

It's a little unexpected that selected checks for TRUE but selected_rows is both TRUE and NULL. Maybe we could use not_false_rows instead of selected_rows?

Comment on lines +17 to +18
selected: RowIdTreeMap,
nulls: RowIdTreeMap,
Copy link
Member

Choose a reason for hiding this comment

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

Can a row id be in both sets? What does that signify?

self.selected.clone() - self.nulls.clone()
}

pub fn union_all(selections: &[Self]) -> Self {
Copy link
Member

Choose a reason for hiding this comment

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

What happens if a row id is null in one set and true in another? I guess that is a repeat of the "can a row id be in both sets" question?

Comment on lines +182 to +183
(Self::AllowList(allow), Self::BlockList(block))
| (Self::BlockList(block), Self::AllowList(allow)) => {
Copy link
Member

Choose a reason for hiding this comment

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

I'm still a fan of simplifying things so that this case isn't allowed but we can investigate in future issue.

/// This is often a result of a filter, where `selected` represents the rows that
/// passed the filter, and `nulls` represents the rows where the filter evaluated
/// to null. For example, in SQL `NULL > 5` evaluates to null. This is distinct
/// from being deselected to support proper three-valued logic for NOT.
Copy link
Member

Choose a reason for hiding this comment

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

Can we go ahead and state here that NULL | TRUE = TRUE and that FALSE & NULL = FALSE?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

breaking-change bug Something isn't working critical-fix Bugs that cause crashes, security vulnerabilities, or incorrect data. python

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Scalar index queries handle NOT and NULL incorrectly

2 participants