Skip to content

DynamoDB

11 min read · main part only; folded sections are optional

After this you can answer: How do keys, consistency, conditional writes and capacity determine whether a DynamoDB design stays correct at scale?

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 aws

Legend: 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 →

  1. 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.
  2. The worker tries a PutItem with 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.
  3. 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.
  4. A creating item still needs its PNG uploaded to the stable S3 object key. A ready item can be skipped. Stable keys make a retry overwrite the intended object rather than create another ticket.
  5. After S3 accepts the image, UpdateItem sets the status to ready, conditional on the stored token matching. A crash before that update leaves work that redelivery can resume.
  6. The API queries the authenticated user’s partition with a strongly consistent Query. It follows LastEvaluatedKey, 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 LastEvaluatedKey

Legend: app participants call AWS services using their own roles; an existing ready item skips the upload and update.

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

At the time of writing; check the AWS docs. Capacity figures are units, not universal requests per second for every item size.

PropertyValue or behaviorSource
Maximum item size400 KB, including attribute names and valuesAWS constraints
Query pageUp to 1 MB before filtering; follow the cursorAWS query pagination
TransactionUp to 100 distinct items, 4 MB totalAWS constraints
Physical-partition throughputUp to 3,000 read units/s and 1,000 write units/sAWS key design
Secondary indexes20 GSIs by default; 5 LSIsAWS index guidance
LSI item collection10 GB per partition-key value, including table and index entriesAWS LSI reference
Streams record retention24 hoursAWS stream components
TTL deletionBackground deletion, typically within a few daysAWS TTL
MiniTickets completion retentionexpiresAt set 30 days aheadapps/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

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.

Caller → resourceExact allowed actionDefined in
Email worker → processed-jobsdynamodb:GetItem, dynamodb:PutItem on that tableinfra/src/jobs/worker-iam.ts, ProcessedJobsTable
Tickets worker → ticketsdynamodb:GetItem, dynamodb:PutItem, dynamodb:UpdateItem on that tableinfra/src/tickets/iam.ts
API task → ticketsdynamodb:Query on the table, no Scan or writesinfra/src/compute/iam.ts, ReadTickets
DynamoDB table → GSIAWS maintains the index from table writes; no worker call to update itinfra/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.

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 failure

Legend: red = retry and failed-delivery path; an expected conditional conflict can finish successfully instead.

What you seeWhy it happensFix
ConditionalCheckFailedExceptionAnother 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 ageA 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 ScanThe 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.
ResourceNotFoundExceptionWrong table name, Region, or local table not created.Compare runtime configuration with the table’s actual identity.
Empty GSI result just after a writeIndex 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, 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.

  • “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.

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 failure

Legend: 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.

Why not use the queue message ID for idempotency?
One booking can be published again under another message ID. The business key must identify the intended effect across publications.
When would an LSI be better than a GSI?
When the same partition key needs another sort order and strong reads matter. Choose it at table creation and account for the 10 GB item-collection limit.
Can a transaction include the ticket image upload?
No. A DynamoDB transaction covers DynamoDB items. Our stable object key and creating-to-ready state make upload recovery repeatable instead.
How do you diagnose a hot GSI?
Inspect throttling reasons, the affected index ARN, and index-key traffic. A well-distributed base table can still have a concentrated GSI key.
Would Streams replace every outbox?
No. Streams can propagate DynamoDB changes, but our booking transaction is in Postgres. A DynamoDB stream does not observe that commit automatically.
  • 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: 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.

Terminal window
aws dynamodb describe-table --table-name minitickets-dev-tickets --region us-east-1
aws dynamodb describe-table --table-name minitickets-dev-processed-jobs --region us-east-1
aws dynamodb describe-time-to-live --table-name minitickets-dev-processed-jobs --region us-east-1

A 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.

Answer these without scrolling up. If one feels hard, reread that section.

  1. What uniquely identifies a ticket, and why is the queue message ID unsuitable?
  2. Which exact operation prevents two workers reserving different canonical ticket records?
  3. Why can a successful table write be absent from its GSI briefly?
  4. Which email duplication windows survive the current conditional PutItem?
  5. 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/