mirror of
https://github.com/Routstr/routstr-core.git
synced 2026-08-09 02:54:37 +00:00
647 lines
14 KiB
Markdown
647 lines
14 KiB
Markdown
# Database Migrations
|
|
|
|
This guide covers database schema management using Alembic migrations in Routstr Core.
|
|
|
|
## Overview
|
|
|
|
Routstr uses Alembic for database migrations with these features:
|
|
|
|
- **Automatic migrations** on startup
|
|
- **Version control** for schema changes
|
|
- **Rollback capability** for safety
|
|
- **Support for multiple databases** (SQLite, PostgreSQL)
|
|
|
|
## Automatic Migrations
|
|
|
|
### Startup Behavior
|
|
|
|
Migrations run automatically when Routstr starts:
|
|
|
|
```python
|
|
# In routstr/core/main.py
|
|
@asynccontextmanager
|
|
async def lifespan(_: FastAPI) -> AsyncGenerator[None, None]:
|
|
logger.info("Running database migrations")
|
|
run_migrations() # Automatic migration
|
|
await init_db() # Initialize connection pool
|
|
# ... rest of startup
|
|
```
|
|
|
|
This ensures:
|
|
|
|
- ✅ Database is always up-to-date
|
|
- ✅ No manual migration steps in production
|
|
- ✅ Zero-downtime deployments
|
|
- ✅ Backwards compatibility
|
|
|
|
### Migration Safety
|
|
|
|
Migrations are designed to be safe:
|
|
|
|
- Idempotent (can run multiple times)
|
|
- Non-destructive by default
|
|
- Tested before release
|
|
- Reversible when possible
|
|
|
|
## Creating Migrations
|
|
|
|
### Auto-generating from Models
|
|
|
|
After modifying SQLModel classes:
|
|
|
|
```bash
|
|
# Generate migration from model changes
|
|
make db-migrate
|
|
|
|
# You'll be prompted for a description
|
|
Enter migration message: Add user preferences table
|
|
|
|
# Review generated file
|
|
cat migrations/versions/xxxx_add_user_preferences_table.py
|
|
```
|
|
|
|
### Manual Migrations
|
|
|
|
For complex changes, create manually:
|
|
|
|
```bash
|
|
# Create empty migration
|
|
alembic revision -m "Complex data transformation"
|
|
|
|
# Edit the generated file
|
|
vim migrations/versions/xxxx_complex_data_transformation.py
|
|
```
|
|
|
|
### Migration Template
|
|
|
|
```python
|
|
"""Add user preferences table
|
|
|
|
Revision ID: a1b2c3d4e5f6
|
|
Revises: f6e5d4c3b2a1
|
|
Create Date: 2024-01-15 10:30:00.123456
|
|
|
|
"""
|
|
from alembic import op
|
|
import sqlalchemy as sa
|
|
import sqlmodel
|
|
|
|
# revision identifiers
|
|
revision = 'a1b2c3d4e5f6'
|
|
down_revision = 'f6e5d4c3b2a1'
|
|
branch_labels = None
|
|
depends_on = None
|
|
|
|
def upgrade() -> None:
|
|
"""Apply migration."""
|
|
# Create new table
|
|
op.create_table(
|
|
'userpreferences',
|
|
sa.Column('id', sa.Integer(), nullable=False),
|
|
sa.Column('api_key_id', sa.Integer(), nullable=False),
|
|
sa.Column('theme', sa.String(), nullable=True),
|
|
sa.Column('notifications_enabled', sa.Boolean(), default=True),
|
|
sa.Column('created_at', sa.DateTime(), nullable=False),
|
|
sa.PrimaryKeyConstraint('id'),
|
|
sa.ForeignKeyConstraint(['api_key_id'], ['apikey.id'], )
|
|
)
|
|
|
|
# Create index
|
|
op.create_index(
|
|
'ix_userpreferences_api_key_id',
|
|
'userpreferences',
|
|
['api_key_id']
|
|
)
|
|
|
|
def downgrade() -> None:
|
|
"""Revert migration."""
|
|
op.drop_index('ix_userpreferences_api_key_id', table_name='userpreferences')
|
|
op.drop_table('userpreferences')
|
|
```
|
|
|
|
## Common Migration Patterns
|
|
|
|
### Adding Columns
|
|
|
|
Add column with default value:
|
|
|
|
```python
|
|
def upgrade():
|
|
# Add nullable column first
|
|
op.add_column(
|
|
'apikey',
|
|
sa.Column('last_rotation', sa.DateTime(), nullable=True)
|
|
)
|
|
|
|
# Populate existing rows
|
|
connection = op.get_bind()
|
|
connection.execute(
|
|
"UPDATE apikey SET last_rotation = created_at WHERE last_rotation IS NULL"
|
|
)
|
|
|
|
# Make non-nullable if needed
|
|
op.alter_column('apikey', 'last_rotation', nullable=False)
|
|
```
|
|
|
|
### Renaming Columns
|
|
|
|
Safe column rename:
|
|
|
|
```python
|
|
def upgrade():
|
|
# SQLite doesn't support ALTER COLUMN, so we need a workaround
|
|
with op.batch_alter_table('apikey') as batch_op:
|
|
batch_op.alter_column('old_name', new_column_name='new_name')
|
|
```
|
|
|
|
### Adding Indexes
|
|
|
|
Performance-improving indexes:
|
|
|
|
```python
|
|
def upgrade():
|
|
# Single column index
|
|
op.create_index(
|
|
'ix_transaction_timestamp',
|
|
'transaction',
|
|
['timestamp']
|
|
)
|
|
|
|
# Composite index
|
|
op.create_index(
|
|
'ix_transaction_key_time',
|
|
'transaction',
|
|
['api_key_id', 'timestamp']
|
|
)
|
|
|
|
# Partial index (PostgreSQL only)
|
|
op.create_index(
|
|
'ix_apikey_active',
|
|
'apikey',
|
|
['balance'],
|
|
postgresql_where='balance > 0'
|
|
)
|
|
```
|
|
|
|
### Data Migrations
|
|
|
|
Transform existing data:
|
|
|
|
```python
|
|
def upgrade():
|
|
# Add new column
|
|
op.add_column(
|
|
'apikey',
|
|
sa.Column('key_type', sa.String(), nullable=True)
|
|
)
|
|
|
|
# Migrate data
|
|
connection = op.get_bind()
|
|
result = connection.execute('SELECT id, metadata FROM apikey')
|
|
|
|
for row in result:
|
|
key_type = 'premium' if row.metadata.get('premium') else 'standard'
|
|
connection.execute(
|
|
f"UPDATE apikey SET key_type = '{key_type}' WHERE id = {row.id}"
|
|
)
|
|
|
|
# Make column non-nullable
|
|
op.alter_column('apikey', 'key_type', nullable=False)
|
|
```
|
|
|
|
### Enum Types
|
|
|
|
Add enum column:
|
|
|
|
```python
|
|
from enum import Enum
|
|
|
|
class KeyStatus(str, Enum):
|
|
ACTIVE = "active"
|
|
SUSPENDED = "suspended"
|
|
EXPIRED = "expired"
|
|
|
|
def upgrade():
|
|
# Create enum type (PostgreSQL)
|
|
key_status_enum = sa.Enum(KeyStatus, name='keystatus')
|
|
key_status_enum.create(op.get_bind(), checkfirst=True)
|
|
|
|
# Add column
|
|
op.add_column(
|
|
'apikey',
|
|
sa.Column(
|
|
'status',
|
|
key_status_enum,
|
|
nullable=False,
|
|
server_default='active'
|
|
)
|
|
)
|
|
```
|
|
|
|
## Database-Specific Considerations
|
|
|
|
### SQLite Limitations
|
|
|
|
SQLite has limitations requiring workarounds:
|
|
|
|
```python
|
|
def upgrade():
|
|
# SQLite doesn't support ALTER COLUMN directly
|
|
# Use batch_alter_table for compatibility
|
|
with op.batch_alter_table('apikey') as batch_op:
|
|
batch_op.alter_column(
|
|
'balance',
|
|
type_=sa.BigInteger(), # Change from Integer
|
|
existing_type=sa.Integer()
|
|
)
|
|
```
|
|
|
|
### PostgreSQL Features
|
|
|
|
Leverage PostgreSQL-specific features:
|
|
|
|
```python
|
|
def upgrade():
|
|
# Use JSONB for better performance
|
|
op.add_column(
|
|
'apikey',
|
|
sa.Column('metadata', sa.JSON().with_variant(
|
|
sa.dialects.postgresql.JSONB(), 'postgresql'
|
|
))
|
|
)
|
|
|
|
# Add GIN index for JSONB queries
|
|
op.create_index(
|
|
'ix_apikey_metadata',
|
|
'apikey',
|
|
['metadata'],
|
|
postgresql_using='gin'
|
|
)
|
|
|
|
# Add check constraint
|
|
op.create_check_constraint(
|
|
'ck_apikey_balance_positive',
|
|
'apikey',
|
|
'balance >= 0'
|
|
)
|
|
```
|
|
|
|
## Migration Commands
|
|
|
|
### Running Migrations
|
|
|
|
```bash
|
|
# Apply all pending migrations
|
|
make db-upgrade
|
|
|
|
# Upgrade to specific revision
|
|
alembic upgrade a1b2c3d4e5f6
|
|
|
|
# Upgrade one revision
|
|
alembic upgrade +1
|
|
```
|
|
|
|
### Checking Status
|
|
|
|
```bash
|
|
# Show current revision
|
|
make db-current
|
|
# Output: a1b2c3d4e5f6 (head)
|
|
|
|
# Show migration history
|
|
make db-history
|
|
# Output:
|
|
# a1b2c3d4e5f6 -> b2c3d4e5f6a7 (head), Add user preferences
|
|
# f6e5d4c3b2a1 -> a1b2c3d4e5f6, Add indexes
|
|
# e5d4c3b2a1f6 -> f6e5d4c3b2a1, Initial schema
|
|
```
|
|
|
|
### Rolling Back
|
|
|
|
```bash
|
|
# Rollback one migration
|
|
make db-downgrade
|
|
|
|
# Rollback to specific revision
|
|
alembic downgrade f6e5d4c3b2a1
|
|
|
|
# Rollback all (dangerous!)
|
|
alembic downgrade base
|
|
```
|
|
|
|
## Testing Migrations
|
|
|
|
### Unit Testing
|
|
|
|
Test migrations in isolation:
|
|
|
|
```python
|
|
import pytest
|
|
from alembic import command
|
|
from alembic.config import Config
|
|
from sqlalchemy import create_engine, inspect
|
|
|
|
def test_migration_add_user_preferences():
|
|
"""Test user preferences migration."""
|
|
# Create test database
|
|
engine = create_engine("sqlite:///:memory:")
|
|
|
|
# Run migrations up to previous version
|
|
alembic_cfg = Config("alembic.ini")
|
|
alembic_cfg.set_main_option("sqlalchemy.url", str(engine.url))
|
|
command.upgrade(alembic_cfg, "f6e5d4c3b2a1")
|
|
|
|
# Verify state before migration
|
|
inspector = inspect(engine)
|
|
tables = inspector.get_table_names()
|
|
assert "userpreferences" not in tables
|
|
|
|
# Run target migration
|
|
command.upgrade(alembic_cfg, "a1b2c3d4e5f6")
|
|
|
|
# Verify state after migration
|
|
inspector = inspect(engine)
|
|
tables = inspector.get_table_names()
|
|
assert "userpreferences" in tables
|
|
|
|
# Check columns
|
|
columns = {col['name'] for col in inspector.get_columns('userpreferences')}
|
|
assert columns == {'id', 'api_key_id', 'theme', 'notifications_enabled', 'created_at'}
|
|
|
|
# Test downgrade
|
|
command.downgrade(alembic_cfg, "f6e5d4c3b2a1")
|
|
inspector = inspect(engine)
|
|
tables = inspector.get_table_names()
|
|
assert "userpreferences" not in tables
|
|
```
|
|
|
|
### Integration Testing
|
|
|
|
Test with real data:
|
|
|
|
```python
|
|
async def test_migration_with_data():
|
|
"""Test migration preserves existing data."""
|
|
# Setup test database with data
|
|
async with test_engine.begin() as conn:
|
|
# Insert test data
|
|
await conn.execute(
|
|
"INSERT INTO apikey (key_hash, balance) VALUES ('test', 1000)"
|
|
)
|
|
|
|
# Run migration
|
|
run_migrations()
|
|
|
|
# Verify data integrity
|
|
async with test_engine.connect() as conn:
|
|
result = await conn.execute("SELECT * FROM apikey WHERE key_hash = 'test'")
|
|
row = result.first()
|
|
assert row.balance == 1000
|
|
assert row.key_type == 'standard' # New column with default
|
|
```
|
|
|
|
## Production Deployment
|
|
|
|
### Zero-Downtime Migrations
|
|
|
|
Strategy for seamless updates:
|
|
|
|
1. **Make migrations backwards compatible**
|
|
|
|
```python
|
|
# Good: Add nullable column
|
|
op.add_column('apikey', sa.Column('new_field', sa.String(), nullable=True))
|
|
|
|
# Bad: Drop column immediately
|
|
# op.drop_column('apikey', 'old_field')
|
|
```
|
|
|
|
2. **Deploy in phases**
|
|
|
|
```bash
|
|
# Phase 1: Deploy code that works with both schemas
|
|
# Phase 2: Run migration
|
|
# Phase 3: Deploy code that requires new schema
|
|
# Phase 4: Clean up deprecated columns
|
|
```
|
|
|
|
3. **Use feature flags**
|
|
|
|
```python
|
|
if feature_enabled('use_new_schema'):
|
|
# Use new column
|
|
query = select(APIKey.new_field)
|
|
else:
|
|
# Use old column
|
|
query = select(APIKey.old_field)
|
|
```
|
|
|
|
### Migration Monitoring
|
|
|
|
Track migration execution:
|
|
|
|
```python
|
|
# Add to migration
|
|
def upgrade():
|
|
start_time = time.time()
|
|
logger.info(f"Starting migration {revision}")
|
|
|
|
try:
|
|
# Migration logic here
|
|
op.create_table(...)
|
|
|
|
duration = time.time() - start_time
|
|
logger.info(f"Migration {revision} completed in {duration:.2f}s")
|
|
except Exception as e:
|
|
logger.error(f"Migration {revision} failed: {e}")
|
|
raise
|
|
```
|
|
|
|
### Backup Before Migration
|
|
|
|
Always backup before major changes:
|
|
|
|
```bash
|
|
#!/bin/bash
|
|
# backup_before_migration.sh
|
|
|
|
# Backup database
|
|
if [[ "$DATABASE_URL" == *"sqlite"* ]]; then
|
|
cp database.db "backup_$(date +%Y%m%d_%H%M%S).db"
|
|
else
|
|
pg_dump $DATABASE_URL > "backup_$(date +%Y%m%d_%H%M%S).sql"
|
|
fi
|
|
|
|
# Run migration
|
|
alembic upgrade head
|
|
|
|
# Verify
|
|
alembic current
|
|
```
|
|
|
|
## Troubleshooting
|
|
|
|
### Common Issues
|
|
|
|
**Migration Conflicts**
|
|
|
|
```bash
|
|
# Multiple heads detected
|
|
alembic heads
|
|
# a1b2c3d4e5f6 (head)
|
|
# b2c3d4e5f6a7 (head)
|
|
|
|
# Merge heads
|
|
alembic merge -m "Merge migrations" a1b2c3 b2c3d4
|
|
```
|
|
|
|
**Failed Migration**
|
|
|
|
```python
|
|
# Add rollback logic
|
|
def upgrade():
|
|
try:
|
|
op.create_table(...)
|
|
except Exception as e:
|
|
# Clean up partial changes
|
|
op.drop_table('partial_table', checkfirst=True)
|
|
raise
|
|
|
|
def downgrade():
|
|
# Ensure clean rollback
|
|
op.drop_table('new_table', checkfirst=True)
|
|
```
|
|
|
|
**Lock Timeout**
|
|
|
|
```python
|
|
# Add timeout handling
|
|
def upgrade():
|
|
connection = op.get_bind()
|
|
|
|
# Set timeout (PostgreSQL)
|
|
connection.execute("SET lock_timeout = '10s'")
|
|
|
|
try:
|
|
op.add_column(...)
|
|
except OperationalError as e:
|
|
if 'lock timeout' in str(e):
|
|
logger.error("Migration failed due to lock timeout")
|
|
raise
|
|
```
|
|
|
|
### Recovery Procedures
|
|
|
|
If migration fails in production:
|
|
|
|
1. **Check current state**
|
|
|
|
```bash
|
|
alembic current
|
|
alembic history
|
|
```
|
|
|
|
2. **Manual rollback if needed**
|
|
|
|
```sql
|
|
-- Check migration table
|
|
SELECT * FROM alembic_version;
|
|
|
|
-- Force version if necessary
|
|
UPDATE alembic_version SET version_num = 'previous_version';
|
|
```
|
|
|
|
3. **Fix and retry**
|
|
|
|
```bash
|
|
# Fix migration file
|
|
vim migrations/versions/problematic_migration.py
|
|
|
|
# Retry
|
|
alembic upgrade head
|
|
```
|
|
|
|
## Best Practices
|
|
|
|
### Migration Guidelines
|
|
|
|
1. **Keep migrations small and focused**
|
|
- One logical change per migration
|
|
- Easier to review and rollback
|
|
|
|
2. **Test migrations thoroughly**
|
|
- Test upgrade and downgrade
|
|
- Test with production-like data
|
|
- Test database-specific features
|
|
|
|
3. **Document breaking changes**
|
|
|
|
```python
|
|
"""BREAKING: Change balance column type
|
|
|
|
This migration requires application update.
|
|
Deploy order:
|
|
1. Update application to handle both int and bigint
|
|
2. Run this migration
|
|
3. Update application to use only bigint
|
|
"""
|
|
```
|
|
|
|
4. **Make migrations idempotent**
|
|
|
|
```python
|
|
def upgrade():
|
|
# Check if column exists
|
|
inspector = inspect(op.get_bind())
|
|
columns = [col['name'] for col in inspector.get_columns('apikey')]
|
|
|
|
if 'new_column' not in columns:
|
|
op.add_column(
|
|
'apikey',
|
|
sa.Column('new_column', sa.String())
|
|
)
|
|
```
|
|
|
|
### Performance Considerations
|
|
|
|
1. **Add indexes concurrently (PostgreSQL)**
|
|
|
|
```python
|
|
def upgrade():
|
|
# Create index without locking table
|
|
op.create_index(
|
|
'ix_large_table_column',
|
|
'large_table',
|
|
['column'],
|
|
postgresql_concurrently=True
|
|
)
|
|
```
|
|
|
|
2. **Batch large updates**
|
|
|
|
```python
|
|
def upgrade():
|
|
connection = op.get_bind()
|
|
|
|
# Process in batches
|
|
batch_size = 1000
|
|
offset = 0
|
|
|
|
while True:
|
|
result = connection.execute(
|
|
f"UPDATE apikey SET processed = true "
|
|
f"WHERE id IN (SELECT id FROM apikey WHERE processed = false LIMIT {batch_size})"
|
|
)
|
|
|
|
if result.rowcount == 0:
|
|
break
|
|
|
|
offset += batch_size
|
|
time.sleep(0.1) # Prevent overload
|
|
```
|
|
|
|
## Next Steps
|
|
|
|
- [Testing Guide](../contributing/testing.md) - Testing migrations
|
|
- [Deployment](../getting-started/docker.md) - Production deployment
|