Skip to content

Commit 2150150

Browse files
committed
Plan for store merge
1 parent ea2a995 commit 2150150

2 files changed

Lines changed: 701 additions & 0 deletions

File tree

Lines changed: 187 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,187 @@
1+
# IHS-138 - Decision sign-off sheet
2+
3+
One row per open decision from [`plan.md`](./plan.md) section 11. Each has a
4+
recommendation and the reasoning; mark **Accept** / **Override** and note any
5+
change. Nothing in Stage 1 (presence flags) depends on these - they gate Stage 2
6+
onward.
7+
8+
Already settled (recorded in the plan, listed here for completeness):
9+
10+
- **Version:** SDK 1.23.0, semver minor, shipped as a bug fix with a required
11+
opt-out and a required behaviour-change note.
12+
- **Where the option lives:** `Config` default -> store default -> per-call
13+
`merge=` override.
14+
15+
---
16+
17+
## D1 - Returned object vs stored object (C2)
18+
19+
**Question.** After a re-fetch, the store keeps the merged canonical object. What
20+
does a query method (`get` / `all` / `filters`) return?
21+
22+
- **(a) Return the freshly built per-query object** (diverges from the store copy).
23+
- **(b) Return the canonical merged store object.**
24+
25+
**Recommendation: (a).**
26+
27+
**Why.**
28+
29+
- Minor-version safe: what `client.get()` returns is byte-for-byte what it returns
30+
today (exactly the fields this query asked for). Only `store.get()` gets richer.
31+
Option (b) changes every query's return value to carry data the caller did not
32+
request - much harder to call non-breaking.
33+
- It shrinks the D2 blast radius: the only "living" object is the internal store
34+
copy; returned objects are stable per-query snapshots.
35+
- Less code: (b) needs the query path to map incoming -> canonical and swap
36+
returned references.
37+
- Clean mental model: "the return value is this query's result; the store
38+
accumulates across queries."
39+
40+
**Cost / what to verify.** `client.get(id) is client.store.get(id)` stops being
41+
true (it holds today). Value equality `==` still holds (id-based, `node.py:720`).
42+
Add a `save()` test: `save()` calls `store.set(self)`, so `self` merges into the
43+
canonical copy while the caller keeps `self` - confirm no field is revived that
44+
the save did not touch.
45+
46+
**Decision:** ____________________
47+
48+
---
49+
50+
## D2 - In-place mutation / living objects (C3)
51+
52+
**Question.** Merge mutates the existing stored object, so a `store.get()`
53+
reference can change under the caller when an unrelated query re-fetches that node.
54+
Is that the intended model?
55+
56+
**Recommendation: accept it, documented, with local-edit protection.**
57+
58+
**Why.**
59+
60+
- It is the correct property for a cache: every store reference points at the one
61+
canonical object, which is always current. The alternative (merge into a new
62+
object, replace the entry) leaves previously handed-out `store.get()` references
63+
stale and diverging from the store - worse.
64+
- Under D1(a) the surprise is confined to code that deliberately holds a
65+
`store.get()` result across queries; plain query return values never mutate.
66+
- Protect unsaved local edits: merge must skip fields flagged
67+
`value_has_been_mutated` (`attribute.py:100`) / `_peer_has_been_mutated`
68+
(`related_node.py:74`), so re-querying never discards in-memory changes.
69+
70+
**Cost.** New, documented behaviour. Covered by the section 6 "behaviour change
71+
must be clear" requirement.
72+
73+
**Decision:** ____________________
74+
75+
---
76+
77+
## D3 - Default for the public `store.set()` (C5)
78+
79+
**Question.** The query-population path defaults to merge (the fix). What should the
80+
public `store.set(node=...)` default to?
81+
82+
- **(a) `merge=False` (replace) by default**, opt into merge.
83+
- **(b) `merge=True` (merge) by default**, same as queries.
84+
85+
**Recommendation: (a) - public `set()` defaults to replace.**
86+
87+
**Why.**
88+
89+
- "set" reads as an imperative "make the store hold this," like `dict[k] = v`.
90+
Merge is the enrichment behaviour the *query* path needs, not what an explicit
91+
`set` implies.
92+
- Preserves the documented contract ("store this object", `store.mdx:147`). That
93+
example sets a fresh object under a custom key, usually with no prior entry, so
94+
replace and merge are identical there - no example breaks.
95+
- The IHS-138 bug lives in the query path, not in manual `set()`. Keep the default
96+
change where the bug is; leave the explicit call predictable.
97+
- A user who wants enrichment passes `merge=True`.
98+
99+
**Note.** This means the default differs by entry point: queries follow
100+
`Config.store_merge` (default merge); `set()` defaults to replace regardless. That
101+
is intentional and should be stated in the `set()` docstring.
102+
103+
**Decision:** ____________________
104+
105+
---
106+
107+
## D4 - Config option name and type
108+
109+
**Question.** How is the global default expressed on `Config`?
110+
111+
- **(a) Bool `store_merge` with a full `description`** (drafted in plan section 3).
112+
- **(b) A clearer bool name** (e.g. `merge_store_results`).
113+
- **(c) Enum `store_update_mode: StoreUpdateMode` (`MERGE` / `REPLACE`)**, matching
114+
existing `Config` enums (`InfrahubClientMode`, `RecorderType`).
115+
116+
**Recommendation: (a) - bool `store_merge` carried by its description.**
117+
118+
**Why.**
119+
120+
- Type-consistency with the per-call `merge: bool` argument. An enum on `Config`
121+
plus a bool per call is a mismatch; keeping both bool is simplest to reason about.
122+
- The clarity the user asked for is delivered by the `description` (which states
123+
both behaviours and the default), per the section 6 hard requirement - so the
124+
terse name is acceptable.
125+
- Enum is the fallback if call-site self-documentation is valued over type
126+
consistency, and we are willing to accept the enum/bool split (or make the
127+
per-call arg accept the enum too).
128+
129+
**Decision:** ____________________
130+
131+
---
132+
133+
## D5 - Internal presence-flag naming
134+
135+
**Question.** Name for the new "present in this response" flag on `Attribute` and
136+
`RelatedNode`.
137+
138+
**Recommendation: `is_fetched`, with a uniform accessor across all three field
139+
types.**
140+
141+
**Why.**
142+
143+
- `RelatedNode` already has `initialized` meaning "has a peer"
144+
(`related_node.py:189`) - a *different* concept - so the new flag there cannot be
145+
called `initialized` without a confusing collision.
146+
- `RelationshipManager.initialized` already means "was fetched." Expose
147+
`is_fetched` on it as a thin alias of `initialized`, add a real `is_fetched`
148+
flag to `Attribute` and `RelatedNode`, and the merge code can branch uniformly
149+
on `field.is_fetched` regardless of field type.
150+
- Internal only (not public API), so low stakes - but the uniform accessor removes
151+
per-type special-casing in the merge.
152+
153+
**Decision:** ____________________
154+
155+
---
156+
157+
## D6 - Scope of `merge=False` (replace)
158+
159+
**Question.** When a node is stored with `merge=False`, does it also drop that
160+
node's relationship peers from the store, or only replace the node's own entry?
161+
162+
**Recommendation: only replace the node's own entry.**
163+
164+
**Why.**
165+
166+
- Peers are independent store entries that other nodes may reference; cascading
167+
eviction could break unrelated references.
168+
- Peers fetched in the same query go through their own `set()` calls and follow the
169+
same `merge` flag independently. No special cascade needed.
170+
171+
**Decision:** ____________________
172+
173+
---
174+
175+
## Summary table
176+
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 |
184+
| 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.

0 commit comments

Comments
 (0)