Files
BanGUI/backend/app/repositories/settings_repo.py
Lukas 1302ac821f Fix non-atomic setup persistence across DB contexts (Issue #30)
Implement transactional setup with explicit state machine and crash-safety
to prevent partial commits from leaving inconsistent state.

## Changes

### Core Implementation
1. **settings_repo.py**: Add atomic batch settings write
   - New set_settings_batch() method: writes multiple settings in single
     transaction (BEGIN IMMEDIATE ... COMMIT). Either all settings persist
     or none do, preventing partial state if crash occurs mid-batch.

2. **setup_service.py**: Refactor run_setup() with transactional phases
   - Phase 0: Compute password hash early (before any DB writes) to ensure
     idempotency. Same hash is used throughout retries, preventing divergent
     hashes from bcrypt's random salt.
   - Phase 1 (Bootstrap DB transaction): Set setup_state=in_progress and
     database_path, then commit. First checkpoint for crash detection.
   - Phase 2 (Filesystem): Initialize runtime database (idempotent)
   - Phase 3 (Runtime DB transaction): Batch-write all settings atomically
   - Phase 4 (Bootstrap DB transaction): Set setup_state=complete and
     setup_completed=1. Final commit point.

3. **protocols.py**: Add set_settings_batch to SettingsRepository protocol

### Testing
- Added 6 new transactionality tests covering:
  - State machine transitions (None → in_progress → complete)
  - Password hash idempotency across retries
  - Atomic batch writes (all-or-nothing persistence)
  - Bootstrap DB state tracking
  - Database path propagation to both DBs
  - Recovery on partial failure
- All 18 tests pass (12 existing + 6 new)

### Documentation
- Updated Docs/Architekture.md with new section 6:
  - Setup state machine with state transitions
  - Transaction boundary documentation
  - Password hash idempotency rationale
  - Backward compatibility notes

## Design Decisions

### Why This Approach
- Current code already idempotent via INSERT OR REPLACE, but password
  hash non-idempotency created silent inconsistency risk
- Simpler than multi-state machine: 2 states sufficient for detection
- Maintains backward compatibility (setup_completed key still written)
- Explicit transactions make crash-safety obvious to future maintainers

### Crash Scenarios Now Handled
1. Crash after Phase 1 → detected by setup_state=in_progress on retry
2. Crash after Phase 2 → runtime DB may be partial, safe to retry
3. Crash after Phase 3 → runtime DB rolls back on next connection
4. Crash after Phase 4 → setup_completed detected, skipped

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-29 19:19:53 +02:00

102 lines
3.0 KiB
Python

"""Settings repository.
Provides CRUD operations for the ``settings`` key-value table in the
application SQLite database. All methods are plain async functions that
accept a :class:`aiosqlite.Connection` — no ORM, no HTTP exceptions.
"""
from __future__ import annotations
from typing import TYPE_CHECKING
if TYPE_CHECKING:
import aiosqlite
async def get_setting(db: aiosqlite.Connection, key: str) -> str | None:
"""Return the value for *key*, or ``None`` if it does not exist.
Args:
db: Active aiosqlite connection.
key: The setting key to look up.
Returns:
The stored value string, or ``None`` if the key is absent.
"""
async with db.execute(
"SELECT value FROM settings WHERE key = ?",
(key,),
) as cursor:
row = await cursor.fetchone()
return str(row[0]) if row is not None else None
async def set_setting(db: aiosqlite.Connection, key: str, value: str) -> None:
"""Insert or replace the setting identified by *key*.
Args:
db: Active aiosqlite connection.
key: The setting key.
value: The value to store.
"""
await db.execute(
"INSERT OR REPLACE INTO settings (key, value) VALUES (?, ?)",
(key, value),
)
await db.commit()
async def delete_setting(db: aiosqlite.Connection, key: str) -> None:
"""Delete the setting identified by *key* if it exists.
Args:
db: Active aiosqlite connection.
key: The setting key to remove.
"""
await db.execute("DELETE FROM settings WHERE key = ?", (key,))
await db.commit()
async def set_settings_batch(db: aiosqlite.Connection, settings: dict[str, str]) -> None:
"""Insert or replace multiple settings atomically in a single transaction.
Wraps all writes in a single BEGIN IMMEDIATE ... COMMIT transaction to ensure
atomicity. Either all settings are persisted or none are. This is useful for
operations that must succeed as a logical unit (e.g., setup initialization).
Args:
db: Active aiosqlite connection.
settings: A dictionary mapping setting keys to their values.
Raises:
Any exception from executing the SQL will cause a rollback.
"""
if not settings:
return
try:
await db.execute("BEGIN IMMEDIATE;")
for key, value in settings.items():
await db.execute(
"INSERT OR REPLACE INTO settings (key, value) VALUES (?, ?)",
(key, value),
)
await db.commit()
except Exception:
await db.rollback()
raise
async def get_all_settings(db: aiosqlite.Connection) -> dict[str, str]:
"""Return all settings as a plain ``dict``.
Args:
db: Active aiosqlite connection.
Returns:
A dictionary mapping every stored key to its value.
"""
async with db.execute("SELECT key, value FROM settings") as cursor:
rows = await cursor.fetchall()
return {str(row[0]): str(row[1]) for row in rows}