Skip to content

Commit f55e237

Browse files
committed
Bug fixes, test
1 parent df87d87 commit f55e237

5 files changed

Lines changed: 277 additions & 4 deletions

File tree

redisvl/migration/async_executor.py

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -78,14 +78,20 @@ async def _enumerate_indexed_keys(
7878
"""Async version: Enumerate document keys using FT.AGGREGATE with SCAN fallback.
7979
8080
Uses FT.AGGREGATE WITHCURSOR for efficient enumeration when the index
81-
has no indexing failures. Falls back to SCAN if:
81+
is fully built and has no indexing failures. Falls back to SCAN if:
8282
- Index has hash_indexing_failures > 0 (would miss failed docs)
83+
- Index has percent_indexed < 1.0 (background HNSW build still in
84+
progress; FT.AGGREGATE returns only fully-indexed docs and would
85+
silently drop the pending tail)
8386
- FT.AGGREGATE command fails for any reason
8487
"""
85-
# Check for indexing failures - if any, fall back to SCAN
88+
# Check for indexing failures or in-progress indexing — either
89+
# condition means FT.AGGREGATE would miss documents, so fall
90+
# back to SCAN for complete enumeration.
8691
try:
8792
info = await client.ft(index_name).info()
8893
failures = int(info.get("hash_indexing_failures", 0) or 0)
94+
percent_indexed = float(info.get("percent_indexed", 1.0) or 1.0)
8995
if failures > 0:
9096
logger.warning(
9197
f"Index '{index_name}' has {failures} indexing failures. "
@@ -96,6 +102,17 @@ async def _enumerate_indexed_keys(
96102
):
97103
yield key
98104
return
105+
if percent_indexed < 1.0:
106+
logger.warning(
107+
f"Index '{index_name}' is still building "
108+
f"(percent_indexed={percent_indexed:.4f}). "
109+
"Using SCAN for complete enumeration."
110+
)
111+
async for key in self._enumerate_with_scan(
112+
client, index_name, batch_size, key_separator
113+
):
114+
yield key
115+
return
99116
except Exception as e:
100117
logger.warning(f"Failed to check index info: {e}. Using SCAN fallback.")
101118
async for key in self._enumerate_with_scan(

redisvl/migration/executor.py

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -56,8 +56,11 @@ def _enumerate_indexed_keys(
5656
"""Enumerate document keys using FT.AGGREGATE with SCAN fallback.
5757
5858
Uses FT.AGGREGATE WITHCURSOR for efficient enumeration when the index
59-
has no indexing failures. Falls back to SCAN if:
59+
is fully built and has no indexing failures. Falls back to SCAN if:
6060
- Index has hash_indexing_failures > 0 (would miss failed docs)
61+
- Index has percent_indexed < 1.0 (background HNSW build still in
62+
progress; FT.AGGREGATE returns only fully-indexed docs and would
63+
silently drop the pending tail)
6164
- FT.AGGREGATE command fails for any reason
6265
6366
Args:
@@ -69,10 +72,13 @@ def _enumerate_indexed_keys(
6972
Yields:
7073
Document keys as strings
7174
"""
72-
# Check for indexing failures - if any, fall back to SCAN
75+
# Check for indexing failures or in-progress indexing — either
76+
# condition means FT.AGGREGATE would miss documents, so fall
77+
# back to SCAN for complete enumeration.
7378
try:
7479
info = client.ft(index_name).info()
7580
failures = int(info.get("hash_indexing_failures", 0) or 0)
81+
percent_indexed = float(info.get("percent_indexed", 1.0) or 1.0)
7682
if failures > 0:
7783
logger.warning(
7884
f"Index '{index_name}' has {failures} indexing failures. "
@@ -82,6 +88,16 @@ def _enumerate_indexed_keys(
8288
client, index_name, batch_size, key_separator
8389
)
8490
return
91+
if percent_indexed < 1.0:
92+
logger.warning(
93+
f"Index '{index_name}' is still building "
94+
f"(percent_indexed={percent_indexed:.4f}). "
95+
"Using SCAN for complete enumeration."
96+
)
97+
yield from self._enumerate_with_scan(
98+
client, index_name, batch_size, key_separator
99+
)
100+
return
85101
except Exception as e:
86102
logger.warning(f"Failed to check index info: {e}. Using SCAN fallback.")
87103
yield from self._enumerate_with_scan(

redisvl/migration/planner.py

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -164,6 +164,25 @@ def create_plan_from_patch(
164164
"the pre-migration count. This is expected and validation accounts for it."
165165
)
166166

167+
# Warn if source index is still building. FT.AGGREGATE returns only
168+
# fully-indexed docs, so applying a migration before the background
169+
# build settles would silently drop the pending tail. The executor
170+
# falls back to SCAN automatically, but surface the condition here
171+
# so users running `rvl migrate plan` can wait for indexing to
172+
# complete before applying.
173+
source_percent_indexed = float(
174+
snapshot.stats_snapshot.get("percent_indexed", 1.0) or 1.0
175+
)
176+
if source_percent_indexed < 1.0:
177+
warnings.append(
178+
f"Source index is still building "
179+
f"(percent_indexed={source_percent_indexed:.4f}). "
180+
"Apply will fall back to SCAN enumeration to avoid missing "
181+
"documents whose background HNSW indexing has not completed. "
182+
"Wait for percent_indexed to reach 1.0 before applying for "
183+
"the fastest migration path."
184+
)
185+
167186
# Check for SVS-VAMANA in target schema and add appropriate warnings
168187
svs_warnings = self._check_svs_vamana_requirements(
169188
merged_target_schema,

tests/unit/test_executor_backup_quantize.py

Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -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"]

tests/unit/test_migration_planner.py

Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1008,6 +1008,124 @@ def test_plan_no_warning_when_stats_missing_failures_key(monkeypatch, tmp_path):
10081008
assert len(failure_warnings) == 0
10091009

10101010

1011+
def test_plan_warns_when_source_is_still_indexing(monkeypatch, tmp_path):
1012+
"""Plan should warn when the source index has percent_indexed < 1.0."""
1013+
source_schema = _make_source_schema()
1014+
dummy_index = DummyIndex(
1015+
source_schema,
1016+
{"num_docs": 100, "hash_indexing_failures": 0, "percent_indexed": "0.42"},
1017+
[b"docs:1"],
1018+
)
1019+
monkeypatch.setattr(
1020+
"redisvl.migration.planner.SearchIndex.from_existing",
1021+
lambda *args, **kwargs: dummy_index,
1022+
)
1023+
1024+
patch_path = tmp_path / "schema_patch.yaml"
1025+
patch_path.write_text(
1026+
yaml.safe_dump(
1027+
{
1028+
"version": 1,
1029+
"changes": {
1030+
"add_fields": [
1031+
{"name": "status", "type": "tag", "path": "$.status"}
1032+
],
1033+
},
1034+
},
1035+
sort_keys=False,
1036+
)
1037+
)
1038+
1039+
planner = MigrationPlanner()
1040+
plan = planner.create_plan(
1041+
"docs",
1042+
redis_url="redis://localhost:6379",
1043+
schema_patch_path=str(patch_path),
1044+
)
1045+
1046+
indexing_warnings = [w for w in plan.warnings if "still building" in w]
1047+
assert len(indexing_warnings) == 1
1048+
assert "0.4200" in indexing_warnings[0]
1049+
1050+
1051+
def test_plan_no_warning_when_source_fully_indexed(monkeypatch, tmp_path):
1052+
"""Plan should NOT warn when percent_indexed == 1.0."""
1053+
source_schema = _make_source_schema()
1054+
dummy_index = DummyIndex(
1055+
source_schema,
1056+
{"num_docs": 100, "hash_indexing_failures": 0, "percent_indexed": "1"},
1057+
[b"docs:1"],
1058+
)
1059+
monkeypatch.setattr(
1060+
"redisvl.migration.planner.SearchIndex.from_existing",
1061+
lambda *args, **kwargs: dummy_index,
1062+
)
1063+
1064+
patch_path = tmp_path / "schema_patch.yaml"
1065+
patch_path.write_text(
1066+
yaml.safe_dump(
1067+
{
1068+
"version": 1,
1069+
"changes": {
1070+
"add_fields": [
1071+
{"name": "status", "type": "tag", "path": "$.status"}
1072+
],
1073+
},
1074+
},
1075+
sort_keys=False,
1076+
)
1077+
)
1078+
1079+
planner = MigrationPlanner()
1080+
plan = planner.create_plan(
1081+
"docs",
1082+
redis_url="redis://localhost:6379",
1083+
schema_patch_path=str(patch_path),
1084+
)
1085+
1086+
indexing_warnings = [w for w in plan.warnings if "still building" in w]
1087+
assert len(indexing_warnings) == 0
1088+
1089+
1090+
def test_plan_no_warning_when_percent_indexed_missing(monkeypatch, tmp_path):
1091+
"""Plan should treat missing percent_indexed as fully indexed (no warning)."""
1092+
source_schema = _make_source_schema()
1093+
dummy_index = DummyIndex(
1094+
source_schema,
1095+
{"num_docs": 100, "hash_indexing_failures": 0},
1096+
[b"docs:1"],
1097+
)
1098+
monkeypatch.setattr(
1099+
"redisvl.migration.planner.SearchIndex.from_existing",
1100+
lambda *args, **kwargs: dummy_index,
1101+
)
1102+
1103+
patch_path = tmp_path / "schema_patch.yaml"
1104+
patch_path.write_text(
1105+
yaml.safe_dump(
1106+
{
1107+
"version": 1,
1108+
"changes": {
1109+
"add_fields": [
1110+
{"name": "status", "type": "tag", "path": "$.status"}
1111+
],
1112+
},
1113+
},
1114+
sort_keys=False,
1115+
)
1116+
)
1117+
1118+
planner = MigrationPlanner()
1119+
plan = planner.create_plan(
1120+
"docs",
1121+
redis_url="redis://localhost:6379",
1122+
schema_patch_path=str(patch_path),
1123+
)
1124+
1125+
indexing_warnings = [w for w in plan.warnings if "still building" in w]
1126+
assert len(indexing_warnings) == 0
1127+
1128+
10111129
# =============================================================================
10121130
# TDD: Validation cluster-safe EXISTS + multi-prefix key translation
10131131
# =============================================================================

0 commit comments

Comments
 (0)