Skip to content

feat(asyncio): Make all calls to postgress using sqlalchemy[asyncio] - #1001

Closed
brightsparc wants to merge 8 commits into
mozilla-ai:mainfrom
introspection-org:julian/async-asyncpg
Closed

feat(asyncio): Make all calls to postgress using sqlalchemy[asyncio]#1001
brightsparc wants to merge 8 commits into
mozilla-ai:mainfrom
introspection-org:julian/async-asyncpg

Conversation

@brightsparc

@brightsparc brightsparc commented Apr 5, 2026

Copy link
Copy Markdown

Description

Fixes the gateway lockup diagnosed in #1000 by removing the sync-psycopg2-in-async-handler anti-pattern at its root, and adds two pluggable strategies for common bottlenecks on the request path.

Four stacked changes, each behind a config flag:

  1. Async DB layer — swap psycopg2 + sqlalchemy.orm.Session for asyncpg + AsyncSession. Contended DB calls now yield to the event loop instead of blocking it.
  2. budget_strategy configfor_update (default, legacy) | cas (lock-free, recommended) | disabled (escape hatch)
  3. log_writer_strategy configsingle (default, inline) | batch (recommended for high-throughput)
  4. DB pool tuningdb_pool_size / db_max_overflow / db_pool_timeout / db_pool_recycle exposed to config, defaults bumped for async workloads.

Plus a k6-based load-test harness under tests/load/ with recorded comparison results.

Why

The gateway uses async def route handlers but (until this PR) every DB call underneath went through synchronous psycopg2. When a query had to wait on anything — a FOR UPDATE row lock, a saturated pool, Postgres itself — the sync call blocked the entire event loop, freezing every in-flight request including /health. The load test on main demonstrates this clearly: under 100 VUs the gateway stalls at 228 requests completed (all on the warmup), the 15-connection pool saturates, and the process needs to be killed after piling up 30s-timeout errors.

After this change, a contended DB call awaits and other coroutines make progress. Fixes the architectural root cause rather than papering over it.

Changes

Dependencies (pyproject.toml)

  • Added asyncpg>=0.29.0 (async Postgres driver)
  • Added aiosqlite>=0.19.0 (async SQLite for local dev/tests)
  • Added sqlalchemy[asyncio] extra
  • Kept psycopg2-binary for Alembic (which remains sync)

Database core (core/database.py)

  • create_enginecreate_async_engine with configurable pool (pool_size, max_overflow, pool_timeout, pool_recycle)
  • sessionmakerasync_sessionmaker(expire_on_commit=False)
  • get_db is now an async generator yielding AsyncSession
  • URL auto-translation: accepts familiar postgresql://… / sqlite:///… and wires the async driver automatically; Alembic gets the sync form
  • create_session() helper for startup-time use outside request scope

Config (core/config.py)

  • budget_strategy (default "for_update") — how per-user budget validation serializes concurrent resets
  • log_writer_strategy (default "single") — how usage log rows are persisted
  • db_pool_size (default 10), db_max_overflow (default 20), db_pool_timeout (default 30.0), db_pool_recycle (default -1)

Budget service (services/budget_service.py) — dispatch via match/case:

  • for_update: legacy behavior (FOR UPDATE across entire request)
  • cas: lock-free — atomic conditional UPDATE (WHERE next_budget_reset_at < now) claims the reset. Compare-and-swap semantics protect the BudgetResetLog insert from duplicates.
  • disabled: skip validate_user_budget entirely, return None

Log writers (services/log_writer.py) — new module with Protocol + 2 implementations:

  • SingleLogWriter: write each event inline, one txn per event
  • BatchLogWriter: queue events onto asyncio.Queue, background task flushes batches of up to 100 rows / 1s. queue.join() drains on shutdown via the FastAPI lifespan.
  • Both expose start() / stop() and are driven from the lifespan. Both treat flush failures as best-effort (rollback + log + drop), consistent with the historical contract.

Startup (main.py)

  • Bootstrap + pricing init + log writer lifecycle run inside a FastAPI lifespan async context manager
  • Keeps all async-engine operations bound to the server's event loop (avoids the event-loop-binding pitfall asyncpg has when connections are created in one loop and reused in another)

Auth / deps (api/deps.py)

  • _verify_and_update_api_key, verify_api_key, verify_api_key_or_master_key async end-to-end
  • New get_log_writer FastAPI dependency

Repositories + services + routes (~20 files)

  • All DB calls converted: db.query(X).filter(...).first()(await db.execute(select(X).where(...))).scalar_one_or_none(), db.commit/rollback/refresh/deleteawait db.commit/rollback/refresh/delete, atomic spend update → await db.execute(update(User)...values(spend=User.spend + cost))
  • log_usage in chat.py refactored to build a UsageLog and enqueue via LogWriter.put() — no longer commits inline

Metrics (metrics.py)

  • gateway_usage_log_queue_depth (gauge)
  • gateway_usage_log_batch_size (histogram, labeled by writer)
  • gateway_usage_log_flush_duration_seconds (histogram, labeled by writer + result)
  • gateway_usage_log_rows (counter, labeled by writer + result=written|dropped)

Tests (tests/gateway/conftest.py + ~10 test files)

  • Async fixtures: test_db, db_session, client.override_get_db all yield AsyncSession
  • New tests for cas and disabled budget strategies (no-FOR-UPDATE assertions via get_active_user spy)

Load test harness (tests/load/)

  • fake_provider.py — click CLI, seedable log-normal jittered delay, zero-delay noop mode
  • load_test.js — k6 scenarios: warmup, distinct_users, same_user with setup()/teardown() lifecycle
  • run_load_test.sh — orchestrator (Postgres container, fake provider, gateway, k6) + post-run DB verification that counts usage_logs rows to validate 100% coverage
  • results/ — recorded sync-vs-async comparison across all 4 budget strategies + the batch log writer

Benchmark results

100 VUs × 30s × noop fake provider, single uvicorn worker:

Scenario Total rps distinct rps same rps Reqs OK Coverage
sync (main) ~0 (stalled) ~0 ~1 391
async + for_update 81.1 46.3 34.8 ⚠️ 6,578
async + cas 82.3 41.5 40.8 6,498
async + disabled 89.5 44.8 44.7 7,113
async + disabled + batch 🏆 97.4 47.4 50.0 7,637 100%

Key findings:

  • sync → async: 391 → 6,578 successful requests (17×), zero failures vs 160 pool-exhaustion timeouts
  • for_update → cas: closes the 25% same_user contention cliff (34.8 → 40.8 req/sec)
  • single → batch log writer: +9% throughput, 100% row coverage on shutdown (7,637 requests → 7,637 rows persisted)
  • same_user beats distinct_users in the batch run because batch groups spend UPDATEs per user

Full results and per-transition analysis: tests/load/results/results.md.

Not in scope

  • Alembic migrations remain sync — they use the original postgresql:// URL driven by psycopg2-binary.
  • Default budget strategy stays for_update for backwards compatibility. Users opt into cas via config.
  • Default log writer stays single for backwards compatibility. Users opt into batch via config.

Possible follow-ups

  • Drop psycopg2-binary by switching Alembic to psycopg[binary] (psycopg3 sync mode). Consolidates to one driver family.
  • Bump default --workers to match production deployment (currently 1 for benchmarking signal isolation).
  • Module-level pricing cache — find_model_pricing is a PK lookup per request.

PR Type

Relevant issues

Fixes #1000.

Checklist

  • I understand the code I am submitting.
  • I have added unit tests that prove my fix/feature works
  • I have run this code locally and verified it fixes the issue.
  • New and existing tests pass locally
  • Documentation was updated where necessary
  • I have read and followed the contribution guidelines
  • AI Usage:
    • No AI was used.
    • AI was used for drafting/refactoring.
    • This is fully AI-generated.

AI Usage Information

  • AI Model used: Claude Opus 4.6
  • AI Developer Tool used: Claude Code
  • Any other info you'd like to share: AI did the mechanical sync→async conversion across ~20 source files and ~10 test files, designed the cas strategy, built the log writer abstraction, and wrote the k6 load test harness. The lockup diagnosis was done live in a running container by inspecting pg_stat_activity / pg_blocking_pids. All benchmarks and results are reproducible via ./tests/load/run_load_test.sh.

When answering questions by the reviewer, please respond yourself, do not copy/paste the reviewer comments into an AI system and paste back its answer. We want to discuss with you, not your AI :)

  • I am an AI Agent filling out this form (check box if true)

@brightsparc

Copy link
Copy Markdown
Author

@njbrake sorry for all the PR's but if you could prioritise reviewing this one, as it unblocks us from serving more than a limited number of users. Includes reproducible load testing with k6

@njbrake

njbrake commented Apr 6, 2026

Copy link
Copy Markdown
Member

@brightsparc sorry for the slow reply! Working on it

@njbrake njbrake left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review by @njbrake (assisted by Claude)

Hey @brightsparc, thanks for this. The diagnosis is spot-on and the benchmarks speak for themselves (391 -> 7,637 requests is compelling). The async migration across ~30 files is mechanically solid and the pluggable strategy design is clean.

That said, there are several issues that need addressing before we can merge. Splitting into must-fix and should-fix:

Must Fix

1. Test suite is broken: log_usage() calls missing log_writer parameter

log_usage now requires log_writer: LogWriter as its second positional argument, but 11 call sites across 6 test files were not updated:

  • tests/gateway/test_log_usage_commit_scope.py (4 calls)
  • tests/gateway/test_atomic_spend_update.py (2 calls)
  • tests/gateway/test_transaction_safety.py (2 calls)
  • tests/gateway/test_pricing_config.py (2 calls)
  • tests/gateway/test_timezone_consistency.py (1 call)

These will all raise TypeError at runtime.

2. Pool config values are dead code

GatewayConfig defines db_pool_size, db_max_overflow, db_pool_timeout, db_pool_recycle but init_db(database_url, auto_migrate) never receives them. They're never passed to create_async_engine. Either wire them through or remove them from this PR until they are.

3. CAS race condition: stale spend after losing reset race

In _validate_cas, after _claim_reset_cas returns False (another request won the race), the local user object still has the pre-reset spend value (expire_on_commit=False). _enforce_budget_limit then checks the stale value and can false-reject.

Scenario: user at spend=9.50 / max_budget=10.00, reset is overdue. Request A wins the CAS (spend -> 0.0). Request B loses but still sees spend=9.50, gets a 403 even though the budget was just reset.

Fix: await db.refresh(user) after losing the CAS race.

Relatedly, previous_spend captured for BudgetResetLog comes from the same stale read, so audit records may be inaccurate under concurrent load.

4. Unrelated changes need to be split out

The branch includes changes unrelated to the async migration:

  • Removal of dict-based response_format handling from gemini and xai providers + their tests
  • Removal of the "Mypy and Provider SDKs" section from AGENTS.md
  • Pre-commit config downgrades (ruff v0.15.8 -> v0.15.4, codespell v2.4.2 -> v2.4.1)
  • docs/package-lock.json version downgrade

These look like they came from the branch being forked from an older main. Please rebase onto current main or revert these changes so the PR only contains the async migration.

5. Docs reference nonexistent split strategy

In budget-management.md:

If you need user-blocked enforcement without budget checks, use split or cas...

There is no split strategy. Only for_update, cas, and disabled exist in code.

6. Large benchmark artifacts should not be committed

tests/load/results/ contains ~4,600 lines of CSV, JSON, and TXT files. These are point-in-time snapshots that will bloat the repo and never be usefully diffed. Please keep results/results.md (the human summary is great) but gitignore the raw data files.

Should Fix

7. health_readiness manual async generator management

The readiness endpoint manually advances get_db() with double anext calls and nested try blocks instead of using Depends(get_db) like every other endpoint. This works but is fragile. Consider using dependency injection here too, or at minimum await db_gen.aclose() for cleanup.

8. Strategy config values not validated

budget_strategy="foobar" silently falls through to for_update. Same for log_writer_strategy. A log warning on unrecognized values would prevent invisible misconfigurations.


Overall the architecture is the right call. Looking forward to landing this once the above is addressed.

@brightsparc
brightsparc force-pushed the julian/async-asyncpg branch from cf2991c to ad85bc3 Compare April 7, 2026 03:57
@brightsparc
brightsparc requested a review from njbrake April 7, 2026 04:14
@njbrake njbrake added the gateway Issues related to any-llm-gateway label Apr 7, 2026
@brightsparc

Copy link
Copy Markdown
Author

I’ve updated this from the base. Let me know if there are any more changes you would like to see before we can land @njbrake

@codecov

codecov Bot commented Apr 13, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 34.47433% with 268 lines in your changes missing coverage. Please review.

Files with missing lines Patch % Lines
src/any_llm/gateway/services/log_writer.py 30.09% 70 Missing and 2 partials ⚠️
src/any_llm/gateway/services/budget_service.py 13.69% 63 Missing ⚠️
src/any_llm/gateway/core/database.py 39.18% 38 Missing and 7 partials ⚠️
src/any_llm/gateway/api/routes/users.py 10.52% 17 Missing ⚠️
src/any_llm/gateway/api/routes/keys.py 12.50% 14 Missing ⚠️
src/any_llm/gateway/api/routes/budgets.py 13.33% 13 Missing ⚠️
src/any_llm/gateway/api/routes/pricing.py 16.66% 10 Missing ⚠️
src/any_llm/gateway/api/deps.py 45.45% 6 Missing ⚠️
src/any_llm/gateway/metrics.py 54.54% 5 Missing ⚠️
...c/any_llm/gateway/repositories/users_repository.py 37.50% 5 Missing ⚠️
... and 9 more
Files with missing lines Coverage Δ
src/any_llm/gateway/core/config.py 60.37% <100.00%> (+5.05%) ⬆️
src/any_llm/gateway/api/routes/health.py 63.63% <80.00%> (+21.70%) ⬆️
src/any_llm/gateway/api/routes/messages.py 48.31% <75.00%> (+0.58%) ⬆️
src/any_llm/gateway/main.py 79.03% <93.75%> (+1.67%) ⬆️
src/any_llm/gateway/services/bootstrap_service.py 40.74% <83.33%> (-47.73%) ⬇️
src/any_llm/gateway/api/routes/models.py 68.75% <50.00%> (+1.00%) ⬆️
src/any_llm/gateway/services/pricing_service.py 40.00% <60.00%> (+6.66%) ⬆️
src/any_llm/gateway/api/routes/chat.py 37.93% <57.14%> (+2.15%) ⬆️
...c/any_llm/gateway/services/pricing_init_service.py 30.30% <50.00%> (+2.17%) ⬆️
src/any_llm/gateway/api/routes/embeddings.py 37.68% <50.00%> (+6.03%) ⬆️
... and 10 more

... and 37 files with indirect coverage changes

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

gateway Issues related to any-llm-gateway

Projects

None yet

Development

Successfully merging this pull request may close these issues.

bug: Gateway locks up under concurrent requests: validate_user_budget holds SELECT FOR UPDATE across the entire LLM call

3 participants