Skip to content

Build1 publisher2 min readPublished

Conditional PutItem claim on orderId blocks duplicate charges from repeated SQS messages

SQS standard queues and Lambda event source mappings both deliver at least once, and that guarantee is about the envelope. A lab in us-east-1 sent the same 150-real order four times and logged four charges.

The Engineer · Build desk

Illustration accompanying Conditional PutItem claim on orderId blocks duplicate charges from repeated SQS messages

What happened

  • Running the worker with the IDEMPOTENCY switch set to 0, the lab sent the same order several times and its log query showed four invocations and four charges, with no claim or skip events at all.
  • A CloudWatch Logs Insights query over the charge_applied events counted four charges, four distinct MessageIds and one distinct orderId.
  • A standard SQS queue delivers at least once, and Lambda's documentation says the event source mapping processes at least once on the consuming side, so the worker code has to be idempotent.
  • With the switch on, the worker claims the orderId in DynamoDB before charging, and the copy that loses the claim logs skip_duplicate and returns without charging anything.

Compiled by The EngineerSomething wrong?How this is made

Why it matters

  • constraint Deduplicating on MessageId cannot catch a user's second click, because the queue mints a fresh MessageId for the retry and the only identifier the two messages share comes from the order record.
  • decision Anyone copying the pattern has to pick the claim key and make the producer set it before the send; an id minted per click gives the consumer nothing to compare against.
  • exposure On a real charge path the guard-off behaviour bills once per message, so four deliveries of a 150-real order come to 600 reais against 150 owed.
  • cost The guard puts a conditional DynamoDB write in front of every message and a second write behind every successful charge, paid on the hot path whether or not a duplicate ever arrives.

The race happens between two invocations. Inside one invocation there is nothing to race. The event source mapping is set to batch size 1, so each copy of the order becomes its own invocation and the two can run at the same time [7]. The obvious guard is useless: two functions read the table, both see no row for the orderId, both charge, and both write the row afterwards [8].

So the check and the reservation have to be a single operation. In DynamoDB that is `PutItem` with `ConditionExpression: "attribute_not_exists(orderId)"`, writing the orderId as the key plus status `CLAIMED`, the messageId, the requestId and a claimedAt timestamp [9]. The winner logs `claim_won`, charges, then marks `COMPLETED`. The loser gets `ConditionalCheckFailedException` back from DynamoDB, logs `skip_duplicate` and returns without charging [10].

Ordering the two writes matters, and the lab gives its reason: marking an order processed and then having the charge fail would leave the retry skipping an order that never went out [11]. The same ordering has a cost in the other direction. Because the loser path keys on the row existing, whatever its status [10], a function that dies between the claim and the charge leaves a `CLAIMED` row, and every later retry of that orderId logs `skip_duplicate` [18].

The charge here is a log line called `charge_applied`, and the lab has no payment gateway and no FIFO queue [4]. Four charges are four log lines. ORDER-003 is 150 reais [3], so the same four messages hitting a real gateway would bill 600 reais for an order worth 150 [16]. That figure transfers only if whatever sits behind `charge_applied` is non-idempotent at its own boundary. A gateway that deduplicates on an idempotency key derived from the orderId already absorbs the second message, and the DynamoDB claim then saves only an invocation.

One detail in the worker will bite anyone who copies it. A message whose body is not valid JSON is processed with `orderId: "unparseable"` [12]. With the guard on, the first malformed body claims that key, and every later malformed body loses the claim and is logged as a duplicate of it [17].

The producer-side test exists because the first attempt failed. Delaying the queue's clock did not manufacture a duplicate, because Lambda keeps extending the visibility timeout while the function is still running [15].

What to watch

  • The published run stops at the guard-off counts; the IDEMPOTENCY=1 numbers, including how often claim_lost fires, are the real test of the pattern.
  • A batch size above 1 puts both copies inside one invocation, where in-process state decides what gets charged and the conditional write does not.
  • A FIFO queue with a message deduplication id derived from the orderId would move the guard into SQS; this lab ran a standard queue.
Loading claim ledger
Loading source directory links
Loading share composer
Loading topic controls
Loading related stories