Back to Feed
article 3m read

The draft button checked for duplicates, then left a gap wide enough for a second one

The button that saves a delivery as a draft in the stores' inventory system checked first and wrote its record only after a slow outside call, so a second click could slip through. Now it claims the delivery in the database first, lets a partial unique index refuse the second claim, fixes a payload hash that couldn't see nested fields, and adds a narrow admin cancel for claims that get stuck.

Article DepthPractice

Flow

Building a claim-first button

  1. 1Pick the key

    Environment, store and delivery

  2. 2Partial index

    Count only requested and created rows

  3. 3Claim first

    Save the requested row before any outbound call

  4. 4One error means taken

    Only the uniqueness error becomes a conflict

  5. 5Settle the same row

    Update it to created or failed

  6. 6Ship the way out

    Admin-only cancel with a reason and no outbound call

The button I built for turning a pending delivery into a saved draft in the stores' inventory system had a gap in it. The gap was as long as the call to that other system.

It only runs in the sandbox so far. The old code checked for a finished draft, called the other system, waited, and then saved my record. While it waited, nothing on my side said a draft was being made. So a double click, a second tab or a second person could check, find nothing, and send a second create. A duplicate draft in someone else's system is not something I can undo. I never saw it happen. The order of the steps was enough to worry me.

Claim first, then call

Now my code saves a "requested" row first. Then it calls. Then it updates that same row to created or failed. One attempt, one row.

The database enforces the claim with a partial unique index. It covers the environment, the store and the delivery, but only for requested and created rows:

create unique index one_active_claim on claims (env, store_id, delivery_id)
  where state in ('requested', 'created');

The old check still runs first, to give a clear message. But if two requests both pass it, only one insert wins. The other gets a uniqueness error. My code treats that one error as "taken", logs it, and returns a conflict without calling out. Any other error still fails as an error. Failed rows sit outside the index, so a person can retry later. The page also says when a draft is still being made, not just when it's done.

A hash that missed the lines

Each attempt also saves a hash of the payload. The old helper passed the top-level keys to the JSON serializer as a filter. That filter hits nested objects too, so the lines came out empty. The new helper sorts keys at every level. The payload looks flat today, so I doubt it ever mattered. It would have the first time a nested field showed up.

A way out for stuck claims

If the process dies after saving the claim but before updating it, the claim blocks that delivery forever. My side can't tell if the call happened. So there's a narrow cancel: admins only, sandbox only, store named, reason required. It must match the delivery, and it can't touch a finished draft. It never calls the other system. It just marks the row cancelled and logs who and why. A person checks the other system before anyone retries.

The part I can't vouch for

My tests fake the database. They prove my code handles the error. They don't prove the real index fires on two real clicks, and I haven't watched it. The call also sends an idempotency key, a tag that could let the other system spot a repeat. Nobody has shown me that it reads it. Until someone does, the person checking the other system before a retry is the real safety net.

Keep reading

Pick up a connected idea or branch into a nearby one.

5 paths forward
Continue the thread

A supplier setup lease that trusted a name tag and the worker's own clock

The lease guarding my long supplier setup job only checked the owner's name, judged expiry by each worker's clock, and never renewed during slow phases. Now every claim bumps a fencing token, every write checks it against the database's clock, the phase-advancing writes also check the phase they expect to leave, and a heartbeat keeps slow work alive.

via #concurrency · #reliability · #databases