Skip to content

Commit d8acb43

Browse files
committed
Enhance datetime migration with production-ready features
- Add comprehensive error handling with configurable failure modes - Implement batch processing with performance monitoring - Add migration verification and data integrity checking - Create resume capability for interrupted migrations - Add detailed CLI commands for migration management - Include comprehensive documentation and troubleshooting guides Addresses #467 datetime field indexing improvements
1 parent 148c6e7 commit d8acb43

File tree

9 files changed

+3639
-68
lines changed

9 files changed

+3639
-68
lines changed

aredis_om/model/cli/migrate_data.py

Lines changed: 301 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
from redis.exceptions import TimeoutError as RedisTimeoutError
1414

1515
from ..migrations.data_migrator import DataMigrationError, DataMigrator
16+
from ..migrations.datetime_migration import ConversionFailureMode
1617

1718

1819
def run_async(coro):
@@ -68,8 +69,10 @@ def migrate_data():
6869
help="Directory containing migration files (default: <root>/data-migrations)",
6970
)
7071
@click.option("--module", help="Python module containing migrations")
72+
@click.option("--detailed", is_flag=True, help="Show detailed migration information")
73+
@click.option("--verbose", "-v", is_flag=True, help="Enable verbose output")
7174
@handle_redis_errors
72-
def status(migrations_dir: str, module: str):
75+
def status(migrations_dir: str, module: str, detailed: bool, verbose: bool):
7376
"""Show current migration status."""
7477
# Default directory to <root>/data-migrations when not provided
7578
from ...settings import get_root_migrations_dir
@@ -90,14 +93,56 @@ def status(migrations_dir: str, module: str):
9093
click.echo(f" Pending: {status_info['pending_count']}")
9194

9295
if status_info["pending_migrations"]:
93-
click.echo("\nPending migrations:")
96+
click.echo("\n⚠️ Pending migrations:")
9497
for migration_id in status_info["pending_migrations"]:
95-
click.echo(f"- {migration_id}")
98+
click.echo(f" - {migration_id}")
9699

97100
if status_info["applied_migrations"]:
98-
click.echo("\nApplied migrations:")
101+
click.echo("\n✅ Applied migrations:")
99102
for migration_id in status_info["applied_migrations"]:
100-
click.echo(f"- {migration_id}")
103+
click.echo(f" ✓ {migration_id}")
104+
105+
# Show detailed information if requested
106+
if detailed:
107+
click.echo("\nDetailed Migration Information:")
108+
109+
# Get all discovered migrations for detailed info
110+
all_migrations = run_async(migrator.discover_migrations())
111+
112+
for migration_id, migration in all_migrations.items():
113+
is_applied = migration_id in status_info["applied_migrations"]
114+
status_icon = "✓" if is_applied else "○"
115+
status_text = "Applied" if is_applied else "Pending"
116+
117+
click.echo(f"\n {status_icon} {migration_id} ({status_text})")
118+
click.echo(f" Description: {migration.description}")
119+
120+
if hasattr(migration, "dependencies") and migration.dependencies:
121+
click.echo(f" Dependencies: {', '.join(migration.dependencies)}")
122+
else:
123+
click.echo(" Dependencies: None")
124+
125+
# Check if migration can run
126+
try:
127+
can_run = run_async(migration.can_run())
128+
can_run_text = "Yes" if can_run else "No"
129+
click.echo(f" Can run: {can_run_text}")
130+
except Exception as e:
131+
click.echo(f" Can run: Error checking ({e})")
132+
133+
# Show rollback support
134+
try:
135+
# Try to call down() in dry-run mode to see if it's supported
136+
supports_rollback = hasattr(migration, "down") and callable(
137+
migration.down
138+
)
139+
rollback_text = "Yes" if supports_rollback else "No"
140+
click.echo(f" Supports rollback: {rollback_text}")
141+
except Exception:
142+
click.echo(" Supports rollback: Unknown")
143+
144+
if verbose:
145+
click.echo(f"\nRaw status data: {status_info}")
101146

102147

103148
@migrate_data.command()
@@ -112,6 +157,19 @@ def status(migrations_dir: str, module: str):
112157
@click.option("--verbose", "-v", is_flag=True, help="Enable verbose output")
113158
@click.option("--limit", type=int, help="Limit number of migrations to run")
114159
@click.option("--yes", "-y", is_flag=True, help="Skip confirmation prompt")
160+
@click.option(
161+
"--failure-mode",
162+
type=click.Choice(["skip", "fail", "default", "log_and_skip"]),
163+
default="log_and_skip",
164+
help="How to handle conversion failures (default: log_and_skip)",
165+
)
166+
@click.option(
167+
"--batch-size",
168+
type=int,
169+
default=1000,
170+
help="Batch size for processing (default: 1000)",
171+
)
172+
@click.option("--max-errors", type=int, help="Maximum errors before stopping migration")
115173
@handle_redis_errors
116174
def run(
117175
migrations_dir: str,
@@ -120,6 +178,9 @@ def run(
120178
verbose: bool,
121179
limit: int,
122180
yes: bool,
181+
failure_mode: str,
182+
batch_size: int,
183+
max_errors: int,
123184
):
124185
"""Run pending migrations."""
125186
import os
@@ -279,5 +340,240 @@ def rollback(
279340
click.echo(f"Migration '{migration_id}' does not support rollback.", err=True)
280341

281342

343+
@migrate_data.command()
344+
@click.option(
345+
"--migrations-dir",
346+
help="Directory containing migration files (default: <root>/data-migrations)",
347+
)
348+
@click.option("--module", help="Python module containing migrations")
349+
@click.option("--verbose", "-v", is_flag=True, help="Enable verbose output")
350+
@click.option("--check-data", is_flag=True, help="Perform data integrity checks")
351+
@handle_redis_errors
352+
def verify(migrations_dir: str, module: str, verbose: bool, check_data: bool):
353+
"""Verify migration status and optionally check data integrity."""
354+
import os
355+
356+
from ...settings import get_root_migrations_dir
357+
358+
resolved_dir = migrations_dir or os.path.join(
359+
get_root_migrations_dir(), "data-migrations"
360+
)
361+
migrator = DataMigrator(
362+
migrations_dir=resolved_dir if not module else None,
363+
migration_module=module,
364+
)
365+
366+
# Get migration status
367+
status_info = run_async(migrator.status())
368+
369+
click.echo("Migration Verification Report:")
370+
click.echo(f" Total migrations: {status_info['total_migrations']}")
371+
click.echo(f" Applied: {status_info['applied_count']}")
372+
click.echo(f" Pending: {status_info['pending_count']}")
373+
374+
if status_info["pending_migrations"]:
375+
click.echo("\n⚠️ Pending migrations found:")
376+
for migration_id in status_info["pending_migrations"]:
377+
click.echo(f" - {migration_id}")
378+
click.echo("\nRun 'om migrate-data run' to apply pending migrations.")
379+
else:
380+
click.echo("\n✅ All migrations are applied.")
381+
382+
if status_info["applied_migrations"]:
383+
click.echo("\nApplied migrations:")
384+
for migration_id in status_info["applied_migrations"]:
385+
click.echo(f" ✓ {migration_id}")
386+
387+
# Perform data integrity checks if requested
388+
if check_data:
389+
click.echo("\nPerforming data integrity checks...")
390+
verification_result = run_async(migrator.verify_data_integrity(verbose=verbose))
391+
392+
if verification_result["success"]:
393+
click.echo("✅ Data integrity checks passed.")
394+
else:
395+
click.echo("❌ Data integrity issues found:")
396+
for issue in verification_result.get("issues", []):
397+
click.echo(f" - {issue}")
398+
399+
if verbose:
400+
click.echo(f"\nDetailed status: {status_info}")
401+
402+
403+
@migrate_data.command()
404+
@click.option(
405+
"--migrations-dir",
406+
help="Directory containing migration files (default: <root>/data-migrations)",
407+
)
408+
@click.option("--module", help="Python module containing migrations")
409+
@click.option("--verbose", "-v", is_flag=True, help="Enable verbose output")
410+
@handle_redis_errors
411+
def stats(migrations_dir: str, module: str, verbose: bool):
412+
"""Show migration statistics and data analysis."""
413+
import os
414+
415+
from ...settings import get_root_migrations_dir
416+
417+
resolved_dir = migrations_dir or os.path.join(
418+
get_root_migrations_dir(), "data-migrations"
419+
)
420+
migrator = DataMigrator(
421+
migrations_dir=resolved_dir if not module else None,
422+
migration_module=module,
423+
)
424+
425+
click.echo("Analyzing migration requirements...")
426+
stats_info = run_async(migrator.get_migration_statistics())
427+
428+
if "error" in stats_info:
429+
click.echo(f"❌ Error: {stats_info['error']}")
430+
return
431+
432+
click.echo("\nMigration Statistics:")
433+
click.echo(f" Total models in registry: {stats_info['total_models']}")
434+
click.echo(
435+
f" Models with datetime fields: {stats_info['models_with_datetime_fields']}"
436+
)
437+
click.echo(f" Total datetime fields: {stats_info['total_datetime_fields']}")
438+
click.echo(
439+
f" Estimated keys to migrate: {stats_info['estimated_keys_to_migrate']}"
440+
)
441+
442+
if stats_info["model_details"]:
443+
click.echo("\nModel Details:")
444+
for model_detail in stats_info["model_details"]:
445+
click.echo(
446+
f"\n 📊 {model_detail['model_name']} ({model_detail['model_type']})"
447+
)
448+
click.echo(
449+
f" Datetime fields: {', '.join(model_detail['datetime_fields'])}"
450+
)
451+
click.echo(f" Keys to migrate: {model_detail['key_count']}")
452+
453+
if model_detail["key_count"] > 10000:
454+
click.echo(" ⚠️ Large dataset - consider batch processing")
455+
elif model_detail["key_count"] > 1000:
456+
click.echo(" ℹ️ Medium dataset - monitor progress")
457+
458+
# Estimate migration time
459+
total_keys = stats_info["estimated_keys_to_migrate"]
460+
if total_keys > 0:
461+
# Rough estimates based on typical performance
462+
estimated_seconds = total_keys / 1000 # Assume ~1000 keys/second
463+
if estimated_seconds < 60:
464+
time_estimate = f"{estimated_seconds:.1f} seconds"
465+
elif estimated_seconds < 3600:
466+
time_estimate = f"{estimated_seconds / 60:.1f} minutes"
467+
else:
468+
time_estimate = f"{estimated_seconds / 3600:.1f} hours"
469+
470+
click.echo(f"\nEstimated migration time: {time_estimate}")
471+
click.echo(
472+
"(Actual time may vary based on data complexity and system performance)"
473+
)
474+
475+
if verbose:
476+
click.echo(f"\nRaw statistics: {stats_info}")
477+
478+
479+
@migrate_data.command()
480+
@click.option(
481+
"--migrations-dir",
482+
help="Directory containing migration files (default: <root>/data-migrations)",
483+
)
484+
@click.option("--module", help="Python module containing migrations")
485+
@click.option("--verbose", "-v", is_flag=True, help="Enable verbose output")
486+
@handle_redis_errors
487+
def progress(migrations_dir: str, module: str, verbose: bool):
488+
"""Show progress of any running or interrupted migrations."""
489+
import os
490+
491+
from ...settings import get_root_migrations_dir
492+
from ..migrations.datetime_migration import MigrationState
493+
494+
resolved_dir = migrations_dir or os.path.join(
495+
get_root_migrations_dir(), "data-migrations"
496+
)
497+
migrator = DataMigrator(
498+
migrations_dir=resolved_dir if not module else None,
499+
migration_module=module,
500+
)
501+
502+
# Check for saved progress
503+
click.echo("Checking for migration progress...")
504+
505+
# Check the built-in datetime migration
506+
datetime_migration_id = "001_datetime_fields_to_timestamps"
507+
state = MigrationState(migrator.redis, datetime_migration_id)
508+
509+
has_progress = run_async(state.has_saved_progress())
510+
511+
if has_progress:
512+
progress_data = run_async(state.load_progress())
513+
514+
click.echo(f"\n📊 Found saved progress for migration: {datetime_migration_id}")
515+
click.echo(f" Timestamp: {progress_data.get('timestamp', 'Unknown')}")
516+
click.echo(f" Current model: {progress_data.get('current_model', 'Unknown')}")
517+
click.echo(f" Processed keys: {len(progress_data.get('processed_keys', []))}")
518+
click.echo(f" Total keys: {progress_data.get('total_keys', 'Unknown')}")
519+
520+
if progress_data.get("stats"):
521+
stats = progress_data["stats"]
522+
click.echo(f" Converted fields: {stats.get('converted_fields', 0)}")
523+
click.echo(f" Failed conversions: {stats.get('failed_conversions', 0)}")
524+
click.echo(f" Success rate: {stats.get('success_rate', 0):.1f}%")
525+
526+
click.echo("\nTo resume the migration, run: om migrate-data run")
527+
click.echo("To clear saved progress, run: om migrate-data clear-progress")
528+
529+
else:
530+
click.echo("✅ No saved migration progress found.")
531+
532+
if verbose:
533+
click.echo(f"\nChecked migration: {datetime_migration_id}")
534+
535+
536+
@migrate_data.command()
537+
@click.option(
538+
"--migrations-dir",
539+
help="Directory containing migration files (default: <root>/data-migrations)",
540+
)
541+
@click.option("--module", help="Python module containing migrations")
542+
@click.option("--yes", "-y", is_flag=True, help="Skip confirmation prompt")
543+
@handle_redis_errors
544+
def clear_progress(migrations_dir: str, module: str, yes: bool):
545+
"""Clear saved migration progress."""
546+
import os
547+
548+
from ...settings import get_root_migrations_dir
549+
from ..migrations.datetime_migration import MigrationState
550+
551+
resolved_dir = migrations_dir or os.path.join(
552+
get_root_migrations_dir(), "data-migrations"
553+
)
554+
migrator = DataMigrator(
555+
migrations_dir=resolved_dir if not module else None,
556+
migration_module=module,
557+
)
558+
559+
# Clear progress for datetime migration
560+
datetime_migration_id = "001_datetime_fields_to_timestamps"
561+
state = MigrationState(migrator.redis, datetime_migration_id)
562+
563+
has_progress = run_async(state.has_saved_progress())
564+
565+
if not has_progress:
566+
click.echo("No saved migration progress found.")
567+
return
568+
569+
if not yes:
570+
if not click.confirm("Clear saved migration progress? This cannot be undone."):
571+
click.echo("Aborted.")
572+
return
573+
574+
run_async(state.clear_progress())
575+
click.echo("✅ Saved migration progress cleared.")
576+
577+
282578
if __name__ == "__main__":
283579
migrate_data()

0 commit comments

Comments
 (0)