Skip to content

Commit 6721270

Browse files
committed
feat(migration): make vector backups mandatory for quantization
Quantization migrations now always write a vector backup before mutating data. The previous opt-out path (--keep-backup / no --backup-dir) is gone; instead the executor auto-defaults backup_dir to ./migration_backups when none is supplied and refuses to run if backup_dir is explicitly set to an empty value. Backup files are retained on disk after a successful migration so the operator can audit or roll back; cleanup is now a manual step. Changes: - executor / async_executor: auto-default backup_dir, drop keep_backup, drop the post-success cleanup step, expose DEFAULT_BACKUP_DIR. - batch_executor: thread backup_dir / batch_size / num_workers through apply_batch and resume_batch so per-index migrations inherit them. - cli migrate: drop --keep-backup, drop the '--workers > 1 requires --backup-dir' guard (backup is now always present), and add --backup-dir / --batch-size / --workers to batch-apply and batch-resume. Refresh the BGSAVE warnings to point at the automatic backup instead. - docs/migrate-indexes.md: update the backup section, the rollback section, and the CLI flag reference to match the new behavior. - scripts/test_migration_e2e.py, scripts/test_crash_resume_e2e.py: drop the keep_backup kwarg from executor.apply() calls. - tests: add TestMandatoryBackupEnforcement covering the auto-default, the empty-string rejection, and that backups are no longer cleaned up on success.
1 parent a85858d commit 6721270

9 files changed

Lines changed: 239 additions & 53 deletions

File tree

docs/user_guide/how_to_guides/migrate-indexes.md

Lines changed: 8 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -606,7 +606,7 @@ The migrator will:
606606

607607
**5. On successful completion:**
608608

609-
The backup phase is set to `completed`. By default, backup files are **automatically deleted** after a successful migration. Pass `--keep-backup` to retain them for post-migration auditing or potential rollback.
609+
The backup phase is set to `completed`. Backup files are **always retained** on disk for post-migration auditing and rollback. Delete them manually from `--backup-dir` once you have verified the migrated data and no longer need a recovery path.
610610

611611
#### Limitations
612612

@@ -688,7 +688,6 @@ rvl migrate validate \
688688
- `--backup-dir <dir>` : Directory for vector backup files. Enables crash-safe resume and manual rollback. Required when using `--workers` > 1.
689689
- `--batch-size <N>` : Keys per pipeline batch (default 500). Values 200 to 1000 are typical.
690690
- `--workers <N>` : Parallel quantization workers (default 1). Each worker opens its own Redis connection. See [Performance](#performance-tuning) for guidance.
691-
- `--keep-backup` : Retain backup files after a successful migration (default: auto-cleanup).
692691

693692
**Batch-specific flags:**
694693
- `--pattern` : Glob pattern to match index names (e.g., `*_idx`)
@@ -753,9 +752,10 @@ Backup files are written to the specified directory with this layout:
753752
**Disk usage:** approximately `num_docs × dims × bytes_per_element`.
754753
For example, 1M docs with 768-dim float32 vectors ≈ 2.9 GB.
755754

756-
By default, backup files are **automatically deleted** after a successful
757-
migration. Pass `--keep-backup` to retain them for post-migration auditing
758-
or potential rollback.
755+
Backup files are **always retained** on disk after a successful migration
756+
so they remain available for post-migration auditing and rollback. Delete
757+
the files manually from the backup directory once you no longer need a
758+
recovery path.
759759

760760
### Crash-Safe Resume
761761

@@ -808,9 +808,9 @@ rvl index create --schema original_schema.yaml --url redis://localhost:6379
808808
```
809809

810810
```{important}
811-
Rollback requires that backup files were preserved. Either pass
812-
`--keep-backup` during migration, or ensure the backup directory was not
813-
cleaned up. Without backup files, rollback is not possible.
811+
Rollback requires that the backup directory still contains the original
812+
backup files. Backups are retained automatically after migration; do not
813+
delete the directory until you are certain rollback is no longer needed.
814814
```
815815

816816
### Python API for Rollback
@@ -863,7 +863,6 @@ report = executor.apply(
863863
backup_dir="/tmp/migration_backups", # enables crash-safe resume
864864
batch_size=500, # keys per pipeline batch
865865
num_workers=4, # parallel quantization workers
866-
keep_backup=True, # retain backups for rollback
867866
)
868867
print(f"Quantized in {report.timings.quantize_duration_seconds}s")
869868
```

redisvl/cli/migrate.py

Lines changed: 72 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -205,6 +205,8 @@ def plan(self):
205205
self._print_plan_summary(args.plan_out, plan)
206206

207207
def apply(self):
208+
from redisvl.migration.executor import DEFAULT_BACKUP_DIR
209+
208210
parser = argparse.ArgumentParser(
209211
usage=(
210212
"rvl migrate apply --plan <migration_plan.yaml> "
@@ -222,7 +224,11 @@ def apply(self):
222224
parser.add_argument(
223225
"--backup-dir",
224226
dest="backup_dir",
225-
help="Directory for vector backup files. Enables crash-safe resume and rollback.",
227+
help=(
228+
"Directory for vector backup files. Enables crash-safe resume "
229+
"and rollback. Defaults to '{}' when quantization is needed. "
230+
"Backup is mandatory for quantization migrations."
231+
).format(DEFAULT_BACKUP_DIR),
226232
default=None,
227233
)
228234
parser.add_argument(
@@ -237,16 +243,9 @@ def apply(self):
237243
dest="num_workers",
238244
type=int,
239245
help="Number of parallel workers for quantization (default 1). "
240-
"Each worker gets its own Redis connection. Requires --backup-dir.",
246+
"Each worker gets its own Redis connection.",
241247
default=1,
242248
)
243-
parser.add_argument(
244-
"--keep-backup",
245-
dest="keep_backup",
246-
action="store_true",
247-
help="Keep backup files after successful migration (default: auto-delete).",
248-
default=False,
249-
)
250249
parser.add_argument(
251250
"--report-out",
252251
help="Path to write migration_report.yaml",
@@ -269,10 +268,6 @@ def apply(self):
269268
if args.num_workers < 1:
270269
parser.error("--workers must be >= 1")
271270

272-
# Validate --workers > 1 requires --backup-dir
273-
if args.num_workers > 1 and args.backup_dir is None:
274-
parser.error("--workers > 1 requires --backup-dir")
275-
276271
redis_url = create_redis_url(args)
277272
plan = load_migration_plan(args.plan)
278273

@@ -300,7 +295,6 @@ def apply(self):
300295
backup_dir=args.backup_dir,
301296
batch_size=args.batch_size,
302297
num_workers=args.num_workers,
303-
keep_backup=args.keep_backup,
304298
)
305299
)
306300
else:
@@ -311,7 +305,6 @@ def apply(self):
311305
backup_dir=args.backup_dir,
312306
batch_size=args.batch_size,
313307
num_workers=args.num_workers,
314-
keep_backup=args.keep_backup,
315308
)
316309

317310
write_migration_report(report, args.report_out)
@@ -516,7 +509,6 @@ def _apply_sync(
516509
backup_dir: Optional[str] = None,
517510
batch_size: int = 500,
518511
num_workers: int = 1,
519-
keep_backup: bool = False,
520512
):
521513
"""Execute migration synchronously."""
522514
executor = MigrationExecutor()
@@ -531,7 +523,6 @@ def _apply_sync(
531523
backup_dir=backup_dir,
532524
batch_size=batch_size,
533525
num_workers=num_workers,
534-
keep_backup=keep_backup,
535526
)
536527

537528
self._print_apply_result(report)
@@ -545,7 +536,6 @@ async def _apply_async(
545536
backup_dir: Optional[str] = None,
546537
batch_size: int = 500,
547538
num_workers: int = 1,
548-
keep_backup: bool = False,
549539
):
550540
"""Execute migration asynchronously (non-blocking for large quantization jobs)."""
551541
executor = AsyncMigrationExecutor()
@@ -560,7 +550,6 @@ async def _apply_async(
560550
backup_dir=backup_dir,
561551
batch_size=batch_size,
562552
num_workers=num_workers,
563-
keep_backup=keep_backup,
564553
)
565554

566555
self._print_apply_result(report)
@@ -759,10 +748,13 @@ def batch_plan(self):
759748

760749
def batch_apply(self):
761750
"""Execute a batch migration plan with state tracking."""
751+
from redisvl.migration.executor import DEFAULT_BACKUP_DIR
752+
762753
parser = argparse.ArgumentParser(
763754
usage=(
764755
"rvl migrate batch-apply --plan <batch_plan.yaml> "
765-
"[--state <batch_state.yaml>] [--report-dir <./reports>]"
756+
"[--state <batch_state.yaml>] [--report-dir <./reports>] "
757+
"[--backup-dir <dir>] [--workers N]"
766758
)
767759
)
768760
parser.add_argument("--plan", help="Path to batch_plan.yaml", required=True)
@@ -781,6 +773,30 @@ def batch_apply(self):
781773
help="Directory for per-index migration reports",
782774
default="./reports",
783775
)
776+
parser.add_argument(
777+
"--backup-dir",
778+
dest="backup_dir",
779+
help=(
780+
"Directory for vector backup files. "
781+
f"Defaults to '{DEFAULT_BACKUP_DIR}' when quantization is needed. "
782+
"Backup is mandatory for quantization migrations."
783+
),
784+
default=None,
785+
)
786+
parser.add_argument(
787+
"--batch-size",
788+
dest="batch_size",
789+
type=int,
790+
help="Keys per pipeline batch (default 500)",
791+
default=500,
792+
)
793+
parser.add_argument(
794+
"--workers",
795+
dest="num_workers",
796+
type=int,
797+
help="Number of parallel workers for quantization (default 1).",
798+
default=1,
799+
)
784800
parser = add_redis_connection_options(parser)
785801
args = parser.parse_args(sys.argv[3:])
786802

@@ -795,8 +811,7 @@ def batch_apply(self):
795811
Vector data will be modified. Original precision cannot be recovered.
796812
To proceed, add --accept-data-loss flag.
797813
798-
If you need to preserve original vectors, backup your data first:
799-
redis-cli BGSAVE"""
814+
Vectors will be automatically backed up before quantization."""
800815
)
801816
sys.exit(1)
802817

@@ -815,16 +830,22 @@ def progress_callback(
815830
report_dir=args.report_dir,
816831
redis_url=redis_url,
817832
progress_callback=progress_callback,
833+
backup_dir=args.backup_dir,
834+
batch_size=args.batch_size,
835+
num_workers=args.num_workers,
818836
)
819837

820838
self._print_batch_report_summary(report)
821839

822840
def batch_resume(self):
823841
"""Resume an interrupted batch migration."""
842+
from redisvl.migration.executor import DEFAULT_BACKUP_DIR
843+
824844
parser = argparse.ArgumentParser(
825845
usage=(
826846
"rvl migrate batch-resume --state <batch_state.yaml> "
827-
"[--plan <batch_plan.yaml>] [--retry-failed]"
847+
"[--plan <batch_plan.yaml>] [--retry-failed] "
848+
"[--backup-dir <dir>]"
828849
)
829850
)
830851
parser.add_argument("--state", help="Path to batch state file", required=True)
@@ -846,6 +867,30 @@ def batch_resume(self):
846867
help="Directory for per-index migration reports",
847868
default="./reports",
848869
)
870+
parser.add_argument(
871+
"--backup-dir",
872+
dest="backup_dir",
873+
help=(
874+
"Directory for vector backup files. "
875+
f"Defaults to '{DEFAULT_BACKUP_DIR}' when quantization is needed. "
876+
"Backup is mandatory for quantization migrations."
877+
),
878+
default=None,
879+
)
880+
parser.add_argument(
881+
"--batch-size",
882+
dest="batch_size",
883+
type=int,
884+
help="Keys per pipeline batch (default 500)",
885+
default=500,
886+
)
887+
parser.add_argument(
888+
"--workers",
889+
dest="num_workers",
890+
type=int,
891+
help="Number of parallel workers for quantization (default 1).",
892+
default=1,
893+
)
849894
parser = add_redis_connection_options(parser)
850895
args = parser.parse_args(sys.argv[3:])
851896

@@ -861,8 +906,7 @@ def batch_resume(self):
861906
Vector data will be modified. Original precision cannot be recovered.
862907
To proceed, add --accept-data-loss flag.
863908
864-
If you need to preserve original vectors, backup your data first:
865-
redis-cli BGSAVE"""
909+
Vectors will be automatically backed up before quantization."""
866910
)
867911
sys.exit(1)
868912

@@ -880,6 +924,9 @@ def progress_callback(
880924
report_dir=args.report_dir,
881925
redis_url=redis_url,
882926
progress_callback=progress_callback,
927+
backup_dir=args.backup_dir,
928+
batch_size=args.batch_size,
929+
num_workers=args.num_workers,
883930
)
884931

885932
self._print_batch_report_summary(report)

redisvl/migration/__init__.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@
1414
from redisvl.migration.async_validation import AsyncMigrationValidator
1515
from redisvl.migration.batch_executor import BatchMigrationExecutor
1616
from redisvl.migration.batch_planner import BatchMigrationPlanner
17-
from redisvl.migration.executor import MigrationExecutor
17+
from redisvl.migration.executor import DEFAULT_BACKUP_DIR, MigrationExecutor
1818
from redisvl.migration.models import BatchPlan, BatchState, SchemaPatch
1919
from redisvl.migration.planner import MigrationPlanner
2020
from redisvl.migration.validation import MigrationValidator
@@ -28,6 +28,7 @@
2828
"BatchMigrationPlanner",
2929
"BatchPlan",
3030
"BatchState",
31+
"DEFAULT_BACKUP_DIR",
3132
"MigrationExecutor",
3233
"MigrationPlanner",
3334
"MigrationValidator",

redisvl/migration/async_executor.py

Lines changed: 23 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
from redisvl.index import AsyncSearchIndex
1616
from redisvl.migration.async_planner import AsyncMigrationPlanner
1717
from redisvl.migration.async_validation import AsyncMigrationValidator
18+
from redisvl.migration.executor import DEFAULT_BACKUP_DIR
1819
from redisvl.migration.models import (
1920
MigrationBenchmarkSummary,
2021
MigrationPlan,
@@ -549,7 +550,6 @@ async def apply(
549550
backup_dir: Optional[str] = None,
550551
batch_size: int = 500,
551552
num_workers: int = 1,
552-
keep_backup: bool = False,
553553
) -> MigrationReport:
554554
"""Apply a migration plan asynchronously.
555555
@@ -575,8 +575,6 @@ async def apply(
575575
num_workers: Parallel quantization workers (default 1). For
576576
low-dimensional vectors (≤ 256 dims) a single worker is
577577
often fastest. Diminishing returns above 4–8 workers.
578-
keep_backup: Retain backup files after success (default
579-
``False``).
580578
"""
581579
started_at = timestamp_utc()
582580
started = time.perf_counter()
@@ -696,6 +694,28 @@ async def apply(
696694
for change in datatype_changes.values()
697695
)
698696

697+
# Auto-default backup_dir when quantization is needed and no dir
698+
# was provided. This ensures vector data is always backed up
699+
# before destructive in-place mutations.
700+
if needs_quantization and backup_dir is None:
701+
backup_dir = DEFAULT_BACKUP_DIR
702+
logger.info(
703+
"Quantization detected — using default backup directory: %s",
704+
backup_dir,
705+
)
706+
707+
# MANDATORY BACKUP ENFORCEMENT: After auto-defaulting, backup_dir
708+
# must be set for any quantization migration. This is a hard safety
709+
# check — quantization without backup is never allowed.
710+
if needs_quantization and not backup_dir:
711+
raise ValueError(
712+
"Vector backup is mandatory for quantization migrations. "
713+
"A backup directory must be provided via --backup-dir or the "
714+
f"default '{DEFAULT_BACKUP_DIR}' must be writable. "
715+
"Quantization without backup is not allowed to prevent "
716+
"irreversible data loss."
717+
)
718+
699719
if backup_dir and has_same_width_quantization:
700720
report.validation.errors.append(
701721
"Crash-safe resume is not supported for same-width datatype "
@@ -1140,10 +1160,6 @@ def _index_progress(indexed: int, total: int, pct: float) -> None:
11401160
finally:
11411161
report.finished_at = timestamp_utc()
11421162

1143-
# Auto-cleanup backup files on success
1144-
if backup_dir and not keep_backup and report.result == "succeeded":
1145-
self._cleanup_backup_files(backup_dir, plan.source.index_name)
1146-
11471163
return report
11481164

11491165
def _cleanup_backup_files(self, backup_dir: str, index_name: str) -> None:

0 commit comments

Comments
 (0)