Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,9 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).

### Changed
- Cache: switch to [otter](https://maypok86.github.io/otter/) as the primary cache implementation (https://github.com/authzed/spicedb/pull/3112)
- 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)
- Caveats: compiled caveats (and their CEL environments) are now cached per schema version rather than rebuilt on every check, reducing check cost for schemas with many caveats (https://github.com/authzed/spicedb/pull/3166)
- Datastore: schema-derived artifacts (e.g. compiled caveats, type systems) are now cached on the stored schema and share its lifetime, so they are rebuilt only when the schema changes (https://github.com/authzed/spicedb/pull/3166)

### Fixed
- The watching schema cache (`--enable-experimental-watchable-schema-cache`) no longer enters permanent fallback on transient watch errors. A new supervisor restarts the watch cycle with bounded exponential backoff and only treats caller-driven cancellation or unsupported-watch as terminal (https://github.com/authzed/spicedb/pull/3134)
Expand Down
44 changes: 40 additions & 4 deletions internal/caveats/run.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
"github.com/authzed/spicedb/internal/telemetry/otelconv"
"github.com/authzed/spicedb/pkg/caveats"
caveattypes "github.com/authzed/spicedb/pkg/caveats/types"
"github.com/authzed/spicedb/pkg/datalayer"
"github.com/authzed/spicedb/pkg/datastore"
"github.com/authzed/spicedb/pkg/genutil/mapz"
core "github.com/authzed/spicedb/pkg/proto/core/v1"
Expand Down Expand Up @@ -51,11 +52,24 @@
return runner.RunCaveatExpression(ctx, expr, context, reader, debugOption)
}

// cachedSchemaProvider is satisfied (structurally) by SchemaReaders backed by a unified
// stored schema. It exposes the shared, per-schema-version CachedSchema, which hosts
// schema-derived caches such as the compiled-caveat cache.
type cachedSchemaProvider interface {
CachedSchema() *datalayer.CachedSchema
}

// CaveatRunner is a helper for running caveats, providing a cache for deserialized caveats.
type CaveatRunner struct {
caveatTypeSet *caveattypes.TypeSet
caveatDefs map[string]*core.CaveatDefinition
deserializedCaveats map[string]*caveats.CompiledCaveat

// schemaCache, when non-nil, is a compiled-caveat cache tied to the stored schema
// (and thus shared across checks and invalidated on schema change). It is discovered
// from the reader on first use. When nil, deserializedCaveats provides per-runner
// caching only (the legacy behavior).
schemaCache *CompiledCaveatCache
}

// NewCaveatRunner creates a new CaveatRunner.
Expand Down Expand Up @@ -91,6 +105,17 @@
ctx, span := tracer.Start(ctx, "PopulateCaveatDefinitions")
defer span.End()

// If the reader is backed by a unified stored schema, use the compiled-caveat cache
// tied to that schema version so deserialization (which rebuilds the CEL environment)
// is paid once per schema rather than once per check.
if cr.schemaCache == nil {
if provider, ok := reader.(cachedSchemaProvider); ok {
if cached := provider.CachedSchema(); cached != nil {
cr.schemaCache = CompiledCaveatCacheFor(cached)
}
}
}

// Collect all referenced caveat definitions in the expression.
caveatNames := mapz.NewSet[string]()
collectCaveatNames(expr, caveatNames)
Expand Down Expand Up @@ -138,12 +163,23 @@
return caveat, deserialized, nil
}

parameterTypes, err := caveattypes.DecodeParameterTypes(cr.caveatTypeSet, caveat.ParameterTypes)
if err != nil {
return nil, nil, err
compile := func() (*caveats.CompiledCaveat, error) {
parameterTypes, err := caveattypes.DecodeParameterTypes(cr.caveatTypeSet, caveat.ParameterTypes)
if err != nil {
return nil, err
}

Check warning on line 170 in internal/caveats/run.go

View check run for this annotation

Codecov / codecov/patch

internal/caveats/run.go#L169-L170

Added lines #L169 - L170 were not covered by tests
return caveats.DeserializeCaveatWithTypeSet(cr.caveatTypeSet, caveat.SerializedExpression, parameterTypes)
}

justDeserialized, err := caveats.DeserializeCaveatWithTypeSet(cr.caveatTypeSet, caveat.SerializedExpression, parameterTypes)
// Prefer the schema-tied cache (shared across checks) when available; fall back to
// per-runner compilation otherwise.
var justDeserialized *caveats.CompiledCaveat
var err error
if cr.schemaCache != nil {
justDeserialized, err = cr.schemaCache.GetOrCompile(caveatDefName, compile)
} else {
justDeserialized, err = compile()
}
if err != nil {
return caveat, nil, err
}
Expand Down
49 changes: 49 additions & 0 deletions internal/caveats/schemacache.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
package caveats

import (
"sync"

"github.com/authzed/spicedb/pkg/caveats"
"github.com/authzed/spicedb/pkg/datalayer"
"github.com/authzed/spicedb/pkg/spiceerrors"
)

// compiledCaveatCacheKey identifies the schema-derived cache of compiled (deserialized)
// caveats. The cache is tied to a single stored-schema version (via datalayer.CachedSchema)
// and is discarded when the schema changes.
var compiledCaveatCacheKey = datalayer.NewDerivedCacheKey("caveats.compiled")

func init() {
if err := datalayer.RegisterDerivedCache(compiledCaveatCacheKey, func() any { return &CompiledCaveatCache{} }); err != nil {
spiceerrors.MustPanicf("failed to register compiled caveat cache: %v", err)
}

Check warning on line 19 in internal/caveats/schemacache.go

View check run for this annotation

Codecov / codecov/patch

internal/caveats/schemacache.go#L18-L19

Added lines #L18 - L19 were not covered by tests
Comment on lines +17 to +19

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Are we expecting to have many of these? Like do we need a registry, or can this be singleton?

}

// CompiledCaveatCache caches deserialized caveats (which embed a built CEL environment) by
// caveat name, for a single schema version. Deserializing a caveat rebuilds its CEL
// environment, which is expensive; caching it on the (shared) stored schema avoids paying
// that cost on every check.
type CompiledCaveatCache struct {
m sync.Map // map[string]*caveats.CompiledCaveat
}

// GetOrCompile returns the cached compiled caveat for name, or invokes compile and caches
// the result. compile is only called on a miss; concurrent misses may call compile more
// than once but only one result is retained.
func (c *CompiledCaveatCache) GetOrCompile(name string, compile func() (*caveats.CompiledCaveat, error)) (*caveats.CompiledCaveat, error) {
if v, ok := c.m.Load(name); ok {
return v.(*caveats.CompiledCaveat), nil
}
compiled, err := compile()
if err != nil {
return nil, err
}
actual, _ := c.m.LoadOrStore(name, compiled)
return actual.(*caveats.CompiledCaveat), nil
}

// CompiledCaveatCacheFor returns the compiled-caveat cache tied to the given cached schema,
// building it lazily on first access.
func CompiledCaveatCacheFor(s *datalayer.CachedSchema) *CompiledCaveatCache {
return datalayer.GetDerivedCache[*CompiledCaveatCache](s, compiledCaveatCacheKey)
}
46 changes: 46 additions & 0 deletions internal/caveats/schemacache_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
package caveats

import (
"errors"
"testing"

"github.com/stretchr/testify/require"

"github.com/authzed/spicedb/pkg/caveats"
caveattypes "github.com/authzed/spicedb/pkg/caveats/types"
)

func TestCompiledCaveatCacheGetOrCompile(t *testing.T) {
env := caveats.NewEnvironmentWithTypeSet(caveattypes.Default.TypeSet)
compiled, err := caveats.CompileCaveatWithName(env, "1 == 1", "test")
require.NoError(t, err)

c := &CompiledCaveatCache{}
calls := 0
compile := func() (*caveats.CompiledCaveat, error) {
calls++
return compiled, nil
}

// First access compiles; subsequent accesses return the cached instance without recompiling.
got1, err := c.GetOrCompile("a", compile)
require.NoError(t, err)
require.Same(t, compiled, got1)

got2, err := c.GetOrCompile("a", compile)
require.NoError(t, err)
require.Same(t, compiled, got2)
require.Equal(t, 1, calls, "compile should be invoked once per name")

// Distinct names compile independently.
_, err = c.GetOrCompile("b", compile)
require.NoError(t, err)
require.Equal(t, 2, calls)

// Errors propagate and are not cached.
boom := errors.New("boom")
_, err = c.GetOrCompile("c", func() (*caveats.CompiledCaveat, error) {
return nil, boom
})
require.ErrorIs(t, err, boom)
}
10 changes: 5 additions & 5 deletions internal/services/v1/permissions_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -337,7 +337,7 @@ func TestCheckPermissionSchemaLoadedOnce(t *testing.T) {
StreamingAPITimeout: 30 * time.Second,
DataLayerOpts: []datalayer.DataLayerOption{
datalayer.WithSchemaMode(datalayer.SchemaModeReadNewWriteBoth),
datalayer.WithSchemaCache(&simpleSchemaCache{items: make(map[datalayer.SchemaCacheKey]*datastore.ReadOnlyStoredSchema)}),
datalayer.WithSchemaCache(&simpleSchemaCache{items: make(map[datalayer.SchemaCacheKey]*datalayer.CachedSchema)}),
},
},
func(t testing.TB, ds datastore.Datastore) (datastore.Datastore, datastore.Revision) {
Expand Down Expand Up @@ -431,7 +431,7 @@ func TestCheckPermissionSchemaLoadedOnceMinLatency(t *testing.T) {
StreamingAPITimeout: 30 * time.Second,
DataLayerOpts: []datalayer.DataLayerOption{
datalayer.WithSchemaMode(datalayer.SchemaModeReadNewWriteBoth),
datalayer.WithSchemaCache(&simpleSchemaCache{items: make(map[datalayer.SchemaCacheKey]*datastore.ReadOnlyStoredSchema)}),
datalayer.WithSchemaCache(&simpleSchemaCache{items: make(map[datalayer.SchemaCacheKey]*datalayer.CachedSchema)}),
},
},
func(t testing.TB, ds datastore.Datastore) (datastore.Datastore, datastore.Revision) {
Expand Down Expand Up @@ -581,17 +581,17 @@ func (r *countingReader) ReadStoredSchema(ctx context.Context) (*datastore.ReadO
// simpleSchemaCache is a minimal SchemaCache for testing.
type simpleSchemaCache struct {
mu sync.Mutex
items map[datalayer.SchemaCacheKey]*datastore.ReadOnlyStoredSchema // GUARDED_BY(mu)
items map[datalayer.SchemaCacheKey]*datalayer.CachedSchema // GUARDED_BY(mu)
}

func (c *simpleSchemaCache) Get(key datalayer.SchemaCacheKey) (*datastore.ReadOnlyStoredSchema, bool) {
func (c *simpleSchemaCache) Get(key datalayer.SchemaCacheKey) (*datalayer.CachedSchema, bool) {
c.mu.Lock()
defer c.mu.Unlock()
v, ok := c.items[key]
return v, ok
}

func (c *simpleSchemaCache) Set(key datalayer.SchemaCacheKey, entry *datastore.ReadOnlyStoredSchema, _ int64) bool {
func (c *simpleSchemaCache) Set(key datalayer.SchemaCacheKey, entry *datalayer.CachedSchema, _ int64) bool {
c.mu.Lock()
defer c.mu.Unlock()
c.items[key] = entry
Expand Down
5 changes: 4 additions & 1 deletion pkg/cmd/datastore/datastore.go
Original file line number Diff line number Diff line change
Expand Up @@ -134,6 +134,9 @@ type Config struct {
BootstrapOverwrite bool `debugmap:"visible"`
BootstrapTimeout time.Duration `debugmap:"visible"`
CaveatTypeSet *caveattypes.TypeSet `debugmap:"hidden"`
// BootstrapSchemaMode controls the schema storage mode used when writing bootstrap
// data. The zero value (SchemaModeReadLegacyWriteLegacy) preserves prior behavior.
BootstrapSchemaMode datalayer.SchemaMode `debugmap:"visible"`

// Hedging
RequestHedgingEnabled bool `debugmap:"visible"`
Expand Down Expand Up @@ -482,7 +485,7 @@ func NewDatastore(ctx context.Context, options ...ConfigOption) (datastore.Datas
}

if len(bootstrapContents) > 0 {
bootstrapDL := datalayer.NewDataLayer(ds)
bootstrapDL := datalayer.NewDataLayer(ds, datalayer.WithSchemaMode(opts.BootstrapSchemaMode))
_, _, err = validationfile.PopulateFromFilesContents(ctx, bootstrapDL, opts.CaveatTypeSet, bootstrapContents)
if err != nil {
return nil, fmt.Errorf("failed to load bootstrap data: %w", err)
Expand Down
16 changes: 16 additions & 0 deletions pkg/cmd/datastore/zz_generated.options.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion pkg/cmd/server/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -228,7 +228,7 @@ func (c *Config) complete(ctx context.Context) (*completedServerConfig, error) {
cachingMode = schemacaching.WatchIfSupported
}

storedSchemaCache, err := CompleteCache[datalayer.SchemaCacheKey, *datastore.ReadOnlyStoredSchema](cacheRegisterer, &c.StoredSchemaCacheConfig)
storedSchemaCache, err := CompleteCache[datalayer.SchemaCacheKey, *datalayer.CachedSchema](cacheRegisterer, &c.StoredSchemaCacheConfig)
if err != nil {
return nil, fmt.Errorf("failed to create stored schema cache: %w", err)
}
Expand Down
16 changes: 8 additions & 8 deletions pkg/datalayer/datalayer_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -709,12 +709,12 @@ func TestStoredSchemaReaderAdapterEmptySchema(t *testing.T) {

// Test the storedSchemaReaderAdapter when schema is empty (no definitions).
adapter := &storedSchemaReaderAdapter{
storedSchema: datastore.NewReadOnlyStoredSchema(&core.StoredSchema{
cached: NewCachedSchema(datastore.NewReadOnlyStoredSchema(&core.StoredSchema{
Version: 1,
VersionOneof: &core.StoredSchema_V1{
V1: &core.StoredSchema_V1StoredSchema{},
},
}),
})),
lastWrittenRevision: datastore.NoRevision,
}

Expand Down Expand Up @@ -765,9 +765,9 @@ func TestStoredSchemaReaderAdapterV1Nil(t *testing.T) {

// Test the v1() fallback path when VersionOneof is nil.
adapter := &storedSchemaReaderAdapter{
storedSchema: datastore.NewReadOnlyStoredSchema(&core.StoredSchema{
cached: NewCachedSchema(datastore.NewReadOnlyStoredSchema(&core.StoredSchema{
Version: 1,
}),
})),
lastWrittenRevision: datastore.NoRevision,
}

Expand Down Expand Up @@ -882,21 +882,21 @@ func TestWriteSchemaDeletesRemovedDefinitions(t *testing.T) {
// testSchemaCache is a simple in-memory cache satisfying SchemaCache for tests.
type testSchemaCache struct {
mu sync.Mutex
items map[SchemaCacheKey]*datastore.ReadOnlyStoredSchema // GUARDED_BY(mu)
items map[SchemaCacheKey]*CachedSchema // GUARDED_BY(mu)
}

func newTestSchemaCache() *testSchemaCache {
return &testSchemaCache{items: make(map[SchemaCacheKey]*datastore.ReadOnlyStoredSchema)}
return &testSchemaCache{items: make(map[SchemaCacheKey]*CachedSchema)}
}

func (c *testSchemaCache) Get(key SchemaCacheKey) (*datastore.ReadOnlyStoredSchema, bool) {
func (c *testSchemaCache) Get(key SchemaCacheKey) (*CachedSchema, bool) {
c.mu.Lock()
defer c.mu.Unlock()
v, ok := c.items[key]
return v, ok
}

func (c *testSchemaCache) Set(key SchemaCacheKey, entry *datastore.ReadOnlyStoredSchema, _ int64) bool {
func (c *testSchemaCache) Set(key SchemaCacheKey, entry *CachedSchema, _ int64) bool {
c.mu.Lock()
defer c.mu.Unlock()
c.items[key] = entry
Expand Down
Loading
Loading