Skip to content

Commit fc31379

Browse files
Matovidloclaude
andcommitted
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>
1 parent 7a463f5 commit fc31379

5 files changed

Lines changed: 78 additions & 55 deletions

File tree

docs/relations-validation.md

Lines changed: 30 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -29,58 +29,63 @@ Keboola's Storage API allows a variables config to be referenced by more than on
2929

3030
## The `multiple parents` guard
3131

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:
3333

3434
```
3535
unexpected state: multiple parents defined by "relations" in <config desc>
3636
```
3737

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.
3939

40-
## Config.ParentKey() and PathsGenerator
40+
## The ordering bug that broke Templates
4141

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:
4343

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.
4547

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.
4749

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 link and validate
4951

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:
5553

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.
5756

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.
6267

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).
6469

65-
## The fix: branch fallback in Config.ParentKey()
70+
## Why the ignore mapper excludes orphaned variables configs
6671

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.
6873

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.
7075

7176
## Why this is warning-only, not a hard error
7277

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.
7479

7580
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.
7681

7782
## Related files
7883

7984
| File | Role |
8085
|---|---|
81-
| `internal/pkg/model/object.go` | `Config.ParentKey()` — branch fallback when multiple relation parents |
86+
| `internal/pkg/mapper/relations/link.go` | Two-pass AfterRemoteOperation / AfterLocalOperation |
8287
| `internal/pkg/model/relation.go` | `Relations.ParentKey()`, `OneToXRelations()` |
83-
| `internal/pkg/mapper/relations/link.go` | Single-pass AfterRemoteOperation / AfterLocalOperation |
84-
| `internal/pkg/model/variables.go` | `VariablesForRelation`, `VariablesFromRelation` definitions |
88+
| `internal/pkg/model/variables.go` | `VariablesValuesForRelation.NewOtherSideRelation` — silent skip for multiple variablesFor |
89+
| `internal/pkg/mapper/ignore/remote.go` | Transitive ignore of orphaned variables configs and their rows |
8590
| `internal/pkg/state/remote/manager.go` | Sequencing of AfterRemoteOperation → PathsGenerator |
8691
| `test/cli/pull/variables-used-twice/` | E2E test covering the shared-variables warning |

internal/pkg/mapper/relations/link.go

Lines changed: 39 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -12,9 +12,27 @@ import (
1212
func (m *relationsMapper) AfterLocalOperation(ctx context.Context, changes *model.LocalChanges) error {
1313
errs := errors.NewMultiError()
1414
allObjects := m.state.LocalObjects()
15-
for _, objectState := range changes.Loaded() {
16-
if err := m.linkAndValidateRelations(objectState.LocalState(), allObjects); err != nil {
17-
errs.Append(err)
15+
loaded := changes.Loaded()
16+
17+
// Pass 1: link all objects so every other-side relation exists before validation.
18+
// Processing link and validate in a single loop would cause order-dependent behaviour:
19+
// a variables config iterated before its consumers would be validated before the consumer
20+
// linking steps add VariablesForRelation entries to it, leaving duplicates undetected until
21+
// PathsGenerator runs and hits a fatal "multiple parents" error.
22+
for _, objectState := range loaded {
23+
if o, ok := objectState.LocalState().(model.ObjectWithRelations); ok {
24+
if err := m.linkRelations(o, allObjects); err != nil {
25+
errs.Append(err)
26+
}
27+
}
28+
}
29+
30+
// Pass 2: validate all objects now that the relation graph is complete.
31+
for _, objectState := range loaded {
32+
if o, ok := objectState.LocalState().(model.ObjectWithRelations); ok {
33+
if err := m.validateRelations(o); err != nil {
34+
errs.Append(errors.PrefixErrorf(err, "invalid %s", objectState.LocalState().Desc()))
35+
}
1836
}
1937
}
2038

@@ -30,9 +48,24 @@ func (m *relationsMapper) AfterLocalOperation(ctx context.Context, changes *mode
3048
func (m *relationsMapper) AfterRemoteOperation(ctx context.Context, changes *model.RemoteChanges) error {
3149
errs := errors.NewMultiError()
3250
allObjects := m.state.RemoteObjects()
33-
for _, objectState := range changes.Loaded() {
34-
if err := m.linkAndValidateRelations(objectState.RemoteState(), allObjects); err != nil {
35-
errs.Append(err)
51+
loaded := changes.Loaded()
52+
53+
// Pass 1: link all objects so every other-side relation exists before validation.
54+
// See AfterLocalOperation for the reasoning behind the two-pass approach.
55+
for _, objectState := range loaded {
56+
if o, ok := objectState.RemoteState().(model.ObjectWithRelations); ok {
57+
if err := m.linkRelations(o, allObjects); err != nil {
58+
errs.Append(err)
59+
}
60+
}
61+
}
62+
63+
// Pass 2: validate all objects now that the relation graph is complete.
64+
for _, objectState := range loaded {
65+
if o, ok := objectState.RemoteState().(model.ObjectWithRelations); ok {
66+
if err := m.validateRelations(o); err != nil {
67+
errs.Append(errors.PrefixErrorf(err, "invalid %s", objectState.RemoteState().Desc()))
68+
}
3669
}
3770
}
3871

@@ -44,19 +77,6 @@ func (m *relationsMapper) AfterRemoteOperation(ctx context.Context, changes *mod
4477
return nil
4578
}
4679

47-
func (m *relationsMapper) linkAndValidateRelations(object model.Object, allObjects model.Objects) error {
48-
errs := errors.NewMultiError()
49-
if o, ok := object.(model.ObjectWithRelations); ok {
50-
if err := m.linkRelations(o, allObjects); err != nil {
51-
errs.Append(err)
52-
}
53-
if err := m.validateRelations(o); err != nil {
54-
errs.Append(errors.PrefixErrorf(err, "invalid %s", object.Desc()))
55-
}
56-
}
57-
return errs.ErrorOrNil()
58-
}
59-
6080
// lintRelations finds the other side of the relation and create a corresponding relation on the other side.
6181
func (m *relationsMapper) linkRelations(object model.ObjectWithRelations, allObjects model.Objects) error {
6282
errs := errors.NewMultiError()

internal/pkg/model/object.go

Lines changed: 4 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -595,17 +595,14 @@ func (r *ConfigRow) GetContent() *orderedmap.OrderedMap {
595595
}
596596

597597
// ParentKey - config parent can be modified via Relations, for example variables config is embedded in another config.
598-
// If multiple relation-defined parents exist (e.g. a shared keboola.variables config referenced by more than one
599-
// consumer), the relations error is ignored and the config falls back to its structural parent (branch). The
600-
// duplicate-relations validation in AfterRemoteOperation logs a warning and removes the extra relations; until that
601-
// cleanup runs, PathsGenerator must not crash so that remote state loading can complete.
602598
func (c *Config) ParentKey() (Key, error) {
603-
parentKey, err := c.Relations.ParentKey(c.Key())
604-
if err == nil && parentKey != nil {
599+
if parentKey, err := c.Relations.ParentKey(c.Key()); err != nil {
600+
return nil, err
601+
} else if parentKey != nil {
605602
return parentKey, nil
606603
}
607604

608-
// No relation-defined parent (or multiple parents — treat as no parent) -> parent is branch.
605+
// No parent defined via "Relations" -> parent is branch
609606
return c.ConfigKey.ParentKey()
610607
}
611608

internal/pkg/model/variables.go

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -180,7 +180,10 @@ func (t *VariablesValuesForRelation) NewOtherSideRelation(relationDefinedOn Obje
180180
variablesConfig := variablesConfigRaw.(*Config)
181181
variablesForRaw, err := variablesConfig.Relations.GetOneByType(VariablesForRelType)
182182
if err != nil {
183-
return nil, nil, errors.PrefixErrorf(err, "invalid %s", variablesConfig.Desc())
183+
// Multiple variablesFor relations exist on the variables config (shared across consumers).
184+
// Pass 2 validation will detect and warn about the duplicates; skip linking here to avoid
185+
// a redundant error before the cleanup runs.
186+
return nil, nil, nil
184187
}
185188
if variablesForRaw == nil {
186189
return nil, nil, errors.NewNestedError(

test/cli/pull/variables-used-twice/expected-stderr

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,4 @@ Warning:
33
- Only one relation "variablesFor" expected, but found 2:
44
- {"componentId":"ex-generic-v2","configId":"%s"}
55
- {"componentId":"ex-generic-v2","configId":"%s"}
6-
- Missing relation "variablesFor" in config "branch:%s/component:keboola.variables/config:%s":
7-
- Referenced from config row "branch:%s/component:keboola.variables/config:%s/row:%s".
8-
- By relation "variablesValuesFor".
6+

0 commit comments

Comments
 (0)