Skip to content

Commit 8bf7edb

Browse files
Matovidloclaude
andcommitted
PSGO-227: fix: two-pass relation linking to prevent order-dependent multiple-parents fatal error
Split AfterRemoteOperation and AfterLocalOperation in relations/link.go into two passes instead of the previous single link+validate-per-object loop: - Pass 1: linkRelations for ALL objects so every other-side relation (including VariablesForRelation entries added to keboola.variables configs) exists before any validation runs. - Pass 2: validateRelations for ALL objects with the complete relation graph, so duplicate variablesFor relations are detected, removed, and logged as warnings regardless of object iteration order. The previous single-pass loop was order-dependent: if a variables config was iterated before its consumers, validateRelations ran with zero variablesFor relations (clean), then consumer linking added two entries, and PathsGenerator later called ParentKey() on a config with two parents — a fatal error. CLI pull was unaffected in practice because the API returned consumers first; the Templates service had a different iteration order and crashed. Add docs/relations-validation.md explaining the variables parent-child model, the ordering bug, the fix, and why shared-variables is warning-only. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent d8d4b6a commit 8bf7edb

2 files changed

Lines changed: 115 additions & 19 deletions

File tree

docs/relations-validation.md

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
# Variables Relations Validation
2+
3+
## Background: the keboola.variables parent-child model
4+
5+
In the Keboola platform, variable configs (`keboola.variables` component) belong to a parent config (e.g. a transformation). In the local CLI directory layout, the variables config lives *inside* the parent's folder:
6+
7+
```
8+
my-transformation/
9+
variables/
10+
config.json ← keboola.variables config
11+
variables/
12+
default/ ← variables values row
13+
```
14+
15+
This parent-child ownership is expressed through relations stored on the objects:
16+
17+
| Relation type | Stored on | Direction | Storage |
18+
|---|---|---|---|
19+
| `variablesFor` | variables config | → parent config | manifest (local only) |
20+
| `variablesFrom` | parent config | → variables config | API |
21+
| `variablesValuesFor` | values row | → parent config | manifest |
22+
| `variablesValuesFrom` | parent config | → values row | API |
23+
24+
The `variablesFor` relation on the variables config determines which parent folder it is placed in during sync. Because each object can have only one file-system path, **each variables config can have exactly one `variablesFor` relation** (i.e. one parent). This is enforced by `OneToXRelations()` in `internal/pkg/model/relation.go`.
25+
26+
## Why multiple parents can appear in the API
27+
28+
Keboola's Storage API allows a variables config to be referenced by more than one consumer via the project's configuration metadata. If a user creates such a setup through the API or the UI directly, the `keboola.variables` config will have multiple `variablesFrom` back-references, which the mapper translates into multiple `variablesFor` relations during the linking step.
29+
30+
## The `multiple parents` guard
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 a fatal error:
33+
34+
```
35+
unexpected state: multiple parents defined by "relations" in <config desc>
36+
```
37+
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+
40+
## The ordering bug that broke Templates
41+
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:
43+
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.
47+
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.
49+
50+
## The fix: two-pass link and validate
51+
52+
`AfterRemoteOperation` and `AfterLocalOperation` were refactored into two explicit passes:
53+
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.
56+
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. No new flags or special-casing were required.
61+
62+
## Why this is warning-only, not a hard error
63+
64+
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.
65+
66+
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.
67+
68+
## Related files
69+
70+
| File | Role |
71+
|---|---|
72+
| `internal/pkg/mapper/relations/link.go` | Two-pass AfterRemoteOperation / AfterLocalOperation |
73+
| `internal/pkg/model/relation.go` | `Relations.ParentKey()`, `OneToXRelations()` |
74+
| `internal/pkg/model/variables.go` | `VariablesForRelation`, `VariablesFromRelation` definitions |
75+
| `internal/pkg/state/remote/manager.go` | Sequencing of AfterRemoteOperation → PathsGenerator |
76+
| `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()

0 commit comments

Comments
 (0)