cmboulanger's picture
disaster-recovery deploy of pdf-tei-editor
6a49f21 verified
Raw
History Blame Contribute Delete
20.6 kB
"""
File locking system for FastAPI.
Ported from server/lib/locking.py with FastAPI adaptations:
- Removed Flask dependencies (current_app, ApiError)
- Accept db_dir and logger as parameters
- Use stable_id-based file identification instead of paths
- Keep SQLite-based implementation (migrated schema)
"""
import sqlite3
import threading
from datetime import datetime, timezone, timedelta
from contextlib import contextmanager
from pathlib import Path
from typing import Dict, Optional, List
import logging
LOCK_TIMEOUT_SECONDS = 90
# Track if locks database has been initialized (to avoid redundant init calls)
_locks_db_initialized: set[str] = set()
_locks_db_init_lock = threading.Lock()
@contextmanager
def get_db_connection(db_dir: Path, logger: logging.Logger):
"""
Context manager for database connections with proper error handling.
Uses DELETE journal mode instead of WAL for the locks database because:
- It's a small database with infrequent writes
- Locks are short-lived and don't benefit from WAL's read concurrency
- DELETE mode avoids WAL file corruption under rapid concurrent access
Args:
db_dir: Directory containing locks.db
logger: Logger instance
Yields:
sqlite3.Connection: Database connection with row factory enabled
"""
db_path = db_dir / "locks.db"
conn = None
try:
conn = sqlite3.connect(
str(db_path),
timeout=30.0,
check_same_thread=False
)
conn.row_factory = sqlite3.Row
# Use DELETE journal mode (simpler, avoids WAL corruption issues)
conn.execute("PRAGMA journal_mode = DELETE")
# Set busy timeout to wait for locks
conn.execute("PRAGMA busy_timeout = 30000")
yield conn
conn.commit()
except sqlite3.Error as e:
logger.error(f"Database error: {e}")
raise RuntimeError(f"Database error: {e}")
finally:
if conn:
conn.close()
def init_locks_db(db_dir: Path, logger: logging.Logger, force: bool = False) -> None:
"""
Initialize the locks database with the required schema.
Creates tables and indexes, and runs any pending migrations.
This function tracks initialization state to avoid redundant calls
during concurrent request handling. The database is initialized once
at application startup via database_init.initialize_all_databases().
Uses DELETE journal mode instead of WAL to avoid corruption issues.
Args:
db_dir: Directory containing locks.db
logger: Logger instance
force: If True, force re-initialization (used at startup)
"""
db_path = db_dir / "locks.db"
db_key = str(db_path.resolve())
# Quick check without lock - skip if already initialized
if not force:
with _locks_db_init_lock:
if db_key in _locks_db_initialized:
return
# Acquire lock for actual initialization
with _locks_db_init_lock:
# Double-check after acquiring lock
if not force and db_key in _locks_db_initialized:
return
db_path.parent.mkdir(parents=True, exist_ok=True)
# Create database and schema if it doesn't exist
if not db_path.exists():
with get_db_connection(db_dir, logger) as conn:
conn.execute("""
CREATE TABLE IF NOT EXISTS locks (
file_hash TEXT PRIMARY KEY,
session_id TEXT NOT NULL,
acquired_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)
""")
# Run migrations using centralized runner
from fastapi_app.lib.core.migration_runner import run_migrations_if_needed
from fastapi_app.lib.core.migrations.versions import LOCKS_MIGRATIONS
try:
run_migrations_if_needed(
db_path=db_path,
migrations=LOCKS_MIGRATIONS,
logger=logger
)
except Exception as e:
logger.error(f"Failed to run migrations for locks.db: {e}")
raise
# Create/update indexes with current structure
try:
with get_db_connection(db_dir, logger) as conn:
# Ensure indexes exist with current schema
conn.execute("""
CREATE INDEX IF NOT EXISTS idx_file_id
ON locks(file_id)
""")
conn.execute("""
CREATE INDEX IF NOT EXISTS idx_session
ON locks(session_id)
""")
conn.execute("""
CREATE INDEX IF NOT EXISTS idx_updated
ON locks(updated_at)
""")
logger.debug(f"Locks database initialized at {db_path}")
except sqlite3.Error as e:
logger.error(f"Failed to create indexes: {e}")
raise RuntimeError(f"Database error: {e}")
# Mark as initialized
_locks_db_initialized.add(db_key)
def acquire_lock(file_id: str, session_id: str, db_dir: Path, logger: logging.Logger, max_retries: int = 3) -> bool:
"""
Tries to acquire a lock for a given file. Returns True on success, False on failure.
This operation is atomic and handles:
- Creating new locks
- Refreshing existing locks owned by the same session
- Taking over stale locks
Args:
file_id: The file's stable_id to lock
session_id: The session ID requesting the lock
db_dir: Directory containing locks.db
logger: Logger instance
max_retries: Maximum number of retry attempts for transient errors
Returns:
bool: True if lock was acquired/refreshed, False if held by another active session
Raises:
RuntimeError: If database operations fail after all retries
"""
import time
logger.debug(f"[LOCK] Session {session_id[:8]}... attempting to acquire lock for file {file_id[:8]}...")
# Ensure database is initialized
init_locks_db(db_dir, logger)
last_error = None
for attempt in range(max_retries):
try:
return _acquire_lock_impl(file_id, session_id, db_dir, logger)
except (sqlite3.OperationalError, RuntimeError) as e:
last_error = e
error_msg = str(e).lower()
# Retry on transient errors like disk I/O, busy, or locked
if any(err in error_msg for err in ['disk i/o', 'busy', 'locked', 'database is malformed']):
if attempt < max_retries - 1:
delay = 0.1 * (2 ** attempt) # Exponential backoff: 0.1, 0.2, 0.4 seconds
logger.warning(
f"[LOCK] Transient error on attempt {attempt + 1}/{max_retries}: {e}. "
f"Retrying in {delay:.1f}s..."
)
time.sleep(delay)
continue
# Non-transient error or max retries exceeded
raise
# Should not reach here, but handle it
logger.error(f"[LOCK] Failed to acquire lock after {max_retries} attempts: {last_error}")
raise RuntimeError(f"Database error after {max_retries} retries: {last_error}")
def _acquire_lock_impl(file_id: str, session_id: str, db_dir: Path, logger: logging.Logger) -> bool:
"""Internal implementation of acquire_lock without retry logic."""
with get_db_connection(db_dir, logger) as conn:
# Use IMMEDIATE transaction to get write lock upfront, preventing race conditions
# This ensures only one transaction can modify locks at a time
conn.isolation_level = None # Auto-commit off for explicit transaction control
conn.execute("BEGIN IMMEDIATE")
try:
cursor = conn.cursor()
# Calculate staleness threshold
stale_threshold = datetime.now(timezone.utc) - timedelta(seconds=LOCK_TIMEOUT_SECONDS)
# Check if there's an existing lock
cursor.execute("""
SELECT session_id, updated_at
FROM locks
WHERE file_id = ?
""", (file_id,))
existing = cursor.fetchone()
if existing:
existing_session = existing['session_id']
updated_at = datetime.fromisoformat(existing['updated_at'])
updated_at_utc = updated_at.replace(tzinfo=timezone.utc)
is_stale = updated_at_utc < stale_threshold
age_seconds = (datetime.now(timezone.utc) - updated_at_utc).total_seconds()
logger.debug(
f"[LOCK] Existing lock found: owner={existing_session[:8]}..., "
f"age={age_seconds:.1f}s, stale={is_stale}, threshold={LOCK_TIMEOUT_SECONDS}s"
)
if existing_session == session_id:
# It's our lock, refresh it
cursor.execute("""
UPDATE locks
SET updated_at = CURRENT_TIMESTAMP
WHERE file_id = ?
""", (file_id,))
conn.commit()
logger.debug(f"[LOCK] Session {session_id[:8]}... refreshed own lock for file {file_id[:8]}...")
return True
elif is_stale:
# Lock is stale, take it over
cursor.execute("""
UPDATE locks
SET session_id = ?, updated_at = CURRENT_TIMESTAMP
WHERE file_id = ?
""", (session_id, file_id))
conn.commit()
logger.warning(
f"[LOCK] Session {session_id[:8]}... took over stale lock for file {file_id[:8]}... "
f"from session {existing_session[:8]}... (was {age_seconds:.1f}s old)"
)
return True
else:
# Lock is held by another active session - DENY
conn.rollback()
logger.warning(
f"[LOCK] Session {session_id[:8]}... DENIED lock for file {file_id[:8]}.... "
f"Held by {existing_session[:8]}... (age={age_seconds:.1f}s, still fresh)"
)
return False
else:
# No local lock — check remote lock cache before granting
if _is_remotely_locked(file_id, db_dir, logger):
conn.rollback()
logger.warning(
f"[LOCK] Session {session_id[:8]}... DENIED lock for file {file_id[:8]}...: "
f"file is locked on a remote instance"
)
return False
# No lock at all, create new one
logger.debug(f"[LOCK] No existing lock found for file {file_id[:8]}...")
cursor.execute("""
INSERT INTO locks (file_id, session_id, updated_at)
VALUES (?, ?, CURRENT_TIMESTAMP)
""", (file_id, session_id))
conn.commit()
logger.info(f"[LOCK] Session {session_id[:8]}... acquired NEW lock for file {file_id[:8]}...")
return True
except Exception as e:
conn.rollback()
logger.error(f"[LOCK] Error during lock acquisition: {e}")
raise
_REMOTE_LOCK_TTL_SECONDS = 90 + 360 # local timeout + one sync-cycle buffer
def _is_remotely_locked(file_id: str, db_dir: Path, logger: logging.Logger) -> bool:
"""Check the remote lock cache to see if another instance holds this file.
Args:
file_id: The file's stable_id.
db_dir: Application db directory (contains metadata.db).
logger: Logger instance.
Returns:
True if a non-expired remote lock exists for this file.
"""
try:
from fastapi_app.lib.core.database import DatabaseManager
from fastapi_app.lib.repository.file_repository import FileRepository
repo = FileRepository(DatabaseManager(db_dir / "metadata.db"))
remote_locks = repo.get_remote_locks()
if file_id not in remote_locks:
return False
lock_info = remote_locks[file_id]
updated_at_str = lock_info.get("updated_at") or lock_info.get("acquired_at", "")
if not updated_at_str:
return False
updated_at = datetime.fromisoformat(updated_at_str)
if updated_at.tzinfo is None:
updated_at = updated_at.replace(tzinfo=timezone.utc)
age_seconds = (datetime.now(timezone.utc) - updated_at).total_seconds()
return age_seconds < _REMOTE_LOCK_TTL_SECONDS
except Exception as exc:
logger.debug(f"[LOCK] Remote lock check failed (non-fatal): {exc}")
return False
def release_lock(file_id: str, session_id: str, db_dir: Path, logger: logging.Logger) -> Dict[str, str]:
"""
Releases the lock for a given file if it is held by the current session.
Args:
file_id: The file's stable_id to unlock
session_id: The session ID releasing the lock
db_dir: Directory containing locks.db
logger: Logger instance
Returns:
dict: Structured response with status, action, and message
- status: "success" or "error"
- action: "released", "already_released", "not_owned"
- message: Human-readable description
Raises:
RuntimeError: If attempting to release a lock owned by another session or database error
"""
logger.debug(f"[LOCK] Session {session_id[:8]}... attempting to release lock for file {file_id[:8]}...")
# Ensure database is initialized
init_locks_db(db_dir, logger)
with get_db_connection(db_dir, logger) as conn:
cursor = conn.cursor()
# Check if lock exists and who owns it
cursor.execute("""
SELECT session_id
FROM locks
WHERE file_id = ?
""", (file_id,))
existing = cursor.fetchone()
if existing:
existing_session = existing['session_id']
if existing_session == session_id:
# Delete the lock
cursor.execute("""
DELETE FROM locks
WHERE file_id = ?
""", (file_id,))
logger.info(f"[LOCK] Session {session_id[:8]}... released lock for file {file_id[:8]}...")
return {
"status": "success",
"action": "released",
"message": f"Lock successfully released for file {file_id}"
}
else:
# Attempting to release someone else's lock
logger.warning(
f"[LOCK] Session {session_id[:8]}... DENIED release of lock for file {file_id[:8]}.... "
f"Owned by {existing_session[:8]}..."
)
raise RuntimeError(
f"Session {session_id} attempted to release a lock owned by {existing_session}"
)
else:
# Lock doesn't exist - idempotent success
logger.info(
f"[LOCK] Session {session_id[:8]}... attempted to release lock for file {file_id[:8]}..., "
f"but no lock exists (idempotent success)"
)
return {
"status": "success",
"action": "already_released",
"message": f"Lock was already released for file {file_id}"
}
def cleanup_stale_locks(db_dir: Path, logger: logging.Logger, timeout_seconds: int = LOCK_TIMEOUT_SECONDS) -> int:
"""
Removes all stale locks from the database.
Args:
db_dir: Directory containing locks.db
logger: Logger instance
timeout_seconds: Lock timeout in seconds
Returns:
int: Number of stale locks purged
"""
# Ensure database is initialized
try:
init_locks_db(db_dir, logger)
except Exception as e:
logger.error(f"Could not initialize locks database: {e}")
return 0
try:
with get_db_connection(db_dir, logger) as conn:
cursor = conn.cursor()
# Calculate the staleness threshold
stale_threshold = datetime.now(timezone.utc) - timedelta(seconds=timeout_seconds)
# Delete stale locks
cursor.execute("""
DELETE FROM locks
WHERE datetime(updated_at) < datetime(?)
""", (stale_threshold.isoformat(),))
purged_count = cursor.rowcount
if purged_count > 0:
logger.info(f"Purged {purged_count} stale locks")
return purged_count
except Exception as e:
logger.error(f"Error purging stale locks: {e}")
return 0
def get_all_active_locks(db_dir: Path, logger: logging.Logger, timeout_seconds: int = LOCK_TIMEOUT_SECONDS) -> Dict[str, str]:
"""
Fetches all non-stale locks and returns a map of file_id -> session_id.
Args:
db_dir: Directory containing locks.db
logger: Logger instance
timeout_seconds: Lock timeout in seconds
Returns:
dict: Dictionary mapping file stable_ids to session IDs for all active locks
"""
# Ensure database is initialized
try:
init_locks_db(db_dir, logger)
except Exception as e:
logger.error(f"Could not initialize locks database: {e}")
return {}
try:
with get_db_connection(db_dir, logger) as conn:
cursor = conn.cursor()
# Calculate the staleness threshold
stale_threshold = datetime.now(timezone.utc) - timedelta(seconds=timeout_seconds)
# Get all non-stale locks
cursor.execute("""
SELECT file_id, session_id
FROM locks
WHERE datetime(updated_at) >= datetime(?)
""", (stale_threshold.isoformat(),))
active_locks = {row['file_id']: row['session_id'] for row in cursor.fetchall()}
return active_locks
except Exception as e:
logger.error(f"Error fetching active locks: {e}")
return {}
def get_locked_file_ids(db_dir: Path, logger: logging.Logger, session_id: Optional[str] = None, repo=None) -> List[str]:
"""
Returns a list of file stable_ids which are currently locked.
Optionally filters by session_id.
Args:
db_dir: Directory containing locks.db
logger: Logger instance
session_id: If provided, only return locks for this session
repo: Unused (kept for backward compatibility)
Returns:
list: List of file stable_ids that are locked
"""
active_locks = get_all_active_locks(db_dir, logger)
locked_file_ids = []
for file_id, lock_session_id in active_locks.items():
if session_id and lock_session_id != session_id:
continue
locked_file_ids.append(file_id)
return locked_file_ids
def check_lock(file_id: str, session_id: str, db_dir: Path, logger: logging.Logger) -> Dict[str, any]:
"""
Checks if a single file is locked by another session.
Args:
file_id: The file's stable_id to check
session_id: The session ID making the check
db_dir: Directory containing locks.db
logger: Logger instance
Returns:
dict: {"is_locked": bool, "locked_by": Optional[str]}
"""
# Ensure database is initialized
try:
init_locks_db(db_dir, logger)
except Exception as e:
logger.error(f"Could not initialize locks database: {e}")
return {"is_locked": False, "locked_by": None}
active_locks = get_all_active_locks(db_dir, logger)
if file_id in active_locks:
lock_owner = active_locks[file_id]
if lock_owner != session_id:
logger.debug(f"File is locked by another session: {lock_owner}")
return {"is_locked": True, "locked_by": lock_owner}
return {"is_locked": False, "locked_by": None}
def reset_locks_db_initialized() -> None:
"""
Reset the locks database initialization tracking.
This is primarily for testing purposes, to allow re-initialization
after database files are deleted/recreated between tests.
"""
with _locks_db_init_lock:
_locks_db_initialized.clear()