@@ -478,3 +478,184 @@ def test_is_already_quantized_same_width_int8_to_uint8():
478478 vec , expected_dims = 128 , source_dtype = "int8" , target_dtype = "uint8"
479479 )
480480 assert result is False
481+
482+
483+ # =============================================================================
484+ # Idempotent Resume Rename Tests (sync executor)
485+ # =============================================================================
486+ # These tests validate that crash-resume for prefix renames is idempotent:
487+ # if a key was already renamed in a prior (crashed) run, retrying should
488+ # skip it instead of aborting with a collision error.
489+
490+
491+ class TestIdempotentResumeRenameStandalone :
492+ """Test _rename_keys_standalone handles already-renamed keys during resume."""
493+
494+ def _make_executor (self ):
495+ return MigrationExecutor ()
496+
497+ def test_already_renamed_keys_skipped_on_resume (self ):
498+ """Simulate crash-resume: 2 of 3 keys were already renamed.
499+
500+ Before the fix, RENAMENX returning False would be treated as a
501+ collision and raise RuntimeError. After the fix, the executor
502+ checks if src is gone + dst exists and counts it as already done.
503+ """
504+ executor = self ._make_executor ()
505+ mock_client = MagicMock ()
506+
507+ # Pipeline: RENAMENX returns True for key3 (not yet renamed),
508+ # False for key1 and key2 (already renamed in prior run).
509+ mock_pipe = MagicMock ()
510+ mock_pipe .execute .return_value = [False , False , True ]
511+ mock_client .pipeline .return_value = mock_pipe
512+
513+ # When executor checks EXISTS for the False results:
514+ # key1: src gone, dst exists → already renamed
515+ # key2: src gone, dst exists → already renamed
516+ def exists_side_effect (key ):
517+ already_renamed_srcs = {"old:1" , "old:2" }
518+ already_renamed_dsts = {"new:1" , "new:2" }
519+ if key in already_renamed_srcs :
520+ return 0 # source gone
521+ if key in already_renamed_dsts :
522+ return 1 # destination exists
523+ return 0
524+
525+ mock_client .exists .side_effect = exists_side_effect
526+
527+ keys = ["old:1" , "old:2" , "old:3" ]
528+ result = executor ._rename_keys_standalone (mock_client , keys , "old:" , "new:" )
529+
530+ # All 3 should count as renamed (2 skipped + 1 actually renamed)
531+ assert result == 3
532+
533+ def test_true_collision_still_raises (self ):
534+ """When source AND destination both exist, it's a real collision → RuntimeError."""
535+ executor = self ._make_executor ()
536+ mock_client = MagicMock ()
537+
538+ mock_pipe = MagicMock ()
539+ mock_pipe .execute .return_value = [False ] # RENAMENX failed
540+ mock_client .pipeline .return_value = mock_pipe
541+
542+ # Both source and destination exist → true collision
543+ mock_client .exists .side_effect = lambda key : 1
544+
545+ keys = ["old:1" ]
546+ with pytest .raises (RuntimeError , match = "destination key.*already exist" ):
547+ executor ._rename_keys_standalone (mock_client , keys , "old:" , "new:" )
548+
549+ def test_src_and_dst_both_gone_is_collision (self ):
550+ """If RENAMENX fails, src is gone, but dst is ALSO gone → collision error.
551+
552+ This is an anomalous state (key deleted externally?) — we treat it
553+ as a collision rather than silently losing data.
554+ """
555+ executor = self ._make_executor ()
556+ mock_client = MagicMock ()
557+
558+ mock_pipe = MagicMock ()
559+ mock_pipe .execute .return_value = [False ]
560+ mock_client .pipeline .return_value = mock_pipe
561+
562+ # src gone, dst also gone
563+ exists_map = {"old:1" : 0 , "new:1" : 0 }
564+ mock_client .exists .side_effect = lambda key : exists_map .get (key , 0 )
565+
566+ keys = ["old:1" ]
567+ with pytest .raises (RuntimeError , match = "destination key.*already exist" ):
568+ executor ._rename_keys_standalone (mock_client , keys , "old:" , "new:" )
569+
570+ def test_mixed_fresh_and_resumed_keys (self ):
571+ """Mix of fresh renames and already-renamed keys — all succeed."""
572+ executor = self ._make_executor ()
573+ mock_client = MagicMock ()
574+
575+ mock_pipe = MagicMock ()
576+ # key1: RENAMENX succeeds
577+ # key2: RENAMENX fails — already renamed (src gone, dst exists)
578+ mock_pipe .execute .return_value = [True , False ]
579+ mock_client .pipeline .return_value = mock_pipe
580+
581+ exists_map = {
582+ "old:2" : 0 , # source gone
583+ "new:2" : 1 , # destination exists
584+ }
585+ mock_client .exists .side_effect = lambda key : exists_map .get (key , 0 )
586+
587+ keys = ["old:1" , "old:2" ]
588+ result = executor ._rename_keys_standalone (mock_client , keys , "old:" , "new:" )
589+
590+ assert result == 2 # 1 fresh + 1 already-renamed
591+
592+
593+ class TestIdempotentResumeRenameCluster :
594+ """Test _rename_keys_cluster handles already-renamed keys during resume."""
595+
596+ def _make_executor (self ):
597+ return MigrationExecutor ()
598+
599+ def test_already_renamed_keys_skipped_on_resume (self ):
600+ """Simulate crash-resume on cluster: keys already renamed are skipped."""
601+ executor = self ._make_executor ()
602+ mock_client = MagicMock ()
603+
604+ # Phase 1 check pipeline: exists(new_key), exists(old_key) for each pair
605+ check_pipe = MagicMock ()
606+ # key1: dst exists (1), src gone (0) → already renamed
607+ # key2: dst exists (1), src gone (0) → already renamed
608+ # key3: dst gone (0), src exists (1) → needs rename
609+ check_pipe .execute .return_value = [1 , 0 , 1 , 0 , 0 , 1 ]
610+
611+ # Phase 2 dump pipeline for key3 only
612+ dump_pipe = MagicMock ()
613+ dump_pipe .execute .return_value = [b"\x00 \x01 \x02 " , - 1 ] # dump data, pttl
614+
615+ # Phase 3 restore pipeline
616+ restore_pipe = MagicMock ()
617+ restore_pipe .execute .return_value = [True , 1 ] # RESTORE ok, DEL ok
618+
619+ mock_client .pipeline .side_effect = [check_pipe , dump_pipe , restore_pipe ]
620+
621+ keys = ["old:1" , "old:2" , "old:3" ]
622+ result = executor ._rename_keys_cluster (mock_client , keys , "old:" , "new:" )
623+
624+ # 2 already-renamed + 1 fresh = 3
625+ assert result == 3
626+
627+ def test_true_collision_raises_on_cluster (self ):
628+ """When source AND destination both exist on cluster → RuntimeError."""
629+ executor = self ._make_executor ()
630+ mock_client = MagicMock ()
631+
632+ check_pipe = MagicMock ()
633+ # key1: dst exists (1), src ALSO exists (1) → true collision
634+ check_pipe .execute .return_value = [1 , 1 ]
635+ mock_client .pipeline .return_value = check_pipe
636+
637+ keys = ["old:1" ]
638+ with pytest .raises (RuntimeError , match = "destination key.*already exists" ):
639+ executor ._rename_keys_cluster (mock_client , keys , "old:" , "new:" )
640+
641+ def test_both_missing_key_skipped_on_cluster (self ):
642+ """Key where both source and destination are gone — warn and skip."""
643+ executor = self ._make_executor ()
644+ mock_client = MagicMock ()
645+
646+ check_pipe = MagicMock ()
647+ # key1: dst gone (0), src gone (0) → both missing
648+ check_pipe .execute .return_value = [0 , 0 ]
649+
650+ # Even with no live_pairs, the code still creates dump/restore pipelines
651+ dump_pipe = MagicMock ()
652+ dump_pipe .execute .return_value = []
653+ restore_pipe = MagicMock ()
654+
655+ mock_client .pipeline .side_effect = [check_pipe , dump_pipe , restore_pipe ]
656+
657+ keys = ["old:1" ]
658+ result = executor ._rename_keys_cluster (mock_client , keys , "old:" , "new:" )
659+
660+ # Key skipped, nothing renamed
661+ assert result == 0
0 commit comments