DynamoDB
After this you can answer: How do keys, consistency, conditional writes and capacity determine whether a DynamoDB design stays correct at scale?
Picture it
Section titled “Picture it”Where it sits
Section titled “Where it sits”These implemented connections match the explorer’s worker-to-processed-jobs, tickets-lambda-to-table, and api-service-to-tickets-table entries. Built here means present in the repository; it is not a claim that compute is running now.
flowchart LR email["minitickets-dev-worker"] -->|"checks and records - worker role dynamodb:GetItem, dynamodb:PutItem"| jobs["DynamoDB: minitickets-dev-processed-jobs"] ticket["minitickets-dev-tickets Lambda"] -->|"reserves and completes - tickets role dynamodb:GetItem, dynamodb:PutItem, dynamodb:UpdateItem"| table["DynamoDB: minitickets-dev-tickets"] api["minitickets-dev-api"] -->|"queries owner - dynamodb:Query; API task role"| table table -->|"projects event-date writes - managed GSI maintenance"| gsi["by-event-date GSI"] class jobs,table service class email,ticket,api app class gsi awsLegend: highlighted = DynamoDB tables; app = MiniTickets processes; AWS = managed index; named roles authorize the SDK calls.
Open tickets in the map → · Open processed jobs in the map →
How it works
Section titled “How it works”- An SQS delivery gives the tickets worker a booking message. The worker validates it and derives
ticketId = bookingId:seat. A duplicate publication therefore identifies the same ticket. - The worker tries a
PutItemwith a condition that the primary key does not exist. DynamoDB evaluates the condition and write together. Concurrent workers cannot both create a new item for the same complete key. - A losing worker receives
ConditionalCheckFailedException. It strongly reads the existing item, validates its identity, and uses the already-reserved QR token. This is an expected race result, not automatically an outage. - A
creatingitem still needs its PNG uploaded to the stable S3 object key. Areadyitem can be skipped. Stable keys make a retry overwrite the intended object rather than create another ticket. - After S3 accepts the image,
UpdateItemsets the status toready, conditional on the stored token matching. A crash before that update leaves work that redelivery can resume. - The API queries the authenticated user’s partition with a strongly consistent
Query. It followsLastEvaluatedKey, the response cursor, until all pages are read. A missing ticket can still mean its queue message has not been processed.
sequenceDiagram participant Queue as tickets SQS queue participant Worker as tickets Lambda box rgb(237,233,254) DynamoDB participant DB as minitickets-dev-tickets end participant S3 as ticket-assets S3 participant API as MiniTickets API Queue->>Worker: Booking message Worker->>DB: Conditional PutItem: creating alt New reservation DB-->>Worker: Created else Reservation already exists DB-->>Worker: ConditionalCheckFailedException Worker->>DB: GetItem with ConsistentRead DB-->>Worker: Canonical ticket end Worker->>S3: PutObject at stable ticket key if not ready Worker->>DB: Conditional UpdateItem: ready API->>DB: Query userId with ConsistentRead DB-->>API: Items and optional LastEvaluatedKeyLegend: app participants call AWS services using their own roles; an existing ready item skips the upload and update.
Under the hood
Section titled “Under the hood”An item is a set of named attributes, such as ticketId, status, and qrToken. The partition key selects where DynamoDB places data through an internal hash function. A sort key orders items sharing that partition-key value and completes their identity. Our tickets key is (userId, ticketId); the processed-jobs key is only jobKey.
A physical partition is AWS-managed storage, replicated across Availability Zones. One logical partition-key value is not a promise of one permanent physical partition. AWS can split item collections without local secondary indexes. Good key design still spreads activity across many values; one extremely busy item remains a shared point of contention. AWS partition mechanics
A GetItem needs the complete primary key. Query needs one partition-key value and can constrain the sort-key range. Scan reads across a table or index. A filter removes returned results after data is read, so adding a filter does not turn a Scan into an efficient key lookup. Design the key from the question the application asks. AWS Query behavior
A global secondary index, or GSI, gives data another partition key and optional sort key. Its storage and capacity are separate from the table. A local secondary index, or LSI, keeps the table’s partition key and changes the sort key. LSIs must be chosen when the table is created; GSIs can be added later. Projection means copying selected attributes into an index. More projected data increases storage and write work. AWS index guidance, AWS LSI reference
Eventually consistent reads may briefly miss a completed write. Strongly consistent reads return the latest successful writes when the read is performed; they do not stop another writer changing the item afterward. Tables and LSIs support strong reads. GSIs do not. A successful table write can therefore be visible in a direct table read before its GSI query catches up. AWS read consistency
On-demand capacity bills read and write requests and manages scaling. Provisioned capacity reserves read and write units; auto scaling adjusts those reservations toward a target. Neither choice fixes a concentrated key pattern. A read capacity unit supports a strongly consistent read per second up to 4 KB; a write capacity unit supports a write per second up to 1 KB. Larger items consume more units. AWS capacity modes, AWS unit sizes
Transactions combine multiple DynamoDB changes into one all-or-nothing operation. They can enforce several item conditions together, but cannot include an SES email or S3 upload. DynamoDB Streams captures item changes for later processing; a Lambda event source mapping can consume the stream. Stream consumers need idempotency because processing can repeat. Neither of our tables enables streams today. AWS transactions, AWS stream components
Limits and numbers
Section titled “Limits and numbers”At the time of writing; check the AWS docs. Capacity figures are units, not universal requests per second for every item size.
| Property | Value or behavior | Source |
|---|---|---|
| Maximum item size | 400 KB, including attribute names and values | AWS constraints |
| Query page | Up to 1 MB before filtering; follow the cursor | AWS query pagination |
| Transaction | Up to 100 distinct items, 4 MB total | AWS constraints |
| Physical-partition throughput | Up to 3,000 read units/s and 1,000 write units/s | AWS key design |
| Secondary indexes | 20 GSIs by default; 5 LSIs | AWS index guidance |
| LSI item collection | 10 GB per partition-key value, including table and index entries | AWS LSI reference |
| Streams record retention | 24 hours | AWS stream components |
| TTL deletion | Background deletion, typically within a few days | AWS TTL |
| MiniTickets completion retention | expiresAt set 30 days ahead | apps/worker/src/processed-jobs.ts |
TTL means time to live: an attribute names when an item becomes eligible for automatic deletion. Its value is a numeric Unix timestamp in seconds. It is cleanup, not an exact timer. Expired items can remain readable until deletion; enforce business expiry in a read or write condition if timing matters. AWS TTL
In MiniTickets
Section titled “In MiniTickets”Built in the repository. infra/src/jobs/processed-jobs-table.ts creates minitickets-dev-processed-jobs with on-demand billing and TTL on expiresAt. DynamoProcessedJobs in apps/worker/src/processed-jobs.ts strongly reads jobKey, then conditionally records completion with attribute_not_exists(jobKey).
The key is booking.confirmed:bookingId, not the SQS message ID. Two messages about the same booking still refer to one business action. apps/worker/src/handlers/booking-confirmed.ts sends the email before marking it done. If it crashes between those operations, a retry may send again. Two simultaneous workers can also both read “not done” and send before one loses the conditional write. This is duplicate suppression with known gaps, not exactly-once email. See duplicate email diagnosis.
The stronger ticket recovery path is implemented in apps/worker/src/tickets/store.ts and apps/worker/src/handlers/tickets.ts. Reservation precedes upload; an existing reservation supplies the canonical token. markReady checks qrToken = :token. The handler resumes partial bookings seat by seat.
infra/src/tickets/storage.ts defines minitickets-dev-tickets: partition key userId, sort key ticketId. Its by-event-date GSI uses eventDate as partition key and eventDateKey as sort key, with all attributes projected. The handler derives eventDate from the start timestamp’s first ten characters and eventDateKey as eventStartsAt#ticketId. This is a date access pattern, not an index partitioned by event ID.
apps/api/src/queries/tickets.ts queries the base table by the authenticated user and follows every response cursor. It does not query the GSI. The API role currently grants Query on the table ARN, with no index ARN grant. A proposed event-day endpoint needs an explicit authorization decision and index permission before using the GSI.
Connections
Section titled “Connections”| Caller → resource | Exact allowed action | Defined in |
|---|---|---|
| Email worker → processed-jobs | dynamodb:GetItem, dynamodb:PutItem on that table | infra/src/jobs/worker-iam.ts, ProcessedJobsTable |
| Tickets worker → tickets | dynamodb:GetItem, dynamodb:PutItem, dynamodb:UpdateItem on that table | infra/src/tickets/iam.ts |
| API task → tickets | dynamodb:Query on the table, no Scan or writes | infra/src/compute/iam.ts, ReadTickets |
| DynamoDB table → GSI | AWS maintains the index from table writes; no worker call to update it | infra/src/tickets/storage.ts, by-event-date |
The SDK signs AWS API requests with the process role. These regional DynamoDB resources sit outside our VPC. They do not have our Postgres security group, and no TCP 5432 rule authorizes them. Our Lambda workers can reach the public AWS service endpoint without joining the VPC. An endpoint and network route provide reachability; IAM still decides whether the operation is allowed.
When it fails
Section titled “When it fails”flowchart TB call["Worker writes DynamoDB"] --> result{"Write result"} result -->|"Expected condition failed"| existing["Read and validate existing item"] result -->|"Throttled or unavailable"| retry["Report failed SQS message"] retry --> visible["Visibility expires and queue retries"] visible -->|"exceeds maxReceiveCount 3"| dlq["Relevant queue DLQ"] existing -->|"Canonical ticket ready"| skip["Skip completed work"] class call,existing,skip app class result service class retry,visible,dlq failureLegend: red = retry and failed-delivery path; an expected conditional conflict can finish successfully instead.
| What you see | Why it happens | Fix |
|---|---|---|
ConditionalCheckFailedException | Another worker reserved the item, or a business condition is false. | Distinguish expected duplication from invalid state; read canonical data and verify identity. |
| Throttling and increasing queue age | A hot key, table/index capacity, configured maximum, or account quota is reached. | Inspect the throttling reason and resource ARN; spread keys or adjust the relevant capacity. |
AccessDeniedException on Query or Scan | The role lacks that operation or targets a table/index outside its grant. | Compare the exact request ARN and caller with the policy; do not grant broad access to bypass design. |
ResourceNotFoundException | Wrong table name, Region, or local table not created. | Compare runtime configuration with the table’s actual identity. |
| Empty GSI result just after a write | Index propagation is asynchronous, or key attributes are missing. | Allow the known consistency delay; inspect base-table data and index key types. |
The AWS throttling guide separates several capacity causes. A retry with exponential backoff and jitter helps temporary overload; it does not repair a permanent hot-key design. Do not swallow a datastore outage and acknowledge the queue message as successful.
At 10x / 100x
Section titled “At 10x / 100x”At 10x, our email keys and ordinary user IDs distribute writes reasonably. The event-date GSI deserves separate attention: all tickets for the same date share a logical index partition key. A sale for one date can concentrate writes even when base-table userId values are diverse. Index write pressure can also throttle base-table writes. AWS GSI throughput
At 100x, measure item sizes, consumed capacity, throttling reasons, and index distribution. On-demand scales within service behavior and quotas; a sudden jump beyond twice the previous peak within thirty minutes can still throttle. Pre-warming and deliberate load distribution matter for scheduled sales. AWS on-demand scaling
A possible future key is eventDate#shard, where a stable hash assigns tickets to several index keys. Sharding means spreading one logical workload across several keys. Date queries must then read each shard and merge ordered results. It trades simpler writes at scale for more read requests, pagination state, and application code. Do not add it before measured traffic justifies it.
Our API currently accumulates every ticket page in memory. A customer with many tickets makes both latency and response size grow. A future paginated endpoint should return an opaque cursor tied to the authenticated owner; never let a caller’s cursor change which owner is authorized.
Common interview traps
Section titled “Common interview traps”- “On-demand means infinite capacity.” It removes capacity reservations, not physical-partition behavior, quotas, maximum-throughput settings, or cost.
- “Strong read then write prevents races.” Another writer can act between them. Put the rule in a conditional write or transaction.
- “A conditional completion record makes SES exactly once.” The send and record do not commit together. Our crash and concurrent-send windows remain.
- “A GSI supports strong reads because the table does.” Only tables and LSIs support that option.
- “TTL releases a seat at its exact deadline.” It removes eligible data later. Application conditions must enforce the deadline.
Design question
Section titled “Design question”Two check-in devices scan the same ticket together. How do you admit it once?
This check-in feature is designed, not built. Authenticate the operator, verify the ticket signature, and confirm access to that event. Resolve the exact primary key without trusting a client-supplied owner. Then conditionally change status from ready to checked-in. A condition tested with the update chooses one winner. A read followed by an unconditional update would allow both scans through.
The current ticket signature uses HMAC, a keyed authentication code. A proposed verifier therefore needs permission to read the existing signing secret, in addition to the new DynamoDB update permission. The current API task role has neither grant. A future public-key signature would let verifiers use a public key, but that is a different token design.
flowchart TB a["Scanner A (designed, not built)"] --> auth["Check-in API (designed, not built)"] b["Scanner B (designed, not built)"] --> auth auth -->|"Authorized ticket and event"| update["DynamoDB conditional UpdateItem (designed, not built)"] update -->|"status was ready"| admit["Admit once"] update -->|"Condition failed"| reject["Already checked in or invalid state"] class a,b,auth app class a,b,auth designed class update service class update designed class admit aws class reject failureLegend: all check-in operations are designed, not built; the conditional update is the single admission decision.
Give each scan attempt a request ID. A lost success response should let that same attempt discover its prior result, while a different attempt is rejected. Store that ID with the check-in state. If admission must update several records together, consider a transaction. Offline devices cannot all guarantee global single admission without coordination; make that product trade-off explicit.
Say it in an interview
Section titled “Say it in an interview”Follow-ups
Section titled “Follow-ups”Why not use the queue message ID for idempotency?
When would an LSI be better than a GSI?
Can a transaction include the ticket image upload?
How do you diagnose a hot GSI?
Would Streams replace every outbox?
Remember it
Section titled “Remember it”- Choose partition and sort keys from the reads and writes the application must perform.
- A condition and write happen together; a read followed by a write does not.
- GSIs have their own keys and capacity, and only eventually consistent reads.
- TTL is delayed cleanup; Streams retain changes for 24 hours.
- Our ticket retries resume stable records; email can still duplicate across its send-record gap.
Advanced
Section titled “Advanced”Advanced: Read-only table inspection and capacity reasoning
Console: DynamoDB → Tables → minitickets-dev-tickets → Overview, Indexes, and Monitor. For the completion table, inspect Additional settings → Time to Live. Avoid exporting customer ticket tokens or email-related records into diagnostic logs.
aws dynamodb describe-table --table-name minitickets-dev-tickets --region us-east-1aws dynamodb describe-table --table-name minitickets-dev-processed-jobs --region us-east-1aws dynamodb describe-time-to-live --table-name minitickets-dev-processed-jobs --region us-east-1A 5 KB strongly consistent item read needs two read units because reads round up in 4 KB blocks. A 1.5 KB item write needs two write units because writes round up in 1 KB blocks. Transactional reads and writes consume twice the corresponding units. Index writes add further consumption; a projection that copies every attribute is convenient but should be justified. AWS capacity units, AWS transaction capacity
UpdateItem can create an item if it does not already exist unless a condition prevents that. Specify existence and business-state conditions deliberately. For counters, atomic arithmetic avoids lost updates but duplicate delivery can still apply the increment twice; deduplicate the operation too.
The API’s maxAttempts: 1 in apps/api/src/queries/tickets.ts is a deliberate local configuration. Do not assume every SDK call has the same retry behavior. The queue workers and API requests have different latency and retry budgets.
Checkpoint
Section titled “Checkpoint”Answer these without scrolling up. If one feels hard, reread that section.
- What uniquely identifies a ticket, and why is the queue message ID unsuitable?
- Which exact operation prevents two workers reserving different canonical ticket records?
- Why can a successful table write be absent from its GSI briefly?
- Which email duplication windows survive the current conditional PutItem?
- What new read complexity would event-date sharding introduce?
Redraw it from memory: the worker, DynamoDB reservation, S3 upload and ready update
Try this: read the tickets GSI definition and compare its keys with the API's Query key condition
Read this book online: https://book.theversehub.in/