Ryan Malloy 5ca1b7a07d Migrate to Procrastinate 3.x with backward compatibility for 2.x
- Add comprehensive compatibility layer supporting both Procrastinate 2.x and 3.x
- Implement version-aware database migration system with pre/post migrations for 3.x
- Create worker option mapping for seamless transition between versions
- Add extensive test coverage for all compatibility features
- Update dependency constraints to support both 2.x and 3.x simultaneously
- Provide Docker containerization with uv caching and multi-service orchestration
- Include demo applications and web interface for testing capabilities
- Bump version to 0.2.0 reflecting new compatibility features

Key Features:
- Automatic version detection and feature flagging
- Unified connector creation across PostgreSQL drivers
- Worker option translation (timeout → fetch_job_polling_interval)
- Database migration utilities with CLI and programmatic interfaces
- Complete Docker Compose setup with PostgreSQL, Redis, workers, and demos

Files Added:
- src/video_processor/tasks/compat.py - Core compatibility layer
- src/video_processor/tasks/migration.py - Migration utilities
- src/video_processor/tasks/worker_compatibility.py - Worker CLI
- tests/test_procrastinate_compat.py - Compatibility tests
- tests/test_procrastinate_migration.py - Migration tests
- Dockerfile - Multi-stage build with uv caching
- docker-compose.yml - Complete development environment
- examples/docker_demo.py - Containerized demo application
- examples/web_demo.py - Flask web interface demo

Migration Support:
- Procrastinate 2.x: Single migration command compatibility
- Procrastinate 3.x: Separate pre/post migration phases
- Database URL validation and connection testing
- Version-specific feature detection and graceful degradation
2025-09-05 10:38:12 -06:00

254 lines
7.7 KiB
Python

"""
Procrastinate migration utilities for upgrading from 2.x to 3.x.
This module provides utilities to help with database migrations and
version compatibility during the upgrade process.
"""
import logging
import subprocess
import sys
from .compat import (
IS_PROCRASTINATE_3_PLUS,
get_migration_commands,
get_version_info,
)
logger = logging.getLogger(__name__)
class ProcrastinateMigrationHelper:
"""Helper class for managing Procrastinate migrations."""
def __init__(self, database_url: str):
self.database_url = database_url
self.version_info = get_version_info()
def get_migration_steps(self) -> list[str]:
"""Get the migration steps for the current version."""
commands = get_migration_commands()
if IS_PROCRASTINATE_3_PLUS:
return [
"1. Apply pre-migrations before deploying new code",
f" Command: {commands['pre_migrate']}",
"2. Deploy new application code",
"3. Apply post-migrations after deployment",
f" Command: {commands['post_migrate']}",
"4. Verify schema is current",
f" Command: {commands['check']}",
]
else:
return [
"1. Apply database migrations",
f" Command: {commands['migrate']}",
"2. Verify schema is current",
f" Command: {commands['check']}",
]
def print_migration_plan(self) -> None:
"""Print the migration plan for the current version."""
print(f"Procrastinate Migration Plan (v{self.version_info['procrastinate_version']})")
print("=" * 60)
for step in self.get_migration_steps():
print(step)
print("\nVersion Info:")
print(f" Current Version: {self.version_info['procrastinate_version']}")
print(f" Is 3.x+: {self.version_info['is_v3_plus']}")
print(f" Features Available: {list(self.version_info['features'].keys())}")
def run_migration_command(self, command: str) -> bool:
"""
Run a migration command.
Args:
command: The command to run
Returns:
True if successful, False otherwise
"""
try:
logger.info(f"Running migration command: {command}")
# Set environment variable for database URL
env = {"PROCRASTINATE_DATABASE_URL": self.database_url}
result = subprocess.run(
command.split(),
env={**dict(sys.environ), **env},
capture_output=True,
text=True,
check=True
)
if result.stdout:
logger.info(f"Migration output: {result.stdout}")
logger.info("Migration command completed successfully")
return True
except subprocess.CalledProcessError as e:
logger.error(f"Migration command failed: {e}")
if e.stdout:
logger.error(f"stdout: {e.stdout}")
if e.stderr:
logger.error(f"stderr: {e.stderr}")
return False
def apply_pre_migration(self) -> bool:
"""Apply pre-migration for Procrastinate 3.x."""
if not IS_PROCRASTINATE_3_PLUS:
logger.warning("Pre-migration only applicable to Procrastinate 3.x+")
return True
commands = get_migration_commands()
return self.run_migration_command(commands["pre_migrate"])
def apply_post_migration(self) -> bool:
"""Apply post-migration for Procrastinate 3.x."""
if not IS_PROCRASTINATE_3_PLUS:
logger.warning("Post-migration only applicable to Procrastinate 3.x+")
return True
commands = get_migration_commands()
return self.run_migration_command(commands["post_migrate"])
def apply_legacy_migration(self) -> bool:
"""Apply legacy migration for Procrastinate 2.x."""
if IS_PROCRASTINATE_3_PLUS:
logger.warning("Legacy migration only applicable to Procrastinate 2.x")
return True
commands = get_migration_commands()
return self.run_migration_command(commands["migrate"])
def check_schema(self) -> bool:
"""Check if the database schema is current."""
commands = get_migration_commands()
return self.run_migration_command(commands["check"])
async def migrate_database(
database_url: str,
pre_migration_only: bool = False,
post_migration_only: bool = False,
) -> bool:
"""
Migrate the Procrastinate database schema.
Args:
database_url: Database connection string
pre_migration_only: Only apply pre-migration (for 3.x)
post_migration_only: Only apply post-migration (for 3.x)
Returns:
True if successful, False otherwise
"""
helper = ProcrastinateMigrationHelper(database_url)
logger.info("Starting Procrastinate database migration")
helper.print_migration_plan()
try:
if IS_PROCRASTINATE_3_PLUS:
# Procrastinate 3.x migration process
if pre_migration_only:
success = helper.apply_pre_migration()
elif post_migration_only:
success = helper.apply_post_migration()
else:
# Apply both pre and post migrations
logger.warning(
"Applying both pre and post migrations. "
"In production, these should be run separately!"
)
success = (
helper.apply_pre_migration() and
helper.apply_post_migration()
)
else:
# Procrastinate 2.x migration process
success = helper.apply_legacy_migration()
if success:
# Verify schema is current
success = helper.check_schema()
if success:
logger.info("Database migration completed successfully")
else:
logger.error("Database migration failed")
return success
except Exception as e:
logger.error(f"Migration error: {e}")
return False
def create_migration_script() -> str:
"""Create a migration script for the current environment."""
version_info = get_version_info()
script = f"""#!/usr/bin/env python3
\"\"\"
Procrastinate migration script for version {version_info['procrastinate_version']}
This script helps migrate your Procrastinate database schema.
\"\"\"
import asyncio
import os
import sys
# Add the project root to Python path
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from video_processor.tasks.migration import migrate_database
async def main():
database_url = os.environ.get(
'PROCRASTINATE_DATABASE_URL',
'postgresql://localhost/procrastinate_dev'
)
print(f"Migrating database: {{database_url}}")
# Parse command line arguments
pre_only = '--pre' in sys.argv
post_only = '--post' in sys.argv
success = await migrate_database(
database_url=database_url,
pre_migration_only=pre_only,
post_migration_only=post_only,
)
if not success:
print("Migration failed!")
sys.exit(1)
print("Migration completed successfully!")
if __name__ == "__main__":
asyncio.run(main())
"""
return script
if __name__ == "__main__":
# Generate migration script when run directly
script_content = create_migration_script()
with open("migrate_procrastinate.py", "w") as f:
f.write(script_content)
print("Generated migration script: migrate_procrastinate.py")
print("Run with: python migrate_procrastinate.py [--pre|--post]")