diff --git a/internal/runtime/internal/controller/value_cache.go b/internal/runtime/internal/controller/value_cache.go index 6aae014ba9..00f69fa6f2 100644 --- a/internal/runtime/internal/controller/value_cache.go +++ b/internal/runtime/internal/controller/value_cache.go @@ -199,6 +199,17 @@ func (vc *valueCache) BuildContext() *vm.Scope { } } + // The syntax pkg only checks the first key when choosing the scope to evaluate an expression. + // if you have: + // - parent: "test": "component1": v + // - child: "test": "component2": v + // the expression "test.component1.v" would result in an error because it would use the child scope. + // To avoid such conflict, we merge the scopes into one: + // - parent: nil + // - child: "test": {"component1": v, "component2": v} + scope.Variables = mergeScopes(scope) + scope.Parent = nil + return scope } @@ -234,3 +245,49 @@ func (vc *valueCache) buildValue(from []ComponentID, offset int) interface{} { } return attrs } + +// mergeScopes merges the Variables maps of the scope hierarchy into a new map. +// The child always overrides the parent's value. +func mergeScopes(scope *vm.Scope) map[string]any { + merged := make(map[string]any) + + var mergeFrom func(*vm.Scope) + mergeFrom = func(s *vm.Scope) { + if s == nil { + return + } + mergeFrom(s.Parent) + for k, v := range s.Variables { + if pm, ok1 := merged[k].(map[string]any); ok1 { + if cm, ok2 := v.(map[string]any); ok2 { + merged[k] = mergeNestedMaps(pm, cm) + continue + } + } + merged[k] = v + } + } + + mergeFrom(scope) + return merged +} + +func mergeNestedMaps(parent, child map[string]any) map[string]any { + merged := make(map[string]any) + + for k, v := range parent { + merged[k] = v + } + + for k, v := range child { + if pm, ok1 := merged[k].(map[string]any); ok1 { + if cm, ok2 := v.(map[string]any); ok2 { + merged[k] = mergeNestedMaps(pm, cm) + continue + } + } + merged[k] = v + } + + return merged +} diff --git a/internal/runtime/internal/controller/value_cache_test.go b/internal/runtime/internal/controller/value_cache_test.go index eebcd9b642..ba60a57089 100644 --- a/internal/runtime/internal/controller/value_cache_test.go +++ b/internal/runtime/internal/controller/value_cache_test.go @@ -3,6 +3,7 @@ package controller import ( "testing" + "github.com/grafana/alloy/syntax/vm" "github.com/stretchr/testify/require" ) @@ -174,3 +175,119 @@ func TestModuleArgumentCache(t *testing.T) { }) } } + +func TestScopeMerge(t *testing.T) { + vc := newValueCache() + scope := vm.NewScope( + map[string]any{ + "test": map[string]any{ + "scope": barArgs{Number: 13}, + }, + }, + ) + vc.SetScope(scope) + vc.CacheExports(ComponentID{"foo", "bar"}, barArgs{Number: 12}) + res := vc.BuildContext() + + expected := map[string]any{ + "test": map[string]any{ + "scope": barArgs{ + Number: 13, + }, + }, + "foo": map[string]any{ + "bar": barArgs{ + Number: 12, + }, + }, + } + require.Equal(t, expected, res.Variables) +} + +func TestScopeMergeConflict(t *testing.T) { + vc := newValueCache() + scope := vm.NewScope( + map[string]any{ + "test": map[string]any{ + "scope": barArgs{Number: 13}, + }, + }, + ) + vc.SetScope(scope) + vc.CacheExports(ComponentID{"test", "bar"}, barArgs{Number: 12}) + res := vc.BuildContext() + + expected := map[string]any{ + "test": map[string]any{ + "scope": barArgs{ + Number: 13, + }, + "bar": barArgs{ + Number: 12, + }, + }, + } + require.Equal(t, expected, res.Variables) +} + +func TestScopeMergeOverride(t *testing.T) { + vc := newValueCache() + scope := vm.NewScope( + map[string]any{ + "test": map[string]any{ + "scope": barArgs{Number: 13}, + }, + }, + ) + vc.SetScope(scope) + vc.CacheExports(ComponentID{"test", "scope"}, barArgs{Number: 12}) + res := vc.BuildContext() + + expected := map[string]any{ + "test": map[string]any{ + "scope": barArgs{ + Number: 12, + }, + }, + } + require.Equal(t, expected, res.Variables) +} + +func TestScopeMergeMoreNesting(t *testing.T) { + vc := newValueCache() + parentScope := vm.NewScope( + map[string]any{ + "test": map[string]any{ + "cp1": map[string]any{ + "scope": barArgs{Number: 13}, + }, + }, + }, + ) + scope := vm.NewScopeWithParent( + parentScope, + map[string]any{ + "test": map[string]any{ + "cp2": barArgs{Number: 12}, + }, + }, + ) + vc.SetScope(scope) + vc.CacheExports(ComponentID{"test", "cp1", "foo"}, barArgs{Number: 12}) + vc.CacheExports(ComponentID{"test", "cp1", "bar", "fizz"}, barArgs{Number: 2}) + res := vc.BuildContext() + + expected := map[string]any{ + "test": map[string]any{ + "cp1": map[string]any{ + "scope": barArgs{Number: 13}, + "foo": barArgs{Number: 12}, + "bar": map[string]any{ + "fizz": barArgs{Number: 2}, + }, + }, + "cp2": barArgs{Number: 12}, + }, + } + require.Equal(t, expected, res.Variables) +}