The Dynoxide hexagonal logo centred on a near-black background

Dynoxide 0.11.2: a stack of conformance fixes

Dynoxide 0.11.2 is a conformance patch - a stack of fixes for spots where the engine did something real DynamoDB doesn't. Most of it is drift the conformance suite turned up faster than I'd been closing it. The lead one I can tie to something concrete: last week I worked out what a conditional DynamoDB transaction costs and pinned the numbers against real AWS. This release makes dynoxide report the same numbers.

The transaction bill

That article measured what real DynamoDB charges inside a TransactWriteItems, and dynoxide wasn't reporting it the same way. Three things changed.

The read/write split now sits at the top level. TransactWriteItems reports ReadCapacityUnits and WriteCapacityUnits in its ConsumedCapacity, where before those numbers only lived in the nested Table breakdown. A standalone ConditionCheck - the action that asserts something about an item without touching it - costs 2 write units, which is the little counterintuitive one from the article: it reads an item to check a condition, but the transaction path bills it as a write.

The replay was the number I most wanted right. Send a TransactWriteItems with a ClientRequestToken, send the same token again inside the idempotency window, and DynamoDB doesn't repeat the write - it hands back the stored result and charges you read capacity for it. Dynoxide was faking that by relabelling the first call's write units as read. It now recomputes a genuine transactional read, rounded at 4KB read granularity rather than the 1KB writes round at, so above 1KB the two diverge: for a ~1.5KB item the first call reports 4 write units and the replay 2. That's the same separation the suite proved against real AWS once I pushed the test item past 1KB.

PartiQL's ExecuteTransaction was worse off - it ignored ClientRequestToken entirely and re-applied the statements on every call. It now honours idempotency the same way, replaying the stored result within the window instead of re-running, and reports capacity split by statement kind (write for a write set, read for an all-SELECT read set, read on a replay) at 2 units per statement, where it used to hand back a flat 1-unit estimate with no split.

While I was in there I closed a race on the same path. A same-token TransactWriteItems now holds the idempotency lock across the whole first call. Before, the lock was released between the cache check and execution, so two concurrent calls with the same token could both run the transaction; the second now waits and replays the first's result.

The UpdateTable bug

This one came in as a bug report from Ryan Elian, and it's a genuine correctness bug rather than a wording mismatch. UpdateTable was replacing a table's AttributeDefinitions with only the attributes carried in the request, where DynamoDB treats them as a delta.

The failure shows up if you build single-table and add indexes as you go. Add a global secondary index and you only need to declare the new index's key attributes - the table keys and any earlier index's attributes are already known. Dynoxide took the request literally and overwrote the stored list, so adding a second GSI with delta-only attributes dropped the table keys and the first index's attributes from DescribeTable. The next PutItem then failed index-key validation with Index key attribute GSI1PK missing from AttributeDefinitions - a write breaking on a table that hadn't changed.

The definitions are now unioned by attribute name, so earlier declarations survive, and a redeclared attribute keeps its existing type (DynamoDB ignores a conflicting type in the delta rather than overwriting or rejecting it). Deleting a GSI prunes its now-orphaned key attributes, and an entry supplied in the delta that no key schema uses is dropped rather than stored. All verified against eu-west-2 (#129).

The rest

The other fixes are smaller, mostly around projection expressions and validation ordering:

  • Projection validation now runs up front, before any item is read, across GetItem, Query, Scan, BatchGetItem and TransactGetItems. Overlapping paths (a and a.b), duplicate paths, and undefined expression-attribute names are rejected with DynamoDB's Invalid ProjectionExpression: message, so a projection that matches nothing rejects rather than quietly returning an empty result.
  • Projecting several indices of one list now comes back compacted and in ascending order (#l[2], #l[0] on [l0, l1, l2] yields [l0, l2]), and naming two sub-attributes of the same list index merges them into one element instead of splitting each into its own (#126).
  • Query accepts a key-condition comparison with the value on the left (:lo <= #sk, treated as #sk >= :lo), and rejects a nested path on a key attribute with DynamoDB's own message rather than dynoxide's wording.
  • CreateTable turns away two combinations it used to accept: a StreamSpecification that disables the stream but still supplies a StreamViewType (#115), and a secondary index using ProjectionType: INCLUDE without a NonKeyAttributes list (#116).
  • A few message and ordering fixes: Query and Scan now return DynamoDB's exact wording when Select: SPECIFIC_ATTRIBUTES is given with no projection (#121); BatchGetItem rejects mixing a ProjectionExpression on one table's block with AttributesToGet on another; and PutItem and UpdateItem report an invalid TableName on its own, before the Return* enum checks, matching the order AWS reports them in.

0.11.2 is out everywhere dynoxide ships:

  • npm: npm install --save-dev dynoxide, then npx dynoxide
  • Homebrew: brew install nubo-db/tap/dynoxide
  • Cargo: cargo install dynoxide-rs (crates.io, docs.rs)
  • Docker: ghcr.io/nubo-db/dynoxide, mirrored best-effort to Docker Hub (nubodb/dynoxide) and ECR Public
  • GitHub: source and pre-built binaries, plus the nubo-db/dynoxide/action GitHub Action for CI

Same standing offer as always: if dynoxide does something real DynamoDB doesn't, that's a bug worth filing. The UpdateTable one above started exactly that way.