Skip to content

fix(storage-postgres): apply a TransactWriteItems once when two sends share a ClientRequestToken - #873

Open
LeeroyHannigan wants to merge 1 commit into
fix/transact-index-key-orderfrom
fix/851-pg-token-race
Open

LeeroyHannigan wants to merge 1 commit into
fix/transact-index-key-orderfrom
fix/851-pg-token-race

Conversation

@LeeroyHannigan

Copy link
Copy Markdown
Collaborator

What

Stacked on #853, which changes the same function. Merge #853 first.

On PostgreSQL, two sends of one TransactWriteItems request with one ClientRequestToken at 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. A WITH query inserts the token row with INSERT ... ON CONFLICT DO UPDATE ... WHERE, and a UNION ALL select reads the stored row. Before this change, two sends at the same moment went through these steps:

  1. The insert of the second send waits on the token row of the first send, which is not committed yet.
  2. The first send commits. The insert locks the committed row. Its WHERE is false, so the insert returns no row.
  3. The select reads with the snapshot that the statement took before that commit. So it returns no row, or the cancelled: row that the committed row replaced.
  4. With no row, the function runs the request as a new one. With the cancelled: row, it answers IdempotentParameterMismatchException, 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:

Sequence Before After
Two clients send [Update cnt SET n = n + :one] with one fresh token at the same moment. 10 rounds, 5 runs Both sends OK in every round. n grew by 2 in 3, 8, 10, 10 and 10 of the 10 rounds Both sends OK, the second one as a replay. n grew by 1 in every round
A storage test sends the same request twice at once, after a cancellation with the token. 20 rounds, 5 runs One call answered the mismatch in 13 to 18 of the 20 rounds One call committed and the other answered the replay in every round

The docs row "Index key validation on UpdateItem and 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 answered OK, and the other answered TransactionInProgressException ("Transaction from previous request is still in progress"). In 2 rounds both sends answered OK. So when a send arrives while another send of the same request runs, it answers TransactionInProgressException, 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 TransactionInProgressException gets that replay on the retry.

Fixes #851.

Testing done

Two new storage tests in crates/storage-postgres/tests/twi_conflict.rs release two transact_write_items calls with one token through a barrier. Each test runs 20 rounds with a fresh token per round. In each round n must grow by 1. One call must commit, and the other must answer the replay or TransactionInProgress.

The new wire test test_two_sends_of_one_request_with_one_token_apply_it_once in tests/test_transact_write_conflict.py uses two clients with no retries and a barrier. It runs 10 rounds with a fresh token per round. In each round it requires that n grows by 1 and that each answer is OK or TransactionInProgressException. This change does not touch the code of SQLite, MongoDB and Cassandra, and their servers ran binaries built from the head of #853.

Server Runs Result
PostgreSQL, the head of #853 5 5 failed
PostgreSQL, this branch 5 5 passed
PostgreSQL, this branch with the second run removed 5 5 failed
SQLite, the head of #853 3 3 passed
MongoDB, the head of #853 3 3 passed
Cassandra, the head of #853 3 3 passed
Amazon DynamoDB, us-east-1, 5 November 2026 3 3 passed

On a PostgreSQL server of this branch:

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-targets and cargo +1.88.0 check --locked --workspace: ok.
  • cargo +1.97.0 test --release --locked -p extenddb-storage-postgres --tests against PostgreSQL 16: 340 passed, 2 ignored. The 3 tests of migrate_data_db_name need a password in the connection string, so they ran again with one.
  • twi_conflict, ten runs: 14 passed in each.

Checklist

  • I have read CONTRIBUTING.md
  • All tests pass (cargo test --workspace)
  • Code is formatted (cargo fmt --check)
  • Clippy is clean (cargo clippy -- -W clippy::pedantic)
  • I have added or updated tests for new functionality
  • I have updated documentation if behavior changed
  • Breaking changes are noted below (if any)
  • If this changes the wire protocol, Storage trait, auth model, on-disk
    format, 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.

…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 LeeroyHannigan added v1 Needed for the 1.0 release release-blocker Must ship for its milestone's release labels Nov 5, 2026

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

release-blocker Must ship for its milestone's release v1 Needed for the 1.0 release

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant