message/validation: refactor validation flow to per-message actors - #2736
message/validation: refactor validation flow to per-message actors#2736nkryuchkov wants to merge 4 commits into
Conversation
|
This is intentionally opened as a draft because it changes the validation concurrency model in a critical path. The main purpose is to get team feedback on whether this architecture is desirable before committing to it. |
Codecov Report❌ Patch coverage is
☔ View full report in Codecov by Sentry. 🚀 New features to boost your workflow:
|
|
@greptileai please review this PR |
Greptile SummaryThis PR replaces the per- Key changes:
The lifecycle mechanics are sound: Confidence Score: 4/5
Important Files Changed
Sequence DiagramsequenceDiagram
participant C as Caller (handleSignedSSVMessage)
participant VA as withValidationActor
participant A as validationActor.run()
participant V as verifyAndResubmit goroutine
C->>VA: submit validationRequest (precheck, verify, commit)
VA->>A: inbox ← validationRequest
A->>A: validatorState(key)
A->>A: precheck(state)
alt precheck fails
A-->>C: respond(err)
else precheck passes
A->>V: go verifyAndResubmit(req)
V->>V: req.verify() [sig verification, concurrent]
V->>A: inbox ← validationVerified{req, err}
alt verify failed
A-->>C: respond(verify err)
else verify passed
A->>A: validatorState(key) [re-fetch]
A->>A: precheck(state) [recheck for duplicates]
alt recheck fails (e.g. duplicate committed)
A-->>C: respond(recheck err)
else recheck passes
A->>A: commit(state)
A-->>C: respond(nil)
end
end
end
Reviews (4): Last reviewed commit: "code review comments [3]" | Re-trigger Greptile |
|
@greptileai please review this again |
|
@greptileai please review this again |
|
@greptileai please review this again |
| mv.validationActors.OnEviction(func(_ context.Context, _ ttlcache.EvictionReason, item *ttlcache.Item[spectypes.MessageID, *validationActor]) { | ||
| item.Value().stop() | ||
| }) |
There was a problem hiding this comment.
Synchronous
stop() in eviction callback may delay TTL eviction thread
stop() blocks until a.active reaches zero, which requires all in-flight submit() goroutines to finish writing to the inbox. In the common case this is fast (inbox is buffered, write is near-instant), but if the inbox is at capacity when eviction fires, stop() will wait for run() to drain at least one slot before the blocked submit() callers can unblock and decrement active.
Calling stop() synchronously in the eviction handler ties up whatever internal TTL-cache goroutine performs eviction for this entire window. Under high load, many concurrent submitters for the evicted actor could measurably delay TTL processing for other MessageIDs.
A simple improvement is to move the call off the eviction thread:
mv.validationActors.OnEviction(func(_ context.Context, _ ttlcache.EvictionReason, item *ttlcache.Item[spectypes.MessageID, *validationActor]) {
go item.Value().stop()
})stop() is idempotent (guarded by stopOnce), so calling it asynchronously is safe.
There was a problem hiding this comment.
@greptileai I’m keeping stop() synchronous in the eviction callback for now. This is an eviction-path concern rather than a steady-state path, and actors are only evicted after 2 epochs with Touch() on every lookup, so eviction should be relatively rare. More importantly, keeping stop() inline preserves a simpler actor lifetime model: once eviction runs, shutdown is completed before the callback returns. I don’t think moving it to a goroutine buys enough to justify making actor teardown more implicit.
There was a problem hiding this comment.
That's a reasonable call. The 2-epoch TTL with Touch() on lookup means evictions only happen for idle MessageIDs, so the frequency argument holds. And the synchronous teardown does keep the lifetime model clearer - once the eviction callback returns, the actor is fully stopped, no background cleanup to track.
The blocking risk is real but bounded: worst case is waiting for buffered inbox to drain, which should be fast unless the actor is genuinely stuck. If you see eviction delays show up in profiling or metrics later, you can always revisit, but keeping it simple now makes sense.
| state := mv.validatorState(key, msg.request.committeeInfo) | ||
| if err := msg.request.precheck(state); err != nil { | ||
| msg.request.respond(err) | ||
| continue | ||
| } | ||
|
|
||
| msg.request.respond(msg.request.commit(state)) |
There was a problem hiding this comment.
precheck runs twice per successfully verified message — metrics/logging side-effects would be doubled
In the validationVerified path the actor re-executes msg.request.precheck(state) as a re-check after signature verification. This is the correct behavioral model described in the PR, but it means that any metrics recording or structured log output inside the precheck closure (e.g. inside validateQBFTLogic / validateQBFTMessageByDutyLogic / validatePartialSigMessagesByDutyLogic) will be emitted twice for every message that passes the initial precheck and reaches the commit stage.
If those helper functions currently emit observability data as side-effects, the double execution will inflate counters and produce duplicate log entries. It's worth auditing those callers, or renaming/documenting this parameter to make the double-invocation contract explicit (e.g. calling the second argument recheck rather than precheck).
Summary
Refactor the message validation pipeline to use per-
MessageIDactors instead of per-MessageIDmutex locking.This PR is a part of
F-ssv-164and depends on #2728.The goal is to explore a different concurrency model for validation on critical paths where multiple messages for the same validator/role arrive close together.
Motivation
The current lock-based validation flow has a hard tradeoff:
This PR explores a third option:
MessageIDactorThis preserves cheap stale/duplicate filtering before signature verification while allowing signature verification to proceed concurrently.
What Changed
MessageIDmutex coordination with a per-MessageIDactor/mailbox modelmessage/validation/validation_actor.gomessage/validation/validation.goto cache and manage actors instead of locksmessage/validation/consensus_validation.gomessage/validation/partial_validation.gomessage/validation/validation_actor_test.goBehavioral Model
For a given
MessageID, validation now follows this shape:This means:
MessageID