Skip to content

fix(engine): compare the Return fields on a TransactWriteItems token replay - #526

Open
LeeroyHannigan wants to merge 10 commits into
mainfrom
fix/transact-token-return-fields
Open

LeeroyHannigan wants to merge 10 commits into
mainfrom
fix/transact-token-return-fields

Conversation

@LeeroyHannigan

@LeeroyHannigan LeeroyHannigan commented Oct 24, 2026 •

Copy link
Copy Markdown
Collaborator

What

compute_fingerprint in crates/engine/src/transact_write_helpers.rs now covers ReturnConsumedCapacity and ReturnItemCollectionMetrics. It stays an HMAC-SHA256 keyed by the token over the TransactItems JSON. When ReturnConsumedCapacity is TOTAL or INDEXES, or ReturnItemCollectionMetrics is SIZE, the field name and value follow the items in the hashed input. A request with neither field, or with both set to NONE, hashes the same bytes as before.

The mismatch message is now the service's: Specified idempotent token was used with different request parameters within the idempotency window. The text is the constant IDEMPOTENT_MISMATCH_MESSAGE in crates/engine/src/transact_write_helpers.rs, and the handler in crates/engine/src/transact_write_items.rs uses it. The four storage crates return StorageError::IdempotentMismatch without text. This change does not touch them. remap_write_error in crates/engine/src/partiql/transaction.rs no longer rewrites that error, because both surfaces carry the same text.

The branch carries a merge of main at 5ada959 (628a7c6). On main, crates/engine/src/partiql/transaction.rs builds a TransactWriteItems body from the statements of an ExecuteTransaction write. It copies the request's ClientRequestToken and ReturnConsumedCapacity into that body and calls handle_transact_write_items. So the fingerprint decides ExecuteTransaction replays too. An ExecuteTransaction retry that keeps the token and the statements but changes ReturnConsumedCapacity now answers the mismatch. Main serves it as a replay. The service answers the mismatch (2026-11-10 table below).

All four backends replay a retry only when its fingerprint equals the stored one. Versions before this change hash the items alone. A token stored before an upgrade or a downgrade therefore answers as follows while the backend keeps it.

  • A token for a request with neither field, or with both set to NONE, has the same fingerprint in both versions. An identical retry replays in both directions.
  • A token that an earlier version stored for a request with TOTAL, INDEXES or SIZE answers IdempotentParameterMismatchException to an identical retry on this version. A retry without the field replays.
  • A token that this version stored for such a request answers IdempotentParameterMismatchException to every retry on an earlier version. This includes an identical retry and a retry without the field.

Runs with two binaries at 386a775 checked the first two cases on SQLite and PostgreSQL. A main binary wrote the tokens, and this branch served the same database. The third case follows from the code and the pinned fingerprint values below. No run with two binaries covered it.

PostgreSQL, SQLite and MongoDB keep a token for 10 minutes. The Cassandra backend stores a token without an expiry and does not check its age. There a later retry gets these answers at any time. The compute_fingerprint comment states the three cases and the lifetimes. docs/manuals/07-upgrade-manual.md gets a v0.1.15 entry with the same content. The entry also covers servers of both releases that share one database during a rolling upgrade. v0.1.14 does not serve ExecuteTransaction, so no token that v0.1.14 stored comes from one.

docs/design/04-component-storage.md section 9 now states what the fingerprint hashes. It also states that ExtendDB translates an ExecuteTransaction write into a TransactWriteItems request before it computes the fingerprint. docs/differences-from-dynamodb.md gets a Transactions section with the replay gaps listed under "Not changed here".

Why

TransactWriteItems decides whether a request with a known ClientRequestToken is a replay by comparing a fingerprint of the request. The fingerprint covered TransactItems only. So ExtendDB served a second request that kept the token and the items but changed ReturnConsumedCapacity or ReturnItemCollectionMetrics as a replay. The service answers IdempotentParameterMismatchException for that request and treats an absent field as NONE.

Fixes #514.

Measured on the service

us-east-1, 2026-10-24 17:31 UTC and 17:36 UTC, client retries off. First call: a token and one Update (SET v = :v) on a fresh key. The replay keeps the token and the items.

first call replay answer to the replay
no Return fields the same 200 {}
none ReturnConsumedCapacity: TOTAL 400 IdempotentParameterMismatchException
none ReturnConsumedCapacity: NONE 200 {}
none ReturnItemCollectionMetrics: SIZE 400 mismatch
none ReturnItemCollectionMetrics: NONE 200 {}
TOTAL none 400 mismatch
TOTAL INDEXES 400 mismatch
INDEXES TOTAL 400 mismatch
SIZE NONE 400 mismatch
none TOTAL and SIZE 400 mismatch
none different items and TOTAL 400 mismatch
TOTAL TOTAL 200, ConsumedCapacity: [{CapacityUnits: 2.0, ReadCapacityUnits: 2.0, TableName}]
INDEXES INDEXES 200, the same plus Table: {CapacityUnits: 2.0, ReadCapacityUnits: 2.0}
SIZE SIZE 200 {}, no ItemCollectionMetrics
none ReturnConsumedCapacity: total 400 ValidationException, Value 'total' at 'returnConsumedCapacity' failed to satisfy constraint: Member must satisfy enum value set: [INDEXES, TOTAL, NONE]
none ReturnConsumedCapacity: BOGUS with different items the same ValidationException: enum validation runs before the token comparison

The mismatch rows carry the message quoted above. The SIZE and SIZE row describes a table without a local secondary index, the table kind the test file uses. ExtendDB already parses the request before it computes the fingerprint. It already renders a matching replay as a transactional read with no ItemCollectionMetrics. So those rows passed on main and pin behaviour the change keeps.

us-east-1, 2026-11-10, table keyed on pk with no local secondary index. Each case ran twice with a new token and new keys, and gave the same answer both times. The second call keeps the token. TransactWriteItems cases sent one Put (19:02 UTC and 20:01 UTC). Four of them ran again with one Update (SET a = :v, 20:22 UTC) and gave the same answers. ExecuteTransaction cases sent one INSERT statement (19:03 UTC).

operation first call second call answer to the second call
TransactWriteItems, Put and Update TOTAL TOTAL, another item 400 mismatch, no item under the second key
TransactWriteItems, Put and Update SIZE SIZE, another item 400 mismatch, no item under the second key
TransactWriteItems, Put and Update none none, another item 400 mismatch, no item under the second key
TransactWriteItems, Put and Update TOTAL TOTAL, the same item 200, ConsumedCapacity: [{CapacityUnits: 2.0, ReadCapacityUnits: 2.0, TableName}]
TransactWriteItems, Put none none, the same attributes in another key order 200 {}
ExecuteTransaction TOTAL none 400 mismatch
ExecuteTransaction none TOTAL 400 mismatch
ExecuteTransaction TOTAL INDEXES 400 mismatch
ExecuteTransaction none none 200 {"Responses": []}
ExecuteTransaction TOTAL TOTAL, another statement 400 mismatch, no item under the second key

The mismatch rows carry the same message. Every first call answered 200. With TOTAL the answer carried ConsumedCapacity: [{CapacityUnits: 2.0, WriteCapacityUnits: 2.0, TableName}], with SIZE it was {"ItemCollectionMetrics": {}}, and with no field {}. ExecuteTransaction adds Responses: []. A TransactWriteItems request with an unknown top-level field ("Bogus": 1), sent as a raw signed request with a new token, answered 200 {} and wrote the item (two sends).

Testing done

Tests

  • Unit tests in crates/engine/src/transact_write_helpers.rs, 10 fingerprint tests and 4 token tests (cargo +1.97.0 test --locked -p extenddb-engine transact_write, 14 passed at 6fabc1c). The fingerprint for a body with neither field equals ef18f4e7c37d6b12, the value from 744ea29. NONE on both fields equals absent. TOTAL, INDEXES and SIZE change the fingerprint and differ from one another. Different items give different fingerprints with NONE, with TOTAL and with SIZE. A test pins the values for pinned_body() and token tok-1: 8fa51ffc1314a046 (TOTAL), ee2c3412f0fdbdf1 (INDEXES), 70eb830c93e9079f (SIZE) and e9957d3c7431e1d4 (TOTAL with SIZE). An HMAC-SHA256 computed outside the crate gives the same values.
  • tests/test_transact_token_return_fields.py, dual target: one test per row of the 2026-10-24 table, plus test_changed_items_with_the_same_return_field_is_a_mismatch with rcc-total and ricm-size from the 2026-11-10 table. test_changed_items_alone_is_a_mismatch reads the second key back and expects no item. _fresh() now returns (pk, token), the order every caller unpacks. On main (744ea29, SQLite) 9 of the 21 tests at 386a775 fail. Main serves the seven changed-field rows as a replay, and the two item-mismatch rows carry the old message. At 386a775 all 21 pass on SQLite, PostgreSQL, MongoDB and Cassandra. At 6fabc1c all 23 pass against a dev-mode SQLite server built from 6fabc1c.
  • tests/test_partiql_transaction.py, four new tests after T5: test_idempotent_replay_with_another_return_consumed_capacity_is_a_mismatch with total-then-absent, absent-then-total and total-then-indexes, and test_idempotent_replay_without_return_consumed_capacity. Each case has its own key and token. At 6fabc1c the file gives 201 passed, 2 xfailed on the same SQLite server. The two xfails are the existing ones (TransactWriteItems reports no index units).
  • Mutation controls, with the Rust code at 7c6edc8. No Rust statement or test assertion changed between 7c6edc8 and 6fabc1c: the later commits change a doc comment, a docstring and docs.
    • Items hashed only when ReturnConsumedCapacity is NONE: fingerprint_differs_for_different_ops_with_total and the pin test fail (12 passed, 2 failed), and the wire test [rcc-total] fails with DID NOT RAISE ClientError.
    • Items hashed only when ReturnItemCollectionMetrics is NONE: fingerprint_differs_for_different_ops_with_size and the pin test fail, and the wire test [ricm-size] fails the same way.
    • The two suffixes in swapped order: the pin test fails at TOTAL with SIZE (13 passed, 1 failed).
    • Both suffixes removed, the fingerprint of 744ea29 and of main: 3 unit tests fail (the two differs_for_return_* tests and the pin test). 10 wire tests fail with DID NOT RAISE ClientError, the three new ExecuteTransaction mismatch cases and the seven changed-field rows. test_idempotent_replay_without_return_consumed_capacity passes, as a replay test should in both builds. The read-back in test_changed_items_alone_is_a_mismatch passes in every build, since none of them writes the second item on a mismatch.

Gates

At 386a775, before the merge of main:

  • cargo +1.97.0 fmt --all -- --check: clean.
  • cargo +1.97.0 clippy --all-targets -- -D warnings: 0 warnings.
  • cargo test --workspace --exclude extenddb-storage-cassandra on stable: 1771 passed, 0 failed, 26 ignored. cargo test -p extenddb-storage-cassandra --lib: 61 passed. The same two commands on 1.88.0: 1771 passed and 61 passed, 0 failed.
  • Wire tests, filter idempotency or transact or transaction (15 files): 142 passed, 2 skipped on SQLite and on PostgreSQL.

At 6fabc1c:

  • cargo +1.97.0 fmt --all -- --check: clean.
  • cargo +1.97.0 clippy --locked --all-targets --features extenddb-storage-cassandra/test-hooks,extenddb-storage-mongodb/test-hooks -- -D warnings: clean, 13 workspace crates. cargo +1.97.0 clippy --locked --all-targets --features extenddb-engine/backup-test-hooks -- -D warnings: clean.
  • cargo +1.97.0 test --locked -p extenddb-engine transact_write: 14 passed. cargo +1.97.0 test --locked -p extenddb-engine partiql::transaction: 10 passed.
  • Wire, against a dev-mode SQLite server built from 6fabc1c: tests/test_transact_token_return_fields.py 23 passed, tests/test_partiql_transaction.py 201 passed, 2 xfailed.
  • Cargo.lock: the branch's own commits change no line of it. The merge of main brings main's changes.
  • CI at 6fabc1c: 57 of 57 checks passed.

Not changed here

  • ReturnItemCollectionMetrics: SIZE on a first call against a table with no local secondary index: the service answers {"ItemCollectionMetrics": {}} (measured 2026-11-10). ExtendDB omits the key (measured on SQLite at this branch). Present on main. The test file does not assert the first-call body for that row.
  • The fingerprint still hashes the TransactItems JSON as written, where the service compares the parsed request. Measured on 2026-10-24, the service replays a retry whose only difference is the spelling of a number (1 and 1.0) or the spacing of an expression. It also replays a retry that names an attribute through an ExpressionAttributeNames alias, or adds ReturnValuesOnConditionCheckFailure: NONE inside an item. ExtendDB answers each with a mismatch, on main and here (measured on SQLite at this branch). In a build with the MongoDB backend, serde_json keeps map keys in request order. So there a retry whose item keys are in another order also answers a mismatch (from the code). The service replays that retry (2026-11-10 table). A build without that backend writes map keys in sorted order and replays it too (from the code).
  • An unknown top-level field: the service ignores it and writes the item (raw signed request, 2026-11-10). ExtendDB refuses the request with SerializationException, on main and here (measured on SQLite at this branch).
  • The new Transactions section of docs/differences-from-dynamodb.md records the rows above with these dates.
  • The Cassandra backend stores a token without an expiry (from the code). Its insert has no USING TTL, its token table has no default TTL, and its read does not check the age. Present on main.
  • Merge check at 386a775: git merge-tree --write-tree against fix(expressions): validate in the service's order, with its messages #399, fix(limits): attribute-name size limit on every write path #433 and fix(query): check legacy conditions against the key schema #507 was clean, and the three merged trees had the single four-argument compute_fingerprint call. fix(limits): attribute-name size limit on every write path #433 is in main since 2026-11-03 and in this branch through the merge.

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).

Clippy ran with -D warnings, without the pedantic group.

ADR / RFC: n/a


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.

…ngerprint

The idempotency fingerprint hashed TransactItems only, so a replay that
changed ReturnConsumedCapacity or ReturnItemCollectionMetrics was served
as a replay. The service answers IdempotentParameterMismatchException for
such a request and treats an absent field as NONE.

Hash the two fields after the items when they are not NONE. A request with
neither field, or with both NONE, keeps the value stored by earlier
versions, pinned by a unit test. Use the service's mismatch message.
Cover each measured answer: a changed ReturnConsumedCapacity or
ReturnItemCollectionMetrics on a token replay is a mismatch with the
service's message, NONE equals absent, the matching replay reports read
capacity and no ItemCollectionMetrics, and an invalid enum value is
refused before the token comparison.

@demohan demohan left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The fix matches the service on every TransactWriteItems shape we measured. We compared 288 replay pairs and the extras in us-east-1 on 10 November 2026, two sends each, on all four backends. Against the merge-base there were no regressions. The new test file passes against DynamoDB on two runs.

These items remain:

  • The upgrade note covers one direction. A downgrade within the window refuses identical TOTAL, INDEXES and SIZE retries, and also a no-field retry of a TOTAL first call. Inline at transact_write_helpers.rs:271.
  • On main, partiql/transaction.rs passes ClientRequestToken and ReturnConsumedCapacity into handle_transact_write_items, so this change also decides ExecuteTransaction replays. This comes from main moving under the branch. Measured: the service refuses a replay that changes ReturnConsumedCapacity (none to TOTAL, TOTAL to none, TOTAL to INDEXES: IdempotentParameterMismatchException, two sends each), and the merged tree matches. Main without this change serves those as 200. After the rebase, add ExecuteTransaction replay tests for first TOTAL then absent, and first absent then TOTAL.
  • Five items in the tests and docstrings, inline.

/// fields on a token replay and treats an absent field as `NONE`, so each
/// field contributes to the hash only when it is not `NONE`. A request with
/// neither field, or with both set to `NONE`, hashes the items alone, which
/// is the value stored by earlier versions; tokens stored before this change

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This holds only for a request with neither field. With the merge-base writing tokens and this branch serving the same SQLite file, identical TOTAL, INDEXES and SIZE retries answer IdempotentParameterMismatchException, as the PR body says. In the other direction, with this branch writing and the merge-base retrying, the same three retries answer mismatch, and so does a no-field retry of a TOTAL first call. Please make the comment say which tokens keep replaying, and add the downgrade direction to the description.

}

#[test]
fn fingerprint_differs_for_different_ops() {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This case uses NONE only. A mutation that hashes the items only when ReturnConsumedCapacity is NONE passes all 7 unit tests and all 21 wire tests. In that build, a retry with TOTAL and different items is served as a replay that writes nothing. Measured: the service answers that retry with IdempotentParameterMismatchException and stores nothing, for TOTAL and for SIZE, two sends each. Please add the same check with ReturnConsumedCapacity::Total.

/// this body and token. A request with neither Return field, or with both
/// set to NONE, must keep producing it so tokens stored before the upgrade
/// still replay.
const PINNED_ITEMS_ONLY: &str = "ef18f4e7c37d6b12";

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Tokens stored with TOTAL, INDEXES or SIZE have the same upgrade exposure as the items-only value pinned here. Swapping the order of the two suffixes passes every test. Pinning the suffixed values for pinned_body() and tok-1 would catch that: TOTAL 8fa51ffc1314a046, INDEXES ee2c3412f0fdbdf1, SIZE 70eb830c93e9079f, TOTAL with SIZE e9957d3c7431e1d4 (HMAC-SHA256 recomputed outside the crate; the items-only value matches ef18f4e7c37d6b12).


A replay with matching fields answers 200 and reports the replay as a
transactional read (ReadCapacityUnits, no WriteCapacityUnits) when
ReturnConsumedCapacity asks for capacity, and carries no

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

On a table with a local secondary index, the service returns ItemCollectionMetrics on a matching SIZE replay: 200 {"ItemCollectionMetrics":{"<table>":[{"ItemCollectionKey":{"pk":{"S":"..."}},"SizeEstimateRangeGB":[0.0,1.0]}]}}, two reps. ExtendDB returns {}, on main and here. The test at line 218 uses a table without one, where {} is correct. Please limit this sentence and the matching line in the description to tables without a local secondary index.



def _fresh() -> tuple[str, str]:
return f"tok-{uuid.uuid4().hex}", uuid.uuid4().hex[:32]

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This returns (token, pk), and every caller unpacks pk, token = _fresh(). No assertion changes, because both values are valid in both roles, but failure output names them the wrong way round.

_call(dynamodb_client, table, pk, token)
with pytest.raises(ClientError) as exc:
_call(dynamodb_client, table, pk + "-b", token)
_assert_mismatch(exc)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The sibling test at line 136 reads the second key back, and this one does not. The service stores nothing here (two sends). Please add the same get_item check. A case with ReturnConsumedCapacity="TOTAL" on both calls and different items would cover the gap at transact_write_helpers.rs:384 end to end.

@pdf-amzn pdf-amzn left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code all looks reasonable.

AI-generated Findings

Should-fix

crates/engine/src/partiql/transaction.rs:419-421 on current main (not in the PR's base). ExecuteTransaction forwards ReturnConsumedCapacity into the TransactWriteItems body it hands to handle_transact_write_items. After this merges, an ExecuteTransaction replay that changes only RCC answers IdempotentParameterMismatchException instead of replaying. The PR measured TransactWriteItems only, so this is unmeasured for ExecuteTransaction. Rebase onto main, then either measure the ExecuteTransaction case on the service and add a dual-target test (next to tests/test_partiql_transaction.py:262 T5), or leave RCC out of the fingerprint on the PartiQL path. The same upgrade-compatibility note from the PR description (a stored token with TOTAL/INDEXES answers mismatch inside the 10-minute window) applies to that path too.

crates/engine/src/partiql/transaction.rs:505-528 on main. Once the classic handler uses the same message, the IdempotentParameterMismatchException arm in remap_write_error is a no-op, and its doc comment ("uses the PartiQL message") is stale. Drop the arm and fix the comment when rebasing, or keep it with a comment saying both surfaces share the message. The literal now appears twice; a shared const in transact_write_helpers.rs would keep the two copies from drifting.

Nit-or-follow-up

tests/test_transact_token_return_fields.py:69: _fresh() returns (token, pk), but every caller unpacks it as pk, token. So the "pk" is the tok-… string and the "token" is 32 hex characters. Both values are valid, so the tests pass, but the names are swapped. Reorder the return tuple.

tests/test_transact_token_return_fields.py:218: test_matching_replay_size_carries_no_metrics doesn't assert the first-call body. The PR records that DynamoDB returns {"ItemCollectionMetrics": {}} there and ExtendDB omits the key. Neither the test file nor docs/differences-from-dynamodb.md records that gap. File an issue or add a doc line.

Known gaps the PR lists but doesn't track: the fingerprint hashes raw JSON where the service compares the parsed request (1 vs 1.0, expression spacing, ExpressionAttributeNames aliasing, per-item ReturnValuesOnConditionCheckFailure: NONE, key order under the MongoDB preserve_order build). An unknown top-level field is ignored by the service and refused with SerializationException by ExtendDB. Record these in docs/differences-from-dynamodb.md or file an issue for each.

crates/engine/src/transact_write_helpers.rs:276: the docstring explains the compatibility rule, but docs/design/04-component-storage.md:817-822 still describes the fingerprint as covering the operations only. Update that section.

crates/engine/src/transact_write_helpers.rs:400: PINNED_ITEMS_ONLY is a good regression pin. Its comment could name the commit that produced the value (744ea29), so a later reader can check it.

Main is 247 commits ahead of the branch point (744ea29), and the
merge is clean. crates/engine/src/transact_write_items.rs changed on
both sides. Main checks the attribute name sizes of every member before
the table lookups (#433) and rewords the comment on stream records
(#576). This branch passes the two Return fields to compute_fingerprint
and changes the mismatch message.

Main adds the PartiQL ExecuteStatement, BatchExecuteStatement and
ExecuteTransaction operations (#411). For an ExecuteTransaction write,
crates/engine/src/partiql/transaction.rs builds a TransactWriteItems
body from the statements. It copies the request's ClientRequestToken
and ReturnConsumedCapacity into that body and passes the body to
handle_transact_write_items. With this branch, the fingerprint of the
body covers ReturnConsumedCapacity. An ExecuteTransaction retry that
keeps the token and the statements but changes ReturnConsumedCapacity
now answers IdempotentParameterMismatchException, where main serves it
as a replay. Amazon DynamoDB answers the mismatch to three such
retries: TOTAL then no field, no field then TOTAL, TOTAL then INDEXES
(us-east-1, 2026-11-10, two sends each). remap_write_error in
the same file rewrites the mismatch message to the text that the
classic handler now returns.
The compute_fingerprint comment said that tokens stored before this
change still replay. That holds only for a request with neither Return
field, or with both set to NONE. The comment now gives the three cases
for the 10 minutes after an upgrade or a downgrade.

The function at 744ea29 hashes the TransactItems JSON alone. This
version appends a suffix for TOTAL, INDEXES and SIZE, and nothing for
NONE. All four storage backends replay a retry only when its
fingerprint equals the stored one. A token for a request without the
fields therefore replays in both directions. A token that an earlier
version stored for TOTAL, INDEXES or SIZE answers
IdempotentParameterMismatchException to an identical retry on this
version. A retry without the field replays. A token that this
version stored for one of those values answers the mismatch to every
retry on an earlier version.

The comment also says that ExecuteTransaction writes use this
fingerprint, because they call handle_transact_write_items with their
ReturnConsumedCapacity.
The fingerprint test for different items used ReturnConsumedCapacity
NONE only. A build that hashes the items only when the field is NONE
passed every unit test. In that build, a retry with TOTAL and different
items replays and writes nothing. Amazon DynamoDB answers that retry
with IdempotentParameterMismatchException and stores nothing, for
TOTAL and for SIZE (us-east-1, 2026-11-10, two sends each). Two new
tests run the different items check with TOTAL and with SIZE.

The fingerprint stored for a request with TOTAL, INDEXES or SIZE must
also stay the same across versions. Otherwise its token stops replaying
after an upgrade. A new test pins the values for pinned_body() and token
tok-1: TOTAL 8fa51ffc1314a046, INDEXES ee2c3412f0fdbdf1, SIZE
70eb830c93e9079f, and TOTAL with SIZE e9957d3c7431e1d4. An HMAC-SHA256
computed outside the crate gives the same four values. The comment on
PINNED_ITEMS_ONLY now names 744ea29, the commit whose fingerprint
produced that value.
After the merge of main, the mismatch message was a literal in two
places: the classic TransactWriteItems handler and remap_write_error in
crates/engine/src/partiql/transaction.rs. The remap arm for
IdempotentParameterMismatchException replaced the handler's message
with the same text, so it changed nothing.

The text is now IDEMPOTENT_MISMATCH_MESSAGE in
crates/engine/src/transact_write_helpers.rs, and the handler uses it.
This change removes the remap arm, so the PartiQL surface passes the
handler's error through. The comments on remap_write_error and on the
module now say that it remaps only the reason of a duplicate-key
INSERT. No answer changes.
tests/test_transact_token_return_fields.py changes in four places.

The module docstring now limits the sentence about a matching replay to
tables without a local secondary index. The tests use such a table:
scoped_table keys it on pk alone. The docstring also gives the date of
the measurement behind each read of a changed key.

_fresh() returned (token, pk), and every caller unpacks
pk, token = _fresh(). It now returns (pk, token), so failure output
names the two values correctly.

test_changed_items_alone_is_a_mismatch now reads the second key back
and expects no item, as its sibling test does. For a retry with no
Return field and another item, Amazon DynamoDB answers
IdempotentParameterMismatchException. It stores no item for the second
key (us-east-1, 2026-11-10, two sends).

A new test sends ReturnConsumedCapacity TOTAL, or
ReturnItemCollectionMetrics SIZE, on both calls, and another item on
the second call. Amazon DynamoDB answers the second call with
IdempotentParameterMismatchException and stores no item for its key
(us-east-1, 2026-11-10, two sends each). The test checks the different
items case of the fingerprint unit tests through the server.
An ExecuteTransaction write reaches handle_transact_write_items with
the request's ReturnConsumedCapacity, so the fingerprint that compares
the field decides ExecuteTransaction replays as well. Four tests next
to the T5 test in tests/test_partiql_transaction.py send one INSERT
twice with the same token.

Amazon DynamoDB answers the second call with
IdempotentParameterMismatchException in three cases: TOTAL then no
field, no field then TOTAL, and TOTAL then INDEXES. The message is the
one TransactWriteItems gives. With no field on both calls the service
answers 200 {"Responses": []} to both (us-east-1, 2026-11-10, two sends
each). Each case uses its own key and token.
docs/design/04-component-storage.md described the stored fingerprint
without its inputs. Section 9 now says that the fingerprint is an
HMAC-SHA256 keyed by the token over the TransactItems JSON. The
ReturnConsumedCapacity and ReturnItemCollectionMetrics values follow
the items when they are not NONE. ExtendDB translates an
ExecuteTransaction write into a TransactWriteItems request before it
computes the fingerprint. The IdempotentMismatch item of the storage
error list now says that the token exists with a different fingerprint.

docs/differences-from-dynamodb.md gets a Transactions section with the
replay gaps that remain. The rows about how a request writes its items
name TransactWriteItems, the operation of the measurements:
- The service replays a retry that changes only the spelling of a
  number, the spacing of an expression or an ExpressionAttributeNames
  alias. It also replays a retry that adds
  ReturnValuesOnConditionCheckFailure NONE inside an item (measured
  2026-10-24). ExtendDB answers each with
  IdempotentParameterMismatchException (measured on SQLite at this
  branch).
- The service answers 200 to a retry whose item lists the same
  attributes in another key order. An identical retry gets the same
  answer (us-east-1, 2026-11-10, two sends). A build with the MongoDB
  backend keeps map keys in request order, so there the retry answers
  the mismatch (from the code).
- The service answers 200 to a raw signed request with an unknown
  top-level field and writes the item (us-east-1, 2026-11-10, two
  sends). ExtendDB refuses the request with SerializationException
  (measured on SQLite at this branch).
- On a table without a local secondary index, the service answers the
  first SIZE call with {"ItemCollectionMetrics": {}} (us-east-1,
  2026-11-10, two sends). ExtendDB answers {}.

docs/manuals/07-upgrade-manual.md gets a v0.1.15 entry for stored
tokens. After an upgrade, a token that v0.1.14 stored for a request
with TOTAL, INDEXES or SIZE answers the mismatch to an identical retry.
After a downgrade, a token that this release stored for such a request
answers the mismatch to every retry. v0.1.14 does not serve
ExecuteTransaction. PostgreSQL, SQLite and MongoDB keep a token for 10
minutes. The Cassandra backend stores a token without an expiry time
and does not check its age.
The compute_fingerprint comment said that a token expires after 10
minutes. It described the answers for 10 minutes after an upgrade or a
downgrade. PostgreSQL, SQLite and MongoDB check the age of a token
against 10 minutes. The Cassandra backend inserts a token without a
TTL, and its token table has no default TTL. Its token check reads no
creation time, and its cleanup of expired tokens does nothing. A
Cassandra token therefore has no expiry.

The comment now describes the answers for a token stored before the
version change, and it gives the lifetime per backend. The upgrade
manual entry states the same lifetimes.
@LeeroyHannigan

Copy link
Copy Markdown
Collaborator Author

Thanks, both. I pushed 6fabc1c. It merges main and answers each item. On 2026-11-10 I measured each service answer the new tests encode, two sends each, with Put and with Update members.

@demohan

  • The comment on compute_fingerprint now says which stored tokens replay after an upgrade and after a downgrade. The description and a new upgrade manual entry say it too. A token lives 10 minutes on PostgreSQL, SQLite and MongoDB. On Cassandra it never expires, which is Cassandra: a TransactWriteItems ClientRequestToken never expires #936.
  • The another-items check also runs with TOTAL and with SIZE. A build that hashes the items only when the field is NONE fails those cases, in the unit tests and in the wire test.
  • The tests pin the four suffixed values for pinned_body() and tok-1, and they equal yours. With the two suffixes swapped, the TOTAL with SIZE pin fails. The comment on PINNED_ITEMS_ONLY names 744ea29.
  • The docstring and the description limit the matching SIZE replay to tables without a local secondary index.
  • _fresh() returns (pk, token).
  • The test reads the second key back. I added a case with TOTAL on both calls and another item, and the same case with SIZE. In both, the service answers IdempotentParameterMismatchException and does not store the second item.
  • ExecuteTransaction replays now go through this check. TOTAL then absent, absent then TOTAL, and TOTAL then INDEXES each answer the mismatch, and two calls with no field replay. The new tests in test_partiql_transaction.py encode these answers.

@pdf-amzn

  • The IdempotentParameterMismatchException arm of remap_write_error is gone. The message is IDEMPOTENT_MISMATCH_MESSAGE in transact_write_helpers.rs.
  • The design doc says what the fingerprint covers, and that ExtendDB translates an ExecuteTransaction statement before it computes the fingerprint.
  • docs/differences-from-dynamodb.md records the first-call {"ItemCollectionMetrics": {}} gap and the known gaps of the description, for TransactWriteItems. I measured two of them. A retry with the item's keys in another order answers 200. A raw request with an unknown top-level field answers 200 and writes the item.

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

v1 Needed for the 1.0 release

Projects

None yet

Development

Successfully merging this pull request may close these issues.

TransactWriteItems token replay ignores ReturnConsumedCapacity and ReturnItemCollectionMetrics

4 participants