go-libp2p-pubsub/validation.go
Marco Munizaga 0c5ee7bbfe
feat(gossipsub): Add MessageBatch (#607)
to support batch publishing messages

Replaces #602.

Batch publishing lets the system know there are multiple related
messages to be published so it can prioritize sending different messages
before sending copies of messages. For example, with the default API,
when you publish two messages A and B, under the hood A gets sent to D=8
peers first, before B gets sent out. With this MessageBatch api we can
now send one copy of A _and then_ one copy of B before sending multiple
copies.

When a node has bandwidth constraints relative to the messages it is
publishing this improves dissemination time.

For more context see this post:
https://ethresear.ch/t/improving-das-performance-with-gossipsub-batch-publishing/21713
2025-05-08 10:23:02 -07:00

604 lines
16 KiB
Go

package pubsub
import (
"context"
"fmt"
"runtime"
"sync"
"time"
"github.com/libp2p/go-libp2p/core/peer"
)
const (
defaultValidateQueueSize = 32
defaultValidateConcurrency = 1024
defaultValidateThrottle = 8192
)
// ValidationError is an error that may be signalled from message publication when the message
// fails validation
type ValidationError struct {
Reason string
}
func (e ValidationError) Error() string {
return e.Reason
}
type dupeErr struct{}
func (dupeErr) Error() string {
return "duplicate message"
}
// Validator is a function that validates a message with a binary decision: accept or reject.
type Validator func(context.Context, peer.ID, *Message) bool
// ValidatorEx is an extended validation function that validates a message with an enumerated decision
type ValidatorEx func(context.Context, peer.ID, *Message) ValidationResult
// ValidationResult represents the decision of an extended validator
type ValidationResult int
const (
// ValidationAccept is a validation decision that indicates a valid message that should be accepted and
// delivered to the application and forwarded to the network.
ValidationAccept = ValidationResult(0)
// ValidationReject is a validation decision that indicates an invalid message that should not be
// delivered to the application or forwarded to the application. Furthermore the peer that forwarded
// the message should be penalized by peer scoring routers.
ValidationReject = ValidationResult(1)
// ValidationIgnore is a validation decision that indicates a message that should be ignored: it will
// be neither delivered to the application nor forwarded to the network. However, in contrast to
// ValidationReject, the peer that forwarded the message must not be penalized by peer scoring routers.
ValidationIgnore = ValidationResult(2)
// internal
validationThrottled = ValidationResult(-1)
)
// ValidatorOpt is an option for RegisterTopicValidator.
type ValidatorOpt func(addVal *addValReq) error
// validation represents the validator pipeline.
// The validator pipeline performs signature validation and runs a
// sequence of user-configured validators per-topic. It is possible to
// adjust various concurrency parameters, such as the number of
// workers and the max number of simultaneous validations. The user
// can also attach inline validators that will be executed
// synchronously; this may be useful to prevent superfluous
// context-switching for lightweight tasks.
type validation struct {
p *PubSub
tracer *pubsubTracer
// mx protects the validator map
mx sync.Mutex
// topicVals tracks per topic validators
topicVals map[string]*validatorImpl
// defaultVals tracks default validators applicable to all topics
defaultVals []*validatorImpl
// validateQ is the front-end to the validation pipeline
validateQ chan *validateReq
// validateThrottle limits the number of active validation goroutines
validateThrottle chan struct{}
// this is the number of synchronous validation workers
validateWorkers int
}
// validation requests
type validateReq struct {
vals []*validatorImpl
src peer.ID
msg *Message
}
// representation of topic validators
type validatorImpl struct {
topic string
validate ValidatorEx
validateTimeout time.Duration
validateThrottle chan struct{}
validateInline bool
}
// async request to add a topic validators
type addValReq struct {
topic string
validate interface{}
timeout time.Duration
throttle int
inline bool
resp chan error
}
// async request to remove a topic validator
type rmValReq struct {
topic string
resp chan error
}
// newValidation creates a new validation pipeline
func newValidation() *validation {
return &validation{
topicVals: make(map[string]*validatorImpl),
validateQ: make(chan *validateReq, defaultValidateQueueSize),
validateThrottle: make(chan struct{}, defaultValidateThrottle),
validateWorkers: runtime.NumCPU(),
}
}
// Start attaches the validation pipeline to a pubsub instance and starts background
// workers
func (v *validation) Start(p *PubSub) {
v.p = p
v.tracer = p.tracer
for i := 0; i < v.validateWorkers; i++ {
go v.validateWorker()
}
}
// AddValidator adds a new validator
func (v *validation) AddValidator(req *addValReq) {
val, err := v.makeValidator(req)
if err != nil {
req.resp <- err
return
}
v.mx.Lock()
defer v.mx.Unlock()
topic := val.topic
_, ok := v.topicVals[topic]
if ok {
req.resp <- fmt.Errorf("duplicate validator for topic %s", topic)
return
}
v.topicVals[topic] = val
req.resp <- nil
}
func (v *validation) makeValidator(req *addValReq) (*validatorImpl, error) {
makeValidatorEx := func(v Validator) ValidatorEx {
return func(ctx context.Context, p peer.ID, msg *Message) ValidationResult {
if v(ctx, p, msg) {
return ValidationAccept
} else {
return ValidationReject
}
}
}
var validator ValidatorEx
switch v := req.validate.(type) {
case func(ctx context.Context, p peer.ID, msg *Message) bool:
validator = makeValidatorEx(Validator(v))
case Validator:
validator = makeValidatorEx(v)
case func(ctx context.Context, p peer.ID, msg *Message) ValidationResult:
validator = ValidatorEx(v)
case ValidatorEx:
validator = v
default:
topic := req.topic
if req.topic == "" {
topic = "(default)"
}
return nil, fmt.Errorf("unknown validator type for topic %s; must be an instance of Validator or ValidatorEx", topic)
}
val := &validatorImpl{
topic: req.topic,
validate: validator,
validateTimeout: 0,
validateThrottle: make(chan struct{}, defaultValidateConcurrency),
validateInline: req.inline,
}
if req.timeout > 0 {
val.validateTimeout = req.timeout
}
if req.throttle > 0 {
val.validateThrottle = make(chan struct{}, req.throttle)
}
return val, nil
}
// RemoveValidator removes an existing validator
func (v *validation) RemoveValidator(req *rmValReq) {
v.mx.Lock()
defer v.mx.Unlock()
topic := req.topic
_, ok := v.topicVals[topic]
if ok {
delete(v.topicVals, topic)
req.resp <- nil
} else {
req.resp <- fmt.Errorf("no validator for topic %s", topic)
}
}
// ValidateLocal synchronously validates a locally published message and
// performs applicable validations. Returns an error if validation fails.
func (v *validation) ValidateLocal(msg *Message) error {
v.p.tracer.PublishMessage(msg)
err := v.p.checkSigningPolicy(msg)
if err != nil {
return err
}
vals := v.getValidators(msg)
return v.validate(vals, msg.ReceivedFrom, msg, true, func(msg *Message) error {
return nil
})
}
// Push pushes a message into the validation pipeline.
// It returns true if the message can be forwarded immediately without validation.
func (v *validation) Push(src peer.ID, msg *Message) bool {
vals := v.getValidators(msg)
if len(vals) > 0 || msg.Signature != nil {
select {
case v.validateQ <- &validateReq{vals, src, msg}:
default:
log.Debugf("message validation throttled: queue full; dropping message from %s", src)
v.tracer.RejectMessage(msg, RejectValidationQueueFull)
}
return false
}
return true
}
// getValidators returns all validators that apply to a given message
func (v *validation) getValidators(msg *Message) []*validatorImpl {
v.mx.Lock()
defer v.mx.Unlock()
var vals []*validatorImpl
vals = append(vals, v.defaultVals...)
topic := msg.GetTopic()
val, ok := v.topicVals[topic]
if !ok {
return vals
}
return append(vals, val)
}
// validateWorker is an active goroutine performing inline validation
func (v *validation) validateWorker() {
for {
select {
case req := <-v.validateQ:
_ = v.validate(req.vals, req.src, req.msg, false, v.sendMsgBlocking)
case <-v.p.ctx.Done():
return
}
}
}
func (v *validation) sendMsgBlocking(msg *Message) error {
select {
case v.p.sendMsg <- msg:
return nil
case <-v.p.ctx.Done():
return v.p.ctx.Err()
}
}
// validate performs validation and only calls onValid if all validators succeed.
// If synchronous is true, onValid will be called before this function returns
// if the message is new and accepted.
func (v *validation) validate(vals []*validatorImpl, src peer.ID, msg *Message, synchronous bool, onValid func(*Message) error) error {
// If signature verification is enabled, but signing is disabled,
// the Signature is required to be nil upon receiving the message in PubSub.pushMsg.
if msg.Signature != nil {
if !v.validateSignature(msg) {
log.Debugf("message signature validation failed; dropping message from %s", src)
v.tracer.RejectMessage(msg, RejectInvalidSignature)
return ValidationError{Reason: RejectInvalidSignature}
}
}
// we can mark the message as seen now that we have verified the signature
// and avoid invoking user validators more than once
id := v.p.idGen.ID(msg)
if !v.p.markSeen(id) {
v.tracer.DuplicateMessage(msg)
return dupeErr{}
} else {
v.tracer.ValidateMessage(msg)
}
var inline, async []*validatorImpl
for _, val := range vals {
if val.validateInline || synchronous {
inline = append(inline, val)
} else {
async = append(async, val)
}
}
// apply inline (synchronous) validators
result := ValidationAccept
loop:
for _, val := range inline {
switch val.validateMsg(v.p.ctx, src, msg) {
case ValidationAccept:
case ValidationReject:
result = ValidationReject
break loop
case ValidationIgnore:
result = ValidationIgnore
}
}
if result == ValidationReject {
log.Debugf("message validation failed; dropping message from %s", src)
v.tracer.RejectMessage(msg, RejectValidationFailed)
return ValidationError{Reason: RejectValidationFailed}
}
// apply async validators
if len(async) > 0 {
select {
case v.validateThrottle <- struct{}{}:
go func() {
v.doValidateTopic(async, src, msg, result, onValid)
<-v.validateThrottle
}()
default:
log.Debugf("message validation throttled; dropping message from %s", src)
v.tracer.RejectMessage(msg, RejectValidationThrottled)
}
return nil
}
if result == ValidationIgnore {
v.tracer.RejectMessage(msg, RejectValidationIgnored)
return ValidationError{Reason: RejectValidationIgnored}
}
// no async validators, accepted message
return onValid(msg)
}
func (v *validation) validateSignature(msg *Message) bool {
err := verifyMessageSignature(msg.Message)
if err != nil {
log.Debugf("signature verification error: %s", err.Error())
return false
}
return true
}
func (v *validation) doValidateTopic(vals []*validatorImpl, src peer.ID, msg *Message, r ValidationResult, onValid func(*Message) error) {
result := v.validateTopic(vals, src, msg)
if result == ValidationAccept && r != ValidationAccept {
result = r
}
switch result {
case ValidationAccept:
_ = onValid(msg)
case ValidationReject:
log.Debugf("message validation failed; dropping message from %s", src)
v.tracer.RejectMessage(msg, RejectValidationFailed)
return
case ValidationIgnore:
log.Debugf("message validation punted; ignoring message from %s", src)
v.tracer.RejectMessage(msg, RejectValidationIgnored)
return
case validationThrottled:
log.Debugf("message validation throttled; ignoring message from %s", src)
v.tracer.RejectMessage(msg, RejectValidationThrottled)
default:
// BUG: this would be an internal programming error, so a panic seems appropiate.
panic(fmt.Errorf("unexpected validation result: %d", result))
}
}
func (v *validation) validateTopic(vals []*validatorImpl, src peer.ID, msg *Message) ValidationResult {
if len(vals) == 1 {
return v.validateSingleTopic(vals[0], src, msg)
}
ctx, cancel := context.WithCancel(v.p.ctx)
defer cancel()
rch := make(chan ValidationResult, len(vals))
rcount := 0
for _, val := range vals {
rcount++
select {
case val.validateThrottle <- struct{}{}:
go func(val *validatorImpl) {
rch <- val.validateMsg(ctx, src, msg)
<-val.validateThrottle
}(val)
default:
log.Debugf("validation throttled for topic %s", val.topic)
rch <- validationThrottled
}
}
result := ValidationAccept
loop:
for i := 0; i < rcount; i++ {
switch <-rch {
case ValidationAccept:
case ValidationReject:
result = ValidationReject
break loop
case ValidationIgnore:
// throttled validation has the same effect, but takes precedence over Ignore as it is not
// known whether the throttled validator would have signaled rejection.
if result != validationThrottled {
result = ValidationIgnore
}
case validationThrottled:
result = validationThrottled
}
}
return result
}
// fast path for single topic validation that avoids the extra goroutine
func (v *validation) validateSingleTopic(val *validatorImpl, src peer.ID, msg *Message) ValidationResult {
select {
case val.validateThrottle <- struct{}{}:
res := val.validateMsg(v.p.ctx, src, msg)
<-val.validateThrottle
return res
default:
log.Debugf("validation throttled for topic %s", val.topic)
return validationThrottled
}
}
func (val *validatorImpl) validateMsg(ctx context.Context, src peer.ID, msg *Message) ValidationResult {
start := time.Now()
defer func() {
log.Debugf("validation done; took %s", time.Since(start))
}()
if val.validateTimeout > 0 {
var cancel func()
ctx, cancel = context.WithTimeout(ctx, val.validateTimeout)
defer cancel()
}
r := val.validate(ctx, src, msg)
switch r {
case ValidationAccept:
fallthrough
case ValidationReject:
fallthrough
case ValidationIgnore:
return r
default:
log.Warnf("Unexpected result from validator: %d; ignoring message", r)
return ValidationIgnore
}
}
// / Options
// WithDefaultValidator adds a validator that applies to all topics by default; it can be used
// more than once and add multiple validators. Having a defult validator does not inhibit registering
// a per topic validator.
func WithDefaultValidator(val interface{}, opts ...ValidatorOpt) Option {
return func(ps *PubSub) error {
addVal := &addValReq{
validate: val,
}
for _, opt := range opts {
err := opt(addVal)
if err != nil {
return err
}
}
val, err := ps.val.makeValidator(addVal)
if err != nil {
return err
}
ps.val.defaultVals = append(ps.val.defaultVals, val)
return nil
}
}
// WithValidateQueueSize sets the buffer of validate queue. Defaults to 32.
// When queue is full, validation is throttled and new messages are dropped.
func WithValidateQueueSize(n int) Option {
return func(ps *PubSub) error {
if n > 0 {
ps.val.validateQ = make(chan *validateReq, n)
return nil
}
return fmt.Errorf("validate queue size must be > 0")
}
}
// WithValidateThrottle sets the upper bound on the number of active validation
// goroutines across all topics. The default is 8192.
func WithValidateThrottle(n int) Option {
return func(ps *PubSub) error {
ps.val.validateThrottle = make(chan struct{}, n)
return nil
}
}
// WithValidateWorkers sets the number of synchronous validation worker goroutines.
// Defaults to NumCPU.
//
// The synchronous validation workers perform signature validation, apply inline
// user validators, and schedule asynchronous user validators.
// You can adjust this parameter to devote less cpu time to synchronous validation.
func WithValidateWorkers(n int) Option {
return func(ps *PubSub) error {
if n > 0 {
ps.val.validateWorkers = n
return nil
}
return fmt.Errorf("number of validation workers must be > 0")
}
}
// WithValidatorTimeout is an option that sets a timeout for an (asynchronous) topic validator.
// By default there is no timeout in asynchronous validators.
func WithValidatorTimeout(timeout time.Duration) ValidatorOpt {
return func(addVal *addValReq) error {
addVal.timeout = timeout
return nil
}
}
// WithValidatorConcurrency is an option that sets the topic validator throttle.
// This controls the number of active validation goroutines for the topic; the default is 1024.
func WithValidatorConcurrency(n int) ValidatorOpt {
return func(addVal *addValReq) error {
addVal.throttle = n
return nil
}
}
// WithValidatorInline is an option that sets the validation disposition to synchronous:
// it will be executed inline in validation front-end, without spawning a new goroutine.
// This is suitable for simple or cpu-bound validators that do not block.
func WithValidatorInline(inline bool) ValidatorOpt {
return func(addVal *addValReq) error {
addVal.inline = inline
return nil
}
}