feat: make SyncNotes return multiple blocks#1843
Conversation
6961007 to
6e0dd69
Compare
bobbinth
left a comment
There was a problem hiding this comment.
Looks good! Thank you! I left a few small comments inline.
proto/proto/rpc.proto
Outdated
| BlockRange block_range = 5; | ||
|
|
||
| // List of all notes together with the Merkle paths from `response.block_header.note_root`. | ||
| repeated note.NoteSyncRecord notes = 4; | ||
| // Blocks containing matching notes, ordered by block number ascending. | ||
| // May be empty if no notes matched in the range. | ||
| repeated NoteSyncBlock blocks = 6; |
There was a problem hiding this comment.
The field numbering looks a bit off here. Could block_range not start at 1?
| /// Estimated byte size of a single [`NoteSyncRecord`]. | ||
| /// | ||
| /// Note ID (~38 bytes) + index + metadata (~26 bytes) + sparse merkle path with 16 | ||
| /// siblings (~608 bytes). | ||
| const NOTE_RECORD_BYTES: usize = 700; |
There was a problem hiding this comment.
This is fine for now, but most likely will be a significant overestimate because sparse Merkle paths get compressed, and in most cases shouldn't be more than a couple hundred bytes. But the compression depends on how many paths there are (the more paths, the worse the compression) - so, taking the worst case is fine for now.
| /// Estimated byte size of a [`NoteSyncBlock`] excluding its notes. | ||
| /// | ||
| /// `BlockHeader` (~341 bytes) + MMR proof with 32 siblings (~1216 bytes). | ||
| const BLOCK_OVERHEAD_BYTES: usize = 1600; |
There was a problem hiding this comment.
Not for this PR, but it would be good to make this based on the actual serialization sizes. For example, we could have BlockHeader::SERIALIZED_SIZE constant and use it here.
Let's create an issue for this (and include NOTE_RECORD_BYTES in this issue too).
igamigo
left a comment
There was a problem hiding this comment.
Looks great! I think there are a couple of edge cases that might need addressing though.
| // `block_to` matches the request's `block_range.block_to`, or the chain tip if it was | ||
| // not specified. |
There was a problem hiding this comment.
nit: This is not always true, right? The text below mentions that the block_to may be smaller than what the user requested (and the chain tip as well)
| /// - `note_tags`: The tags the client is interested in, resulting notes are restricted to the | ||
| /// first block containing a matching note. | ||
| /// - `block_range`: The range of blocks from which to synchronize notes. | ||
| /// Returns as many blocks with matching notes as fit within the response payload |
There was a problem hiding this comment.
nit: You can reference the max payload size constant here
| @@ -130,26 +130,34 @@ impl rpc_server::Rpc for StoreApi { | |||
| let request = request.into_inner(); | |||
|
|
|||
| let chain_tip = self.state.latest_block_num().await; | |||
There was a problem hiding this comment.
nit (feel free to disregard): There's no need to read the latest block number if the user set a block_to
| let request = request.into_inner(); | ||
|
|
||
| let chain_tip = self.state.latest_block_num().await; | ||
| let requested_block_to = request.block_range.as_ref().and_then(|r| r.block_to); |
There was a problem hiding this comment.
nit: I would move this down to be below line 134
| if accumulated_size > MAX_RESPONSE_PAYLOAD_BYTES { | ||
| break; | ||
| } |
There was a problem hiding this comment.
Returning a single block is always guaranteed, right? We should make sure this is the case because otherwise it might be problematic
|
|
||
| loop { | ||
| let range = current_from..=checkpoint; | ||
| let Some(note_sync) = self.db.get_note_sync(range, note_tags.clone()).await? else { |
There was a problem hiding this comment.
Wonder if get_note_sync could change to take something like &[u32] to avoid cloning every time here
| block_range: RangeInclusive<BlockNumber>, | ||
| ) -> Result<(NoteSyncUpdate, MmrProof, BlockNumber), NoteSyncError> { | ||
| ) -> Result<Vec<(NoteSyncUpdate, MmrProof)>, NoteSyncError> { | ||
| let inner = self.inner.read().await; |
There was a problem hiding this comment.
I think we want to take the lock in the loop to avoid keeping it for the whole duration
There was a problem hiding this comment.
(and it can be moved right next to the open_at() call)
| if block_num >= checkpoint { | ||
| break; | ||
| } |
There was a problem hiding this comment.
We break here but AFAIK we are supposed to return notes for the checkpoint block, right? I think this might be a special case. If the user calls SyncMmr which returns the chain tip header and the MmrDelta, the user already has the header (and it's already validated by default). So when block_num == checkpoint, we might to just returnr the notes, and not return the merkle path at all (open_at would otherwise fail, I think). cc @bobbinth
| break; | ||
| } | ||
|
|
||
| let mmr_proof = inner.blockchain.open_at(block_num, checkpoint)?; |
There was a problem hiding this comment.
Before, I think if the user set a block_to beyond the chain tip, the response would be automatically valid because the store would find no note after the chain tip, but now because we open_at the user-set checkpoint, this might fail. I think this is fine because it's a wrong argument, but maybe at the RPC level we should check that the block_to is not beyond the chain tip and return early.
Closes #1809
This PR updates the RPC component to batch multiple blocks into a single
SyncNotesresponse.Changes
sync_noteshandler now loops over store calls, accumulating blocks until the 4MB budget is exceeded or the range is exhaustedSyncNotesResponseproto from single-block (header + mmr_path + notes) to multi-block (repeatedNoteSyncBlockblocks +BlockRange). AddedStoreSyncNotesResponsefor the store-internal single-block response, usingBlockRangeinstead ofPaginationInfo.sync_notesusesopen_at(block_num, checkpoint)instead ofopen(block_num)so MMR proofs are relative toblock_toFollow ups: