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
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

1 participant