diff --git a/README.md b/README.md index d014445..8dd32ec 100644 --- a/README.md +++ b/README.md @@ -42,6 +42,14 @@ if err = protovalidate.Validate(moneyTransfer); err != nil { } ``` +To bound validation with a `context.Context` (cancellation or deadline): + +```go +if err = protovalidate.ValidateContext(ctx, moneyTransfer); err != nil { + // Handle failure; errors.Is(err, context.DeadlineExceeded) on timeout. +} +``` + ## Installation > [!TIP] diff --git a/any.go b/any.go index ead36b7..7d93982 100644 --- a/any.go +++ b/any.go @@ -15,6 +15,8 @@ package protovalidate import ( + "context" + "buf.build/gen/go/bufbuild/protovalidate/protocolbuffers/go/buf/validate" "google.golang.org/protobuf/proto" "google.golang.org/protobuf/reflect/protoreflect" @@ -58,7 +60,11 @@ type anyPB struct { NotInValue protoreflect.Value } -func (a anyPB) Evaluate(_ protoreflect.Message, val protoreflect.Value, cfg *validationConfig) error { +func (a anyPB) Evaluate(msg protoreflect.Message, val protoreflect.Value, cfg *validationConfig) error { + return a.EvaluateContext(context.Background(), msg, val, cfg) +} + +func (a anyPB) EvaluateContext(_ context.Context, _ protoreflect.Message, val protoreflect.Value, cfg *validationConfig) error { typeURL := val.Message().Get(a.TypeURLDescriptor).String() err := &ValidationError{} diff --git a/cel.go b/cel.go index f643a82..7f12429 100644 --- a/cel.go +++ b/cel.go @@ -15,6 +15,7 @@ package protovalidate import ( + "context" "errors" "google.golang.org/protobuf/reflect/protoreflect" @@ -26,8 +27,12 @@ type celPrograms struct { programSet } -func (c celPrograms) Evaluate(_ protoreflect.Message, val protoreflect.Value, cfg *validationConfig) error { - err := c.Eval(val, c.Descriptor, cfg) +func (c celPrograms) Evaluate(msg protoreflect.Message, val protoreflect.Value, cfg *validationConfig) error { + return c.EvaluateContext(context.Background(), msg, val, cfg) +} + +func (c celPrograms) EvaluateContext(ctx context.Context, _ protoreflect.Message, val protoreflect.Value, cfg *validationConfig) error { + err := c.EvalContext(ctx, val, c.Descriptor, cfg) if err != nil { var valErr *ValidationError if errors.As(err, &valErr) { @@ -43,7 +48,11 @@ func (c celPrograms) Evaluate(_ protoreflect.Message, val protoreflect.Value, cf } func (c celPrograms) EvaluateMessage(msg protoreflect.Message, cfg *validationConfig) error { - return c.Eval(protoreflect.ValueOfMessage(msg), nil, cfg) + return c.EvaluateMessageContext(context.Background(), msg, cfg) +} + +func (c celPrograms) EvaluateMessageContext(ctx context.Context, msg protoreflect.Message, cfg *validationConfig) error { + return c.EvalContext(ctx, protoreflect.ValueOfMessage(msg), nil, cfg) } func (c celPrograms) Tautology() bool { diff --git a/cel/library.go b/cel/library.go index b9f262e..4f19b1b 100644 --- a/cel/library.go +++ b/cel/library.go @@ -40,8 +40,10 @@ import ( // // Using this function, you can create a CEL environment that is identical to // the one used to evaluate protovalidate CEL expressions. -func NewLibrary() cel.Library { - return &library{ +// Options may be passed to configure the library; see [LibraryOption]. +func NewLibrary(options ...LibraryOption) cel.Library { + lib := &library{ + interruptCheckFrequency: DefaultInterruptCheckFrequency, uniqueScalarPool: sync.Pool{New: func() any { return map[ref.Val]struct{}{} }}, @@ -49,6 +51,37 @@ func NewLibrary() cel.Library { return map[string]struct{}{} }}, } + for _, option := range options { + option(lib) + } + return lib +} + +// DefaultInterruptCheckFrequency is the default value for +// [WithInterruptCheckFrequency]. +// +// cel-go checks for context cancellation only inside comprehension loops +// (macros such as all, exists, map and filter), once per iteration. The +// frequency selects how many of those iterations pass between successive polls +// of ctx.Done(): the per-iteration bookkeeping happens regardless, and the +// frequency only gates the (very cheap) non-blocking channel poll. Raising it +// therefore buys no measurable throughput, while making cancellation latency +// proportionally worse. 1 is the responsive default. +const DefaultInterruptCheckFrequency = 1 + +// LibraryOption configures the CEL library returned by [NewLibrary]. +type LibraryOption func(*library) + +// WithInterruptCheckFrequency sets how many comprehension iterations elapse +// between checks for context cancellation during CEL evaluation. +// +// A value of 0 disables interrupt checking entirely: CEL expressions then run +// to completion and cannot be cancelled mid-evaluation. +// +// Note that expressions containing no comprehension are never interruptible, +// whatever the frequency, because cel-go only checks inside comprehension loops. +func WithInterruptCheckFrequency(frequency uint) LibraryOption { + return func(lib *library) { lib.interruptCheckFrequency = frequency } } // library is the collection of functions and settings required by protovalidate @@ -59,8 +92,9 @@ func NewLibrary() cel.Library { // All implementations of protovalidate MUST implement these functions and // should avoid exposing additional functions as they will not be portable. type library struct { - uniqueScalarPool sync.Pool - uniqueBytesPool sync.Pool + uniqueScalarPool sync.Pool + uniqueBytesPool sync.Pool + interruptCheckFrequency uint } func (l *library) CompileOptions() []cel.EnvOption { //nolint:funlen,gocyclo @@ -378,11 +412,15 @@ func (l *library) CompileOptions() []cel.EnvOption { //nolint:funlen,gocyclo } func (l *library) ProgramOptions() []cel.ProgramOption { - return []cel.ProgramOption{ + opts := []cel.ProgramOption{ cel.EvalOptions( cel.OptOptimize, ), } + if l.interruptCheckFrequency > 0 { + opts = append(opts, cel.InterruptCheckFrequency(l.interruptCheckFrequency)) + } + return opts } func (l *library) uniqueMemberOverload(itemType *cel.Type, overload func(lister traits.Lister) ref.Val) cel.FunctionOpt { diff --git a/enum.go b/enum.go index e29f931..01cbfc9 100644 --- a/enum.go +++ b/enum.go @@ -15,6 +15,8 @@ package protovalidate import ( + "context" + "buf.build/gen/go/bufbuild/protovalidate/protocolbuffers/go/buf/validate" "google.golang.org/protobuf/proto" "google.golang.org/protobuf/reflect/protoreflect" @@ -42,7 +44,11 @@ type definedEnum struct { ValueDescriptors protoreflect.EnumValueDescriptors } -func (d definedEnum) Evaluate(_ protoreflect.Message, val protoreflect.Value, _ *validationConfig) error { +func (d definedEnum) Evaluate(msg protoreflect.Message, val protoreflect.Value, cfg *validationConfig) error { + return d.EvaluateContext(context.Background(), msg, val, cfg) +} + +func (d definedEnum) EvaluateContext(_ context.Context, _ protoreflect.Message, val protoreflect.Value, _ *validationConfig) error { if d.ValueDescriptors.ByNumber(val.Enum()) == nil { return &ValidationError{Violations: []*Violation{{ Proto: validate.Violation_builder{ diff --git a/evaluator.go b/evaluator.go index e6bbe6d..df52511 100644 --- a/evaluator.go +++ b/evaluator.go @@ -15,6 +15,8 @@ package protovalidate import ( + "context" + "google.golang.org/protobuf/reflect/protoreflect" ) @@ -36,6 +38,9 @@ type evaluator interface { // build. This error is not recoverable. // Evaluate(msg protoreflect.Message, val protoreflect.Value, cfg *validationConfig) error + + // EvaluateContext is like Evaluate, but honors context cancellation. + EvaluateContext(ctx context.Context, msg protoreflect.Message, val protoreflect.Value, cfg *validationConfig) error } // messageEvaluator is essentially the same as evaluator, but specialized for @@ -46,6 +51,10 @@ type messageEvaluator interface { // EvaluateMessage checks that the provided msg is valid. See // evaluator.Evaluate for behavior EvaluateMessage(msg protoreflect.Message, cfg *validationConfig) error + + // EvaluateMessageContext is like EvaluateMessage, but honors context + // cancellation. + EvaluateMessageContext(ctx context.Context, msg protoreflect.Message, cfg *validationConfig) error } // evaluators are a set of evaluator applied together to a value. Evaluation @@ -53,10 +62,14 @@ type messageEvaluator interface { // true or a different error is returned. type evaluators []evaluator -func (e evaluators) Evaluate(msg protoreflect.Message, val protoreflect.Value, cfg *validationConfig) (err error) { +func (e evaluators) Evaluate(msg protoreflect.Message, val protoreflect.Value, cfg *validationConfig) error { + return e.EvaluateContext(context.Background(), msg, val, cfg) +} + +func (e evaluators) EvaluateContext(ctx context.Context, msg protoreflect.Message, val protoreflect.Value, cfg *validationConfig) (err error) { var ok bool for _, eval := range e { - evalErr := eval.Evaluate(msg, val, cfg) + evalErr := eval.EvaluateContext(ctx, msg, val, cfg) if ok, err = mergeViolations(err, evalErr, cfg); !ok { return err } @@ -81,10 +94,14 @@ func (m messageEvaluators) Evaluate(val protoreflect.Value, cfg *validationConfi return m.EvaluateMessage(val.Message(), cfg) } -func (m messageEvaluators) EvaluateMessage(msg protoreflect.Message, cfg *validationConfig) (err error) { +func (m messageEvaluators) EvaluateMessage(msg protoreflect.Message, cfg *validationConfig) error { + return m.EvaluateMessageContext(context.Background(), msg, cfg) +} + +func (m messageEvaluators) EvaluateMessageContext(ctx context.Context, msg protoreflect.Message, cfg *validationConfig) (err error) { var ok bool for _, eval := range m { - evalErr := eval.EvaluateMessage(msg, cfg) + evalErr := eval.EvaluateMessageContext(ctx, msg, cfg) if ok, err = mergeViolations(err, evalErr, cfg); !ok { return err } diff --git a/field.go b/field.go index 9c2c5b7..c15bcc5 100644 --- a/field.go +++ b/field.go @@ -15,6 +15,8 @@ package protovalidate import ( + "context" + "buf.build/gen/go/bufbuild/protovalidate/protocolbuffers/go/buf/validate" "google.golang.org/protobuf/proto" "google.golang.org/protobuf/reflect/protoreflect" @@ -60,11 +62,19 @@ func (f field) shouldIgnoreEmpty() bool { return f.HasPresence || f.Ignore == validate.Ignore_IGNORE_IF_ZERO_VALUE } -func (f field) Evaluate(_ protoreflect.Message, val protoreflect.Value, cfg *validationConfig) error { - return f.EvaluateMessage(val.Message(), cfg) +func (f field) Evaluate(msg protoreflect.Message, val protoreflect.Value, cfg *validationConfig) error { + return f.EvaluateContext(context.Background(), msg, val, cfg) +} + +func (f field) EvaluateContext(ctx context.Context, _ protoreflect.Message, val protoreflect.Value, cfg *validationConfig) error { + return f.EvaluateMessageContext(ctx, val.Message(), cfg) +} + +func (f field) EvaluateMessage(msg protoreflect.Message, cfg *validationConfig) error { + return f.EvaluateMessageContext(context.Background(), msg, cfg) } -func (f field) EvaluateMessage(msg protoreflect.Message, cfg *validationConfig) (err error) { +func (f field) EvaluateMessageContext(ctx context.Context, msg protoreflect.Message, cfg *validationConfig) (err error) { if f.shouldIgnoreAlways() { return nil } @@ -95,7 +105,7 @@ func (f field) EvaluateMessage(msg protoreflect.Message, cfg *validationConfig) return nil } - return f.Value.EvaluateField(msg, msg.Get(f.Value.Descriptor), cfg, true) + return f.Value.EvaluateFieldContext(ctx, msg, msg.Get(f.Value.Descriptor), cfg, true) } func (f field) Tautology() bool { diff --git a/internal/gen/tests/example/v1/bench.pb.go b/internal/gen/tests/example/v1/bench.pb.go index 28bbb56..6fdf5c5 100644 --- a/internal/gen/tests/example/v1/bench.pb.go +++ b/internal/gen/tests/example/v1/bench.pb.go @@ -1096,6 +1096,280 @@ func (*BenchComplexSchema_OneofI32) isBenchComplexSchema_Choice() {} func (*BenchComplexSchema_OneofMsg) isBenchComplexSchema_Choice() {} +// BenchCrossReference models a message whose CEL rule enforces referential +// integrity: every reference held by every rule must resolve to a declared +// entry. Evaluating it costs O(rules * refs_per_rule * entries), so its +// validation time grows multiplicatively with the size of the message. +// +// It exists to exercise cancellation of a single long-running CEL expression. +// It is not part of the faked benchmark corpus; tests build it explicitly. +type BenchCrossReference struct { + state protoimpl.MessageState `protogen:"hybrid.v1"` + Entries []*BenchCrossReference_Entry `protobuf:"bytes,1,rep,name=entries,proto3" json:"entries,omitempty"` + Rules []*BenchCrossReference_Rule `protobuf:"bytes,2,rep,name=rules,proto3" json:"rules,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *BenchCrossReference) Reset() { + *x = BenchCrossReference{} + mi := &file_tests_example_v1_bench_proto_msgTypes[7] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *BenchCrossReference) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*BenchCrossReference) ProtoMessage() {} + +func (x *BenchCrossReference) ProtoReflect() protoreflect.Message { + mi := &file_tests_example_v1_bench_proto_msgTypes[7] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +func (x *BenchCrossReference) GetEntries() []*BenchCrossReference_Entry { + if x != nil { + return x.Entries + } + return nil +} + +func (x *BenchCrossReference) GetRules() []*BenchCrossReference_Rule { + if x != nil { + return x.Rules + } + return nil +} + +func (x *BenchCrossReference) SetEntries(v []*BenchCrossReference_Entry) { + x.Entries = v +} + +func (x *BenchCrossReference) SetRules(v []*BenchCrossReference_Rule) { + x.Rules = v +} + +type BenchCrossReference_builder struct { + _ [0]func() // Prevents comparability and use of unkeyed literals for the builder. + + Entries []*BenchCrossReference_Entry + Rules []*BenchCrossReference_Rule +} + +func (b0 BenchCrossReference_builder) Build() *BenchCrossReference { + m0 := &BenchCrossReference{} + b, x := &b0, m0 + _, _ = b, x + x.Entries = b.Entries + x.Rules = b.Rules + return m0 +} + +type BenchCrossReference_Key struct { + state protoimpl.MessageState `protogen:"hybrid.v1"` + Kind string `protobuf:"bytes,1,opt,name=kind,proto3" json:"kind,omitempty"` + Name string `protobuf:"bytes,2,opt,name=name,proto3" json:"name,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *BenchCrossReference_Key) Reset() { + *x = BenchCrossReference_Key{} + mi := &file_tests_example_v1_bench_proto_msgTypes[15] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *BenchCrossReference_Key) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*BenchCrossReference_Key) ProtoMessage() {} + +func (x *BenchCrossReference_Key) ProtoReflect() protoreflect.Message { + mi := &file_tests_example_v1_bench_proto_msgTypes[15] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +func (x *BenchCrossReference_Key) GetKind() string { + if x != nil { + return x.Kind + } + return "" +} + +func (x *BenchCrossReference_Key) GetName() string { + if x != nil { + return x.Name + } + return "" +} + +func (x *BenchCrossReference_Key) SetKind(v string) { + x.Kind = v +} + +func (x *BenchCrossReference_Key) SetName(v string) { + x.Name = v +} + +type BenchCrossReference_Key_builder struct { + _ [0]func() // Prevents comparability and use of unkeyed literals for the builder. + + Kind string + Name string +} + +func (b0 BenchCrossReference_Key_builder) Build() *BenchCrossReference_Key { + m0 := &BenchCrossReference_Key{} + b, x := &b0, m0 + _, _ = b, x + x.Kind = b.Kind + x.Name = b.Name + return m0 +} + +type BenchCrossReference_Entry struct { + state protoimpl.MessageState `protogen:"hybrid.v1"` + Key *BenchCrossReference_Key `protobuf:"bytes,1,opt,name=key,proto3" json:"key,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *BenchCrossReference_Entry) Reset() { + *x = BenchCrossReference_Entry{} + mi := &file_tests_example_v1_bench_proto_msgTypes[16] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *BenchCrossReference_Entry) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*BenchCrossReference_Entry) ProtoMessage() {} + +func (x *BenchCrossReference_Entry) ProtoReflect() protoreflect.Message { + mi := &file_tests_example_v1_bench_proto_msgTypes[16] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +func (x *BenchCrossReference_Entry) GetKey() *BenchCrossReference_Key { + if x != nil { + return x.Key + } + return nil +} + +func (x *BenchCrossReference_Entry) SetKey(v *BenchCrossReference_Key) { + x.Key = v +} + +func (x *BenchCrossReference_Entry) HasKey() bool { + if x == nil { + return false + } + return x.Key != nil +} + +func (x *BenchCrossReference_Entry) ClearKey() { + x.Key = nil +} + +type BenchCrossReference_Entry_builder struct { + _ [0]func() // Prevents comparability and use of unkeyed literals for the builder. + + Key *BenchCrossReference_Key +} + +func (b0 BenchCrossReference_Entry_builder) Build() *BenchCrossReference_Entry { + m0 := &BenchCrossReference_Entry{} + b, x := &b0, m0 + _, _ = b, x + x.Key = b.Key + return m0 +} + +type BenchCrossReference_Rule struct { + state protoimpl.MessageState `protogen:"hybrid.v1"` + Refs []*BenchCrossReference_Key `protobuf:"bytes,1,rep,name=refs,proto3" json:"refs,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *BenchCrossReference_Rule) Reset() { + *x = BenchCrossReference_Rule{} + mi := &file_tests_example_v1_bench_proto_msgTypes[17] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *BenchCrossReference_Rule) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*BenchCrossReference_Rule) ProtoMessage() {} + +func (x *BenchCrossReference_Rule) ProtoReflect() protoreflect.Message { + mi := &file_tests_example_v1_bench_proto_msgTypes[17] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +func (x *BenchCrossReference_Rule) GetRefs() []*BenchCrossReference_Key { + if x != nil { + return x.Refs + } + return nil +} + +func (x *BenchCrossReference_Rule) SetRefs(v []*BenchCrossReference_Key) { + x.Refs = v +} + +type BenchCrossReference_Rule_builder struct { + _ [0]func() // Prevents comparability and use of unkeyed literals for the builder. + + Refs []*BenchCrossReference_Key +} + +func (b0 BenchCrossReference_Rule_builder) Build() *BenchCrossReference_Rule { + m0 := &BenchCrossReference_Rule{} + b, x := &b0, m0 + _, _ = b, x + x.Refs = b.Refs + return m0 +} + var File_tests_example_v1_bench_proto protoreflect.FileDescriptor const file_tests_example_v1_bench_proto_rawDesc = "" + @@ -1180,7 +1454,18 @@ const file_tests_example_v1_bench_proto_rawDesc = "" + "\x0eMapI64MsgEntry\x12\x10\n" + "\x03key\x18\x01 \x01(\x03R\x03key\x123\n" + "\x05value\x18\x02 \x01(\v2\x1d.tests.example.v1.BenchScalarR\x05value:\x028\x01B\b\n" + - "\x06choice*O\n" + + "\x06choice\"\xa8\x04\n" + + "\x13BenchCrossReference\x12E\n" + + "\aentries\x18\x01 \x03(\v2+.tests.example.v1.BenchCrossReference.EntryR\aentries\x12@\n" + + "\x05rules\x18\x02 \x03(\v2*.tests.example.v1.BenchCrossReference.RuleR\x05rules\x1a-\n" + + "\x03Key\x12\x12\n" + + "\x04kind\x18\x01 \x01(\tR\x04kind\x12\x12\n" + + "\x04name\x18\x02 \x01(\tR\x04name\x1aD\n" + + "\x05Entry\x12;\n" + + "\x03key\x18\x01 \x01(\v2).tests.example.v1.BenchCrossReference.KeyR\x03key\x1aE\n" + + "\x04Rule\x12=\n" + + "\x04refs\x18\x01 \x03(\v2).tests.example.v1.BenchCrossReference.KeyR\x04refs:\xcb\x01\xbaH\xc7\x01\x1a\xc4\x01\n" + + "\frefs_resolve\x125every rule reference must resolve to a declared entry\x1a}this.rules.all(rule, rule.refs.all(ref,this.entries.exists(entry, entry.key.kind == ref.kind && entry.key.name == ref.name)))*O\n" + "\tBenchEnum\x12\x1a\n" + "\x16BENCH_ENUM_UNSPECIFIED\x10\x00\x12\x12\n" + "\x0eBENCH_ENUM_ONE\x10\x01\x12\x12\n" + @@ -1189,7 +1474,7 @@ const file_tests_example_v1_bench_proto_rawDesc = "" + "BenchProtoP\x01ZBbuf.build/go/protovalidate/internal/gen/tests/example/v1;examplev1\xa2\x02\x03TEX\xaa\x02\x10Tests.Example.V1\xca\x02\x10Tests\\Example\\V1\xe2\x02\x1cTests\\Example\\V1\\GPBMetadata\xea\x02\x12Tests::Example::V1b\x06proto3" var file_tests_example_v1_bench_proto_enumTypes = make([]protoimpl.EnumInfo, 1) -var file_tests_example_v1_bench_proto_msgTypes = make([]protoimpl.MessageInfo, 14) +var file_tests_example_v1_bench_proto_msgTypes = make([]protoimpl.MessageInfo, 18) var file_tests_example_v1_bench_proto_goTypes = []any{ (BenchEnum)(0), // 0: tests.example.v1.BenchEnum (*BenchScalar)(nil), // 1: tests.example.v1.BenchScalar @@ -1199,35 +1484,43 @@ var file_tests_example_v1_bench_proto_goTypes = []any{ (*BenchRepeatedBytesUnique)(nil), // 5: tests.example.v1.BenchRepeatedBytesUnique (*BenchMap)(nil), // 6: tests.example.v1.BenchMap (*BenchComplexSchema)(nil), // 7: tests.example.v1.BenchComplexSchema - nil, // 8: tests.example.v1.BenchMap.EntriesEntry - nil, // 9: tests.example.v1.BenchComplexSchema.MapStrStrEntry - nil, // 10: tests.example.v1.BenchComplexSchema.MapI32I64Entry - nil, // 11: tests.example.v1.BenchComplexSchema.MapU64BoolEntry - nil, // 12: tests.example.v1.BenchComplexSchema.MapStrBytesEntry - nil, // 13: tests.example.v1.BenchComplexSchema.MapStrMsgEntry - nil, // 14: tests.example.v1.BenchComplexSchema.MapI64MsgEntry + (*BenchCrossReference)(nil), // 8: tests.example.v1.BenchCrossReference + nil, // 9: tests.example.v1.BenchMap.EntriesEntry + nil, // 10: tests.example.v1.BenchComplexSchema.MapStrStrEntry + nil, // 11: tests.example.v1.BenchComplexSchema.MapI32I64Entry + nil, // 12: tests.example.v1.BenchComplexSchema.MapU64BoolEntry + nil, // 13: tests.example.v1.BenchComplexSchema.MapStrBytesEntry + nil, // 14: tests.example.v1.BenchComplexSchema.MapStrMsgEntry + nil, // 15: tests.example.v1.BenchComplexSchema.MapI64MsgEntry + (*BenchCrossReference_Key)(nil), // 16: tests.example.v1.BenchCrossReference.Key + (*BenchCrossReference_Entry)(nil), // 17: tests.example.v1.BenchCrossReference.Entry + (*BenchCrossReference_Rule)(nil), // 18: tests.example.v1.BenchCrossReference.Rule } var file_tests_example_v1_bench_proto_depIdxs = []int32{ 1, // 0: tests.example.v1.BenchRepeatedMessage.x:type_name -> tests.example.v1.BenchScalar - 8, // 1: tests.example.v1.BenchMap.entries:type_name -> tests.example.v1.BenchMap.EntriesEntry + 9, // 1: tests.example.v1.BenchMap.entries:type_name -> tests.example.v1.BenchMap.EntriesEntry 1, // 2: tests.example.v1.BenchComplexSchema.nested:type_name -> tests.example.v1.BenchScalar 7, // 3: tests.example.v1.BenchComplexSchema.self_ref:type_name -> tests.example.v1.BenchComplexSchema 1, // 4: tests.example.v1.BenchComplexSchema.rep_msg:type_name -> tests.example.v1.BenchScalar - 9, // 5: tests.example.v1.BenchComplexSchema.map_str_str:type_name -> tests.example.v1.BenchComplexSchema.MapStrStrEntry - 10, // 6: tests.example.v1.BenchComplexSchema.map_i32_i64:type_name -> tests.example.v1.BenchComplexSchema.MapI32I64Entry - 11, // 7: tests.example.v1.BenchComplexSchema.map_u64_bool:type_name -> tests.example.v1.BenchComplexSchema.MapU64BoolEntry - 12, // 8: tests.example.v1.BenchComplexSchema.map_str_bytes:type_name -> tests.example.v1.BenchComplexSchema.MapStrBytesEntry - 13, // 9: tests.example.v1.BenchComplexSchema.map_str_msg:type_name -> tests.example.v1.BenchComplexSchema.MapStrMsgEntry - 14, // 10: tests.example.v1.BenchComplexSchema.map_i64_msg:type_name -> tests.example.v1.BenchComplexSchema.MapI64MsgEntry + 10, // 5: tests.example.v1.BenchComplexSchema.map_str_str:type_name -> tests.example.v1.BenchComplexSchema.MapStrStrEntry + 11, // 6: tests.example.v1.BenchComplexSchema.map_i32_i64:type_name -> tests.example.v1.BenchComplexSchema.MapI32I64Entry + 12, // 7: tests.example.v1.BenchComplexSchema.map_u64_bool:type_name -> tests.example.v1.BenchComplexSchema.MapU64BoolEntry + 13, // 8: tests.example.v1.BenchComplexSchema.map_str_bytes:type_name -> tests.example.v1.BenchComplexSchema.MapStrBytesEntry + 14, // 9: tests.example.v1.BenchComplexSchema.map_str_msg:type_name -> tests.example.v1.BenchComplexSchema.MapStrMsgEntry + 15, // 10: tests.example.v1.BenchComplexSchema.map_i64_msg:type_name -> tests.example.v1.BenchComplexSchema.MapI64MsgEntry 0, // 11: tests.example.v1.BenchComplexSchema.enum_field:type_name -> tests.example.v1.BenchEnum 1, // 12: tests.example.v1.BenchComplexSchema.oneof_msg:type_name -> tests.example.v1.BenchScalar - 1, // 13: tests.example.v1.BenchComplexSchema.MapStrMsgEntry.value:type_name -> tests.example.v1.BenchScalar - 1, // 14: tests.example.v1.BenchComplexSchema.MapI64MsgEntry.value:type_name -> tests.example.v1.BenchScalar - 15, // [15:15] is the sub-list for method output_type - 15, // [15:15] is the sub-list for method input_type - 15, // [15:15] is the sub-list for extension type_name - 15, // [15:15] is the sub-list for extension extendee - 0, // [0:15] is the sub-list for field type_name + 17, // 13: tests.example.v1.BenchCrossReference.entries:type_name -> tests.example.v1.BenchCrossReference.Entry + 18, // 14: tests.example.v1.BenchCrossReference.rules:type_name -> tests.example.v1.BenchCrossReference.Rule + 1, // 15: tests.example.v1.BenchComplexSchema.MapStrMsgEntry.value:type_name -> tests.example.v1.BenchScalar + 1, // 16: tests.example.v1.BenchComplexSchema.MapI64MsgEntry.value:type_name -> tests.example.v1.BenchScalar + 16, // 17: tests.example.v1.BenchCrossReference.Entry.key:type_name -> tests.example.v1.BenchCrossReference.Key + 16, // 18: tests.example.v1.BenchCrossReference.Rule.refs:type_name -> tests.example.v1.BenchCrossReference.Key + 19, // [19:19] is the sub-list for method output_type + 19, // [19:19] is the sub-list for method input_type + 19, // [19:19] is the sub-list for extension type_name + 19, // [19:19] is the sub-list for extension extendee + 0, // [0:19] is the sub-list for field type_name } func init() { file_tests_example_v1_bench_proto_init() } @@ -1246,7 +1539,7 @@ func file_tests_example_v1_bench_proto_init() { GoPackagePath: reflect.TypeOf(x{}).PkgPath(), RawDescriptor: unsafe.Slice(unsafe.StringData(file_tests_example_v1_bench_proto_rawDesc), len(file_tests_example_v1_bench_proto_rawDesc)), NumEnums: 1, - NumMessages: 14, + NumMessages: 18, NumExtensions: 0, NumServices: 0, }, diff --git a/internal/gen/tests/example/v1/bench_protoopaque.pb.go b/internal/gen/tests/example/v1/bench_protoopaque.pb.go index f90bc1f..116c31f 100644 --- a/internal/gen/tests/example/v1/bench_protoopaque.pb.go +++ b/internal/gen/tests/example/v1/bench_protoopaque.pb.go @@ -1078,6 +1078,286 @@ func (*benchComplexSchema_OneofI32) isBenchComplexSchema_Choice() {} func (*benchComplexSchema_OneofMsg) isBenchComplexSchema_Choice() {} +// BenchCrossReference models a message whose CEL rule enforces referential +// integrity: every reference held by every rule must resolve to a declared +// entry. Evaluating it costs O(rules * refs_per_rule * entries), so its +// validation time grows multiplicatively with the size of the message. +// +// It exists to exercise cancellation of a single long-running CEL expression. +// It is not part of the faked benchmark corpus; tests build it explicitly. +type BenchCrossReference struct { + state protoimpl.MessageState `protogen:"opaque.v1"` + xxx_hidden_Entries *[]*BenchCrossReference_Entry `protobuf:"bytes,1,rep,name=entries,proto3"` + xxx_hidden_Rules *[]*BenchCrossReference_Rule `protobuf:"bytes,2,rep,name=rules,proto3"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *BenchCrossReference) Reset() { + *x = BenchCrossReference{} + mi := &file_tests_example_v1_bench_proto_msgTypes[7] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *BenchCrossReference) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*BenchCrossReference) ProtoMessage() {} + +func (x *BenchCrossReference) ProtoReflect() protoreflect.Message { + mi := &file_tests_example_v1_bench_proto_msgTypes[7] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +func (x *BenchCrossReference) GetEntries() []*BenchCrossReference_Entry { + if x != nil { + if x.xxx_hidden_Entries != nil { + return *x.xxx_hidden_Entries + } + } + return nil +} + +func (x *BenchCrossReference) GetRules() []*BenchCrossReference_Rule { + if x != nil { + if x.xxx_hidden_Rules != nil { + return *x.xxx_hidden_Rules + } + } + return nil +} + +func (x *BenchCrossReference) SetEntries(v []*BenchCrossReference_Entry) { + x.xxx_hidden_Entries = &v +} + +func (x *BenchCrossReference) SetRules(v []*BenchCrossReference_Rule) { + x.xxx_hidden_Rules = &v +} + +type BenchCrossReference_builder struct { + _ [0]func() // Prevents comparability and use of unkeyed literals for the builder. + + Entries []*BenchCrossReference_Entry + Rules []*BenchCrossReference_Rule +} + +func (b0 BenchCrossReference_builder) Build() *BenchCrossReference { + m0 := &BenchCrossReference{} + b, x := &b0, m0 + _, _ = b, x + x.xxx_hidden_Entries = &b.Entries + x.xxx_hidden_Rules = &b.Rules + return m0 +} + +type BenchCrossReference_Key struct { + state protoimpl.MessageState `protogen:"opaque.v1"` + xxx_hidden_Kind string `protobuf:"bytes,1,opt,name=kind,proto3"` + xxx_hidden_Name string `protobuf:"bytes,2,opt,name=name,proto3"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *BenchCrossReference_Key) Reset() { + *x = BenchCrossReference_Key{} + mi := &file_tests_example_v1_bench_proto_msgTypes[15] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *BenchCrossReference_Key) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*BenchCrossReference_Key) ProtoMessage() {} + +func (x *BenchCrossReference_Key) ProtoReflect() protoreflect.Message { + mi := &file_tests_example_v1_bench_proto_msgTypes[15] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +func (x *BenchCrossReference_Key) GetKind() string { + if x != nil { + return x.xxx_hidden_Kind + } + return "" +} + +func (x *BenchCrossReference_Key) GetName() string { + if x != nil { + return x.xxx_hidden_Name + } + return "" +} + +func (x *BenchCrossReference_Key) SetKind(v string) { + x.xxx_hidden_Kind = v +} + +func (x *BenchCrossReference_Key) SetName(v string) { + x.xxx_hidden_Name = v +} + +type BenchCrossReference_Key_builder struct { + _ [0]func() // Prevents comparability and use of unkeyed literals for the builder. + + Kind string + Name string +} + +func (b0 BenchCrossReference_Key_builder) Build() *BenchCrossReference_Key { + m0 := &BenchCrossReference_Key{} + b, x := &b0, m0 + _, _ = b, x + x.xxx_hidden_Kind = b.Kind + x.xxx_hidden_Name = b.Name + return m0 +} + +type BenchCrossReference_Entry struct { + state protoimpl.MessageState `protogen:"opaque.v1"` + xxx_hidden_Key *BenchCrossReference_Key `protobuf:"bytes,1,opt,name=key,proto3"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *BenchCrossReference_Entry) Reset() { + *x = BenchCrossReference_Entry{} + mi := &file_tests_example_v1_bench_proto_msgTypes[16] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *BenchCrossReference_Entry) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*BenchCrossReference_Entry) ProtoMessage() {} + +func (x *BenchCrossReference_Entry) ProtoReflect() protoreflect.Message { + mi := &file_tests_example_v1_bench_proto_msgTypes[16] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +func (x *BenchCrossReference_Entry) GetKey() *BenchCrossReference_Key { + if x != nil { + return x.xxx_hidden_Key + } + return nil +} + +func (x *BenchCrossReference_Entry) SetKey(v *BenchCrossReference_Key) { + x.xxx_hidden_Key = v +} + +func (x *BenchCrossReference_Entry) HasKey() bool { + if x == nil { + return false + } + return x.xxx_hidden_Key != nil +} + +func (x *BenchCrossReference_Entry) ClearKey() { + x.xxx_hidden_Key = nil +} + +type BenchCrossReference_Entry_builder struct { + _ [0]func() // Prevents comparability and use of unkeyed literals for the builder. + + Key *BenchCrossReference_Key +} + +func (b0 BenchCrossReference_Entry_builder) Build() *BenchCrossReference_Entry { + m0 := &BenchCrossReference_Entry{} + b, x := &b0, m0 + _, _ = b, x + x.xxx_hidden_Key = b.Key + return m0 +} + +type BenchCrossReference_Rule struct { + state protoimpl.MessageState `protogen:"opaque.v1"` + xxx_hidden_Refs *[]*BenchCrossReference_Key `protobuf:"bytes,1,rep,name=refs,proto3"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *BenchCrossReference_Rule) Reset() { + *x = BenchCrossReference_Rule{} + mi := &file_tests_example_v1_bench_proto_msgTypes[17] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *BenchCrossReference_Rule) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*BenchCrossReference_Rule) ProtoMessage() {} + +func (x *BenchCrossReference_Rule) ProtoReflect() protoreflect.Message { + mi := &file_tests_example_v1_bench_proto_msgTypes[17] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +func (x *BenchCrossReference_Rule) GetRefs() []*BenchCrossReference_Key { + if x != nil { + if x.xxx_hidden_Refs != nil { + return *x.xxx_hidden_Refs + } + } + return nil +} + +func (x *BenchCrossReference_Rule) SetRefs(v []*BenchCrossReference_Key) { + x.xxx_hidden_Refs = &v +} + +type BenchCrossReference_Rule_builder struct { + _ [0]func() // Prevents comparability and use of unkeyed literals for the builder. + + Refs []*BenchCrossReference_Key +} + +func (b0 BenchCrossReference_Rule_builder) Build() *BenchCrossReference_Rule { + m0 := &BenchCrossReference_Rule{} + b, x := &b0, m0 + _, _ = b, x + x.xxx_hidden_Refs = &b.Refs + return m0 +} + var File_tests_example_v1_bench_proto protoreflect.FileDescriptor const file_tests_example_v1_bench_proto_rawDesc = "" + @@ -1162,7 +1442,18 @@ const file_tests_example_v1_bench_proto_rawDesc = "" + "\x0eMapI64MsgEntry\x12\x10\n" + "\x03key\x18\x01 \x01(\x03R\x03key\x123\n" + "\x05value\x18\x02 \x01(\v2\x1d.tests.example.v1.BenchScalarR\x05value:\x028\x01B\b\n" + - "\x06choice*O\n" + + "\x06choice\"\xa8\x04\n" + + "\x13BenchCrossReference\x12E\n" + + "\aentries\x18\x01 \x03(\v2+.tests.example.v1.BenchCrossReference.EntryR\aentries\x12@\n" + + "\x05rules\x18\x02 \x03(\v2*.tests.example.v1.BenchCrossReference.RuleR\x05rules\x1a-\n" + + "\x03Key\x12\x12\n" + + "\x04kind\x18\x01 \x01(\tR\x04kind\x12\x12\n" + + "\x04name\x18\x02 \x01(\tR\x04name\x1aD\n" + + "\x05Entry\x12;\n" + + "\x03key\x18\x01 \x01(\v2).tests.example.v1.BenchCrossReference.KeyR\x03key\x1aE\n" + + "\x04Rule\x12=\n" + + "\x04refs\x18\x01 \x03(\v2).tests.example.v1.BenchCrossReference.KeyR\x04refs:\xcb\x01\xbaH\xc7\x01\x1a\xc4\x01\n" + + "\frefs_resolve\x125every rule reference must resolve to a declared entry\x1a}this.rules.all(rule, rule.refs.all(ref,this.entries.exists(entry, entry.key.kind == ref.kind && entry.key.name == ref.name)))*O\n" + "\tBenchEnum\x12\x1a\n" + "\x16BENCH_ENUM_UNSPECIFIED\x10\x00\x12\x12\n" + "\x0eBENCH_ENUM_ONE\x10\x01\x12\x12\n" + @@ -1171,7 +1462,7 @@ const file_tests_example_v1_bench_proto_rawDesc = "" + "BenchProtoP\x01ZBbuf.build/go/protovalidate/internal/gen/tests/example/v1;examplev1\xa2\x02\x03TEX\xaa\x02\x10Tests.Example.V1\xca\x02\x10Tests\\Example\\V1\xe2\x02\x1cTests\\Example\\V1\\GPBMetadata\xea\x02\x12Tests::Example::V1b\x06proto3" var file_tests_example_v1_bench_proto_enumTypes = make([]protoimpl.EnumInfo, 1) -var file_tests_example_v1_bench_proto_msgTypes = make([]protoimpl.MessageInfo, 14) +var file_tests_example_v1_bench_proto_msgTypes = make([]protoimpl.MessageInfo, 18) var file_tests_example_v1_bench_proto_goTypes = []any{ (BenchEnum)(0), // 0: tests.example.v1.BenchEnum (*BenchScalar)(nil), // 1: tests.example.v1.BenchScalar @@ -1181,35 +1472,43 @@ var file_tests_example_v1_bench_proto_goTypes = []any{ (*BenchRepeatedBytesUnique)(nil), // 5: tests.example.v1.BenchRepeatedBytesUnique (*BenchMap)(nil), // 6: tests.example.v1.BenchMap (*BenchComplexSchema)(nil), // 7: tests.example.v1.BenchComplexSchema - nil, // 8: tests.example.v1.BenchMap.EntriesEntry - nil, // 9: tests.example.v1.BenchComplexSchema.MapStrStrEntry - nil, // 10: tests.example.v1.BenchComplexSchema.MapI32I64Entry - nil, // 11: tests.example.v1.BenchComplexSchema.MapU64BoolEntry - nil, // 12: tests.example.v1.BenchComplexSchema.MapStrBytesEntry - nil, // 13: tests.example.v1.BenchComplexSchema.MapStrMsgEntry - nil, // 14: tests.example.v1.BenchComplexSchema.MapI64MsgEntry + (*BenchCrossReference)(nil), // 8: tests.example.v1.BenchCrossReference + nil, // 9: tests.example.v1.BenchMap.EntriesEntry + nil, // 10: tests.example.v1.BenchComplexSchema.MapStrStrEntry + nil, // 11: tests.example.v1.BenchComplexSchema.MapI32I64Entry + nil, // 12: tests.example.v1.BenchComplexSchema.MapU64BoolEntry + nil, // 13: tests.example.v1.BenchComplexSchema.MapStrBytesEntry + nil, // 14: tests.example.v1.BenchComplexSchema.MapStrMsgEntry + nil, // 15: tests.example.v1.BenchComplexSchema.MapI64MsgEntry + (*BenchCrossReference_Key)(nil), // 16: tests.example.v1.BenchCrossReference.Key + (*BenchCrossReference_Entry)(nil), // 17: tests.example.v1.BenchCrossReference.Entry + (*BenchCrossReference_Rule)(nil), // 18: tests.example.v1.BenchCrossReference.Rule } var file_tests_example_v1_bench_proto_depIdxs = []int32{ 1, // 0: tests.example.v1.BenchRepeatedMessage.x:type_name -> tests.example.v1.BenchScalar - 8, // 1: tests.example.v1.BenchMap.entries:type_name -> tests.example.v1.BenchMap.EntriesEntry + 9, // 1: tests.example.v1.BenchMap.entries:type_name -> tests.example.v1.BenchMap.EntriesEntry 1, // 2: tests.example.v1.BenchComplexSchema.nested:type_name -> tests.example.v1.BenchScalar 7, // 3: tests.example.v1.BenchComplexSchema.self_ref:type_name -> tests.example.v1.BenchComplexSchema 1, // 4: tests.example.v1.BenchComplexSchema.rep_msg:type_name -> tests.example.v1.BenchScalar - 9, // 5: tests.example.v1.BenchComplexSchema.map_str_str:type_name -> tests.example.v1.BenchComplexSchema.MapStrStrEntry - 10, // 6: tests.example.v1.BenchComplexSchema.map_i32_i64:type_name -> tests.example.v1.BenchComplexSchema.MapI32I64Entry - 11, // 7: tests.example.v1.BenchComplexSchema.map_u64_bool:type_name -> tests.example.v1.BenchComplexSchema.MapU64BoolEntry - 12, // 8: tests.example.v1.BenchComplexSchema.map_str_bytes:type_name -> tests.example.v1.BenchComplexSchema.MapStrBytesEntry - 13, // 9: tests.example.v1.BenchComplexSchema.map_str_msg:type_name -> tests.example.v1.BenchComplexSchema.MapStrMsgEntry - 14, // 10: tests.example.v1.BenchComplexSchema.map_i64_msg:type_name -> tests.example.v1.BenchComplexSchema.MapI64MsgEntry + 10, // 5: tests.example.v1.BenchComplexSchema.map_str_str:type_name -> tests.example.v1.BenchComplexSchema.MapStrStrEntry + 11, // 6: tests.example.v1.BenchComplexSchema.map_i32_i64:type_name -> tests.example.v1.BenchComplexSchema.MapI32I64Entry + 12, // 7: tests.example.v1.BenchComplexSchema.map_u64_bool:type_name -> tests.example.v1.BenchComplexSchema.MapU64BoolEntry + 13, // 8: tests.example.v1.BenchComplexSchema.map_str_bytes:type_name -> tests.example.v1.BenchComplexSchema.MapStrBytesEntry + 14, // 9: tests.example.v1.BenchComplexSchema.map_str_msg:type_name -> tests.example.v1.BenchComplexSchema.MapStrMsgEntry + 15, // 10: tests.example.v1.BenchComplexSchema.map_i64_msg:type_name -> tests.example.v1.BenchComplexSchema.MapI64MsgEntry 0, // 11: tests.example.v1.BenchComplexSchema.enum_field:type_name -> tests.example.v1.BenchEnum 1, // 12: tests.example.v1.BenchComplexSchema.oneof_msg:type_name -> tests.example.v1.BenchScalar - 1, // 13: tests.example.v1.BenchComplexSchema.MapStrMsgEntry.value:type_name -> tests.example.v1.BenchScalar - 1, // 14: tests.example.v1.BenchComplexSchema.MapI64MsgEntry.value:type_name -> tests.example.v1.BenchScalar - 15, // [15:15] is the sub-list for method output_type - 15, // [15:15] is the sub-list for method input_type - 15, // [15:15] is the sub-list for extension type_name - 15, // [15:15] is the sub-list for extension extendee - 0, // [0:15] is the sub-list for field type_name + 17, // 13: tests.example.v1.BenchCrossReference.entries:type_name -> tests.example.v1.BenchCrossReference.Entry + 18, // 14: tests.example.v1.BenchCrossReference.rules:type_name -> tests.example.v1.BenchCrossReference.Rule + 1, // 15: tests.example.v1.BenchComplexSchema.MapStrMsgEntry.value:type_name -> tests.example.v1.BenchScalar + 1, // 16: tests.example.v1.BenchComplexSchema.MapI64MsgEntry.value:type_name -> tests.example.v1.BenchScalar + 16, // 17: tests.example.v1.BenchCrossReference.Entry.key:type_name -> tests.example.v1.BenchCrossReference.Key + 16, // 18: tests.example.v1.BenchCrossReference.Rule.refs:type_name -> tests.example.v1.BenchCrossReference.Key + 19, // [19:19] is the sub-list for method output_type + 19, // [19:19] is the sub-list for method input_type + 19, // [19:19] is the sub-list for extension type_name + 19, // [19:19] is the sub-list for extension extendee + 0, // [0:19] is the sub-list for field type_name } func init() { file_tests_example_v1_bench_proto_init() } @@ -1228,7 +1527,7 @@ func file_tests_example_v1_bench_proto_init() { GoPackagePath: reflect.TypeOf(x{}).PkgPath(), RawDescriptor: unsafe.Slice(unsafe.StringData(file_tests_example_v1_bench_proto_rawDesc), len(file_tests_example_v1_bench_proto_rawDesc)), NumEnums: 1, - NumMessages: 14, + NumMessages: 18, NumExtensions: 0, NumServices: 0, }, diff --git a/map.go b/map.go index 70c4ec7..2e097d2 100644 --- a/map.go +++ b/map.go @@ -15,6 +15,7 @@ package protovalidate import ( + "context" "fmt" "strconv" @@ -66,10 +67,20 @@ func newKVPairs(valEval *value) *kvPairs { } } -func (m *kvPairs) Evaluate(msg protoreflect.Message, val protoreflect.Value, cfg *validationConfig) (err error) { +func (m *kvPairs) Evaluate(msg protoreflect.Message, val protoreflect.Value, cfg *validationConfig) error { + return m.EvaluateContext(context.Background(), msg, val, cfg) +} + +func (m *kvPairs) EvaluateContext(ctx context.Context, msg protoreflect.Message, val protoreflect.Value, cfg *validationConfig) (err error) { var ok bool val.Map().Range(func(key protoreflect.MapKey, value protoreflect.Value) bool { - evalErr := m.evalPairs(msg, key, value, cfg) + if cfg.cancellable { + if ctxErr := ctx.Err(); ctxErr != nil { + err = ctxErr + return false + } + } + evalErr := m.evalPairsContext(ctx, msg, key, value, cfg) if evalErr != nil { element := validate.FieldPathElement_builder{ FieldNumber: proto.Int32(m.FieldPathElement.GetFieldNumber()), @@ -107,15 +118,15 @@ func (m *kvPairs) Evaluate(msg protoreflect.Message, val protoreflect.Value, cfg return err } -func (m *kvPairs) evalPairs(msg protoreflect.Message, key protoreflect.MapKey, value protoreflect.Value, cfg *validationConfig) (err error) { - evalErr := m.KeyRules.EvaluateField(msg, key.Value(), cfg, true) +func (m *kvPairs) evalPairsContext(ctx context.Context, msg protoreflect.Message, key protoreflect.MapKey, value protoreflect.Value, cfg *validationConfig) (err error) { + evalErr := m.KeyRules.EvaluateFieldContext(ctx, msg, key.Value(), cfg, true) markViolationForKey(evalErr) ok, err := mergeViolations(err, evalErr, cfg) if !ok { return err } - evalErr = m.ValueRules.EvaluateField(msg, value, cfg, true) + evalErr = m.ValueRules.EvaluateFieldContext(ctx, msg, value, cfg, true) _, err = mergeViolations(err, evalErr, cfg) return err } diff --git a/message.go b/message.go index ba8741f..5bfc8ab 100644 --- a/message.go +++ b/message.go @@ -15,6 +15,7 @@ package protovalidate import ( + "context" "fmt" "google.golang.org/protobuf/reflect/protoreflect" @@ -34,11 +35,24 @@ type message struct { nestedEvaluators messageEvaluators } -func (m *message) Evaluate(_ protoreflect.Message, val protoreflect.Value, cfg *validationConfig) error { - return m.EvaluateMessage(val.Message(), cfg) +func (m *message) Evaluate(msg protoreflect.Message, val protoreflect.Value, cfg *validationConfig) error { + return m.EvaluateContext(context.Background(), msg, val, cfg) +} + +func (m *message) EvaluateContext(ctx context.Context, _ protoreflect.Message, val protoreflect.Value, cfg *validationConfig) error { + return m.EvaluateMessageContext(ctx, val.Message(), cfg) } func (m *message) EvaluateMessage(msg protoreflect.Message, cfg *validationConfig) error { + return m.EvaluateMessageContext(context.Background(), msg, cfg) +} + +func (m *message) EvaluateMessageContext(ctx context.Context, msg protoreflect.Message, cfg *validationConfig) error { + if cfg.cancellable { + if err := ctx.Err(); err != nil { + return err + } + } var ( err error ok bool @@ -47,11 +61,11 @@ func (m *message) EvaluateMessage(msg protoreflect.Message, cfg *validationConfi if m.Err != nil { return m.Err } - if ok, err = mergeViolations(err, m.evaluators.EvaluateMessage(msg, cfg), cfg); !ok { + if ok, err = mergeViolations(err, m.evaluators.EvaluateMessageContext(ctx, msg, cfg), cfg); !ok { return err } } - _, err = mergeViolations(err, m.nestedEvaluators.EvaluateMessage(msg, cfg), cfg) + _, err = mergeViolations(err, m.nestedEvaluators.EvaluateMessageContext(ctx, msg, cfg), cfg) return err } @@ -91,11 +105,19 @@ func (u unknownMessage) Err() error { func (u unknownMessage) Tautology() bool { return false } -func (u unknownMessage) Evaluate(_ protoreflect.Message, _ protoreflect.Value, _ *validationConfig) error { +func (u unknownMessage) Evaluate(msg protoreflect.Message, val protoreflect.Value, cfg *validationConfig) error { + return u.EvaluateContext(context.Background(), msg, val, cfg) +} + +func (u unknownMessage) EvaluateContext(_ context.Context, _ protoreflect.Message, _ protoreflect.Value, _ *validationConfig) error { return u.Err() } -func (u unknownMessage) EvaluateMessage(_ protoreflect.Message, _ *validationConfig) error { +func (u unknownMessage) EvaluateMessage(msg protoreflect.Message, cfg *validationConfig) error { + return u.EvaluateMessageContext(context.Background(), msg, cfg) +} + +func (u unknownMessage) EvaluateMessageContext(_ context.Context, _ protoreflect.Message, _ *validationConfig) error { return u.Err() } @@ -107,8 +129,12 @@ type embeddedMessage struct { message *message } -func (m *embeddedMessage) Evaluate(_ protoreflect.Message, val protoreflect.Value, cfg *validationConfig) error { - err := m.message.EvaluateMessage(val.Message(), cfg) +func (m *embeddedMessage) Evaluate(msg protoreflect.Message, val protoreflect.Value, cfg *validationConfig) error { + return m.EvaluateContext(context.Background(), msg, val, cfg) +} + +func (m *embeddedMessage) EvaluateContext(ctx context.Context, _ protoreflect.Message, val protoreflect.Value, cfg *validationConfig) error { + err := m.message.EvaluateMessageContext(ctx, val.Message(), cfg) updateViolationPaths(err, m.FieldPathElement, nil) return err } diff --git a/message_oneof.go b/message_oneof.go index ce7842d..3e98909 100644 --- a/message_oneof.go +++ b/message_oneof.go @@ -15,6 +15,7 @@ package protovalidate import ( + "context" "fmt" "strings" @@ -39,11 +40,19 @@ func (o messageOneof) formatFields() string { return strings.Join(names, ", ") } -func (o messageOneof) Evaluate(_ protoreflect.Message, val protoreflect.Value, cfg *validationConfig) error { - return o.EvaluateMessage(val.Message(), cfg) +func (o messageOneof) Evaluate(msg protoreflect.Message, val protoreflect.Value, cfg *validationConfig) error { + return o.EvaluateContext(context.Background(), msg, val, cfg) +} + +func (o messageOneof) EvaluateContext(ctx context.Context, _ protoreflect.Message, val protoreflect.Value, cfg *validationConfig) error { + return o.EvaluateMessageContext(ctx, val.Message(), cfg) } func (o messageOneof) EvaluateMessage(msg protoreflect.Message, cfg *validationConfig) error { + return o.EvaluateMessageContext(context.Background(), msg, cfg) +} + +func (o messageOneof) EvaluateMessageContext(_ context.Context, msg protoreflect.Message, cfg *validationConfig) error { if !cfg.filter.ShouldValidate(msg, msg.Descriptor()) { return nil } diff --git a/native_bool.go b/native_bool.go index d2c4e6b..9eb0a84 100644 --- a/native_bool.go +++ b/native_bool.go @@ -15,6 +15,7 @@ package protovalidate import ( + "context" "fmt" "buf.build/gen/go/bufbuild/protovalidate/protocolbuffers/go/buf/validate" @@ -58,7 +59,11 @@ type nativeBoolEval struct { constVal bool } -func (n nativeBoolEval) Evaluate(_ protoreflect.Message, val protoreflect.Value, _ *validationConfig) error { +func (n nativeBoolEval) Evaluate(msg protoreflect.Message, val protoreflect.Value, cfg *validationConfig) error { + return n.EvaluateContext(context.Background(), msg, val, cfg) +} + +func (n nativeBoolEval) EvaluateContext(_ context.Context, _ protoreflect.Message, val protoreflect.Value, _ *validationConfig) error { if val.Bool() != n.constVal { return &ValidationError{Violations: []*Violation{n.newViolation(boolConstSite, "bool.const", fmt.Sprintf("must equal %t", n.constVal), diff --git a/native_bytes.go b/native_bytes.go index 023117b..0e940cf 100644 --- a/native_bytes.go +++ b/native_bytes.go @@ -16,6 +16,7 @@ package protovalidate import ( "bytes" + "context" "errors" "fmt" "math" @@ -274,8 +275,12 @@ type nativeBytesEval struct { var errNotUTF8 = errors.New("must be valid UTF-8 to apply regexp") +func (n nativeBytesEval) Evaluate(msg protoreflect.Message, val protoreflect.Value, cfg *validationConfig) error { + return n.EvaluateContext(context.Background(), msg, val, cfg) +} + //nolint:gocyclo // this code has nested ifs but it's not hard to follow. -func (n nativeBytesEval) Evaluate(_ protoreflect.Message, val protoreflect.Value, cfg *validationConfig) error { +func (n nativeBytesEval) EvaluateContext(_ context.Context, _ protoreflect.Message, val protoreflect.Value, cfg *validationConfig) error { bytesVal := val.Bytes() byteLen := uint64(len(bytesVal)) var violations []*Violation diff --git a/native_enum.go b/native_enum.go index 01d3a60..28b37d2 100644 --- a/native_enum.go +++ b/native_enum.go @@ -15,6 +15,7 @@ package protovalidate import ( + "context" "fmt" "slices" @@ -116,7 +117,11 @@ var enumProcessors = []enumProcessor{ }, } -func (n nativeEnumEval) Evaluate(_ protoreflect.Message, val protoreflect.Value, cfg *validationConfig) error { +func (n nativeEnumEval) Evaluate(msg protoreflect.Message, val protoreflect.Value, cfg *validationConfig) error { + return n.EvaluateContext(context.Background(), msg, val, cfg) +} + +func (n nativeEnumEval) EvaluateContext(_ context.Context, _ protoreflect.Message, val protoreflect.Value, cfg *validationConfig) error { enumVal := int32(val.Enum()) var violations []*Violation diff --git a/native_map.go b/native_map.go index cb49d9d..58c35db 100644 --- a/native_map.go +++ b/native_map.go @@ -15,6 +15,7 @@ package protovalidate import ( + "context" "fmt" "math" @@ -85,7 +86,16 @@ type nativeMapEval struct { maxPairs uint64 } -func (n nativeMapEval) Evaluate(_ protoreflect.Message, val protoreflect.Value, _ *validationConfig) error { +func (n nativeMapEval) Evaluate(msg protoreflect.Message, val protoreflect.Value, cfg *validationConfig) error { + return n.EvaluateContext(context.Background(), msg, val, cfg) +} + +func (n nativeMapEval) EvaluateContext(ctx context.Context, _ protoreflect.Message, val protoreflect.Value, cfg *validationConfig) error { + if cfg.cancellable { + if err := ctx.Err(); err != nil { + return err + } + } size := uint64(val.Map().Len()) //nolint:gosec // int will never be negative or out of uint64 range // min_pairs diff --git a/native_numeric.go b/native_numeric.go index 2b51874..0e0870a 100644 --- a/native_numeric.go +++ b/native_numeric.go @@ -15,6 +15,7 @@ package protovalidate import ( + "context" "fmt" "math" "slices" @@ -518,7 +519,11 @@ func (n nativeNumericCompare[T]) conjunction() string { return "or" } -func (n nativeNumericCompare[T]) Evaluate(_ protoreflect.Message, val protoreflect.Value, cfg *validationConfig) error { +func (n nativeNumericCompare[T]) Evaluate(msg protoreflect.Message, val protoreflect.Value, cfg *validationConfig) error { + return n.EvaluateContext(context.Background(), msg, val, cfg) +} + +func (n nativeNumericCompare[T]) EvaluateContext(_ context.Context, _ protoreflect.Message, val protoreflect.Value, cfg *validationConfig) error { valT := n.config.extractVal(val) var violations []*Violation diff --git a/native_repeated.go b/native_repeated.go index 6abf61f..5c14061 100644 --- a/native_repeated.go +++ b/native_repeated.go @@ -16,6 +16,7 @@ package protovalidate import ( "bytes" + "context" "fmt" "math" @@ -148,7 +149,16 @@ type nativeRepeatedEval struct { uniqueFn uniqueChecker } -func (n nativeRepeatedEval) Evaluate(_ protoreflect.Message, val protoreflect.Value, cfg *validationConfig) error { +func (n nativeRepeatedEval) Evaluate(msg protoreflect.Message, val protoreflect.Value, cfg *validationConfig) error { + return n.EvaluateContext(context.Background(), msg, val, cfg) +} + +func (n nativeRepeatedEval) EvaluateContext(ctx context.Context, _ protoreflect.Message, val protoreflect.Value, cfg *validationConfig) error { + if cfg.cancellable { + if err := ctx.Err(); err != nil { + return err + } + } list := val.List() size := uint64(list.Len()) //nolint:gosec // len can't be < 0 and is always within uint64 range var violations []*Violation diff --git a/native_string.go b/native_string.go index 5aa828b..9b015c1 100644 --- a/native_string.go +++ b/native_string.go @@ -15,6 +15,7 @@ package protovalidate import ( + "context" "errors" "fmt" "regexp" @@ -378,8 +379,12 @@ type nativeStringEval struct { strict bool } +func (n nativeStringEval) Evaluate(msg protoreflect.Message, val protoreflect.Value, cfg *validationConfig) error { + return n.EvaluateContext(context.Background(), msg, val, cfg) +} + //nolint:gocyclo // this code has nested ifs but it's not hard to follow. -func (n nativeStringEval) Evaluate(_ protoreflect.Message, val protoreflect.Value, cfg *validationConfig) error { +func (n nativeStringEval) EvaluateContext(_ context.Context, _ protoreflect.Message, val protoreflect.Value, cfg *validationConfig) error { strVal := val.String() var violations []*Violation diff --git a/oneof.go b/oneof.go index 326fd23..5beb6d1 100644 --- a/oneof.go +++ b/oneof.go @@ -15,6 +15,8 @@ package protovalidate import ( + "context" + "buf.build/gen/go/bufbuild/protovalidate/protocolbuffers/go/buf/validate" "google.golang.org/protobuf/proto" "google.golang.org/protobuf/reflect/protoreflect" @@ -28,11 +30,19 @@ type oneof struct { Required bool } -func (o oneof) Evaluate(_ protoreflect.Message, val protoreflect.Value, cfg *validationConfig) error { - return o.EvaluateMessage(val.Message(), cfg) +func (o oneof) Evaluate(msg protoreflect.Message, val protoreflect.Value, cfg *validationConfig) error { + return o.EvaluateContext(context.Background(), msg, val, cfg) +} + +func (o oneof) EvaluateContext(ctx context.Context, _ protoreflect.Message, val protoreflect.Value, cfg *validationConfig) error { + return o.EvaluateMessageContext(ctx, val.Message(), cfg) } func (o oneof) EvaluateMessage(msg protoreflect.Message, cfg *validationConfig) error { + return o.EvaluateMessageContext(context.Background(), msg, cfg) +} + +func (o oneof) EvaluateMessageContext(_ context.Context, msg protoreflect.Message, cfg *validationConfig) error { if !cfg.filter.ShouldValidate(msg, o.Descriptor) || !o.Required || msg.WhichOneof(o.Descriptor) != nil { return nil diff --git a/option.go b/option.go index 7eafc76..8435772 100644 --- a/option.go +++ b/option.go @@ -117,6 +117,30 @@ func WithNowFunc(fn func() *timestamppb.Timestamp) Option { return nowFuncOption(fn) } +// WithCELInterruptCheckFrequency sets how many CEL comprehension iterations +// elapse between checks for context cancellation during [ContextValidator.ValidateContext]. +// +// cel-go only checks for cancellation inside comprehension loops (all, exists, +// map, filter), once per iteration, so cancellation latency is proportional to +// this value. The per-iteration bookkeeping happens regardless of the +// frequency, which merely gates a cheap non-blocking channel poll; raising it +// therefore buys no measurable throughput while making cancellation +// correspondingly less responsive. The default, +// [buf.build/go/protovalidate/cel.DefaultInterruptCheckFrequency], is 1. +// +// A value of 0 disables interrupt checking: CEL expressions run to completion +// and cannot be cancelled mid-evaluation. Expressions with no comprehension are +// never interruptible regardless of this setting. +func WithCELInterruptCheckFrequency(frequency uint) ValidatorOption { + return celInterruptCheckFrequencyOption{frequency} +} + +type celInterruptCheckFrequencyOption struct{ frequency uint } + +func (o celInterruptCheckFrequencyOption) applyToValidator(cfg *config) { + cfg.interruptCheckFrequency = o.frequency +} + type messageDescriptorsOption struct { descriptors []protoreflect.MessageDescriptor } diff --git a/program.go b/program.go index a79a681..1e65e22 100644 --- a/program.go +++ b/program.go @@ -15,11 +15,15 @@ package protovalidate import ( + "context" + "errors" "fmt" "buf.build/gen/go/bufbuild/protovalidate/protocolbuffers/go/buf/validate" "github.com/google/cel-go/cel" "github.com/google/cel-go/common/types" + "github.com/google/cel-go/common/types/ref" + "github.com/google/cel-go/interpreter" "google.golang.org/protobuf/proto" "google.golang.org/protobuf/reflect/protoreflect" ) @@ -41,16 +45,31 @@ func (s programSet) Eval( val protoreflect.Value, fieldDesc protoreflect.FieldDescriptor, cfg *validationConfig, +) error { + return s.EvalContext(context.Background(), val, fieldDesc, cfg) +} + +// EvalContext is like Eval, but honors context cancellation. +func (s programSet) EvalContext( + ctx context.Context, + val protoreflect.Value, + fieldDesc protoreflect.FieldDescriptor, + cfg *validationConfig, ) error { if len(s.programs) == 0 { return nil } + if cfg.cancellable { + if err := ctx.Err(); err != nil { + return err + } + } activation := getBindings() defer putBindings(activation) activation.This = newOptional(thisToCel(val.Interface(), fieldDesc, s.env.CELTypeAdapter())) var violations []*Violation for _, expr := range s.programs { - violation, err := expr.eval(activation, cfg) + violation, err := expr.evalContext(ctx, activation, cfg) if err != nil { return err } @@ -91,8 +110,12 @@ type compiledProgram struct { Descriptor protoreflect.FieldDescriptor } -//nolint:nilnil // non-existence of violations is intentional func (expr compiledProgram) eval(activation *bindings, cfg *validationConfig) (*Violation, error) { + return expr.evalContext(context.Background(), activation, cfg) +} + +//nolint:nilnil // non-existence of violations is intentional +func (expr compiledProgram) evalContext(ctx context.Context, activation *bindings, cfg *validationConfig) (*Violation, error) { activation.NowFn = cfg.nowFn if expr.Rules != nil { activation.Rules = expr.Rules.Interface() @@ -100,8 +123,28 @@ func (expr compiledProgram) eval(activation *bindings, cfg *validationConfig) (* if expr.Value.IsValid() { activation.Rule = expr.Value.Interface() } - value, _, err := expr.Program.Eval(activation) + var ( + value ref.Val + err error + ) + if cfg.cancellable { + value, _, err = expr.Program.ContextEval(ctx, activation) + } else { + // The context can never be cancelled (e.g. context.Background), so there + // is nothing to interrupt. Skip ContextEval, which would otherwise wrap + // the activation on every evaluation just to poll a nil channel. This + // keeps the Validate path free of any context-related overhead. + value, _, err = expr.Program.Eval(activation) + } if err != nil { + // Only cel-go's interrupt error signals cancellation; any other failure + // is a genuine evaluation error and must keep its RuntimeError identity + // (with the rule id) even if the context has expired in the meantime. + if errors.Is(err, interpreter.InterruptError{}) { + if ctxErr := ctx.Err(); ctxErr != nil { + return nil, ctxErr + } + } return nil, &RuntimeError{cause: fmt.Errorf( "error evaluating %s: %w", expr.Source.GetId(), err)} } diff --git a/program_test.go b/program_test.go index 9dc899b..135fed1 100644 --- a/program_test.go +++ b/program_test.go @@ -287,6 +287,62 @@ func TestSet_BindThis(t *testing.T) { } } +func TestCompiled_ContextCancelled(t *testing.T) { + t.Parallel() + ctx, cancel := context.WithCancel(context.Background()) + cancel() + program := programSet{programs: []compiledProgram{{ + Program: mockProgram{Val: types.True}, + Source: validate.Rule_builder{Id: proto.String("x")}.Build(), + }}} + err := program.EvalContext(ctx, protoreflect.ValueOfString("a"), nil, &validationConfig{cancellable: true}) + assert.ErrorIs(t, err, context.Canceled) +} + +func TestCompiled_ContextInterrupted(t *testing.T) { + t.Parallel() + env, err := cel.NewEnv() + require.NoError(t, err) + ast, iss := env.Compile("[1, 2, 3].all(x, x > 0)") + require.NoError(t, iss.Err()) + prog, err := env.Program(ast, cel.InterruptCheckFrequency(1)) + require.NoError(t, err) + + ctx, cancel := context.WithCancel(context.Background()) + cancel() + + expr := compiledProgram{ + Program: prog, + Source: validate.Rule_builder{Id: proto.String("x")}.Build(), + } + activation := getBindings() + defer putBindings(activation) + _, err = expr.evalContext(ctx, activation, &validationConfig{cancellable: true}) + require.ErrorIs(t, err, context.Canceled) + var re *RuntimeError + assert.NotErrorAs(t, err, &re, "context cancellation must not be wrapped in RuntimeError, got: %v", err) +} + +// TestCompiled_EvalErrorNotMaskedByExpiredContext guards against reclassifying +// a genuine evaluation error as a context error just because the context +// expired while the expression ran: only cel-go's interrupt error signals +// cancellation. +func TestCompiled_EvalErrorNotMaskedByExpiredContext(t *testing.T) { + t.Parallel() + ctx, cancel := context.WithCancel(context.Background()) + cancel() + expr := compiledProgram{ + Program: mockProgram{Err: errors.New("division by zero")}, + Source: validate.Rule_builder{Id: proto.String("x")}.Build(), + } + activation := getBindings() + defer putBindings(activation) + _, err := expr.evalContext(ctx, activation, &validationConfig{cancellable: true}) + var re *RuntimeError + require.ErrorAs(t, err, &re, "a real evaluation error must stay a RuntimeError, got: %v", err) + assert.NotErrorIs(t, err, context.Canceled) +} + type mockProgram struct { Val ref.Val Err error diff --git a/proto/tests/example/v1/bench.proto b/proto/tests/example/v1/bench.proto index 1e158ca..1a3156f 100644 --- a/proto/tests/example/v1/bench.proto +++ b/proto/tests/example/v1/bench.proto @@ -190,3 +190,36 @@ enum BenchEnum { BENCH_ENUM_ONE = 1; BENCH_ENUM_TWO = 2; } + +// BenchCrossReference models a message whose CEL rule enforces referential +// integrity: every reference held by every rule must resolve to a declared +// entry. Evaluating it costs O(rules * refs_per_rule * entries), so its +// validation time grows multiplicatively with the size of the message. +// +// It exists to exercise cancellation of a single long-running CEL expression. +// It is not part of the faked benchmark corpus; tests build it explicitly. +message BenchCrossReference { + option (buf.validate.message).cel = { + id: "refs_resolve" + message: "every rule reference must resolve to a declared entry" + expression: + "this.rules.all(rule, rule.refs.all(ref," + "this.entries.exists(entry, entry.key.kind == ref.kind && entry.key.name == ref.name)))" + }; + + message Key { + string kind = 1; + string name = 2; + } + + message Entry { + Key key = 1; + } + + message Rule { + repeated Key refs = 1; + } + + repeated Entry entries = 1; + repeated Rule rules = 2; +} diff --git a/repeated.go b/repeated.go index ec71b2c..b530d90 100644 --- a/repeated.go +++ b/repeated.go @@ -15,6 +15,8 @@ package protovalidate import ( + "context" + "buf.build/gen/go/bufbuild/protovalidate/protocolbuffers/go/buf/validate" "google.golang.org/protobuf/proto" "google.golang.org/protobuf/reflect/protoreflect" @@ -48,11 +50,20 @@ func newListItems(valEval *value) listItems { } func (r listItems) Evaluate(msg protoreflect.Message, val protoreflect.Value, cfg *validationConfig) error { + return r.EvaluateContext(context.Background(), msg, val, cfg) +} + +func (r listItems) EvaluateContext(ctx context.Context, msg protoreflect.Message, val protoreflect.Value, cfg *validationConfig) error { list := val.List() var ok bool var err error for i := range list.Len() { - itemErr := r.ItemRules.EvaluateField(msg, list.Get(i), cfg, true) + if cfg.cancellable { + if ctxErr := ctx.Err(); ctxErr != nil { + return ctxErr + } + } + itemErr := r.ItemRules.EvaluateFieldContext(ctx, msg, list.Get(i), cfg, true) if itemErr != nil { updateViolationPaths(itemErr, validate.FieldPathElement_builder{ FieldNumber: proto.Int32(r.FieldPathElement.GetFieldNumber()), diff --git a/validator.go b/validator.go index b9107a6..200af2e 100644 --- a/validator.go +++ b/validator.go @@ -15,6 +15,7 @@ package protovalidate import ( + "context" "fmt" "sync" @@ -27,7 +28,7 @@ import ( ) var ( - getGlobalValidator = sync.OnceValues(func() (Validator, error) { return New() }) + getGlobalValidator = sync.OnceValues(func() (ContextValidator, error) { return New() }) // GlobalValidator provides access to the global Validator instance that is // used by the [Validate] function. This is intended to be used by libraries @@ -37,7 +38,7 @@ var ( // Using the global Validator instance (either through [Validator] or via // GlobalValidator) will result in lower memory usage than using multiple // Validator instances, because each Validator instance has its own caches. - GlobalValidator Validator = globalValidator{} + GlobalValidator ContextValidator = globalValidator{} ) // Validator performs validation on any proto.Message values. The Validator is @@ -52,13 +53,43 @@ type Validator interface { Validate(msg proto.Message, options ...ValidationOption) error } -// New creates a Validator with the given options. An error may occur in setting -// up the CEL execution environment if the configuration is invalid. See the -// individual ValidatorOption for how they impact the fallibility of New. -func New(options ...ValidatorOption) (Validator, error) { +// ContextValidator is a [Validator] that additionally supports context-aware +// validation. The value returned by [New] and the [GlobalValidator] both +// implement it. Callers that hold only a [Validator] can type-assert it to +// ContextValidator, or use the package-level [ValidateContext] function. +// +// ContextValidator is a separate interface (rather than a method on Validator) +// to preserve backward compatibility for external implementations of Validator. +type ContextValidator interface { + Validator + + // ValidateContext behaves like Validate, but stops early if ctx is + // cancelled or its deadline is exceeded, returning the context error + // (context.Canceled or context.DeadlineExceeded), detectable with + // errors.Is. + // + // Cancellation is observed at message, repeated and map boundaries, before + // each CEL rule, and inside CEL comprehension macros (all, exists, map, + // filter): the loops in which an expensive expression spends its time. A + // CEL expression containing no comprehension is not interruptible once it + // begins; see [WithCELInterruptCheckFrequency]. + // + // Passing a context that can never be cancelled, such as context.Background, + // costs nothing: evaluation then takes exactly the same path as Validate. + ValidateContext(ctx context.Context, msg proto.Message, options ...ValidationOption) error +} + +// New creates a ContextValidator with the given options. An error may occur in +// setting up the CEL execution environment if the configuration is invalid. See +// the individual ValidatorOption for how they impact the fallibility of New. +// +// The returned value implements [ContextValidator], and therefore also +// [Validator]; assigning it to a Validator remains valid. +func New(options ...ValidatorOption) (ContextValidator, error) { cfg := config{ - extensionTypeResolver: protoregistry.GlobalTypes, - nowFn: timestamppb.Now, + extensionTypeResolver: protoregistry.GlobalTypes, + nowFn: timestamppb.Now, + interruptCheckFrequency: pvcel.DefaultInterruptCheckFrequency, } for _, opt := range options { opt.applyToValidator(&cfg) @@ -72,7 +103,9 @@ func New(options ...ValidatorOption) (Validator, error) { env, err := cel.NewEnv( cel.CustomTypeProvider(reg), cel.CustomTypeAdapter(reg), - cel.Lib(pvcel.NewLibrary()), + cel.Lib(pvcel.NewLibrary( + pvcel.WithInterruptCheckFrequency(cfg.interruptCheckFrequency), + )), ) if err != nil { return nil, fmt.Errorf( @@ -88,29 +121,66 @@ func New(options ...ValidatorOption) (Validator, error) { cfg.desc..., ) + baseCfg := &validationConfig{ + failFast: cfg.failFast, + filter: nopFilter{}, + nowFn: cfg.nowFn, + } + cancellableCfg := baseCfg.clone() + cancellableCfg.cancellable = true + return &validator{ - builder: bldr, - cfg: &validationConfig{ - failFast: cfg.failFast, - filter: nopFilter{}, - nowFn: cfg.nowFn, - }, + builder: bldr, + cfg: baseCfg, + cancellableCfg: cancellableCfg, }, nil } type validator struct { builder *builder - cfg *validationConfig + // cfg and cancellableCfg are identical but for the cancellable flag. Both + // are built once and treated as immutable, so a ValidateContext call with no + // ValidationOptions can select one without allocating a copy. + cfg *validationConfig + cancellableCfg *validationConfig } func (v *validator) Validate( msg proto.Message, options ...ValidationOption, +) error { + // Pass cancellable=false without consulting the context: a background + // context can never be cancelled, and probing it would add interface calls + // to the hot path. + return v.validate(context.Background(), false, msg, options...) +} + +func (v *validator) ValidateContext( + ctx context.Context, + msg proto.Message, + options ...ValidationOption, +) error { + // Check the context before anything else (including a nil msg) so that a + // cancelled context always yields its error, as documented. + if err := ctx.Err(); err != nil { + return err + } + return v.validate(ctx, ctx.Done() != nil, msg, options...) +} + +func (v *validator) validate( + ctx context.Context, + cancellable bool, + msg proto.Message, + options ...ValidationOption, ) error { if msg == nil { return nil } cfg := v.cfg + if cancellable { + cfg = v.cancellableCfg + } if len(options) > 0 { cfg = cfg.clone() for _, opt := range options { @@ -119,7 +189,7 @@ func (v *validator) Validate( } refl := msg.ProtoReflect() eval := v.builder.Load(refl.Descriptor()) - err := eval.EvaluateMessage(refl, cfg) + err := eval.EvaluateMessageContext(ctx, refl, cfg) finalizeViolationPaths(err) return err } @@ -129,27 +199,39 @@ func (v *validator) Validate( // function is safe and acceptable. If you need to provide i.e. a custom // ExtensionTypeResolver, you'll need to construct a Validator. func Validate(msg proto.Message, options ...ValidationOption) error { + return ValidateContext(context.Background(), msg, options...) +} + +// ValidateContext is the context-aware counterpart to Validate, using the +// global Validator instance. See [ContextValidator.ValidateContext]. +func ValidateContext(ctx context.Context, msg proto.Message, options ...ValidationOption) error { globalValidator, err := getGlobalValidator() if err != nil { return err } - return globalValidator.Validate(msg, options...) + return globalValidator.ValidateContext(ctx, msg, options...) } type config struct { - failFast bool - disableLazy bool - desc []protoreflect.MessageDescriptor - extensionTypeResolver protoregistry.ExtensionTypeResolver - allowUnknownFields bool - nowFn func() *timestamppb.Timestamp - disableNativeRules bool + failFast bool + disableLazy bool + desc []protoreflect.MessageDescriptor + extensionTypeResolver protoregistry.ExtensionTypeResolver + allowUnknownFields bool + nowFn func() *timestamppb.Timestamp + disableNativeRules bool + interruptCheckFrequency uint } type validationConfig struct { failFast bool filter Filter nowFn func() *timestamppb.Timestamp + // cancellable reports whether the context passed to ValidateContext can + // ever be cancelled (ctx.Done() != nil). When false, evaluation skips every + // per-node context check and cel-go's ContextEval, so the Validate path + // costs exactly what it did before context support existed. + cancellable bool } func (cfg *validationConfig) clone() *validationConfig { @@ -162,3 +244,7 @@ type globalValidator struct{} func (globalValidator) Validate(msg proto.Message, options ...ValidationOption) error { return Validate(msg, options...) } + +func (globalValidator) ValidateContext(ctx context.Context, msg proto.Message, options ...ValidationOption) error { + return ValidateContext(ctx, msg, options...) +} diff --git a/validator_bench_test.go b/validator_bench_test.go index 914811f..f98fa39 100644 --- a/validator_bench_test.go +++ b/validator_bench_test.go @@ -15,6 +15,8 @@ package protovalidate import ( + "context" + "fmt" "os" "strings" "testing" @@ -247,3 +249,111 @@ func benchSuccess(b *testing.B, msg proto.Message) { _ = val.Validate(msg) } } + +// newCrossReference builds a BenchCrossReference with n entries and n rules of +// n references each. Every reference resolves to the *last* entry, so the +// exists() macro scans the whole entry list and no short-circuit truncates the +// work: evaluating the message-level rule costs Theta(n^3) comprehension +// iterations. The message is valid, so validation runs to completion. +func newCrossReference(n int) *pb.BenchCrossReference { + entries := make([]*pb.BenchCrossReference_Entry, n) + for i := range n { + entries[i] = pb.BenchCrossReference_Entry_builder{ + Key: pb.BenchCrossReference_Key_builder{Kind: "k", Name: fmt.Sprintf("n-%d", i)}.Build(), + }.Build() + } + last := pb.BenchCrossReference_Key_builder{Kind: "k", Name: fmt.Sprintf("n-%d", n-1)}.Build() + rules := make([]*pb.BenchCrossReference_Rule, n) + for i := range n { + refs := make([]*pb.BenchCrossReference_Key, n) + for j := range n { + refs[j] = last + } + rules[i] = pb.BenchCrossReference_Rule_builder{Refs: refs}.Build() + } + return pb.BenchCrossReference_builder{Entries: entries, Rules: rules}.Build() +} + +// benchContextVariants compares the three evaluation paths on one message: +// +// - Validate: the legacy entry point, which delegates with context.Background() +// - Background: ValidateContext with a context that can never be cancelled +// - Cancellable: ValidateContext with a real cancellable context +// +// Validate and Background must match the pre-context baseline: because +// context.Background().Done() is nil, evalContext skips cel-go's ContextEval +// and its per-evaluation activation wrapping. Only Cancellable pays for +// cancellation support. +func benchContextVariants(b *testing.B, msg proto.Message) { + b.Helper() + options := []ValidatorOption{WithMessages(msg), WithDisableLazy()} + if strings.EqualFold(os.Getenv("DISABLE_NATIVE_RULES"), "true") { + options = append(options, WithDisableNativeRules()) + } + val, err := New(options...) + require.NoError(b, err) + + b.Run("Validate", func(b *testing.B) { + b.ReportAllocs() + for b.Loop() { + _ = val.Validate(msg) + } + }) + b.Run("Background", func(b *testing.B) { + ctx := context.Background() + b.ReportAllocs() + for b.Loop() { + _ = val.ValidateContext(ctx, msg) + } + }) + b.Run("Cancellable", func(b *testing.B) { + ctx := b.Context() // cancelled when the benchmark ends; Done() is non-nil + b.ReportAllocs() + for b.Loop() { + _ = val.ValidateContext(ctx, msg) + } + }) +} + +// BenchmarkContextOverhead quantifies what context support costs across message +// shapes exercising the different cancellation checkpoints. +func BenchmarkContextOverhead(b *testing.B) { + faked := func(msg proto.Message) proto.Message { + require.NoError(b, protogofakeit.New(gofakeit.New(1)).FakeProto(msg)) + return msg + } + b.Run("Scalar", func(b *testing.B) { + benchContextVariants(b, faked(&pb.BenchScalar{})) + }) + b.Run("RepeatedMessage", func(b *testing.B) { + benchContextVariants(b, faked(&pb.BenchRepeatedMessage{})) + }) + b.Run("Map", func(b *testing.B) { + benchContextVariants(b, faked(&pb.BenchMap{})) + }) + b.Run("ComplexSchema", func(b *testing.B) { + benchContextVariants(b, faked(&pb.BenchComplexSchema{})) + }) +} + +// BenchmarkInterruptCheckFrequency shows that the interrupt check frequency does +// not meaningfully affect throughput. cel-go calls checkInterrupt on every +// comprehension iteration regardless of the frequency; the frequency only gates +// a non-blocking channel poll. Raising it therefore trades cancellation latency +// away for no throughput gain, which is why the default is 1. +func BenchmarkInterruptCheckFrequency(b *testing.B) { + msg := newCrossReference(8) + for _, frequency := range []uint{0, 1, 10, 100} { + b.Run(fmt.Sprintf("freq=%d", frequency), func(b *testing.B) { + val, err := New(WithMessages(msg), WithDisableLazy(), + WithCELInterruptCheckFrequency(frequency)) + require.NoError(b, err) + ctx := b.Context() + b.ReportAllocs() + b.ResetTimer() + for b.Loop() { + _ = val.ValidateContext(ctx, msg) + } + }) + } +} diff --git a/validator_test.go b/validator_test.go index cf9b1d4..1ee10cd 100644 --- a/validator_test.go +++ b/validator_test.go @@ -15,9 +15,11 @@ package protovalidate import ( + "context" "testing" "time" + pvcel "buf.build/go/protovalidate/cel" pb "buf.build/go/protovalidate/internal/gen/tests/example/v1" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -632,3 +634,142 @@ func TestValidator_Validate_Issue296(t *testing.T) { err = val.Validate(msg) require.NoError(t, err) } + +// validateOnly implements only the Validator interface. It exists to guard the +// backward-compatibility contract: ValidateContext must stay off Validator, so +// external types implementing only Validate keep satisfying it. +type validateOnly struct{} + +func (validateOnly) Validate(proto.Message, ...ValidationOption) error { return nil } + +var ( + _ Validator = validateOnly{} + _ ContextValidator = (*validator)(nil) +) + +func TestValidator_ValidateContext_Cancelled(t *testing.T) { + t.Parallel() + val, err := New() + require.NoError(t, err) + ctx, cancel := context.WithCancel(context.Background()) + cancel() + err = val.ValidateContext(ctx, &pb.Person{}) + assert.ErrorIs(t, err, context.Canceled) +} + +// TestValidator_ValidateContext_CancelledNilMessage pins the check order: a +// cancelled context yields its error even when the message is nil, so callers +// using the error as a cancellation signal never miss it. +func TestValidator_ValidateContext_CancelledNilMessage(t *testing.T) { + t.Parallel() + val, err := New() + require.NoError(t, err) + ctx, cancel := context.WithCancel(context.Background()) + cancel() + err = val.ValidateContext(ctx, nil) + assert.ErrorIs(t, err, context.Canceled) +} + +func TestValidator_ValidateContext_DelegatesToValidate(t *testing.T) { + t.Parallel() + val, err := New() + require.NoError(t, err) + // A valid message returns nil through both entry points. + msg := &pb.Person{Id: 1, Email: "a@b.co", Name: "x", Home: &pb.Coordinates{}} + assert.Equal(t, + val.Validate(msg), + val.ValidateContext(context.Background(), msg), + ) +} + +func TestValidateContext_Global(t *testing.T) { + t.Parallel() + ctx, cancel := context.WithCancel(context.Background()) + cancel() + err := ValidateContext(ctx, &pb.Person{}) + assert.ErrorIs(t, err, context.Canceled) +} + +// TestValidateContext_InterruptsExpensiveCELExpression covers the motivating +// case: a single CEL rule whose cost grows multiplicatively with message size +// (see BenchCrossReference). Uncancelled it runs for seconds; a deadline must +// cut it short rather than waiting for it to finish. +func TestValidateContext_InterruptsExpensiveCELExpression(t *testing.T) { + t.Parallel() + msg := newCrossReference(150) // ~3s of CEL evaluation when uncancelled + val, err := New(WithMessages(msg), WithDisableLazy()) + require.NoError(t, err) + + ctx, cancel := context.WithTimeout(context.Background(), 50*time.Millisecond) + defer cancel() + + start := time.Now() + err = val.ValidateContext(ctx, msg) + elapsed := time.Since(start) + + require.ErrorIs(t, err, context.DeadlineExceeded) + assert.Less(t, elapsed, time.Second, + "the deadline must interrupt evaluation, not be noticed after it completes") +} + +// TestValidateContext_InterruptCheckFrequency documents why the default +// frequency is 1. cel-go polls ctx.Done() once every `frequency` comprehension +// iterations, so cancellation latency scales with the frequency, while the +// per-iteration bookkeeping happens either way. A coarse frequency therefore +// buys no throughput (see BenchmarkInterruptCheckFrequency) and only makes a +// deadline less meaningful. +func TestValidateContext_InterruptCheckFrequency(t *testing.T) { + t.Parallel() + if testing.Short() { + t.Skip("timing-sensitive; runs a multi-second CEL expression") + } + msg := newCrossReference(150) + + latency := func(frequency uint) time.Duration { + val, err := New(WithMessages(msg), WithDisableLazy(), + WithCELInterruptCheckFrequency(frequency)) + require.NoError(t, err) + ctx, cancel := context.WithTimeout(context.Background(), 50*time.Millisecond) + defer cancel() + start := time.Now() + err = val.ValidateContext(ctx, msg) + require.ErrorIs(t, err, context.DeadlineExceeded) + return time.Since(start) + } + + def := latency(pvcel.DefaultInterruptCheckFrequency) + coarse := latency(100) + + assert.Less(t, def, 500*time.Millisecond, + "the default frequency should honor the deadline promptly") + assert.Greater(t, coarse, 2*def, + "a coarser frequency should notice the deadline substantially later") +} + +// TestWithCELInterruptCheckFrequency_Disabled shows that a frequency of 0 turns +// interrupt checking off: validation still works, but CEL expressions run to +// completion and cannot be cancelled mid-evaluation. +func TestWithCELInterruptCheckFrequency_Disabled(t *testing.T) { + t.Parallel() + msg := newCrossReference(8) + val, err := New(WithMessages(msg), WithDisableLazy(), + WithCELInterruptCheckFrequency(0)) + require.NoError(t, err) + require.NoError(t, val.Validate(msg)) + require.NoError(t, val.ValidateContext(context.Background(), msg)) +} + +func TestValidator_ValidateContext_CancelMidTraversal(t *testing.T) { + t.Parallel() + val, err := New() + require.NoError(t, err) + ctx, cancel := context.WithCancel(context.Background()) + // Cancel as soon as traversal asks whether to validate anything. + filter := FilterFunc(func(protoreflect.Message, protoreflect.Descriptor) bool { + cancel() + return true + }) + msg := &pb.BenchRepeatedMessage{X: []*pb.BenchScalar{{}, {}}} + err = val.ValidateContext(ctx, msg, WithFilter(filter)) + assert.ErrorIs(t, err, context.Canceled) +} diff --git a/value.go b/value.go index 9a6b3fa..b94805c 100644 --- a/value.go +++ b/value.go @@ -15,6 +15,8 @@ package protovalidate import ( + "context" + "buf.build/gen/go/bufbuild/protovalidate/protocolbuffers/go/buf/validate" "google.golang.org/protobuf/reflect/protoreflect" ) @@ -40,10 +42,19 @@ type value struct { } func (v *value) Evaluate(msg protoreflect.Message, val protoreflect.Value, cfg *validationConfig) error { - return v.EvaluateField(msg, val, cfg, cfg.filter.ShouldValidate(msg, v.Descriptor)) + return v.EvaluateContext(context.Background(), msg, val, cfg) +} + +func (v *value) EvaluateContext(ctx context.Context, msg protoreflect.Message, val protoreflect.Value, cfg *validationConfig) error { + return v.EvaluateFieldContext(ctx, msg, val, cfg, cfg.filter.ShouldValidate(msg, v.Descriptor)) +} + +func (v *value) EvaluateField(msg protoreflect.Message, val protoreflect.Value, cfg *validationConfig, shouldValidate bool) error { + return v.EvaluateFieldContext(context.Background(), msg, val, cfg, shouldValidate) } -func (v *value) EvaluateField( +func (v *value) EvaluateFieldContext( + ctx context.Context, msg protoreflect.Message, val protoreflect.Value, cfg *validationConfig, @@ -57,11 +68,11 @@ func (v *value) EvaluateField( if v.IgnoreEmpty && val.Equal(v.Zero) { return nil } - if ok, err = mergeViolations(err, v.Rules.Evaluate(msg, val, cfg), cfg); !ok { + if ok, err = mergeViolations(err, v.Rules.EvaluateContext(ctx, msg, val, cfg), cfg); !ok { return err } } - _, err = mergeViolations(err, v.NestedRules.Evaluate(msg, val, cfg), cfg) + _, err = mergeViolations(err, v.NestedRules.EvaluateContext(ctx, msg, val, cfg), cfg) return err } diff --git a/wrapper.go b/wrapper.go index f64db8c..ce4aec9 100644 --- a/wrapper.go +++ b/wrapper.go @@ -14,7 +14,11 @@ package protovalidate -import "google.golang.org/protobuf/reflect/protoreflect" +import ( + "context" + + "google.golang.org/protobuf/reflect/protoreflect" +) // wrappedValueEval adapts a native evaluator built against a wrapper WKT's // inner scalar field (e.g. google.protobuf.Int32Value.value) so it can run @@ -32,7 +36,11 @@ type wrappedValueEval struct { } func (w wrappedValueEval) Evaluate(msg protoreflect.Message, val protoreflect.Value, cfg *validationConfig) error { - return w.inner.Evaluate(msg, val.Message().Get(w.innerField), cfg) + return w.EvaluateContext(context.Background(), msg, val, cfg) +} + +func (w wrappedValueEval) EvaluateContext(ctx context.Context, msg protoreflect.Message, val protoreflect.Value, cfg *validationConfig) error { + return w.inner.EvaluateContext(ctx, msg, val.Message().Get(w.innerField), cfg) } func (w wrappedValueEval) Tautology() bool {