Fix nested inheritance history and budget accounting
This commit is contained in:
@@ -3,8 +3,31 @@
|
|||||||
Reviewed implementation commits `78d4b59` through `2ca1f80` against the
|
Reviewed implementation commits `78d4b59` through `2ca1f80` against the
|
||||||
[feature roadmap](named-inheritance.md), [implementation plan](implementation.md),
|
[feature roadmap](named-inheritance.md), [implementation plan](implementation.md),
|
||||||
and the user's agreed sibling lookup, enclosing-first expansion, presence, and
|
and the user's agreed sibling lookup, enclosing-first expansion, presence, and
|
||||||
provenance decisions. Both findings below require remediation. No implementation
|
provenance decisions. Both findings below are now resolved. The original review
|
||||||
fixes are included in this review.
|
evidence is retained below; source line references identify the reviewed baseline.
|
||||||
|
|
||||||
|
## Resolution — 2026-09-30
|
||||||
|
|
||||||
|
- The destination walker now detaches each mapping/sequence container before
|
||||||
|
replacing its child pointers. Retained enclosing templates remain immutable;
|
||||||
|
untouched subtrees remain shared, with existing retained-history accounting.
|
||||||
|
Regressions check value introduction, inherited control arrival/consumption,
|
||||||
|
container explanations, a three-generation chain, and sequence-contained scopes.
|
||||||
|
The nested-history regression failed before the fix and passes afterward.
|
||||||
|
- Budget accounting now maintains one logical document total and updates it from
|
||||||
|
old/new scope counts. Each scope receives the budget remaining after other
|
||||||
|
branches are reserved. Early expansion checks and final node/depth validation
|
||||||
|
remain in place. Boundary tests include independent scopes inside sequences,
|
||||||
|
unchanged sibling data, and consumed null directives.
|
||||||
|
- The maintained `BenchmarkInheritanceIndependentScopes` covers 256, 512, and
|
||||||
|
1,024 independent scopes. Allocations measured approximately 8.95, 17.87, and
|
||||||
|
36.12 MB/op respectively: roughly proportional to scope count. This benchmark
|
||||||
|
has a larger per-scope payload than the original review probe, so absolute
|
||||||
|
values are not a before/after comparison. No timing threshold was introduced.
|
||||||
|
- Validation after the fixes: `./scripts/check.sh`, `go test -race ./...`,
|
||||||
|
the new independent-scope benchmark, and the existing expansion/inspected-load
|
||||||
|
benchmarks passed on Linux. Existing redaction, ownership, and history-budget
|
||||||
|
tests remain passing; no public contract or limit was changed.
|
||||||
|
|
||||||
## 1. Preserve immutable enclosing snapshots during nested resolution
|
## 1. Preserve immutable enclosing snapshots during nested resolution
|
||||||
|
|
||||||
|
|||||||
@@ -58,6 +58,28 @@ func BenchmarkInheritanceInspectedLoad(b *testing.B) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Each scope has constant-sized output and evidence. This distinguishes budget
|
||||||
|
// accounting overhead from legitimate growth in expanded parent payloads.
|
||||||
|
func BenchmarkInheritanceIndependentScopes(b *testing.B) {
|
||||||
|
for _, size := range []int{256, 512, 1024} {
|
||||||
|
b.Run(strconv.Itoa(size), func(b *testing.B) {
|
||||||
|
input := &document.Node{Kind: document.Mapping}
|
||||||
|
for index := 0; index < size; index++ {
|
||||||
|
input.Mapping = append(input.Mapping, document.Entry{
|
||||||
|
Key: strconv.Itoa(index), Value: inheritanceBenchmarkInput("wide", 1),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
b.ReportAllocs()
|
||||||
|
b.ResetTimer()
|
||||||
|
for range b.N {
|
||||||
|
if _, diagnostics := expandInheritanceTree(input, defaultProjectionLimits()); len(diagnostics) != 0 {
|
||||||
|
b.Fatal(diagnostics)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func inheritanceBenchmarkInput(shape string, size int) *document.Node {
|
func inheritanceBenchmarkInput(shape string, size int) *document.Node {
|
||||||
value := func(text string) *document.Node { return &document.Node{Kind: document.String, Text: text} }
|
value := func(text string) *document.Node { return &document.Node{Kind: document.String, Text: text} }
|
||||||
mapping := func(entries ...document.Entry) *document.Node {
|
mapping := func(entries ...document.Entry) *document.Node {
|
||||||
|
|||||||
@@ -8,6 +8,98 @@ import (
|
|||||||
"gitea.maximumdirect.net/eric/ConfigKit/internal/document"
|
"gitea.maximumdirect.net/eric/ConfigKit/internal/document"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
func TestNestedInheritanceHistoryRetainsUnresolvedTemplates(t *testing.T) {
|
||||||
|
input := `presets:
|
||||||
|
base:
|
||||||
|
settings:
|
||||||
|
primary: {width: 10}
|
||||||
|
secondary: {extends: primary}
|
||||||
|
custom:
|
||||||
|
extends: base
|
||||||
|
settings:
|
||||||
|
primary: {width: 20}
|
||||||
|
grandchild:
|
||||||
|
extends: custom
|
||||||
|
settings:
|
||||||
|
primary: {width: 30}
|
||||||
|
`
|
||||||
|
loaded, err := Load[map[string]any](Bytes("nested", []byte(input)), testSecretBinding())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
for name, width := range map[string]string{"custom": "20", "grandchild": "30"} {
|
||||||
|
t.Run(name, func(t *testing.T) {
|
||||||
|
root := Keys("presets", name)
|
||||||
|
value, _ := loaded.Inspection.Explain(root.Key("settings").Key("secondary").Key("width"))
|
||||||
|
introduced := false
|
||||||
|
for _, change := range value.History {
|
||||||
|
if change.Origin.Operation == ChangeInheritance && !change.Before.Present && change.After.YAML == width+"\n" {
|
||||||
|
introduced = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !introduced || value.Effective.YAML != width+"\n" {
|
||||||
|
t.Fatalf("missing nested introduction: %#v", value)
|
||||||
|
}
|
||||||
|
controlPath := root.Key("settings").Key("secondary").Key("extends")
|
||||||
|
control, _ := loaded.Inspection.Explain(controlPath)
|
||||||
|
arrived, consumed := false, false
|
||||||
|
for _, change := range control.History {
|
||||||
|
arrived = arrived || change.After.YAML == "primary\n"
|
||||||
|
consumed = consumed || (change.Before.YAML == "primary\n" && !change.After.Present)
|
||||||
|
}
|
||||||
|
if !arrived || !consumed || control.Effective.Present {
|
||||||
|
t.Fatalf("missing control history: %#v", control)
|
||||||
|
}
|
||||||
|
container, _ := loaded.Inspection.Explain(root)
|
||||||
|
unresolved := false
|
||||||
|
for _, change := range container.History {
|
||||||
|
if change.Origin.Operation == ChangeInheritance && strings.Contains(change.After.YAML, "extends: primary") {
|
||||||
|
unresolved = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !unresolved || strings.Contains(container.Effective.YAML, "extends:") {
|
||||||
|
t.Fatalf("invalid enclosing snapshot: %#v", container)
|
||||||
|
}
|
||||||
|
if _, present := loaded.Inspection.Metadata().Lookup(controlPath); present {
|
||||||
|
t.Fatal("control is effective")
|
||||||
|
}
|
||||||
|
if len(loaded.Inspection.Metadata().History(controlPath)) != len(control.History) {
|
||||||
|
t.Fatal("metadata and explanation disagree")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNestedInheritanceHistoryInsideSequence(t *testing.T) {
|
||||||
|
input := `base:
|
||||||
|
items:
|
||||||
|
- first: {value: inherited}
|
||||||
|
second: {extends: first}
|
||||||
|
child: {extends: base}
|
||||||
|
`
|
||||||
|
loaded, err := Load[map[string]any](Bytes("sequence", []byte(input)), testSecretBinding())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
path := Keys("child", "items").Index(0).Key("second")
|
||||||
|
control, _ := loaded.Inspection.Explain(path.Key("extends"))
|
||||||
|
consumed := false
|
||||||
|
for _, change := range control.History {
|
||||||
|
consumed = consumed || (change.Before.YAML == "first\n" && !change.After.Present)
|
||||||
|
}
|
||||||
|
if !consumed || control.Effective.Present {
|
||||||
|
t.Fatalf("sequence control history = %#v", control)
|
||||||
|
}
|
||||||
|
value, _ := loaded.Inspection.Explain(path.Key("value"))
|
||||||
|
introduced := false
|
||||||
|
for _, change := range value.History {
|
||||||
|
introduced = introduced || (!change.Before.Present && change.After.YAML == "inherited\n")
|
||||||
|
}
|
||||||
|
if !introduced || value.Effective.YAML != "inherited\n" {
|
||||||
|
t.Fatalf("sequence value history = %#v", value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func inheritanceInspectionForTest(t *testing.T, sources ...*document.Node) (*Inspection, *inheritanceScope) {
|
func inheritanceInspectionForTest(t *testing.T, sources ...*document.Node) (*Inspection, *inheritanceScope) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
composed, diagnostics := composeDocuments(sources...)
|
composed, diagnostics := composeDocuments(sources...)
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
type inheritanceWalker struct {
|
type inheritanceWalker struct {
|
||||||
root **document.Node
|
nodes int // logical non-directive nodes in the entire working tree
|
||||||
origins map[string]Origin
|
origins map[string]Origin
|
||||||
limits projectionLimits
|
limits projectionLimits
|
||||||
retained *retainedNodeCounter
|
retained *retainedNodeCounter
|
||||||
@@ -38,8 +38,12 @@ func expandInheritanceWithBudgets(input *document.Node, inputOrigins map[string]
|
|||||||
if inputOrigins == nil {
|
if inputOrigins == nil {
|
||||||
origins = directInheritanceOrigins(input, Path{}, nil)
|
origins = directInheritanceOrigins(input, Path{}, nil)
|
||||||
}
|
}
|
||||||
|
nodes, within := inheritanceLowerBoundCount(root, limits.nodes)
|
||||||
|
if !within {
|
||||||
|
return nil, []Diagnostic{inheritanceBudgetDiagnostic(Path{}, root.Location)}
|
||||||
|
}
|
||||||
walker := inheritanceWalker{
|
walker := inheritanceWalker{
|
||||||
root: &root, limits: limits, origins: origins,
|
nodes: nodes, limits: limits, origins: origins,
|
||||||
retained: newRetainedNodeCounter(historyLimit),
|
retained: newRetainedNodeCounter(historyLimit),
|
||||||
}
|
}
|
||||||
if problem := walker.visit(&root, Path{}, true); problem != nil {
|
if problem := walker.visit(&root, Path{}, true); problem != nil {
|
||||||
@@ -79,7 +83,14 @@ func (w *inheritanceWalker) visit(node **document.Node, path Path, unscoped bool
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if scopeHasInheritanceDefinitions(*node) {
|
if scopeHasInheritanceDefinitions(*node) {
|
||||||
resolved, diagnostics := expandSiblingDefinitionsWithOrigins(*node, path, w.limits, w.origins, w.retained)
|
// Only this scope can change the logical total. Reserve the nodes
|
||||||
|
// outside it so expansion checks the remaining budget before cloning.
|
||||||
|
before, _ := inheritanceLowerBoundCount(*node, -1)
|
||||||
|
limits := w.limits
|
||||||
|
if limits.nodes >= 0 {
|
||||||
|
limits.nodes -= w.nodes - before
|
||||||
|
}
|
||||||
|
resolved, diagnostics := expandSiblingDefinitionsWithOrigins(*node, path, limits, w.origins, w.retained)
|
||||||
if len(diagnostics) != 0 {
|
if len(diagnostics) != 0 {
|
||||||
return &diagnostics[0]
|
return &diagnostics[0]
|
||||||
}
|
}
|
||||||
@@ -92,11 +103,15 @@ func (w *inheritanceWalker) visit(node **document.Node, path Path, unscoped bool
|
|||||||
w.controls = append(w.controls, resolved.controls...)
|
w.controls = append(w.controls, resolved.controls...)
|
||||||
w.replacements = append(w.replacements, resolved.replacements...)
|
w.replacements = append(w.replacements, resolved.replacements...)
|
||||||
w.operations = append(w.operations, resolved.operations...)
|
w.operations = append(w.operations, resolved.operations...)
|
||||||
if !inheritanceLowerBoundWithin(*w.root, w.limits.nodes) {
|
after, _ := inheritanceLowerBoundCount(*node, -1)
|
||||||
problem := inheritanceBudgetDiagnostic(path, (*node).Location)
|
w.nodes += after - before
|
||||||
return &problem
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
// Operations retain immutable enclosing templates. Detach just this
|
||||||
|
// container before replacing descendants during destination resolution;
|
||||||
|
// untouched subtrees remain shared with the retained evidence.
|
||||||
|
working := **node
|
||||||
|
working.Mapping = slices.Clone(working.Mapping)
|
||||||
|
*node = &working
|
||||||
indices := make([]int, len((*node).Mapping))
|
indices := make([]int, len((*node).Mapping))
|
||||||
for index := range indices {
|
for index := range indices {
|
||||||
indices[index] = index
|
indices[index] = index
|
||||||
@@ -111,6 +126,9 @@ func (w *inheritanceWalker) visit(node **document.Node, path Path, unscoped bool
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
case document.Sequence:
|
case document.Sequence:
|
||||||
|
working := **node
|
||||||
|
working.Sequence = slices.Clone(working.Sequence)
|
||||||
|
*node = &working
|
||||||
for index := range (*node).Sequence {
|
for index := range (*node).Sequence {
|
||||||
child := &(*node).Sequence[index]
|
child := &(*node).Sequence[index]
|
||||||
if problem := w.visit(child, path.Index(index), (*child).Kind == document.Mapping); problem != nil {
|
if problem := w.visit(child, path.Index(index), (*child).Kind == document.Mapping); problem != nil {
|
||||||
|
|||||||
@@ -194,3 +194,26 @@ func TestInheritanceExpansionHonorsOutputNodeAndDepthBudgets(t *testing.T) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestInheritanceBudgetIncludesOtherScopesAndSequenceItems(t *testing.T) {
|
||||||
|
input := parseCompositionDocument(t, "source-1", `extends: null
|
||||||
|
groups:
|
||||||
|
- a: {x: one}
|
||||||
|
b: {extends: a}
|
||||||
|
- a: {x: two}
|
||||||
|
b: {extends: a}
|
||||||
|
unchanged: {x: three}
|
||||||
|
`)
|
||||||
|
// Root (1), groups key/list (2), two expanded scopes (18), and
|
||||||
|
// unchanged key/mapping/key/value (4). Consumed controls cost nothing.
|
||||||
|
for _, limit := range []int{24, 25, 26} {
|
||||||
|
result, diagnostics := expandInheritanceTree(input, projectionLimits{nodes: limit, depth: 5})
|
||||||
|
if limit < 25 {
|
||||||
|
if result != nil || len(diagnostics) != 1 || diagnostics[0].Code != CodeSchemaProjectionLimit {
|
||||||
|
t.Fatalf("limit %d accepted or wrong error: %#v", limit, diagnostics)
|
||||||
|
}
|
||||||
|
} else if result == nil || len(diagnostics) != 0 {
|
||||||
|
t.Fatalf("limit %d rejected: %#v", limit, diagnostics)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user