Skip to content

Commit f9e7309

Browse files
authored
fix(datastore): preserve staged deletes across Merge composition (#1044)
`MemoryDataStore.Merge` propagated `src.DeletedRemoteKeys` by calling `dst.<Store>.Delete(key)`, which removed the record from `dst.Records` but never appended the key to `dst.DeletedRemoteKeys`. Chained Merges (operation and changeset composition) consumed the prior step's staged deletes at every hop; both the JSON file and catalog persistence backends drive remote deletes off DeletedRemoteKeys, so any delete that passed through more than one Merge silently never reached the remote. Each of the three propagation blocks (`AddressRef`, `ChainMetadata`, `ContractMetadata`) now calls `dst.<Store>.RemoteDelete(key)` followed by `dst.<Store>.Delete(key)` with the per-store `NotFound` sentinel tolerated. Delete intent now survives chained Merges, and Merge is idempotent on repeated calls with the same source. Behavior change: Merge previously returned Err<X>NotFound when the source staged a delete for a key not present in `dst.Records`; it now succeeds and stages the delete on dst. This aligns observable behavior with the RemoteDelete docstring, which already advertised staging deletes for records not held locally. Three existing tests in `memory_datastore_test.go` are inverted accordingly; new tests cover chained-merge preservation, idempotency, and the precedence of source-live records over destination-staged deletes.
1 parent 6eebfec commit f9e7309

3 files changed

Lines changed: 164 additions & 13 deletions

File tree

.changeset/large-wombats-fall.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"chainlink-deployments-framework": patch
3+
---
4+
5+
fix(datastore): preserve staged deletes across Merge composition

datastore/memory_datastore.go

Lines changed: 32 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -62,7 +62,26 @@ func (s *MemoryDataStore) WriteMetadata(bundle MetadataBundle, opts ...WriteMeta
6262
return WriteMetadataToDataStore(s, bundle, opts...)
6363
}
6464

65-
// Merge merges the given mutable data store into the current MemoryDataStore.
65+
// Merge applies records and staged deletions from other onto this MemoryDataStore.
66+
//
67+
// Staged deletions (other.<Store>.DeletedRemoteKeys) are propagated by appending the
68+
// key to s.<Store>.DeletedRemoteKeys via RemoteDelete and then removing the record
69+
// from s.<Store>.Records via Delete (tolerating the per-store NotFound sentinel).
70+
// The DeletedRemoteKeys append is what lets chained operations preserve delete intent
71+
// across intermediate Merges.
72+
//
73+
// NotFound on the Delete step is tolerated because the source's staged key may
74+
// legitimately not be present in the destination's Records (RemoteDelete is allowed
75+
// to stage deletes for records that exist only in the remote backing store), and
76+
// because we want repeated Merges of the same source to be idempotent.
77+
//
78+
// Precedence: a live record in other.<Store>.Records overrides a staged delete in
79+
// s.<Store>.DeletedRemoteKeys for the same key. This is a side-effect of Upsert,
80+
// which clears the key from DeletedRemoteKeys whenever a record is upserted (so a
81+
// direct `RemoteDelete(k); Upsert(rec-with-k)` cancels the staged delete on the
82+
// same store). Staged deletes are only "sticky" on the source side. Callers that
83+
// need a destination-side staged delete to survive Merge must ensure the source
84+
// either omits the live record or stages the delete itself.
6685
func (s *MemoryDataStore) Merge(other DataStore) error {
6786
// Fetch address ref records from the other data store
6887
addressRefs, err := other.Addresses().Fetch()
@@ -84,7 +103,10 @@ func (s *MemoryDataStore) Merge(other DataStore) error {
84103
if keyErr != nil {
85104
return fmt.Errorf("failed to parse address ref key: %w", keyErr)
86105
}
87-
if err = s.AddressRefStore.Delete(key); err != nil {
106+
if err = s.AddressRefStore.RemoteDelete(key); err != nil {
107+
return err
108+
}
109+
if err = s.AddressRefStore.Delete(key); err != nil && !errors.Is(err, ErrAddressRefNotFound) {
88110
return err
89111
}
90112
}
@@ -109,7 +131,10 @@ func (s *MemoryDataStore) Merge(other DataStore) error {
109131
if keyErr != nil {
110132
return fmt.Errorf("failed to parse chain metadata key: %w", keyErr)
111133
}
112-
if err = s.ChainMetadataStore.Delete(key); err != nil {
134+
if err = s.ChainMetadataStore.RemoteDelete(key); err != nil {
135+
return err
136+
}
137+
if err = s.ChainMetadataStore.Delete(key); err != nil && !errors.Is(err, ErrChainMetadataNotFound) {
113138
return err
114139
}
115140
}
@@ -135,7 +160,10 @@ func (s *MemoryDataStore) Merge(other DataStore) error {
135160
if keyErr != nil {
136161
return fmt.Errorf("failed to parse contract metadata key: %w", keyErr)
137162
}
138-
if err = s.ContractMetadataStore.Delete(key); err != nil {
163+
if err = s.ContractMetadataStore.RemoteDelete(key); err != nil {
164+
return err
165+
}
166+
if err = s.ContractMetadataStore.Delete(key); err != nil && !errors.Is(err, ErrContractMetadataNotFound) {
139167
return err
140168
}
141169
}

datastore/memory_datastore_test.go

Lines changed: 127 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,9 @@ func TestMemoryDataStore_Merge(t *testing.T) {
4747
excpectedChainMetadataCount int
4848
expectedContractMetadataCount int
4949
expectedError error
50+
expectedAddressDRK []string
51+
expectedChainMetaDRK []string
52+
expectedContractMetaDRK []string
5053
}{
5154
{
5255
name: "Merge single address",
@@ -72,7 +75,7 @@ func TestMemoryDataStore_Merge(t *testing.T) {
7275
expectedContractMetadataCount: 1,
7376
},
7477
{
75-
name: "Merge deletions errors: delete address ref record that does not exist produces an error",
78+
name: "Merge propagate deletions: stages delete for address ref record not present in destination",
7679
setup: func() (*MemoryDataStore, *MemoryDataStore) {
7780
dataStore1 := NewMemoryDataStore()
7881
dataStore2 := NewMemoryDataStore()
@@ -90,17 +93,18 @@ func TestMemoryDataStore_Merge(t *testing.T) {
9093
require.NoError(t, err, "Adding env metadata to dataStore1 should not fail")
9194

9295
// dataStore2 stages a record for deletion that does not exist in dataStore1
93-
require.NoError(t, dataStore2.Addresses().RemoteDelete(NewAddressRefKey(0, "typeA", semver.MustParse("1.0.0"), "q")))
96+
stagedKey := NewAddressRefKey(0, "typeA", semver.MustParse("1.0.0"), "q")
97+
require.NoError(t, dataStore2.Addresses().RemoteDelete(stagedKey))
9498

9599
return dataStore1, dataStore2
96100
},
97101
expectedAddrRefsCount: 1,
98102
excpectedChainMetadataCount: 1,
99103
expectedContractMetadataCount: 1,
100-
expectedError: ErrAddressRefNotFound,
104+
expectedAddressDRK: []string{NewAddressRefKey(0, "typeA", semver.MustParse("1.0.0"), "q").String()},
101105
},
102106
{
103-
name: "Merge deletions errors: delete chain metadata record that does not exist produces an error",
107+
name: "Merge propagate deletions: stages delete for chain metadata record not present in destination",
104108
setup: func() (*MemoryDataStore, *MemoryDataStore) {
105109
dataStore1 := NewMemoryDataStore()
106110
dataStore2 := NewMemoryDataStore()
@@ -118,17 +122,18 @@ func TestMemoryDataStore_Merge(t *testing.T) {
118122
require.NoError(t, err, "Adding env metadata to dataStore2 should not fail")
119123

120124
// dataStore2 stages a record for deletion that does not exist in dataStore1
121-
require.NoError(t, dataStore2.ChainMetadata().RemoteDelete(NewChainMetadataKey(10)))
125+
stagedKey := NewChainMetadataKey(10)
126+
require.NoError(t, dataStore2.ChainMetadata().RemoteDelete(stagedKey))
122127

123128
return dataStore1, dataStore2
124129
},
125130
expectedAddrRefsCount: 1,
126131
excpectedChainMetadataCount: 1,
127132
expectedContractMetadataCount: 1,
128-
expectedError: ErrChainMetadataNotFound,
133+
expectedChainMetaDRK: []string{NewChainMetadataKey(10).String()},
129134
},
130135
{
131-
name: "Merge deletions errors: delete contract metadata record that does not exist produces an error",
136+
name: "Merge propagate deletions: stages delete for contract metadata record not present in destination",
132137
setup: func() (*MemoryDataStore, *MemoryDataStore) {
133138
dataStore1 := NewMemoryDataStore()
134139
dataStore2 := NewMemoryDataStore()
@@ -146,14 +151,15 @@ func TestMemoryDataStore_Merge(t *testing.T) {
146151
require.NoError(t, err, "Adding env metadata to dataStore2 should not fail")
147152

148153
// dataStore2 stages a record for deletion that does not exist in dataStore1
149-
require.NoError(t, dataStore2.ContractMetadata().RemoteDelete(NewContractMetadataKey(10, "0x111")))
154+
stagedKey := NewContractMetadataKey(10, "0x111")
155+
require.NoError(t, dataStore2.ContractMetadata().RemoteDelete(stagedKey))
150156

151157
return dataStore1, dataStore2
152158
},
153159
expectedAddrRefsCount: 1,
154160
excpectedChainMetadataCount: 1,
155161
expectedContractMetadataCount: 1,
156-
expectedError: ErrContractMetadataNotFound,
162+
expectedContractMetaDRK: []string{NewContractMetadataKey(10, "0x111").String()},
157163
},
158164
{
159165
name: "Merge propagate deletions: deletes record from remote data store",
@@ -183,6 +189,9 @@ func TestMemoryDataStore_Merge(t *testing.T) {
183189
expectedAddrRefsCount: 0,
184190
excpectedChainMetadataCount: 0,
185191
expectedContractMetadataCount: 0,
192+
expectedAddressDRK: []string{addressRefRecord.Key().String()},
193+
expectedChainMetaDRK: []string{chainMetadataRecord.Key().String()},
194+
expectedContractMetaDRK: []string{contractMetadataRecord.Key().String()},
186195
},
187196
{
188197
name: "Match existing address with labels",
@@ -275,6 +284,115 @@ func TestMemoryDataStore_Merge(t *testing.T) {
275284

276285
_, err = dataStore1.EnvMetadata().Get()
277286
require.NoError(t, err, "Fetching env metadata from dataStore1 should not fail")
287+
288+
// Verify staged deletes were propagated to DeletedRemoteKeys
289+
if len(tt.expectedAddressDRK) > 0 {
290+
require.ElementsMatch(t, tt.expectedAddressDRK, dataStore1.AddressRefStore.DeletedRemoteKeys,
291+
"dataStore1 AddressRefStore.DeletedRemoteKeys should contain the expected staged keys after merge")
292+
}
293+
if len(tt.expectedChainMetaDRK) > 0 {
294+
require.ElementsMatch(t, tt.expectedChainMetaDRK, dataStore1.ChainMetadataStore.DeletedRemoteKeys,
295+
"dataStore1 ChainMetadataStore.DeletedRemoteKeys should contain the expected staged keys after merge")
296+
}
297+
if len(tt.expectedContractMetaDRK) > 0 {
298+
require.ElementsMatch(t, tt.expectedContractMetaDRK, dataStore1.ContractMetadataStore.DeletedRemoteKeys,
299+
"dataStore1 ContractMetadataStore.DeletedRemoteKeys should contain the expected staged keys after merge")
300+
}
278301
})
279302
}
280303
}
304+
305+
func TestMemoryDataStore_Merge_ChainedComposition(t *testing.T) {
306+
t.Parallel()
307+
308+
recA := AddressRef{
309+
Address: "0xAAA",
310+
Type: "typeA",
311+
Version: semver.MustParse("2.0.0"),
312+
Qualifier: "qa",
313+
}
314+
315+
t.Run("Chained merge preserves DRK across two hops", func(t *testing.T) {
316+
t.Parallel()
317+
318+
dataStore1 := NewMemoryDataStore()
319+
require.NoError(t, dataStore1.Addresses().Add(recA))
320+
321+
dataStore2 := NewMemoryDataStore()
322+
require.NoError(t, dataStore2.Addresses().RemoteDelete(recA.Key()))
323+
324+
dataStore3 := NewMemoryDataStore()
325+
require.NoError(t, dataStore3.Merge(dataStore2.Seal()))
326+
327+
dataStore4 := NewMemoryDataStore()
328+
require.NoError(t, dataStore4.Merge(dataStore3.Seal()))
329+
330+
require.Contains(t, dataStore4.AddressRefStore.DeletedRemoteKeys, recA.Key().String(),
331+
"DRK should survive two merge hops")
332+
333+
addressRefs, err := dataStore4.Addresses().Fetch()
334+
require.NoError(t, err)
335+
require.Empty(t, addressRefs, "recA should not appear in dataStore4 records after chained deletes")
336+
})
337+
338+
t.Run("Merge is idempotent on staged deletes", func(t *testing.T) {
339+
t.Parallel()
340+
341+
dataStore1 := NewMemoryDataStore()
342+
require.NoError(t, dataStore1.Addresses().Add(recA))
343+
344+
dataStore2 := NewMemoryDataStore()
345+
require.NoError(t, dataStore2.Addresses().RemoteDelete(recA.Key()))
346+
347+
sealed := dataStore2.Seal()
348+
349+
require.NoError(t, dataStore1.Merge(sealed), "first merge should succeed")
350+
require.NoError(t, dataStore1.Merge(sealed), "second merge should succeed (idempotent)")
351+
352+
addressRefs, err := dataStore1.Addresses().Fetch()
353+
require.NoError(t, err)
354+
require.Empty(t, addressRefs, "recA should not be present after idempotent merges")
355+
356+
require.Contains(t, dataStore1.AddressRefStore.DeletedRemoteKeys, recA.Key().String(),
357+
"DRK should be present after idempotent merges")
358+
})
359+
}
360+
361+
// TestMemoryDataStore_Merge_SourceLiveRecordClearsDestStagedDelete pins the
362+
// precedence rule documented on MemoryDataStore.Merge: when src.<Store>.Records
363+
// contains a live record for key K and src.<Store>.DeletedRemoteKeys does NOT
364+
// contain K, Merge's upsert clears K from dst.<Store>.DeletedRemoteKeys.
365+
//
366+
// In other words, a live record on the source side overrides a staged delete on
367+
// the destination side for the same key. Staged deletes are sticky only on the
368+
// source side of Merge.
369+
//
370+
// If this test starts failing, the precedence note on MemoryDataStore.Merge is
371+
// out of date — update either the assertion or the docstring to keep them in sync.
372+
func TestMemoryDataStore_Merge_SourceLiveRecordClearsDestStagedDelete(t *testing.T) {
373+
t.Parallel()
374+
375+
rec := AddressRef{
376+
Address: "0xBEEF",
377+
Type: "typeP",
378+
Version: semver.MustParse("1.0.0"),
379+
Qualifier: "qP",
380+
}
381+
382+
dst := NewMemoryDataStore()
383+
require.NoError(t, dst.Addresses().RemoteDelete(rec.Key()))
384+
require.Contains(t, dst.AddressRefStore.DeletedRemoteKeys, rec.Key().String(),
385+
"precondition: dst.DRK should contain the staged key before Merge")
386+
387+
src := NewMemoryDataStore()
388+
require.NoError(t, src.Addresses().Add(rec))
389+
390+
require.NoError(t, dst.Merge(src.Seal()))
391+
392+
addressRefs, err := dst.Addresses().Fetch()
393+
require.NoError(t, err)
394+
require.Len(t, addressRefs, 1, "dst should now contain src's live record")
395+
396+
require.NotContains(t, dst.AddressRefStore.DeletedRemoteKeys, rec.Key().String(),
397+
"dst.DRK should no longer contain the previously-staged key — Upsert cleared it")
398+
}

0 commit comments

Comments
 (0)