Skip to content

ADR-133: Client-Side Shard Bucket Creation

Status: Accepted (partially superseded by ADR-145 for quota limits; create shape amended by ADR-150) Date: 2026-09-11 Issue: #439

Partially superseded by ADR-145: this record's token sizing (capacity_milli // shard_count) and its create-race routing (consumption-only retry) apply to dripping limits only. A quota limit's starting tokens, its per-shard ceiling (C // gc) and a lost create race that carries a move follow ADR-145; ADR-134's per-shard-share wording reads the same way. Shard targeting, the undivided wcu and the attribute_not_exists(PK) guard still apply to every bucket.

Context

GHSA-76rv-2r9v-c5m6 mitigates DynamoDB hot partitions with pre-shard buckets: every bucket carries a reserved wcu limit, and when a speculative write exhausts it the client doubles shard_count on shard 0 and retries on another shard. That retry could never succeed in a fresh deployment: the speculative UpdateItem requires attribute_exists(PK), the slow path's batch_get_* reads hardcoded shard 0, and _commit_initial() created buckets on shard 0 only. The sole writer of a shard N>0 item was the aggregator's propagate_shard_count() (Path 2), whose own comment — "Client already created this shard" — shows the original design expected the client to create shards. With --no-aggregator, or a lagging stream, every BUCKET_MISSING re-read and re-wrote shard 0: writes stayed on the hot partition and about half of all acquires paid a full slow-path fallback for nothing.

Option A: the client creates shard N>0 items itself. Option B: require the aggregator.

Decision

The client slow path must read and, when missing, create the same shard the speculative attempt selected (Option A). Repository.select_shard() is the single place a shard is drawn; the slow path receives that shard rather than drawing again. A shard N>0 item is created with effective per-shard tokens — capacity_milli // shard_count for application limits, wcu undivided, stored cp/ra undivided — exactly as the aggregator's Path 2 does, and stamped with the cached shard_count. The create keeps attribute_not_exists(PK); if the aggregator wins the race, the transaction's condition failure routes to the existing consumption-only retry on that same shard, whose tk >= consumed condition is the same admission gate the speculative write uses.

Rationale

The security label and the advisory make this a mitigation that must work in every deployment. --no-aggregator is a supported mode (it is what the test suite's shared minimal stack runs), so Option B would leave a documented security control inert there.

Capacity bound. Every refiller — the aggregator's try_refill_bucket() and the client slow path, which carries shard_count on BucketState — caps each shard at capacity_milli // shard_count, so N shards admit at most capacity per window, never N x capacity. That holds only while every shard agrees on the count, so a client winning a bump propagates it. Neither a bump nor Path 2 touches shard 0's balance when the count doubles, so shard 0 may hold capacity while shard 1 starts at capacity/2: a one-time transient of up to 1.5x, decaying as shard 0 drains, not a reconciliation scheme.

Race with the aggregator. Both create under attribute_not_exists(PK), so exactly one succeeds; the client losing costs one extra conditional write and never over-admits. The transaction still carries one item per (entity, resource, shard), so the 100-item limit is unaffected; one cancelled by a sibling item re-issues its Put from its per-index reason.

Cost (non-cascade, one user limit, warm cache; a cold config cache adds ~1.5 RCU):

Path RT RCU WCU Notes
(a) Speculative hit on an existing shard 1 0 1 Unchanged steady state
(b) First acquire on a not-yet-created shard 4 2.5 2 Failed conditional (1 WCU), disable walk (3-key BatchGet, 1.5 RCU), META + bucket BatchGet (1 RCU), single-item transaction downgraded to PutItem (1 WCU); once per shard
(b') …after a wcu-driven doubling 5 2.5 3 (b) plus the shard_count bump, once per doubling
(c) Previous broken fallback 4 2.5 2 Same per-call cost as (b), paid on every acquire that drew a missing shard, every write landing on shard 0

Consequences

Positive: - Write sharding engages with or without the aggregator, and without stream lag. - The BUCKET_MISSING fallback is now self-healing: one create, then fast-path hits. - A shard-retry that finds a missing shard creates it instead of fast-rejecting the caller.

Negative: - The slow path now carries a shard and its count through read, LeaseEntry and write; batch_get_* keys grow to (entity_id, resource, shard_id). - A cascading child never takes the child-only shard retry (it would bypass the parent); it pays the slow path instead — a rare-path extra round trip for correctness. - Non-speculative clients (speculative_writes=False) never consume wcu and so never trigger doubling; they stay on shard 0.

Known limitation — per-shard request ceiling. No shard holds more than its share, so a single request above capacity // shard_count is unadmittable on every shard while the entity is still under its configured limit. Statuses report the share, not the undivided config, so RateLimitExceeded is honest; MAX_SHARD_COUNT (32) bounds how small a share gets.

  • _sync_bucket_params() reconciles shard 0 only (#468); the parallel cascade fast path always writes the parent on shard 0 (#474); the per-shard ceiling has no event or metric (#475).

Alternatives Considered

Option B — require the aggregator for write sharding

Rejected because: a security mitigation cannot depend on an optional component being deployed and its stream being caught up.

Create the new shard with full (undivided) tokens

Rejected because: it multiplies admitted capacity by shard_count and diverges from Path 2.

Re-draw a random shard on the slow path

Rejected because: a second draw can land on shard 0 again, and a BUCKET_MISSING on shard N would then never create shard N — the same failure with extra randomness.