Skip to content

Commit 7a463f5

Browse files
Matovidloclaude
andcommitted
fix: prevent PathsGenerator crash when keboola.variables config has multiple parents
When a keboola.variables config is referenced by more than one consumer config via the API, the relation mapper creates multiple variablesFor relations on it. The single-pass AfterRemoteOperation removes these duplicates and emits a warning — but only when consumers are iterated before the variables config. If the variables config is iterated first (the order used by the Templates service), the duplicate relations are not cleaned up before PathsGenerator runs, causing it to crash with "unexpected state: multiple parents defined by relations". Fix: change Config.ParentKey() to fall back to the structural parent (branch) when Relations.ParentKey() returns a multiple-parents error, instead of propagating the fatal error. This makes PathsGenerator non-fatal for the shared-variables scenario regardless of iteration order. The duplicate-relations warning is still emitted correctly by validateRelations in AfterRemoteOperation. Also reverts the earlier two-pass link.go change which introduced different warning messages and broke the variables-used-twice E2E test. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 8bf7edb commit 7a463f5

3 files changed

Lines changed: 52 additions & 59 deletions

File tree

docs/relations-validation.md

Lines changed: 26 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -29,35 +29,44 @@ 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 a fatal 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 an error:
3333

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

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.
38+
This guard exists to detect invalid state in the relation graph.
3939

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

42-
The relation mapper (`internal/pkg/mapper/relations/link.go`) processes objects in `AfterRemoteOperation` and `AfterLocalOperation`. It also validates them in the same loop iteration (link → validate per object). This is order-dependent:
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.
4343

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.
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.
45+
46+
## The ordering issue
4747

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.
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:
4949

50-
## The fix: two-pass link and validate
50+
```
51+
for each object:
52+
linkRelations(object) // adds other-side relations to OTHER objects
53+
validateRelations(object) // validates THIS object's relations
54+
```
55+
56+
This is order-dependent. If the variables config Y is iterated **before** its consumers X and Z:
57+
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.
5162

52-
`AfterRemoteOperation` and `AfterLocalOperation` were refactored into two explicit passes:
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.
5364

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.
65+
## The fix: branch fallback in Config.ParentKey()
5666

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

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. No new flags or special-casing were required.
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.
6170

6271
## Why this is warning-only, not a hard error
6372

@@ -69,8 +78,9 @@ Templates only reads remote state to discover existing configs and apply templat
6978

7079
| File | Role |
7180
|---|---|
72-
| `internal/pkg/mapper/relations/link.go` | Two-pass AfterRemoteOperation / AfterLocalOperation |
81+
| `internal/pkg/model/object.go` | `Config.ParentKey()` — branch fallback when multiple relation parents |
7382
| `internal/pkg/model/relation.go` | `Relations.ParentKey()`, `OneToXRelations()` |
83+
| `internal/pkg/mapper/relations/link.go` | Single-pass AfterRemoteOperation / AfterLocalOperation |
7484
| `internal/pkg/model/variables.go` | `VariablesForRelation`, `VariablesFromRelation` definitions |
7585
| `internal/pkg/state/remote/manager.go` | Sequencing of AfterRemoteOperation → PathsGenerator |
7686
| `test/cli/pull/variables-used-twice/` | E2E test covering the shared-variables warning |

internal/pkg/mapper/relations/link.go

Lines changed: 19 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -12,27 +12,9 @@ import (
1212
func (m *relationsMapper) AfterLocalOperation(ctx context.Context, changes *model.LocalChanges) error {
1313
errs := errors.NewMultiError()
1414
allObjects := m.state.LocalObjects()
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-
}
15+
for _, objectState := range changes.Loaded() {
16+
if err := m.linkAndValidateRelations(objectState.LocalState(), allObjects); err != nil {
17+
errs.Append(err)
3618
}
3719
}
3820

@@ -48,24 +30,9 @@ func (m *relationsMapper) AfterLocalOperation(ctx context.Context, changes *mode
4830
func (m *relationsMapper) AfterRemoteOperation(ctx context.Context, changes *model.RemoteChanges) error {
4931
errs := errors.NewMultiError()
5032
allObjects := m.state.RemoteObjects()
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-
}
33+
for _, objectState := range changes.Loaded() {
34+
if err := m.linkAndValidateRelations(objectState.RemoteState(), allObjects); err != nil {
35+
errs.Append(err)
6936
}
7037
}
7138

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

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+
8060
// lintRelations finds the other side of the relation and create a corresponding relation on the other side.
8161
func (m *relationsMapper) linkRelations(object model.ObjectWithRelations, allObjects model.Objects) error {
8262
errs := errors.NewMultiError()

internal/pkg/model/object.go

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -595,14 +595,17 @@ 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.
598602
func (c *Config) ParentKey() (Key, error) {
599-
if parentKey, err := c.Relations.ParentKey(c.Key()); err != nil {
600-
return nil, err
601-
} else if parentKey != nil {
603+
parentKey, err := c.Relations.ParentKey(c.Key())
604+
if err == nil && parentKey != nil {
602605
return parentKey, nil
603606
}
604607

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

0 commit comments

Comments
 (0)