Count destination wrappers in shared input limits

This commit is contained in:
2026-10-14 13:31:32 +00:00
parent 2f5d57ce1d
commit c71faf2065
5 changed files with 208 additions and 6 deletions

View File

@@ -312,7 +312,7 @@ Done: diagnostics consistently distinguish application paths from source syntax.
## Stage 7 — Complete shared placement accounting ## Stage 7 — Complete shared placement accounting
Status: planned. Scope: resource bounds with no new public limit system. Status: complete. Scope: resource bounds with no new public limit system.
1. Wire Stage 2's prechecks and synthetic-node charges into both tracked ordinary 1. Wire Stage 2's prechecks and synthetic-node charges into both tracked ordinary
acquisition and import traversal, using `importAccounting` and current limit acquisition and import traversal, using `importAccounting` and current limit
@@ -327,8 +327,18 @@ Status: planned. Scope: resource bounds with no new public limit system.
empty mappings. Verify root placement and NoSource do not add charges. empty mappings. Verify root placement and NoSource do not add charges.
Preserve history/expansion limits; do not turn fixture values into defaults. Preserve history/expansion limits; do not turn fixture values into defaults.
Validate: destination/limit/import/override tests and everyday check. Done: Validated with `go test ./internal/engine -run 'Destination|Import|Grouped|Overlay|Override'`
placement cannot bypass current budgets and introduces no unrelated caps. and `./scripts/check.sh` (go1.26.8 linux/amd64); both passed. New coverage in
`TestDestinationPlacementCountsAgainstSharedNodeBudget`,
`TestMultipleMountedInputsSharePlacementCharges`,
`TestImportedEmptyBodiesChargeEachDestinationWrapper`,
`TestSelectedOverlayAndCLIUsePlacementNodeCharges`, and
`TestNoSourceAndRootPlacementAddNoWrapperCharges` checks totals below, at, and
above the boundary, multiple shallow inputs, imported empty bodies, and charging
of upper tiers from mounted inputs. `TestPlaceDocumentRootAndBounds` continues
to cover per-document depth/node boundaries; existing grouped accounting tests
cover unchanged raw source/byte charging. Done: placement cannot bypass current
budgets and introduces no unrelated caps.
## Stage 8 — Preserve provenance, history, and source resource contexts ## Stage 8 — Preserve provenance, history, and source resource contexts

View File

@@ -86,6 +86,7 @@ func destinationWithinLimits(body *document.Node, destination Path, maxDepth, ma
if segments > maxDepth || segments > maxNodes/2 { if segments > maxDepth || segments > maxNodes/2 {
return false return false
} }
wrapperNodes := destinationWrapperNodeCount(destination)
for _, segment := range destination.segments { for _, segment := range destination.segments {
if segment.kind != pathSegmentKey { if segment.kind != pathSegmentKey {
return false return false
@@ -94,7 +95,11 @@ func destinationWithinLimits(body *document.Node, destination Path, maxDepth, ma
if documentDepth(body) > maxDepth-segments { if documentDepth(body) > maxDepth-segments {
return false return false
} }
return countInputNodes(body) <= maxNodes-2*segments return countInputNodes(body) <= maxNodes-wrapperNodes
}
func destinationWrapperNodeCount(destination Path) int {
return 2 * len(destination.segments)
} }
func documentDepth(node *document.Node) int { func documentDepth(node *document.Node) int {

View File

@@ -0,0 +1,176 @@
package engine
import (
"testing"
"testing/fstest"
"gitea.maximumdirect.net/eric/ConfigKit/internal/document"
)
func TestDestinationPlacementCountsAgainstSharedNodeBudget(t *testing.T) {
t.Parallel()
limits := defaultImportLimits()
destination := Keys("collections", "entry")
empty := Mapping("empty", map[string]any{})
parsed, diagnostics := parseSource(empty, "fixture", defaultParseLimits())
if len(diagnostics) != 0 {
t.Fatal(diagnostics)
}
perInput := countInputNodes(parsed.document) + destinationWrapperNodeCount(destination)
for _, test := range []struct {
name string
delta int
wantError bool
wantCharge int
}{
{name: "below aggregate bound", delta: -1, wantCharge: perInput},
{name: "at aggregate bound", delta: 0, wantCharge: perInput},
{name: "above aggregate bound", delta: 1, wantError: true},
} {
t.Run(test.name, func(t *testing.T) {
t.Parallel()
accounting := &importAccounting{nodes: limits.nodes - perInput + test.delta}
composition, _, _, diagnostics := acquireUnderlayGroup(loadGroup{inputs: []loadInput{{
source: empty, destination: destination,
}}}, 1, nil, accounting)
if test.wantError {
if composition != nil || len(diagnostics) != 1 || diagnostics[0].Code != CodeSourceLimitExceeded {
t.Fatalf("over-budget placement = %#v, %#v", composition, diagnostics)
}
return
}
if composition == nil || len(diagnostics) != 0 || composition.inputNodes != test.wantCharge {
t.Fatalf("bounded placement = %#v, %#v", composition, diagnostics)
}
})
}
}
func TestMultipleMountedInputsSharePlacementCharges(t *testing.T) {
t.Parallel()
limits := defaultImportLimits()
inputs := []loadInput{
{source: Mapping("one", map[string]any{}), destination: Keys("collection", "one")},
{source: Mapping("two", map[string]any{}), destination: Keys("collection", "two")},
{source: Mapping("three", map[string]any{}), destination: Keys("collection", "three")},
}
perInput := countInputNodes(&document.Node{Kind: document.Mapping}) + destinationWrapperNodeCount(Keys("collection", "one"))
charge := len(inputs) * perInput
accounting := &importAccounting{nodes: limits.nodes - charge}
composition, _, _, diagnostics := acquireUnderlayGroup(loadGroup{inputs: inputs}, 1, nil, accounting)
if len(diagnostics) != 0 || composition == nil || accounting.nodes != limits.nodes || composition.inputNodes != charge {
t.Fatalf("shared placement accounting = %#v, %#v, accounting %#v; want charge %d", composition, diagnostics, accounting, charge)
}
}
func TestImportedEmptyBodiesChargeEachDestinationWrapper(t *testing.T) {
t.Parallel()
filesystem := NewFilesystem("bundle", fstest.MapFS{
"main.yml": &fstest.MapFile{Data: []byte("configkit: {imports: [{path: fragment.yml}]}\n")},
"fragment.yml": &fstest.MapFile{Data: []byte("{}\n")},
})
mainSource := filesystem.File("main.yml")
mainParsed, diagnostics := parseSource(mainSource, "fixture", defaultParseLimits())
if len(diagnostics) != 0 {
t.Fatal(diagnostics)
}
fragmentParsed, diagnostics := parseSource(Bytes("fragment", []byte("{}\n")), "fixture", defaultParseLimits())
if len(diagnostics) != 0 {
t.Fatal(diagnostics)
}
rawNodes := countInputNodes(mainParsed.document) + countInputNodes(fragmentParsed.document)
destination := Keys("collection", "empty")
placementNodes := 2 * destinationWrapperNodeCount(destination)
limits := defaultImportLimits()
for _, test := range []struct {
name string
delta int
wantError bool
}{
{name: "below aggregate bound", delta: -1},
{name: "at aggregate bound", delta: 0},
{name: "above aggregate bound", delta: 1, wantError: true},
} {
t.Run(test.name, func(t *testing.T) {
t.Parallel()
accounting := &importAccounting{nodes: limits.nodes - rawNodes - placementNodes + test.delta}
composition, sources, _, diagnostics := acquireUnderlayGroup(loadGroup{inputs: []loadInput{{
source: mainSource, imports: true, destination: destination,
}}}, 1, nil, accounting)
if test.wantError {
if composition != nil || len(diagnostics) == 0 || diagnostics[0].Code != CodeImportLimitExceeded {
t.Fatalf("over-budget imports = %#v, %#v", composition, diagnostics)
}
return
}
if len(diagnostics) != 0 || composition == nil || len(sources) != 2 {
t.Fatalf("bounded imports = %#v, sources %#v, diagnostics %#v", composition, sources, diagnostics)
}
if composition.inputNodes != rawNodes+placementNodes || accounting.nodes != limits.nodes+test.delta {
t.Fatalf("import node charge = %d, accounting %d; want %d and %d", composition.inputNodes, accounting.nodes, rawNodes+placementNodes, limits.nodes+test.delta)
}
if node, present := documentAtPath(composition.root, Keys("collection", "empty")); !present || node.Kind != document.Mapping || len(node.Mapping) != 0 {
t.Fatalf("placed empty import body = %#v, present %v", node, present)
}
})
}
}
func TestNoSourceAndRootPlacementAddNoWrapperCharges(t *testing.T) {
t.Parallel()
accounting := &importAccounting{}
composition, sources, _, diagnostics := acquireUnderlayGroup(loadGroup{inputs: []loadInput{
{source: NoSource(), destination: Keys("absent")},
{source: Mapping("root", map[string]any{})},
}}, 1, nil, accounting)
if len(diagnostics) != 0 || composition == nil || len(sources) != 1 || accounting.nodes != 1 || composition.inputNodes != 1 {
t.Fatalf("root and absent placement = %#v, sources %#v, diagnostics %#v, accounting %#v", composition, sources, diagnostics, accounting)
}
}
func TestSelectedOverlayAndCLIUsePlacementNodeCharges(t *testing.T) {
t.Parallel()
limits := defaultImportLimits()
baseNodes := countInputNodes(&document.Node{Kind: document.Mapping}) + destinationWrapperNodeCount(Keys("mounted"))
t.Run("selected overlay", func(t *testing.T) {
t.Parallel()
filesystem := NewFilesystem("bundle", fstest.MapFS{
"overlays/test.yml": &fstest.MapFile{Data: []byte("{}\n")},
})
request := loadRequest[map[string]any]{
underlays: []loadGroup{{inputs: []loadInput{{source: Mapping("empty", map[string]any{}), destination: Keys("mounted")}}}},
overlay: "test", overlayFilesystem: &FilesystemContext{Root: ".", BaseDir: ".", FS: &filesystem},
}
accounting := &importAccounting{nodes: limits.nodes - baseNodes}
_, diagnostics := assembleLayeredInput(request, assembledInput{}, accounting)
if len(diagnostics) != 1 || diagnostics[0].Code != CodeOverlayLimitExceeded {
t.Fatalf("overlay diagnostics = %#v", diagnostics)
}
})
t.Run("CLI override", func(t *testing.T) {
t.Parallel()
override := Override{Path: Keys("mounted", "value"), Value: "x", Flag: "--value"}
builder := valueBuilder{sourceID: "fixture", active: make(map[valueVisit]bool)}
if _, issue := builder.build(override.Value, override.Path, len(override.Path.segments)); issue != nil {
t.Fatal(issue)
}
overrideNodes := builder.nodes + destinationWrapperNodeCount(override.Path)
request := loadRequest[map[string]any]{
underlays: []loadGroup{{inputs: []loadInput{{source: Mapping("empty", map[string]any{}), destination: Keys("mounted")}}}},
overrides: []Override{override},
}
accounting := &importAccounting{nodes: limits.nodes - baseNodes - overrideNodes + 1}
_, diagnostics := assembleLayeredInput(request, assembledInput{}, accounting)
if len(diagnostics) != 1 || diagnostics[0].Code != CodeOverrideLimitExceeded {
t.Fatalf("override diagnostics = %#v", diagnostics)
}
})
}

View File

@@ -144,9 +144,13 @@ func loadImportedWithAccessTracked(access *filesystemAccess, basePath, mainPath
body, declarations, diagnostics := splitImportDirectives(main.document) body, declarations, diagnostics := splitImportDirectives(main.document)
if len(diagnostics) == 0 { if len(diagnostics) == 0 {
_, placed, err := placeDocument(body, state.destination, body.Location, maxDocumentDepth, maxDocumentNodes) _, placed, err := placeDocument(body, state.destination, body.Location, maxDocumentDepth, maxDocumentNodes)
placementNodes := destinationWrapperNodeCount(state.destination)
if err != nil { if err != nil {
diagnostics = []Diagnostic{sourceDiagnostic(CodeSourceLimitExceeded, main.source.ID, "configuration exceeds the source depth or node limit after destination placement")} diagnostics = []Diagnostic{sourceDiagnostic(CodeSourceLimitExceeded, main.source.ID, "configuration exceeds the source depth or node limit after destination placement")}
} else if accounting.nodes+placementNodes > limits.nodes {
diagnostics = []Diagnostic{importDiagnostic(CodeImportLimitExceeded, "configuration exceeds the aggregate node limit", nil)}
} else { } else {
accounting.nodes += placementNodes
state.documents = append(state.documents, placed) state.documents = append(state.documents, placed)
diagnostics = state.walkDeclarations(basePath, declarations, 0) diagnostics = state.walkDeclarations(basePath, declarations, 0)
} }
@@ -303,6 +307,11 @@ func (s *importTraversal) visitPath(basePath string, declaration importDeclarati
if err != nil { if err != nil {
return []Diagnostic{importDiagnostic(CodeImportLimitExceeded, "configuration exceeds the source depth or node limit after destination placement", &location)} return []Diagnostic{importDiagnostic(CodeImportLimitExceeded, "configuration exceeds the source depth or node limit after destination placement", &location)}
} }
placementNodes := destinationWrapperNodeCount(s.destination)
if s.accounting.nodes+placementNodes > s.limits.nodes {
return []Diagnostic{importDiagnostic(CodeImportLimitExceeded, "configuration exceeds the aggregate node limit", &location)}
}
s.accounting.nodes += placementNodes
if versionNode := fragmentVersionClaim(placed, s.rule); versionNode != nil { if versionNode := fragmentVersionClaim(placed, s.rule); versionNode != nil {
versionLocation := publicLocation(versionNode.Location) versionLocation := publicLocation(versionNode.Location)
return []Diagnostic{{ return []Diagnostic{{

View File

@@ -56,7 +56,9 @@ func assembleBaseInputTracked[T any](source Source, options Options[T], destinat
return input, []Diagnostic{sourceDiagnostic(CodeSourceLimitExceeded, parsed.source.ID, "configuration exceeds the source depth or node limit after destination placement")} return input, []Diagnostic{sourceDiagnostic(CodeSourceLimitExceeded, parsed.source.ID, "configuration exceeds the source depth or node limit after destination placement")}
} }
input.document = placed input.document = placed
if accounting.sources+1 > limits.sources || accounting.bytes+parsed.bytes > limits.bytes || accounting.nodes+countInputNodes(parsed.document) > limits.nodes { rawNodes := countInputNodes(parsed.document)
placementNodes := destinationWrapperNodeCount(destination)
if accounting.sources+1 > limits.sources || accounting.bytes+parsed.bytes > limits.bytes || accounting.nodes+rawNodes+placementNodes > limits.nodes {
return input, []Diagnostic{sourceDiagnostic(CodeSourceLimitExceeded, parsed.source.ID, "configuration exceeds the aggregate input limit")} return input, []Diagnostic{sourceDiagnostic(CodeSourceLimitExceeded, parsed.source.ID, "configuration exceeds the aggregate input limit")}
} }
if diagnostic := registerAcquiredSource(source, options.Filesystem, parsed.source, visited, hostToken); diagnostic != nil { if diagnostic := registerAcquiredSource(source, options.Filesystem, parsed.source, visited, hostToken); diagnostic != nil {
@@ -64,7 +66,7 @@ func assembleBaseInputTracked[T any](source Source, options Options[T], destinat
} }
accounting.sources++ accounting.sources++
accounting.bytes += parsed.bytes accounting.bytes += parsed.bytes
accounting.nodes += countInputNodes(parsed.document) accounting.nodes += rawNodes + placementNodes
} }
if !activeOverlay(options.Overlay) || options.Imports { if !activeOverlay(options.Overlay) || options.Imports {
contexts, diagnostic := contextsForAcquiredSources(source, options.Filesystem, parsed.source, input.sources) contexts, diagnostic := contextsForAcquiredSources(source, options.Filesystem, parsed.source, input.sources)