All work

CozySell

Subscription back-office software for resellers of a fashion platform's products: it takes their orders and carries each one through purchase and payment at the supplier automatically

Period
Since Feb 2026
Role
Planning, development, subscription billing, deployment, operations (solo)
Commits
3,425
Delivery
A dedicated server per customer, or installed on the customer's own server
Current version
1.4.307

Stack Django 5, DRF, Celery, PostgreSQL, Redis, Next.js 16, Chrome extension (MV3), Windows agent (Python), FastAPI, AWS ECR, EC2, Lightsail

CozySell order brand report screen. It has cards for order count, gross sales, cancellations, returns and net sales, and a table of sales by brand.
Order brand report. Test data, with brand names and the integrated tool's name blurred

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”
Auto-order design doc

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

  1. 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.

  2. 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.

  3. 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.

  4. 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.

  5. 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. 1

    A Chrome extension collects the supplier's product info and sends it to the server as about 5KB of data.

  2. 2

    Celery periodically pulls new orders from the product management tool, where orders from 6 marketplaces gather, and saves them as purchase candidates.

  3. 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. 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. 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. 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

  1. Deploy to the production server first
  2. Pin digests and register the release
  3. After operator approval, ship to canary customers
  4. Check, then ship to all customers

What happens on each customer server

  1. Check for a new version via heartbeat
  2. Pause payments and wait until 0 are in progress
  3. Pull and start the images, then migrate
  4. Health check

If the health check fails, the DB stays as is and only the images roll back to the previous version

CozySell release flow diagram
CozySell automation work queue. A notice says 1 auto-failed item, 1 auto-held item and 1 status-unknown item are waiting for user review.
Automation work queue. It gathers the items the automation wasn't sure about and hands them to a person. Status-unknown items are treated as neither failed nor succeeded

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. 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. 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. 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. 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.

OperatorAuto-purchase
  1. 1Customer order becomes a purchase candidate
  2. 2Buys it from the supplier directly (no run record)
  3. 3Product management tool status changesSync doesn't re-read the row, so it stays a candidate
  4. 4Runs the candidate as a first attempt
  5. 5Skips the supplier lookup because it's a first attempt
  6. 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)
A shortened version of the actual fix diff, with identifiers that name the supplier generalized

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. 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. 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. 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. 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. 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.

Updater (inside backend)docker compose
  1. 1Button clicked, runs right inside the request
  2. 2No docker tools, so the pull fails, responds with 502 and 'restored'
  3. 3(If the socket had been there) pull and digest check pass
  4. 4Runs the container replace commandTag changed, so it recreates the backend too
  5. 5Updater dies along with the backendNew backend starts
  6. 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.json
A shortened version of the actual code

Verification

  • 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. 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. 2. Did the cleanup code not get called?

    It was called normally. By design, it didn't reclaim locks from another host.

  3. 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. 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. 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.

DeployChromium
  1. 1Sep 16: container created
  2. 2Sep 17, 09:53: profile lock created
  3. 310:07: stop signal, force-killed after 2 minutesCan't release the lock
  4. 4New container, different hostname
  5. 5From Sep 18: launch refused every day
  6. 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 False

After

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")
A shortened version of the actual code. From browser_manager.cleanup_stale_locks to profile_lock.inspect_orphaned_container_lock

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.
Top of the CozySell landing page, with the headline 'Complex multi-store management, now made easy'.
Landing page, cozysell.ewr.kr

Next case

LORENZ Operations Platform

Staff copied and entered orders one by one, and checked deliveries by hand on courier websites.