Repository navigation
fix(storage-postgres): apply a TransactWriteItems once when two sends share a ClientRequestToken - #873
Open
LeeroyHannigan wants to merge 1 commit into
Open
fix(storage-postgres): apply a TransactWriteItems once when two sends share a ClientRequestToken#873LeeroyHannigan wants to merge 1 commit into
LeeroyHannigan wants to merge 1 commit into
Conversation
…re a token On PostgreSQL, two sends of one TransactWriteItems request with one ClientRequestToken at the same moment could both apply the request (#851). The token check is one statement. It inserts the token row with INSERT ... ON CONFLICT DO UPDATE ... WHERE in a WITH query, and it selects the stored row with UNION ALL. The second send's insert waited on the first send's uncommitted token row. When the first send committed, the insert locked the committed row. Its WHERE was false, so it returned no row. The select read with the snapshot that the statement took before that commit, and it returned no row either. The request then ran as a new one. On a PostgreSQL server built from the parent commit, two clients sent [Update cnt SET n = n + :one] with one fresh token in 10 rounds. Both sends answered OK in every round, and n grew by 2 in 3, 8, 10, 10 and 10 rounds of five runs. The same stale read gave the wrong answer when the token was stored as cancelled. One of two sends of the same request took the cancelled row and committed. The other read the cancelled row with its older snapshot and answered IdempotentParameterMismatchException, the answer for a different request. In both cases the select read an older version of the row than the one the insert locked. The insert's WHERE is false on the locked row, so that row is less than 10 minutes old and is not this request's cancelled row. A select that returns no row, or this request's cancelled row, has therefore read an older version. For these two results the check now runs the statement once more. At READ COMMITTED the second statement takes a new snapshot, which holds the locked row. The first statement keeps that row locked until the transaction ends, so the second statement does not wait, and it reads the same row. A committed row of the request answers as a replay. A row of another request answers the mismatch. Amazon DynamoDB applied such a request once in each of 20 rounds (us-east-1, 2026-11-05, retries off). In 18 rounds one send answered OK and the other TransactionInProgressException. In 2 rounds both sends answered OK. So the second send answers TransactionInProgressException, or waits and answers as the first send did. The PostgreSQL send has waited for the commit when it reads the row, so it answers the replay. A client that retries TransactionInProgressException gets the same replay on its retry. Two storage tests in twi_conflict.rs send a request twice at the same moment, released by a barrier. Each test runs 20 rounds with a fresh token per round. One test sends the request alone. The other first sends it with the token while its condition fails. In each round n grows by 1, one send commits, and the other answers the replay or TransactionInProgress. On the parent commit, 13 to 18 of the 20 rounds failed in each of five runs of each test. A wire test in test_transact_write_conflict.py sends the request from two clients in 10 rounds. It requires that n grows by 1 and that each answer is OK or TransactionInProgressException, on every backend and against the service. The docs row on index key validation no longer lists the PostgreSQL difference. Fixes #851.
LeeroyHannigan
requested review from
amrith,
c33howard,
eliseong,
hitesh96db,
jcshepherd,
navareamol,
pdf-amzn and
robinnsc
as code owners
November 5, 2026 19:44
This branch has not been deployed
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.
What
Stacked on #853, which changes the same function. Merge #853 first.
On PostgreSQL, two sends of one
TransactWriteItemsrequest with oneClientRequestTokenat the same moment now apply the request once. The second send answers as a replay of the first.check_idempotency_token_in_tx(crates/storage-postgres/src/data/tx_helpers.rs) runs one statement. AWITHquery inserts the token row withINSERT ... ON CONFLICT DO UPDATE ... WHERE, and aUNION ALLselect reads the stored row. Before this change, two sends at the same moment went through these steps:WHEREis false, so the insert returns no row.cancelled:row that the committed row replaced.cancelled:row, it answersIdempotentParameterMismatchException, the answer for a different request.The statement is now in
run_token_statement, unchanged. For these two results, the function runs the statement once more. At READ COMMITTED the second statement takes a new snapshot, which holds the locked row. The first statement keeps that row locked until the transaction ends. So the second statement does not wait, and it reads the same row. A committed row of the request gets the replay. A row of another request gets the mismatch. The function runs the statement two times at most.The answers on PostgreSQL, before (the head of #853) and after:
[Update cnt SET n = n + :one]with one fresh token at the same moment. 10 rounds, 5 runsOKin every round.ngrew by 2 in 3, 8, 10, 10 and 10 of the 10 roundsOK, the second one as a replay.ngrew by 1 in every roundThe docs row "Index key validation on
UpdateItemand transaction writes" no longer lists this PostgreSQL difference. The code of SQLite, MongoDB and Cassandra does not change.Why
On PostgreSQL, two sends of one request with one token at the same moment could apply the request two times (#851).
On 5 November 2026, two clients sent
[Update cnt SET n = n + :one]to Amazon DynamoDB in us-east-1, with retries off. In each of 20 rounds, both clients sent it with one fresh token at the same moment. The service applied the request once in each round. In 18 rounds, one send answeredOK, and the other answeredTransactionInProgressException("Transaction from previous request is still in progress"). In 2 rounds both sends answeredOK. So when a send arrives while another send of the same request runs, it answersTransactionInProgressException, or it waits and answers as that send did.The PostgreSQL send waits on the row lock until the first send commits. When it reads the row, the first send is complete, and it answers as the first send did, with the replay. A client that retries
TransactionInProgressExceptiongets that replay on the retry.Fixes #851.
Testing done
Two new storage tests in
crates/storage-postgres/tests/twi_conflict.rsrelease twotransact_write_itemscalls with one token through a barrier. Each test runs 20 rounds with a fresh token per round. In each roundnmust grow by 1. One call must commit, and the other must answer the replay orTransactionInProgress.two_sends_of_one_request_with_one_token_apply_it_oncesends[Update cnt SET n = n + :one]. On the head of fix(transact): rank index-key faults and handle the ClientRequestToken as the service does #853 it failed in 5 of 5 runs, with 14 to 18 failing rounds per run. In each failing round both calls committed, andngrew by 2.two_sends_that_run_a_cancelled_request_again_apply_it_oncefirst sends the request with the token while its condition fails. Then it sends the request twice at once. On the head of fix(transact): rank index-key faults and handle the ClientRequestToken as the service does #853 it failed in 5 of 5 runs, with 13 to 18 failing rounds per run. In each failing round one call committed, and the other answered the mismatch.The new wire test
test_two_sends_of_one_request_with_one_token_apply_it_onceintests/test_transact_write_conflict.pyuses two clients with no retries and a barrier. It runs 10 rounds with a fresh token per round. In each round it requires thatngrows by 1 and that each answer isOKorTransactionInProgressException. This change does not touch the code of SQLite, MongoDB and Cassandra, and their servers ran binaries built from the head of #853.On a PostgreSQL server of this branch:
tests/test_transact_index_key_token.py,tests/test_transact_index_key_cross_member.py,tests/test_index_key_validation_on_update.py,tests/test_idempotency_account_scope.pyandtests/test_transaction_operations.py: 211 passed, 1 xfailed (TransactWriteItems token replay ignores ReturnConsumedCapacity and ReturnItemCollectionMetrics #514).tests/(26 files), 880 passed, 6 skipped and 1 xfailed. Intests/python/(7 files), 154 passed.Rust, on this branch:
cargo +1.97.0 fmt --all --check: clean.cargo +1.97.0 clippy --locked --workspace --all-targets -- -D warnings: clean.cargo +1.97.0 check --locked --workspace --all-targetsandcargo +1.88.0 check --locked --workspace: ok.cargo +1.97.0 test --release --locked -p extenddb-storage-postgres --testsagainst PostgreSQL 16: 340 passed, 2 ignored. The 3 tests ofmigrate_data_db_nameneed a password in the connection string, so they ran again with one.twi_conflict, ten runs: 14 passed in each.Checklist
cargo test --workspace)cargo fmt --check)cargo clippy -- -W clippy::pedantic)Storagetrait, auth model, on-diskformat, or public CLI surface, an RFC has been accepted or is linked
below. Otherwise, an ADR captures the decision (link below).
ADR / RFC: n/a. The token store keeps its schema, and PostgreSQL now gives an answer that DynamoDB gives.
By submitting this pull request, I confirm that my contribution is made under
the terms of the Apache License 2.0 and I agree to the Developer Certificate of
Origin (DCO). See CONTRIBUTING.md for details.