Skip to content

feat(node): subnet read_state cache#9239

Open
andrewbattat wants to merge 13 commits intomasterfrom
andrew/read-state-cache
Open

feat(node): subnet read_state cache#9239
andrewbattat wants to merge 13 commits intomasterfrom
andrew/read-state-cache

Conversation

@andrewbattat
Copy link
Contributor

@andrewbattat andrewbattat commented Mar 7, 2026

NODE-1863

This change adds an in-memory cache in the boundary node for subnet read_state responses, keyed by (subnet_id, request paths).

When the same subnet/path request repeats within the TTL (default 30s), the BN serves the cached response directly instead of forwarding to the replica.

@andrewbattat andrewbattat self-assigned this Mar 7, 2026
@github-actions github-actions bot added the feat label Mar 7, 2026
@andrewbattat andrewbattat changed the title feat(node): read state cache feat(node): subnet read_state cache Mar 17, 2026
@andrewbattat andrewbattat marked this pull request as ready for review March 18, 2026 03:22
@andrewbattat andrewbattat requested a review from a team as a code owner March 18, 2026 03:22
@github-actions github-actions bot added the @node label Mar 18, 2026
}

#[derive(Clone)]
struct CachedResponse {
Copy link
Contributor

@blind-oracle blind-oracle Mar 18, 2026

Choose a reason for hiding this comment

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

We're missing response headers here completely.

Not sure it's a problem, but I'd just drop CachedResponse and use normal Response<Bytes> as a cache value. You will still have to split it into parts after fetching from the cache and reassemble as Response<AxumBody> but it's unavoidable probably.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Thanks Igor! WDYT? 8fb4414

Copy link
Contributor

@blind-oracle blind-oracle Mar 19, 2026

Choose a reason for hiding this comment

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

Very good, body mapping is even more concise than .into_parts()/from_parts() 👍
And we avoid panic-prone .expect() too, which would probably never happen, but still.


if response.status().is_success() {
let (parts, body) = response.into_parts();
let body_bytes = axum::body::to_bytes(body, 10 * 1024 * 1024)
Copy link
Contributor

Choose a reason for hiding this comment

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

Probably better to use buffer_body from ic-bn-lib and use a configurable limit from CLI instead of hardcoded one. Maybe cache_max_item_size that is applied to normal cached requests. Or add a separate option to your new CLI section (also add timeout too, needed for buffer_body).

Copy link
Contributor Author

Choose a reason for hiding this comment

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

8f6a260

Thank, Igor! Used 1mb for subnet_read_state_cache_max_item_size default instaed of cache_max_item_size because 10mb seemed too high, but I'm happy to reuse cache_max_item_size for simplicity.

Copy link
Contributor

Choose a reason for hiding this comment

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

👍 I guess we also need (separately) to improve the caching CLI opts (cache_...) to add the timeout there too. Currently it resorts to some default of 1 minute and not configurable. But that's out of the scope here.

pub subnet_read_state_cache_ttl: Duration,

/// Maximum number of cached subnet read_state entries
#[clap(env, long, default_value = "1000")]
Copy link
Contributor

Choose a reason for hiding this comment

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

I think it would be nicer to use bytes here, not number of entries. You can look at how that's done in generic http cache https://github.com/dfinity/ic-bn-lib/blob/main/ic-bn-lib/src/http/cache.rs using weigh_entry

/// TTL for cached subnet read_state responses.
/// Set to 0 to disable caching.
#[clap(env, long, default_value = "30s", value_parser = parse_duration)]
pub subnet_read_state_cache_ttl: Duration,
Copy link
Contributor

Choose a reason for hiding this comment

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

If we want this thing to be enabled by default, I'd add a separate bool flag e.g. subnet_read_state_cache_disable and rely on that to enable/disable it instead of zero TTL. This would be more explicit.

}
}

fn build_cache_key(subnet_id: SubnetId, ctx: &RequestContext) -> Option<CacheKey> {
Copy link
Contributor

Choose a reason for hiding this comment

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

There's some path, @r-birkner told me, that leads to the metrics endpoint of the subnets. Probably it's worth figuring it out and bypass the cache if it's metrics (so that we don't serve stale metrics). Not sure if that's critical, but probably worth doing.

Copy link
Contributor

Choose a reason for hiding this comment

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

Copy link
Contributor

Choose a reason for hiding this comment

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

Ah yes, I forgot the ticket context, that it should be an opt-in set of paths that we cache.

.time_to_live(ttl)
.build();

let hits = register_int_counter_with_registry!(
Copy link
Contributor

@blind-oracle blind-oracle Mar 18, 2026

Choose a reason for hiding this comment

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

Once you change the limit from number of entries to memory - I'd also add a gauge that shows how much memory it consumes. And the number of entries too.

I think they're stored as atomics in Moka and should be cheap to read on each call.

https://docs.rs/moka/latest/moka/sync/struct.Cache.html#method.entry_count
https://docs.rs/moka/latest/moka/sync/struct.Cache.html#method.weighted_size

let (parts, body) = response.into_parts();
let body_bytes = buffer_body(body, state.max_item_size, state.body_timeout)
.await
.map_err(|e| ErrorCause::Other(format!("failed to buffer response body: {e}")))?;
Copy link
Contributor

Choose a reason for hiding this comment

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

nit: there are special ErrorCause::UnableToReadBody/ErrorCause::BodyTimedOut and ErrorCause::PayloadTooLarge for such cases.

Idealy we should map the library error causes to our local ones, like this:

let body = buffer_body(body, MAX_REQUEST_BODY_SIZE, Duration::from_secs(60))
.await
.map_err(|e| match e {
HttpError::BodyReadingFailed(v) => ErrorCause::UnableToReadBody(v),
HttpError::BodyTooBig => ErrorCause::PayloadTooLarge(MAX_REQUEST_BODY_SIZE),
HttpError::BodyTimedOut => ErrorCause::BodyTimedOut,
_ => ErrorCause::Other(e.to_string()),
})?;

Better even to create a helper function that wraps buffer_body and returns correct error cause, to avoid duplication.

P.S.
Damn, here we also have a hardcoded timeout, need to improve :)

#[derive(Clone, Debug, PartialEq, Eq, Hash)]
struct CacheKey {
subnet_id: SubnetId,
paths: ReadStatePaths,
Copy link
Contributor

@blind-oracle blind-oracle Mar 19, 2026

Choose a reason for hiding this comment

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

nit: how big the paths can be? I wonder if we shouldn't store them directly in the cache and just calculate some hash over them before passing to moka.

We do smth like this here:
https://github.com/dfinity/ic-bn-lib/blob/b58540c74c224266be8fce3f7ebc41840dbe16c4/ic-bn-lib/src/http/cache.rs#L696-L718

Not sure it will save us a lot, though. Especially since we will scope the paths that we cache to only a small subset.

Comment on lines +34 to +35
pub hits: IntCounter,
pub misses: IntCounter,
Copy link
Contributor

Choose a reason for hiding this comment

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

nit: do these need to be public?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants