Skip to content

Commit 0c20d2e

Browse files
committed
feat(caveats): cache compiled caveats per schema version
ComputeCheck constructs a fresh CaveatRunner per call, so its per-runner deserialized-caveat cache never persists across checks. Deserializing a caveat rebuilds its CEL environment, which is expensive and, with a rich caveat type set, dominated check cost. Cache compiled caveats on the schema-derived cache (CachedSchema), keyed by caveat name, so the CEL environment is built once per schema version rather than once per check. Falls back to per-runner caching when the reader is not backed by a unified stored schema.
1 parent 7d262ac commit 0c20d2e

3 files changed

Lines changed: 135 additions & 4 deletions

File tree

internal/caveats/run.go

Lines changed: 40 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import (
1111
"github.com/authzed/spicedb/internal/telemetry/otelconv"
1212
"github.com/authzed/spicedb/pkg/caveats"
1313
caveattypes "github.com/authzed/spicedb/pkg/caveats/types"
14+
"github.com/authzed/spicedb/pkg/datalayer"
1415
"github.com/authzed/spicedb/pkg/datastore"
1516
"github.com/authzed/spicedb/pkg/genutil/mapz"
1617
core "github.com/authzed/spicedb/pkg/proto/core/v1"
@@ -51,11 +52,24 @@ func RunSingleCaveatExpression(
5152
return runner.RunCaveatExpression(ctx, expr, context, reader, debugOption)
5253
}
5354

55+
// cachedSchemaProvider is satisfied (structurally) by SchemaReaders backed by a unified
56+
// stored schema. It exposes the shared, per-schema-version CachedSchema, which hosts
57+
// schema-derived caches such as the compiled-caveat cache.
58+
type cachedSchemaProvider interface {
59+
CachedSchema() *datalayer.CachedSchema
60+
}
61+
5462
// CaveatRunner is a helper for running caveats, providing a cache for deserialized caveats.
5563
type CaveatRunner struct {
5664
caveatTypeSet *caveattypes.TypeSet
5765
caveatDefs map[string]*core.CaveatDefinition
5866
deserializedCaveats map[string]*caveats.CompiledCaveat
67+
68+
// schemaCache, when non-nil, is a compiled-caveat cache tied to the stored schema
69+
// (and thus shared across checks and invalidated on schema change). It is discovered
70+
// from the reader on first use. When nil, deserializedCaveats provides per-runner
71+
// caching only (the legacy behavior).
72+
schemaCache *CompiledCaveatCache
5973
}
6074

6175
// NewCaveatRunner creates a new CaveatRunner.
@@ -91,6 +105,17 @@ func (cr *CaveatRunner) PopulateCaveatDefinitionsForExpr(ctx context.Context, ex
91105
ctx, span := tracer.Start(ctx, "PopulateCaveatDefinitions")
92106
defer span.End()
93107

108+
// If the reader is backed by a unified stored schema, use the compiled-caveat cache
109+
// tied to that schema version so deserialization (which rebuilds the CEL environment)
110+
// is paid once per schema rather than once per check.
111+
if cr.schemaCache == nil {
112+
if provider, ok := reader.(cachedSchemaProvider); ok {
113+
if cached := provider.CachedSchema(); cached != nil {
114+
cr.schemaCache = CompiledCaveatCacheFor(cached)
115+
}
116+
}
117+
}
118+
94119
// Collect all referenced caveat definitions in the expression.
95120
caveatNames := mapz.NewSet[string]()
96121
collectCaveatNames(expr, caveatNames)
@@ -138,12 +163,23 @@ func (cr *CaveatRunner) get(caveatDefName string) (*core.CaveatDefinition, *cave
138163
return caveat, deserialized, nil
139164
}
140165

141-
parameterTypes, err := caveattypes.DecodeParameterTypes(cr.caveatTypeSet, caveat.ParameterTypes)
142-
if err != nil {
143-
return nil, nil, err
166+
compile := func() (*caveats.CompiledCaveat, error) {
167+
parameterTypes, err := caveattypes.DecodeParameterTypes(cr.caveatTypeSet, caveat.ParameterTypes)
168+
if err != nil {
169+
return nil, err
170+
}
171+
return caveats.DeserializeCaveatWithTypeSet(cr.caveatTypeSet, caveat.SerializedExpression, parameterTypes)
144172
}
145173

146-
justDeserialized, err := caveats.DeserializeCaveatWithTypeSet(cr.caveatTypeSet, caveat.SerializedExpression, parameterTypes)
174+
// Prefer the schema-tied cache (shared across checks) when available; fall back to
175+
// per-runner compilation otherwise.
176+
var justDeserialized *caveats.CompiledCaveat
177+
var err error
178+
if cr.schemaCache != nil {
179+
justDeserialized, err = cr.schemaCache.GetOrCompile(caveatDefName, compile)
180+
} else {
181+
justDeserialized, err = compile()
182+
}
147183
if err != nil {
148184
return caveat, nil, err
149185
}

internal/caveats/schemacache.go

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
package caveats
2+
3+
import (
4+
"sync"
5+
6+
"github.com/authzed/spicedb/pkg/caveats"
7+
"github.com/authzed/spicedb/pkg/datalayer"
8+
"github.com/authzed/spicedb/pkg/spiceerrors"
9+
)
10+
11+
// compiledCaveatCacheKey identifies the schema-derived cache of compiled (deserialized)
12+
// caveats. The cache is tied to a single stored-schema version (via datalayer.CachedSchema)
13+
// and is discarded when the schema changes.
14+
var compiledCaveatCacheKey = datalayer.NewDerivedCacheKey("caveats.compiled")
15+
16+
func init() {
17+
if err := datalayer.RegisterDerivedCache(compiledCaveatCacheKey, func() any { return &CompiledCaveatCache{} }); err != nil {
18+
spiceerrors.MustPanicf("failed to register compiled caveat cache: %v", err)
19+
}
20+
}
21+
22+
// CompiledCaveatCache caches deserialized caveats (which embed a built CEL environment) by
23+
// caveat name, for a single schema version. Deserializing a caveat rebuilds its CEL
24+
// environment, which is expensive; caching it on the (shared) stored schema avoids paying
25+
// that cost on every check.
26+
type CompiledCaveatCache struct {
27+
m sync.Map // map[string]*caveats.CompiledCaveat
28+
}
29+
30+
// GetOrCompile returns the cached compiled caveat for name, or invokes compile and caches
31+
// the result. compile is only called on a miss; concurrent misses may call compile more
32+
// than once but only one result is retained.
33+
func (c *CompiledCaveatCache) GetOrCompile(name string, compile func() (*caveats.CompiledCaveat, error)) (*caveats.CompiledCaveat, error) {
34+
if v, ok := c.m.Load(name); ok {
35+
return v.(*caveats.CompiledCaveat), nil
36+
}
37+
compiled, err := compile()
38+
if err != nil {
39+
return nil, err
40+
}
41+
actual, _ := c.m.LoadOrStore(name, compiled)
42+
return actual.(*caveats.CompiledCaveat), nil
43+
}
44+
45+
// CompiledCaveatCacheFor returns the compiled-caveat cache tied to the given cached schema,
46+
// building it lazily on first access.
47+
func CompiledCaveatCacheFor(s *datalayer.CachedSchema) *CompiledCaveatCache {
48+
return datalayer.GetDerivedCache[*CompiledCaveatCache](s, compiledCaveatCacheKey)
49+
}
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
package caveats
2+
3+
import (
4+
"errors"
5+
"testing"
6+
7+
"github.com/stretchr/testify/require"
8+
9+
"github.com/authzed/spicedb/pkg/caveats"
10+
caveattypes "github.com/authzed/spicedb/pkg/caveats/types"
11+
)
12+
13+
func TestCompiledCaveatCacheGetOrCompile(t *testing.T) {
14+
env := caveats.NewEnvironmentWithTypeSet(caveattypes.Default.TypeSet)
15+
compiled, err := caveats.CompileCaveatWithName(env, "1 == 1", "test")
16+
require.NoError(t, err)
17+
18+
c := &CompiledCaveatCache{}
19+
calls := 0
20+
compile := func() (*caveats.CompiledCaveat, error) {
21+
calls++
22+
return compiled, nil
23+
}
24+
25+
// First access compiles; subsequent accesses return the cached instance without recompiling.
26+
got1, err := c.GetOrCompile("a", compile)
27+
require.NoError(t, err)
28+
require.Same(t, compiled, got1)
29+
30+
got2, err := c.GetOrCompile("a", compile)
31+
require.NoError(t, err)
32+
require.Same(t, compiled, got2)
33+
require.Equal(t, 1, calls, "compile should be invoked once per name")
34+
35+
// Distinct names compile independently.
36+
_, err = c.GetOrCompile("b", compile)
37+
require.NoError(t, err)
38+
require.Equal(t, 2, calls)
39+
40+
// Errors propagate and are not cached.
41+
boom := errors.New("boom")
42+
_, err = c.GetOrCompile("c", func() (*caveats.CompiledCaveat, error) {
43+
return nil, boom
44+
})
45+
require.ErrorIs(t, err, boom)
46+
}

0 commit comments

Comments
 (0)