Skip to content

Commit 0670a1c

Browse files
ndeloofclaude
authored andcommitted
perf(schema): compile compose-spec schema once
Validate rebuilt a jsonschema Compiler and recompiled the embedded compose-spec schema (including loading and resolving the draft 2020-12 meta-schema) on every call. Compile it once behind a sync.Once instead, which keeps Validate cheap on the loader hot path and makes it safe to call concurrently without sharing any mutable compiler state. Switch MustCompile to Compile so a schema compilation failure is returned as an error instead of panicking. Add a concurrent Validate test (run with -race) as a regression guard. Relates to docker/compose#13866 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Signed-off-by: Nicolas De Loof <nicolas.deloof@gmail.com>
1 parent 9bc8469 commit 0670a1c

2 files changed

Lines changed: 60 additions & 11 deletions

File tree

schema/schema.go

Lines changed: 35 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ import (
2424
"fmt"
2525
"slices"
2626
"strings"
27+
"sync"
2728
"time"
2829

2930
"github.com/santhosh-tekuri/jsonschema/v6"
@@ -46,22 +47,45 @@ func durationFormatChecker(input any) error {
4647
//go:embed compose-spec.json
4748
var Schema string
4849

50+
// compiledSchema is the compose-spec schema compiled once and reused across
51+
// every Validate call. Compiling the schema (which loads and resolves the
52+
// draft 2020-12 meta-schema) is expensive and was previously redone on every
53+
// call; doing it once behind a sync.Once keeps Validate cheap and makes it
54+
// safe to call from several goroutines without sharing any mutable compiler
55+
// state. see https://github.com/docker/compose/issues/13866
56+
var (
57+
compiledSchema *jsonschema.Schema
58+
compiledSchemaErr error
59+
compiledSchemaOnce sync.Once
60+
)
61+
62+
func compileSchema() (*jsonschema.Schema, error) {
63+
compiledSchemaOnce.Do(func() {
64+
compiler := jsonschema.NewCompiler()
65+
shema, err := jsonschema.UnmarshalJSON(strings.NewReader(Schema))
66+
if err != nil {
67+
compiledSchemaErr = err
68+
return
69+
}
70+
if err := compiler.AddResource("compose-spec.json", shema); err != nil {
71+
compiledSchemaErr = err
72+
return
73+
}
74+
compiler.RegisterFormat(&jsonschema.Format{
75+
Name: "duration",
76+
Validate: durationFormatChecker,
77+
})
78+
compiledSchema, compiledSchemaErr = compiler.Compile("compose-spec.json")
79+
})
80+
return compiledSchema, compiledSchemaErr
81+
}
82+
4983
// Validate uses the jsonschema to validate the configuration
5084
func Validate(config map[string]interface{}) error {
51-
compiler := jsonschema.NewCompiler()
52-
shema, err := jsonschema.UnmarshalJSON(strings.NewReader(Schema))
85+
schema, err := compileSchema()
5386
if err != nil {
5487
return err
5588
}
56-
err = compiler.AddResource("compose-spec.json", shema)
57-
if err != nil {
58-
return err
59-
}
60-
compiler.RegisterFormat(&jsonschema.Format{
61-
Name: "duration",
62-
Validate: durationFormatChecker,
63-
})
64-
schema := compiler.MustCompile("compose-spec.json")
6589

6690
// santhosh-tekuri doesn't allow derived types
6791
// see https://github.com/santhosh-tekuri/jsonschema/pull/240

schema/schema_test.go

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ package schema
1919
import (
2020
"os"
2121
"strings"
22+
"sync"
2223
"testing"
2324

2425
"github.com/santhosh-tekuri/jsonschema/v6"
@@ -250,6 +251,30 @@ func TestValidateVariables(t *testing.T) {
250251
assert.NilError(t, Validate(config))
251252
}
252253

254+
// TestValidateConcurrent ensures Validate can safely be called from several
255+
// goroutines at once. Run with -race to catch the regression reported in
256+
// https://github.com/docker/compose/issues/13866 ("concurrent map iteration
257+
// and map write" while compiling the schema).
258+
func TestValidateConcurrent(t *testing.T) {
259+
config := map[string]any{
260+
"services": map[string]any{
261+
"foo": map[string]any{
262+
"image": "busybox",
263+
},
264+
},
265+
}
266+
267+
var wg sync.WaitGroup
268+
for i := 0; i < 50; i++ {
269+
wg.Add(1)
270+
go func() {
271+
defer wg.Done()
272+
assert.NilError(t, Validate(config))
273+
}()
274+
}
275+
wg.Wait()
276+
}
277+
253278
func TestSchema(t *testing.T) {
254279
compiler := jsonschema.NewCompiler()
255280
json, err := jsonschema.UnmarshalJSON(strings.NewReader(Schema))

0 commit comments

Comments
 (0)