Skip to content

Commit a9b3972

Browse files
committed
fix: resolve all open review comments across migration PRs
- Validation: replace multi-key EXISTS with per-key calls (cluster-safe) - Validation: multi-prefix key translation matches against all prefixes - Wizard: block rename to a name staged for removal - Batch executor: broad regex filename sanitization for report files - CLI: empty-string plan_path no longer bypasses batch-resume safety gate - Quantize: fix split_keys docstring, remove dead sum() code - Add 10 TDD tests covering all fixes
1 parent f22bcdd commit a9b3972

9 files changed

Lines changed: 401 additions & 26 deletions

File tree

redisvl/cli/migrate.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -843,7 +843,7 @@ def batch_resume(self):
843843
# Load the batch plan to check for quantization safety gate
844844
executor = BatchMigrationExecutor()
845845
state = executor._load_state(args.state)
846-
plan_path = args.plan or state.plan_path or None
846+
plan_path = args.plan or (state.plan_path.strip() if state.plan_path else None)
847847
if plan_path:
848848
batch_plan = executor._load_batch_plan(plan_path)
849849
if batch_plan.requires_quantization and not args.accept_data_loss:

redisvl/migration/async_validation.py

Lines changed: 12 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -78,15 +78,21 @@ async def validate(
7878
# new_key = new_prefix + key[len(old_prefix):]
7979
keys_to_check = key_sample
8080
if plan.rename_operations.change_prefix is not None:
81-
old_prefix = plan.source.keyspace.prefixes[0]
81+
old_prefixes = plan.source.keyspace.prefixes
8282
new_prefix = plan.rename_operations.change_prefix
8383
keys_to_check = []
8484
for k in key_sample:
85-
if k.startswith(old_prefix):
86-
keys_to_check.append(new_prefix + k[len(old_prefix) :])
87-
else:
88-
keys_to_check.append(k)
89-
existing_count = await client.exists(*keys_to_check)
85+
translated = k
86+
for old_prefix in old_prefixes:
87+
if k.startswith(old_prefix):
88+
translated = new_prefix + k[len(old_prefix) :]
89+
break
90+
keys_to_check.append(translated)
91+
# Check keys one at a time to avoid Redis Cluster cross-slot
92+
# errors from multi-key EXISTS commands.
93+
existing_count = 0
94+
for key in keys_to_check:
95+
existing_count += await client.exists(key)
9096
validation.key_sample_exists = existing_count == len(keys_to_check)
9197

9298
# Run automatic functional checks (always).

redisvl/migration/batch_executor.py

Lines changed: 5 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
from __future__ import annotations
44

5+
import re
56
import time
67
from pathlib import Path
78
from typing import Any, Callable, Optional
@@ -221,13 +222,10 @@ def _migrate_single_index(
221222
redis_client=redis_client,
222223
)
223224

224-
# Sanitize index_name to prevent path traversal and invalid filenames
225-
safe_name = (
226-
index_name.replace("/", "_")
227-
.replace("\\", "_")
228-
.replace("..", "_")
229-
.replace(":", "_")
230-
)
225+
# Sanitize index_name: replace any character that isn't
226+
# alphanumeric, dot, hyphen, or underscore to avoid path
227+
# traversal and filesystem-invalid characters (e.g. : on Windows).
228+
safe_name = re.sub(r"[^A-Za-z0-9._-]", "_", index_name)
231229
report_file = report_dir / f"{safe_name}_report.yaml"
232230
write_yaml(report.model_dump(exclude_none=True), str(report_file))
233231

redisvl/migration/quantize.py

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -128,7 +128,9 @@ def split_keys(keys: List[str], num_workers: int) -> List[List[str]]:
128128
num_workers: Number of workers
129129
130130
Returns:
131-
List of key slices (some may be empty if keys < workers)
131+
List of key slices. May contain fewer than ``num_workers``
132+
entries when ``len(keys) < num_workers``; returns an empty
133+
list when *keys* is empty.
132134
"""
133135
if num_workers < 1:
134136
raise ValueError(f"num_workers must be >= 1, got {num_workers}")
@@ -212,9 +214,6 @@ def _worker_quantize(
212214
backup.mark_complete()
213215
elif backup.header.phase == "completed":
214216
# Already done from previous run
215-
docs_quantized = sum(
216-
1 for _ in range(0, total, batch_size) for _ in keys[:batch_size]
217-
)
218217
docs_quantized = total
219218

220219
return {"worker_id": worker_id, "docs": docs_quantized}

redisvl/migration/validation.py

Lines changed: 12 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -67,17 +67,21 @@ def validate(
6767
# new_key = new_prefix + key[len(old_prefix):]
6868
keys_to_check = key_sample
6969
if plan.rename_operations.change_prefix is not None:
70-
old_prefix = plan.source.keyspace.prefixes[0]
70+
old_prefixes = plan.source.keyspace.prefixes
7171
new_prefix = plan.rename_operations.change_prefix
72-
# Mirror executor logic exactly:
73-
# new_key = new_prefix + key[len(old_prefix):]
7472
keys_to_check = []
7573
for k in key_sample:
76-
if k.startswith(old_prefix):
77-
keys_to_check.append(new_prefix + k[len(old_prefix) :])
78-
else:
79-
keys_to_check.append(k)
80-
existing_count = target_index.client.exists(*keys_to_check)
74+
translated = k
75+
for old_prefix in old_prefixes:
76+
if k.startswith(old_prefix):
77+
translated = new_prefix + k[len(old_prefix) :]
78+
break
79+
keys_to_check.append(translated)
80+
# Check keys one at a time to avoid Redis Cluster cross-slot
81+
# errors from multi-key EXISTS commands.
82+
existing_count = sum(
83+
target_index.client.exists(key) for key in keys_to_check
84+
)
8185
validation.key_sample_exists = existing_count == len(keys_to_check)
8286

8387
# Run automatic functional checks (always).

redisvl/migration/wizard.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -295,11 +295,18 @@ def _build_patch(
295295
field_rename = self._prompt_rename_field(rename_schema)
296296
if field_rename:
297297
# Check rename target doesn't collide with staged additions
298+
# or staged removals
299+
staged_remove_names = set(changes.remove_fields)
298300
if field_rename.new_name in staged_add_names:
299301
print(
300302
f"Cannot rename to '{field_rename.new_name}': "
301303
"a field with that name is already staged for addition."
302304
)
305+
elif field_rename.new_name in staged_remove_names:
306+
print(
307+
f"Cannot rename to '{field_rename.new_name}': "
308+
"a field with that name is staged for removal."
309+
)
303310
else:
304311
# Collapse chained renames: if there's an existing
305312
# rename X→Y and the user now renames Y→Z, collapse

tests/unit/test_batch_migration.py

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1364,3 +1364,89 @@ def test_failed_status_when_all_fail(self, tmp_path):
13641364
assert report.status == "failed"
13651365
assert report.summary.failed == 2
13661366
assert report.summary.successful == 0
1367+
1368+
1369+
# =============================================================================
1370+
# TDD: Batch executor/planner hardening fixes
1371+
# =============================================================================
1372+
1373+
1374+
class TestBatchFileSanitization:
1375+
"""Test that report filenames are broadly sanitized."""
1376+
1377+
def test_special_chars_in_index_name(self, tmp_path):
1378+
"""Colons, spaces, pipes, and other special chars should be sanitized."""
1379+
import re
1380+
1381+
# Simulate the sanitization logic
1382+
index_name = "my:index/with\\special|chars <>"
1383+
safe_name = re.sub(r"[^A-Za-z0-9._-]", "_", index_name)
1384+
report_file = tmp_path / f"{safe_name}_report.yaml"
1385+
1386+
# Should not raise
1387+
report_file.write_text("ok")
1388+
assert report_file.exists()
1389+
# No forbidden chars in filename
1390+
assert ":" not in safe_name
1391+
assert "/" not in safe_name
1392+
assert "\\" not in safe_name
1393+
assert "|" not in safe_name
1394+
assert "<" not in safe_name
1395+
assert ">" not in safe_name
1396+
1397+
1398+
class TestBatchPlannerDedup:
1399+
"""Test that duplicate index names are deduplicated."""
1400+
1401+
def test_explicit_indexes_deduped(self):
1402+
"""Duplicate index names in explicit list should be deduplicated."""
1403+
planner = BatchMigrationPlanner()
1404+
result = planner._resolve_index_names(
1405+
indexes=["idx1", "idx2", "idx1", "idx3", "idx2"],
1406+
pattern=None,
1407+
indexes_file=None,
1408+
redis_client=MockRedisClient(indexes=[]),
1409+
)
1410+
assert result == ["idx1", "idx2", "idx3"]
1411+
1412+
1413+
class TestBatchFailurePolicyValidation:
1414+
"""Test that invalid failure policies are rejected."""
1415+
1416+
def test_invalid_failure_policy_raises(self):
1417+
"""Unknown failure_policy values should raise ValueError."""
1418+
planner = BatchMigrationPlanner()
1419+
mock_client = MockRedisClient(indexes=["idx1"])
1420+
1421+
with pytest.raises(ValueError, match="Invalid failure_policy"):
1422+
planner.create_batch_plan(
1423+
indexes=["idx1"],
1424+
schema_patch_path="nonexistent.yaml",
1425+
redis_client=mock_client,
1426+
failure_policy="invalid_policy",
1427+
)
1428+
1429+
1430+
class TestBatchResumeEmptyPlanPath:
1431+
"""Test that empty-string plan_path doesn't bypass safety gate."""
1432+
1433+
def test_empty_plan_path_raises(self):
1434+
"""resume() should raise when plan_path is empty string."""
1435+
executor = BatchMigrationExecutor()
1436+
1437+
state = BatchState(
1438+
batch_id="test",
1439+
plan_path="", # Empty string
1440+
started_at="2024-01-01T00:00:00Z",
1441+
updated_at="2024-01-01T00:00:00Z",
1442+
remaining=["idx1"],
1443+
)
1444+
1445+
# resume calls _load_state which needs a file, but the plan_path
1446+
# validation happens first. Let's test via the executor's resume method
1447+
# by mocking _load_state.
1448+
from unittest.mock import patch as mock_patch
1449+
1450+
with mock_patch.object(executor, "_load_state", return_value=state):
1451+
with pytest.raises(ValueError, match="No batch plan path"):
1452+
executor.resume("fake_state.yaml")

tests/unit/test_migration_planner.py

Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1006,3 +1006,126 @@ def test_plan_no_warning_when_stats_missing_failures_key(monkeypatch, tmp_path):
10061006

10071007
failure_warnings = [w for w in plan.warnings if "hash indexing failure" in w]
10081008
assert len(failure_warnings) == 0
1009+
1010+
1011+
# =============================================================================
1012+
# TDD: Validation cluster-safe EXISTS + multi-prefix key translation
1013+
# =============================================================================
1014+
from unittest.mock import MagicMock
1015+
1016+
from redisvl.migration.models import (
1017+
DiffClassification,
1018+
KeyspaceSnapshot,
1019+
MigrationPlan,
1020+
MigrationValidation,
1021+
RenameOperations,
1022+
SourceSnapshot,
1023+
ValidationPolicy,
1024+
)
1025+
from redisvl.migration.validation import MigrationValidator
1026+
1027+
1028+
def _make_minimal_plan(
1029+
*,
1030+
key_sample,
1031+
prefixes,
1032+
change_prefix=None,
1033+
merged_target_schema=None,
1034+
):
1035+
"""Build a minimal MigrationPlan for validator testing."""
1036+
if merged_target_schema is None:
1037+
merged_target_schema = {
1038+
"index": {"name": "target_idx", "prefix": "new:", "storage_type": "hash"},
1039+
"fields": [{"name": "title", "type": "text"}],
1040+
}
1041+
1042+
return MigrationPlan(
1043+
source=SourceSnapshot(
1044+
index_name="src_idx",
1045+
schema_snapshot={
1046+
"index": {"name": "src_idx", "prefix": "old:", "storage_type": "hash"},
1047+
"fields": [{"name": "title", "type": "text"}],
1048+
},
1049+
stats_snapshot={"num_docs": 3, "hash_indexing_failures": 0},
1050+
keyspace=KeyspaceSnapshot(
1051+
storage_type="hash",
1052+
prefixes=prefixes,
1053+
key_separator=":",
1054+
key_sample=key_sample,
1055+
),
1056+
),
1057+
requested_changes={"version": 1, "changes": {}},
1058+
merged_target_schema=merged_target_schema,
1059+
diff_classification=DiffClassification(supported=True),
1060+
rename_operations=RenameOperations(change_prefix=change_prefix),
1061+
)
1062+
1063+
1064+
class TestValidatorClusterSafeExists:
1065+
"""Verify per-key EXISTS calls (not multi-key splat)."""
1066+
1067+
def test_exists_called_per_key(self, monkeypatch):
1068+
"""EXISTS should be called once per key, not with *keys_to_check."""
1069+
plan = _make_minimal_plan(
1070+
key_sample=["old:1", "old:2", "old:3"],
1071+
prefixes=["old:"],
1072+
)
1073+
1074+
mock_client = MagicMock()
1075+
mock_client.exists.return_value = 1 # Each key exists
1076+
1077+
mock_index = MagicMock()
1078+
mock_index.client = mock_client
1079+
mock_index.info.return_value = {"num_docs": 3, "hash_indexing_failures": 0}
1080+
mock_index.schema.to_dict.return_value = plan.merged_target_schema
1081+
mock_index.search.return_value = MagicMock(total=3)
1082+
1083+
monkeypatch.setattr(
1084+
"redisvl.migration.validation.SearchIndex.from_existing",
1085+
lambda *a, **kw: mock_index,
1086+
)
1087+
1088+
validator = MigrationValidator()
1089+
validation, _, _ = validator.validate(plan, redis_url="redis://localhost")
1090+
1091+
# EXISTS should have been called 3 times (once per key), not once with 3 args
1092+
assert mock_client.exists.call_count == 3
1093+
for call in mock_client.exists.call_args_list:
1094+
# Each call should have exactly 1 positional arg
1095+
assert len(call.args) == 1
1096+
1097+
1098+
class TestValidatorMultiPrefixKeyTranslation:
1099+
"""Verify multi-prefix key translation during prefix change."""
1100+
1101+
def test_multi_prefix_keys_translated(self, monkeypatch):
1102+
"""Keys matching different prefixes should all be translated correctly."""
1103+
plan = _make_minimal_plan(
1104+
key_sample=["pfx_a:1", "pfx_b:2", "pfx_a:3"],
1105+
prefixes=["pfx_a:", "pfx_b:"],
1106+
change_prefix="new:",
1107+
)
1108+
1109+
mock_client = MagicMock()
1110+
mock_client.exists.return_value = 1
1111+
1112+
mock_index = MagicMock()
1113+
mock_index.client = mock_client
1114+
mock_index.info.return_value = {"num_docs": 3, "hash_indexing_failures": 0}
1115+
mock_index.schema.to_dict.return_value = plan.merged_target_schema
1116+
mock_index.search.return_value = MagicMock(total=3)
1117+
1118+
monkeypatch.setattr(
1119+
"redisvl.migration.validation.SearchIndex.from_existing",
1120+
lambda *a, **kw: mock_index,
1121+
)
1122+
1123+
validator = MigrationValidator()
1124+
validation, _, _ = validator.validate(plan, redis_url="redis://localhost")
1125+
1126+
# Verify the keys were translated correctly
1127+
called_keys = [call.args[0] for call in mock_client.exists.call_args_list]
1128+
assert "new:1" in called_keys
1129+
assert "new:2" in called_keys
1130+
assert "new:3" in called_keys
1131+
assert validation.key_sample_exists is True

0 commit comments

Comments
 (0)