Skip to content

fix: report per-index ConsumedCapacity on transactional and PartiQL writes - #188

Merged
hicksy merged 7 commits into
mainfrom
fix/transactional-index-consumed-capacity
Aug 15, 2026
Merged

fix: report per-index ConsumedCapacity on transactional and PartiQL writes#188
hicksy merged 7 commits into
mainfrom
fix/transactional-index-consumed-capacity

Conversation

@hicksy

@hicksy hicksy commented Aug 14, 2026

Copy link
Copy Markdown
Member

Closes #178.

What this changes

TransactWriteItems, ExecuteStatement, ExecuteTransaction and
BatchExecuteStatement reported a Table arm and nothing else under INDEXES.
They now report the per-index breakdown the single-item paths already did, and the
index units fold into the total, so figures move under TOTAL as well on an
indexed table.

The rule that decides this, and the one worth reviewing carefully, is that the
transactional 2x factor applies to the base table arm alone. An index arm inside a
transaction costs what the same write costs outside one. A GSI key move charges the
index the same either way while the table arm doubles.

Checking that against real DynamoDB turned up four more divergences in the same
paths, all fixed here:

  • The transactional table arm was sized on the request rather than on the item.
    Delete and ConditionCheck carry a key and no item, so both were sized on the
    key: deleting a 3KB item reported 2 units against DynamoDB's 6. A ConditionCheck
    writes nothing and is still charged on the image it read.
  • A same-token replay recomputed from the request, which has the same problem. A
    replayed delete of a 9KB item reported 2 against DynamoDB's 6. It is now charged
    against the images the first call was sized on, at 4KB read granularity.
  • BatchExecuteStatement had no way to report capacity at all. It now accepts
    ReturnConsumedCapacity and aggregates per table across the batch. A failed
    statement is still charged the write it attempted, sized on the row already
    stored rather than on the item the statement carried.
  • Four request shapes DynamoDB rejects were accepted. A batch or transaction may
    not mix reads with writes, and may not name the same item twice, reads included.
    Both surfaces now reject both, top level, before any statement runs.

Everything above is captured against eu-west-2, 74 rows across the four surfaces.

Two things reviewers should know

This is behaviour-breaking twice over. A request that previously succeeded in a
mixed or duplicate shape now fails, and the reported numbers change. Both are
covered in CHANGELOG.md.

It costs about 7% on a 25-statement BatchExecuteStatement, measured against
958e340. That is the price of the four validations, and it is stated in the
changelog beside them rather than left for someone to find.

Checklist

  • Tests added or updated
  • cargo fmt --check and cargo clippy -- -D warnings pass locally
  • CHANGELOG.md updated if this is a user-visible change
  • Linked issue, discussion, or a short note explaining the motivation
  • I agree my contribution is licensed under the project's terms
    (MIT License and Apache License, Version 2.0)

DynamoDB compatibility note

This moves dynoxide towards DynamoDB on every point above, and each figure is
pinned to a captured value rather than to a derivation. One gap is deliberately
left: a PartiQL SELECT against an index still scans the base table and drops its
WHERE clause, so its capacity lands on the wrong arm. That is a data-correctness
bug rather than a capacity one, it is tracked as #179, and fixing the capacity
figure before the qualifier is honoured would pin the wrong number.

hicksy added 7 commits August 14, 2026 22:55
TransactWriteItems reported a Table arm and nothing else under INDEXES,
and sized that arm from the request payload rather than the item images.
The transactional 2x factor applies to the table arm alone, so an index
arm inside a transaction costs what it costs outside one.
ExecuteStatement and ExecuteTransaction join the same model, and
BatchExecuteStatement gains a capacity surface it never had. Also adds
the four request validations AWS enforces on batches and transactions,
which turn some previously accepted requests into errors. A PartiQL
SELECT against an index is untouched and still wrong (#179).
The image sizes a same-token replay bills against move onto the types
the idempotency caches hold, so neither response type carries a
pub(crate) field. BatchStatementRequest becomes non_exhaustive: it is
two fields short of DynamoDB's, so it will gain fields again.
Four surfaces each sorted the aggregated tables and built one
ConsumedCapacity per table, and three also folded per-table read units.
Both now live beside the aggregation. The batch failure surcharge stays
at its call site, being the one rule of the four that diverges.
Classification, duplicate detection and execution each parsed the whole
statement list. Measured on a 25-statement batch: 244.8 to 166.7 us, a
third of the call. Parsing was the larger half of that by more than two to
one, against everything target resolution does, which inverts the guess
that the per-statement metadata load dominated. It does not, on native,
where table metadata is already cached in memory; on the wasm backend,
which has no such cache, the balance is expected to fall the other way.

The prepared item carries the resolved target beside the parsed statement,
so duplicate detection reuses it rather than resolving again. Parse failure
stays a per-statement error rather than a request-level one, which a
Result<Vec<_>, _> would have quietly changed.

Adds the batch to both benchmark suites. The PartiQL surfaces had no
coverage in either, so the blocking instruction-count gate could not see a
change to this path at all.
The parse-once change recovers 81.0 us on a 25-statement batch. That is
the number to trust: an earlier run on a different workload recovered
78.1, and two workloads agreeing within 4 per cent while differing in
everything else is the signature of a change whose mechanism is two fewer
parses and nothing else.

Supersedes the figures in 27419e7, which are wrong. Its benchmark inserted
a string into a sort key declared N, so all 25 statements were rejected;
BatchExecuteStatement reports member failures inside a successful response,
so the call returned Ok and nothing complained. Its 244.8 to 166.7 us, its
33 per cent, its predicted-versus-measured agreement and its
parse-versus-resolution ratio all describe the rejection path. The
corrected figures are 495.7 to 414.7, about 16 per cent, and against the
merge base the batch path is 387.9 to 414.7, about 7 per cent slower,
which is what the four validations cost.

Both benchmarks now assert the workload succeeded before timing it, and
the merge-gating instruction-count one no longer measures 25 rejections.

Also restores the mode guard ahead of aggregation on both transactional
surfaces, which the shared-helper extraction had moved inside the builder
so that every write transaction cloned its charge set before discarding
it; sorts the transactional read and replay paths by table name, which the
write path already did; and drops a doc claim about sharing a key-schema
lookup that was never implemented.
The compatibility summary said TransactWriteItems and PartiQL writes
report no per-index breakdown. That was true when it was written and
the code has since caught up.
@github-actions

Copy link
Copy Markdown
Contributor

Criterion Benchmark Results

Baseline is the per-benchmark median of the last 5 stored runs, so one unusually fast or slow runner cannot skew the comparison. The range column is the spread across those runs.

Benchmark Baseline (ns/iter) Range Current Change
batch_execute_statement_25 827,417 new
batch_get_item_100 1,389,601 814,524 - 1,451,464 1,436,226 +3.4%
batch_write_item_25 1,119,577 533,898 - 1,182,554 1,179,296 +5.3%
delete_item 51,384 26,334 - 53,068 54,789 +6.6%
get_item 14,455 8,000 - 15,127 15,254 +5.5%
put_item/put_item/large 269,725 115,520 - 280,567 252,379 -6.4%
put_item/put_item/medium 44,503 19,445 - 46,845 48,095 +8.1%
put_item/put_item/small 25,143 10,759 - 26,885 26,755 +6.4%
query_base_table 1,093,125 666,833 - 1,164,732 1,108,634 +1.4%
query_gsi 25,592 12,359 - 26,517 26,187 +2.3%
scan_with_filter 8,217,608 5,182,664 - 8,785,317 8,502,792 +3.5%
transact_write_items_4 257,350 112,673 - 267,776 285,381 +10.9%
update_item 163,483 85,461 - 185,342 158,855 -2.8%

All benchmarks within 50% of the 5-run median.

Runs in the baseline
  • runs/2026-06-26-e066fc0
  • runs/2026-07-05-7d5fd8a
  • runs/2026-07-24-f7e7d96
  • runs/2026-07-30-bc2a16c
  • runs/2026-07-30T220642Z-be8bfbc

@hicksy
hicksy merged commit 6b7aaf2 into main Aug 15, 2026
20 checks passed
@hicksy
hicksy deleted the fix/transactional-index-consumed-capacity branch August 15, 2026 13:56
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.

TransactWriteItems and PartiQL writes report no per-index ConsumedCapacity under INDEXES

1 participant