Skip to content

Commit c739145

Browse files
committed
fix(sdk): merge re-fetched nodes into the client store instead of overwriting
1 parent 2150150 commit c739145

20 files changed

Lines changed: 1900 additions & 110 deletions

File tree

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
The client store merge fix comes with four observable behaviour changes. First, the object returned by `get`, `filters` or `all` is a per-query snapshot and is no longer the same Python object as the store entry: `client.get(id) is client.store.get(id)` was previously true and now is false, although the two still compare equal (`==`). Second, the store hands out living objects: `store.get()` returns the same object across calls, and a later query that re-fetches the node updates that object in place. Third, the store is now timestamp-coherent per branch: the first population stamps the branch cache as live or as one `at` point in time, queries at the same timestamp use the store normally (a fully historical script gets complete store functionality), and a query at a mismatching timestamp skips the store with a warning instead of silently blending or overwriting data from a different point in time (which is what pre-1.23.0 versions did). Fourth, a successful `save()`, `create()` or `update()` now resets the node's in-memory mutation tracking: the persisted values count as server state, so later fetches of the same node refresh those fields in the store instead of treating the long-saved edit as a pending local change forever.

changelog/413.fixed.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Fixed the client store silently losing data when the same node was fetched more than once. Previously the latest query fully replaced the stored node, so a shallow re-fetch (for example a node returned as a related node of another query) dropped attributes and relationships that an earlier, deeper query had loaded, and left duplicate entries in the store. The store now keeps one object per node UUID and merges each fetch into it field by field: fields carried by the new fetch overwrite the stored ones (even to empty or `None`), fields the fetch did not request keep their stored value, and local unsaved edits always win over a re-fetch. The previous replace behaviour remains available per query and per `store.set()` call with `merge=False`, or globally with the new `store_merge` configuration option (`INFRAHUB_STORE_MERGE`).

dev/specs/ihs-138-store-merge/decisions.md

Lines changed: 140 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,10 @@ Add a `save()` test: `save()` calls `store.set(self)`, so `self` merges into the
4343
canonical copy while the caller keeps `self` - confirm no field is revived that
4444
the save did not touch.
4545

46-
**Decision:** ____________________
46+
**Decision: ACCEPTED (a).** Query methods return the per-query object; the store
47+
holds the merged canonical copy. Guiding rule agreed with the reporter: *"queries
48+
hand you what you asked for; the store remembers the union of everything it has
49+
seen."*
4750

4851
---
4952

@@ -70,7 +73,8 @@ Is that the intended model?
7073
**Cost.** New, documented behaviour. Covered by the section 6 "behaviour change
7174
must be clear" requirement.
7275

73-
**Decision:** ____________________
76+
**Decision: ACCEPTED.** The store keeps one always-current, merged object per node;
77+
`store.get()` references reflect later fetches. Unsaved local edits are protected.
7478

7579
---
7680

@@ -100,7 +104,14 @@ public `store.set(node=...)` default to?
100104
`Config.store_merge` (default merge); `set()` defaults to replace regardless. That
101105
is intentional and should be stated in the `set()` docstring.
102106

103-
**Decision:** ____________________
107+
**Decision: OVERRIDE -> (b) merge by default, uniformly.** The recommendation above
108+
was not taken. Agreed model: *anything that lands in the store gets merged*, with no
109+
special-casing by entry point - `store.set()` merges just like the query path. This
110+
is the simpler, single-rule mental model. Consequence: **replace is opt-in only** -
111+
the sole way to store a node verbatim / drop previously cached data is
112+
`store.set(node, merge=False)` or the `store_merge=False` config opt-out. The public
113+
`store.set()` docstring must state that it merges by default and how to force
114+
replace.
104115

105116
---
106117

@@ -126,7 +137,8 @@ is intentional and should be stated in the `set()` docstring.
126137
consistency, and we are willing to accept the enum/bool split (or make the
127138
per-call arg accept the enum too).
128139

129-
**Decision:** ____________________
140+
**Decision: ACCEPTED (a).** Bool `store_merge` with the full description. Revisit only
141+
if the description proves insufficient in review.
130142

131143
---
132144

@@ -150,7 +162,8 @@ types.**
150162
- Internal only (not public API), so low stakes - but the uniform accessor removes
151163
per-type special-casing in the merge.
152164

153-
**Decision:** ____________________
165+
**Decision: ACCEPTED.** `is_fetched` on `Attribute` and `RelatedNode`; uniform
166+
`is_fetched` accessor on `RelationshipManager` aliasing its existing `initialized`.
154167

155168
---
156169

@@ -168,20 +181,131 @@ node's relationship peers from the store, or only replace the node's own entry?
168181
- Peers fetched in the same query go through their own `set()` calls and follow the
169182
same `merge` flag independently. No special cascade needed.
170183

171-
**Decision:** ____________________
184+
**Decision: ACCEPTED.** `merge=False` replaces the node's own entry only; peers are
185+
handled independently.
186+
187+
---
188+
189+
## D7 - Mutation-flag lifecycle (added during implementation, 2026-07-04)
190+
191+
**Question.** D2 protects "unsaved local edits" via the mutation flags
192+
(`value_has_been_mutated`, `_peer_has_been_mutated`, `_has_update`). The code review
193+
showed those flags were sticky - never reset after a successful save - so a saved
194+
edit would block merge refreshes of that field forever, and the store would serve
195+
the stale saved value even after the server changed. What is the flag lifecycle?
196+
197+
**Decision: reset on save, propagate on merge.**
198+
199+
- A successful `create()`/`update()`/`save()` resets all three flag types
200+
(`_reset_mutation_tracking()` at the end of `_process_mutation_result`): the
201+
persisted values count as server state from then on.
202+
- The merge propagates the markers from an unsaved incoming copy instead of
203+
clearing them, so an unsaved edit merged into the store (manual `store.set`)
204+
keeps its will-be-saved status and is still sent by the store copy's next save.
205+
206+
**Why.** The two halves depend on each other: propagation is only safe because
207+
saving resets the flags (a freshly saved copy no longer reads as "pending"), and
208+
resetting is what makes "local edits win" mean *unsaved* edits, which is what D2
209+
intended. Consequence, listed in the changelog: mutation tracking now resets after
210+
a successful mutation, so a second `update()` no longer re-sends relationship edits
211+
that were already persisted.
212+
213+
---
214+
215+
## D8 - Same-peer identity fields gate on presence (refines the merge rule)
216+
217+
**Question.** The plan said cardinality-one peer identity "always refreshes" when
218+
the relationship was fetched. A payload carrying only `node { id }` for the *same*
219+
peer would then null previously fetched `hfid`/`display_label`/`typename`/`kind`
220+
and drop the cached `_peer` - the silent-loss class this feature exists to prevent
221+
(reachable via `from_graphql` on custom payloads + `store.set`, not via
222+
SDK-generated queries).
223+
224+
**Decision: split by peer change.** A changed peer (different id, including cleared
225+
to none) takes the full incoming identity, so moves and move-to-root behave as
226+
specified. The same peer only refreshes the descriptive identity fields the
227+
incoming payload actually carried - consistent with how attribute properties and
228+
edge properties already merge.
229+
230+
---
231+
232+
## D9 - Timestamp-coherent store (supersedes grill item 3's mechanism, 2026-07-04)
233+
234+
**Question.** Grill item 3 decided historical (`at`) reads must not blend into the
235+
live cache. The first implementation made `at` queries skip the store by default,
236+
with an explicit `populate_store=True` opt-in that forced replace. That required
237+
widening `populate_store` from `bool = True` to `bool | None = None` (to detect an
238+
explicit `True`), left `at` + `prefetch_relationships` without `.peer` resolution,
239+
and - because the opt-in forced replace - quietly revived the IHS-138 bug for
240+
fully-historical scripts. Was the signature change actually needed?
241+
242+
**Decision: no - make the store timestamp-coherent instead.** The store holds one
243+
timestamp context per branch: live data, or one `at` instant, stamped by the first
244+
population. Same-context queries use the store normally, so a script running all
245+
its queries at one `at` gets full store functionality (merge, `.peer`, hfid
246+
lookups). A mismatching population (live vs historical, or two different instants)
247+
emits a warning and skips the store for that query; the query itself still returns
248+
its results.
249+
250+
**Why.**
251+
252+
- The incoherence grill item 3 feared comes only from *mixing* timestamps; a
253+
consistent-`at` session is exactly as coherent as a live one and deserves the
254+
same cache behaviour, including the merge semantics this whole feature adds.
255+
- `populate_store: bool = True` is restored - no public signature change, no `None`
256+
sentinel in a boolean parameter.
257+
- Strictly safer than both alternatives considered: pre-1.23 silently overwrote
258+
live entries with historical data; skip-by-default silently degraded historical
259+
scripts. Warn-and-skip makes the mismatch visible without breaking mixed scripts
260+
(which are safer than they were on 1.22, not worse).
261+
- Mismatch policy is warn + skip rather than raise: raising would break scripts
262+
that mix `at` and live queries, which worked (subtly wrong) before, and this
263+
ships as a semver-minor bug fix.
264+
- Documented footgun: recompute a relative timestamp per call and every query
265+
after the first trips the warning - compute `at` once, or use one
266+
`client.clone()` per timestamp (the context is per client store, per branch).
172267

173268
---
174269

175270
## Summary table
176271

177-
| ID | Decision | Recommendation |
178-
|----|----------|----------------|
179-
| D1 | Returned vs stored object | (a) return per-query object; store is canonical |
180-
| D2 | In-place mutation model | Accept, documented, protect local edits |
181-
| D3 | `store.set()` default | (a) replace by default; queries merge by default |
182-
| D4 | Config option name/type | (a) bool `store_merge` + full description |
183-
| D5 | Presence-flag name | `is_fetched`, uniform accessor on all three types |
272+
| ID | Decision | Outcome |
273+
|----|----------|---------|
274+
| D1 | Returned vs stored object | Return per-query object; store holds the merged canonical copy |
275+
| D2 | In-place mutation model | Accept; one always-current merged object per node; protect local edits |
276+
| D3 | `store.set()` default | **Merge by default, uniformly** (override); replace is opt-in via `merge=False` |
277+
| D4 | Config option name/type | Bool `store_merge` + full description |
278+
| D5 | Presence-flag name | `is_fetched`, uniform accessor on all three field types |
184279
| D6 | `merge=False` scope | Node entry only; peers handled independently |
185-
186-
Once D1-D3 are settled, Stage 2 onward is unblocked. D4-D6 can be confirmed during
187-
implementation without reblocking.
280+
| D7 | Mutation-flag lifecycle | Reset on successful save; merge propagates pending markers (2026-07-04) |
281+
| D8 | Same-peer identity fields | Presence-gated for the same peer; full refresh on peer change (2026-07-04) |
282+
| D9 | `at` and the store | Timestamp-coherent per branch; mismatching populations warn + skip (2026-07-04) |
283+
284+
**Guiding rule:** queries hand you what you asked for; the store remembers the union
285+
of everything it has seen. Replace happens only when explicitly requested
286+
(`merge=False` / `store_merge=False`).
287+
288+
All decisions are settled and implemented (D1-D6 as planned; D7/D8/D9 added during
289+
the 2026-07-04 code-review hardening - see plan section 13 for the full list of
290+
implementation outcomes, including the performance constraints on `store.set()`).
291+
The remaining external gate is the 1.23.0 pre-release run of the Ansible collection
292+
and `infrahubctl` integration suites.
293+
294+
## Grill refinements (2026-07-02)
295+
296+
Stress-testing the plan (see plan section 12) added these, all accepted:
297+
298+
- **Merge scope** also covers node-level scalars (`display_label`, `typename`) and
299+
merges attributes/properties field-by-field (not object-swap).
300+
- **Kind change is a documented exception to D2:** if `typename` differs for the same
301+
uuid (a `ConvertObjectType` migration), the store entry is *replaced* wholesale, not
302+
mutated in place - merging across schemas is incoherent.
303+
- **`at` (time-travel) queries skip store population by default** (behaviour change
304+
from today; must be in the migration note). Explicit `populate_store=True` + `at`
305+
replaces rather than blends. *Superseded by D9:* the shipped mechanism is a
306+
timestamp-coherent store (one `at` context per branch, warn + skip on mismatch),
307+
which keeps the same goal - never blend timestamps - without the `populate_store`
308+
signature change.
309+
- **Release gate:** Ansible collection + `infrahubctl` integration suites run against
310+
the 1.23.0 pre-release; migration note enumerates the D1 identity change and the
311+
`at` change with before/after.

0 commit comments

Comments
 (0)