feat(vms/evm/sync/code): add code-by-hash request handler - #5402
feat(vms/evm/sync/code): add code-by-hash request handler#5402powerslider wants to merge 21 commits into
Conversation
bd2c481 to
23b127f
Compare
494c5b0 to
ea95d0a
Compare
855c26c to
7134d5e
Compare
6efbe12 to
ea9b785
Compare
There was a problem hiding this comment.
IMO we shouldn't migrate this, but just tighten the client.Client interface to the only method that's used GetCode, and we can fully migrate it later
74b9817 to
0ccd4eb
Compare
b86960f to
3630309
Compare
dcbe4f7 to
7c3dff9
Compare
alarso16
left a comment
There was a problem hiding this comment.
A few nits, looks good to me
567b4e5 to
df3e77e
Compare
- Serves code-by-hash requests at `p2p.EVMCodeRequestHandlerID`. - `code.Handler` / `code.Responder` bind `syncpb.GetCodeRequest` and `syncpb.GetCodeResponse` to the generic shell. - `code.Stats` + `code.NoopStats` for the metrics surface. - `synctest.CodeRecorder` for assertions in tests. resolves #5401 Signed-off-by: Tsvetan Dimitrov (tsvetan.dimitrov@avalabs.org)
- Collapse handler API into a single `RegisterHandler` entry. - Unexport the responder plumbing. - Verify each blob against its hash, re-request on mismatch. - Integration-test the handler to client round trip. - Add `synctest.NewSelfNetwork` loopback helper.
df3e77e to
77c5019
Compare
There was a problem hiding this comment.
Pull request overview
Adds EVM “code-by-hash” sync plumbing: a p2p request handler for serving contract bytecode by hash, plus a syncer that batches requested hashes, verifies responses, and persists code into the local DB. This supports the broader EVM state sync workflow by enabling peers to fetch missing contract code blobs safely.
Changes:
- Added a
p2p.EVMCodeRequestHandlerIDhandler to serve code blobs by requested hashes with size/limit checks. - Added a code syncer that consumes code hashes, batches requests, verifies responses, and writes code + clears “to fetch” markers.
- Added UTs and an in-process integration-style test using a new
synctest.NewSelfNetworkhelper.
Reviewed changes
Copilot reviewed 7 out of 7 changed files in this pull request and generated 3 comments.
Show a summary per file
| File | Description |
|---|---|
| vms/evm/sync/synctest/network.go | Adds a single-node loopback p2p network helper used by sync tests. |
| vms/evm/sync/synctest/BUILD.bazel | Updates Bazel deps for the enhanced synctest network helper. |
| vms/evm/sync/code/syncer.go | Introduces the code syncer (batching, verification, persistence). |
| vms/evm/sync/code/syncer_test.go | Adds syncer unit tests plus a tampered-response retry/verification test. |
| vms/evm/sync/code/handler.go | Adds the code-by-hash request handler and request-size cap. |
| vms/evm/sync/code/handler_test.go | Adds handler tests (order, duplicates, missing hash, limits, sentinel uniqueness). |
| vms/evm/sync/code/BUILD.bazel | Expands library sources and adds Bazel tests for the new code sync components. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| resp := &syncpb.GetCodeResponse{} | ||
| outcome, err := c.Send(ctx, req, resp) | ||
| if err != nil { | ||
| // Send already de-scored the peer, re-request from another. | ||
| continue |
| if len(code) > params.MaxCodeSize { | ||
| return fmt.Errorf("%w: hash %s size %d", errCodeSizeExceeded, hashes[i], len(code)) | ||
| } |
There was a problem hiding this comment.
We should comment why this is required. I suspect it isn't, although I feel like it does prevent us from doing some more work than we need to.
For instance, if we requested a bunch of big contracts, it may be that essentially the max message size of bytes is returned by the peer. And then we would hash all of those code snippets (with the last one potentially failing). So in this case we could still end up hashing up to the max message size worth of bytes.
However, that assumed that we queried a bunch of big contracts... In practice the request is going to be of a bunch of contracts picked by us (the client) - so a malicious node would only be able to force us to hash the size of the actual expected results + 1 max size contract at the end.
There was a problem hiding this comment.
Yeah, it is not required. An oversized blob cannot hash to what we asked for, so the hash check alone rejects it. The size check is purely about bounding the work. #5654 fixes that separately by moving the retry loop into the Dispatcher with a backoff on peer-scoped failures, which is where the pacing belongs. Comment change here e198c32#r3729919331
| numWorkers int | ||
| codeHashesPerReq int // best-effort target size, the final batch may be smaller |
There was a problem hiding this comment.
These are only set in tests, and the tests that set them don't fail if we stop setting them. Feels like we can just get rid of these.
| // Slow path: code already on disk, just clear its marker. | ||
| if rawdb.HasCode(s.db, codeHash) { | ||
| if err := customrawdb.DeleteCodeToFetch(s.db, codeHash); err != nil { | ||
| return fmt.Errorf("failed to delete stale code marker: %w", err) | ||
| } | ||
| continue | ||
| } | ||
|
|
||
| // Fast path: dedupe concurrent fetches for the same hash. |
There was a problem hiding this comment.
These comments are unusual. The Fast path happening after the Slow path is weird. It makes it seem like it was an optimistic check, but with the wrong order.
There was a problem hiding this comment.
This is not relevant anymore, but it worked the following way: the two checks were ordered by cost, not by optimism. The disk read came first because a hash whose code is already stored needs no network work at all, only its marker cleared. The in-flight check came second because it only matters for a hash we are actually going to fetch. So "slow" and "fast" were describing the check itself, a disk read versus a map lookup, rather than the path through the function.
But again now I've redesigned the flow via the manager goroutine mechanism you proposed.
| if tt.perReq > 0 { | ||
| // One worker drains the whole channel, so the batch boundaries | ||
| // are fixed instead of left to the scheduler. | ||
| s.numWorkers = 1 | ||
| s.codeHashesPerReq = tt.perReq | ||
| } |
There was a problem hiding this comment.
Removing this branch doesn't cause the test to fail, so I'm not sure that these are really asserting the behavior they are intending to.
Old Design: - Each worker held a private batch, so hashes could sit unsent while no single batch was full and no request was in flight. - The disk check and the in-flight claim were separate steps on shared state, so no single moment reflected both. - The claim set had to be concurrent and outlived a run, stranding any entry a failed batch left behind. New Design: - A single manager now owns the queue, the db and the claim set. - Hands full batches to fetchers that only retrieve and verify. - Intake pauses while a batch waits, so a request can never exceed what the peer accepts.
- SetLimit caps concurrency directly, replacing two channels, a pending counter, a drained flag, and a termination rule spanning all three. - Persisting moves onto the fetching worker. - Dropping the dedup, leaving released hashes in the claim set, and removing the empty-batch guard each passed the previous suite.
alarso16
left a comment
There was a problem hiding this comment.
I like this a lot better
| for { | ||
| select { | ||
| case <-ctx.Done(): | ||
| return batch, ctx.Err() |
There was a problem hiding this comment.
Should you be returning the batch? It seems like if the context is canceled, we won't request it
| type claimSet struct { | ||
| mu sync.Mutex | ||
| hashes map[common.Hash]struct{} | ||
| } | ||
|
|
||
| func newClaimSet() *claimSet { | ||
| return &claimSet{ | ||
| hashes: make(map[common.Hash]struct{}), | ||
| } | ||
| } | ||
|
|
||
| // claim reports whether codeHash was taken, and false if it was already held. | ||
| func (c *claimSet) claim(codeHash common.Hash) bool { | ||
| c.mu.Lock() | ||
| defer c.mu.Unlock() | ||
|
|
||
| if _, dup := c.hashes[codeHash]; dup { | ||
| return false | ||
| } | ||
| c.hashes[codeHash] = struct{}{} | ||
| return true | ||
| } | ||
|
|
||
| func (c *claimSet) release(hashes []common.Hash) { | ||
| c.mu.Lock() | ||
| defer c.mu.Unlock() | ||
|
|
||
| for _, codeHash := range hashes { | ||
| delete(c.hashes, codeHash) | ||
| } | ||
| } |
There was a problem hiding this comment.
| type claimSet struct { | |
| mu sync.Mutex | |
| hashes map[common.Hash]struct{} | |
| } | |
| func newClaimSet() *claimSet { | |
| return &claimSet{ | |
| hashes: make(map[common.Hash]struct{}), | |
| } | |
| } | |
| // claim reports whether codeHash was taken, and false if it was already held. | |
| func (c *claimSet) claim(codeHash common.Hash) bool { | |
| c.mu.Lock() | |
| defer c.mu.Unlock() | |
| if _, dup := c.hashes[codeHash]; dup { | |
| return false | |
| } | |
| c.hashes[codeHash] = struct{}{} | |
| return true | |
| } | |
| func (c *claimSet) release(hashes []common.Hash) { | |
| c.mu.Lock() | |
| defer c.mu.Unlock() | |
| for _, codeHash := range hashes { | |
| delete(c.hashes, codeHash) | |
| } | |
| } | |
| type claimSet struct { | |
| m sync.Map | |
| } | |
| func newClaimSet() *claimSet { | |
| return &claimSet{ | |
| m: new(sync.Map), | |
| } | |
| } | |
| // claim reports whether codeHash was taken, and false if it was already held. | |
| func (c *claimSet) claim(codeHash common.Hash) bool { | |
| _, alreadyClaimed := c.m.LoadOrStore(codeHash, struct{}{}) | |
| return !alreadyClaimed | |
| } | |
| func (c *claimSet) release(hashes []common.Hash) { | |
| for _, codeHash := range hashes { | |
| c.m.Delete(codeHash) | |
| } | |
| } |
As an option, I think you can use any atomic map safely (note: this code may not compile)
Why this should be merged
Check #5401
How this works
numSyncWorkersat a time. A hash repeated while its fetch is outstanding is not fetched again.AppErrorrather than being dropped, so the client fails over to another peer immediately instead of waiting out a timeout. Code sync reserves the 1000 range for its sentinels.How this was tested
UTs plus integration tests that run the syncer against the real handler over an in-process network.
Need to be documented in RELEASES.md?
no
resolves #5401
Signed-off-by: Tsvetan Dimitrov (tsvetan.dimitrov@avalabs.org)