Repository navigation
perf: send a message with one INSERT ... ON CONFLICT DO UPDATE - #57
Merged
Merged
Conversation
The pre-check SELECT, the insert and the DuplicateKeyError recovery re-read become one statement. RETURNING xmax = 0 tells a new row from a replay, so last_message_id moves only when the message was created.
This was referenced Oct 17, 2026
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.
CreateMessageUseCaseused to make up to three trips to the database for a send: afetch_by_idempotency_keypre-check, the insert, and after aDuplicateKeyError, a rollback and a second fetch outside theTransactionblock, so outside the retried unit. A new message now costs one statement:The no-op update makes Postgres return the existing row on a replay. A request racing an uncommitted insert with the same key waits on that row's lock and then gets the winner's row.
xmax = 0is true only for a row version this statement inserted, which gives the endpoint its 201 or 200 and limits thechats.last_message_idupdate to new messages. The membership check, the upsert and that update stay inside oneTransaction. This followsDecksRepository.get_or_createfrom modern-python/fastapi-sqlalchemy-template#90.Checked against Postgres 17.11 by hand
xmax = 0FOR KEY SHAREon the rowPostgres does not document
xmaxinRETURNING, so the tests below are what would notice a change.Tests
test_send_writes_the_message_with_a_single_statement: this was the red test. Onmaina send issues three statements that touchmessages: the pre-check, the insert, and advanced-alchemy's refresh SELECT.test_send_returns_the_message_a_concurrent_request_created_with_the_same_key: a second real connection holds an uncommitted insert, the use case blocks on its lock (the test pollspg_stat_activityforwait_event_type = 'Lock'), and after the commit the use case returns the first row withcreated=Falseand leaveslast_message_idasNULL. It commits real rows and deletes them infinally, liketests/test_create_deck.pyin the template. It also passes onmain, because the old recovery path handled this case, so it guards against regressions rather than proving the change. It passed 8 runs in a row locally._RacingMessagesRepository,_NeverFoundMessagesRepositoryand their two tests. The code they covered is gone, andfetch_by_idempotency_keyhad no other caller.Trade-off worth a look
Every replay now writes a new row version and consumes a sequence value, so each retried send leaves a gap in
messages.id. ADR 0001 relies on ids for client gap detection, so a client sees a gap and makes one catch-up fetch that comes back empty. Sequences already leave gaps on rollbacks; this adds one per idempotent retry. The replay row write also fires any UPDATE trigger, andmessageshas none.Docs
ON CONFLICT DO NOTHING(the loser gets no row back), and advanced-alchemy'supsert(which reads first).CreateChatUseCaseonly and points to 0012. ADRs 0006 and 0009 and the test-pattern note in AGENTS.md drop their references to the removed message recovery path.CreateChatUseCasestill uses the pre-check plus recovery for direct chats. Moving it over is harder because it also inserts members, so it is left for a separate change.Verification
ruff format,ruff check,ty check, the full suite (141 passed, 100% coverage),tests/migrations, andjust adr-check, all run locally against Postgres 17.