apollo_batcher: acquire view call semaphore permit before storage reads - #14987
Conversation
Move the view_call_semaphore try_acquire_owned() check in Batcher::call_contract to the top of the function, before the storage reads (get_height_from_storage, get_block_info) and view state reader construction. Under load, when the semaphore is exhausted (the exact scenario it exists to protect against), a rejected call previously still paid for two storage reads and non-trivial allocation work for a request that gets discarded anyway. Checking the permit first makes the rejection path near-free.
PR SummaryLow Risk Overview When all If the semaphore is full and storage would also fail, callers now get Reviewed by Cursor Bugbot for commit 0e80912. Bugbot is set up for automated code reviews on this repo. Configure here. |
asaf-sw
left a comment
There was a problem hiding this comment.
@asaf-sw reviewed 1 file and all commit messages.
Reviewable status:complete! all files reviewed, all discussions resolved (waiting on gkaempfer).
|
Security scan complete — no issues detected. Generated by Claude Code |
Summary
Follow-up performance optimization on #14972, which added
view_call_semaphore(capacityMAX_CONCURRENT_VIEW_CALLS = 32) toBatcher::call_contractto bound how many view calls can occupy tokio blocking-pool threads at once.The merged code acquired the semaphore permit (
try_acquire_owned()) after two storage reads (get_height_from_storage,get_block_info) and after constructing the view state reader andBlockContext(which clones a storage reader, wraps a class-manager client, and boxes aStateReaderAndContractManager).This moves the permit acquisition to the top of
call_contract, before any of that setup work.Why
The semaphore exists specifically to protect the node when view calls are piling up under load. In exactly that scenario — the slots are exhausted and the call is going to be rejected anyway — the old ordering still paid for two storage reads (DB transactions) and non-trivial allocation work for a request whose result is thrown away. Checking the permit first makes the rejection path near-free, avoiding wasted I/O and allocation precisely when the system is already resource-constrained.
No other behavior changes: on the success path the permit still moves into the
spawn_blockingclosure and is held until the blocking task ends, per #14972's original guarantee. The only observable difference is that if the semaphore is exhausted and a storage read would have separately failed, the caller now sees the more informativeContractCallFailed { "Too many concurrent contract calls..." }instead ofInternalError— a strictly better error for a retryable backpressure condition.Testing
SEED=0 cargo test -p apollo_batcher: 132 passed, 0 failed (includingcall_contract_rejected_when_all_view_call_slots_are_takenandview_call_slot_is_freed_only_when_the_blocking_task_ends).Generated by Claude Code