Skip to content

find_first_key_by_prefix, find_last_key_by_prefix#6171

Closed
afck wants to merge 3 commits into
linera-io:mainfrom
afck:last-key
Closed

find_first_key_by_prefix, find_last_key_by_prefix#6171
afck wants to merge 3 commits into
linera-io:mainfrom
afck:last-key

Conversation

@afck

@afck afck commented Apr 29, 2026

Copy link
Copy Markdown
Contributor

Motivation

As discussed in #6163, it's useful to be able to read the last key of a map view.

Proposal

Add find_first_key_by_prefix, find_last_key_by_prefix, and the value variants to ReadableKeyValueStore, with default impls falling back to find_keys_by_prefix(...).first()/.last(). Native overrides:

  • memory: BTreeMap range, O(log N)
  • RocksDB
  • DynamoDB: Query with Limit=1 and ScanIndexForward=true/false
  • IndexedDB: IDBCursor with Next/Prev direction
  • Wrappers (journaling, value_splitting, lru_caching, metering, dual): forward to the inner store; metering adds latency histograms; value_splitting strips the 4-byte segment index from the result.

Adds view-level helpers:

  • ByteMapView::first_key, last_key, first_key_value, last_key_value
  • MapView / CustomMapView::first_index, last_index,
    first_index_value, last_index_value
  • ByteSetView::first_key, last_key
  • SetView / CustomSetView::first_index, last_index

The view methods take a fast path straight to the store when there are no staged changes; otherwise they fall back to the existing for_each_key_while / for_each_index machinery, which already handles the merge of in-memory updates with persisted state.

Test Plan

Tests were added.

Release Plan

  • Nothing to do / These changes follow the usual release cycle.

Links

afck and others added 3 commits April 29, 2026 15:19
Adds `find_first_key_by_prefix`, `find_last_key_by_prefix`, and the value
variants to `ReadableKeyValueStore`, with default impls falling back to
`find_keys_by_prefix(...).first()/.last()`. Native overrides:

- memory: BTreeMap range, O(log N).
- RocksDB: forward iterator first; for last, walk forward in the bounded
  range and keep the last entry. RocksDB's reverse-iteration primitives
  interact awkwardly with our prefix-bounded scans (seek_for_prev /
  seek_to_last with iterate bounds returned invalid for keys clearly in
  range), so the asymmetric forward-walk is a robust fallback for now.
- ScyllaDB: prepared statements with `ORDER BY k ASC/DESC LIMIT 1`.
- DynamoDB: `Query` with `Limit=1` and `ScanIndexForward=true/false`.
- IndexedDB: `IDBCursor` with `Next`/`Prev` direction.
- Wrappers (journaling, value_splitting, lru_caching, metering, dual):
  forward to the inner store; metering adds latency histograms;
  value_splitting strips the 4-byte segment index from the result.

Adds view-level helpers:
- `ByteMapView::first_key`, `last_key`, `first_key_value`, `last_key_value`
- `MapView` / `CustomMapView::first_index`, `last_index`,
  `first_index_value`, `last_index_value`
- `ByteSetView::first_key`, `last_key`
- `SetView` / `CustomSetView::first_index`, `last_index`

The view methods take a fast path straight to the store when there are
no staged changes; otherwise they fall back to the existing
`for_each_key_while` / `for_each_index` machinery, which already handles
the merge of in-memory updates with persisted state.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
`LruCachingStore::find_{first,last}_key[_value]_by_prefix` now check
the existing find_keys / find_key_values cache before forwarding to the
inner store, returning `.first()` / `.last_back()` of the cached result
on hit. No new cache map is added; if the cache misses we still fall
through to the inner store so the inner-store optimization is preserved.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The database is opened with an 8-byte fixed-prefix extractor for prefix
bloom-filter optimization. Without `total_order_seek` set in
`ReadOptions`, RocksDB's `seek_for_prev` and `seek_to_last` only seek
within the bloom-prefix scope and silently return invalid for keys
whose extractor-prefix differs from the seek target — which is why the
earlier reverse-iterator attempts returned `valid=false` for keys that
clearly existed in the bounded range.

With `total_order_seek(true)`, `seek_for_prev(upper_bound)` (stepping
back if it lands exactly on the upper bound) gives us the largest key
with the prefix in a single seek. Replaces the O(N) forward-walk
fallback in `find_last_key_by_prefix_internal` and
`find_last_key_value_by_prefix_internal`.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@afck afck changed the title Last key find_first_key_by_prefix, find_last_key_by_prefix Apr 29, 2026
@github-actions

Copy link
Copy Markdown

Instruction Count Benchmark Results

Baseline: 7e00aaf232

Deterministic metrics — reproducible across runs (34 benchmarks)
Benchmark Instructions Total R+W
Cold Load
load_1000 693,857 (No change) 1,010,338 (No change)
CollectionView
indices_100 192,360 (No change) 267,627 (No change)
load_all_100_from_storage 636,509 (-0.00%) 898,677 (-0.00%)
load_all_100_in_memory 340,885 (+0.01%) 477,162 (+0.01%)
pre_save_100 265,735 (-0.06%) 367,473 (-0.06%)
try_load_10_from_100 100,549 (-0.02%) 142,375 (-0.02%)
MapView
contains_key_10_from_100 52,533 (No change) 74,551 (No change)
contains_key_10_from_1000 355,093 (No change) 501,852 (No change)
get_10_from_100 55,031 (+0.04%) 78,197 (+0.03%)
get_10_from_1000 357,626 (No change) 505,553 (No change)
get_100_missing_from_1000 610,063 (-0.00%) 851,207 (-0.00%)
indices_100 100,461 (No change) 138,344 (No change)
indices_1000 948,231 (No change) 1,322,215 (No change)
insert_100 257,264 (+0.02%) 355,714 (+0.02%)
insert_1000 2,963,505 (No change) 4,013,290 (No change)
post_save_1000 1,027,028 (+0.00%) 1,481,044 (+0.00%)
pre_save_100 332,564 (-0.01%) 462,461 (-0.01%)
pre_save_1000 3,381,734 (+0.00%) 4,758,759 (+0.00%)
remove_500_from_1000 1,189,614 (No change) 1,660,519 (No change)
QueueView / BucketQueueView
delete_500_from_1000 22,407 (No change) 34,370 (No change)
front_100_from_1000 5,701 (No change) 8,420 (No change)
pre_save_1000 43,122 (+0.05%) 60,496 (+0.04%)
push_1000 24,367 (+0.09%) 33,322 (+0.08%)
delete_500_from_1000 10,222 (No change) 12,325 (No change)
front_100_from_1000 9,116 (-0.23%) 13,855 (-0.19%)
pre_save_1000 1,042,902 (No change) 1,498,608 (No change)
push_1000 24,273 (-0.09%) 33,199 (-0.08%)
ReentrantCollectionView
contains_key_10_from_100 141,876 (-0.01%) 201,794 (-0.01%)
indices_100 237,116 (No change) 332,341 (No change)
load_all_100_from_storage 801,677 (No change) 1,129,931 (No change)
load_all_100_in_memory 411,235 (+0.01%) 566,271 (+0.00%)
pre_save_100 350,812 (No change) 488,465 (No change)
RegisterView
get_set_100 81,271 (-0.03%) 120,100 (-0.02%)
pre_save 5,485 (No change) 8,089 (No change)

Regression threshold: 1%${\color{red}\textbf{red}}$ = regression, ${\color{green}\textbf{green}}$ = improvement.

Cache-dependent metrics — expect fluctuations between runs (34 benchmarks)
Benchmark L1 Hits LLC Hits RAM Hits Est. Cycles
Cold Load
load_1000 1,001,809 (-0.00%) 8,355 (+0.01%) 174 (No change) 1,049,674 (+0.00%)
CollectionView
indices_100 266,382 (+0.00%) 856 (-0.23%) 389 (No change) 284,277 (-0.00%)
load_all_100_from_storage 894,110 (-0.00%) 3,900 (-0.08%) 667 (-0.15%) 936,955 (-0.01%)
load_all_100_in_memory 475,025 (+0.01%) 1,400 (-0.36%) 737 (+0.14%) 507,820 (+0.01%)
pre_save_100 365,522 (-0.06%) 1,354 (+0.82%) 597 (-0.17%) 393,187 (-0.05%)
try_load_10_from_100 141,519 (-0.02%) 637 (-0.31%) 219 (-0.45%) 152,369 (-0.04%)
MapView
contains_key_10_from_100 74,254 (-0.00%) 92 (${\color{red}\textbf{+1.10\%%}}$) 205 (No change) 81,889 (+0.00%)
contains_key_10_from_1000 498,672 (-0.00%) 2,975 (+0.03%) 205 (No change) 520,722 (+0.00%)
get_10_from_100 77,893 (+0.03%) 92 (${\color{red}\textbf{+4.55\%%}}$) 212 (+0.47%) 85,773 (+0.09%)
get_10_from_1000 502,362 (-0.00%) 2,979 (+0.07%) 212 (No change) 524,677 (+0.00%)
get_100_missing_from_1000 847,991 (-0.00%) 2,987 (+0.20%) 229 (-0.43%) 870,941 (-0.00%)
indices_100 137,714 (-0.00%) 228 (+0.44%) 402 (No change) 152,924 (+0.00%)
indices_1000 1,314,547 (+0.00%) 6,482 (-0.03%) 1,186 (No change) 1,388,467 (-0.00%)
insert_100 354,968 (+0.02%) 92 (${\color{red}\textbf{+6.98\%%}}$) 654 (+0.15%) 378,318 (+0.03%)
insert_1000 4,006,256 (+0.00%) 3,048 (-0.49%) 3,986 (No change) 4,161,006 (-0.00%)
post_save_1000 1,469,655 (+0.00%) 11,209 (+0.02%) 180 (+0.56%) 1,532,000 (+0.00%)
pre_save_100 461,082 (-0.00%) 766 (-0.39%) 613 (-0.16%) 486,367 (-0.01%)
pre_save_1000 4,744,830 (+0.00%) 10,113 (-0.28%) 3,816 (+0.03%) 4,928,955 (-0.00%)
remove_500_from_1000 1,656,139 (+0.00%) 4,200 (-0.02%) 180 (No change) 1,683,439 (-0.00%)
QueueView / BucketQueueView
delete_500_from_1000 34,171 (+0.00%) 37 (${\color{green}\textbf{-2.63\%%}}$) 162 (No change) 40,026 (-0.01%)
front_100_from_1000 8,246 (-0.02%) 38 (${\color{red}\textbf{+5.56\%%}}$) 136 (No change) 13,196 (+0.06%)
pre_save_1000 60,147 (+0.04%) 62 (No change) 287 (+0.35%) 70,502 (+0.09%)
push_1000 33,116 (+0.08%) 46 (No change) 160 (+0.63%) 38,946 (+0.15%)
delete_500_from_1000 12,155 (No change) 32 (No change) 138 (No change) 17,145 (No change)
front_100_from_1000 13,658 (-0.16%) 35 (${\color{green}\textbf{-7.89\%%}}$) 162 (-0.61%) 19,503 (-0.37%)
pre_save_1000 1,493,910 (+0.00%) 2,731 (-0.07%) 1,967 (No change) 1,576,410 (-0.00%)
push_1000 32,987 (-0.08%) 53 (${\color{red}\textbf{+1.92\%%}}$) 159 (-0.62%) 38,817 (-0.14%)
ReentrantCollectionView
contains_key_10_from_100 200,575 (-0.01%) 1,024 (-0.19%) 195 (-0.51%) 212,520 (-0.03%)
indices_100 330,774 (+0.00%) 1,196 (-0.08%) 371 (No change) 349,739 (-0.00%)
load_all_100_from_storage 1,123,372 (+0.00%) 6,150 (-0.02%) 409 (No change) 1,168,437 (-0.00%)
load_all_100_in_memory 563,881 (+0.00%) 1,845 (-0.05%) 545 (+0.18%) 592,181 (+0.01%)
pre_save_100 485,542 (+0.00%) 2,232 (-0.09%) 691 (No change) 520,887 (-0.00%)
RegisterView
get_set_100 119,883 (-0.02%) 36 (${\color{green}\textbf{-12.20\%%}}$) 181 (-0.55%) 126,398 (-0.06%)
pre_save 7,880 (-0.03%) 44 (${\color{red}\textbf{+4.76\%%}}$) 165 (No change) 13,875 (+0.06%)

Cache metrics fluctuate because anything that changes the virtual memory layout
shifts which data lands on which cache lines, changing the L1/LLC/RAM distribution.
Probable causes: ASLR (even across identical binaries), executable binary size changes,
shared library size changes, and even filename length differences.

Cachegrind simulates a two-level cache (L1 + LLC) auto-detected from the host CPU.
Est. Cycles = L1 hits + 5 × LLC hits + 35 × RAM hits.

Runner cache sizes: L1d cache: 64 KiB (2 instances);L1i cache: 64 KiB (2 instances) L2 cache: 1 MiB (2 instances);L3 cache: 32 MiB (1 instance)

@MathieuDutSik MathieuDutSik left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

It is a nice feature.
But it should be noted, that while it is not exactly a subset, it is strongly related to the PR #4975

Comment on lines +353 to +359
if let Some(keys) = cache.query_find_keys(key_prefix) {
#[cfg(with_metrics)]
metrics::FIND_KEYS_BY_PREFIX_CACHE_HIT_COUNT
.with_label_values(&[])
.inc();
return Ok(keys.into_iter().next());
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

If you match on the query_find_key_values then you can also prematurely end the search.

Comment on lines +370 to +376
if let Some(keys) = cache.query_find_keys(key_prefix) {
#[cfg(with_metrics)]
metrics::FIND_KEYS_BY_PREFIX_CACHE_HIT_COUNT
.with_label_values(&[])
.inc();
return Ok(keys.into_iter().next_back());
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Same.

Comment on lines +381 to +396
async fn find_first_key_value_by_prefix(
&self,
key_prefix: &[u8],
) -> Result<Option<(Vec<u8>, Vec<u8>)>, Self::Error> {
if let Some(cache) = self.get_exclusive_cache() {
let mut cache = cache.lock().unwrap();
if let Some(key_values) = cache.query_find_key_values(key_prefix) {
#[cfg(with_metrics)]
metrics::FIND_KEY_VALUES_BY_PREFIX_CACHE_HIT_COUNT
.with_label_values(&[])
.inc();
return Ok(key_values.into_iter().next());
}
}
self.store.find_first_key_value_by_prefix(key_prefix).await
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

I guess we do not need a corresponding cache entry such as cache.query_find_first_key or similar just yet.

Comment on lines +846 to +857
let mut view = CustomSetStateView::load(context.clone()).await?;
view.set.insert(&1u128)?;
view.set.insert(&256u128)?;
view.save().await?;
}
{
let view = CustomSetStateView::load(context.clone()).await?;
assert_eq!(view.set.first_index().await?, Some(1u128));
assert_eq!(view.set.last_index().await?, Some(256u128));
}
Ok(())
}

@MathieuDutSik MathieuDutSik Apr 29, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

I would prefer actually to have all those tests in a single function fn unit_tests_first_last_map_view.

I would also add tests to run_map_view_mutability. It is a fuzzing test and it could catch some regression.

More importantly, we should have tests that exercise the pain points of the implementation. And that can be for example the ValueSplitting problems show up with large values. But there is a LimitedTestMemoryStore that can help dealing with that.

Comment on lines +263 to +271
Ok(self
.store
.find_first_key_by_prefix(key_prefix)
.await?
.map(|mut key| {
key.truncate(key.len() - 4);
key
}))
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Unfortunately, this could be a leftover key, so I am not sure that even this construction works.

Comment on lines +273 to +285
async fn find_last_key_by_prefix(
&self,
key_prefix: &[u8],
) -> Result<Option<Vec<u8>>, Self::Error> {
// The largest stored key under any user-key prefix is `K|n` for the largest
// user key `K` and its highest segment index `n`. Strip the trailing 4 bytes.
Ok(self
.store
.find_last_key_by_prefix(key_prefix)
.await?
.map(|mut key| {
key.truncate(key.len() - 4);
key

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

That is also broken by the leftover keys.
Scenario is the following:

  • Write a big value that uses several keys.
  • Remove that key with a DeleteKey in a batch. Since removing the other segment keys requires doing a read, they are left over.
  • Then those keys get a wrong result.

The solution is to:

  • Read the index of the key in question. If zero, it is good, return.
  • If non-zero, we know it is a segment key (leftover or not, no way to know with an async operation). So, revert to a find_keys_by_prefix.

key.truncate(key.len() - 4);
key
}))
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

The implementation is actually using the default trait implementation, which uses a find_key_values_by_prefix.

That makes the implementation correct. However, in my opinion, it also makes it slow. All the containers are using this ValueSplitting. So, this is just about the same as using the find_keys_by_prefix.

Instead, I think that a better approach is:

  • Look for the first (key, value). If reading it allows to prove it is not segment, then return it.
  • Barring that, use find_key_values_by_prefix.

@MathieuDutSik MathieuDutSik mentioned this pull request Apr 30, 2026
@afck

afck commented Apr 30, 2026

Copy link
Copy Markdown
Contributor Author

Closing in favor of #6183.

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