Skip to content

perf(opa): cache prepared eval queries to avoid per-Eval PrepareForEval#448

Open
matiasinsaurralde wants to merge 1 commit into
boostsecurityio:mainfrom
matiasinsaurralde:perf/opa-prepared-query-cache
Open

perf(opa): cache prepared eval queries to avoid per-Eval PrepareForEval#448
matiasinsaurralde wants to merge 1 commit into
boostsecurityio:mainfrom
matiasinsaurralde:perf/opa-prepared-query-cache

Conversation

@matiasinsaurralde

Copy link
Copy Markdown
Contributor

Summary

Opa.Eval built a fresh rego.New(...).Eval() on every call, which internally re-runs PrepareForEval (query compile + evaluation-plan build) each time. That work is identical for a fixed (query, compiler), so it was pure repeated cost.

This caches one rego.PreparedEvalQuery per query string on Opa and evaluates with pq.Eval(ctx, rego.EvalInput(input)) so input stays per-call.

Where this repeats

AnalyzeOrg constructs one shared Opa and reuses it across the whole repo loop; every repo runs the same two query strings on it data.poutine.queries.inventory.result and data.poutine.queries.findings.result. So for an org of N repos, main re-prepares each of those identical query strings N times (2N prepares total) even though the compiler never changes after startup.

Verified with a simulated 50-repo scan: each query is prepared 50× on main vs once with this change.

Single-target scans (analyze_repo / analyze_local) run each query once, so they see no benefit — and no harm (one prepare either way). The win is org-scale scanning, poutine's primary use case.

Correctness

  • Input is passed per call via rego.EvalInput, never baked into the cached plan.
  • Config lives in the store and is read via a fresh transaction on every eval, so WithConfig needs no cache invalidation.
  • Compile() clears the cache because it swaps the compiler — and thus the rule set — that the cached plans are bound to.

Impact

Benchmarks: Apple M4 Pro, go test -benchmem -count=6, compared with benchstat.

The fixed prep cost that was removed. Measured on a trivial query, where evaluation time is negligible and the PrepareForEval overhead is the whole signal. This overhead was being re-paid on every Eval:

Metric Before After Reduction
Time 75.3 µs 6.3 µs −92% (12.0× faster)
Memory 48.4 KiB 7.3 KiB −85% (6.6× less)
Allocs 618 130 −79% (4.8× fewer)

What that means for the two hot production queries. These are dominated by rule evaluation, so removing the prep step leaves wall-clock unchanged but strips the prep step's allocations from every call — and every call happens once per repo:

Query Time Memory Allocs
inventory.result 913 µs → 915 µs (flat) 1.17 → 1.13 MiB (−3%) 16,447 → 15,990 (−457/call)
findings.result flat (see note) 6.71 → 6.73 MiB (flat) 130,046 → 129,590 (−456/call)

Over an org scan those per-call allocation savings recur 2N times (inventory + findings per repo), on top of eliminating the 2N redundant query compilations.

findings.result time is unchanged. One run showed +6%, but it reproduces as statistical noise (p=0.13, ±18% variance over ~100 iters/run); memory and allocs are flat-to-down. Not a regression — the win here is dropping the redundant per-package prep, not speeding up rule evaluation (the larger, separate cost).

Tests

Behavior is unchanged — the full snapshot suite (test/snapshot) passes. Adds invariant tests (opa/eval_prepared_test.go) for per-call input isolation, concurrent shared-Opa eval, and Compile() cache invalidation.

  • go test ./opa/ -race (includes eval_prepared_test.go)
  • go test ./scanner/ -race
  • go test ./formatters/... -race
  • make lint-branch (0 issues)
  • Snapshot regression suite (test/snapshot) — output identical
  • go test ./... — all packages pass

Eval built a fresh rego.New(...).Eval() on every call, which internally
re-runs PrepareForEval (query compile + evaluation-plan build) each time.
That work is identical for a fixed (query, compiler), so it was pure
repeated cost on the hot findings.result / inventory.result queries run
once per package.

Cache one rego.PreparedEvalQuery per query string on Opa and evaluate with
pq.Eval(ctx, rego.EvalInput(input)) so input stays per-call. Config lives in
the store and is read via a fresh transaction each eval, so WithConfig needs
no invalidation; Compile() clears the cache because it swaps the compiler
(and thus the rule set) the plans are bound to.

Removes the fixed prep overhead per Eval (~12x faster / ~6.6x less mem /
~4.8x fewer allocs on a trivial query; production queries keep their eval
wall-clock but shed ~460 allocs each). Adds invariant tests covering
per-input correctness, concurrent shared-Opa eval, and Compile() cache
invalidation.

Signed-off-by: Matías Insaurralde <matias@insaurral.de>
@matiasinsaurralde matiasinsaurralde requested a review from a team as a code owner July 9, 2026 16:26
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.

1 participant