Skip to content

Commit da1a600

Browse files
perf(opa): cache prepared eval queries to avoid per-Eval PrepareForEval
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>
1 parent 2593420 commit da1a600

2 files changed

Lines changed: 203 additions & 9 deletions

File tree

opa/eval_prepared_test.go

Lines changed: 141 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,141 @@
1+
package opa
2+
3+
import (
4+
"context"
5+
"sync"
6+
"testing"
7+
8+
"github.com/boostsecurityio/poutine/models"
9+
"github.com/boostsecurityio/poutine/results"
10+
"github.com/stretchr/testify/assert"
11+
"github.com/stretchr/testify/require"
12+
)
13+
14+
// These tests pin the invariants that Eval's cached query plan must preserve:
15+
//
16+
// 1. The same query on the same Opa, evaluated with DIFFERENT inputs, must
17+
// return per-input-correct results — input must stay per-eval and must never
18+
// be baked into a cached plan.
19+
// 2. Concurrent Eval calls on a shared Opa (as AnalyzeOrg does across repo
20+
// goroutines) must be race-free and correct.
21+
// 3. Config changes (WithConfig) and rule-set changes (Compile) must be
22+
// reflected by subsequent Evals — a stale cached plan bound to the old
23+
// compiler would be a regression.
24+
25+
func TestEvalSameQueryDifferentInputs(t *testing.T) {
26+
o, err := NewOpa(context.TODO(), &models.Config{Include: []models.ConfigInclude{}})
27+
noOpaErrors(t, err)
28+
ctx := context.TODO()
29+
30+
// Reuse the exact same query string across calls, varying only the input.
31+
const q = `utils.job_uses_self_hosted_runner(input)`
32+
cases := map[string]bool{
33+
"self-hosted": true,
34+
"ubuntu-latest": false,
35+
"random-name": true,
36+
"windows-2022": false,
37+
}
38+
// Run each case twice, interleaved, to catch any input carried across calls.
39+
for pass := 0; pass < 2; pass++ {
40+
for runner, want := range cases {
41+
var got bool
42+
err := o.Eval(ctx, q, map[string]interface{}{"runs_on": []string{runner}}, &got)
43+
noOpaErrors(t, err)
44+
assert.Equal(t, want, got, "runner=%s pass=%d", runner, pass)
45+
}
46+
}
47+
}
48+
49+
func TestEvalConcurrentSharedOpa(t *testing.T) {
50+
o, err := NewOpa(context.TODO(), &models.Config{Include: []models.ConfigInclude{}})
51+
noOpaErrors(t, err)
52+
ctx := context.TODO()
53+
54+
const q = `utils.job_uses_self_hosted_runner(input)`
55+
type tc struct {
56+
runner string
57+
want bool
58+
}
59+
cases := []tc{
60+
{"self-hosted", true},
61+
{"ubuntu-latest", false},
62+
{"random-name", true},
63+
{"windows-2019", false},
64+
}
65+
66+
var wg sync.WaitGroup
67+
for i := 0; i < 64; i++ {
68+
c := cases[i%len(cases)]
69+
wg.Add(1)
70+
go func(c tc) {
71+
defer wg.Done()
72+
var got bool
73+
if err := o.Eval(ctx, q, map[string]interface{}{"runs_on": []string{c.runner}}, &got); err != nil {
74+
t.Errorf("eval error: %v", err)
75+
return
76+
}
77+
assert.Equal(t, c.want, got, "runner=%s", c.runner)
78+
}(c)
79+
}
80+
wg.Wait()
81+
}
82+
83+
func TestEvalReflectsRecompile(t *testing.T) {
84+
o, err := NewOpa(context.TODO(), &models.Config{Include: []models.ConfigInclude{}})
85+
noOpaErrors(t, err)
86+
ctx := context.TODO()
87+
88+
const q = `data.rules.pr_runs_on_self_hosted.rule`
89+
90+
var before *results.Rule
91+
err = o.Eval(ctx, q, nil, &before)
92+
noOpaErrors(t, err)
93+
require.NotNil(t, before)
94+
assert.Equal(t, []interface{}{}, before.Config["allowed_runners"].Value)
95+
96+
// Change config (which rewrites the store) and recompile the modules.
97+
err = o.WithConfig(ctx, &models.Config{
98+
RulesConfig: map[string]map[string]interface{}{
99+
"pr_runs_on_self_hosted": {"allowed_runners": []string{"self-hosted"}},
100+
},
101+
})
102+
require.NoError(t, err)
103+
require.NoError(t, o.Compile(ctx, nil, nil))
104+
105+
// The same query on the same Opa must now reflect the new config, not a
106+
// stale cached result/plan.
107+
var after *results.Rule
108+
err = o.Eval(ctx, q, nil, &after)
109+
noOpaErrors(t, err)
110+
require.NotNil(t, after)
111+
assert.Equal(t, []interface{}{"self-hosted"}, after.Config["allowed_runners"].Value)
112+
}
113+
114+
// TestEvalRecompileInvalidatesCache specifically guards the cache-invalidation in
115+
// Compile. Unlike TestEvalReflectsRecompile (whose config change is read from the
116+
// store on every eval and so would pass even without invalidation), this changes
117+
// the *rule set*: it primes the cache with a query against a rule, then recompiles
118+
// with that rule skipped. A cached plan bound to the old compiler would still
119+
// resolve the rule; a correctly invalidated cache re-prepares and the rule is
120+
// gone. This test FAILS if the invalidation in Compile is removed.
121+
func TestEvalRecompileInvalidatesCache(t *testing.T) {
122+
o, err := NewOpa(context.TODO(), &models.Config{Include: []models.ConfigInclude{}})
123+
noOpaErrors(t, err)
124+
ctx := context.TODO()
125+
126+
const q = `data.rules.pr_runs_on_self_hosted.rule`
127+
128+
// Prime the cache: the rule resolves.
129+
var before *results.Rule
130+
require.NoError(t, o.Eval(ctx, q, nil, &before))
131+
require.NotNil(t, before)
132+
133+
// Recompile with the rule skipped → new compiler, rule removed from the set.
134+
require.NoError(t, o.Compile(ctx, []string{"pr_runs_on_self_hosted"}, nil))
135+
136+
// With a correctly invalidated cache, the query now resolves to nothing and
137+
// Eval reports the empty result set. A stale plan would still succeed.
138+
var after *results.Rule
139+
err = o.Eval(ctx, q, nil, &after)
140+
require.Error(t, err, "skipped rule must not resolve after recompile; stale cache would still find it")
141+
}

opa/opa.go

Lines changed: 62 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import (
1010
"path/filepath"
1111
"slices"
1212
"strings"
13+
"sync"
1314

1415
"github.com/boostsecurityio/poutine/models"
1516
"github.com/open-policy-agent/opa/v1/ast"
@@ -37,6 +38,15 @@ type Opa struct {
3738
Store storage.Store
3839
LoadPaths []string
3940
customEmbeddedRules []embeddedSource
41+
42+
// preparedMu guards prepared. prepared caches one compiled query plan
43+
// (rego.PreparedEvalQuery) per query string so Eval does not re-run
44+
// PrepareForEval — the query compile + evaluation-plan build — on every call.
45+
// The cache is invalidated in Compile, which is the only thing that changes
46+
// the rule set the plans are bound to (config lives in the store and is read
47+
// fresh on every eval, so it needs no invalidation).
48+
preparedMu sync.RWMutex
49+
prepared map[string]rego.PreparedEvalQuery
4050
}
4151

4252
func NewOpa(ctx context.Context, config *models.Config) (*Opa, error) {
@@ -232,20 +242,26 @@ func (o *Opa) Compile(ctx context.Context, skip []string, allowed []string) erro
232242
}
233243

234244
o.Compiler = compiler
245+
246+
// Cached plans are bound to the previous compiler (and thus the previous rule
247+
// set); drop them so subsequent Evals re-prepare against the new compiler.
248+
o.preparedMu.Lock()
249+
o.prepared = nil
250+
o.preparedMu.Unlock()
251+
235252
return nil
236253
}
237254

238255
func (o *Opa) Eval(ctx context.Context, query string, input map[string]interface{}, result interface{}) error {
239-
regoInstance := rego.New(
240-
rego.Query(query),
241-
rego.Compiler(o.Compiler),
242-
rego.PrintHook(o),
243-
rego.Input(input),
244-
rego.Imports([]string{"data.poutine.utils"}),
245-
rego.Store(o.Store),
246-
)
256+
pq, err := o.preparedQuery(ctx, query)
257+
if err != nil {
258+
return err
259+
}
247260

248-
rs, err := regoInstance.Eval(ctx)
261+
// Input is supplied per call via EvalInput; the cached plan holds no input,
262+
// and each Eval opens a fresh store transaction, so config changes written by
263+
// WithConfig are observed here without invalidating the cache.
264+
rs, err := pq.Eval(ctx, rego.EvalInput(input))
249265
if err != nil {
250266
return err
251267
}
@@ -263,6 +279,43 @@ func (o *Opa) Eval(ctx context.Context, query string, input map[string]interface
263279
return json.Unmarshal(data, result)
264280
}
265281

282+
// preparedQuery returns the cached compiled plan for query, preparing (and
283+
// caching) it on first use. The compiled plan is bound to the current compiler
284+
// and is safe for concurrent evaluation; Compile clears the cache when it swaps
285+
// the compiler out.
286+
func (o *Opa) preparedQuery(ctx context.Context, query string) (rego.PreparedEvalQuery, error) {
287+
o.preparedMu.RLock()
288+
pq, ok := o.prepared[query]
289+
o.preparedMu.RUnlock()
290+
if ok {
291+
return pq, nil
292+
}
293+
294+
pq, err := rego.New(
295+
rego.Query(query),
296+
rego.Compiler(o.Compiler),
297+
rego.PrintHook(o),
298+
rego.Imports([]string{"data.poutine.utils"}),
299+
rego.Store(o.Store),
300+
).PrepareForEval(ctx)
301+
if err != nil {
302+
return rego.PreparedEvalQuery{}, fmt.Errorf("failed to prepare query: %w", err)
303+
}
304+
305+
o.preparedMu.Lock()
306+
defer o.preparedMu.Unlock()
307+
// Another goroutine may have prepared the same query while we were preparing;
308+
// prefer the already-cached one so all callers share a single plan.
309+
if existing, ok := o.prepared[query]; ok {
310+
return existing, nil
311+
}
312+
if o.prepared == nil {
313+
o.prepared = make(map[string]rego.PreparedEvalQuery)
314+
}
315+
o.prepared[query] = pq
316+
return pq, nil
317+
}
318+
266319
func Capabilities() (*ast.Capabilities, error) {
267320
capabilites := &ast.Capabilities{}
268321
err := json.Unmarshal(capabilitiesJson, capabilites)

0 commit comments

Comments
 (0)