From 5d086c13caac16316c8a17702ee555dee698dccb Mon Sep 17 00:00:00 2001 From: Eric Rakestraw Date: Sun, 9 Aug 2026 01:38:42 +0000 Subject: [PATCH] Cache compiled JSON schemas per validator --- .../validate/valid_json_schema/validator.go | 93 ++++++++++++++---- .../valid_json_schema/validator_test.go | 98 +++++++++++++++++++ 2 files changed, 172 insertions(+), 19 deletions(-) diff --git a/internal/modules/generic/validate/valid_json_schema/validator.go b/internal/modules/generic/validate/valid_json_schema/validator.go index 30e5d13..7b5b58f 100644 --- a/internal/modules/generic/validate/valid_json_schema/validator.go +++ b/internal/modules/generic/validate/valid_json_schema/validator.go @@ -4,6 +4,7 @@ import ( "bytes" "context" "fmt" + "sync" "github.com/santhosh-tekuri/jsonschema/v6" @@ -16,39 +17,50 @@ const ReasonCodeInvalidJSON = "invalid_json" const ReasonCodeSchemaInvalid = "json_schema_invalid" type Options struct{} -type Validator struct{} + +type schemaIdentity struct { + id string + name string + version string + digest string +} + +type compiledSchema struct { + schema *jsonschema.Schema + err error +} + +type schemaCompiler func([]byte) (*jsonschema.Schema, error) + +type Validator struct { + mu sync.Mutex + schemas map[schemaIdentity]compiledSchema + compile schemaCompiler +} var _ contracts.SerializedValidator = (*Validator)(nil) -func New(Options) *Validator { return &Validator{} } +func New(Options) *Validator { + return &Validator{schemas: make(map[schemaIdentity]compiledSchema), compile: compileSchema} +} + func (v *Validator) Name() string { return Key } func (v *Validator) ExecutionClass() contracts.ExecutionClass { return contracts.ExecutionClassDeterministic } func (v *Validator) Validate(_ context.Context, req contracts.SerializedValidationRequest) (contracts.ValidationResult, error) { - return validate(req.Content, req.Schema.JSONSchema) -} - -func validate(content, schemaContent []byte) (contracts.ValidationResult, error) { - if len(schemaContent) == 0 { - return contracts.ValidationResult{}, fmt.Errorf("response schema content is not available") + if len(req.Schema.JSONSchema) == 0 { + _, err := v.compiledSchema(req.Schema) + return contracts.ValidationResult{}, err } - instance, err := jsonschema.UnmarshalJSON(bytes.NewReader(content)) + instance, err := jsonschema.UnmarshalJSON(bytes.NewReader(req.Content)) if err != nil { return contracts.ValidationResult{Approved: false, ReasonCode: ReasonCodeInvalidJSON, Message: "payload is not valid JSON"}, nil } - schemaDocument, err := jsonschema.UnmarshalJSON(bytes.NewReader(schemaContent)) + schema, err := v.compiledSchema(req.Schema) if err != nil { - return contracts.ValidationResult{}, fmt.Errorf("parse response schema: %w", err) - } - compiler := jsonschema.NewCompiler() - if err := compiler.AddResource("schema.json", schemaDocument); err != nil { - return contracts.ValidationResult{}, fmt.Errorf("load response schema: %w", err) - } - schema, err := compiler.Compile("schema.json") - if err != nil { - return contracts.ValidationResult{}, fmt.Errorf("compile response schema: %w", err) + return contracts.ValidationResult{}, err } if err := schema.Validate(instance); err != nil { return contracts.ValidationResult{Approved: false, ReasonCode: ReasonCodeSchemaInvalid, Message: "payload does not conform to response schema"}, nil @@ -56,6 +68,49 @@ func validate(content, schemaContent []byte) (contracts.ValidationResult, error) return contracts.ValidationResult{Approved: true}, nil } +func (v *Validator) compiledSchema(schema contracts.ArtifactSchema) (*jsonschema.Schema, error) { + identity := schemaIdentity{ + id: schema.ID, + name: schema.Name, + version: schema.Version, + digest: contracts.DigestArtifactSchema(schema), + } + v.mu.Lock() + defer v.mu.Unlock() + if v.schemas == nil { + v.schemas = make(map[schemaIdentity]compiledSchema) + } + if cached, ok := v.schemas[identity]; ok { + return cached.schema, cached.err + } + compiler := v.compile + if compiler == nil { + compiler = compileSchema + } + compiled, err := compiler(schema.JSONSchema) + v.schemas[identity] = compiledSchema{schema: compiled, err: err} + return compiled, err +} + +func compileSchema(schemaContent []byte) (*jsonschema.Schema, error) { + if len(schemaContent) == 0 { + return nil, fmt.Errorf("response schema content is not available") + } + schemaDocument, err := jsonschema.UnmarshalJSON(bytes.NewReader(schemaContent)) + if err != nil { + return nil, fmt.Errorf("parse response schema: %w", err) + } + compiler := jsonschema.NewCompiler() + if err := compiler.AddResource("schema.json", schemaDocument); err != nil { + return nil, fmt.Errorf("load response schema: %w", err) + } + schema, err := compiler.Compile("schema.json") + if err != nil { + return nil, fmt.Errorf("compile response schema: %w", err) + } + return schema, nil +} + func Spec() pipeline.ValidatorSpec { return pipeline.ValidatorSpec{Key: Key, ExecutionClass: contracts.ExecutionClassDeterministic} } diff --git a/internal/modules/generic/validate/valid_json_schema/validator_test.go b/internal/modules/generic/validate/valid_json_schema/validator_test.go index 63f916d..ee43eb2 100644 --- a/internal/modules/generic/validate/valid_json_schema/validator_test.go +++ b/internal/modules/generic/validate/valid_json_schema/validator_test.go @@ -2,9 +2,14 @@ package validjsonschema import ( "context" + "fmt" "strings" + "sync" + "sync/atomic" "testing" + "github.com/santhosh-tekuri/jsonschema/v6" + "gitea.maximumdirect.net/eric/notarius/internal/framework/contracts" "gitea.maximumdirect.net/eric/notarius/internal/framework/pipeline" ) @@ -65,6 +70,93 @@ func TestValidatorErrorsWhenSchemaContentIsMalformed(t *testing.T) { } } +func TestValidatorCachesEachSchemaIdentity(t *testing.T) { + validator := New(Options{}) + var compilations atomic.Int32 + validator.compile = func(content []byte) (*jsonschema.Schema, error) { + compilations.Add(1) + return compileSchema(content) + } + + request := requestWithSchema(`{"name":"Aria"}`, objectSchema()) + requests := []contracts.SerializedValidationRequest{ + request, + withSchema(request, func(schema *contracts.ArtifactSchema) { schema.JSONSchema = append(schema.JSONSchema, ' ') }), + withSchema(request, func(schema *contracts.ArtifactSchema) { schema.ID = "test.schema.other" }), + withSchema(request, func(schema *contracts.ArtifactSchema) { schema.Name = "test_schema_other" }), + withSchema(request, func(schema *contracts.ArtifactSchema) { schema.Version = "v2" }), + } + for index, candidate := range requests { + for attempt := 0; attempt < 2; attempt++ { + result, err := validator.Validate(context.Background(), candidate) + if err != nil || !result.Approved { + t.Fatalf("Validate(request %d, attempt %d) = %#v, %v", index, attempt, result, err) + } + } + } + if got, want := compilations.Load(), int32(len(requests)); got != want { + t.Fatalf("schema compilations = %d, want %d", got, want) + } +} + +func TestValidatorCompilesConcurrentSchemaOnce(t *testing.T) { + validator := New(Options{}) + var compilations atomic.Int32 + validator.compile = func(content []byte) (*jsonschema.Schema, error) { + compilations.Add(1) + return compileSchema(content) + } + request := requestWithSchema(`{"name":"Aria"}`, objectSchema()) + + const validators = 32 + var group sync.WaitGroup + errs := make(chan error, validators) + group.Add(validators) + for index := 0; index < validators; index++ { + go func() { + defer group.Done() + result, err := validator.Validate(context.Background(), request) + if err != nil { + errs <- err + return + } + if !result.Approved { + errs <- fmt.Errorf("Validate() = %#v, want approved", result) + } + }() + } + group.Wait() + close(errs) + for err := range errs { + t.Error(err) + } + if got := compilations.Load(); got != 1 { + t.Fatalf("schema compilations = %d, want 1", got) + } +} + +func TestValidatorCachesSchemaCompilationErrors(t *testing.T) { + validator := New(Options{}) + var compilations atomic.Int32 + validator.compile = func(content []byte) (*jsonschema.Schema, error) { + compilations.Add(1) + return compileSchema(content) + } + request := requestWithSchema(`{"name":"Aria"}`, []byte(`{"type":`)) + + _, first := validator.Validate(context.Background(), request) + _, second := validator.Validate(context.Background(), request) + if first == nil || second == nil { + t.Fatalf("Validate() errors = %v, %v, want cached schema errors", first, second) + } + if first.Error() != second.Error() { + t.Fatalf("cached schema errors differ: %q and %q", first, second) + } + if got := compilations.Load(); got != 1 { + t.Fatalf("schema compilations = %d, want 1", got) + } +} + func TestSpecAndRegister(t *testing.T) { if Spec().Key != Key || Spec().ExecutionClass != contracts.ExecutionClassDeterministic { t.Fatalf("Spec() = %#v, want key and deterministic execution", Spec()) @@ -91,6 +183,12 @@ func requestWithSchema(payload string, schema []byte) contracts.SerializedValida } } +func withSchema(request contracts.SerializedValidationRequest, mutate func(*contracts.ArtifactSchema)) contracts.SerializedValidationRequest { + request.Schema = contracts.CloneArtifactSchema(request.Schema) + mutate(&request.Schema) + return request +} + func objectSchema() []byte { return []byte(`{ "$schema": "https://json-schema.org/draft/2020-12/schema",