Skip to content

Commit 467da53

Browse files
authored
fix: Prevent a persistent-store outage from throwing in the sync FDv2 evaluation (#506)
1 parent 6a70132 commit 467da53

5 files changed

Lines changed: 102 additions & 10 deletions

File tree

ldclient/client.py

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -408,8 +408,9 @@ def _evaluate_internal(self, key: str, context: Context, default: Any, event_fac
408408
if self._config.offline:
409409
return EvaluationDetail(default, None, error_reason('CLIENT_NOT_READY')), None
410410

411-
if self._data_system.data_availability != DataAvailability.REFRESHED:
412-
if self._data_system.data_availability == DataAvailability.CACHED:
411+
availability = self._data_system.data_availability
412+
if availability != DataAvailability.REFRESHED:
413+
if availability == DataAvailability.CACHED:
413414
log.warning("Feature Flag evaluation attempted before client has initialized - using last known values from feature store for feature key: " + key)
414415
else:
415416
log.warning("Feature Flag evaluation attempted before client has initialized! Feature store unavailable - returning default: " + str(default) + " for feature key: " + key)
@@ -479,8 +480,9 @@ def all_flags_state(self, context: Context, **kwargs) -> FeatureFlagsState:
479480
log.warning("all_flags_state() called, but client is in offline mode. Returning empty state")
480481
return FeatureFlagsState(False)
481482

482-
if self._data_system.data_availability != DataAvailability.REFRESHED:
483-
if self._data_system.data_availability == DataAvailability.CACHED:
483+
availability = self._data_system.data_availability
484+
if availability != DataAvailability.REFRESHED:
485+
if availability == DataAvailability.CACHED:
484486
log.warning("all_flags_state() called before client has finished initializing! Using last known values from feature store")
485487
else:
486488
log.warning("all_flags_state() called before client has finished initializing! Feature store unavailable - returning empty state")

ldclient/impl/datasystem/fdv2.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -380,7 +380,7 @@ def _consume_synchronizer_results(
380380
"""
381381
Consume results from a synchronizer until a condition is met or it fails.
382382
383-
:return: Tuple of (should_remove_sync, fallback_to_fdv1, directive)
383+
:return: the ConditionDirective describing how to proceed
384384
"""
385385
action_queue: Queue = Queue()
386386
timer = RepeatingTask(

ldclient/impl/datasystem/fdv2_common.py

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -398,10 +398,16 @@ def data_availability(self) -> DataAvailability:
398398
if self._store.selector().is_defined():
399399
return DataAvailability.REFRESHED
400400

401-
if not self._configured_with_data_sources or self._store.is_initialized():
401+
if not self._configured_with_data_sources:
402402
return DataAvailability.CACHED
403403

404-
return DataAvailability.DEFAULTS
404+
try:
405+
store_initialized = self._store.is_initialized()
406+
except Exception as e:
407+
log.error("Error checking persistent store readiness; treating data as unavailable: %s", e)
408+
return DataAvailability.DEFAULTS
409+
410+
return DataAvailability.CACHED if store_initialized else DataAvailability.DEFAULTS
405411

406412
@property
407413
def target_availability(self) -> DataAvailability:

ldclient/impl/datasystem/store.py

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -521,7 +521,7 @@ def __mapping(data: Dict[str, ModelEntity]) -> Dict[str, Dict[str, Any]]:
521521
return e
522522
return None
523523

524-
def close(self) -> Optional[Exception]:
524+
def close(self) -> None:
525525
"""Close the store and any persistent store if configured."""
526526
with self._lock:
527527
if self._persistent_store is not None:
@@ -532,8 +532,7 @@ def close(self) -> Optional[Exception]:
532532
if callable(close):
533533
close()
534534
except Exception as e:
535-
return e
536-
return None
535+
log.warning("Error closing the persistent store: %s", e)
537536

538537
def get_data_store_status_provider(self) -> Optional[DataStoreStatusProvider]:
539538
"""Get the data store status provider for the persistent store, if configured."""

ldclient/testing/impl/datasystem/test_fdv2_persistence.py

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,11 +3,13 @@
33
from threading import Event
44
from typing import Any, Callable, Dict, List, Mapping, Optional
55

6+
from ldclient.client import Context
67
from ldclient.config import Config, DataSystemConfig
78
from ldclient.impl.datasystem import DataAvailability
89
from ldclient.impl.datasystem.fdv2 import FDv2
910
from ldclient.integrations.test_datav2 import TestDataV2
1011
from ldclient.interfaces import DataStoreMode, FeatureStore, FlagChange
12+
from ldclient.testing.test_ldclient import make_client
1113
from ldclient.versioned_data_kind import FEATURES, SEGMENTS, VersionedDataKind
1214

1315

@@ -782,3 +784,86 @@ def init(self, all_data):
782784
assert err is not None, "Commit should return error from persistent store"
783785
assert isinstance(err, RuntimeError)
784786
assert str(err) == "Simulated persistent store failure"
787+
788+
789+
class ThrowingInitializedStore(StubFeatureStore):
790+
"""A persistent store whose ``initialized`` check raises, to simulate a
791+
store I/O error (for example a Redis connection failure) during the
792+
warm-start availability gate."""
793+
794+
@property
795+
def initialized(self) -> bool:
796+
raise RuntimeError("persistent store I/O error")
797+
798+
799+
def test_variation_does_not_throw_when_persistent_store_errors_during_warm_start(caplog):
800+
"""A persistent-store error at the warm-start availability gate must not
801+
propagate out of the client.
802+
803+
While a synchronizer is configured but has not yet supplied a basis, the
804+
availability gate consults the persistent store's initialized state. If that
805+
query raises, evaluation must degrade to the default value with
806+
``CLIENT_NOT_READY`` and ``all_flags_state()`` must return an invalid state,
807+
rather than raising. This is the sync counterpart to the async fix in #486.
808+
"""
809+
persistent_store = ThrowingInitializedStore()
810+
811+
# A synchronizer is configured but the data system is never started, so no
812+
# basis arrives. This is the warm-start window in which the gate reads the
813+
# persistent store's initialized state.
814+
data_system_config = DataSystemConfig(
815+
data_store_mode=DataStoreMode.READ_ONLY,
816+
data_store=persistent_store,
817+
initializers=None,
818+
synchronizers=[TestDataV2.data_source().builder],
819+
)
820+
fdv2 = FDv2(Config(sdk_key="dummy"), data_system_config)
821+
822+
# The gate itself must not raise: it degrades to DEFAULTS instead.
823+
assert fdv2.data_availability == DataAvailability.DEFAULTS
824+
825+
# Drive the same gate through the client and confirm it degrades
826+
# instead of propagating the error.
827+
client = make_client()
828+
try:
829+
client._data_system = fdv2
830+
context = Context.from_dict({"key": "user", "kind": "user"})
831+
832+
assert client.variation("flag-key", context, default="default-value") == "default-value"
833+
834+
detail = client.variation_detail("flag-key", context, default="default-value")
835+
assert detail.value == "default-value"
836+
assert detail.reason == {"kind": "ERROR", "errorKind": "CLIENT_NOT_READY"}
837+
assert detail.is_default_value() is True
838+
839+
assert client.all_flags_state(context).valid is False
840+
finally:
841+
client.close()
842+
843+
assert any(
844+
"Error checking persistent store readiness" in record.message
845+
for record in caplog.records
846+
if record.levelname == "ERROR"
847+
)
848+
849+
850+
def test_persistent_store_close_logs_and_swallows_error(caplog):
851+
"""A persistent-store close error is logged as a warning, not raised."""
852+
from ldclient.impl.datasystem.store import Store
853+
from ldclient.impl.listeners import Listeners
854+
855+
class ClosingFailsStore(StubFeatureStore):
856+
def close(self):
857+
raise RuntimeError("close boom")
858+
859+
store = Store(Listeners(), Listeners())
860+
store.with_persistence(ClosingFailsStore(), True, None)
861+
862+
# close() must log the error rather than raise it.
863+
store.close()
864+
865+
assert any(
866+
"Error closing the persistent store" in record.message
867+
for record in caplog.records
868+
if record.levelname == "WARNING"
869+
)

0 commit comments

Comments
 (0)