@@ -273,3 +273,106 @@ def test_default_backup_dir_exported_from_package(self):
273273
274274 assert DEFAULT_BACKUP_DIR
275275 assert isinstance (DEFAULT_BACKUP_DIR , str )
276+
277+
278+ class TestEnumerateScanFallback :
279+ """SCAN-fallback conditions in MigrationExecutor._enumerate_indexed_keys."""
280+
281+ def _build_executor_with_info (self , info_dict ):
282+ """Construct an executor and a mock client whose ft().info() returns info_dict."""
283+ from redisvl .migration .executor import MigrationExecutor
284+
285+ executor = MigrationExecutor ()
286+ mock_client = MagicMock ()
287+ mock_ft = MagicMock ()
288+ mock_ft .info .return_value = info_dict
289+ mock_client .ft .return_value = mock_ft
290+ return executor , mock_client
291+
292+ def test_falls_back_to_scan_when_percent_indexed_below_one (self ):
293+ """percent_indexed < 1.0 must trigger SCAN fallback to avoid silent loss."""
294+ executor , mock_client = self ._build_executor_with_info (
295+ {"hash_indexing_failures" : 0 , "percent_indexed" : "0.5" }
296+ )
297+
298+ with (
299+ patch .object (
300+ executor ,
301+ "_enumerate_with_scan" ,
302+ return_value = iter (["doc:1" , "doc:2" ]),
303+ ) as scan_mock ,
304+ patch .object (
305+ executor ,
306+ "_enumerate_with_aggregate" ,
307+ return_value = iter (["should-not-be-used" ]),
308+ ) as aggregate_mock ,
309+ ):
310+ keys = list (executor ._enumerate_indexed_keys (mock_client , "idx" ))
311+
312+ scan_mock .assert_called_once ()
313+ aggregate_mock .assert_not_called ()
314+ assert keys == ["doc:1" , "doc:2" ]
315+
316+ def test_uses_aggregate_when_fully_indexed (self ):
317+ """percent_indexed == 1.0 with no failures should use FT.AGGREGATE."""
318+ executor , mock_client = self ._build_executor_with_info (
319+ {"hash_indexing_failures" : 0 , "percent_indexed" : "1" }
320+ )
321+
322+ with (
323+ patch .object (
324+ executor ,
325+ "_enumerate_with_scan" ,
326+ return_value = iter (["should-not-be-used" ]),
327+ ) as scan_mock ,
328+ patch .object (
329+ executor ,
330+ "_enumerate_with_aggregate" ,
331+ return_value = iter (["doc:1" , "doc:2" ]),
332+ ) as aggregate_mock ,
333+ ):
334+ keys = list (executor ._enumerate_indexed_keys (mock_client , "idx" ))
335+
336+ scan_mock .assert_not_called ()
337+ aggregate_mock .assert_called_once ()
338+ assert keys == ["doc:1" , "doc:2" ]
339+
340+ def test_failures_take_precedence_over_percent_indexed (self ):
341+ """hash_indexing_failures > 0 always triggers SCAN, regardless of percent_indexed."""
342+ executor , mock_client = self ._build_executor_with_info (
343+ {"hash_indexing_failures" : 7 , "percent_indexed" : "1" }
344+ )
345+
346+ with patch .object (
347+ executor ,
348+ "_enumerate_with_scan" ,
349+ return_value = iter (["doc:1" ]),
350+ ) as scan_mock :
351+ keys = list (executor ._enumerate_indexed_keys (mock_client , "idx" ))
352+
353+ scan_mock .assert_called_once ()
354+ assert keys == ["doc:1" ]
355+
356+ def test_treats_missing_percent_indexed_as_complete (self ):
357+ """Missing percent_indexed key should default to 1.0 (use FT.AGGREGATE)."""
358+ executor , mock_client = self ._build_executor_with_info (
359+ {"hash_indexing_failures" : 0 }
360+ )
361+
362+ with (
363+ patch .object (
364+ executor ,
365+ "_enumerate_with_scan" ,
366+ return_value = iter (["should-not-be-used" ]),
367+ ) as scan_mock ,
368+ patch .object (
369+ executor ,
370+ "_enumerate_with_aggregate" ,
371+ return_value = iter (["doc:1" ]),
372+ ) as aggregate_mock ,
373+ ):
374+ keys = list (executor ._enumerate_indexed_keys (mock_client , "idx" ))
375+
376+ scan_mock .assert_not_called ()
377+ aggregate_mock .assert_called_once ()
378+ assert keys == ["doc:1" ]
0 commit comments