diff --git a/docs/roadmap/implementation.md b/docs/roadmap/implementation.md index f5ee3fc..7eb38db 100644 --- a/docs/roadmap/implementation.md +++ b/docs/roadmap/implementation.md @@ -371,7 +371,7 @@ Roadmap candidate 7. Owners: engine metadata, metadata cloning, inheritance hist Validate: `go test ./internal/engine -run 'Inheritance|Inherited|History|Metadata|Independent'`, engine race tests, and the everyday check. -## Stage 15: Reuse frozen inspection mapping lookups +## Stage 15: Reuse frozen inspection mapping lookups — Complete Roadmap candidate 7. Owners: publication, inspection, document/snapshot/history helpers. diff --git a/internal/engine/document_lookup.go b/internal/engine/document_lookup.go index 7036c2b..dec4d4e 100644 --- a/internal/engine/document_lookup.go +++ b/internal/engine/document_lookup.go @@ -7,7 +7,7 @@ type documentLookup struct { byNode map[*document.Node]map[string]int } -func buildDocumentLookup(root *document.Node) *documentLookup { +func buildDocumentLookup(roots ...*document.Node) *documentLookup { lookup := &documentLookup{byNode: make(map[*document.Node]map[string]int)} visited := make(map[*document.Node]struct{}) var visit func(*document.Node) @@ -35,7 +35,9 @@ func buildDocumentLookup(root *document.Node) *documentLookup { } } } - visit(root) + for _, root := range roots { + visit(root) + } return lookup } diff --git a/internal/engine/explain.go b/internal/engine/explain.go index 282a0cf..b350106 100644 --- a/internal/engine/explain.go +++ b/internal/engine/explain.go @@ -40,7 +40,7 @@ func (r *Inspection) Explain(path Path) (Explanation, bool) { if r == nil || r.final == nil || r.baseline == nil || !r.metadata.hasHistoricalPath(path) { return Explanation{}, false } - explanation := Explanation{Path: path.clone(), Effective: valueSnapshot(r.final, path, r.secrets)} + explanation := Explanation{Path: path.clone(), Effective: valueSnapshotWithLookup(r.final, path, r.secrets, r.immutableLookup)} if info, ok := r.metadata.Lookup(path); ok { explanation.Origin = cloneOrigin(info.Origin) } @@ -53,7 +53,7 @@ func (r *Inspection) Explain(path Path) (Explanation, bool) { } replayIndex := new(mappingIndex) for _, application := range r.applications { - operationPath, relevant := applicationOperationPath(application, path) + operationPath, relevant := applicationOperationPath(application, path, r.immutableLookup) var before ValueSnapshot if relevant { before = valueSnapshot(working, path, r.secrets) @@ -69,7 +69,7 @@ func (r *Inspection) Explain(path Path) (Explanation, bool) { explanation.History = append(explanation.History, ValueChange{ Before: before, After: after, - Origin: historyApplicationOrigin(application, operationPath), + Origin: historyApplicationOrigin(application, operationPath, r.immutableLookup), }) } appendInheritanceExplanation(&explanation, &working, path, r.inheritance, r.secrets) @@ -78,13 +78,13 @@ func (r *Inspection) Explain(path Path) (Explanation, bool) { // decoding applies them to the final input tree. Show that reconciliation // explicitly, including canonical scalar conversions, before domain changes. replayed, _ := documentAtPath(working, path) - decoded, _ := documentAtPath(r.input, path) + decoded, _ := documentAtPathWithLookup(r.input, path, r.immutableLookup) if !configurationValuesEqual(replayed, decoded) { origin := cloneOrigin(r.inputOrigins[path.String()]) origin.Operation = ChangeDecoding explanation.History = append(explanation.History, ValueChange{ Before: valueSnapshot(working, path, r.secrets), - After: valueSnapshot(r.input, path, r.secrets), + After: valueSnapshotWithLookup(r.input, path, r.secrets, r.immutableLookup), Origin: origin, Reasons: []string{"applied retained defaults and canonical typed decoding"}, }) diff --git a/internal/engine/explain_test.go b/internal/engine/explain_test.go index 4cd9c27..5b80204 100644 --- a/internal/engine/explain_test.go +++ b/internal/engine/explain_test.go @@ -10,6 +10,7 @@ import ( "path/filepath" "reflect" "strings" + "sync" "testing" "gitea.maximumdirect.net/eric/ConfigKit/internal/document" @@ -566,3 +567,49 @@ func TestExplanationReplayDescriptorsRemainDetachedAcrossQueries(t *testing.T) { } t.Fatalf("resolution change missing from repeated explanation: %#v", second.History) } + +func TestConcurrentReadOnlyInspectionQueries(t *testing.T) { + type config struct { + Group struct { + Value string `yaml:"value"` + } `yaml:"group"` + } + result, err := testLoad(Bytes("concurrent-inspection.yml", []byte("group: {value: input}\n")), Options[config]{ + Definition: Definition[config]{Resolve: func(value *config, resolution *Resolution) error { + value.Group.Value = "resolved" + resolution.Record(Keys("group", "value"), ChangeNormalization, "normalized", Keys("group", "value")) + return nil + }}, + }) + if err != nil { + t.Fatal(err) + } + paths := []Path{Keys("group"), Keys("group", "value")} + var wait sync.WaitGroup + errors := make(chan string, 8) + for worker := 0; worker < cap(errors); worker++ { + wait.Add(1) + go func() { + defer wait.Done() + for iteration := 0; iteration < 8; iteration++ { + for _, path := range paths { + explanation, found := result.Explain(path) + if !found || !explanation.Effective.Present { + errors <- "missing effective explanation for " + path.String() + return + } + metadata := result.Metadata() + if _, found := metadata.Lookup(path); !found || len(metadata.History(path)) == 0 { + errors <- "missing metadata for " + path.String() + return + } + } + } + }() + } + wait.Wait() + close(errors) + for message := range errors { + t.Error(message) + } +} diff --git a/internal/engine/history.go b/internal/engine/history.go index a72c526..546d407 100644 --- a/internal/engine/history.go +++ b/internal/engine/history.go @@ -65,7 +65,7 @@ func cloneHistoryApplications(values []historyApplication) []historyApplication return cloned } -func applicationOperationPath(application historyApplication, query Path) (Path, bool) { +func applicationOperationPath(application historyApplication, query Path, lookup *documentLookup) (Path, bool) { if !application.path.valid() || application.value == nil { return Path{}, false } @@ -80,7 +80,7 @@ func applicationOperationPath(application historyApplication, query Path) (Path, } for length := 1; length <= len(query.segments); length++ { prefix := Path{segments: clonePathSegments(query.segments[:length], 0)} - node, present := documentAtPath(application.value, prefix) + node, present := documentAtPathWithLookup(application.value, prefix, lookup) if !present { return Path{}, false } @@ -91,7 +91,7 @@ func applicationOperationPath(application historyApplication, query Path) (Path, return Path{}, false } -func historyApplicationOrigin(application historyApplication, operationPath Path) Origin { +func historyApplicationOrigin(application historyApplication, operationPath Path, lookup *documentLookup) Origin { if application.origin.Layer != "" { return cloneOrigin(application.origin) } @@ -100,7 +100,7 @@ func historyApplicationOrigin(application historyApplication, operationPath Path if !ok { return origin } - if node, present := documentAtPath(application.value, relative); present { + if node, present := documentAtPathWithLookup(application.value, relative, lookup); present { origin.Locations = []Location{publicLocation(node.Location)} } return origin diff --git a/internal/engine/load.go b/internal/engine/load.go index 9c2fa0d..582a006 100644 --- a/internal/engine/load.go +++ b/internal/engine/load.go @@ -84,6 +84,7 @@ type Inspection struct { input *document.Node inputOrigins map[string]Origin final *document.Node + immutableLookup *documentLookup resolution []resolutionSnapshot resolutionReplay []resolutionSnapshot applications []historyApplication diff --git a/internal/engine/load_result.go b/internal/engine/load_result.go index 091a697..135452b 100644 --- a/internal/engine/load_result.go +++ b/internal/engine/load_result.go @@ -98,6 +98,11 @@ func publishLoadResult[T any](schema *schemaDescriptor, definition Definition[T] final, applications, resolutionSnapshots := resolved.final, resolved.applications, resolved.snapshots finalizeMetadataPaths(&metadata, final, applications, decoded.baseline, decoded.final) secrets := classifySecretPaths(schema, definition.Secret, metadata, final, applications, decoded.baseline, decoded.final) + retainedTrees := make([]*document.Node, 0, len(applications)+2) + retainedTrees = append(retainedTrees, decoded.final, final) + for _, application := range applications { + retainedTrees = append(retainedTrees, application.value) + } inspection := &Inspection{ metadata: metadata, sources: cloneSourceInfos(accumulator.sources), @@ -107,6 +112,7 @@ func publishLoadResult[T any](schema *schemaDescriptor, definition Definition[T] input: decoded.final, inputOrigins: cloneOriginIndex(decoded.origins), final: final, + immutableLookup: buildDocumentLookup(retainedTrees...), resolution: resolutionSnapshots, resolutionReplay: coalescedResolutionSnapshots(resolutionSnapshots), applications: applications, diff --git a/internal/engine/resolution_index_test.go b/internal/engine/resolution_index_test.go index a205e6e..701c1f8 100644 --- a/internal/engine/resolution_index_test.go +++ b/internal/engine/resolution_index_test.go @@ -102,7 +102,11 @@ func TestDocumentLookupMatchesPathScan(t *testing.T) { {Key: "01", Value: &document.Node{Kind: document.String, Text: "leading zero key"}}, {Key: "items", Value: sequence}, }} - lookup := buildDocumentLookup(root) + applicationRoot := &document.Node{Kind: document.Mapping, Mapping: []document.Entry{ + {Key: "shared", Value: sequence}, + {Key: "application", Value: &document.Node{Kind: document.Mapping, Mapping: []document.Entry{{Key: "value", Value: &document.Node{Kind: document.String, Text: "application"}}}}}, + }} + lookup := buildDocumentLookup(root, applicationRoot) paths := []Path{ Path{}, Keys("0"), @@ -123,6 +127,13 @@ func TestDocumentLookupMatchesPathScan(t *testing.T) { t.Errorf("indexed lookup %s = (%p, %v), want (%p, %v)", path.String(), gotNode, gotFound, wantNode, wantFound) } } + for _, path := range []Path{Path{}, Keys("shared"), Keys("shared").Index(1).Key("name"), Keys("application", "value")} { + wantNode, wantFound := documentAtPath(applicationRoot, path) + gotNode, gotFound := documentAtPathWithLookup(applicationRoot, path, lookup) + if gotNode != wantNode || gotFound != wantFound { + t.Errorf("indexed application lookup %s = (%p, %v), want (%p, %v)", path.String(), gotNode, gotFound, wantNode, wantFound) + } + } } func TestApplyResolutionMetadataPreservesOverlappingDeltaOrder(t *testing.T) { diff --git a/internal/engine/snapshot.go b/internal/engine/snapshot.go index 7a97c9f..488b953 100644 --- a/internal/engine/snapshot.go +++ b/internal/engine/snapshot.go @@ -12,7 +12,11 @@ import ( // Snapshot projection is the shared redaction boundary for effective YAML // and explanation values. It borrows frozen trees and returns a detached tree. func valueSnapshot(root *document.Node, path Path, secrets map[string]bool) ValueSnapshot { - node, present := documentAtPath(root, path) + return valueSnapshotWithLookup(root, path, secrets, nil) +} + +func valueSnapshotWithLookup(root *document.Node, path Path, secrets map[string]bool, lookup *documentLookup) ValueSnapshot { + node, present := documentAtPathWithLookup(root, path, lookup) if !present { return ValueSnapshot{} }