ADR-114: Composite Bucket Items¶
Status: Proposed Date: 2026-01-28 Issue: #248
Context¶
Each rate limit (rpm, tpm) for an entity+resource is stored as a separate DynamoDB
item with SK #BUCKET#{resource}#{limit_name}. For an entity with N limits,
acquire() reads N+1 items via BatchGetItem and writes N items via
TransactWriteItems. The transaction 2x WCU tax means 2 limits with cascade costs
11 CU per acquire, scaling linearly with limit count.
GSI2 produces N entries per entity+resource (one per limit), inflating resource aggregation queries. The per-limit item design also prevents atomic operations across limits — each item is updated independently within the transaction.
Entity metadata (#META) must remain a separate item because it carries GSI1 keys
for parent→children queries. Merging META into bucket items would duplicate it
across resources and break GSI1 deduplication. The existing 60s config cache
already skips META reads on cache hits.
Decision¶
All limits for an entity+resource must be stored in a single composite DynamoDB
item with SK #BUCKET#{resource}. Per-limit attributes must use the prefix
b_{limit_name}_{field} with short field names: tk (tokens), cp (capacity),
ra (refill amount), rp (refill period), tc (total consumed).
GSI2SK must be per-entity (BUCKET#{entity_id}), not per-limit.
Consequences¶
Positive: - Acquire cost is constant regardless of limit count (1 item read, 1 item write) - Non-cascade acquire drops from 5.5 CU to 2 CU; cascade drops from 11 CU to 6 CU - GSI2 entries reduced from N per entity+resource to 1 - All limits for an entity+resource are atomically readable and writable
Negative:
- Breaking schema change requiring migration (new SK format)
- Per-limit attributes use short names (tk, cp) that are less readable than current names
- Deserialization must enumerate prefixed attributes to reconstruct BucketState objects
- Adding or removing limits requires updating a shared item rather than creating/deleting items
Alternatives Considered¶
Keep separate items, use UpdateItem instead of PutItem¶
Rejected because: fixes lost updates but does not reduce item count, CU cost, or GSI2 inflation — the core scaling problem remains.
Merge entity metadata into composite bucket item¶
Rejected because: duplicates META across resources, breaks GSI1 parent→children queries, and requires fan-out writes on metadata changes.
Nested map per limit (limits.rpm.tokens_milli)¶
Rejected because: DynamoDB cannot use atomic ADD on nested paths without overlapping SET+ADD errors (ADR-111, issue #168).