@@ -537,3 +537,245 @@ def test_no_persistent_store_status_provider_without_store():
537537 assert set_on_ready .wait (1 ), "Data system did not become ready in time"
538538
539539 fdv2 .stop ()
540+
541+
542+ def test_persistent_store_outage_recovery_flushes_on_recovery ():
543+ """Test that in-memory store is flushed to persistent store when it recovers from outage"""
544+ from ldclient .interfaces import DataStoreStatus
545+
546+ persistent_store = StubFeatureStore ()
547+
548+ # Create synchronizer with initial data
549+ td_synchronizer = TestDataV2 .data_source ()
550+ td_synchronizer .update (td_synchronizer .flag ("feature-flag" ).on (True ))
551+
552+ data_system_config = DataSystemConfig (
553+ data_store_mode = DataStoreMode .READ_WRITE ,
554+ data_store = persistent_store ,
555+ initializers = None ,
556+ primary_synchronizer = td_synchronizer .build_synchronizer ,
557+ )
558+
559+ set_on_ready = Event ()
560+ fdv2 = FDv2 (Config (sdk_key = "dummy" ), data_system_config )
561+ fdv2 .start (set_on_ready )
562+
563+ assert set_on_ready .wait (1 ), "Data system did not become ready in time"
564+
565+ # Verify initial data is in the persistent store
566+ snapshot = persistent_store .get_data_snapshot ()
567+ assert "feature-flag" in snapshot [FEATURES ]
568+ assert snapshot [FEATURES ]["feature-flag" ]["on" ] is True
569+
570+ # Reset tracking to isolate recovery behavior
571+ persistent_store .reset_operation_tracking ()
572+
573+ event = Event ()
574+ fdv2 .flag_tracker .add_listener (lambda _flag_change : event .set ())
575+ # Simulate a new flag being added while store is "offline"
576+ # (In reality, the store is still online, but we're testing the recovery mechanism)
577+ td_synchronizer .update (td_synchronizer .flag ("new-flag" ).on (False ))
578+
579+ # Block until the flag has propagated through the data store
580+ assert event .wait (1 )
581+
582+ # Now simulate the persistent store coming back online with stale data
583+ # by triggering the recovery callback directly
584+ fdv2 ._persistent_store_outage_recovery (DataStoreStatus (available = True , stale = True ))
585+
586+ # Verify that init was called on the persistent store (flushing in-memory data)
587+ assert persistent_store .init_called_count > 0 , "Store should have been reinitialized"
588+
589+ # Verify both flags are now in the persistent store
590+ snapshot = persistent_store .get_data_snapshot ()
591+ assert "feature-flag" in snapshot [FEATURES ]
592+ assert "new-flag" in snapshot [FEATURES ]
593+
594+ fdv2 .stop ()
595+
596+
597+ def test_persistent_store_outage_recovery_no_flush_when_not_stale ():
598+ """Test that recovery does NOT flush when store comes back online without stale data"""
599+ from ldclient .interfaces import DataStoreStatus
600+
601+ persistent_store = StubFeatureStore ()
602+
603+ td_synchronizer = TestDataV2 .data_source ()
604+ td_synchronizer .update (td_synchronizer .flag ("feature-flag" ).on (True ))
605+
606+ data_system_config = DataSystemConfig (
607+ data_store_mode = DataStoreMode .READ_WRITE ,
608+ data_store = persistent_store ,
609+ initializers = None ,
610+ primary_synchronizer = td_synchronizer .build_synchronizer ,
611+ )
612+
613+ set_on_ready = Event ()
614+ fdv2 = FDv2 (Config (sdk_key = "dummy" ), data_system_config )
615+ fdv2 .start (set_on_ready )
616+
617+ assert set_on_ready .wait (1 ), "Data system did not become ready in time"
618+
619+ # Reset tracking
620+ persistent_store .reset_operation_tracking ()
621+
622+ # Simulate store coming back online but NOT stale (data is fresh)
623+ fdv2 ._persistent_store_outage_recovery (DataStoreStatus (available = True , stale = False ))
624+
625+ # Verify that init was NOT called (no flush needed)
626+ assert persistent_store .init_called_count == 0 , "Store should not be reinitialized when not stale"
627+
628+ fdv2 .stop ()
629+
630+
631+ def test_persistent_store_outage_recovery_no_flush_when_unavailable ():
632+ """Test that recovery does NOT flush when store is unavailable"""
633+ from ldclient .interfaces import DataStoreStatus
634+
635+ persistent_store = StubFeatureStore ()
636+
637+ td_synchronizer = TestDataV2 .data_source ()
638+ td_synchronizer .update (td_synchronizer .flag ("feature-flag" ).on (True ))
639+
640+ data_system_config = DataSystemConfig (
641+ data_store_mode = DataStoreMode .READ_WRITE ,
642+ data_store = persistent_store ,
643+ initializers = None ,
644+ primary_synchronizer = td_synchronizer .build_synchronizer ,
645+ )
646+
647+ set_on_ready = Event ()
648+ fdv2 = FDv2 (Config (sdk_key = "dummy" ), data_system_config )
649+ fdv2 .start (set_on_ready )
650+
651+ assert set_on_ready .wait (1 ), "Data system did not become ready in time"
652+
653+ # Reset tracking
654+ persistent_store .reset_operation_tracking ()
655+
656+ # Simulate store being unavailable (even if marked as stale)
657+ fdv2 ._persistent_store_outage_recovery (DataStoreStatus (available = False , stale = True ))
658+
659+ # Verify that init was NOT called (store is not available)
660+ assert persistent_store .init_called_count == 0 , "Store should not be reinitialized when unavailable"
661+
662+ fdv2 .stop ()
663+
664+
665+ def test_persistent_store_commit_encodes_data_correctly ():
666+ """Test that Store.commit() properly encodes data before writing to persistent store"""
667+ from ldclient .impl .datasystem .protocolv2 import (
668+ Change ,
669+ ChangeSet ,
670+ ChangeType ,
671+ IntentCode ,
672+ ObjectKind
673+ )
674+ from ldclient .impl .datasystem .store import Store
675+ from ldclient .impl .listeners import Listeners
676+
677+ persistent_store = StubFeatureStore ()
678+ store = Store (Listeners (), Listeners ())
679+ store .with_persistence (persistent_store , True , None )
680+
681+ # Create a flag with raw data
682+ flag_data = {
683+ "key" : "test-flag" ,
684+ "version" : 1 ,
685+ "on" : True ,
686+ "variations" : [True , False ],
687+ "fallthrough" : {"variation" : 0 },
688+ }
689+
690+ # Apply a changeset to add the flag to the in-memory store
691+ changeset = ChangeSet (
692+ intent_code = IntentCode .TRANSFER_FULL ,
693+ changes = [
694+ Change (
695+ action = ChangeType .PUT ,
696+ kind = ObjectKind .FLAG ,
697+ key = "test-flag" ,
698+ version = 1 ,
699+ object = flag_data ,
700+ )
701+ ],
702+ selector = None ,
703+ )
704+ store .apply (changeset , True )
705+
706+ # Reset tracking
707+ persistent_store .reset_operation_tracking ()
708+
709+ # Now commit the in-memory store to the persistent store
710+ err = store .commit ()
711+ assert err is None , "Commit should succeed"
712+
713+ # Verify that init was called with properly encoded data
714+ assert persistent_store .init_called_count == 1 , "Init should be called once"
715+
716+ # Verify the data in the persistent store is properly encoded
717+ snapshot = persistent_store .get_data_snapshot ()
718+ assert "test-flag" in snapshot [FEATURES ]
719+
720+ # The data should be in the encoded format (as a dict with all required fields)
721+ flag_in_store = snapshot [FEATURES ]["test-flag" ]
722+ assert flag_in_store ["key" ] == "test-flag"
723+ assert flag_in_store ["version" ] == 1
724+ assert flag_in_store ["on" ] is True
725+
726+
727+ def test_persistent_store_commit_with_no_persistent_store ():
728+ """Test that Store.commit() safely handles the case where there's no persistent store"""
729+ from ldclient .impl .datasystem .store import Store
730+ from ldclient .impl .listeners import Listeners
731+
732+ # Create store without persistent store
733+ store = Store (Listeners (), Listeners ())
734+
735+ # Commit should succeed but do nothing
736+ err = store .commit ()
737+ assert err is None , "Commit should succeed even without persistent store"
738+
739+
740+ def test_persistent_store_commit_handles_errors ():
741+ """Test that Store.commit() handles errors from persistent store gracefully"""
742+ from ldclient .impl .datasystem .protocolv2 import (
743+ Change ,
744+ ChangeSet ,
745+ ChangeType ,
746+ IntentCode ,
747+ ObjectKind
748+ )
749+ from ldclient .impl .datasystem .store import Store
750+ from ldclient .impl .listeners import Listeners
751+
752+ class FailingFeatureStore (StubFeatureStore ):
753+ """A feature store that always fails on init"""
754+ def init (self , all_data ):
755+ raise RuntimeError ("Simulated persistent store failure" )
756+
757+ persistent_store = FailingFeatureStore ()
758+ store = Store (Listeners (), Listeners ())
759+ store .with_persistence (persistent_store , True , None )
760+
761+ # Add some data to the in-memory store
762+ changeset = ChangeSet (
763+ intent_code = IntentCode .TRANSFER_FULL ,
764+ changes = [
765+ Change (
766+ action = ChangeType .PUT ,
767+ kind = ObjectKind .FLAG ,
768+ key = "test-flag" ,
769+ version = 1 ,
770+ object = {"key" : "test-flag" , "version" : 1 , "on" : True },
771+ )
772+ ],
773+ selector = None ,
774+ )
775+ store .apply (changeset , True )
776+
777+ # Commit should return the error without raising
778+ err = store .commit ()
779+ assert err is not None , "Commit should return error from persistent store"
780+ assert isinstance (err , RuntimeError )
781+ assert str (err ) == "Simulated persistent store failure"
0 commit comments