Skip to content

Commit 9b739c6

Browse files
committed
feat(migration): refuse batch plans with overlapping index prefixes
Two RediSearch indexes whose key prefixes overlap (one is a literal string-prefix of the other, matching FT.CREATE PREFIX semantics) cover the same Redis keyspace. Running a batch quantization migration over them re-reads vectors that an earlier index in the batch has already quantized, producing garbage bytes and corrupting data. Reject these batches at plan time so the operator must split the migration into disjoint groups before any vectors are touched. - utils: add normalize_prefixes and find_overlapping_index_groups, and align build_scan_match_patterns with FT.CREATE PREFIX semantics (literal prefix + glob, no injected key separator) so the scan pattern matches the same keyspace the planner reasons about. - planner: same scan-pattern fix in MigrationPlanner; bump the SCAN COUNT hint while we're touching the loop. - batch_planner: collect prefixes during applicability checks and raise ValueError listing the overlapping (index, index, [pairs]) before create_batch_plan returns. - tests/unit: TestBatchMigrationPlannerOverlapDetection covers identical, nested, multi-prefix, and disjoint cases plus the applicable-only filtering rule. - tests/integration: TestBatchMigrationOverlapDetectionIntegration exercises the same paths against a real Redis with FT.CREATE'd indexes.
1 parent 7352af0 commit 9b739c6

5 files changed

Lines changed: 409 additions & 13 deletions

File tree

redisvl/migration/batch_planner.py

Lines changed: 60 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,12 @@
1313
from redisvl.index import SearchIndex
1414
from redisvl.migration.models import BatchIndexEntry, BatchPlan, SchemaPatch
1515
from redisvl.migration.planner import MigrationPlanner
16-
from redisvl.migration.utils import list_indexes, timestamp_utc
16+
from redisvl.migration.utils import (
17+
find_overlapping_index_groups,
18+
list_indexes,
19+
normalize_prefixes,
20+
timestamp_utc,
21+
)
1722
from redisvl.redis.connection import RedisConnectionFactory
1823

1924

@@ -83,17 +88,27 @@ def create_batch_plan(
8388

8489
# Check applicability for each index
8590
batch_entries: List[BatchIndexEntry] = []
91+
applicable_prefixes: List[Tuple[str, List[str]]] = []
8692
requires_quantization = False
8793

8894
for index_name in index_names:
89-
entry, has_quantization = self._check_index_applicability(
95+
entry, has_quantization, prefixes = self._check_index_applicability(
9096
index_name=index_name,
9197
shared_patch=shared_patch,
9298
redis_client=client,
9399
)
94100
batch_entries.append(entry)
95101
if has_quantization:
96102
requires_quantization = True
103+
if entry.applicable:
104+
applicable_prefixes.append((index_name, prefixes))
105+
106+
# Refuse plan creation when applicable indexes share keyspace.
107+
# Overlapping indexes cause double-mutation of the same keys during
108+
# sequential batch execution (e.g., double-quantization of vectors).
109+
overlaps = find_overlapping_index_groups(applicable_prefixes)
110+
if overlaps:
111+
raise ValueError(self._format_overlap_error(overlaps))
97112

98113
batch_id = f"batch_{uuid.uuid4().hex[:12]}"
99114

@@ -156,16 +171,19 @@ def _check_index_applicability(
156171
index_name: str,
157172
shared_patch: SchemaPatch,
158173
redis_client: Any,
159-
) -> Tuple[BatchIndexEntry, bool]:
174+
) -> Tuple[BatchIndexEntry, bool, List[str]]:
160175
"""Check if the shared patch can be applied to a specific index.
161176
162177
Returns:
163-
Tuple of (BatchIndexEntry, requires_quantization).
178+
Tuple of (BatchIndexEntry, requires_quantization, prefixes).
179+
``prefixes`` is the list of key prefixes the index is bound to,
180+
or an empty list when the index could not be loaded.
164181
"""
165182
try:
166183
index = SearchIndex.from_existing(index_name, redis_client=redis_client)
167184
schema_dict = index.schema.to_dict()
168185
field_names = {f["name"] for f in schema_dict.get("fields", [])}
186+
prefixes = normalize_prefixes(schema_dict.get("index", {}).get("prefix"))
169187

170188
# Build a set of field names that includes rename targets so
171189
# that update_fields referencing the NEW name of a renamed field
@@ -189,6 +207,7 @@ def _check_index_applicability(
189207
skip_reason=f"Missing fields: {', '.join(missing_fields)}",
190208
),
191209
False,
210+
prefixes,
192211
)
193212

194213
# Validate rename targets don't collide with each other or
@@ -212,6 +231,7 @@ def _check_index_applicability(
212231
skip_reason=f"Rename targets collide: {', '.join(duplicates)}",
213232
),
214233
False,
234+
prefixes,
215235
)
216236
# Check if any rename target already exists and isn't itself being renamed away
217237
collisions = [
@@ -227,6 +247,7 @@ def _check_index_applicability(
227247
skip_reason=f"Rename targets already exist: {', '.join(collisions)}",
228248
),
229249
False,
250+
prefixes,
230251
)
231252

232253
# Check that add_fields don't already exist.
@@ -247,6 +268,7 @@ def _check_index_applicability(
247268
skip_reason=f"Fields already exist: {', '.join(existing_adds)}",
248269
),
249270
False,
271+
prefixes,
250272
)
251273

252274
# Try creating a plan to check for blocked changes
@@ -268,6 +290,7 @@ def _check_index_applicability(
268290
),
269291
),
270292
False,
293+
prefixes,
271294
)
272295

273296
# Detect quantization from the plan we already created
@@ -279,7 +302,11 @@ def _check_index_applicability(
279302
)
280303
)
281304

282-
return BatchIndexEntry(name=index_name, applicable=True), has_quantization
305+
return (
306+
BatchIndexEntry(name=index_name, applicable=True),
307+
has_quantization,
308+
prefixes,
309+
)
283310

284311
except (
285312
ConnectionError,
@@ -298,8 +325,36 @@ def _check_index_applicability(
298325
skip_reason=str(e),
299326
),
300327
False,
328+
[],
301329
)
302330

331+
@staticmethod
332+
def _format_overlap_error(
333+
overlaps: List[Tuple[str, str, List[Tuple[str, str]]]],
334+
) -> str:
335+
"""Build a human-readable error for overlapping index prefixes."""
336+
lines = [
337+
"Refusing to create batch plan: overlapping indexes detected.",
338+
"",
339+
"Multiple indexes in the batch share Redis key prefixes. Running a",
340+
"batch migration over overlapping indexes can mutate the same keys",
341+
"more than once (e.g., double-quantization of vectors), corrupting",
342+
"the underlying data.",
343+
"",
344+
"Conflicts:",
345+
]
346+
for name_a, name_b, pairs in overlaps:
347+
pretty_pairs = ", ".join(f"'{pa}' <-> '{pb}'" for pa, pb in pairs)
348+
lines.append(f" - {name_a} <-> {name_b}: {pretty_pairs}")
349+
lines.extend(
350+
[
351+
"",
352+
"Resolve by migrating overlapping indexes one at a time, or by",
353+
"narrowing the batch to a set of indexes with disjoint prefixes.",
354+
]
355+
)
356+
return "\n".join(lines)
357+
303358
def write_batch_plan(self, batch_plan: BatchPlan, path: str) -> None:
304359
"""Write batch plan to YAML file."""
305360
plan_path = Path(path).resolve()

redisvl/migration/planner.py

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -639,16 +639,18 @@ def _sample_keys(
639639
break
640640
if prefix == "":
641641
match_pattern = "*"
642-
elif prefix.endswith(key_separator):
643-
match_pattern = f"{prefix}*"
644642
else:
645-
match_pattern = f"{prefix}{key_separator}*"
643+
# Use literal prefix + glob, matching Redis Search PREFIX
644+
# semantics (pure string-prefix match). Do NOT insert the
645+
# key_separator — a PREFIX of "doc" must match "doc:1",
646+
# "doca:1", etc., exactly like FT.CREATE does.
647+
match_pattern = f"{prefix}*"
646648
cursor = 0
647649
while True:
648650
cursor, keys = client.scan(
649651
cursor=cursor,
650652
match=match_pattern,
651-
count=max(self.key_sample_limit, 10),
653+
count=max(self.key_sample_limit, 1000),
652654
)
653655
for key in keys:
654656
decoded_key = key.decode() if isinstance(key, bytes) else str(key)

redisvl/migration/utils.py

Lines changed: 58 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -100,10 +100,11 @@ def build_scan_match_patterns(prefixes: List[str], key_separator: str) -> List[s
100100
"Using '*' which will scan the entire keyspace."
101101
)
102102
return ["*"]
103-
if key_separator and not prefix.endswith(key_separator):
104-
patterns.add(f"{prefix}{key_separator}*")
105-
else:
106-
patterns.add(f"{prefix}*")
103+
# Use literal prefix + glob, matching Redis Search PREFIX semantics
104+
# (pure string-prefix match). Do NOT insert the key_separator — a
105+
# PREFIX of "doc" must match "doc:1", "doca:1", etc., exactly like
106+
# FT.CREATE does.
107+
patterns.add(f"{prefix}*")
107108
return sorted(patterns)
108109

109110

@@ -340,6 +341,59 @@ def timestamp_utc() -> str:
340341
return time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())
341342

342343

344+
def normalize_prefixes(prefix: Any) -> List[str]:
345+
"""Normalize an IndexInfo.prefix value to a list of strings."""
346+
if prefix is None:
347+
return []
348+
if isinstance(prefix, str):
349+
return [prefix]
350+
if isinstance(prefix, (list, tuple)):
351+
return [str(p) for p in prefix]
352+
return [str(prefix)]
353+
354+
355+
def _prefixes_overlap(a: List[str], b: List[str]) -> List[Tuple[str, str]]:
356+
"""Return concrete (prefix_a, prefix_b) pairs whose keyspaces overlap.
357+
358+
Two prefixes overlap when one is a literal string-prefix of the other,
359+
matching RediSearch FT.CREATE PREFIX semantics. An empty prefix matches
360+
every key.
361+
"""
362+
pairs: List[Tuple[str, str]] = []
363+
for pa in a:
364+
for pb in b:
365+
if pa == "" or pb == "" or pa.startswith(pb) or pb.startswith(pa):
366+
pairs.append((pa, pb))
367+
return pairs
368+
369+
370+
def find_overlapping_index_groups(
371+
indexes_with_prefixes: List[Tuple[str, List[str]]],
372+
) -> List[Tuple[str, str, List[Tuple[str, str]]]]:
373+
"""Find pairs of indexes whose key prefixes overlap.
374+
375+
Args:
376+
indexes_with_prefixes: list of (index_name, prefixes) tuples.
377+
378+
Returns:
379+
A list of (index_a, index_b, overlapping_prefix_pairs) tuples.
380+
Empty list when no overlaps exist.
381+
"""
382+
overlaps: List[Tuple[str, str, List[Tuple[str, str]]]] = []
383+
for i in range(len(indexes_with_prefixes)):
384+
name_a, prefixes_a = indexes_with_prefixes[i]
385+
if not prefixes_a:
386+
continue
387+
for j in range(i + 1, len(indexes_with_prefixes)):
388+
name_b, prefixes_b = indexes_with_prefixes[j]
389+
if not prefixes_b:
390+
continue
391+
pairs = _prefixes_overlap(prefixes_a, prefixes_b)
392+
if pairs:
393+
overlaps.append((name_a, name_b, pairs))
394+
return overlaps
395+
396+
343397
def estimate_disk_space(
344398
plan: MigrationPlan,
345399
*,

0 commit comments

Comments
 (0)