diff --git a/schema/schema.go b/schema/schema.go index a73eda24..a765e337 100644 --- a/schema/schema.go +++ b/schema/schema.go @@ -24,6 +24,7 @@ import ( "fmt" "slices" "strings" + "sync" "time" "github.com/santhosh-tekuri/jsonschema/v6" @@ -46,22 +47,45 @@ func durationFormatChecker(input any) error { //go:embed compose-spec.json var Schema string +// compiledSchema is the compose-spec schema compiled once and reused across +// every Validate call. Compiling the schema (which loads and resolves the +// draft 2020-12 meta-schema) is expensive and was previously redone on every +// call; doing it once behind a sync.Once keeps Validate cheap and makes it +// safe to call from several goroutines without sharing any mutable compiler +// state. see https://github.com/docker/compose/issues/13866 +var ( + compiledSchema *jsonschema.Schema + compiledSchemaErr error + compiledSchemaOnce sync.Once +) + +func compileSchema() (*jsonschema.Schema, error) { + compiledSchemaOnce.Do(func() { + compiler := jsonschema.NewCompiler() + shema, err := jsonschema.UnmarshalJSON(strings.NewReader(Schema)) + if err != nil { + compiledSchemaErr = err + return + } + if err := compiler.AddResource("compose-spec.json", shema); err != nil { + compiledSchemaErr = err + return + } + compiler.RegisterFormat(&jsonschema.Format{ + Name: "duration", + Validate: durationFormatChecker, + }) + compiledSchema, compiledSchemaErr = compiler.Compile("compose-spec.json") + }) + return compiledSchema, compiledSchemaErr +} + // Validate uses the jsonschema to validate the configuration func Validate(config map[string]interface{}) error { - compiler := jsonschema.NewCompiler() - shema, err := jsonschema.UnmarshalJSON(strings.NewReader(Schema)) + schema, err := compileSchema() if err != nil { return err } - err = compiler.AddResource("compose-spec.json", shema) - if err != nil { - return err - } - compiler.RegisterFormat(&jsonschema.Format{ - Name: "duration", - Validate: durationFormatChecker, - }) - schema := compiler.MustCompile("compose-spec.json") // santhosh-tekuri doesn't allow derived types // see https://github.com/santhosh-tekuri/jsonschema/pull/240 diff --git a/schema/schema_test.go b/schema/schema_test.go index 405b27a4..52e23081 100644 --- a/schema/schema_test.go +++ b/schema/schema_test.go @@ -19,6 +19,7 @@ package schema import ( "os" "strings" + "sync" "testing" "github.com/santhosh-tekuri/jsonschema/v6" @@ -250,6 +251,30 @@ func TestValidateVariables(t *testing.T) { assert.NilError(t, Validate(config)) } +// TestValidateConcurrent ensures Validate can safely be called from several +// goroutines at once. Run with -race to catch the regression reported in +// https://github.com/docker/compose/issues/13866 ("concurrent map iteration +// and map write" while compiling the schema). +func TestValidateConcurrent(t *testing.T) { + config := map[string]any{ + "services": map[string]any{ + "foo": map[string]any{ + "image": "busybox", + }, + }, + } + + var wg sync.WaitGroup + for i := 0; i < 50; i++ { + wg.Add(1) + go func() { + defer wg.Done() + assert.NilError(t, Validate(config)) + }() + } + wg.Wait() +} + func TestSchema(t *testing.T) { compiler := jsonschema.NewCompiler() json, err := jsonschema.UnmarshalJSON(strings.NewReader(Schema))