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
2 changes: 1 addition & 1 deletion cmd/migrate.go
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ func RunMigrations(logger *slog.Logger, config db.Config) error {
return errors.Wrap(err, "failed to connect to db")
}
metaschemaRepository := postgres.NewMetaSchemaRepository(logger, dbc)
metaschemaService := metaschema.NewService(metaschemaRepository)
metaschemaService := metaschema.NewService(metaschemaRepository, logger, 0)
if err = metaschemaService.MigrateDefault(context.Background()); err != nil {
return errors.Wrap(err, "failed to add default schemas to db")
}
Expand Down
14 changes: 9 additions & 5 deletions cmd/serve.go
Original file line number Diff line number Diff line change
Expand Up @@ -163,12 +163,16 @@ func StartServer(logger *slog.Logger, cfg *config.Frontier) error {
return err
}

// load metadata schema in memory from db
if schemas, err := deps.MetaSchemaService.List(context.Background()); err != nil {
// prime the metaschema cache and start its periodic refresh
if err := deps.MetaSchemaService.Init(ctx); err != nil {
logger.Warn("metaschemas initialization failed", "err", err)
} else {
logger.Info("metaschemas loaded", "count", len(schemas))
}
defer func() {
logger.Debug("cleaning up metaschemas")
if err := deps.MetaSchemaService.Close(); err != nil {
logger.Warn("metaschema service cleanup failed", "err", err)
}
}()

// apply schema
if err = deps.BootstrapService.MigrateSchema(ctx); err != nil {
Expand Down Expand Up @@ -489,7 +493,7 @@ func buildAPIDependencies(
domainService := domain.NewService(logger, domainRepository, userService, organizationService, membershipService)

metaschemaRepository := postgres.NewMetaSchemaRepository(logger, dbc)
metaschemaService := metaschema.NewService(metaschemaRepository)
metaschemaService := metaschema.NewService(metaschemaRepository, logger, cfg.App.Metaschema.RefreshInterval)

userPATService := userpat.NewService(logger, userPATRepo, cfg.App.PAT, organizationService, roleService, membershipService, projectService, auditRecordRepository)
membershipService.SetUserPATService(userPATService)
Expand Down
8 changes: 8 additions & 0 deletions config/sample.config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -204,6 +204,14 @@ app:
# this is used to validate the webhook payloads
encryption_key: "hash-secret-should-be-32-chars--"

# metaschema cache configuration
metaschema:
# how often each server reloads the metaschema cache from the database, so a
# change made on one server reaches the others. 0 disables the background
# refresh; the cache is still primed once at startup.
# e.g. 30s, 1m, 5m
refresh_interval: 1m

db:
driver: postgres
url: postgres://frontier:@localhost:5432/frontier?sslmode=disable
Expand Down
11 changes: 11 additions & 0 deletions core/metaschema/config.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
package metaschema

import "time"

// Config holds runtime configuration for the metaschema service.
type Config struct {
// RefreshInterval is how often each server reloads the metaschema cache from
// the database, so a change made on one pod reaches the others. A value of 0
// disables the background refresh; the cache is still primed once at startup.
RefreshInterval time.Duration `yaml:"refresh_interval" mapstructure:"refresh_interval" default:"1m"`
}
142 changes: 116 additions & 26 deletions core/metaschema/service.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,37 +2,56 @@ package metaschema

import (
"context"
"fmt"
"log/slog"
"sync"
"time"

"github.com/raystack/frontier/pkg/utils"

"github.com/pkg/errors"
"github.com/raystack/frontier/pkg/metadata"
"github.com/robfig/cron/v3"
"github.com/xeipuuv/gojsonschema"
)

type Service struct {
repository Repository
logger *slog.Logger
refreshInterval time.Duration

mu sync.RWMutex
metaSchemaCache map[string]MetaSchema

syncJob *cron.Cron
syncJobMu sync.Mutex
}

func NewService(repository Repository) *Service {
func NewService(repository Repository, logger *slog.Logger, refreshInterval time.Duration) *Service {
return &Service{
repository: repository,
logger: logger,
refreshInterval: refreshInterval,
metaSchemaCache: make(map[string]MetaSchema),
}
}

func (s Service) Create(ctx context.Context, toCreate MetaSchema) (MetaSchema, error) {
func (s *Service) Create(ctx context.Context, toCreate MetaSchema) (MetaSchema, error) {
mschema, err := s.repository.Create(ctx, toCreate)
if err != nil {
return MetaSchema{}, err
}
s.mu.Lock()
s.metaSchemaCache[mschema.Name] = mschema
s.mu.Unlock()
return mschema, nil
}

func (s Service) Get(ctx context.Context, idOrName string) (MetaSchema, error) {
if schema, ok := s.metaSchemaCache[idOrName]; ok {
func (s *Service) Get(ctx context.Context, idOrName string) (MetaSchema, error) {
s.mu.RLock()
schema, ok := s.metaSchemaCache[idOrName]
s.mu.RUnlock()
if ok {
return schema, nil
}

Expand All @@ -41,65 +60,60 @@ func (s Service) Get(ctx context.Context, idOrName string) (MetaSchema, error) {
if err != nil {
return MetaSchema{}, err
}

return schema, nil
}
return MetaSchema{}, ErrInvalidID
}

func (s Service) List(ctx context.Context) ([]MetaSchema, error) {
if len(s.metaSchemaCache) == 0 {
schemas, err := s.repository.List(ctx)
if err != nil {
return nil, err
}
for _, schema := range schemas {
s.metaSchemaCache[schema.Name] = schema
}
return schemas, nil
}

func (s *Service) List(ctx context.Context) ([]MetaSchema, error) {

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

List no longer falls back to the DB, so a failed prime disables validation silently.

The old List loaded from the DB whenever the cache was empty, so a transient DB failure at boot healed itself on the first request. Now List only reads the cache. If the priming reload in Init fails (a DB blip at startup), the cache stays empty, List returns an empty slice with no error, and Validate returns nil for every user, org, group, role and prospect create.

With refresh_interval=0 (the documented single-pod, local and test setting) this never recovers until a Create/Update. With the default 1m it recovers only after the next reload, and every validation in that window is skipped with no error surfaced.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Handled by the Init fix in 7201eb2 rather than by restoring the lazy read. The real risk here is a failed prime leaving validation off. Init now returns that error and boot fails, so the server never starts on an empty cache from a startup DB blip. Also note Validate reads the cache directly and never used List's old DB fallback, so bringing the fallback back would not have covered the Validate path. I kept List cache-only so the cache has a single owner. If you still want a lazy read for the ListMetaSchemas endpoint on its own, I can add it.

s.mu.RLock()
defer s.mu.RUnlock()
schemas := make([]MetaSchema, 0, len(s.metaSchemaCache))
for _, schema := range s.metaSchemaCache {
schemas = append(schemas, schema)
}
return schemas, nil
}

func (s Service) Update(ctx context.Context, id string, toUpdate MetaSchema) (MetaSchema, error) {
func (s *Service) Update(ctx context.Context, id string, toUpdate MetaSchema) (MetaSchema, error) {
if utils.IsValidUUID(id) {
schema, err := s.repository.Update(ctx, id, toUpdate)
if err != nil {
return MetaSchema{}, err
}
s.mu.Lock()
s.metaSchemaCache[schema.Name] = schema
s.mu.Unlock()
return schema, nil
}
return MetaSchema{}, ErrInvalidID
}

func (s Service) Delete(ctx context.Context, id string) error {
func (s *Service) Delete(ctx context.Context, id string) error {
if utils.IsValidUUID(id) {
name, err := s.repository.Delete(ctx, id)
if err != nil {
return err
}

s.mu.Lock()
delete(s.metaSchemaCache, name)
s.mu.Unlock()
return nil
}
return ErrInvalidID
}

func (s Service) MigrateDefault(ctx context.Context) error {
func (s *Service) MigrateDefault(ctx context.Context) error {
return s.repository.MigrateDefaults(ctx)
}

// Validate the metadata against the json-schema. In case metaschema doesn't exists in the cache, it will return nil (no validation)
func (s Service) Validate(mdata metadata.Metadata, name string) error {
var mschema MetaSchema
var ok bool
if mschema, ok = s.metaSchemaCache[name]; !ok {
// Validate checks the metadata against the json-schema. When the named
// metaschema is not in the cache it returns nil (no validation).
func (s *Service) Validate(mdata metadata.Metadata, name string) error {
s.mu.RLock()
mschema, ok := s.metaSchemaCache[name]
s.mu.RUnlock()
if !ok {
return nil
}

Expand All @@ -115,3 +129,79 @@ func (s Service) Validate(mdata metadata.Metadata, name string) error {
}
return nil
}

// reload replaces the cache with the current set of metaschemas from the
// database. It holds the write lock across the read and the swap, so a Create,
// Update, or Delete that runs concurrently applies to the cache after the swap
// and is never lost to a stale snapshot. On a database error it returns without
// touching the cache, and it refuses to swap an empty set over a populated
// cache, so neither a blip nor an unexpected empty read blanks validation. The
// caller decides what to do with the error: fail startup for the initial prime,
// log and keep the cache for a scheduled refresh.
func (s *Service) reload(ctx context.Context) error {
s.mu.Lock()
defer s.mu.Unlock()
schemas, err := s.repository.List(ctx)
Comment thread
rohilsurana marked this conversation as resolved.
if err != nil {
return err
}
if len(schemas) == 0 && len(s.metaSchemaCache) > 0 {
return fmt.Errorf("metaschema list returned no schemas, keeping the current cache of %d", len(s.metaSchemaCache))
}
fresh := make(map[string]MetaSchema, len(schemas))
for _, schema := range schemas {
fresh[schema.Name] = schema
}
s.metaSchemaCache = fresh
Comment thread
rohilsurana marked this conversation as resolved.
return nil
}

// Init primes the cache from the database and, when refreshInterval is greater
// than zero, starts a background job that reloads it on that interval so a
// change made on one pod reaches the others.
func (s *Service) Init(ctx context.Context) error {
// The initial prime must succeed. If it fails, startup stops here rather
// than serving with an empty cache, which would skip validation silently.
if err := s.reload(ctx); err != nil {
return fmt.Errorf("prime metaschema cache: %w", err)
}
s.mu.RLock()
count := len(s.metaSchemaCache)
s.mu.RUnlock()
s.logger.InfoContext(ctx, "metaschemas loaded", "count", count)
Comment thread
coderabbitai[bot] marked this conversation as resolved.

if s.refreshInterval <= 0 {
return nil
}

s.syncJobMu.Lock()
defer s.syncJobMu.Unlock()
if s.syncJob != nil {
<-s.syncJob.Stop().Done()
}
s.syncJob = cron.New(cron.WithChain(
cron.SkipIfStillRunning(cron.DefaultLogger),
cron.Recover(cron.DefaultLogger),
))
if _, err := s.syncJob.AddFunc(fmt.Sprintf("@every %s", s.refreshInterval.String()), func() {
// A scheduled refresh keeps the last good cache on error, so a database
// blip does not blank validation between successful reloads.
if err := s.reload(ctx); err != nil {
s.logger.WarnContext(ctx, "metaschema cache refresh failed", "err", err)
}
}); err != nil {
return err
}
s.syncJob.Start()
return nil
}

// Close stops the background refresh job. It is safe to call when none started.
func (s *Service) Close() error {
s.syncJobMu.Lock()
defer s.syncJobMu.Unlock()
if s.syncJob != nil {
<-s.syncJob.Stop().Done()
}
return nil
}
Loading
Loading