Skip to content

Commit 11bd6a1

Browse files
committed
refactor: Simplify the FDv2 store persist path and clean up docstrings
Remove the _stage_persist_full/_stage_persist_delta hook: _set_basis and _apply_delta now return the collections to persist, and each apply does its own persist (sync inline under the lock, async awaited under the persist lock). Make AsyncStore.commit take its snapshot and write under the persist lock so the flush is atomic. Tidy the _StoreBase and read-only view docstrings.
1 parent a28145f commit 11bd6a1

5 files changed

Lines changed: 67 additions & 105 deletions

File tree

ldclient/impl/datasystem/async_fdv1.py

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -36,11 +36,11 @@
3636

3737

3838
class _AsyncReadOnlyFeatureStoreView(AsyncReadOnlyStore):
39-
"""Exposes only async ``get``/``all`` over a stable async store, decoding dict items.
39+
"""Read-only view of an async feature store.
4040
41-
The wrapped store is always async and never swaps, so it is read directly.
42-
Items stored as dicts are decoded into model objects; items already decoded
43-
are returned unchanged.
41+
Serves every read from the wrapped store. Items that a custom feature store
42+
keeps as raw dicts are decoded into model objects; items that are already
43+
models pass through unchanged.
4444
"""
4545

4646
def __init__(self, store: AsyncReadOnlyStore):

ldclient/impl/datasystem/async_store.py

Lines changed: 14 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -43,9 +43,6 @@ 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 persisted to the persistent store
47-
self._persist = False
48-
4946
# Serializes async store writes; held only across the awaited I/O, never with self._lock.
5047
self._async_persist_lock = AsyncLock()
5148

@@ -97,23 +94,10 @@ def _on_memory_store_active(self) -> None:
9794
except Exception as e:
9895
log.warning("Failed to disable persistent store cache: %s", e)
9996

100-
def _stage_persist_full(self, collections: Collections, persist: bool) -> Optional[Collections]:
101-
self._persist = persist
102-
return collections if self._should_persist() else None
103-
104-
def _stage_persist_delta(self, collections: Collections, persist: bool) -> Optional[Collections]:
105-
self._persist = persist
106-
return collections if self._should_persist() else None
107-
10897
async def apply(self, change_set: ChangeSet, persist: bool) -> None:
10998
"""
110-
Apply a changeset to the store using the async persist path.
111-
112-
The in-memory update and change-set notification run under the
113-
synchronous lock with no awaits inside it. The persistent-store write is
114-
awaited afterwards, outside the lock, serialized by the async persist lock.
115-
The in-memory store is authoritative, so change listeners fire before the
116-
awaited store write completes.
99+
Apply a changeset to the in-memory store and, if configured, the async
100+
persistent store.
117101
118102
Args:
119103
change_set: The changeset to apply
@@ -160,10 +144,8 @@ async def apply(self, change_set: ChangeSet, persist: bool) -> None:
160144

161145
async def commit(self) -> Optional[Exception]:
162146
"""
163-
Persist the data in the memory store to the async persistent store, if configured.
164-
165-
The memory read happens under the synchronous lock; the store write is
166-
awaited afterwards, serialized by the async persist lock.
147+
Persist the contents of the memory store to the async persistent store,
148+
if configured.
167149
168150
Returns:
169151
Exception if the commit failed, None otherwise
@@ -174,21 +156,21 @@ def __mapping(data: Dict[str, ModelEntity]) -> Dict[str, Dict[str, Any]]:
174156

175157
return __mapping
176158

177-
all_data: Optional[Collections] = None
178-
with self._lock:
179-
if self._should_persist():
180-
all_data = {}
181-
for kind in [FEATURES, SEGMENTS]:
182-
all_data[kind] = self._memory_store.all(kind, __mapping_from_kind(kind))
183-
184-
if all_data is None:
185-
return None
186-
187159
store = self._persistent_store
188160
if store is None:
189161
return None
190162

191163
async with self._async_persist_lock:
164+
all_data: Optional[Collections] = None
165+
with self._lock:
166+
if self._should_persist():
167+
all_data = {}
168+
for kind in [FEATURES, SEGMENTS]:
169+
all_data[kind] = self._memory_store.all(kind, __mapping_from_kind(kind))
170+
171+
if all_data is None:
172+
return None
173+
192174
try:
193175
await store.init(all_data)
194176
except Exception as e:

ldclient/impl/datasystem/fdv1.py

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -147,11 +147,11 @@ def is_monitoring_enabled(self) -> bool:
147147

148148

149149
class _ReadOnlyFeatureStoreView(ReadOnlyStore):
150-
"""Exposes only ``get``/``all`` over a feature store, decoding dict items.
150+
"""Read-only view of a feature store.
151151
152-
The wrapped store is stable and never swaps, so it is read directly. Items
153-
stored as dicts are decoded into model objects; items already decoded are
154-
returned unchanged, then the caller's ``callback`` is applied.
152+
Serves every read from the wrapped store. Items that a custom feature store
153+
keeps as raw dicts are decoded into model objects; items that are already
154+
models pass through unchanged.
155155
"""
156156

157157
def __init__(self, store: FeatureStore):

ldclient/impl/datasystem/fdv2.py

Lines changed: 5 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -37,13 +37,12 @@
3737

3838

3939
class _ReadOnlyStoreView(ReadOnlyStore):
40-
"""Exposes only ``get``/``all`` over a store, decoding dict items.
40+
"""Read-only view of the data system store.
4141
42-
Resolves the active store on each read rather than at construction, so a held
43-
instance follows the active-store swap: reads hit the persistent store before
44-
the in-memory store has data, and the in-memory store afterwards. Items stored
45-
as dicts are decoded into model objects; items already decoded are returned
46-
unchanged, then the caller's ``callback`` is applied.
42+
Serves every read from the store's active store, so a held instance follows
43+
the swap from the persistent store to the in-memory store once it has data.
44+
Items that a custom persistent store keeps as raw dicts are decoded into
45+
model objects; items that are already models pass through unchanged.
4746
"""
4847

4948
def __init__(self, store: Store):

ldclient/impl/datasystem/store.py

Lines changed: 40 additions & 59 deletions
Original file line numberDiff line numberDiff line change
@@ -160,11 +160,8 @@ class _StoreBase:
160160
synchronizer), reads serve from memory and the persistent store is no longer
161161
read from.
162162
163-
This base holds the in-memory store, dependency tracking, listeners, the
164-
active-store swap, and the changeset-to-memory apply. It never references a
165-
persistent store: subclasses own their concretely-typed store and supply the
166-
persist step through the ``_stage_persist_full``/``_stage_persist_delta``
167-
hooks and the ``_on_memory_store_active`` hook.
163+
This base owns the in-memory store, dependency tracking, listeners, and the
164+
active-store swap. It holds no persistent store of its own.
168165
"""
169166

170167
def __init__(
@@ -198,6 +195,10 @@ def __init__(
198195
# Thread synchronization
199196
self._lock = threading.RLock()
200197

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+
201202
def selector(self) -> Selector:
202203
"""Returns the current selector."""
203204
with self._lock:
@@ -211,25 +212,13 @@ def _on_memory_store_active(self) -> None:
211212
"""
212213
pass
213214

214-
def _stage_persist_full(self, collections: Collections, persist: bool) -> Optional[Collections]:
215-
"""
216-
Persist a full data set. Subclasses supply the persist step.
217-
218-
A subclass either writes the data synchronously and returns None, or
219-
returns the collections for the caller to persist afterwards. The
220-
subclass owns the decision of whether to persist at all.
221-
"""
222-
raise NotImplementedError
223-
224-
def _stage_persist_delta(self, collections: Collections, persist: bool) -> Optional[Collections]:
215+
def _should_persist(self) -> bool:
225216
"""
226-
Persist a delta update. Subclasses supply the persist step.
227-
228-
A subclass either writes the data synchronously and returns None, or
229-
returns the collections for the caller to persist afterwards. The
230-
subclass owns the decision of whether to persist at all.
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.
231220
"""
232-
raise NotImplementedError
221+
return False
233222

234223
def _set_basis(
235224
self, collections: Collections, selector: Selector, persist: bool
@@ -243,8 +232,10 @@ def _set_basis(
243232
persist: Whether to persist the data to the persistent store
244233
245234
Returns:
246-
The collections the subclass deferred for later persistence, or None.
235+
The collections to persist, or None if there is nothing to persist.
247236
"""
237+
self._persist = persist
238+
248239
# Take snapshot for change detection if we have flag listeners
249240
old_data: Optional[Collections] = None
250241
if self._flag_change_listeners.has_listeners():
@@ -266,20 +257,17 @@ def _set_basis(
266257
self._active_store = self._memory_store
267258

268259
# In-memory store is now authoritative. The subclass reacts here (e.g. by
269-
# disabling the persistent-store cache) before the persist step below.
260+
# disabling the persistent-store cache) before the caller persists.
270261
self._on_memory_store_active()
271262

272-
# Persist through the subclass hook
273-
pending = self._stage_persist_full(collections, persist)
274-
275263
# Send change events if we had listeners
276264
if old_data is not None:
277265
affected_items = self._compute_changed_items_for_full_data_set(
278266
old_data, collections
279267
)
280268
self._send_change_events(affected_items)
281269

282-
return pending
270+
return collections if self._should_persist() else None
283271

284272
def _apply_delta(
285273
self, collections: Collections, selector: Selector, persist: bool
@@ -293,8 +281,10 @@ def _apply_delta(
293281
persist: Whether to persist the changes to the persistent store
294282
295283
Returns:
296-
The collections the subclass deferred for later persistence, or None.
284+
The collections to persist, or None if there is nothing to persist.
297285
"""
286+
self._persist = persist
287+
298288
ok = self._memory_store.apply_delta(collections)
299289
if ok is False:
300290
return None
@@ -317,13 +307,11 @@ def _apply_delta(
317307
# Update state
318308
self._selector = selector if selector is not None else Selector.no_selector()
319309

320-
pending = self._stage_persist_delta(collections, persist)
321-
322310
# Send change events
323311
if affected_items:
324312
self._send_change_events(affected_items)
325313

326-
return pending
314+
return collections if self._should_persist() else None
327315

328316
def _changes_to_store_data(self, changes: List[Change]) -> Collections:
329317
"""
@@ -424,9 +412,6 @@ def __init__(
424412
self._persistent_store_status_provider: Optional[DataStoreStatusProvider] = None
425413
self._persistent_store_writable = False
426414

427-
# True if the data in the memory store may be persisted to the persistent store
428-
self._persist = False
429-
430415
def with_persistence(
431416
self,
432417
persistent_store: FeatureStore,
@@ -456,27 +441,44 @@ def with_persistence(
456441

457442
def apply(self, change_set: ChangeSet, persist: bool) -> None:
458443
"""
459-
Apply a changeset to the store.
444+
Apply a changeset to the in-memory store and, if configured, the
445+
persistent store.
460446
461447
Args:
462448
change_set: The changeset to apply
463449
persist: Whether the changes should be persisted to the persistent store
464450
"""
465451
collections = self._changes_to_store_data(change_set.changes)
466452

453+
pending: Optional[Collections] = None
454+
is_full = False
455+
467456
with self._lock:
468457
try:
469458
if change_set.intent_code == IntentCode.TRANSFER_FULL:
470-
self._set_basis(collections, change_set.selector, persist)
459+
pending = self._set_basis(collections, change_set.selector, persist)
460+
is_full = True
471461
elif change_set.intent_code == IntentCode.TRANSFER_CHANGES:
472-
self._apply_delta(collections, change_set.selector, persist)
462+
pending = self._apply_delta(collections, change_set.selector, persist)
473463
elif change_set.intent_code == IntentCode.TRANSFER_NONE:
474464
# No-op, no changes to apply
475465
return
476466

477467
# Notify changeset listeners
478468
self._change_set_listeners.notify(change_set)
479469

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])
481+
480482
except Exception as e:
481483
# Log error but don't re-raise - matches Go behavior
482484
log.error("Store: couldn't apply changeset: %s", str(e))
@@ -502,27 +504,6 @@ def _on_memory_store_active(self) -> None:
502504
except Exception as e:
503505
log.warning("Failed to disable persistent store cache: %s", e)
504506

505-
def _stage_persist_full(self, collections: Collections, persist: bool) -> Optional[Collections]:
506-
self._persist = persist
507-
if not self._should_persist():
508-
return None
509-
store = self._persistent_store
510-
assert store is not None
511-
store.init(collections)
512-
return None
513-
514-
def _stage_persist_delta(self, collections: Collections, persist: bool) -> Optional[Collections]:
515-
self._persist = persist
516-
if not self._should_persist():
517-
return None
518-
store = self._persistent_store
519-
assert store is not None
520-
for kind in collections:
521-
kind_data = collections[kind]
522-
for key in kind_data:
523-
store.upsert(kind, kind_data[key])
524-
return None
525-
526507
def commit(self) -> Optional[Exception]:
527508
"""
528509
Commit persists the data in the memory store to the persistent store, if configured.

0 commit comments

Comments
 (0)