Skip to content

perf: stream equality and range index scans instead of materializing every id - #1298

Open
brettwooldridge wants to merge 1 commit into
nitrite:mainfrom
brettwooldridge:feat/lazy-index-scan
Open

perf: stream equality and range index scans instead of materializing every id#1298
brettwooldridge wants to merge 1 commit into
nitrite:mainfrom
brettwooldridge:feat/lazy-index-scan

Conversation

@brettwooldridge

Copy link
Copy Markdown
Contributor

NitriteIndexer.findByFilter returns a LinkedHashSet of every matching id, so find(k = v).firstOrNull() built the whole match set before handing back one row, and a bounded page paid for the entire result. On a non-unique index over a low-cardinality field that set is a large fraction of the collection on every lookup.

The composite layout already keeps its rows in key order, so the two plan shapes that map onto one bounded walk of it, an equality on the indexed field and a two-sided range on it, are now served by a lazy iterator that starts at the first key inside the bounds and stops at the first key outside them. It honours the plan's reverse scan order by visiting the key groups backwards while reading each group forwards, exactly as the materialized scan orders them, skips entries removed in an open transaction, and returns a document indexed under several keys once.

NitriteIndex.findNitriteIdStream and NitriteIndexer.findByFilterStream are new default methods returning null, so every other index type, plugin indexer and plan shape keeps the materialized path unchanged. ReadOperations prefers the stream when one is offered; the covered-count shortcut that lets size() answer without fetching documents is kept by counting the streamed ids on demand, so size() still reads the index only.

Tests compare the stream with the materialized scan for equality, range and reverse order, check the shapes it declines, show with a spied map that only one key is read for the first row, and exercise counts, paging, descending order, multi-valued fields and removals through the public API.

…every id

NitriteIndexer.findByFilter returns a LinkedHashSet of every matching id,
so find(k = v).firstOrNull() built the whole match set before handing back
one row, and a bounded page paid for the entire result. On a non-unique
index over a low-cardinality field that set is a large fraction of the
collection on every lookup.

The composite layout already keeps its rows in key order, so the two plan
shapes that map onto one bounded walk of it, an equality on the indexed
field and a two-sided range on it, are now served by a lazy iterator that
starts at the first key inside the bounds and stops at the first key outside
them. It honours the plan's reverse scan order by visiting the key groups
backwards while reading each group forwards, exactly as the materialized
scan orders them, skips entries removed in an open transaction, and returns
a document indexed under several keys once.

NitriteIndex.findNitriteIdStream and NitriteIndexer.findByFilterStream are
new default methods returning null, so every other index type, plugin
indexer and plan shape keeps the materialized path unchanged. ReadOperations
prefers the stream when one is offered; the covered-count shortcut that lets
size() answer without fetching documents is kept by counting the streamed
ids on demand, so size() still reads the index only.

Tests compare the stream with the materialized scan for equality, range and
reverse order, check the shapes it declines, show with a spied map that only
one key is read for the first row, and exercise counts, paging, descending
order, multi-valued fields and removals through the public API.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
@coderabbitai

coderabbitai Bot commented Sep 4, 2026

Copy link
Copy Markdown
Contributor

Warning

Review limit reached

Next included review available in 53 minutes.

Check out review usage here.

View limit details

Limit details: You’ve used all 4 included reviews currently available.

You've used all free OSS reviews for now. Wait for the free limit to reset to keep reviewing this public repository.

Learn how review limits work.

Review configuration:

⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Team

Run ID: fd44f501-dad1-4223-988b-ec4e3617f33f

📥 Commits

Reviewing files that changed from the base of the PR and between 38caf34 and 69fc5ce.

📒 Files selected for processing (9)
  • nitrite/src/main/java/org/dizitart/no2/collection/operation/ReadOperations.java
  • nitrite/src/main/java/org/dizitart/no2/common/streams/DocumentStream.java
  • nitrite/src/main/java/org/dizitart/no2/common/streams/IndexedStream.java
  • nitrite/src/main/java/org/dizitart/no2/index/ComparableIndexer.java
  • nitrite/src/main/java/org/dizitart/no2/index/NitriteIndex.java
  • nitrite/src/main/java/org/dizitart/no2/index/NitriteIndexer.java
  • nitrite/src/main/java/org/dizitart/no2/index/SingleFieldIndex.java
  • nitrite/src/test/java/org/dizitart/no2/collection/LazyIndexScanTest.java
  • nitrite/src/test/java/org/dizitart/no2/index/SingleFieldIndexTest.java

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@anidotnet

Copy link
Copy Markdown
Contributor

Superseded by #1304 — I don't have push access to your fork, so the rebase went to a branch on the upstream repo. Same commits, authorship preserved, plus:

Local on the rebase: nitrite 1783 pass, nitrite-mvstore-adapter 5444 pass. Thanks for this one — the reverse-walk-forward-within-group detail is the part that would have been easy to get wrong.

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.

2 participants