You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
fix: two-pass relation linking to prevent order-dependent multiple-parents fatal error
When a keboola.variables config is referenced by more than one consumer
config, the mapper creates multiple variablesFor relations on it. In the
original single-pass AfterRemoteOperation (link+validate per object),
if the variables config is iterated before its consumers, validateRelations
runs when it has 0 variablesFor — finding nothing to clean up. Consumers
are processed later, leaving two variablesFor entries that cause
PathsGenerator to crash with "multiple parents defined by relations".
This ordering happened in the Templates service but not in CLI pull
(where the API returns consumers before the variables config).
Fix: split AfterRemoteOperation and AfterLocalOperation into two passes:
Pass 1 — linkRelations for all objects (builds complete relation graph)
Pass 2 — validateRelations for all objects (detects and removes duplicates)
This is order-independent: the variables config always has both variablesFor
entries present when validated, so cleanup always fires before PathsGenerator.
Also suppress the duplicate warning that would otherwise appear in Pass 1:
VariablesValuesForRelation.NewOtherSideRelation silently skips (nil, nil, nil)
when the parent variables config has multiple variablesFor — deferring to
Pass 2 to emit the canonical "only one relation variablesFor expected" warning.
The ignore mapper then excludes the orphaned config and its rows from the
local output, same as before.
Update expected-stderr for the variables-used-twice E2E test to reflect the
new single warning (the secondary "missing relation variablesFor" message was
an artifact of the old single-pass ordering and is no longer produced).
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Copy file name to clipboardExpand all lines: docs/relations-validation.md
+30-25Lines changed: 30 additions & 25 deletions
Display the source diff
Display the rich diff
Original file line number
Diff line number
Diff line change
@@ -29,58 +29,63 @@ Keboola's Storage API allows a variables config to be referenced by more than on
29
29
30
30
## The `multiple parents` guard
31
31
32
-
`Relations.ParentKey()` (`internal/pkg/model/relation.go:66`) collects all relations that define a parent key. If more than one is found it returns an error:
32
+
`Relations.ParentKey()` (`internal/pkg/model/relation.go:66`) collects all relations that define a parent key. If more than one is found it returns a fatal error:
33
33
34
34
```
35
35
unexpected state: multiple parents defined by "relations" in <config desc>
36
36
```
37
37
38
-
This guard exists to detect invalid state in the relation graph.
38
+
This guard exists to protect the CLI sync engine: without it, path generation would be ambiguous and the local directory structure would be corrupted.
39
39
40
-
## Config.ParentKey() and PathsGenerator
40
+
## The ordering bug that broke Templates
41
41
42
-
`Config.ParentKey()`(`internal/pkg/model/object.go`) calls `Relations.ParentKey()` to find the relation-defined parent. If that returns an error (multiple parents) or nil (no parent), it falls back to the structural parent — the branch.
42
+
The relation mapper (`internal/pkg/mapper/relations/link.go`) previously processed objects in a single pass: link → validate per object. This is order-dependent:
43
43
44
-
`PathsGenerator.doUpdate()` (`internal/pkg/state/local/paths.go`) calls `object.ParentKey()` on every loaded object to build local directory paths. With the branch-fallback in `Config.ParentKey()`, a variables config that has multiple `variablesFor` relations is placed at the branch root (e.g. `main/variables/`) rather than inside any specific parent folder. This is non-fatal: PathsGenerator can complete and remote state loading succeeds.
44
+
1. If the variables config Y is iterated **before** its consumers X and Z, `validateRelations(Y)` runs when Y has zero `variablesFor` relations — nothing to clean up.
45
+
2. Later iterations for X and Z call `linkRelations`, which adds `VariablesForRelation` entries to Y.
46
+
3. After the mapper loop, `PathsGenerator.Invoke()` (called in `remote/manager.go`) calls `Y.ParentKey()`, finds two parents, and returns the fatal error.
45
47
46
-
## The ordering issue
48
+
CLI `pull` was unaffected in practice because the API happened to return consumer configs before variables configs, making consumers iterated first. The Templates service load path had a different iteration order and hit the bug.
47
49
48
-
The relation mapper (`internal/pkg/mapper/relations/link.go`) processes objects in `AfterRemoteOperation`and `AfterLocalOperation`. It links and validates each object in a single pass:
50
+
## The fix: two-pass linkand validate
49
51
50
-
```
51
-
for each object:
52
-
linkRelations(object) // adds other-side relations to OTHER objects
53
-
validateRelations(object) // validates THIS object's relations
54
-
```
52
+
`AfterRemoteOperation` and `AfterLocalOperation` were refactored into two explicit passes:
55
53
56
-
This is order-dependent. If the variables config Y is iterated **before** its consumers X and Z:
54
+
**Pass 1 — link all objects**
55
+
Run `linkRelations` for every loaded object. This creates all other-side relations (including adding `variablesFor` entries to every variables config) before any validation happens.
57
56
58
-
1.`validateRelations(Y)` runs when Y has zero `variablesFor` relations — nothing to clean up.
59
-
2. Later iterations for X and Z call `linkRelations`, which adds `VariablesForRelation` entries to Y.
60
-
3. Y ends up with two `variablesFor` relations in the loaded remote state.
61
-
4.`PathsGenerator.doUpdate()` calls `Y.ParentKey()` — before the branch-fallback fix this was a fatal error.
57
+
**Pass 2 — validate all objects**
58
+
Run `validateRelations` for every loaded object. With the complete relation graph now in place, duplicates are correctly detected, removed, and logged as warnings — regardless of the order objects appear in the API response.
59
+
60
+
This makes behaviour order-independent: both CLI and the Templates service emit a warning and continue when a variables config has multiple parents, instead of crashing.
61
+
62
+
## Silent skip in VariablesValuesForRelation.NewOtherSideRelation
63
+
64
+
A values row's `variablesValuesFor` relation is linked by looking up the parent variables config's single `variablesFor` relation to determine the target consumer config. In the two-pass approach, Pass 1 runs before validation, so when `linkRelations(values_row)` runs, the parent variables config Y may already hold two `variablesFor` entries (added earlier in the same pass by the consumer configs).
65
+
66
+
Calling `GetOneByType(VariablesForRelType)` on Y at this point returns an error ("only one expected, but found 2"). If that error were propagated, it would produce a duplicate "invalid config Y" message alongside the one already generated by Pass 2 validation of Y itself.
62
67
63
-
CLI `pull` was unaffected in practice because the API happened to return consumer configs before variables configs, making consumers iterated first. The Templates service load path had a different iteration order and triggered step 3-4.
68
+
To avoid the duplicate, `NewOtherSideRelation` silently returns `(nil, nil, nil)` when `GetOneByType` reports multiple relations — deferring to Pass 2 to emit the canonical warning. The `variablesValuesFor` relation on the values row is left in place; the values row is then transitively excluded from the pull output by the ignore mapper (see below).
64
69
65
-
## The fix: branch fallback in Config.ParentKey()
70
+
## Why the ignore mapper excludes orphaned variables configs
66
71
67
-
`Config.ParentKey()` was changed to ignore the "multiple parents" error from `Relations.ParentKey()` and fall back to the branch (structural parent) instead of propagating the error. This makes PathsGenerator non-fatal for shared-variables configs regardless of iteration order.
72
+
After `AfterRemoteOperation` completes, the ignore mapper (`internal/pkg/mapper/ignore/remote.go`) runs. It marks a `keboola.variables` config as ignored when it has no `variablesFor` relation. Config rows are **transitively ignored** when their parent config is ignored. So after Pass 2 removes Y's duplicate `variablesFor` entries (leaving Y with zero), both Y and its values rows are removed from `changes.Loaded()` and never written to the local filesystem.
68
73
69
-
The `validateRelations` function still detects the duplicate `variablesFor` relations and logs a warning. In CLI `pull`, this warning fires correctly when consumers are iterated before the variables config (the API's typical return order). In the Templates service load path the warning may fire differently (or not at all for the duplicate, depending on iteration order), but Templates is only reading remote state — it never generates a local directory hierarchy — so the warning is not critical there.
74
+
`PathsGenerator` only processes objects in `changes.Loaded()`, so it never encounters Y with multiple parents — making the fatal guard in `Relations.ParentKey()` safe to keep as-is.
70
75
71
76
## Why this is warning-only, not a hard error
72
77
73
-
The `validateRelations` function removes all duplicate relations and logs a warning. The invalid `variablesFor` entries are dropped, leaving the variables config parentless for path generation purposes (it is placed at the branch root rather than inside a parent folder). The project can still be synced — it just does not represent the shared-variables scenario in the local directory hierarchy.
78
+
The `validateRelations` function removes all duplicate relations and logs a warning. The invalid `variablesFor` entries are dropped and the variables config (along with its rows) is excluded from the local sync output. The project can still be synced — it just does not represent the shared-variables scenario in the local directory hierarchy.
74
79
75
80
Templates only reads remote state to discover existing configs and apply template changes on top. It never generates or syncs a local directory hierarchy, so the "ambiguous path" concern does not apply. Making this a hard error in the Templates code path only served to block users from using templates in projects with this configuration.
76
81
77
82
## Related files
78
83
79
84
| File | Role |
80
85
|---|---|
81
-
|`internal/pkg/model/object.go`|`Config.ParentKey()` — branch fallback when multiple relation parents|
0 commit comments