Count destination wrappers in shared input limits
This commit is contained in:
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
176
internal/engine/destination_accounting_test.go
Normal file
176
internal/engine/destination_accounting_test.go
Normal 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)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -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{{
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
Reference in New Issue
Block a user