Skip to content

Commit ae96f4b

Browse files
committed
fix: Only persist after a successful in-memory apply
Move the persist flag and the _should_persist decision out of _StoreBase into the Store and AsyncStore subclasses; the base is now a pure in-memory engine (_set_basis/_apply_delta take no persist flag and return whether the memory apply succeeded). Each subclass sets its persist flag only after a successful apply, so a failed decode or apply can no longer flip it and let a later commit treat unapplied or empty memory as writable and overwrite the persistent store.
1 parent 11bd6a1 commit ae96f4b

2 files changed

Lines changed: 47 additions & 49 deletions

File tree

‎ldclient/impl/datasystem/async_store.py‎

Lines changed: 15 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,10 @@ def __init__(
4343
self._persistent_store_status_provider: Optional[DataStoreStatusProvider] = None
4444
self._persistent_store_writable = False
4545

46+
# True if the data in the memory store may be written to the persistent
47+
# store. Set on each successful apply from its persist flag.
48+
self._persist = False
49+
4650
# Serializes async store writes; held only across the awaited I/O, never with self._lock.
4751
self._async_persist_lock = AsyncLock()
4852

@@ -105,25 +109,29 @@ async def apply(self, change_set: ChangeSet, persist: bool) -> None:
105109
"""
106110
collections = self._changes_to_store_data(change_set.changes)
107111

108-
pending: Optional[Collections] = None
112+
applied = False
109113
is_full = False
110114

111115
with self._lock:
112116
try:
113117
if change_set.intent_code == IntentCode.TRANSFER_FULL:
114-
pending = self._set_basis(collections, change_set.selector, persist)
118+
applied = self._set_basis(collections, change_set.selector)
115119
is_full = True
116120
elif change_set.intent_code == IntentCode.TRANSFER_CHANGES:
117-
pending = self._apply_delta(collections, change_set.selector, persist)
121+
applied = self._apply_delta(collections, change_set.selector)
118122
elif change_set.intent_code == IntentCode.TRANSFER_NONE:
119123
return
120124

121125
self._change_set_listeners.notify(change_set)
126+
127+
if applied:
128+
# Memory now holds this data, so it may be persisted.
129+
self._persist = persist
122130
except Exception as e:
123131
log.error("Store: couldn't apply changeset: %s", str(e))
124132
return
125133

126-
if pending is None:
134+
if not applied or not self._should_persist():
127135
return
128136

129137
store = self._persistent_store
@@ -133,10 +141,10 @@ async def apply(self, change_set: ChangeSet, persist: bool) -> None:
133141
async with self._async_persist_lock:
134142
try:
135143
if is_full:
136-
await store.init(pending)
144+
await store.init(collections)
137145
else:
138-
for kind in pending:
139-
kind_data = pending[kind]
146+
for kind in collections:
147+
kind_data = collections[kind]
140148
for key in kind_data:
141149
await store.upsert(kind, kind_data[key])
142150
except Exception as e:

‎ldclient/impl/datasystem/store.py‎

Lines changed: 32 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -195,10 +195,6 @@ def __init__(
195195
# Thread synchronization
196196
self._lock = threading.RLock()
197197

198-
# True if the data in the memory store may be written to the persistent
199-
# store. Set on each apply from its persist flag.
200-
self._persist = False
201-
202198
def selector(self) -> Selector:
203199
"""Returns the current selector."""
204200
with self._lock:
@@ -212,30 +208,19 @@ def _on_memory_store_active(self) -> None:
212208
"""
213209
pass
214210

215-
def _should_persist(self) -> bool:
216-
"""
217-
Returns whether the current data should be written to the persistent
218-
store. The base engine has no persistent store, so it never persists;
219-
subclasses override this based on their store.
220-
"""
221-
return False
222-
223211
def _set_basis(
224-
self, collections: Collections, selector: Selector, persist: bool
225-
) -> Optional[Collections]:
212+
self, collections: Collections, selector: Selector
213+
) -> bool:
226214
"""
227215
Set the basis of the store. Any existing data is discarded.
228216
229217
Args:
230218
collections: The new basis data
231219
selector: The selector identifying the data
232-
persist: Whether to persist the data to the persistent store
233220
234221
Returns:
235-
The collections to persist, or None if there is nothing to persist.
222+
True if the in-memory store was updated, False if it was not.
236223
"""
237-
self._persist = persist
238-
239224
# Take snapshot for change detection if we have flag listeners
240225
old_data: Optional[Collections] = None
241226
if self._flag_change_listeners.has_listeners():
@@ -245,7 +230,7 @@ def _set_basis(
245230

246231
ok = self._memory_store.set_basis(collections)
247232
if ok is False:
248-
return None
233+
return False
249234

250235
# Update dependency tracker
251236
self._reset_dependency_tracker(collections)
@@ -267,27 +252,24 @@ def _set_basis(
267252
)
268253
self._send_change_events(affected_items)
269254

270-
return collections if self._should_persist() else None
255+
return True
271256

272257
def _apply_delta(
273-
self, collections: Collections, selector: Selector, persist: bool
274-
) -> Optional[Collections]:
258+
self, collections: Collections, selector: Selector
259+
) -> bool:
275260
"""
276261
Apply a delta update to the store.
277262
278263
Args:
279264
collections: The delta changes
280265
selector: The selector identifying the data
281-
persist: Whether to persist the changes to the persistent store
282266
283267
Returns:
284-
The collections to persist, or None if there is nothing to persist.
268+
True if the in-memory store was updated, False if it was not.
285269
"""
286-
self._persist = persist
287-
288270
ok = self._memory_store.apply_delta(collections)
289271
if ok is False:
290-
return None
272+
return False
291273

292274
has_listeners = self._flag_change_listeners.has_listeners()
293275
affected_items: Set[KindAndKey] = set()
@@ -311,7 +293,7 @@ def _apply_delta(
311293
if affected_items:
312294
self._send_change_events(affected_items)
313295

314-
return collections if self._should_persist() else None
296+
return True
315297

316298
def _changes_to_store_data(self, changes: List[Change]) -> Collections:
317299
"""
@@ -412,6 +394,10 @@ def __init__(
412394
self._persistent_store_status_provider: Optional[DataStoreStatusProvider] = None
413395
self._persistent_store_writable = False
414396

397+
# True if the data in the memory store may be written to the persistent
398+
# store. Set on each successful apply from its persist flag.
399+
self._persist = False
400+
415401
def with_persistence(
416402
self,
417403
persistent_store: FeatureStore,
@@ -450,34 +436,38 @@ def apply(self, change_set: ChangeSet, persist: bool) -> None:
450436
"""
451437
collections = self._changes_to_store_data(change_set.changes)
452438

453-
pending: Optional[Collections] = None
439+
applied = False
454440
is_full = False
455441

456442
with self._lock:
457443
try:
458444
if change_set.intent_code == IntentCode.TRANSFER_FULL:
459-
pending = self._set_basis(collections, change_set.selector, persist)
445+
applied = self._set_basis(collections, change_set.selector)
460446
is_full = True
461447
elif change_set.intent_code == IntentCode.TRANSFER_CHANGES:
462-
pending = self._apply_delta(collections, change_set.selector, persist)
448+
applied = self._apply_delta(collections, change_set.selector)
463449
elif change_set.intent_code == IntentCode.TRANSFER_NONE:
464450
# No-op, no changes to apply
465451
return
466452

467453
# Notify changeset listeners
468454
self._change_set_listeners.notify(change_set)
469455

470-
# Persist synchronously, inline under the lock
471-
if pending is not None:
472-
store = self._persistent_store
473-
assert store is not None
474-
if is_full:
475-
store.init(pending)
476-
else:
477-
for kind in pending:
478-
kind_data = pending[kind]
479-
for key in kind_data:
480-
store.upsert(kind, kind_data[key])
456+
if applied:
457+
# Memory now holds this data, so it may be persisted.
458+
self._persist = persist
459+
460+
# Persist synchronously, inline under the lock
461+
if self._should_persist():
462+
store = self._persistent_store
463+
assert store is not None
464+
if is_full:
465+
store.init(collections)
466+
else:
467+
for kind in collections:
468+
kind_data = collections[kind]
469+
for key in kind_data:
470+
store.upsert(kind, kind_data[key])
481471

482472
except Exception as e:
483473
# Log error but don't re-raise - matches Go behavior

0 commit comments

Comments
 (0)