Skip to content

refactor: convert SQLAlchemy sync to async (AsyncSession) - #151

Closed
wpfleger96 wants to merge 6 commits into
mainfrom
hayt/async-flip
Closed

refactor: convert SQLAlchemy sync to async (AsyncSession)#151
wpfleger96 wants to merge 6 commits into
mainfrom
hayt/async-flip

Conversation

@wpfleger96

Copy link
Copy Markdown
Owner

What

Converts the entire SQLAlchemy usage from synchronous Session to async AsyncSession (aiosqlite backend).

Why

The API layer (FastAPI), CLI bridge (asyncio.run() wrappers), and analysis pipeline all run in async contexts. Using a sync session forced run_sync escape hatches and blocked the event loop during DB I/O. This conversion makes the session type consistent with how every caller already expects to use it.

Scope

Production changes:

  • database/session.py: async engine, AsyncSessionFactory, session_scope(), cleanup_database(), open_db_session(); sync helpers kept for Alembic only
  • api/deps.py: get_db now yields AsyncSession
  • All API routers: async def + await session.execute() throughout
  • All service classes (session_service, device_service, day_service, event_service, stats_service, report_service, waveform_service, analysis_facade, database_service): methods converted to async
  • CLI groups: async inner _run() functions wrapped with asyncio.run() at the Click boundary
  • analysis_facade.get_analysis_result(): queries DB directly — bypasses AnalysisService which is still volatile
  • api/routers/rx.py: rewritten async using get_db
  • analysis/rx_tracker.py: all methods async

Test changes:

  • conftest.py: async_db_session, async_test_device, async_test_session_factory fixtures
  • integration/conftest.py: autouse reset_database_state calls cleanup_database() before/after each test
  • test_rx_tracker.py: all 25 tests → async def
  • test_analysis_facade.py: all 14 tests → async def
  • test_analysis_batch.py: mock.executeAsyncMock; coordinator test async
  • test_cli_commands.py: CLI tests use init_database + asyncio.run(setup) instead of db_session fixture (fixes Alembic/create_all double-initialization conflict)

Volatile surfaces deferred

Per coordination note — Duncan's duncan/async-prep branch rewrites these; diffs kept minimal:

  • export_service.py — streaming rewrite in flight
  • database_service.py is_sqlite_target()/reset() — capability split pending
  • import_jobs.py shutdown/reaper — stop-event semantics pending
  • import_service.py/importers.py — per-chunk injected scope ownership pending
  • analysis_facade.py batch ID discovery, single/waveform/report read-scope — streaming pending
  • _make_compute_only() — public seam replacement pending

Result

1063 passed, 61 skipped, 0 failures.

npub1mn7jgtj4w2pd0g0zeuhxsa6jy6p0rewxz4kujt98my82ahfmp72sxjexk7 and others added 6 commits July 31, 2026 17:50
… UTC contract

Sync-prep PR for the two-PR async migration (see docs/async-migration-plan.md).
The app stays fully synchronous; all changes are async-readiness foundations.

Key changes:
- Migrate all session.query() to select() + execute() (56 call sites, 14 files)
- Add lazy="raise" to all ORM relationships; explicit eager loads at call sites
- DatabaseTarget URL resolver with precedence chain and snore serve --db export
- UTCDateTime custom type; absolute-instant columns migrated; wall-clock columns
  documented as naive by contract
- SQLite connection recipe: connect_args autocommit=False + listener toggles
  dbapi_conn.autocommit=True around PRAGMAs, restores False (probe-verified)
- Delete schema.sql (zero callers, obsolete SQLite-only subset)
- DatabaseService SQL portability; Alembic env.py reads resolved URL
- Transaction-ownership table; import savepoints with forced-failure test
- I/O-compute DTO split; batch analysis splits into read+compute / write sessions
  to eliminate SQLite write-lock contention under ThreadPoolExecutor
- Import-job state machine: pending->running->succeeded/failed/cancelled,
  pending->cancelled, coalescing observer channels, TTL reaping terminal-only
- Docs: async-migration-plan.md, mcp-server-plan.md, roadmap.md entry

Co-authored-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
Fixes all findings from Thufir's pass-1 review that were not yet addressed
in the working tree, plus mypy/ruff clean-up so just check passes green.

Mypy fixes:
- import_data.py: _sse_generator asserts isinstance(msg, dict) after
  sentinel/None guards so mypy sees a concrete dict, not object
- test_sqlite_connection_recipe.py: _make_session_data return type changed
  from object to UnifiedSession; __import__('datetime') replaced with
  a direct import; TYPE_CHECKING guard added for the forward reference
- test_utc_datetime.py: dialect() constructor calls are untyped on some
  SQLAlchemy builds; consolidated into named variables so the single
  type: ignore comment covers the right line

Ruff fixes:
- export_service.py: orphaned 'from sqlalchemy import func' inside
  _query_sessions removed (func was used in the old text() version,
  not the typed select() version)
- analysis_facade.py: import block inside submit() re-sorted
- test_utc_datetime.py: import block inside AnalysisResult ordering test
  re-sorted; removed duplicate dialect compile test methods

Drift test strengthened (§5):
- test_api_server.py: test_openapi_matches_committed_generated_types now
  checks BOTH directions — generated types that no longer exist in the
  live API (stale), AND live API paths missing from generated types
  (ungenerated). Catches the exact failure Thufir named: a new DELETE
  route whose types were never regenerated.

Co-authored-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
…ricity, UTC, docs)

Co-authored-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
…ts, honest docs

Pass-3 remediation batch addressing all 5 IMPORTANT + 2 MINOR findings from
Thufir's final review of SNORE PR-1 (async-prep).

## §7 DTO/coordinator — bounded export streaming
- ExportService._query_sessions_chunked: yields session dicts via yield_per()
  — no full fetchall() materialisation
- ExportService._build_export_rows: generator that yields (session, events,
  settings) in EXPORT_CHUNK_SIZE=500 chunks; bulk-loads events+settings per
  chunk so memory is bounded regardless of result set size
- export_csv and export_json consume _build_export_rows instead of
  _build_export_sessions; _build_export_sessions kept as backward-compat wrapper
- analysis_facade.py: pending dict typed as dict[Future[str], int] (fixes mypy)
- import_jobs.py state diagram corrected: POST starts the worker, not GET

## §6 Import ownership — deeper forced-failure test
- test_sqlite_connection_recipe.py: new test_failed_session_after_child_writes
  injects failure AFTER waveform+event rows are flushed (at _import_statistics),
  asserts no device/day/session/waveform/event/statistics rows survive the
  savepoint rollback while the next session's rows do
- _make_session_data_with_children: builds UnifiedSession with real waveform
  blob, event, statistics, and settings for rollback verification

## §3 Import lifecycle — shutdown test with live worker
- test_import_state_machine.py: TestShutdown.test_shutdown_with_live_worker
  starts a real background thread blocked on a gate, calls shutdown(timeout=0.05),
  asserts _cancel_flag is set and 'still alive' warning is logged, then unblocks
  the thread and confirms it reaches terminal state

## §3/§4 Route/worker tests replacing simulations
- TestRegistrationFailure: replaced manual simulation with three tests that
  call _start_worker directly; proves Thread.start() failure, Thread.__init__()
  failure, and create_job failure each cancel the job, remove it from the store,
  and clean up the upload directory
- TestShutdown: added test_shutdown_with_live_worker (real thread, not pending-only)
- TestRouteWorkerBehavior: new class with four route/worker tests:
  run_import_completes_as_succeeded, run_import_cancel_before_detect_produces_cancelled,
  sse_route_stream_emits_keepalive_while_running, route_cancel_delete_transitions_running
- TestWorkerTerminalState docstring clarified: internal _finish/_finish_cancelled
  calls test the state machine; route-level behavior covered in new class

## §5 Honest behavior tests — UTC production path
- test_utc_datetime.py: test_latest_result_via_production_path_with_mixed_offsets
  now calls AnalysisService.get_analysis_result() (the real service method) instead
  of reconstructing the query directly; distinguishes the two rows by
  session_duration_hours in programmatic_result_json

## §5 Honest behavior tests — serve --db lifespan test
- test_database_target.py: TestServeCommandEnvExport.test_lifespan_opens_flagged_database
  runs the real serve Click command to capture the exported SNORE_DATABASE_URL,
  then invokes the FastAPI lifespan with that URL and asserts init_database_from_url
  is called with the flag-specified path — not just that the env var is set

## §5 Docs fixes
- docs/async-migration-plan.md §6 ownership table: import_sessions_batch row
  corrected (caller provides session via db=, not opens own session_scope)
- docs/async-migration-plan.md §6 footer: revised to describe actual call graph
  (ImportService injects session, import_sessions_batch uses savepoints only)
- docs/async-migration-plan.md §7 batch analysis description: corrected
  'read+compute session_scope' to three explicit phases (read-only, compute
  no-session, write-only)
- docs/async-migration-plan.md §7 splits list: five → six surfaces; added Export
  (_build_export_rows generator); single-analysis description updated to
  load_session_inputs_raw + prepare_inputs + compute_analysis
- docs/async-migration-plan.md §5: added dialect compilation suite mention and
  runtime-unproven disclaimer block
- import_jobs.py module docstring state diagram: GET no longer starts the worker

## Test infrastructure fixes
- test_analysis_batch.py: updated all monkeypatch targets from load_session_inputs/
  _store_result to load_session_inputs_raw/prepare_inputs/store_result; cancel
  test now asserts cancelled==3, successful==0 (not trusts trivial load_calls==[])
- test_analysis_facade.py TestRunBatchAnalysis: same patch target updates
- importers.py: batch_day_ids annotated as set[int] (mypy fix)
- import_data.py: upload cleanup comment corrected to 'end of worker execution'
  (MINOR fix per spec)

Co-authored-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
PR-2 async flip dependencies. No greenlet pin. No Postgres drivers.

Co-authored-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
Replace sync Session with AsyncSession throughout services, API routers,
CLI groups, and CLI commands. Converts all DB-touching code paths to use
await session.execute() / await session.scalar() patterns.

Key changes:
- database/session.py: add async engine, session factory, session_scope(),
  cleanup_database(), open_db_session(); keep sync helpers for Alembic only
- api/deps.py: replace sync get_db with async get_db yielding AsyncSession
- All API routers: add async/await throughout
- All service classes (session, device, day, event, stats, report, waveform,
  analysis_facade, database_service): converted to async methods
- CLI groups: wrap async inner functions with asyncio.run() at click boundary
- analysis_facade: get_analysis_result() queries DB directly (bypasses
  AnalysisService which is still sync-volatile)
- api/routers/rx.py: rewritten to async using get_db
- analysis/rx_tracker.py: all methods converted to async

Test changes:
- conftest.py: add async_db_session, async_test_device, async_test_session_factory
- integration/conftest.py: autouse reset_database_state calls cleanup_database()
- test_rx_tracker.py: all 25 tests converted to async def
- test_analysis_facade.py: all 14 tests converted to async def
- test_analysis_batch.py: mock execute as AsyncMock; coordinator test async
- test_cli_commands.py: CLI tests use init_database + asyncio.run(setup)
  pattern instead of db_session (incompatible Alembic initialization paths);
  remove spurious init_database() from test_db_init_creates_database

Volatile surfaces deferred per Paul's coordination note:
- export_service.py (streaming rewrite in flight)
- database_service.py is_sqlite_target()/reset() (split pending)
- import_jobs.py shutdown/reaper (stop-event semantics pending)
- import_service.py/importers.py transaction ownership (per-chunk scopes pending)
- analysis_facade.py batch ID discovery / single/waveform/report read-scope
- _make_compute_only() (public seam replacement pending)

Co-authored-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
@wpfleger96 wpfleger96 closed this Aug 1, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant