Replace check-then-insert race condition with INSERT ON CONFLICT. - upsert_pending uses RETURNING id for atomic upsert - UNIQUE(source_id, content_hash) constraint from migration 6 - blocklist_import_workflow updated to use upsert_pending - test_import_source_success fixed for async mock patterns Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
152 lines
4.7 KiB
Markdown
152 lines
4.7 KiB
Markdown
# Database Migrations
|
|
|
|
BanGUI uses SQLite with a versioned migration system. Migrations are applied automatically on startup.
|
|
|
|
## Schema Version Table
|
|
|
|
The `schema_migrations` table tracks applied migrations:
|
|
|
|
```sql
|
|
CREATE TABLE IF NOT EXISTS schema_migrations (
|
|
version INTEGER PRIMARY KEY,
|
|
migrated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
|
|
);
|
|
```
|
|
|
|
## How Migrations Work
|
|
|
|
On startup (`init_db()`):
|
|
|
|
1. Current schema version is read from `schema_migrations`
|
|
2. If version < latest, each missing migration is applied in order
|
|
3. Each migration runs inside a `BEGIN IMMEDIATE ... COMMIT` transaction
|
|
4. On failure, `ROLLBACK` restores database to pre-migration state
|
|
|
|
## Transactional Guarantees
|
|
|
|
Every migration is **atomic**. If any statement fails:
|
|
|
|
- All DDL changes are rolled back
|
|
- `schema_migrations` table is NOT updated
|
|
- Next startup re-applies the same migration from scratch
|
|
|
|
```python
|
|
try:
|
|
await db.execute("BEGIN IMMEDIATE;")
|
|
for statement in statements:
|
|
await db.execute(statement)
|
|
await db.execute("INSERT INTO schema_migrations (version) VALUES (?);", (version,))
|
|
await db.commit()
|
|
except Exception:
|
|
await db.rollback()
|
|
raise
|
|
```
|
|
|
|
## Idempotency
|
|
|
|
Migrations use `CREATE TABLE IF NOT EXISTS` and `CREATE INDEX IF NOT EXISTS` where possible. Re-running a failed or partial migration is safe.
|
|
|
|
## WAL Mode and Crash Safety
|
|
|
|
BanGUI uses SQLite WAL mode (`PRAGMA journal_mode=WAL`). After a crash:
|
|
|
|
- SQLite auto-recovers using the WAL file
|
|
- `.wal` file may contain uncommitted changes that are rolled back
|
|
- Orphaned `.wal` files from previous crashes are detected and cleaned up on startup
|
|
|
|
### Detecting Orphaned WAL Files
|
|
|
|
On startup, if the database is in WAL mode but no WAL file exists:
|
|
|
|
```python
|
|
async def _cleanup_orphaned_wal_files(db: aiosqlite.Connection, db_path: Path) -> None:
|
|
"""Remove orphaned WAL files after crashes."""
|
|
wal_path = Path(str(db_path) + "-wal")
|
|
if wal_path.exists() and db_path.exists():
|
|
# Check if WAL file is stale (database was opened since)
|
|
pass # SQLite handles this automatically
|
|
```
|
|
|
|
## Migration Failure Recovery
|
|
|
|
If a migration fails mid-way:
|
|
|
|
1. **Startup fails** — application refuses to start
|
|
2. **Rollback occurs** — database returns to pre-migration state
|
|
3. **Logs show error** — exception with full traceback
|
|
|
|
### Manual Recovery Steps
|
|
|
|
1. **Check current schema version:**
|
|
```bash
|
|
sqlite3 bangui.db "SELECT MAX(version) FROM schema_migrations;"
|
|
```
|
|
|
|
2. **Check which tables exist:**
|
|
```bash
|
|
sqlite3 bangui.db "SELECT name FROM sqlite_master WHERE type='table';"
|
|
```
|
|
|
|
3. **Manually apply the failed migration:**
|
|
```bash
|
|
sqlite3 bangui.db "BEGIN IMMEDIATE;"
|
|
# Run your migration SQL here
|
|
sqlite3 bangui.db "INSERT INTO schema_migrations (version) VALUES (?);"
|
|
sqlite3 bangui.db "COMMIT;"
|
|
```
|
|
|
|
4. **Or roll back to a known state:**
|
|
```bash
|
|
sqlite3 bangui.db "DELETE FROM schema_migrations WHERE version > ?;"
|
|
```
|
|
|
|
### Complete Database Reset (Development Only)
|
|
|
|
If the database is unrecoverable:
|
|
|
|
```bash
|
|
rm bangui.db bangui.db-wal bangui.db-shm
|
|
# Restart application - schema will be recreated from migration 1
|
|
```
|
|
|
|
## Migration Version History
|
|
|
|
| Version | Description |
|
|
|---------|-------------|
|
|
| 1 | Initial schema (settings, sessions, blocklist_sources, import_log, geo_cache, history_archive) |
|
|
| 2 | Hash session tokens (DROP + recreate sessions) |
|
|
| 3 | Add last_seen to geo_cache |
|
|
| 4 | Add scheduler_lock table |
|
|
| 5 | Add indexes to history_archive |
|
|
| 6 | Add import_runs table for idempotent imports |
|
|
| 7 | Add indexes to import_log |
|
|
| 8 | Migrate import_log.timestamp TEXT→INTEGER UNIX |
|
|
| 9 | Change import_log.source_id FK to ON DELETE RESTRICT |
|
|
|
|
## Adding New Migrations
|
|
|
|
1. Increment `_CURRENT_SCHEMA_VERSION` in `backend/app/db.py`
|
|
2. Add migration script to `_MIGRATIONS` dict with new version key
|
|
3. Write migration as `CREATE IF NOT EXISTS` or `ALTER TABLE ADD COLUMN` to ensure idempotency
|
|
4. Test with `test_apply_migration_is_atomic_rollback` pattern
|
|
5. Update this document with migration description
|
|
|
|
## Long-Running Migrations
|
|
|
|
For migrations that modify large tables:
|
|
|
|
- Use `ALTER TABLE ADD COLUMN` (instant on SQLite)
|
|
- Avoid `CREATE INDEX CONCURRENTLY` (SQLite does not support this)
|
|
- For table rebuilds, split into phases with explicit progress tracking
|
|
|
|
## Disaster Recovery Checklist
|
|
|
|
If database is corrupted after migration failure:
|
|
|
|
- [ ] Stop all BanGUI instances
|
|
- [ ] Backup `bangui.db`, `bangui.db-wal`, `bangui.db-shm`
|
|
- [ ] Run `PRAGMA integrity_check;`
|
|
- [ ] Identify last successful migration version
|
|
- [ ] Delete `schema_migrations` rows for failed migrations
|
|
- [ ] Either: manually fix migration, or restore from backup
|
|
- [ ] Restart application |