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>
102 lines
3.0 KiB
Python
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}
|