Skip to content

Commit 398abc0

Browse files
authored
feat: in-process embedded permissions and per-schema-version caching (#3166)
1 parent 2614340 commit 398abc0

21 files changed

Lines changed: 972 additions & 31 deletions

CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,8 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
88
- Schema: reads inside write transactions now use a cheap hash-only lookup (`schema_revision`) to check the cache before loading the full schema blob, reducing DB round-trips on cache hits (https://github.com/authzed/spicedb/pull/3160)
99
- Updated the Prometheus buckets for `grpc_server_handling_seconds` and `spicedb_datastore_query_latency` to be able to correlate them (https://github.com/authzed/spicedb/pull/3188)
1010
- Use `testcontainers` instead of `ory/dockertest` for running containers in integration tests (https://github.com/authzed/spicedb/pull/2782)
11+
- Embedded: add `pkg/embedded`, an in-process library for running permission checks against a datastore via the dispatch engine, without standing up a gRPC server (https://github.com/authzed/spicedb/pull/3166)
12+
- Caveats: compiled caveats (and their CEL environments) are now cached per schema version — hung off the stored schema (`ReadOnlyStoredSchema`) and rebuilt only when the schema changes — rather than rebuilt on every check, reducing check cost for schemas with many caveats (https://github.com/authzed/spicedb/pull/3166)
1113

1214
### Fixed
1315
- Fixed a nil pointer dereference panic in `CheckBulkPermissions` that could occur under concurrent load when a tracing-enabled check shared a singleflight dispatch with a non-tracing bulk check. Debug-enabled checks are no longer singleflighted together with non-debug checks. (https://github.com/authzed/spicedb/pull/3174)

internal/caveats/run.go

Lines changed: 43 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -51,11 +51,24 @@ func RunSingleCaveatExpression(
5151
return runner.RunCaveatExpression(ctx, expr, context, reader, debugOption)
5252
}
5353

54+
// cachedSchemaProvider is satisfied (structurally) by SchemaReaders backed by a unified
55+
// stored schema. It exposes the shared, per-schema-version stored schema, which hosts
56+
// schema-derived caches such as the compiled-caveat cache.
57+
type cachedSchemaProvider interface {
58+
StoredSchema() *datastore.ReadOnlyStoredSchema
59+
}
60+
5461
// CaveatRunner is a helper for running caveats, providing a cache for deserialized caveats.
5562
type CaveatRunner struct {
5663
caveatTypeSet *caveattypes.TypeSet
5764
caveatDefs map[string]*core.CaveatDefinition
5865
deserializedCaveats map[string]*caveats.CompiledCaveat
66+
67+
// compiledCaveatCache, when non-nil, is a compiled-caveat cache tied to the stored schema
68+
// (and thus shared across checks and invalidated on schema change). It is discovered
69+
// from the reader on first use. When nil, deserializedCaveats provides per-runner
70+
// caching only (the legacy behavior).
71+
compiledCaveatCache *CompiledCaveatCache
5972
}
6073

6174
// NewCaveatRunner creates a new CaveatRunner.
@@ -91,6 +104,21 @@ func (cr *CaveatRunner) PopulateCaveatDefinitionsForExpr(ctx context.Context, ex
91104
ctx, span := tracer.Start(ctx, "PopulateCaveatDefinitions")
92105
defer span.End()
93106

107+
// If the reader is backed by a unified stored schema, use the compiled-caveat cache
108+
// tied to that schema version so deserialization (which rebuilds the CEL environment)
109+
// is paid once per schema rather than once per check.
110+
if cr.compiledCaveatCache == nil {
111+
if provider, ok := reader.(cachedSchemaProvider); ok {
112+
if stored := provider.StoredSchema(); stored != nil {
113+
compiledCaveatCache, err := CompiledCaveatCacheFor(stored)
114+
if err != nil {
115+
return err
116+
}
117+
cr.compiledCaveatCache = compiledCaveatCache
118+
}
119+
}
120+
}
121+
94122
// Collect all referenced caveat definitions in the expression.
95123
caveatNames := mapz.NewSet[string]()
96124
collectCaveatNames(expr, caveatNames)
@@ -138,12 +166,23 @@ func (cr *CaveatRunner) get(caveatDefName string) (*core.CaveatDefinition, *cave
138166
return caveat, deserialized, nil
139167
}
140168

141-
parameterTypes, err := caveattypes.DecodeParameterTypes(cr.caveatTypeSet, caveat.ParameterTypes)
142-
if err != nil {
143-
return nil, nil, err
169+
compile := func() (*caveats.CompiledCaveat, error) {
170+
parameterTypes, err := caveattypes.DecodeParameterTypes(cr.caveatTypeSet, caveat.ParameterTypes)
171+
if err != nil {
172+
return nil, err
173+
}
174+
return caveats.DeserializeCaveatWithTypeSet(cr.caveatTypeSet, caveat.SerializedExpression, parameterTypes)
144175
}
145176

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

internal/caveats/run_test.go

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -731,6 +731,70 @@ func TestCaveatRunnerPopulateCaveatDefinitionsForExpr(t *testing.T) {
731731
req.True(result.IsPartial())
732732
}
733733

734+
// storedSchemaCaveatReader wraps a CaveatDefinitionLookup so it also advertises a stored
735+
// schema. That is what makes CaveatRunner discover and use the schema-tied compiled-caveat
736+
// cache (the cachedSchemaProvider path) rather than per-runner compilation.
737+
type storedSchemaCaveatReader struct {
738+
CaveatDefinitionLookup
739+
stored *datastore.ReadOnlyStoredSchema
740+
}
741+
742+
func (r storedSchemaCaveatReader) StoredSchema() *datastore.ReadOnlyStoredSchema {
743+
return r.stored
744+
}
745+
746+
func TestCaveatRunnerUsesSchemaTiedCompiledCaveatCache(t *testing.T) {
747+
req := require.New(t)
748+
749+
rawDS, err := dsfortesting.NewMemDBDatastoreForTesting(t, 0, 0, memdb.DisableGC)
750+
req.NoError(err)
751+
752+
ds, _ := testfixtures.DatastoreFromSchemaAndTestRelationships(t, rawDS, `
753+
caveat first_caveat(firstparam int) {
754+
firstparam == 42
755+
}
756+
`, nil)
757+
758+
headRevisionResult, err := ds.HeadRevision(t.Context())
759+
req.NoError(err)
760+
761+
dl := datalayer.NewDataLayer(ds)
762+
sr, err := dl.SnapshotReader(headRevisionResult.Revision, datalayer.NoSchemaHashForTesting).ReadSchema(t.Context())
763+
req.NoError(err)
764+
765+
// A stored schema is what a unified-schema reader exposes; wrapping the real reader with
766+
// one drives the schema-tied cache path.
767+
stored := datastore.NewReadOnlyStoredSchema(&core.StoredSchema{
768+
VersionOneof: &core.StoredSchema_V1{
769+
V1: &core.StoredSchema_V1StoredSchema{SchemaText: "caveat first_caveat(firstparam int) { firstparam == 42 }"},
770+
},
771+
})
772+
reader := storedSchemaCaveatReader{CaveatDefinitionLookup: sr, stored: stored}
773+
774+
runner := NewCaveatRunner(types.Default.TypeSet)
775+
expr := caveatexpr("first_caveat")
776+
777+
result, err := runner.RunCaveatExpression(t.Context(), expr,
778+
map[string]any{"firstparam": int64(42)}, reader, RunCaveatExpressionNoDebugging)
779+
req.NoError(err)
780+
req.True(result.Value())
781+
782+
// The runner discovered the schema-tied cache...
783+
req.NotNil(runner.compiledCaveatCache)
784+
shared, err := CompiledCaveatCacheFor(stored)
785+
req.NoError(err)
786+
req.Same(runner.compiledCaveatCache, shared) // ...and it is THE cache hung off that schema.
787+
788+
// ...and the run populated it: the compiled caveat is now cached, so GetOrCompile
789+
// returns it without recompiling (proving the cache is actually used across checks).
790+
got, err := shared.GetOrCompile("first_caveat", func() (*pkgcaveats.CompiledCaveat, error) {
791+
t.Fatal("compile should not be called; first_caveat should already be cached")
792+
return nil, nil
793+
})
794+
req.NoError(err)
795+
req.NotNil(got)
796+
}
797+
734798
func TestCaveatRunnerEmptyExpression(t *testing.T) {
735799
req := require.New(t)
736800

internal/caveats/schemacache.go

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
package caveats
2+
3+
import (
4+
"sync"
5+
6+
"github.com/authzed/spicedb/pkg/caveats"
7+
"github.com/authzed/spicedb/pkg/datastore"
8+
)
9+
10+
// compiledCaveatCacheKey identifies the schema-derived cache of compiled (deserialized) caveats
11+
// hung off a datastore.ReadOnlyStoredSchema. The cache is tied to a single stored-schema version
12+
// and is discarded when the schema changes.
13+
var compiledCaveatCacheKey = datastore.NewDerivedCacheKey[*CompiledCaveatCache]()
14+
15+
// CompiledCaveatCache caches deserialized caveats (which embed a built CEL environment) by
16+
// caveat name, for a single schema version. Deserializing a caveat rebuilds its CEL
17+
// environment, which is expensive; caching it on the (shared) stored schema avoids paying
18+
// that cost on every check.
19+
type CompiledCaveatCache struct {
20+
m sync.Map // map[string]*caveats.CompiledCaveat
21+
}
22+
23+
// GetOrCompile returns the cached compiled caveat for name, or invokes compile and caches
24+
// the result. compile is only called on a miss; concurrent misses may call compile more
25+
// than once but only one result is retained.
26+
func (c *CompiledCaveatCache) GetOrCompile(name string, compile func() (*caveats.CompiledCaveat, error)) (*caveats.CompiledCaveat, error) {
27+
if v, ok := c.m.Load(name); ok {
28+
return v.(*caveats.CompiledCaveat), nil
29+
}
30+
compiled, err := compile()
31+
if err != nil {
32+
return nil, err
33+
}
34+
actual, _ := c.m.LoadOrStore(name, compiled)
35+
return actual.(*caveats.CompiledCaveat), nil
36+
}
37+
38+
// CompiledCaveatCacheFor returns the compiled-caveat cache tied to the given stored schema,
39+
// building it lazily on first access.
40+
func CompiledCaveatCacheFor(s *datastore.ReadOnlyStoredSchema) (*CompiledCaveatCache, error) {
41+
return datastore.LoadOrStoreDerived(s, compiledCaveatCacheKey, func() *CompiledCaveatCache {
42+
return &CompiledCaveatCache{}
43+
})
44+
}
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+
}

internal/datastore/common/sqlschema.go

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -59,7 +59,8 @@ func (s *SQLSingleStoreSchemaReaderWriter[T]) ReadStoredSchema(ctx context.Conte
5959
return nil, fmt.Errorf("failed to unmarshal schema: %w", err)
6060
}
6161

62-
return datastore.NewReadOnlyStoredSchema(storedSchema), nil
62+
// len(data) is the exact serialized size, used as the rough schema-size base for cache cost.
63+
return datastore.NewReadOnlyStoredSchemaWithSize(storedSchema, len(data)), nil
6364
}
6465

6566
// WriteStoredSchema writes the stored schema to the unified schema table.

internal/datastore/memdb/storedschema.go

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,8 @@ func (r *memdbReader) ReadStoredSchema(_ context.Context) (*datastore.ReadOnlySt
3939
return nil, fmt.Errorf("failed to unmarshal schema: %w", err)
4040
}
4141

42-
return datastore.NewReadOnlyStoredSchema(storedSchema), nil
42+
// len(sd.data) is the exact serialized size, used as the rough schema-size base for cache cost.
43+
return datastore.NewReadOnlyStoredSchemaWithSize(storedSchema, len(sd.data)), nil
4344
}
4445

4546
// assertSchemaHash verifies the stored schema hash matches expectedHash.

pkg/cmd/datastore/datastore.go

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -138,6 +138,9 @@ type Config struct {
138138
BootstrapOverwrite bool `debugmap:"visible"`
139139
BootstrapTimeout time.Duration `debugmap:"visible" default:"10s"`
140140
CaveatTypeSet *caveattypes.TypeSet `debugmap:"hidden"`
141+
// BootstrapSchemaMode controls the schema storage mode used when writing bootstrap
142+
// data. The zero value (SchemaModeReadLegacyWriteLegacy) preserves prior behavior.
143+
BootstrapSchemaMode datalayer.SchemaMode `debugmap:"visible"`
141144

142145
// Hedging
143146
RequestHedgingEnabled bool `debugmap:"visible"`
@@ -529,7 +532,7 @@ func NewDatastore(ctx context.Context, options ...ConfigOption) (datastore.Datas
529532
}
530533

531534
if len(bootstrapContents) > 0 {
532-
bootstrapDL := datalayer.NewDataLayer(ds)
535+
bootstrapDL := datalayer.NewDataLayer(ds, datalayer.WithSchemaMode(opts.BootstrapSchemaMode))
533536
_, _, err = validationfile.PopulateFromFilesContents(ctx, bootstrapDL, opts.CaveatTypeSet, bootstrapContents)
534537
if err != nil {
535538
return nil, fmt.Errorf("failed to load bootstrap data: %w", err)

pkg/cmd/datastore/zz_generated.options.go

Lines changed: 16 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

pkg/datalayer/hashcache.go

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -113,7 +113,14 @@ func (c *schemaHashCache) Set(schemaHash SchemaHash, schema *datastore.ReadOnlyS
113113
schema: schema,
114114
})
115115

116-
c.cache.Set(SchemaCacheKey(schemaHash), schema, int64(schema.EstimatedSize()))
116+
// Cost the entry by the schema's estimated byte size (schema blob plus the schema-derived
117+
// caches it will accrete), so the cache's max-cost budget is in bytes. Floor at 1 so an
118+
// (effectively empty) schema is still admitted with a non-zero weight.
119+
cost := schema.EstimatedSize()
120+
if cost < 1 {
121+
cost = 1
122+
}
123+
c.cache.Set(SchemaCacheKey(schemaHash), schema, cost)
117124
return nil
118125
}
119126

0 commit comments

Comments
 (0)