-
Notifications
You must be signed in to change notification settings - Fork 79
[async 8/8] test: reproduce the Flask-era lazy-load Sentry issues with polymorphic data#482
Closed
somethingnew2-0 wants to merge 7 commits into
Closed
Conversation
Every operation class now stores raw constructor arguments and resolves them (queries, row locks, id lookups) in a _resolve() method invoked as the first statement of execute() / execute_for_group() / execute_for_role(). __init__ signatures and public attribute names are unchanged, and every call site already chains .execute() immediately, so behavior is identical - resolution just happens at execute time. Async SQLAlchemy prep: __init__ cannot be async, so all session work must live behind execute() before the flip makes it awaitable. For the four operations whose execute() wraps asyncio.run(self._execute()), _resolve() runs before asyncio.run on the calling thread. Side benefit: the with_for_update row locks in the approve/reject operations now begin at execute time rather than construction time, which narrows the lock window to the actual mutation. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
ModifyGroupUsers and ModifyRoleGroups previously created asyncio tasks whose coroutines queried db.session (requester lookup + get_all_possible_request_approvers) before invoking the notification hook. Under async SQLAlchemy, ContextVar-scoped sessions are inherited by child tasks, and concurrent operations on one AsyncSession raise InvalidRequestError - so spawned tasks may only perform network I/O. The approve helpers now just mutate and return the request; after the final commit, the main coroutine resolves requester/approvers/group sequentially and spawns hook-only coroutines. Also fixes a latent bug in ModifyRoleGroups: its pending-request queries never eager-loaded AccessRequest.requested_group / RoleRequest.requester_role / RoleRequest.requested_group (all raise_on_sql), so the notify task raised - and asyncio.wait silently swallowed it - whenever an approved request had been created in a different session than the one approving it. The notifications now always have their relationships loaded. resolved_at (expired at flush after the func.now() assignment) is explicitly refreshed so hooks see a concrete timestamp. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The atomic flip (POST_MIGRATION_TODO items 2, 3, 11): - extensions.py: async_sessionmaker + async_scoped_session keyed on the existing _session_scope ContextVar (item 3). The db.session global proxy now yields an AsyncSession; remove()/create_all()/drop_all() are coroutines. ContextVar scoping (not asyncio.current_task) is load-bearing: BaseHTTPMiddleware.call_next runs handlers in a child task, and context copies propagate where task identity would not. - database.py: create_async_engine with a driver-rewrite map so existing deployment URLs (postgresql://, postgresql+pg8000://, sqlite://) keep booting on asyncpg/aiosqlite; CloudSQL goes through connector.connect_async + asyncpg IAM auth as the async_creator. get_db is an async generator; DbSession yields AsyncSession. - okta_service.py (item 11): every method is natively async under its primary name; the asyncio.run-per-call sync wrappers and duplicated async_* variants are gone. One event loop end-to-end; fresh per-call SDK clients retained (CLI and tests run their own loops). - operations/routers/auth/syncer/integrity/MCP: async def + awaits throughout; routers paginate via apaginate; the four fan-out operations keep their create_task Okta parallelism on the request loop. Spawned tasks perform network I/O only - never db.session (concurrent use of one AsyncSession is illegal). - Sync plugin hooks (deferred to item 18) are bridged: session-bound app_group_lifecycle hooks run via AsyncSession.run_sync (greenlet bridge), notification/conditional-access hooks via anyio.to_thread.run_sync so plugin network I/O cannot block the event loop. - manage.py: Click commands stay sync; _with_app_context drives the async body with one asyncio.run per invocation and disposes the engine it created. Plugin-contributed CLI commands that touch db.session synchronously will need the same treatment (docs follow). - migrations/env.py: async-template (connection.run_sync) over the same engine builder, so CloudSQL migrations keep working; pg8000 dropped from requirements. Tests are converted in the next commit; mypy/ruff/import/alembic gates are green at this point. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
POST_MIGRATION_TODO item 15. pytest-asyncio 1.4.0 in asyncio_mode=auto with function-scoped event loops (config in tox.ini [pytest]); the 'coroutine was never awaited' warning is escalated to an error as a missed-await tripwire. - conftest: the db fixture builds an aiosqlite StaticPool in-memory engine (TEST_DATABASE_URI still supported, legacy driver names normalized to asyncpg); table create/drop via conn.run_sync; engine disposed per test so the aiosqlite worker thread closes. The client fixture is a DatetimeAwareAsyncClient over ASGITransport (raise_app_exceptions=False and follow_redirects=True replicate the old TestClient defaults). AsyncClient is required, not stylistic: requests must run on the test's own loop because the engine is bound to it. - ~440 tests are async def with awaited client/session/operation calls; Query.count() sites go through tests/helpers.db_count. Okta mock targets renamed to the merged method names; patches on async methods auto-create AsyncMock so spy assertions are unchanged. - test_okta_service's concurrency test now asserts per-call SDK client isolation across asyncio.gather on one loop (the per-thread-loop design it used to pin down no longer exists). test_mcp wraps request phases in the app's lifespan context so the MCP session manager task group is running. test_oidc's cookie/redirect clients carry over verbatim on httpx. - Tests share one session with the app under test, so identity-map state expired by request rollbacks or fetch-synced bulk updates is reloaded explicitly (expire_all/refresh) at the staleness points - attribute access on an expired object is sync lazy IO, which AsyncSession correctly refuses. - Two spy-count assertions in the group sync tests widened by the okta method-name merge (the spy now also sees ModifyGroupUsers' internal removals); system behavior unchanged. Full suite: 515 passed - same count as the sync baseline. ruff and mypy clean across 164 files. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- README + k8s example manifests document plain postgresql:// URIs (legacy +pg8000 names are normalized to asyncpg by the engine builder); README notes asyncpg's ssl= parameter handling and the statement_cache_size=0 requirement behind transaction-mode PgBouncer - db-migrations workflow matrix uses the plain postgresql:// URI - POST_MIGRATION_TODO: items 2, 3, 11, and 15 are complete and removed; new 11a (Okta SDK v3 upgrade - kept at 2.9.8 behind the OktaService facade for this migration) and 11b (shared pooled Okta client via lifespan); item 18 documents the sync-hook bridging now in place (run_sync for session-bound lifecycle hooks, anyio.to_thread for notification/conditional-access hooks) and the async rules for plugin-contributed CLI commands Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
...ng them The four fan-out operations (ModifyGroupUsers, ModifyRoleGroups, DeleteUser, DeleteGroup) drained their asyncio.create_task lists with asyncio.wait, which does not propagate task exceptions - a failed Okta call or notification hook vanished silently, so partial Okta failures during membership changes were invisible. They now drain through drain_fan_out_tasks (gather with return_exceptions=True), which logs every failure at ERROR with the operation context and traceback. Failures still do not fail the request: the local DB state is committed before the tasks are awaited, so raising would turn an Okta straggler into a misleading request error; the syncer reconciles Okta drift on its next run. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
...c data ACCESS-FLASK-4N (RoleGroup.active_role_associated_group_member_mappings), ACCESS-FLASK-44 (RoleGroupMap.active_group), and ACCESS-FLASK-4E (AppGroup.app, OktaGroup.active_group_tags) were production InvalidRequestError lazy='raise_on_sql' failures that never reproduced in tests - raise_on_sql raises identically under test, so the missing ingredient was data shape: the failing serializations required polymorphic subtypes in nested positions (role groups whose associated groups are app groups, tagged groups inside request refs, app groups inside membership rows) that the suite never built. The five relationships involved were demoted to lazy="select" as a band-aid; the async migration flips them back to raise_on_sql, so this battery builds the production shape once and walks every read surface - groups list/detail per type, roles, users with via-role app-group memberships, access-request detail with role-group and app-group targets, role requests, tags, apps, and the audit endpoints filtered and unfiltered. Assertions check that the nested payloads are populated (mappings -> active_group -> app, tags on every row type), so both failure modes are covered: a missing eager load fails loudly instead of paging on-call, and a silently-empty collection fails instead of shipping wrong data. All 17 scenarios pass against the current loaders - the eager-load topology closed every gap behind the four Sentry issues. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This was referenced Jun 12, 2026
@somethingnew2-0
somethingnew2-0
force-pushed
the
pcollins/async-3-fanout-errors
branch
from
June 12, 2026 17:07
cffb000 to
a651bbf
Compare
somethingnew2-0
commented
Jun 12, 2026
Collaborator
Author
@somethingnew2-0
somethingnew2-0
deleted the
pcollins/async-4-polymorphic-coverage
branch
June 12, 2026 17:11
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.
Uh oh!
There was an error while loading. Please reload this page.
Regression battery for the four Flask-era Sentry issues (
ACCESS-FLASK-4N,ACCESS-FLASK-44,ACCESS-FLASK-4E) — theInvalidRequestError: ... lazy='raise_on_sql'production failures that motivated demoting five relationships tolazy="select", which #477 flips back toraise_on_sql.Why they never reproduced locally:
raise_on_sqlraises identically under test, so the only production-only ingredient was data shape. The failing serializations required polymorphic subtypes in nested positions — a role group whose associated groups are app groups, tagged groups inside request refs, app groups inside membership rows — which the suite never built. The error chain in the reports maps exactly onto the schema nesting:RoleGroup.active_role_associated_group_member_mappings(4N) → each mapping'sactive_group(44) → that group'sappwhen it's an AppGroup (4E), plusactive_group_tags(4E) on every rich group ref.This battery builds that shape once (tagged role group with member+owner associations to a tagged app group and a tagged plain group, via-role memberships, pending access/role requests targeting both group types) and walks every read surface: groups list + detail per type, roles, users, access-request detail with role-group and app-group targets, role requests, tags, apps, and
/api/audit/users+/api/audit/groupsboth unfiltered and with theuser_id/group_idfilters the old model comments singled out. Assertions verify the nested payloads are populated, not just 200 — so both failure modes are covered: a missing eager load fails loudly instead of paging on-call, and a silently-empty collection fails instead of shipping wrong data.Result: all 17 scenarios pass against the current loaders — the eager-load topology from the FastAPI migration plus #477's fixes closes every gap behind the four Sentry issues. Full suite: 536 passed.
PR stack
Merge bottom-up; each PR is based on the branch of the one before it (retarget to
mainas predecessors merge).__init__into_resolve()db.sessionaccess out of spawned notification tasks🤖 Generated with Claude Code