Skip to content

perf: cut avoidable SQL round trips from every endpoint - #63

Merged
lesnik512 merged 2 commits into
mainfrom
perf/cut-round-trips
Oct 17, 2026
Merged

lesnik512 merged 2 commits into
mainfrom
perf/cut-round-trips

Conversation

@lesnik512

Copy link
Copy Markdown
Member

Database round trips dominate request time once Postgres is across a network. This counts the statements each endpoint sends and removes the ones advanced-alchemy adds without need, following fastapi-sqlalchemy-template#95 and litestar-sqlalchemy-template#72.

Counts

Measured with a before_cursor_execute listener on the test connection, savepoints excluded. Round trips (RT) add BEGIN and COMMIT per Transaction, since the asyncpg adapter sends each as its own round trip. Authenticated requests also run the revocation check on their own checkout.

Endpoint statements before after RT before after
POST /auth/register/ 2 1 4 3
POST /auth/logout/ 1 + 3 1 + 2 8 5
GET /auth/me/ 1 + 1 1 + 1 6 4
POST /chats/ direct, new 1 + 7 1 + 2 12 5
POST /chats/ direct, replay 1 + 3 1 + 2 8 5
POST /chats/ group of 3 1 + 10 1 + 2 15 5
GET /chats/ 1 + 2 1 + 1 7 4
GET /chats/{id}/ 1 + 3 1 + 1 8 4
POST /chats/{id}/read/ 1 + 3 1 + 2 8 5
POST messages, new 1 + 6 1 + 3 11 6
PATCH /messages/{id}/ 1 + 6 1 + 2 11 5
DELETE message, older 1 + 7 1 + 3 12 6
DELETE message, was last 1 + 12 1 + 3 17 6
DELETE message, already deleted 1 + 2 1 + 1 7 4

Login, message listing and message replays are unchanged apart from the cheaper revocation check. tests/api/test_statement_counts.py pins every count above.

Changes

  • Revocation check. CheckTokenRevokedUseCase gets Database.autocommit_transaction, a db_retry.Transaction opened at AUTOCOMMIT. Its single SELECT then goes out without BEGIN or COMMIT, so every authenticated request saves two round trips. The query is still inside the Transaction block, so ADR 0010's recovery still applies; the pooled connection-loss test covers it. The test fixture overrides Database.autocommit_isolation_level to None, because the asyncpg adapter rolls back an open transaction when its isolation level changes, and that would drop the outer test transaction. test_check_token_revoked.py checks against a real engine that asyncpg reports no open transaction during the query. The lost-connection-in-session case no longer includes the revocation check: that state cannot happen for it, since it always starts on a fresh session from its own child container, and with an existing connection SQLAlchemy ignores the isolation option and warns.
  • Creates. create(..., auto_refresh=False) for users and group chats drops the refresh SELECT, since the remaining columns have Python-side defaults. Members go in as one INSERT ... RETURNING, and the returned rows are attached with set_committed_value, so a new chat is not read back. A direct-chat replay loads members with joinedload and uniquify=True.
  • Chat reads. GET /chats/{id}/ loads the chat with its members in one query and checks membership in Python. A missing chat still returns 403. The listing loads last_message with joinedload.
  • Writes. advanced-alchemy's update(item_id=...) read the row twice and refreshed it after the flush. It is replaced by plain UPDATE statements, with RETURNING for edits. Updates to chats set updated_at themselves, because the audit listener does not run for core updates.
  • Delete. The repoint from ADR 0007 is now one conditional UPDATE chats SET last_message_id = (newest active message) WHERE id = :chat AND last_message_id = :deleted. That removes the chat read and the separate newest-message query, and the check and the write happen in one statement.
  • Authorization lookups. Edit and delete fetch the message together with an EXISTS membership flag. Mark-read fetches the member row together with an EXISTS for the target message. Error order is unchanged: 404, then 403 for membership, then 403 for authorship; for mark-read, 403 then 400.
  • Logout. Expired tokens are pruned with a plain DELETE instead of delete_where, which selected the rows first.

Testing

just test: 154 passed, coverage 100%. just lint is clean. The first commit adds the statement-count tests, and they fail on main.

@lesnik512
lesnik512 merged commit e27c2e2 into main Oct 17, 2026
4 checks passed
@lesnik512
lesnik512 deleted the perf/cut-round-trips branch October 17, 2026 11:39
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