Skip to content

feat(rpc): add streaming SyncAccountVaultV2 endpoint - #2483

Draft
kkovaacs wants to merge 4 commits into
nextfrom
krisztian/grpc-api-streaming
Draft

feat(rpc): add streaming SyncAccountVaultV2 endpoint#2483
kkovaacs wants to merge 4 commits into
nextfrom
krisztian/grpc-api-streaming

Conversation

@kkovaacs

Copy link
Copy Markdown
Collaborator

Summary

As proposed in issue #2356 this PR adds a PoC streaming implementation for syncing account vault changes via a new SyncAccountVaultV2 endpoint. Major changes compared to SyncAccountVault:

  • Stream individual vault updates to avoid block-range pagination and response-size estimation.
  • Return only the final vault value at the requested block for each key updated within the inclusive BlockRange, omitting intermediate historical values.

Changelog

[[entry]]
scope       = "rpc"
impact      = "added"
description = "Added a new SyncAccountVaultV2 public gRPC endpoint that uses response streaming instead of pagination."

Comment thread crates/rpc/src/server/api/sync_account_vault_v2.rs Outdated
// for validation, this pins the history generation so pruning cannot remove rows between
// internal database pages. Cancellation and the bounded send timeout release the view if
// the client stops consuming the stream.
let view = self.state.view();

@sergerad sergerad Aug 18, 2026

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Hmm I don't think we can do this. This basically allows user requests to control how long a snapshot can be held for. AFAIU this could be a simple OOM DOS vector? Depending on how long streams take, how much users can control their range, and how many streams we allow to be created at once.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Also the comment overstates what the view gives us: the pin is best-effort, not absolute.
PublishedGenerations::prune_tip only honors a pinned generation for
SNAPSHOT_PRUNE_LAG_CAP (= HISTORICAL_BLOCK_RETENTION = 50) blocks of chain progress
(crates/store/src/state/view/snapshot.rs:53,97) — beyond that the writer prunes anyway,
"accepting the historical-read race for that reader". A slow client on a large vault
(SEND_TIMEOUT is per-item and resets, so a stream can legally live for hours) will have
covering rows pruned between page transactions; later pages silently skip those keys and
the stream still ends OK, which the docs define as "result complete".

I don't think we need the pin at all. The result set for a fixed [from, to] is already
stable under concurrent commits (new rows fail block_num <= block_to; closing an open
row keeps valid_until > block_to); the only mid-stream hazard is pruning. Suggestion:

  • don't hold the view across pages — acquire per page fetch;
  • after each page's read, check the prune cutoff and terminate the stream with a
    retryable non-OK status if block_to < cutoff (check-after-read closes the race);
  • this is the same predicate as the request-time pruned-horizon guard (other comment below),
    so one mechanism fixes both silent-incompleteness paths and removes the
    user-controlled snapshot lifetime entirely.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

I wasn't quite sure we can do this, but you're right: since the per-response-chunk timeout is 10s the lifetime of the response stream might actually be significantly longer than ideal.

I've hopefully fixed both: removed the pinned view and added a check so that each chunk of responses we return is still within the retention window. We now return a BlockPruned error if block_to is too old.

See 9d757fd for details.

cursor: Option<AccountVaultCursor>,
page_size: NonZeroUsize,
) -> Result<AccountVaultValuesPage, DatabaseError> {
let block_range = self.scope_range(block_range)?;

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

scope_range only rejects ranges beyond the tip (block_to > tipRangeBeyondTip). I think
there's a missing check at the other end: nothing rejects a block_to older than the pruning
horizon (block_to < chain_tip − HISTORICAL_BLOCK_RETENTION).

prune_history deletes superseded rows with valid_until <= chain_tip − HISTORICAL_BLOCK_RETENTION.
This query needs exactly the covering rows (valid_until > block_to), so if block_to is below the
cutoff, a key changed in-range whose covering row was superseded before the cutoff is already
deleted — the key is silently omitted and the stream still ends OK, which the new docs define as
"result complete". The client can't distinguish "no change" from "pruned".

Note this only affects requests targeting an old block: a catch-up client requesting [C+1, tip] is
safe regardless of how old C is, since every covering row it needs has
valid_until > block_to ≥ cutoff and thus can't have been pruned.

The analogous point-read path fails loudly here (GetAccountError::BlockPruned,
crates/store/src/state/view/account/mod.rs:91); I think this endpoint needs the equivalent guard —
reject block_to < chain_tip − HISTORICAL_BLOCK_RETENTION with a BlockPruned-style error so the
client re-requests against a newer target instead of committing an incomplete delta.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Added in 9d757fd.

Validate target block is in the retained account-history window and don't
pin a view for the lifetime of the response stream: return a BlockPruned
error instead so that the client can recover.

@Mirko-von-Leipzig Mirko-von-Leipzig left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Looks pretty good to me.

Comment on lines +619 to +622
query = query.filter(
t::block_num
.gt(cursor_block)
.or(t::block_num.eq(cursor_block).and(t::vault_key.gt(cursor_key.to_bytes()))),

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Why do we want also traverse by block number? I thought it would just be the keys themselves?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

The cursor's shaped like (block_num, vault_key) so that it matches the primary index on the table: PRIMARY KEY (account_id, block_num, vault_key). This way the cursor (plus the implicit account_id we always have in the query) expresses a single point in the primary key space.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Maybe to rephrase my question a bit: are we returning every change to a key within that range, or only the final value at the end of the requested range?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Only the final value. Quoting from the doc string of select_account_vault_updates_v2:

Requiring valid_until > block_to removes intermediate updates while retaining a historical value that was superseded after the target.

Comment on lines +633 to +651
// Check the retention horizon after reading the page, within the same transaction. This ensures
// the page and chain tip come from one SQLite snapshot: if pruning has already made the target
// incomplete, discard the page instead of returning an apparently complete delta.
let chain_tip =
SelectDsl::select(schema::block_headers::table, max(schema::block_headers::block_num))
.get_result::<Option<i64>>(conn)?
.ok_or_else(|| {
DatabaseError::DataCorrupted("block headers table is empty".to_owned())
})?;
let chain_tip = BlockNumber::from_raw_sql(chain_tip)?;
let oldest_available = chain_tip
.checked_sub(HISTORICAL_BLOCK_RETENTION)
.unwrap_or(BlockNumber::GENESIS);
if target_block < oldest_available {
return Err(DatabaseError::BlockPruned {
block_num: target_block,
oldest_available,
});
}

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Should this check come before the page load?

Ideally this would all be part of the state view that we get implicitly.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Sure, we can move that check before the page load. The important thing is that it runs within the same transaction so that pruning cannot happen after the check.

Unfortunately StateView cannot guarantee that pruning doesn't remove historical data that's still visible relative to the block number. History pruning keys off the oldest live snapshot generation. However, to prevent extreme cases where a slow reader would stall pruning indefinitely old views are ignored once they falls more than HISTORICAL_BLOCK_RETENTION blocks behind the chain tip.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

However, to prevent extreme cases where a slow reader would stall pruning indefinitely old views are ignored once they falls more than HISTORICAL_BLOCK_RETENTION blocks behind the chain tip.

My perspective is that this should be a panic situation. As in, pruning should respect existing views, and if they take very long then clearly we have a major bug that requires fixing. And given that this would only affect full nodes in production, chain would continue even if this takes down the public facing RPC.

Unfortunately StateView cannot guarantee that pruning doesn't remove historical data that's still visible relative to the block number.

This should never be allowed imo - it basically removes the entire point of the view/snapshot.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

I think @sergerad might have opinions on this.

Since we're re-creating the StateView per page read I think this streaming-responses implementation is no worse than the old paged implementations?

The old implementation of select_account_vault_assets seems to have the same issue re pruning. Once pruning removes account vault data because the start of the block range goes out of the retention block range we'll return partial data. If the end of the block range goes out of the retention block range the client might miss entries completely.

@Mirko-von-Leipzig Mirko-von-Leipzig Aug 19, 2026

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

I may be misunderstanding. I just mean that once we have a view, it cannot be moved out from under us.

I imagined the usage/API would look something like this:

/// A read-only view of the state at a given moment in time.
///
/// SQLite, RocksDb and tips are guaranteed to be consistent wrt to each
/// other, and cannot move out from under you while this view is held.
struct View {
    db: DbTransaction,
    smt: RocksDbSnapshot,
    chain_tip: BlockNumber,
    proven_tip: BlockNumber,
}

async fn next_page(&self, state_at: BlockNumber, cursor: Key) -> Result<Page, Err> {
    let view = self.state.view().await?;

    // Does this view still support the block we need?
    if !view.contains(state_at) {
        return Err(Err::BlockPruned);
    }
    
    view.get_page(cursor, 1024).await
}

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

I'm not sure we can hold a SQLite transaction for the lifetime of a stream of responses. We have a 10s send timeout for each individual value, so a rogue / slow client can control how long it would hold a transaction (and the view).

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.

3 participants