Skip to content

perf: check token revocation without BEGIN and COMMIT round trips - #55

Closed
lesnik512 wants to merge 1 commit into
mainfrom
perf/revocation-autocommit
Closed

lesnik512 wants to merge 1 commit into
mainfrom
perf/revocation-autocommit

Conversation

@lesnik512

Copy link
Copy Markdown
Member

Every authenticated request checks its token against revoked_tokens before the handler runs. Until now that check ran inside an ordinary transaction, so it sent BEGIN, the SELECT and COMMIT: three round trips to Postgres for a single-row lookup. This PR runs the check with isolation_level="AUTOCOMMIT", which SQLAlchemy's asyncpg adapter turns into no BEGIN and no COMMIT. The check now costs one round trip.

Change

  • app/database/resources.py: create_autocommit_transaction returns a db-retry Transaction with AUTOCOMMIT when the session is bound to an engine and has not started a transaction yet. Otherwise it returns a plain Transaction. SQLAlchemy only applies an isolation level before the session holds a connection. Test sessions are bound to a connection that is already inside the outer test transaction, so they keep the savepoint behaviour they have today.
  • app/ioc.py: a new Database.autocommit_transaction provider (bound_type=None, so the type-based wiring of Transaction elsewhere is unchanged), wired into check_token_revoked_use_case only.
  • tests/use_cases/test_check_token_revoked.py: runs the check against a real engine with an asyncpg query logger on the pooled connection and asserts no BEGIN, COMMIT or ROLLBACK is sent. It fails on main with ['BEGIN;', 'COMMIT;'].

postgres_retry still wraps the check. The existing connection-loss tests for it pass, including the case where the pooled connection died while idle.

Why not one checkout per request

The original idea was to let the revocation check share the handler's request container so the request takes one pool checkout instead of two. That doesn't work, for two reasons:

  • modern-di-litestar builds the request container in the di_container Litestar dependency, which resolves only when the handler runs. The JWT auth middleware calls revoked_token_handler before that.
  • db-retry's Transaction.__aexit__ calls session.close(), which returns the connection to the pool. Checkouts follow transactions, so two use cases in one request take two checkouts even with a shared session.

One checkout would need the revocation read inside every use case's transaction, or no database read for revocation at all (a cache, which would delay logout taking effect). Removing the transaction round trips keeps the current semantics.

Measurements

The app, Postgres 17 and an httpx load generator ran as containers on one Docker network (Apple M2, Docker Desktop). Pool settings were the defaults (pool_size=5, pre-ping off). Each run had a 5 s warm-up and a 10 s window. Main and branch alternated in each round, with the API restarted at each switch.

Local network, 3 rounds each:

endpoint clients main req/s branch req/s main p50 branch p50
GET /api/auth/me/ 1 720 / 732 / 779 740 / 794 / 783 1.30 ms 1.25 ms
GET /api/auth/me/ 20 1168 / 1201 / 1218 1162 / 1227 / 1223 15.77 ms 15.21 ms
GET /api/chats/{id}/messages/ 1 522 / 380 / 505 567 / 534 / 545 1.95 ms 1.75 ms
GET /api/chats/{id}/messages/ 20 730 / 669 / 804 853 / 824 / 861 24.91 ms 22.36 ms

On a local network a round trip costs about 20 µs, so the gain is small. The me results are within run-to-run noise. The message list improves in every round, by 8% at 1 client and 17% at 20.

With tc qdisc add dev eth0 root netem delay 1ms in the db container (measured SELECT 1 round trip: 1.25 ms min, 3.0 ms p50), 2 rounds each:

endpoint clients main req/s branch req/s main p50 branch p50
GET /api/auth/me/ 1 41 / 39 53 / 61 25.19 ms 17.03 ms
GET /api/auth/me/ 20 388 / 375 658 / 680 52.05 ms 29.36 ms
GET /api/chats/{id}/messages/ 1 32 / 31 42 / 41 30.83 ms 23.82 ms
GET /api/chats/{id}/messages/ 20 296 / 324 525 / 515 61.38 ms 37.24 ms

At 1 client, p50 drops by 7 to 8 ms, about two round trips at that latency. At 20 clients throughput rises 68 to 75%, because each request holds a connection from the five-connection pool for less time.

Tests

just test: 142 passed, coverage 100%. just lint is clean.

@lesnik512 lesnik512 closed this Oct 16, 2026
@lesnik512
lesnik512 deleted the perf/revocation-autocommit branch October 16, 2026 18:54
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