Problem
Resellers list products from a major Korean fashion platform (the supplier, from here on) on 6 online marketplaces. When an order comes in, they buy the same product from the supplier and ship it straight to the customer. Order collection was already automated, but buying from the supplier, writing the result back to the order and entering the tracking number were done by hand, one order at a time.
Sellers with several businesses had it worse. Each store and business an order came through needed its own supplier account and payment method. The product management tool they used only gathered the orders; it didn't buy anything from the supplier.
“[Supplier] accounts and payment methods must be separated for each store/business group the order came in through”
Goals
- Give each customer their own server so customer data never mixes.
- Never pay for the same order twice. Pay for each candidate only once, and never run 2 payments on the same account at the same time.
- Never update while a payment is in progress. Pause, update once in-progress payments reach 0, then turn it back on.
- A new version ships only after operator approval, starting with canary customers.
How I got there
Feb 2026
I built product collection first
When I took apart the extension of the product management tool we used, it was sending whole product-page HTML to its server for parsing, hundreds of KB to several MB at a time, and it was tied to one storefront platform. I connected the marketplace APIs first and built a new extension that parses in the browser and sends only a few KB of data.
Mar to Apr 2026
Manual purchases, automatic paid-order alerts
Purchases at the supplier were still made by hand. Instead, the server gathered paid orders and sent an hourly Telegram alert. I also considered having the extension check for orders, but scheduling and retry state had to live on the server, so I dropped that.
May 2026
Automating up to just before payment
The first auto-purchase stopped at the final check screen before payment, and paying needed approval in Telegram. I built a dedicated purchase worker instead of adapting an existing one. One way of reading the payment keypad I tried and then rolled back.
May to Jun 2026
From a button a person pressed to an automatic chain
I designed separate supplier accounts and payment methods per business group, and made purchases started by hand go through the same chain. After a valid 35-hour-old order dropped out of the candidates, I extended the candidate window from 2 hours to 48. Approval now passes automatically when the margin conditions are met, and I added a duplicate-payment guard and blocking for unconfirmed payments.
May to Jul 2026
A server per customer, updates over heartbeat
I first assumed several customers on one server, but at the end of May I switched to a server per customer and built the license system. Refusing to boot without a license meant a server restarted after expiry couldn't even reach the renewal screen, so I changed it to boot in a locked state. Updates moved from a manual SSH plan to customer servers checking for new versions over a heartbeat and receiving them canary first, after operator approval. Self-hosted servers never update on their own; the customer approves it in the app.
How it works now
Here is the order things run in, from an incoming order to payment, and from a new version to a customer server.
- 1
A Chrome extension collects the supplier's product info and sends it to the server as about 5KB of data.
- 2
Celery periodically pulls new orders from the product management tool, where orders from 6 marketplaces gather, and saves them as purchase candidates.
- 3
The auto-order chain checks for claims, settlement amounts, recent run history and locks, and picks only the orders that qualify. Up to 5 at a time, every 90 seconds.
- 4
A worker fills in the supplier's order form and pays through a one-click payment service. Each payment account handles only one order at a time.
- 5
If the result is uncertain, it stops as 'blocked' and sends a Telegram alert. Confirmation is done by checking the order history, never by paying again.
- 6
Each customer server sends a heartbeat to the license server to check for a new version. Once in-progress payments reach 0, it pulls and starts the images, and if the health check fails, it goes back to the previous version.
How a new version reaches customers
- Deploy to the production server first
- Pin digests and register the release
- After operator approval, ship to canary customers
- Check, then ship to all customers
What happens on each customer server
- Check for a new version via heartbeat
- Pause payments and wait until 0 are in progress
- Pull and start the images, then migrate
- Health check
If the health check fails, the DB stays as is and only the images roll back to the previous version

Key decisions
A separate server for each customer
- Why
- With many customers on one server, the code has to keep guarding against data mixing between customers. With separate servers that risk nearly disappears, and each server can have its own encryption key.
- Alternatives
- A multi-tenant setup with many customers on one server
- Trade-off
- I have to run as many servers as there are customers. So I had to build the license server, remote updates and canary deploys myself.
When unsure, 'blocked' instead of failed
- Why
- Marking it failed and retrying can pick the same order for purchase again and cause a double payment. That actually happened to me, so the default is now to reclassify the status and block payment.
- Alternatives
- Record it as failed and retry automatically
- Trade-off
- An alert that looks like a failure goes out first, which can be confusing, and auto-purchasing for that account stops until it's confirmed.
A digest match and a signed record for every release
- Why
- I need to know exactly what gets installed on a customer server to roll back when something breaks. The digests of all 3 images must match exactly. A release moves only in the order planned, in progress, done. Each step leaves an Ed25519-signed record. Canary targets are fixed by a hash of the rollout ID.
- Alternatives
- Auto-updates where customer servers pull the latest version on their own, or adopting Kubernetes
- Trade-off
- The process got heavier. Once I got the record order wrong and had to reissue a release.
Hard problems
This includes problems I caught before release.
Paying again for an order the operator had already bought
Auto-purchase paid again for an order the operator had already bought from the supplier by hand, so the same product was paid for twice. It happened 5 days after I added a guard against duplicate payments.
Why it was hard
When a person orders from the supplier directly, no auto-purchase run record is left, so duplicate checks that only look at the DB couldn't catch it. There was a guard that looks up the supplier's order history, but I had set it to turn on only for retries. I had assumed first attempts were safe. On top of that, when the status changed on the product management tool's side, the sync didn't re-read that row, so an order that had already been handled stayed a purchase target.
How I traced it
1. Were the matching criteria wrong?
The start of the guard showed that on a first attempt, it never looked up the supplier's order history at all and just returned 'no duplicate'.
2. Why was an already-handled order still a candidate?
When the status changed in the product management tool, the sync didn't re-read that row, so the local row was stuck in its old state.
3. Could I just delete the condition?
No. On a first attempt, the lookup started from the run record's creation time, so an order a person had placed directly before that fell outside the range.
4. Which account should the lookup use?
A first attempt may have no account-selection record, so I made it use the supplier account set in the order routing instead.
Root cause
The duplicate guard (evaluate_run_for_duplicate) skipped the supplier lookup and returned 'no duplicate' right away whenever the run wasn't a retry.
- 1Customer order becomes a purchase candidate
- 2Buys it from the supplier directly (no run record)
- 3Product management tool status changesSync doesn't re-read the row, so it stays a candidate
- 4Runs the candidate as a first attempt
- 5Skips the supplier lookup because it's a first attempt
- 6Pays, and the same product is paid for twice
Fix
It now looks up the supplier's order history on first attempts too. The lookup starts from the time the customer placed the order, and if there's no account-selection record, it uses the account set in the routing. It blocks payment and removes the candidate only when product number, option, quantity and amount clearly match. I kept one exception: if the supplier lookup itself fails, it lets the order through so normal orders don't all halt. That is a separate decision from blocking when a payment result is uncertain.
Before
if not _is_redispatch(run):
return DuplicateMatch("none", reason="first_attempt")
since = _first_attempt_at(run)After
is_redispatch = _is_redispatch(run)
account_ids = _supplier_account_ids_for_order(run)
if not account_ids:
fb = _dispatch_supplier_account_id(run) # account set in the routing
if fb:
account_ids = [fb]
since = _first_attempt_at(run) if is_redispatch else _candidate_order_datetime(run)Verification
- 30 duplicate guard tests pass, including 4 new ones (detects an order a person bought first, passes when the lookup fails, no matching order, no account)
- 63 pipeline integration tests pass
- Shipped as 1.0.38 the same day after an independent review approved it
Prevention
- I corrected for false positives so a different recipient or option isn't treated as a duplicate.
- If the supplier's order list comes back cut off, it's treated as 'incomplete'.
- I wrote 'duplicate order detection' into the design doc as an invariant that must not be weakened, even in designs that raise throughput.
Takeaway
An order a person bought at the supplier first leaves no auto-purchase run record, so the supplier's order history is checked on the first attempt too.
The update button that shut itself down
On versions 1.0.5 through 1.0.7, self-hosted customers couldn't update from inside the app. Pressing the button showed a 502 with the message 'restored to the previous version', but nothing had actually changed. The only option left was for an operator to SSH in and update by hand.
Why it was hard
The bug had 2 layers. The backend container had no docker tools or socket. Even with those added, the update recreated the backend too, and the process running the update died with it. The updater was replacing the very container it was running in. And simply recreating a worker that held a payment session would also cut off the payment in progress.
How I traced it
1. Where does the 502 come from?
The update API ran the update synchronously inside the request, and it failed right at the first image pull.
2. Would attaching the docker socket be enough?
No. The moment the container was recreated, the updater died with it and never reached the health check or rollback. I concluded the update logic was fine and only the place it ran was wrong.
3. Then where should it run?
I had a systemd watcher on the host see a request file and start a one-off container outside the compose project. I dropped the idea of rewriting it in bash because it would duplicate the verification and rollback logic.
4. Would it work on a real server?
A smoke run that changed nothing turned up 2 problems. The systemd environment had no $HOME, so the script exited right away, and the working-directory option overrode the image's settings.
5. What about the first real update?
The health check request got a 400 for a host name that wasn't allowed, and file permissions kept it from reading or writing settings. Each time, the automatic rollback protected the server. I fixed them one by one, and the update from 1.0.9 to 1.0.10 succeeded.
Root cause
The update API (FleetApplyUpdateView) ran the update synchronously while handling the request, inside the backend container it was about to replace.
- 1Button clicked, runs right inside the request
- 2No docker tools, so the pull fails, responds with 502 and 'restored'
- 3(If the socket had been there) pull and digest check pass
- 4Runs the container replace commandTag changed, so it recreates the backend too
- 5Updater dies along with the backendNew backend starts
- 6Health check, rollback and result report never run
Fix
The API no longer runs the update itself. It only leaves a request file and returns 202. If a request already exists, it returns 409. I set up a systemd watcher on the host that watches for a request file and runs a script. The script takes a lock, pauses auto-purchasing, waits until in-progress payments reach 0 and then starts a one-off update container from the current version's image. This container sits outside the compose project, so it survives when the other containers are recreated.
Before
try:
result = run_fleet_update(str(offer["target_version"]), ...)
except FleetUpdateError:
return Response({"detail": "...restored to the previous version"}, status=502)After
# API: leave only a request and respond right away
if self_update_request_pending():
return Response({...}, status=409)
if not request_self_update(offer): # .fleet-control/request.json
return Response({...}, status=501)
return Response({"queued": True}, status=202)
# host script (run by a systemd path watcher, shortened)
flock -n 9 || exit 0; trap cleanup EXIT INT TERM
pause; drain until inflight == 0 # abort the update after 15 minutes
docker run --rm -v /var/run/docker.sock:... <current version image> \
python manage.py fleet_update --request request.jsonVerification
- 80 fleet tests pass, with 17 new tests (request queue, 501 when not installed, 409 on a duplicate request, stale requests invalidated and more)
- Addressed the 2 HIGH findings from code review
- A no-change smoke run on a real server, then a real update from 1.0.9 to 1.0.10
Prevention
- A request file expires after 1 hour, and only one runs at a time.
- If in-progress payments don't reach 0 within 15 minutes, it doesn't update.
- I made a patch-up script for customers who were already installed, and new installs get the allowed-hosts setting automatically.
Takeaway
A comment saying recovery is guaranteed doesn't count until the recovery path has actually run. When I add a new self-update mechanism, its first install goes the old way.
A browser profile lock left behind by a forced kill during deploy
An account that runs supplier jobs failed every day for 11 days. Each run ended in 0.006 seconds, before the browser even opened, and the last 10 failures were all 'browser launch failed'.
Why it was hard
Chromium's profile lock has the form 'hostname-PID', and inside a container the hostname is the container ID. When the container is recreated, the leftover lock looks like 'a profile in use by another computer'. The app's cleanup logic was set not to touch locks with a different hostname, and since it was a different PID namespace, there was no way to check whether that process was alive. It was also intermittent: it only happened if a browser was open at the moment of the forced kill.
How I traced it
1. Is the lock owner still alive?
The hostname the lock pointed to didn't belong to any container, and there were 0 Chromium processes using that profile.
2. Did the cleanup code not get called?
It was called normally. By design, it didn't reclaim locks from another host.
3. Is an offline recovery tool enough?
The first fix required stopping the 5 workers that share the same volume, so it couldn't run in production.
4. When was the lock created?
Lining up the lock file's timestamp with that day's deploy log, there was a record 14 minutes later of a forced kill for 'not stopping within 2 minutes'.
5. Do all the failures have the same cause?
Failures that ended at exactly 30 seconds had a different cause, a page-load wait timeout. I classified them separately.
Root cause
During a deploy, the container went past its 2-minute stop grace period and was force-killed, and Chromium couldn't release the lock. A lock pointing to a container that no longer existed was left behind, with no path to reclaim it. The same server saw 40 forced kills during deploys in 60 days.
- 1Sep 16: container created
- 2Sep 17, 09:53: profile lock created
- 310:07: stop signal, force-killed after 2 minutesCan't release the lock
- 4New container, different hostname
- 5From Sep 18: launch refused every day
- 6Sep 28: online preview, then appliedRecovered without stopping the workers
Fix
If the lock's hostname looks like a container ID, it checks whether any container with that ID still exists, in any state. Then it scans every process on the host to see whether any Chromium process is using a profile directory with the same device and inode. If any check is incomplete, it doesn't delete. I also added an online recovery mode that doesn't stop the workers, and a step that reclaims locks automatically after every deploy.
Before
host, pid_text = os.readlink(lock_path).rsplit("-", 1)
if host != socket.gethostname() or not pid_text.isdigit():
return False # once the container changes, always FalseAfter
m = re.fullmatch(r"([a-f0-9]{12}|[a-f0-9]{64})-([1-9][0-9]*)", target)
for c in containers: # containers in every state
if c["Id"].startswith(m[1]) or c["Config"].get("Hostname") == m[1]:
raise ProfileInUseError("former_owner_container_present")
ident = (info.st_dev, info.st_ino)
for ref in process_refs: # --user-data-dir resolved against /proc/<pid>/root
if ref.error:
raise ProfileInUseError("host_process_scan_incomplete")
if ref.identity == ident:
raise ProfileInUseError("host_profile_process_still_active")Verification
- 14 new tests, 109 related tests pass
- Reproduced the forced kill with a real Chromium container and confirmed it doesn't reclaim locks whose owner is alive or paused
- Confirmed the regression tests fail when the fix is reverted, then previewed and applied it on the production server
Prevention
- Leftover locks are reclaimed automatically after every deploy. If reclaiming fails, the deploy doesn't stop. It only leaves a warning.
- Browser launch failures are recorded by cause.
- Adding the same reclaim step to the customer server update path is still on the list.
Takeaway
Chromium's child processes can still be alive after the lock is released, so I check the processes directly before deleting a profile.
Results
- Production deploy
- From 38m 48s to 5m 58s
- Customer image publish
- From 10m 36s to 4m 12s
- Customer server update
- 2m 58s from payment pause to resume
- Releases
- 241 in 60 days (June to early August 2026)
- Windows agent rebuilds
- About 80% fewer
- License server tests
- 598 test functions
- Operators no longer buy and pay at the supplier one order at a time. The automatic chain picks the orders that meet the conditions and pays for them, and only the cases it isn't sure about stay in the work queue for the operator to check.
- Customer server updates went from manual SSH work to a single button in the app.
- The monitoring screen shows a customer PC's status even when the browser on that PC is closed.
What I learned
- The image deployed to our own production server first is pinned by digest, and that same image goes out to customer servers. Nothing is rebuilt per environment.
- An update counts as successful when the health check passes and payments resume. A finished deploy command alone doesn't count.

