Skip to content

Cut round trips on deck detail, deck update and card upsert - #95

Merged
lesnik512 merged 1 commit into
mainfrom
perf/fewer-round-trips
Oct 17, 2026
Merged

lesnik512 merged 1 commit into
mainfrom
perf/fewer-round-trips

Conversation

@lesnik512

Copy link
Copy Markdown
Member

Three endpoints send fewer statements to Postgres.

endpoint before after
GET /api/decks/{id}/ deck, then cards (selectinload) one query (joinedload)
PUT /api/decks/{id}/ BEGIN, SELECT, SELECT, UPDATE, SELECT, COMMIT BEGIN, UPDATE ... RETURNING, COMMIT
PUT /api/decks/{id}/cards/ BEGIN, SELECT, UPDATE per card (one pipelined executemany), SELECT, COMMIT BEGIN, INSERT ... ON CONFLICT (id) DO UPDATE ... RETURNING, COMMIT

advanced-alchemy's update(item_id=...) fetches the row in the service, fetches it again in the repository, and refreshes it after the flush. upsert_many selects the existing rows first and refreshes them afterwards. Both repository methods now build the statement themselves, the way get_or_create already did.

Numbers

In-process (httpx ASGITransport), sequential requests, best of 9 runs of 300 requests (100 with added latency), Postgres 17 in Docker. "1 ms" adds tc netem delay 1ms on the db container. On this host one round trip then costs about 3.5 to 4 ms, so treat the ratio as what carries over, not the absolute values. Microseconds per request:

endpoint local before local after 1 ms before 1 ms after
GET /api/decks/{id}/ 1158 951 9998 4528
PUT /api/decks/{id}/ 2677 2033 25662 13580
PUT /api/decks/{id}/cards/ (10 cards) 3157 3136 24119 16439
GET /api/cards/{id}/ (untouched, control) 497 527 4014 3851

Concurrent throughput through Granian was too noisy on this machine to report.

Behaviour

  • PUT /api/decks/{id}/ now always writes and moves updated_at. advanced-alchemy skipped the UPDATE when the values were unchanged.
  • Card upsert sets updated_at explicitly, because onupdate does not apply to ON CONFLICT DO UPDATE. It runs inside wrap_sqlalchemy_exception, so a duplicate front is still a DuplicateKeyError (422). An empty list still returns no cards.
  • On the read engine, which runs in AUTOCOMMIT, the deck and its cards now come from one snapshot.
  • Unchanged, as before: a card id that doesn't exist is inserted with that id, and a card id from another deck is moved to this one.

Tests

A statements fixture records what each request sends on the test connection, without savepoints. Three tests assert one statement for the endpoints above; they failed before the change with 2, 4 and 3. Guard tests, which pass before and after: updated_at moves on both updates, an empty card list, and a duplicate front. AGENTS.md describes the new write path and drops the fetch_with_cards caveat.

Deck detail loads cards with joinedload (one query instead of two). Deck
update is one UPDATE ... RETURNING instead of advanced-alchemy's update,
which read the row twice and refreshed it. Card upsert is one INSERT ...
ON CONFLICT (id) DO UPDATE ... RETURNING instead of upsert_many, which
selected the rows before and after writing them.
@lesnik512
lesnik512 merged commit c70a2e3 into main Oct 17, 2026
3 checks passed
@lesnik512
lesnik512 deleted the perf/fewer-round-trips branch October 17, 2026 10:33
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