[graphql-stream] Support previousTransaction with cached streamed transactions [20/n] - #27709
[graphql-stream] Support previousTransaction with cached streamed transactions [20/n]#27709tpham-mysten wants to merge 2 commits into
Conversation
|
The latest updates on your projects. Learn more about Vercel for GitHub.
1 Skipped Deployment
|
| // A streamed checkpoint runs ahead of the KV backend, so a just-streamed transaction (e.g. a | ||
| // live `previousTransaction`) is not in the backend yet; serve it from the in-memory streamed | ||
| // store while it is. Only in a live context (`checkpoint_viewed_at` is `None`): a | ||
| // checkpoint-bounded read only references transactions the backend already has. | ||
| if self.scope.checkpoint_viewed_at().is_none() | ||
| && let Some(streaming_transactions) = ctx.data_opt::<Arc<StreamedTransactionStore>>() | ||
| && let Some(contents) = streaming_transactions.get(&digest) | ||
| { | ||
| return Ok(Self { | ||
| scope: self.scope.clone(), | ||
| contents: Some(contents), | ||
| }); | ||
| } |
There was a problem hiding this comment.
I think it's worth moving checkpoint_viewed_at on Scope into DataSource:
enum DataSource {
Indexed { viewed_at: u64 }, // normal query path
Backfill, // indexed reads, deliberately unbounded
Executed { execution_objects: ExecutionObjectMap },
Streamed { checkpoint: Arc<ProcessedCheckpoint> },
}
so that fetch bodies can match on the datasource and decide where to retrieve data accordingly
match scope.data_source() {
Executed { .. } => Ok(None),
Streamed { .. } | Backfill => {
if let Some(c) = streamed_store.get(&digest) { return Ok(Some(c)); }
kv.load_one_transaction(digest).await
}
Indexed { viewed_at } => {
let tx = kv.load_one_transaction(digest).await?;
Ok(tx.filter(|t| t.cp_sequence_number() <= viewed_at))
}
}
At least so that the logic in L578-602 are bundled together, as the current scope (execution, stream, or indexed data) impacts where we can read from, and given that Executed and Streamed already hold some relevant data.
Building on this, what do you think about having some Arc<StreamedOverlays> that bundles all the streamed and cached data, such that CheckpointStreamTask writes to it through index_and_broadcast, eviction task drains it, and these overlays get handed to the resolvers as we do with package_store today through matching_edges:
S::matching_edges(checkpoint, &overlays, ...)
Then we can slap it into scope
Scope::for_streamed_checkpoint(overlays, limits, checkpoint)
→ package_store field = overlays.packages
→ DataSource::Streamed { checkpoint, overlays }
culminating in
Streamed { overlays, .. } => overlays.transactions.get(&digest)
.map_or_else(|| kv.load(digest), ...),
Backfill => kv.load(digest),
There was a problem hiding this comment.
I think it's worth moving checkpoint_viewed_at on Scope into DataSource:
Yes, this is what we discussed at standup. But I think it's orthogonal: we can get the match style on the existing enum, without moving checkpoint_viewed_at, like:
match scope.data_source() {
DataSource::Executed { .. } => Ok(None),
DataSource::Streamed { .. } => {
if let Some(c) = streamed_store.get(&digest) { return Ok(Some(c)); }
kv.load_one_transaction(digest).await
}
DataSource::Indexed => match scope.checkpoint_viewed_at() {
None => { // backfill: unbounded indexed read
if let Some(c) = streamed_store.get(&digest) { return Ok(Some(c)); }
kv.load_one_transaction(digest).await
}
Some(viewed_at) => { // bounded query
let tx = kv.load_one_transaction(digest).await?;
Ok(tx.filter(|t| t.cp_sequence_number() <= viewed_at))
}
},
}At least so that the logic in L578-602 are bundled together, as the current scope (execution, stream, or indexed data) impacts where we can read from, and given that Executed and Streamed already hold some relevant data.
I get the motivation for this, and I think it's more a style/standard call than a correctness one, so whichever we pick, we should use everywhere, and it can go in a follow-up PR.
Here's how I see the two:
match on DataSource
- Pro: makes it clear the mode is what decides where we read from, and it's exhaustive, so a new variant forces us to handle it.
- Con: the
kv.load_one_transactionand the streamed-store check get repeated in several cases.
if-chain (current)
- Pro: reads as a step-by-step lookup: try the nearest place we might already have it (already hydrated, then on the scope, then the streamed store), then fall back to KV. Same flow in every mode; the mode only affects two steps (is the store usable, does the KV read need a cutoff). It's the usual "do we have it, else fetch it" shape we use in a lot of other lookups.
- Con: the mode's effect is spread across a few guards instead of one place, so "mode decides source" is implicit.
I slightly lean 60:40 to the if-chain, since the main flow is this step-by-step lookup and the mode only tweaks two steps, so a match ends up repeating the same logic arms. But it's close and mostly taste, so happy to bring it to standup. Either way it doesn't block this PR.
Building on this, what do you think about having some Arc that bundles all the streamed and cached data, such that CheckpointStreamTask writes to it through index_and_broadcast, eviction task drains it, and these overlays get handed to the resolvers as we do with package_store today through matching_edges:
Yeah I think it's nice wrapper. I have implemented that StreamedOverlays
Actually, I found the name StreamedOverlays is a bit confusing as it's basically an object holding shared cached store. WDYT about StreamedCaches?
tpham-mysten
left a comment
There was a problem hiding this comment.
Thanks @wlmyng!
| // A streamed checkpoint runs ahead of the KV backend, so a just-streamed transaction (e.g. a | ||
| // live `previousTransaction`) is not in the backend yet; serve it from the in-memory streamed | ||
| // store while it is. Only in a live context (`checkpoint_viewed_at` is `None`): a | ||
| // checkpoint-bounded read only references transactions the backend already has. | ||
| if self.scope.checkpoint_viewed_at().is_none() | ||
| && let Some(streaming_transactions) = ctx.data_opt::<Arc<StreamedTransactionStore>>() | ||
| && let Some(contents) = streaming_transactions.get(&digest) | ||
| { | ||
| return Ok(Self { | ||
| scope: self.scope.clone(), | ||
| contents: Some(contents), | ||
| }); | ||
| } |
There was a problem hiding this comment.
I think it's worth moving checkpoint_viewed_at on Scope into DataSource:
Yes, this is what we discussed at standup. But I think it's orthogonal: we can get the match style on the existing enum, without moving checkpoint_viewed_at, like:
match scope.data_source() {
DataSource::Executed { .. } => Ok(None),
DataSource::Streamed { .. } => {
if let Some(c) = streamed_store.get(&digest) { return Ok(Some(c)); }
kv.load_one_transaction(digest).await
}
DataSource::Indexed => match scope.checkpoint_viewed_at() {
None => { // backfill: unbounded indexed read
if let Some(c) = streamed_store.get(&digest) { return Ok(Some(c)); }
kv.load_one_transaction(digest).await
}
Some(viewed_at) => { // bounded query
let tx = kv.load_one_transaction(digest).await?;
Ok(tx.filter(|t| t.cp_sequence_number() <= viewed_at))
}
},
}At least so that the logic in L578-602 are bundled together, as the current scope (execution, stream, or indexed data) impacts where we can read from, and given that Executed and Streamed already hold some relevant data.
I get the motivation for this, and I think it's more a style/standard call than a correctness one, so whichever we pick, we should use everywhere, and it can go in a follow-up PR.
Here's how I see the two:
match on DataSource
- Pro: makes it clear the mode is what decides where we read from, and it's exhaustive, so a new variant forces us to handle it.
- Con: the
kv.load_one_transactionand the streamed-store check get repeated in several cases.
if-chain (current)
- Pro: reads as a step-by-step lookup: try the nearest place we might already have it (already hydrated, then on the scope, then the streamed store), then fall back to KV. Same flow in every mode; the mode only affects two steps (is the store usable, does the KV read need a cutoff). It's the usual "do we have it, else fetch it" shape we use in a lot of other lookups.
- Con: the mode's effect is spread across a few guards instead of one place, so "mode decides source" is implicit.
I slightly lean 60:40 to the if-chain, since the main flow is this step-by-step lookup and the mode only tweaks two steps, so a match ends up repeating the same logic arms. But it's close and mostly taste, so happy to bring it to standup. Either way it doesn't block this PR.
Building on this, what do you think about having some Arc that bundles all the streamed and cached data, such that CheckpointStreamTask writes to it through index_and_broadcast, eviction task drains it, and these overlays get handed to the resolvers as we do with package_store today through matching_edges:
Yeah I think it's nice wrapper. I have implemented that StreamedOverlays
Actually, I found the name StreamedOverlays is a bit confusing as it's basically an object holding shared cached store. WDYT about StreamedCaches?
49151ab to
35ef612
Compare
35ef612 to
7490a97
Compare
…esolves live [20/n]
…the tx store via scope
7490a97 to
24ce74e
Compare
Description
On a live subscription,
Object.previousTransactionresolved only its digest: the contents (kind, sender, effects) came back empty. The lookup falls through to the KV backend, which lags the streamed tip, so a transaction that was just streamed is not yet readable there.The fix caches each streamed checkpoint's transactions in memory (
StreamedTransactionStore), consulted before the KV backend so a just-streamedpreviousTransactionresolves from memory and hands off to the backend once it catches up. This mirrors the existingStreamedPackageStore, so the shared cache-and-evict logic is pulled into a genericStreamedStore, and both stores are now flushed by a single eviction task keyed on each one's backing watermark.Test plan
Stack
Release notes
Check each box that your changes affect. If none of the boxes relate to your changes, release notes aren't required.