Skip to content

Sync keyvaluestore#6153

Open
MathieuDutSik wants to merge 8 commits into
linera-io:mainfrom
MathieuDutSik:sync_keyvaluestore
Open

Sync keyvaluestore#6153
MathieuDutSik wants to merge 8 commits into
linera-io:mainfrom
MathieuDutSik:sync_keyvaluestore

Conversation

@MathieuDutSik

Copy link
Copy Markdown
Contributor

Motivation

The KeyValueStore is an async library that is being used for implementing the views. When used for
protocol code that is fine. However, contract code is actually sync, so using KeyValueStore is actually
using the wrong tools.

Fixes #3026

Proposal

Introduce a SyncKeyValueStore that does the operation in a sync way. The translation is direct.
After this the justification for having async fn execute_operation(_) collapses, and we can instead write
fn execute_operation(_). That asyncness was anyway entirely artificial since we have in fact a .block_wait()
in the linera-sdk code.

A similar change cannot be made for the Service trait since it uses the async-graphql functionality, which does
not have a sync variant.

Test Plan

CI

The test test_wasm_end_to_end_matching_engine_benchmark runs in 762.849ms in main and in 683.950916ms for this branch.

Release Plan

That can actually be backported to testnet_conway since all the artificial .blocking_wait() calls
are inside of the linera-sdk, not of the protocol.

But of course, the documentation would have to be changed.

Links

None

@github-actions

github-actions Bot commented Apr 28, 2026

Copy link
Copy Markdown

Instruction Count Benchmark Results

Baseline: cef8728b2b

Deterministic metrics — reproducible across runs (34 benchmarks)
Benchmark Instructions Total R+W
BucketQueueView
delete_500_from_1000 22,397 34,353
front_100_from_1000 5,701 8,420
pre_save_1000 43,132 60,513
push_1000 24,357 33,305
Cold Load
load_1000 693,837 (+0.00%) 1,010,313 (+0.00%)
CollectionView
indices_100 192,273 (-0.06%) 267,440 (-0.08%)
load_all_100_from_storage 636,997 (-0.19%) 899,456 (-0.19%)
load_all_100_in_memory 340,850 (-0.01%) 476,907 (-0.05%)
pre_save_100 265,779 (-0.03%) 367,272 (-0.09%)
try_load_10_from_100 100,631 (+0.11%) 142,520 (+0.14%)
MapView
contains_key_10_from_100 52,502 (-0.06%) 74,509 (-0.06%)
contains_key_10_from_1000 355,052 (-0.01%) 501,800 (-0.01%)
get_10_from_100 55,010 (-0.04%) 78,171 (-0.03%)
get_10_from_1000 357,626 (+0.01%) 505,553 (+0.01%)
get_100_missing_from_1000 609,363 (-0.12%) 850,107 (-0.13%)
indices_100 100,133 (-0.32%) 137,952 (-0.27%)
indices_1000 946,199 (-0.21%) 1,320,175 (-0.15%)
insert_100 257,141 (-0.05%) 355,985 (+0.08%)
insert_1000 2,962,503 (-0.03%) 4,016,287 (+0.07%)
post_save_1000 1,027,028 (No change) 1,481,044 (No change)
pre_save_100 332,617 (+0.01%) 462,561 (+0.02%)
pre_save_1000 3,383,711 (+0.06%) 4,761,731 (+0.06%)
remove_500_from_1000 1,199,609 (+0.84%) 1,675,512 (+0.90%)
QueueView
delete_500_from_1000 10,243 (No change) 12,351 (No change)
front_100_from_1000 9,037 (${\color{green}\textbf{-1.09\%%}}$) 13,780 (-0.73%)
pre_save_1000 1,041,910 (-0.10%) 1,497,616 (-0.07%)
push_1000 24,293 (-0.00%) 33,222 (-0.01%)
ReentrantCollectionView
contains_key_10_from_100 142,643 (+0.53%) 203,061 (+0.61%)
indices_100 237,911 (+0.36%) 333,732 (+0.44%)
load_all_100_from_storage 803,083 (-0.04%) 1,132,736 (+0.02%)
load_all_100_in_memory 412,300 (+0.26%) 568,120 (+0.33%)
pre_save_100 351,346 (+0.23%) 489,142 (+0.21%)
RegisterView
get_set_100 81,166 (-0.15%) 119,932 (-0.16%)
pre_save 5,493 (+0.15%) 8,101 (+0.15%)

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
BucketQueueView
delete_500_from_1000 34,160 34 159 39,895
front_100_from_1000 8,253 30 137 13,198
pre_save_1000 60,162 61 290 70,617
push_1000 33,103 43 159 38,883
Cold Load
load_1000 1,001,792 (+0.00%) 8,350 (-0.06%) 171 (${\color{green}\textbf{-2.29\%%}}$) 1,049,527 (-0.01%)
CollectionView
indices_100 266,193 (-0.08%) 854 (+0.12%) 393 (-0.76%) 284,218 (-0.11%)
load_all_100_from_storage 894,956 (-0.19%) 3,844 (-0.93%) 656 (${\color{green}\textbf{-1.20\%%}}$) 937,136 (-0.23%)
load_all_100_in_memory 474,776 (-0.05%) 1,396 (+0.50%) 735 (-0.94%) 507,481 (-0.09%)
pre_save_100 365,345 (-0.09%) 1,339 (-0.22%) 588 (${\color{green}\textbf{-1.51\%%}}$) 392,620 (-0.17%)
try_load_10_from_100 141,656 (+0.14%) 641 (${\color{red}\textbf{+1.26\%%}}$) 223 (${\color{green}\textbf{-2.19\%%}}$) 152,666 (+0.04%)
MapView
contains_key_10_from_100 74,210 (-0.06%) 97 (${\color{red}\textbf{+5.43\%%}}$) 202 (${\color{green}\textbf{-2.88\%%}}$) 81,765 (-0.28%)
contains_key_10_from_1000 498,624 (-0.01%) 2,974 (-0.07%) 202 (${\color{green}\textbf{-2.88\%%}}$) 520,564 (-0.05%)
get_10_from_100 77,872 (-0.03%) 89 (${\color{red}\textbf{+1.14\%%}}$) 210 (${\color{green}\textbf{-1.41\%%}}$) 85,667 (-0.14%)
get_10_from_1000 502,363 (+0.01%) 2,979 (-0.03%) 211 (-0.47%) 524,643 (-0.00%)
get_100_missing_from_1000 846,898 (-0.13%) 2,983 (+0.03%) 226 (${\color{green}\textbf{-1.31\%%}}$) 869,723 (-0.14%)
indices_100 137,324 (-0.28%) 233 (${\color{red}\textbf{+3.56\%%}}$) 395 (-0.25%) 152,314 (-0.25%)
indices_1000 1,312,505 (-0.16%) 6,490 (+0.17%) 1,180 (No change) 1,386,255 (-0.14%)
insert_100 355,245 (+0.07%) 82 (${\color{green}\textbf{-2.38\%%}}$) 658 (${\color{red}\textbf{+1.08\%%}}$) 378,685 (+0.13%)
insert_1000 4,009,245 (+0.07%) 3,053 (+0.36%) 3,989 (+0.18%) 4,164,125 (+0.08%)
post_save_1000 1,469,657 (+0.00%) 11,207 (+0.02%) 180 (${\color{green}\textbf{-1.64\%%}}$) 1,531,992 (-0.01%)
pre_save_100 461,176 (+0.01%) 773 (${\color{red}\textbf{+1.18\%%}}$) 612 (-0.49%) 486,461 (+0.00%)
pre_save_1000 4,747,807 (+0.06%) 10,110 (-0.04%) 3,814 (-0.05%) 4,931,847 (+0.06%)
remove_500_from_1000 1,671,132 (+0.91%) 4,201 (-0.05%) 179 (${\color{red}\textbf{+1.13\%%}}$) 1,698,402 (+0.90%)
QueueView
delete_500_from_1000 12,180 (+0.04%) 35 (${\color{green}\textbf{-7.89\%%}}$) 136 (${\color{green}\textbf{-1.45\%%}}$) 17,115 (-0.47%)
front_100_from_1000 13,585 (-0.71%) 33 (${\color{green}\textbf{-8.33\%%}}$) 162 (-0.61%) 19,420 (-0.75%)
pre_save_1000 1,492,920 (-0.07%) 2,733 (-0.15%) 1,963 (+0.05%) 1,575,290 (-0.06%)
push_1000 33,016 (+0.01%) 46 (${\color{green}\textbf{-11.54\%%}}$) 160 (No change) 38,846 (-0.07%)
ReentrantCollectionView
contains_key_10_from_100 201,830 (+0.61%) 1,031 (${\color{red}\textbf{+1.08\%%}}$) 200 (+0.50%) 213,985 (+0.62%)
indices_100 332,160 (+0.44%) 1,201 (+0.33%) 371 (-0.27%) 351,150 (+0.42%)
load_all_100_from_storage 1,126,159 (+0.02%) 6,174 (+0.46%) 403 (-0.98%) 1,171,134 (+0.02%)
load_all_100_in_memory 565,714 (+0.32%) 1,863 (${\color{red}\textbf{+1.20\%%}}$) 543 (-0.73%) 594,034 (+0.30%)
pre_save_100 486,215 (+0.21%) 2,246 (+0.40%) 681 (+0.15%) 521,280 (+0.21%)
RegisterView
get_set_100 119,723 (-0.15%) 32 (${\color{green}\textbf{-21.95\%%}}$) 177 (${\color{green}\textbf{-2.75\%%}}$) 126,078 (-0.32%)
pre_save 7,896 (+0.13%) 39 (${\color{green}\textbf{-4.88\%%}}$) 166 (${\color{red}\textbf{+2.47\%%}}$) 13,901 (${\color{red}\textbf{+1.02\%%}}$)

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 marked this pull request as ready for review May 3, 2026 16:54

/// A synchronous in-memory store.
#[derive(Clone)]
pub struct SyncMemoryStore {

@ma2bd ma2bd May 4, 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 don't see any difference with MemoryStore. Seems like you could just implement all traits for MemoryStore.

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.

Yes, the MemoryStore was anyway sync, so nest a SyncMemoryStore in it.

Comment thread linera-views/src/backends/memory.rs Outdated
Comment on lines +295 to +297
fn max_stream_queries(&self) -> usize {
self.max_stream_queries
}

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 don't think we need this in a SyncReadableKeyValueStore implementation.

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.

Eliminated.

Comment thread linera-views/src/backends/memory.rs Outdated
let map = self
.map
.read()
.expect("SyncMemoryStore lock should not be poisoned");

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.

nit: .unwrap() is accepted in this case

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.

Ok, done.

Ok(values)
}

async fn find_key_values_by_prefix(

@ma2bd ma2bd May 4, 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.

Could all the async methods simply call their synchronous sister instead of duplicating the code?

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.

You are right, the code is simplified.

}
}

impl SyncReadableKeyValueStore for SyncMemoryStore {

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.

Note that RocksDb would be a happy SyncReadableKeyValueStore (although I don't quite have a usecase for it right now).

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.

Ok, done.

@MathieuDutSik MathieuDutSik force-pushed the sync_keyvaluestore branch 3 times, most recently from 04cb35f to 340796a Compare May 4, 2026 16:54
Comment thread linera-views-derive/src/lib.rs Outdated
Self::post_load(context, &[])
} else {
let keys = Self::pre_load(&context)?;
let values = context.store().read_multi_values_bytes(&keys).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.

Is this .await the only difference?

Would it make sense have a single variable maybe_await that is either "" or ".await" instead? Then we could deduplicate this and this line would become:

Suggested change
let values = context.store().read_multi_values_bytes(&keys).await?;
let values = context.store().read_multi_values_bytes(&keys)#maybe_await?;

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.

In that case, it is not the only difference. There is also

use linera_views::{context::Context as _, store::ReadableKeyValueStore as _};

vs

use linera_views::{context::SyncContext as _, store::SyncReadableKeyValueStore as _};

Comment thread linera-views-derive/src/lib.rs Outdated

let has_pending_changes_fn = match mode {
ViewMode::Async => quote! {
async fn has_pending_changes(&self) -> bool {

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.

…and a maybe_async would allow deduplicating this and others.

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.

Yes, we can do that.

Comment thread linera-views/src/backends/memory.rs Outdated
const TEST_MEMORY_MAX_STREAM_QUERIES: usize = 10;

/// The number of streams for the sync test store
const SYNC_MEMORY_MAX_STREAM_QUERIES: usize = 10;

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.

What kind of streams? Isn't this only used in the async case?

@MathieuDutSik MathieuDutSik May 5, 2026

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.

Yes, we had a leakage of abstraction:

  • Yes, the SYNC_MEMORY_MAX_STREAM_QUERIES is not relevant to the SyncMemoryStore.
  • But the MemoryStore was nesting a SyncMemoryStore hence that structure.

Addressed.

Comment thread linera-views/src/backends/memory.rs Outdated
/// A synchronous in-memory store.
///
/// This is the primary type holding the actual fields and sync trait
/// implementations, since the underlying storage (BTreeMap behind

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.

Suggested change
/// implementations, since the underlying storage (BTreeMap behind
/// implementations, since the underlying storage (`BTreeMap` behind

(Or "B-tree map", I guess.)

{
/// The type of the key-value store used by this context.
type Store: SyncReadableKeyValueStore
+ SyncWritableKeyValueStore

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's the only difference compared to Context… I also noticed that some (all?) of the view types are actually identical to their async versions.

I'm wondering if it would be better to, instead of having sync versions of all these structs and traits, just add sync (i.e. blocking) methods to the existing structs and traits?

In some impls (memory, RocksDB) the async methods would call the more natural sync ones; in others (ScyllaDB, DynamoDB) it would be the other way round? For views, some of the methods would still have two full implementations, of course, but e.g. all the mutation methods would not, since they are already sync!

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.

Using blocking method like .blocking_wait() is exactly what we have now in the linera-sdk.
I think the benchmarks demonstrate that this has costs.

We certainly could provide Sync and async functionalities for all the storage. However, I do not think it is a good idea since it oversteps the crucial question: What is actually being used?
And the situation is the following:

  • For protocol code, async code is most natural.
  • For smart contract code, sync code is most natural.

I think that is a better approach than looking at having the most general code.

#[allocative(bound = "C, T: Allocative")]
pub struct SyncLazyRegisterView<C, T>(
/// The inner async lazy register view whose state and logic we reuse.
pub(crate) LazyRegisterView<C, T>,

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.

Should this be private? Do we access it elsewhere?

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.

Yes.

Comment thread linera-views/src/sync_views/log_view.rs Outdated
#[allocative(bound = "C, T: Allocative")]
pub struct SyncLogView<C, T>(
/// The inner async log view whose state and logic we reuse.
pub(crate) LogView<C, T>,

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.

(Private?)

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.

Yes.

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.

Have a version of linera_views that is synchronous

3 participants