fair RPC batching layer - #4695
Conversation
|
Claude finished @MartinquaXD's task in 4m 46s —— View job PR Review: fair RPC batching layer
Nice change — the approach is clean and the fairness reasoning holds up. I traced the motivating scenario (spammy producer floods 1000 balance calls, Correctness: I did not find any hard bugs.
One optional (non-blocking) note left inline: the permit is acquired after the batch is assembled, so under sustained overload a built batch is held out of the queue while blocked on Minor observations (not worth acting on): test coverage is limited to the zero-delay round-robin path (the |
|
Claude finished @MartinquaXD's task in 4m 2s —— View job PR Review: fair RPC batching layer
Solid change — the fairness approach is well reasoned and the implementation holds up. I re-traced the motivating scenario (spammy balance producer floods requests, Correctness: no bugs found.
One minor, non-blocking observation (edge case, up to you — no change requested): if the The limited test coverage (zero-delay round-robin only) and the Nothing here blocks merge. 👍 |
jmg-duarte
left a comment
There was a problem hiding this comment.
LGTM, just have a small nit
Description
When we inspect
Solvable_orders::update()- the function responsible for assembling the final auction - with tempo we see that sometimes fetching balances takes a very long time (>100ms). This is very bad since we only issue very few RPC requests fetching new balances because basically all balances we need are already cached.The reason why this takes so long is that the background task that updates balances whenever we see a new block saturates the RPC buffering layer. What happens is effectively this:
Solvable_orders::update()processes ordersa. it finds a missing balance - gets enqueued AFTER the 1000 already enqueue requests
Solvable_orders::update()RPC calls get resolved (~few ms)That's why simply delaying the balances cache background task for 1 or 2 seconds causes

Solvable_orders::update()to be significantly faster and less spikey.There are a few ways how to handle this:
Changes
What I ended up going for was to simply make the buffering layer fairer.
Instead of every caller sending requests to 1 global queue which allows 1 spammy producer to significantly delay other producers we now store requests in 1 queue per producer (identified by tokio task id). Then when it comes time to assemble the batches we send over the wire we simply do round robin across all sub-queues.
That way the problem should be resolved for all producers without actually having to change any callers or introduce additional communication channels.
To facilitate this the batching logic provided in futures (
calls.chunks_timeout(config.ethrpc_max_batch_size, config.ethrpc_batch_delay)) needed to be replaced with handrolled logic but the behavior was preserved.The last change is that we now spawn a separate task per batch instead of doing everything in 1 task. This allows multiple cores to work on those tasks in parallel and was informed by the fact that the task regularly blocks the IO worker for >50ms when we are processing huge requests or responses (instrumented with dial9).
How to test
added basic unit test to verify that the round robin logic works
I temporarily tested 2 variants of this PR in prod:
FuturesUnorderedThere is definitely a noticeable improvement but unfortunately not as large as simply delaying the balances cache task. This PR still makes sense to me as it addresses a fundamental issue and whatever change we apply to the balance caching task (if we even do anything) would still cooperate with this PR.
