Skip to content

[graphql-stream] Support previousTransaction with cached streamed transactions [20/n] - #27709

Open
tpham-mysten wants to merge 2 commits into
mainfrom
tpham-mysten/streamed-transaction-store
Open

[graphql-stream] Support previousTransaction with cached streamed transactions [20/n]#27709
tpham-mysten wants to merge 2 commits into
mainfrom
tpham-mysten/streamed-transaction-store

Conversation

@tpham-mysten

@tpham-mysten tpham-mysten commented Aug 14, 2026

Copy link
Copy Markdown
Contributor

Description

On a live subscription, Object.previousTransaction resolved 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-streamed previousTransaction resolves from memory and hands off to the backend once it catches up. This mirrors the existing StreamedPackageStore, so the shared cache-and-evict logic is pulled into a generic StreamedStore, and both stores are now flushed by a single eviction task keyed on each one's backing watermark.

Test plan

  • e2e + unit tests

Stack

Release notes

Check each box that your changes affect. If none of the boxes relate to your changes, release notes aren't required.

  • Protocol:
  • Nodes (Validators and Full nodes):
  • gRPC:
  • JSON-RPC:
  • GraphQL:
  • CLI:
  • Rust SDK:
  • Indexing Framework:

@tpham-mysten
tpham-mysten deployed to sui-typescript-aws-kms-test-env August 14, 2026 18:53 — with GitHub Actions Active
@vercel

vercel Bot commented Aug 14, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
sui-docs Building Building Preview Aug 20, 2026 3:31pm
sui-kiosk Building Building Preview Aug 20, 2026 3:31pm
1 Skipped Deployment
Project Deployment Actions Updated (UTC)
multisig-toolkit Ignored Ignored Preview Aug 20, 2026 3:31pm

Request Review

@tpham-mysten tpham-mysten changed the title [graphql-stream] Cache streamed transactions so previousTransaction resolves live [20/n] [graphql-stream] Support previousTransaction with cached streamed transactions [20/n] Aug 14, 2026
@tpham-mysten
tpham-mysten marked this pull request as ready for review August 14, 2026 18:56
@tpham-mysten
tpham-mysten requested a review from a team as a code owner August 14, 2026 18:56
@tpham-mysten
tpham-mysten deployed to sui-typescript-aws-kms-test-env August 14, 2026 18:56 — with GitHub Actions Active
Comment on lines +590 to +602
// 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),
});
}

@wlmyng wlmyng Aug 14, 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 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), 

@tpham-mysten tpham-mysten Aug 18, 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.

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_transaction and 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 tpham-mysten left a comment

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.

Thanks @wlmyng!

Comment on lines +590 to +602
// 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),
});
}

@tpham-mysten tpham-mysten Aug 18, 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.

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_transaction and 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
tpham-mysten requested a review from wlmyng August 18, 2026 04:23
@tpham-mysten
tpham-mysten force-pushed the tpham-mysten/streamed-transaction-store branch from 49151ab to 35ef612 Compare August 18, 2026 04:26
@tpham-mysten
tpham-mysten force-pushed the tpham-mysten/streamed-transaction-store branch from 35ef612 to 7490a97 Compare August 19, 2026 01:54
@tpham-mysten
tpham-mysten deployed to sui-typescript-aws-kms-test-env August 19, 2026 01:55 — with GitHub Actions Active
Base automatically changed from tpham-mysten/reject-far-ahead-subscription to main August 20, 2026 13:53
@tpham-mysten
tpham-mysten force-pushed the tpham-mysten/streamed-transaction-store branch from 7490a97 to 24ce74e Compare August 20, 2026 15:29
@tpham-mysten
tpham-mysten deployed to sui-typescript-aws-kms-test-env August 20, 2026 15:29 — with GitHub Actions Active
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