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>
This commit is contained in:
@@ -67,6 +67,9 @@ class SettingsRepository(Protocol):
|
||||
async def get_all_settings(self, db: aiosqlite.Connection) -> dict[str, str]:
|
||||
...
|
||||
|
||||
async def set_settings_batch(self, db: aiosqlite.Connection, settings: dict[str, str]) -> None:
|
||||
...
|
||||
|
||||
|
||||
class BlocklistRepository(Protocol):
|
||||
async def create_source(
|
||||
|
||||
@@ -57,6 +57,36 @@ async def delete_setting(db: aiosqlite.Connection, key: str) -> None:
|
||||
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``.
|
||||
|
||||
|
||||
Reference in New Issue
Block a user