Skip to content

Add ValidateContext for cancellation and timeout support#327

Open
tonobo wants to merge 1 commit into
bufbuild:mainfrom
tonobo:add-validate-context
Open

Add ValidateContext for cancellation and timeout support#327
tonobo wants to merge 1 commit into
bufbuild:mainfrom
tonobo:add-validate-context

Conversation

@tonobo

@tonobo tonobo commented Jul 2, 2026

Copy link
Copy Markdown

Add a context-aware ValidateContext(ctx, msg, options...) function alongside Validate() to let callers constrain lengthy or complex validations (e.g., CEL rule evaluation over large collections) using context cancellation or deadlines.

Content:

  • Add a ContextValidator interface (Validator plus ValidateContext). New now returns ContextValidator, so New() callers get ValidateContext directly while still satisfying Validator. This keeps the change backward compatible: the Validator interface is unchanged, so external implementations of Validator keep compiling.
  • Add the package-level ValidateContext helper and implement ValidateContext on the global validator. Validate() delegates to ValidateContext(context.Background(), ...), keeping a single code path.
  • Thread context through every evaluator via parallel *Context methods; the non-context methods delegate with context.Background(), so behavior is unchanged on the Validate() path.
  • Evaluate CEL through cel-go's ContextEval and enable InterruptCheckFrequency so a single long-running expression can be interrupted mid-evaluation.
  • Check ctx.Err() at message entry and in the repeated/map iteration loops so native validation of large messages also honors cancellation.
  • Return the raw context error (context.Canceled / DeadlineExceeded), never wrapped, so callers can match it with errors.Is.

@CLAassistant

CLAassistant commented Jul 2, 2026

Copy link
Copy Markdown

CLA assistant check
All committers have signed the CLA.

@tonobo

tonobo commented Jul 2, 2026

Copy link
Copy Markdown
Author

@pkwarren Would you like to give it a shot? 😄

@rodaine

rodaine commented Jul 9, 2026

Copy link
Copy Markdown
Member

Hi, @tonobo, thanks for the patch! I'd like to dig into your use-case here, could you share some details on the size of the message you're validating where cancellation is relevant? What type of validation rules are you using that are this CPU intensive?

It's cool to see that cel-go added support for context-aware execution, but I'm wary of foisting this behavior onto all users of this library when most messages are evaluated on the order of a microsecond. I'd love it if you could add benchmarks to show that this does not regress the default usage.

@rodaine rodaine self-assigned this Jul 9, 2026
@tonobo

tonobo commented Jul 9, 2026

Copy link
Copy Markdown
Author

Hey, great to hear someone is interested in this contribution!

First, the use case: imagine a global repository managing all contracts, so
besides the actual proto definitions there is also type validation in place.
As time goes on, types get more nested and more complicated. CEL rules start
iterating over objects, and one thing leads to another: inefficient rules get
introduced. Nothing actually prevents you from writing nested loops over
maps :D

To make this concrete, here is an example. A message holds a list of named sets plus several rule lists that reference
those sets by key, and cross-field CEL rules enforce referential integrity:

    message Firewall {
      option (buf.validate.message).cel = {
        id: "ipset_keys_unique"
        message: "ipset keys must be unique within this firewall"
        expression: "this.ipsets.map(s, string(s.key.type) + ':' + s.key.name).unique()"
      };

      // one of these exists per rule list (ingress/egress, several stages),
      // so this pattern repeats 4+ times on the same message
      option (buf.validate.message).cel = {
        id: "ingress_ipsets_exist"
        message: "all ingress rules must only reference ipsets which exist within this firewall"
        expression:
          "this.ingress.rules.all(rule, rule.remote_ipsets.all(key,"
          "this.ipsets.exists(ipset, ipset.key.type == key.type && ipset.key.name == key.name)))"
      };

      // ...
    }

Each of those rules is O(rules * refs_per_rule * ipsets), so validation cost
grows roughly cubically with message size, and there are several such rules
per message. Perfectly reasonable contracts to enforce, but on large messages
the evaluation time explodes.

CEL provides different options to tackle this. On one end, you can set
program-based cost limits. I considered that as well, but program compilation
happens at a different level, so it would have to be an option on the
initializer, not a per-validation option. That's why we decided to go the
other way around and address the problem from the other end: context
cancellation. AFAIR cel-go internally uses context.Background() anyway, the
main adjustment is the InterruptCheckFrequency() we attach to the evaluation.
I haven't fully dug into the cel-go internals, but it probably just adds a
periodic check and might cost a few extra cycles. My guess is the overhead is
minimal.

In conclusion: protovalidate is used to enforce contracts between parties,
and context cancellation brings more trust into "anonymous rules". Assume a
bad case: a message in the queue costs you 10s to validate, your workers are
busy validating protos all the time, and suddenly quadratic (or worse) CEL
expression costs are a real problem.

Regarding the benchmarks: fair point! I'll attach a benchmark test comparing
the default branch against this one.

Introduce a context-aware ValidateContext(ctx, msg, options...) alongside
the existing Validate(), so callers can bound long or expensive validation
with a context deadline or cancellation. The motivating case is complex
validation work such as CEL rules over large collections.

Content:
- Add a ContextValidator interface (Validator plus ValidateContext). New now
  returns ContextValidator, so New() callers get ValidateContext directly while
  still satisfying Validator. The change is backward compatible: the Validator
  interface is unchanged, so external implementations of it keep compiling.
- Add the package-level ValidateContext helper and implement ValidateContext on
  the global validator.
- Thread context through every evaluator via parallel *Context methods.
- Evaluate CEL through cel-go's ContextEval and enable InterruptCheckFrequency
  so a long-running expression can be interrupted mid-evaluation.
- Check ctx.Err() at message entry and in the repeated/map iteration loops so
  native validation of large messages also honors cancellation.
- Return the raw context error (context.Canceled / DeadlineExceeded), never
  wrapped, so callers can match it with errors.Is.

Keeping Validate() free of overhead:

  A context that can never be cancelled (context.Background) has a nil Done
  channel. ValidateContext detects this once per call and selects one of two
  validation configs built at New(); evaluation then skips every per-node
  ctx.Err() check and calls cel-go's Eval rather than ContextEval, whose
  activation wrapping would otherwise be paid on every expression. Validate()
  therefore takes the same path it did before context support existed, and
  neither entry point allocates to carry the flag.

Choosing the interrupt check frequency:

  cel-go only checks for cancellation inside comprehension loops (all, exists,
  map, filter), once per iteration, and never for expressions without a
  comprehension. The frequency does not skip that per-iteration bookkeeping; it
  only gates a cheap non-blocking poll of ctx.Done(). Raising it therefore buys
  no measurable throughput while making cancellation latency proportionally
  worse: on the BenchCrossReference fixture a 50ms deadline is honored in 50ms
  at frequency 1, but takes 2.2s at frequency 100. The default is therefore 1,
  and WithCELInterruptCheckFrequency lets callers tune or disable it (0).

Benchmarks:
- BenchmarkContextOverhead compares Validate, ValidateContext with a background
  context, and ValidateContext with a cancellable context.
- BenchmarkInterruptCheckFrequency shows throughput is flat across frequencies.
- BenchCrossReference is a new message whose CEL rule costs O(n^3), used to test
  that an expensive expression is actually interrupted by a deadline.
@tonobo tonobo force-pushed the add-validate-context branch from c9a35a6 to 0686058 Compare July 9, 2026 20:45
@tonobo

tonobo commented Jul 9, 2026

Copy link
Copy Markdown
Author
  • Validate no longer pays for context support: the validator now detects whether the context can be cancelled at all (ctx.Done() != nil). If
    not, it skips every ctx.Err() check and uses plain Eval instead of ContextEval, so the non-context path costs the same as before this PR.
    Benchmarks added (BenchmarkContextOverhead).

  • New BenchCrossReference test message with an expensive cross-referencing CEL rule, used to prove a deadline actually interrupts a
    long-running expression.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants