Some checks failed
CI / Backend Tests (push) Has been cancelled
CI / Lint (push) Has been cancelled
CI / Type Check (push) Has been cancelled
CI / Import Boundary (push) Has been cancelled
CI / OpenAPI Breaking Changes (push) Has been cancelled
CI / OpenAPI Baseline Commit (push) Has been cancelled
- Add stop_grace_period to backend container for graceful shutdown - Document WAL mode rationale and orphaned file cleanup in db.py - Handle database close errors gracefully in lifespan - Clean up orphaned WAL files during startup before opening DB - Reorder imports and fix formatting in startup.py
561 lines
21 KiB
Python
561 lines
21 KiB
Python
"""Application database schema definition and initialisation.
|
|
|
|
BanGUI maintains its own SQLite database that stores configuration, session
|
|
state, blocklist source definitions, and import run logs. This module is
|
|
the single source of truth for the schema — all ``CREATE TABLE`` statements
|
|
live here and are applied on first run via :func:`init_db`.
|
|
|
|
The fail2ban database is separate and is accessed read-only by the history
|
|
and ban services.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from pathlib import Path
|
|
|
|
import aiosqlite
|
|
|
|
from app.utils.logging_compat import get_logger
|
|
|
|
log = get_logger(__name__)
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# DDL statements
|
|
# ---------------------------------------------------------------------------
|
|
|
|
_CREATE_SETTINGS: str = """
|
|
CREATE TABLE IF NOT EXISTS settings (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
key TEXT NOT NULL UNIQUE,
|
|
value TEXT NOT NULL,
|
|
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
|
|
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
|
|
);
|
|
"""
|
|
|
|
_CREATE_SESSIONS: str = """
|
|
CREATE TABLE IF NOT EXISTS sessions (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
token_hash TEXT NOT NULL UNIQUE,
|
|
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
|
|
expires_at TEXT NOT NULL
|
|
);
|
|
"""
|
|
|
|
_CREATE_SESSIONS_TOKEN_INDEX: str = """
|
|
CREATE UNIQUE INDEX IF NOT EXISTS idx_sessions_token_hash ON sessions (token_hash);
|
|
"""
|
|
|
|
_CREATE_BLOCKLIST_SOURCES: str = """
|
|
CREATE TABLE IF NOT EXISTS blocklist_sources (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
name TEXT NOT NULL,
|
|
url TEXT NOT NULL UNIQUE,
|
|
enabled INTEGER NOT NULL DEFAULT 1,
|
|
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
|
|
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
|
|
);
|
|
"""
|
|
|
|
_CREATE_IMPORT_LOG: str = """
|
|
CREATE TABLE IF NOT EXISTS import_log (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
source_id INTEGER REFERENCES blocklist_sources(id) ON DELETE RESTRICT,
|
|
source_url TEXT NOT NULL,
|
|
timestamp INTEGER NOT NULL,
|
|
ips_imported INTEGER NOT NULL DEFAULT 0,
|
|
ips_skipped INTEGER NOT NULL DEFAULT 0,
|
|
errors TEXT
|
|
);
|
|
"""
|
|
|
|
_CREATE_GEO_CACHE: str = """
|
|
CREATE TABLE IF NOT EXISTS geo_cache (
|
|
ip TEXT PRIMARY KEY,
|
|
country_code TEXT,
|
|
country_name TEXT,
|
|
asn TEXT,
|
|
org TEXT,
|
|
cached_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
|
|
);
|
|
"""
|
|
|
|
_CREATE_HISTORY_ARCHIVE: str = """
|
|
CREATE TABLE IF NOT EXISTS history_archive (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
jail TEXT NOT NULL,
|
|
ip TEXT NOT NULL,
|
|
timeofban INTEGER NOT NULL,
|
|
bancount INTEGER NOT NULL,
|
|
data TEXT NOT NULL,
|
|
action TEXT NOT NULL CHECK(action IN ('ban', 'unban')),
|
|
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
|
|
UNIQUE(ip, jail, action, timeofban)
|
|
);
|
|
"""
|
|
|
|
_CREATE_SCHEMA_MIGRATIONS: str = """
|
|
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'))
|
|
);
|
|
"""
|
|
|
|
# Ordered list of DDL statements to execute on initialisation.
|
|
# NOTE: _CREATE_SESSIONS_TOKEN_INDEX is intentionally omitted here.
|
|
# The old 0.8.0 schema has a `sessions.token` column (not `token_hash`), so
|
|
# running CREATE INDEX … ON sessions (token_hash) in migration 1 would fail
|
|
# with "no such column: token_hash" on legacy databases. Migration 2 drops
|
|
# and recreates the sessions table with token_hash and also creates the index,
|
|
# so there is no need to create it in migration 1.
|
|
_SCHEMA_STATEMENTS: list[str] = [
|
|
_CREATE_SETTINGS,
|
|
_CREATE_SESSIONS,
|
|
_CREATE_BLOCKLIST_SOURCES,
|
|
_CREATE_IMPORT_LOG,
|
|
_CREATE_GEO_CACHE,
|
|
_CREATE_HISTORY_ARCHIVE,
|
|
]
|
|
|
|
_CURRENT_SCHEMA_VERSION: int = 9
|
|
|
|
_MIGRATIONS: dict[int, str] = {
|
|
1: "\n".join(_SCHEMA_STATEMENTS),
|
|
2: """
|
|
-- Migration 2: Hash session tokens for security.
|
|
-- Drop the old sessions table and recreate with token_hash column.
|
|
-- This invalidates all existing sessions, which is acceptable as the DB
|
|
-- contents were exposed in plaintext.
|
|
DROP TABLE IF EXISTS sessions;
|
|
CREATE TABLE sessions (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
token_hash TEXT NOT NULL UNIQUE,
|
|
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
|
|
expires_at TEXT NOT NULL
|
|
);
|
|
CREATE UNIQUE INDEX idx_sessions_token_hash ON sessions (token_hash);
|
|
""",
|
|
3: """
|
|
-- Migration 3: Add last_seen timestamp to geo_cache for retention policy.
|
|
-- Tracks when each IP was last referenced to enable purging of stale entries.
|
|
-- SQLite rejects ALTER TABLE ADD COLUMN with a non-constant NOT NULL default
|
|
-- when the table already contains rows, so we rebuild the table instead.
|
|
-- Existing rows receive last_seen = cached_at as a reasonable approximation
|
|
-- (the IP was at least seen when it was first cached).
|
|
DROP TABLE IF EXISTS geo_cache_new;
|
|
CREATE TABLE geo_cache_new (
|
|
ip TEXT PRIMARY KEY,
|
|
country_code TEXT,
|
|
country_name TEXT,
|
|
asn TEXT,
|
|
org TEXT,
|
|
cached_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
|
|
last_seen TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
|
|
);
|
|
INSERT INTO geo_cache_new (ip, country_code, country_name, asn, org, cached_at, last_seen)
|
|
SELECT ip, country_code, country_name, asn, org, cached_at, cached_at FROM geo_cache;
|
|
DROP TABLE geo_cache;
|
|
ALTER TABLE geo_cache_new RENAME TO geo_cache;
|
|
""",
|
|
4: """
|
|
-- Migration 4: Add scheduler_lock table for multi-worker safety.
|
|
-- Implements database-backed locking to ensure only one worker runs the scheduler.
|
|
-- Uses atomic transactions to prevent race conditions in container orchestration.
|
|
-- Lock is held by the process that successfully inserts the singleton row (id=1).
|
|
CREATE TABLE scheduler_lock (
|
|
id INTEGER PRIMARY KEY CHECK (id = 1),
|
|
pid INTEGER NOT NULL,
|
|
hostname TEXT NOT NULL,
|
|
created_at REAL NOT NULL,
|
|
heartbeat_at REAL NOT NULL,
|
|
heartbeat_timeout REAL NOT NULL DEFAULT 300
|
|
);
|
|
""",
|
|
5: """
|
|
-- Migration 5: Add indexes to history_archive table for query performance.
|
|
-- The history_archive table supports filtering by jail, IP, action, and time range,
|
|
-- combined with pagination (ORDER BY timeofban DESC LIMIT/OFFSET).
|
|
-- These indexes accelerate common dashboard and API queries.
|
|
-- See Docs/Backend-Development.md § Database Performance for details.
|
|
|
|
-- Composite index for common queries: jail + timeofban ordering (dashboard filter).
|
|
CREATE INDEX IF NOT EXISTS idx_history_archive_jail_timeofban
|
|
ON history_archive (jail, timeofban DESC);
|
|
|
|
-- Composite index for time-range + jail queries (history timeline filters).
|
|
CREATE INDEX IF NOT EXISTS idx_history_archive_timeofban_jail_action
|
|
ON history_archive (timeofban DESC, jail, action);
|
|
|
|
-- Index for single-column filters: supports IP prefix searches and exact matches.
|
|
CREATE INDEX IF NOT EXISTS idx_history_archive_ip
|
|
ON history_archive (ip);
|
|
|
|
-- Index for action-based queries: supports ban/unban filtering.
|
|
CREATE INDEX IF NOT EXISTS idx_history_archive_action
|
|
ON history_archive (action);
|
|
""",
|
|
6: """
|
|
-- Migration 6: Add import_runs table for tracking blocklist import idempotency.
|
|
-- Tracks unique imports by source and content hash to enable idempotent retries.
|
|
-- On import crash, retry will detect the operation_id and skip duplicate bans.
|
|
-- This prevents duplicate IP bans if the scheduler retries after a failure.
|
|
CREATE TABLE IF NOT EXISTS import_runs (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
source_id INTEGER NOT NULL REFERENCES blocklist_sources(id) ON DELETE CASCADE,
|
|
content_hash TEXT NOT NULL,
|
|
status TEXT NOT NULL CHECK(status IN ('pending', 'completed', 'failed')),
|
|
imported_count INTEGER NOT NULL DEFAULT 0,
|
|
skipped_count INTEGER NOT NULL DEFAULT 0,
|
|
error_message TEXT,
|
|
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
|
|
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
|
|
UNIQUE(source_id, content_hash)
|
|
);
|
|
-- Index for looking up completed imports by source
|
|
CREATE INDEX IF NOT EXISTS idx_import_runs_source_status
|
|
ON import_runs (source_id, status);
|
|
""",
|
|
7: """
|
|
-- Migration 7: Add indexes to import_log table for cursor-based pagination.
|
|
-- The import_log table is paginated by id (newest first) and filtered by source_id.
|
|
-- These indexes accelerate pagination queries and maintain consistent ordering.
|
|
-- See Docs/Backend-Development.md § Database Performance for details.
|
|
|
|
-- Index for ordering by id DESC for cursor-based pagination (newest first)
|
|
CREATE INDEX IF NOT EXISTS idx_import_log_id_desc
|
|
ON import_log (id DESC);
|
|
|
|
-- Composite index for source_id + id DESC ordering (filtered pagination)
|
|
CREATE INDEX IF NOT EXISTS idx_import_log_source_id_desc
|
|
ON import_log (source_id, id DESC);
|
|
""",
|
|
8: """
|
|
-- Migration 8: Migrate import_log.timestamp from TEXT ISO 8601 to INTEGER UNIX epoch.
|
|
-- Standardizes all BanGUI timestamps on INTEGER UNIX (seconds since epoch).
|
|
-- This aligns import_log with history_archive which already uses INTEGER timeofban.
|
|
-- TEXT ISO 8601: "2024-06-15T13:45:00.000Z"
|
|
-- INTEGER UNIX: 1718453100
|
|
ALTER TABLE import_log ADD COLUMN timestamp_unix INTEGER;
|
|
UPDATE import_log SET timestamp_unix = strftime('%s', timestamp);
|
|
ALTER TABLE import_log DROP COLUMN timestamp;
|
|
ALTER TABLE import_log RENAME COLUMN timestamp_unix TO timestamp;
|
|
""",
|
|
9: """
|
|
-- Migration 9: Change import_log.source_id foreign key to ON DELETE RESTRICT.
|
|
-- Previously, deleting a blocklist source set source_id to NULL, leaving orphaned
|
|
-- log records with populated URL but NULL source_id (meaningless/useless data).
|
|
-- Now, RESTRICT prevents source deletion if import logs exist, preserving data
|
|
-- integrity. Admin must delete logs before deleting source.
|
|
-- See Issue #11: Foreign Key ON DELETE Semantics Problem.
|
|
DROP INDEX IF EXISTS idx_import_log_source_id_desc;
|
|
DROP TABLE IF EXISTS _import_log_backup;
|
|
CREATE TABLE _import_log_backup (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
source_id INTEGER REFERENCES blocklist_sources(id) ON DELETE RESTRICT,
|
|
source_url TEXT NOT NULL,
|
|
timestamp INTEGER NOT NULL,
|
|
ips_imported INTEGER NOT NULL DEFAULT 0,
|
|
ips_skipped INTEGER NOT NULL DEFAULT 0,
|
|
errors TEXT
|
|
);
|
|
INSERT INTO _import_log_backup (id, source_id, source_url, timestamp, ips_imported, ips_skipped, errors)
|
|
SELECT id, source_id, source_url, timestamp, ips_imported, ips_skipped, errors FROM import_log;
|
|
DROP TABLE import_log;
|
|
ALTER TABLE _import_log_backup RENAME TO import_log;
|
|
CREATE INDEX IF NOT EXISTS idx_import_log_source_id_desc
|
|
ON import_log (source_id, id DESC);
|
|
""",
|
|
}
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Public API
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
async def _configure_connection(db: aiosqlite.Connection) -> None:
|
|
"""Apply hardening pragmas to a newly-opened SQLite connection.
|
|
|
|
WAL mode is intentionally kept despite the risk of orphaned ``.wal``/``.shm``
|
|
files after unclean shutdowns. The benefits for concurrent readers
|
|
(readers do not block writers) outweigh the cleanup overhead, especially
|
|
under load. BanGUI runs as a single worker, but multiple concurrent HTTP
|
|
requests can still issue overlapping reads; DELETE mode would serialize
|
|
those reads behind any write, degrading API performance.
|
|
|
|
Orphaned files are handled by :func:`_cleanup_wal_files`, which is called
|
|
during startup before the database is opened.
|
|
"""
|
|
await db.execute("PRAGMA journal_mode=WAL;")
|
|
await db.execute("PRAGMA foreign_keys=ON;")
|
|
await db.execute("PRAGMA busy_timeout=5000;")
|
|
|
|
|
|
async def _cleanup_wal_files(db_path: str) -> None:
|
|
"""Remove orphaned WAL files after crashes.
|
|
|
|
When SQLite crashes in WAL mode, it may leave behind stale .wal and .shm
|
|
files that prevent the database from opening properly. This function removes
|
|
them if they exist and are not in use by any connection.
|
|
|
|
The actual recovery is done by SQLite automatically when opening the database.
|
|
This just cleans up orphaned files from previous crashes.
|
|
|
|
Args:
|
|
db_path: Path to the database file.
|
|
"""
|
|
import time
|
|
|
|
wal_path = Path(db_path + "-wal")
|
|
shm_path = Path(db_path + "-shm")
|
|
|
|
for path in (wal_path, shm_path):
|
|
if path.exists():
|
|
# Skip files that were modified recently — they likely belong to an
|
|
# active connection. Only remove stale files left by crashes.
|
|
mtime = path.stat().st_mtime
|
|
if time.time() - mtime < 10:
|
|
continue
|
|
try:
|
|
path.unlink()
|
|
log.warning("orphaned_sqlite_file_removed", path=str(path))
|
|
except OSError:
|
|
pass # File in use or permission denied
|
|
|
|
|
|
async def _get_current_schema_version(db: aiosqlite.Connection) -> int:
|
|
"""Return the highest applied schema version for the given database."""
|
|
await db.execute(_CREATE_SCHEMA_MIGRATIONS)
|
|
async with db.execute("SELECT MAX(version) FROM schema_migrations;") as cursor:
|
|
row = await cursor.fetchone()
|
|
if row is None or row[0] is None:
|
|
return 0
|
|
return int(row[0])
|
|
|
|
|
|
async def _parse_migration_statements(script: str) -> list[str]:
|
|
"""Parse a migration script into individual SQL statements.
|
|
|
|
Splits on semicolons but ignores semicolons inside string literals and
|
|
comments. Handles both block (-- comment) and line comments.
|
|
|
|
Args:
|
|
script: The raw migration script.
|
|
|
|
Returns:
|
|
List of SQL statements (stripped of whitespace and comments).
|
|
"""
|
|
statements: list[str] = []
|
|
current_stmt: list[str] = []
|
|
i = 0
|
|
|
|
while i < len(script):
|
|
char = script[i]
|
|
|
|
# Skip block comments (-- ...)
|
|
if i < len(script) - 1 and script[i : i + 2] == "--":
|
|
while i < len(script) and script[i] != "\n":
|
|
i += 1
|
|
i += 1
|
|
continue
|
|
|
|
# Skip line comments (/* ... */)
|
|
if i < len(script) - 1 and script[i : i + 2] == "/*":
|
|
i += 2
|
|
while i < len(script) - 1:
|
|
if script[i : i + 2] == "*/":
|
|
i += 2
|
|
break
|
|
i += 1
|
|
continue
|
|
|
|
# Handle string literals (single or double quotes)
|
|
if char in ("'", '"'):
|
|
quote = char
|
|
current_stmt.append(char)
|
|
i += 1
|
|
while i < len(script):
|
|
if script[i] == quote:
|
|
if i + 1 < len(script) and script[i + 1] == quote:
|
|
# Escaped quote
|
|
current_stmt.append(quote)
|
|
current_stmt.append(quote)
|
|
i += 2
|
|
else:
|
|
# End of string
|
|
current_stmt.append(quote)
|
|
i += 1
|
|
break
|
|
else:
|
|
current_stmt.append(script[i])
|
|
i += 1
|
|
continue
|
|
|
|
# Statement separator
|
|
if char == ";":
|
|
stmt = "".join(current_stmt).strip()
|
|
if stmt:
|
|
statements.append(stmt)
|
|
current_stmt = []
|
|
i += 1
|
|
continue
|
|
|
|
current_stmt.append(char)
|
|
i += 1
|
|
|
|
# Add any remaining statement
|
|
stmt = "".join(current_stmt).strip()
|
|
if stmt:
|
|
statements.append(stmt)
|
|
|
|
return statements
|
|
|
|
|
|
async def _apply_migration(db: aiosqlite.Connection, version: int) -> None:
|
|
"""Apply a single migration step and record its completion atomically.
|
|
|
|
Wraps all DDL statements and the schema_migrations insert in a single
|
|
BEGIN IMMEDIATE ... COMMIT transaction to ensure atomicity. If any
|
|
statement fails, the entire migration is rolled back.
|
|
|
|
Args:
|
|
db: An open aiosqlite.Connection.
|
|
version: The migration version number.
|
|
|
|
Raises:
|
|
Any exception from executing the migration statements or inserting
|
|
the schema migration record will cause a rollback.
|
|
"""
|
|
migration_script = _MIGRATIONS[version]
|
|
statements = await _parse_migration_statements(migration_script)
|
|
|
|
try:
|
|
await db.execute("BEGIN IMMEDIATE;")
|
|
|
|
for statement in statements:
|
|
try:
|
|
await db.execute(statement)
|
|
except aiosqlite.OperationalError as exc:
|
|
# Ignore duplicate column / table errors so migrations remain
|
|
# idempotent when a legacy database already has the object.
|
|
msg = str(exc).lower()
|
|
if "duplicate column name" in msg or "table" in msg and "already exists" in msg:
|
|
continue
|
|
raise
|
|
|
|
await db.execute("INSERT INTO schema_migrations (version) VALUES (?);", (version,))
|
|
|
|
await db.commit()
|
|
except Exception:
|
|
await db.rollback()
|
|
raise
|
|
|
|
|
|
async def _migrate_schema(db: aiosqlite.Connection) -> None:
|
|
"""Migrate the database schema to the latest supported version."""
|
|
current_version = await _get_current_schema_version(db)
|
|
if current_version == _CURRENT_SCHEMA_VERSION:
|
|
return
|
|
|
|
if current_version > _CURRENT_SCHEMA_VERSION:
|
|
raise RuntimeError(
|
|
f"database schema version {current_version} is newer than supported version {_CURRENT_SCHEMA_VERSION}"
|
|
)
|
|
|
|
log.info("migrating_database_schema", from_version=current_version, to_version=_CURRENT_SCHEMA_VERSION)
|
|
for next_version in range(current_version + 1, _CURRENT_SCHEMA_VERSION + 1):
|
|
await _apply_migration(db, next_version)
|
|
log.info("database_schema_ready", schema_version=_CURRENT_SCHEMA_VERSION)
|
|
|
|
|
|
async def init_db(db: aiosqlite.Connection) -> None:
|
|
"""Create or migrate the BanGUI application database schema.
|
|
|
|
This function is idempotent — calling it on an already-initialised
|
|
database has no effect. It should be called once during application
|
|
startup inside the FastAPI lifespan handler.
|
|
|
|
Args:
|
|
db: An open :class:`aiosqlite.Connection` to the application database.
|
|
"""
|
|
log.info("initialising_database_schema")
|
|
await _configure_connection(db)
|
|
await _migrate_schema(db)
|
|
|
|
|
|
async def open_db(database_path: str) -> aiosqlite.Connection:
|
|
"""Open a new application SQLite connection with the standard settings.
|
|
|
|
Creates the parent directory if it does not exist.
|
|
|
|
Args:
|
|
database_path: Path to the BanGUI SQLite database.
|
|
|
|
Returns:
|
|
A configured :class:`aiosqlite.Connection` instance.
|
|
|
|
Raises:
|
|
DatabasePathInvalidError: If the directory cannot be created or is inaccessible.
|
|
DatabasePermissionDeniedError: If aiosqlite.connect raises PermissionError.
|
|
DatabaseCorruptedError: If the database file is corrupted.
|
|
DatabaseUnavailableError: For any other unexpected error.
|
|
"""
|
|
from app.exceptions import (
|
|
DatabaseCorruptedError,
|
|
DatabasePathInvalidError,
|
|
DatabasePermissionDeniedError,
|
|
DatabaseUnavailableError,
|
|
)
|
|
|
|
db_dir = Path(database_path).parent
|
|
if not db_dir.exists():
|
|
try:
|
|
db_dir.mkdir(parents=True, exist_ok=True)
|
|
except PermissionError as exc:
|
|
log.error("database_open_failed", error=str(exc), database_path=database_path)
|
|
raise DatabasePathInvalidError(database_path) from exc
|
|
except OSError as exc:
|
|
log.error("database_open_failed", error=str(exc), database_path=database_path)
|
|
raise DatabaseUnavailableError(database_path, str(exc)) from exc
|
|
|
|
try:
|
|
db = await aiosqlite.connect(database_path)
|
|
except PermissionError as exc:
|
|
log.error("database_open_failed", error=str(exc), database_path=database_path)
|
|
raise DatabasePermissionDeniedError(database_path) from exc
|
|
except aiosqlite.OperationalError as exc:
|
|
error_msg = str(exc).lower()
|
|
sqlite_code = getattr(exc, "sqlite_errorcode", None)
|
|
log.error(
|
|
"database_open_failed",
|
|
error=str(exc),
|
|
sqlite_errorcode=sqlite_code,
|
|
database_path=database_path,
|
|
)
|
|
if "database is locked" in error_msg or "busy" in error_msg:
|
|
raise DatabaseUnavailableError(database_path, str(exc)) from exc
|
|
if "unable to open database file" in error_msg:
|
|
raise DatabasePathInvalidError(database_path) from exc
|
|
raise DatabaseUnavailableError(database_path, str(exc)) from exc
|
|
except aiosqlite.DatabaseError as exc:
|
|
log.error(
|
|
"database_open_failed",
|
|
error=str(exc),
|
|
database_path=database_path,
|
|
)
|
|
raise DatabaseCorruptedError(database_path) from exc
|
|
except OSError as exc:
|
|
log.error("database_open_failed", error=str(exc), database_path=database_path)
|
|
raise DatabaseUnavailableError(database_path, str(exc)) from exc
|
|
except Exception as exc:
|
|
log.error("database_open_failed", error=str(exc), database_path=database_path)
|
|
raise DatabaseUnavailableError(database_path, str(exc)) from exc
|
|
|
|
db.row_factory = aiosqlite.Row
|
|
try:
|
|
await _configure_connection(db)
|
|
except Exception:
|
|
await db.close()
|
|
raise
|
|
return db
|