Back to Feed
article 3m read

Checkout guard couldn't tell a rebuilt cart from an untouched one

A deleted-and-recreated cart line looked identical to my snapshot check, so reviewed checkout now trusts a database-owned version number and a one-time receipt instead of a comparison.

Article DepthPractice

Flow

Reviewed checkout, step by step

  1. 1Lock receipt

    Serialize attempts on one receipt.

  2. 2Validate receipt

    Right workflow, not expired; if consumed, replay the stored order.

  3. 3Compare generation

    Live cart counter must equal the receipt's, otherwise plan-changed.

  4. 4Recheck business rules

    Catalog eligibility, case sizes and delivery plan against current data.

  5. 5Write and consume

    Order, revision and command receipt written; receipt marked consumed in the same commit.

  6. 6Conflict path

    A serialization failure restarts the transaction a bounded number of times, then reports exhaustion.

A checkout guard could be fooled by a cart that was deleted and rebuilt exactly as it was.

In the ordering system the planner fills a draft cart, the plan gets reviewed, and a workflow later turns the cart into a purchase order. Checkout must write the cart that was reviewed, or refuse and change nothing.

Where comparing falls short

The old guard took my app's lock on the cart, reread every line, and checked that the line IDs and last-changed times matched the snapshot from review. That catches added, removed and edited lines.

It misses two things. First, if someone deletes a line and puts back an identical one, same ID and same times, the snapshot still matches. This is called the ABA problem: things changed and changed back, and a check that only looks at the start and end sees nothing. Second, the lock was my app's lock. Code that doesn't ask for it, like a script or a console session, can change lines anyway.

A number that only goes up

I added a version number to the cart. A trigger in Postgres adds one to it every time any line is inserted, changed or deleted, no matter who did it. If a line moves between carts, both carts go up. Since the number only climbs, the delete-and-rebuild trick now shows up as a change.

The price is that every line write also writes the cart row. Two writers on the same cart now wait for each other.

The receipt checkout trusts

When the reviewed plan is saved into the cart, the same transaction saves a receipt. It holds the cart, the version number right after saving, a hash of the plan, the workflow run that owns it, a description of each line, an expiry a few minutes out, and a spent flag.

Checkout gets only the receipt ID and the run ID. It looks everything else up, so no caller can sneak in a different plan. I also cut the public checkout endpoint down to the plain manual path, which rejects reviewed requests and planner-made lines.

The checkout itself runs as one strict transaction:

It locks the receipt first. If the receipt is spent, it replays the saved order. If it has expired, it says no. Then it locks the cart and refuses if the cart changed since review.

Then it reruns the catalog, case-size and delivery checks, writes the order, and marks the receipt spent in the same commit. If Postgres says two transactions collided, it retries a few times, then fails with its own error so "busy" doesn't look like "changed".

One connection sets it up, the other changes it

Mocks can't fake how a real database locks, so the tests use two real connections. One sets up a reviewed cart; the other changes it. These all get refused with no order written: a changed quantity, the delete-and-rebuild, an extra line, a made-up receipt, a receipt from another run, and an expired one. Two checkouts at once on one receipt give one order, and the second caller gets that order back. CI runs this on a real Postgres, and the main job needs it to pass.

The migration that sorted first

My migration name was all digits after the date. One it depended on had an underscore after the date. Migrations run in name order and a digit sorts before an underscore, so mine was first in line. I renamed it, and a deploy check now fails if a migration sorts ahead of a listed prerequisite.

Keep reading

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

5 paths forward