Skip to content

feat: add RocksIterator.stream(Mapper, Mapper) for internal iteration - #186

Merged
dfa1 merged 3 commits into
dfa1:mainfrom
arpitJ-dev:feature/stream-with-mapper
Oct 8, 2026
Merged

dfa1 merged 3 commits into
dfa1:mainfrom
arpitJ-dev:feature/stream-with-mapper

Conversation

@arpitJ-dev

Copy link
Copy Markdown
Contributor

Fixes #181.

Adds KeyValue<K, V> and a package-private RocksIteratorSpliterator wired to the existing key(Mapper)/value(Mapper) scoped reads. RocksIterator.stream(Mapper<K>, Mapper<V>) returns a Stream<KeyValue<K, V>> whose close() closes the iterator, including on early termination (e.g. .limit(n)), so it works directly in try-with-resources.

Per discussion on #181: kept Mapper<K>/Mapper<V> instead of new KeyMapper/ValueMapper interfaces, and left out a byte[] overload since the mapped version already covers that with no forced copy.

Tested locally: ./mvnw test -pl core -am, full suite green (including 5 new tests in RocksIteratorTest).

Adds KeyValue<K, V>, a mapped key/value pair, and a package-private
RocksIteratorSpliterator that wires tryAdvance to the iterator's
existing key(Mapper)/value(Mapper) scoped reads. stream() returns a
Stream<KeyValue<K, V>> whose close() closes the iterator, even on
early termination, so a caller can use it directly in try-with-
resources instead of the raw seekToFirst()/isValid()/next() loop.

Per discussion on dfa1#181: keeps Mapper<K>/Mapper<V> rather than adding
new KeyMapper/ValueMapper interfaces, and does not add a byte[]-based
overload since the mapped version already covers that case with no
forced intermediate copy.

// When
List<KeyValue<String, String>> entries;
try (RocksIterator it = db.newIterator();

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

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

what if we merge the 2 methods into one? db.newIteratorStream(keyMapper, valueMapper)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Good call, added newIteratorStream(keyMapper, valueMapper) as a default method on RocksDBReadOperations that delegates to newIterator().stream(...). Kept the two-arg stream() too since it's still useful when you already have an iterator open, but this is the one most callers will want.

Per dfa1's review on this PR: merges newIterator() + stream() into a
single call on RocksDBReadOperations, right next to newIterator(). It
just delegates to newIterator().stream(keyMapper, valueMapper), so it
keeps the same close-on-stream-close behavior with no extra code.
@dfa1

dfa1 commented Sep 29, 2026

Copy link
Copy Markdown
Owner

Thanks for this — the shape is right: reusing Mapper rather than new interfaces, and dropping the Snapshot from the sketch in #181 (it would have leaked). ./mvnw package -pl core is javadoc-clean and the suite is green locally.

Three things before merge.

1. stream() silently truncates on an iteration error

RocksIteratorSpliterator.tryAdvance returns false on !isValid() without calling checkError(). docs/reference.md already warns about exactly this:

isValid() turning false means "end of range or error" — always call checkError() after the loop.

Repro — 50k entries, flushed, 4KB of one SST data block corrupted, then scanned:

loop + checkError() : throws RocksDBException after 25161 entries
stream              : returns 25161 entries, no exception

Half the database comes back looking complete. Worse, the caller can't add the check themselves, because the stream closes the iterator on the way out. One line:

if (!iterator.isValid()) {
	iterator.checkError();
	return false;
}

2. The stream consumes the iterator, so it can never be reused

onClose(this::close) makes one scan cost one newIterator()/rocksdb_iter_destroy pair — a SuperVersion ref, an arena, the merging-iterator heap over memtables + L0 + every level, plus this wrapper's own confined Arena. Measured on 200k keys, best of 5:

scan length fresh iterator per scan reused iterator overhead
1 entry 1.06 µs 0.55 µs 1.93x
10 entries 1.68 µs 1.11 µs 1.51x
1000 entries 64.8 µs 62.5 µs 1.04x

Noise on a full table scan; 1.5–1.9x on the short repeated scans a Stream API invites (.limit(10), prefix lookup in a loop). It's also the one-shot shape AGENTS.md already rejected once, for ReadBatch: "There is deliberately no one-shot multiGet()."

Dropping onClose — and newIteratorStream with it — lets the caller keep the handle:

try (var it = db.newIterator()) {
	var head   = it.stream(k, v).limit(10).toList();
	it.seek(prefix);
	var window = it.stream(k, v).takeWhile(p -> ...).toList();
}

On the obvious safety worry: I checked, and closing the iterator then using the stream throws IllegalStateException, not a crash — NativeObject.close() nulls the pointer atomically and every tryAdvance step goes through ptr(). That holds with or without onClose, so the ownership transfer isn't buying safety here, only preventing reuse.

3. The javadoc contradicts itself

Returns a Stream over the remaining entries from the current position … Positions itself with seekToFirst() on first use

Both can't be true. It's always a full scan, which also means it.seek(prefix) followed by .stream(...) is impossible — probably the most common reason to want this. Dropping the started flag and the seek fixes the doc and unblocks range scans in one go.


Also needs a CHANGELOG.md [Unreleased] entry and a Stream row in the Iterators table in docs/reference.md.

Worth noting the fix for all three is a net deletion — smaller than what's here now. Happy to talk through any of it.

stream() silently truncated instead of throwing when isValid() went
false from a background error, not just end-of-range. checkError() is
now called in that case, matching the manual seekToFirst/isValid/next
loop's own checkError()-after-the-loop convention.

Drop the forced seekToFirst() and onClose(this::close): stream() now
has the same positioning contract as next() (seek first, same as the
manual loop) and never closes the iterator, so a seek() + stream() +
reseek() + stream() sequence works and one iterator can be reused
across short, repeated scans instead of paying for a fresh iterator
per call. Remove newIteratorStream(), which only existed to hide that
per-call cost.

Update CHANGELOG.md and the docs/reference.md Iterators table.
@arpitJ-dev

Copy link
Copy Markdown
Contributor Author

Thanks for the thorough review, especially the benchmark. Fixed all three: checkError() now runs when the range ends, stream() no longer seeks or closes the iterator so it's reusable across reseeks, and the javadoc matches that. Added a test for the seek-then-restream case your example showed. CHANGELOG.md and the docs/reference.md Iterators table are updated too.

@dfa1
dfa1 merged commit 57abcc8 into dfa1:main Oct 8, 2026
@dfa1

dfa1 commented Oct 8, 2026

Copy link
Copy Markdown
Owner

Thanks for your contribution @arpitJ-dev!

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.

Better ergonomics for Iterator and Stream

2 participants