Repository navigation
fix(icp-rosetta): take the blockchain write lock only to store synced blocks - #11965
Merged
basvandijk merged 4 commits intoOct 29, 2026
Conversation
Contributor
There was a problem hiding this comment.
🟡 Changes recommended
Cancellation between the final batch write and verification can leave the stored tip unverified across subsequent syncs.
1 open issue
What changed in this PR
This PR reduces ICP Rosetta API blocking during ledger synchronization by moving network fetches outside the blockchain write lock, building on the earlier nested-read-lock fix.
Changes:
- Uses a read lock to check the local tip and write locks to store batches and finalize synchronization.
- Adds a mutex to serialize sync calls.
- Adds gated-fetch tests covering reader access, batching, concurrent syncs, and cancellation.
| File | Description |
|---|---|
rs/rosetta-api/icp/ledger_canister_blocks_synchronizer/src/ledger_blocks_sync.rs |
Narrows write-lock scope, serializes syncs, and adds locking regression tests. |
Review effort: Balanced
|
✅ No security or compliance issues detected. Reviewed everything up to 6f8f068. Security OverviewDetected Code Changes
|
… blocks LedgerBlocksSynchronizer::sync_blocks took the blockchain's write lock on every call, also when there were no new blocks, and held it while it fetched new blocks from the ledger, so API requests that read blocks waited for those fetches. Now sync_blocks checks for new blocks under a read lock and fetches them without a lock. It takes the write lock only to store each batch of blocks and, at the end, to store the last blocks, verify them, make the Rosetta blocks and prune, as before. A new mutex keeps syncs serialized, which the write lock used to do. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
When a sync's range was a multiple of the batch size, the last intermediate batch stored the tip, and the sync then had to take the write lock again to verify it. If the sync was cancelled while waiting for that lock, for example by the watchdog, the tip stayed unverified: later syncs see no new blocks and return early until the ledger moves on. Never store the last blocks in an intermediate batch, so they are always stored and verified under one write lock. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
basvandijk
force-pushed
the
ai/icp-rosetta-sync-write-lock-2026-10-27
branch
from
October 28, 2026 10:32
8a76c4c to
514479c
Compare
mbjorkqvist
approved these changes
Oct 29, 2026
mbjorkqvist
left a comment
Contributor
There was a problem hiding this comment.
Thanks @basvandijk! Just one comment regarding new behavior during initial sync that this PR introduces.
basvandijk
enabled stacked auto-merge
October 29, 2026 14:17
basvandijk
disabled stacked auto-merge
October 29, 2026 14:17
…initial sync Review feedback: now that the sync no longer holds the write lock during the initial sync, requests that read blocks run against a store without verified blocks. They returned non-retriable errors, and looking up the latest verified block read every stored block, since `verified` has no index. Until the initial sync is complete, `/account/balance`, `/block`, `/block/transaction`, `/search/transactions` and the `query_block_range` method of `/call` now return the retriable error of `/network/status` instead of reading the store. They still check the request first, so invalid requests get their own errors. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Review feedback: the check runs before each endpoint's first block read, after the request checks that come before that read, not after every request check. For example, `/search/transactions` checks `limit`, `offset`, `max_block` and the account identifier after its first read. On master those checks also ran only after the initial sync, since the read waited for the sync's write lock. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.

Problem
ICP Rosetta keeps its blocks in
LedgerBlocksSynchronizer::blockchain, atokio::sync::RwLock<Blocks>that every API request that reads blocks takes for reading. The sync thread callssync_blocksonce a second, andsync_blocks:sync_range_of_blocksfetched the new blocks from the ledger and its archives, with retries.So API requests that read blocks queued behind every sync, and waited for the network round trips of every sync with new blocks. During the initial sync, which is a single
sync_blockscall, they waited until it finished.This was reported in #11962.
Fix
sync_blocksreads the local tip under a read lock, and returns without taking the write lock when there are no new blocks.sync_range_of_blocksfetches and checks blocks without a lock. It takes the write lock only to store each batch of 500k blocks, and once at the end to store the remaining blocks, which always include the last one, and verify them. It returns that write guard, andsync_blocksmakes the Rosetta blocks and prunes under it, as before.sync_lockmutex makessync_blockscalls wait for each other, which the write lock used to do. Rosetta runs a single sync task on a current-thread runtime, so its syncs don't overlap, butsync_blocksis public, and two overlapping calls would fetch and store the same blocks./account/balance,/block,/block/transaction,/search/transactionsand thequery_block_rangemethod of/callreturn the retriable "still syncing" error of/network/status(feat(ICP_Rosetta): FI-1876: Return errors immediately during initial sync #7203) instead of reading the store. The check runs before each endpoint's first block read, so the request checks that come before that read still answer right away, as before. The other/callmethods and the construction endpoints don't read blocks and keep working.Why it needs #11962
This PR doesn't touch the code #11962 changed, but it relies on its fix. Before #11962, some request handlers took a second read lock on the blockchain while holding one, which deadlocks with a write lock request queued in between. Before this PR, the initial sync held the write lock throughout, so no request held a read lock while it ran. Now requests run while the sync fetches, and every write lock the sync takes to store blocks can land in one of those windows. The watchdog only restarts a stuck sync after the first successful one, so without #11962 a deadlock during the initial sync would last until Rosetta is restarted. This PR is stacked on #11964 only because GitHub stacks are linear; it doesn't depend on it.
What readers see
/callquery_block_range, which can return the first unverified block and mark the response idempotent.Blockchain is emptyerror, and looking up the latest verified block would read every stored block, since theverifiedcolumn has no index. The initial sync is the first successful sync after Rosetta starts, so like/network/statusthey return the error until then even if the store has blocks from an earlier run, for example while Rosetta can't reach the ledger.Testing
New tests in
ledger_blocks_sync.rsuse a mock ledger whose fetches wait until the test lets them through:sync_lockreading_blocks_does_not_wait_for_fetches: at every fetch of a 3-block sync, a reader gets the read lock and doesn't see the new blocks yetsync_without_new_blocks_does_not_wait_for_readerssync_stores_blocks_in_batches_and_verifies_them_at_the_end: batch size 2, 5 blockssync_verifies_its_last_blocks_when_it_stores_them: batch size 2, 4 blocks; a reader that gets the read lock right after the sync stores its last block finds it verifiedconcurrent_syncs_store_each_block_onceblock_idxprimary keysync_dropped_while_fetching_does_not_block_the_next_one: like the watchdog aborting the sync taskA variant that stores the blocks of every fetch right away fails
reading_blocks_does_not_wait_for_fetches. A variant that stores the last blocks of a sync in a batch of their own, before it takes the write lock again to verify them, failssync_verifies_its_last_blocks_when_it_stores_them: if the sync were cancelled in between, the stored tip would stay unverified until the ledger moves on.sync_dropped_while_fetching_does_not_block_the_next_oneguards that a sync the watchdog aborts releasessync_lock.test_endpoints_that_read_blocks_return_error_during_initial_syncinrequest_handler/tests.rscalls each endpoint that reads blocks while the initial sync isn't complete and the blockchain is write-locked: each returns the "still syncing" error without waiting for the lock. Once the initial sync is complete, they all succeed. Without the check in any one of the endpoints, the test fails for that endpoint.test_invalid_requests_get_their_own_errors_during_initial_syncchecks that during the initial sync, the request checks before the first block read still return their own errors, and a/callmethod that doesn't read blocks isn't affected. It fails if the check in/account/balance,/search/transactionsor/callcomes before those request checks.The test targets that depend on the changed files pass locally, including
icp_rosetta_system_tests. So dorosetta-integration_tests, which run Rosetta's sync against PocketIC, and the_localvariants ofrosetta_testandrosetta_neuron_follow_testinrs/tests/financial_integrations/rosetta. PR CI doesn't run those system tests, because they're taggedlong_test.Not changed here
/network/optionslists error 702 (NotAvailableOffline) as not retriable, but the "still syncing" error is retriable./network/statushas returned it since #7203, and this PR returns it from more endpoints. Offline mode returns 702 as not retriable, so fixing this needs a separate error code for "still syncing".This PR fixes an issue reported in #11962, which was created following the steps in
.claude/skills/fix-flaky-tests/SKILL.md.🤖 Generated with Claude Code