Skip to content

Commit 9f35a80

Browse files
committed
fix(datastore): preserve staged deletes across Merge composition
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 9f35a80

2 files changed

Lines changed: 161 additions & 13 deletions

File tree

datastore/memory_datastore.go

Lines changed: 34 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -62,7 +62,28 @@ 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 — persistence backends (JSON file, catalog) drive their
72+
// remote deletes off DeletedRemoteKeys, so a Merge that only removed records and left
73+
// DeletedRemoteKeys empty would silently drop the delete on every hop.
74+
//
75+
// NotFound on the Delete step is tolerated because the source's staged key may
76+
// legitimately not be present in the destination's Records (RemoteDelete is allowed
77+
// to stage deletes for records that exist only in the remote backing store), and
78+
// because we want repeated Merges of the same source to be idempotent.
79+
//
80+
// Precedence: a live record in other.<Store>.Records overrides a staged delete in
81+
// s.<Store>.DeletedRemoteKeys for the same key. This is a side-effect of Upsert,
82+
// which clears the key from DeletedRemoteKeys whenever a record is upserted (so a
83+
// direct `RemoteDelete(k); Upsert(rec-with-k)` cancels the staged delete on the
84+
// same store). Staged deletes are only "sticky" on the source side. Callers that
85+
// need a destination-side staged delete to survive Merge must ensure the source
86+
// either omits the live record or stages the delete itself.
6687
func (s *MemoryDataStore) Merge(other DataStore) error {
6788
// Fetch address ref records from the other data store
6889
addressRefs, err := other.Addresses().Fetch()
@@ -84,7 +105,10 @@ func (s *MemoryDataStore) Merge(other DataStore) error {
84105
if keyErr != nil {
85106
return fmt.Errorf("failed to parse address ref key: %w", keyErr)
86107
}
87-
if err = s.AddressRefStore.Delete(key); err != nil {
108+
if err = s.AddressRefStore.RemoteDelete(key); err != nil {
109+
return err
110+
}
111+
if err = s.AddressRefStore.Delete(key); err != nil && !errors.Is(err, ErrAddressRefNotFound) {
88112
return err
89113
}
90114
}
@@ -109,7 +133,10 @@ func (s *MemoryDataStore) Merge(other DataStore) error {
109133
if keyErr != nil {
110134
return fmt.Errorf("failed to parse chain metadata key: %w", keyErr)
111135
}
112-
if err = s.ChainMetadataStore.Delete(key); err != nil {
136+
if err = s.ChainMetadataStore.RemoteDelete(key); err != nil {
137+
return err
138+
}
139+
if err = s.ChainMetadataStore.Delete(key); err != nil && !errors.Is(err, ErrChainMetadataNotFound) {
113140
return err
114141
}
115142
}
@@ -135,7 +162,10 @@ func (s *MemoryDataStore) Merge(other DataStore) error {
135162
if keyErr != nil {
136163
return fmt.Errorf("failed to parse contract metadata key: %w", keyErr)
137164
}
138-
if err = s.ContractMetadataStore.Delete(key); err != nil {
165+
if err = s.ContractMetadataStore.RemoteDelete(key); err != nil {
166+
return err
167+
}
168+
if err = s.ContractMetadataStore.Delete(key); err != nil && !errors.Is(err, ErrContractMetadataNotFound) {
139169
return err
140170
}
141171
}

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)