Flow
Building a top-up action that two taps cannot double
1Run it on the server
The page sends one request; the server reads the saved order.
2Clean the group key
Normalize product names so a stray space does not split a group.
3Lock, then read
Take a lock that lasts one transaction before loading the order.
4Return the changes
Send back each changed line and log it; zero changes on a repeat.
5Keep one rule
Turn off any older rounding that can change the same number.
6Test the overlap
Fire two requests at once and check only one reports changes.
Adding the same top-up twice was possible with the round-up-to-full-cases button. The math was right both times. Two runs of the rule just couldn't see each other.
The rule works on the whole product
The button is in the checkout dialog of the ordering system. Suppliers sell by the case, so each product in an order should add up to whole cases. The rule looks at a product across all the stores in the order. It adds up the lines, works out how much is missing to reach the next full case, and puts that on one line for a chosen store. If that store has no line for the product, it gets one. If the store isn't in the order, the button says no.
Clean up product names before you group by them. A stray space can split one product into two groups, and then both get rounded.
Say a case is twelve and the stores want thirty. Six are missing. Add six and you have thirty-six. Run it again and it adds nothing. But if two runs both read thirty, both add six, and you end at forty-two. That's off by six again, every time.
Moved to the server, still read too early
The first version ran in the browser. It used what was on screen, unsaved typing included, and saved each line one at a time. If a save failed halfway, the order was half done. A disabled button only stops a double click on one screen.
So I moved the rule to the server. The page sends one request. The server makes the changes and sends back a list of what it changed. The page reloads the order and throws away its drafts. Good so far. My mistake was reading the order before the transaction began. Only the writes were inside it. Two requests could both read thirty and both commit a valid change. A transaction makes your writes all-or-nothing. It doesn't stop someone else from reading the same starting point.
The same day, carts started following the person across devices. Now a phone and a desktop can change the same order, and the overlap is normal use.
Lock first, then look
The next commit put the read inside the transaction, behind a lock:
await t.lockForThisTransaction("top-up", owner, supplier)
const lines = await t.loadLines(owner, supplier) // read after the lock
It's a Postgres advisory lock that lasts one transaction. The key is the action, the person and the supplier. A second request for the same order waits. When it gets in, every group is already whole, so it reports zero changes. Other people's orders have other keys and don't wait. The lock lets go on commit or rollback, so a crash can't leave it stuck.
Nothing sets a lock timeout yet, so a stuck request would make others wait. The first version of the lock line used the wrong database helper, and a one-line follow-up fixed it.
Only one thing rounds now
An older rule let single-line edits round a number by a rough guess. Two rules that change the same number will clash. I turned the old one off, so only the group button rounds. The server now logs each change, which gives you a trail for odd numbers. Enter in a quantity box now just leaves the field. My read is that both were saving the same edit.
Later that day I added a second button that lifts every line under one case up to one case. It had its own lock from the start.
Gaps to close before you copy this
- Write a test that fires two requests at the same order and checks that only one reports changes. I don't have one.
- Decide whether plain line edits should take the lock. Mine don't.
- Find the chosen store by something steadier than its name. Mine matches the name.
- Check whether your bulk buttons share a lock key. Mine seem not to, so a phone and a desktop could overlap, though I'm less sure of this one.