fix(api): break the import cycle, stop pinning DB connections, revive retries - #66
Merged
Merged
Conversation
… retries
The module packages each re-exported their router while core.dependencies
imports identity.models, so importing any submodule dragged the router
graph back through a half-initialised core.dependencies. `import
helprs.core.dependencies` failed on its own; the app booted only because
main.py happens to import admin.views -- which imports a model -- first,
so re-sorting imports would have broken startup. That cycle was also the
reason two services carried function-level imports. The packages are bare
now, both workarounds are gone, and a test imports each module in a fresh
interpreter so the cycle cannot grow back.
FastAPI tears yield-dependencies down only after a streaming body ends, so
the SSE route's DbSession -- and the one behind its auth dependency -- kept
a pooled connection and an open transaction for the life of the stream, up
to CONTAINER_TTL_SECONDS. With 15 connections per worker, 15 viewers
starved the worker, and each idle-in-transaction backend blocked VACUUM.
finalize_session did the same across wait_container. Both now do their
database work in short transactions either side of the wait.
On client disconnect only finalization was detached, not the event drain.
The drain stopped with the generator, so finalize_session then built its
scorecard from a truncated history and posted that to the PR -- defeating
the thing detaching was meant to protect. Both are detached together.
Also:
- container status persisted the enum member NAME ("RUNNING") while the
column default is a value ("pending"), so a DB-defaulted row raised
LookupError through the ORM. values_callable plus a data migration.
- per_page=0 divided by zero and per_page=-5 reached Postgres as a
negative LIMIT; ?status=bogus raised ValueError mid-handler. All three
were 500s and are now 422s, constrained in the signature.
- a failed container start rolled back the FAILED status *and* the session
row, so the user got a 502 and a dashboard showing nothing happened.
- mark_failed wrote "failed" and the replay query never selected it, so
one handler exception dropped a delivery for good and MAX_RETRY_COUNT
was unreachable. Failed events are replayable and re-claimable, spaced
by updated_at, and abandonment is now a state that can actually occur.
- core.dependencies went through identity's repository instead of running
its own SELECT over another module's table.
|
helPRs session created for this PR. Skill: |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Correctness findings from the 2026-08-01 audit. Every bug below was reproduced before being fixed, and the regression tests were checked against the old code to confirm they fail on it.
The import cycle the app was living with
Each module package re-exported its router, and
core/dependencies.pyimportsidentity.models. Importing any submodule therefore pulled the whole router graph back through a half-initialisedcore.dependencies:The app booted only because
main.pyhappens to importadmin.views— which imports a model — before any router. An isort pass would have broken startup. It was also the reasonidentity/service.pyandcontainer/service.pyeach carried a function-level import.The four
__init__.pyfiles are bare now, both deferred imports are hoisted, andtests/test_import_graph.pyimports each module in a fresh interpreter — once anything pulls inhelprs.main, everything is insys.modulesand a cycle is invisible.Two paths pinned a database connection for minutes
FastAPI tears yield-dependencies down only after the streaming body completes. The SSE route took
db: DbSession, and its authentication dependency took another — so a pooled connection, with an open transaction, stayed checked out for the life of the stream: up toCONTAINER_TTL_SECONDS(900s). AtDB_POOL_SIZE + DB_MAX_OVERFLOW= 15 per worker, fifteen viewers starve every other request on that worker, and each stream is an idle-in-transaction backend blocking VACUUM.get_db_context's own docstring warns against exactly this.finalize_sessiondid the same thing, holding a session acrossasyncio.wait_for(docker.wait_container(...), timeout=900)— plus a lock on the session row.The SSE route now takes neither dependency: it authenticates and authorizes inside a short
get_db_context()that closes before streaming starts.get_current_user_for_streamis split intostream_token(request)andauthenticate_token(session, settings, token)so the route can call them directly.finalize_sessionis three phases — read, wait with nothing held, write.Client disconnect silently truncated the scorecard
On disconnect, finalization was detached but the drain was not. The generator closed,
stream_and_persiststopped consuming, andfinalize_sessionthen built its scorecard from a truncated event list and posted that to the PR — quietly defeating the thing detaching finalization exists to protect, and only in the common case of someone closing the tab during a ten-minute session.Both are detached together now. Re-reading the log from the start is deliberate and cheap:
_persistis idempotent throughON CONFLICT DO NOTHING. The stream also gained anexcept Exceptionarm that emits anevent: errorframe — previously a mid-stream failure produced a truncated body with nodone, and nativeEventSourcereconnected straight back into it.The enum was stored in the wrong form
SQLAlchemy's
Enumpersists the member name unless told otherwise, so the ORM wrote'RUNNING'while the column'sserver_defaultis'pending'and the API returns'running':Any row created without an explicit status was permanently unreadable through the ORM. Fixed with
values_callableplus a data migration, verified end-to-end against a real Postgres: a legacyRUNNINGrow comes backrunningafteralembic upgrade head.Three query parameters returned 500
per_page=0divided by zero computingtotal_pages;per_page=-5reached Postgres as a negativeLIMIT;?status=bogusraisedValueErrorbefore any DB call. The handler validated by hand withmin()/max()and a bareContainerStatus(status). All three are constrained in the signature now, so FastAPI answers 422 before the handler runs.Contract change worth flagging:
per_page=500used to be silently clamped to 100 and is now a 422. The frontend only ever sends 20 (dashboardApi.ts:78), so nothing in-tree breaks, but an external caller relying on the clamp would notice.A failed container start left no trace
start_containersetFAILEDand flushed, then raised — and the exception unwound throughget_db, which rolls back, discarding the status and the session row created earlier in the same request. The user got a 502 and a dashboard showing nothing had ever happened. The row is committed before the container is touched, and the failure status is committed rather than flushed. The new test rolls the request session back explicitly; it fails against the old code.The retry policy was fiction
mark_failedwrotestatus="failed", andget_replayable_eventsonly ever selectedpending/processing. Nothing moved a row back, so a single handler exception dropped the delivery permanently,retry_countcould never exceed 1, and bothMAX_RETRY_COUNT = 5and the entireabandonedbranch were unreachable code documented as a working policy.failedis now replayable and claimable — selecting it without teachingmark_processingto accept it would just have the reaper pick the same rows forever. Idleness is measured fromupdated_atso retries are spaced by the grace period, andmark_processinggained theretry_countguard so abandonment sticks.Also
core/dependencies.pyran its ownselect(GitHubUser)over identity's table; it goes throughidentity.repository.get_by_idnow, and parses the UUID once instead of parsing then discarding it.stream_tokenrequires a well-formedBearervalue. The old code usedremoveprefix, so a bareAuthorization: <token>was accepted there but rejected by the header-only path — two notions of a valid header in one module.test_service.pyhoisted.Verification
ruff+ruff format+mypycleanDeferred, not forgotten
cleanup_all_runningandreconcile_stale_sessionsare not scoped to the worker that owns a session, so one worker restarting cancels live sessions belonging to its peers and marks the survivors FAILED. Unlike the reaper these are not idempotent — they are actively destructive to other workers. Fixing it properly needs an ownership marker (a boot id on the container labels, or a column), which is a bigger change than belongs in this PR.