← Rohan Sharma

Sharding a Pre-Order Engine: Why One Big Group Doesn't Work

Architecture · Webhooks · Sharding

A webhook that tells you what is still there, instead of what changed, quietly turns every edit into a full-catalog diff. This is the architecture that came out of fixing that — and the race condition that made me reorder every write in the system.

What pre-order actually is

The ask is one sentence: when a product sells out, offer a pre-order instead of “sold out”, and go back to normal when it restocks. The catalog lives in a commerce platform we integrate with, not in our database, so “pre-order” is not a column we own. It is three separate pieces of remote state that all have to agree.

VARIANTtracked · qty 4sells outavailable≤ threshold ?PRE-ORDERinventory policy: CONTINUEpre-order metafield: trueproduct ∈ selling-plan groupBUY NOWinventory policy: DENYpre-order metafield: clearedproduct ∈ selling-plan groupyesnorestock ↑sells out ↓
A variant crossing the threshold in both directions. Two of the three flags move; the third does not.

That last line matters more than it looks. Group membership is product-level and durable — set once when the merchant enrolls, untouched by every subsequent flip. The inventory policy and the metafield are variant-level and dynamic. Conflating those two layers is what let an early version flip non-enrolled sibling variants into pre-order alongside their enrolled siblings.

wantPreOrder = inventoryTracked && availableQty <= threshold

inventoryTracked is not defensive noise — a variant with untracked inventory has no meaningful stock level, so it must never flip at all.

Two systems, one contract

Three things move these flags and only one of them is us: our own writes, the platform's order and inventory events, and the merchant editing a product by hand in the platform's admin. That third one is fatal to the obvious design. Apply the flags at request time and call it done, and you are correct until the first time someone changes something without going through you — which is immediately.

So: two services, deployed to two different clouds, that never call each other. They coordinate through one shared store.

INTENTCONTRACTCONVERGENCEdashboardINTENT SERVICEmerchant enrolls orunenrolls productsCOORDINATION STORErealtime document DBDESIRED STATECOMMERCE PLATFORMsomeone else's databaseOBSERVED STATECONVERGENCE FNwebhook handlerstatelesstask queuesync worker1 task per variant① write intent② then write the platformwebhooksread desiredfan outrepair whichever flag disagreesneither service ever calls the other
The intent service declares what should be true. The convergence function observes what is true and repairs the difference. The store is the only thing they share.

The system is one reconciler between desired state (ours) and observed state (theirs). Neither side trusts the other's view of the platform.

The fan-out is not decoration. A product can carry dozens of variants and each repair is two or three Admin API calls. Do that inline in a webhook handler and you blow the platform's delivery timeout, it retries, and a non-idempotent handler multiplies the damage. So the handler classifies the event, enqueues one task per variant, and returns immediately.

The webhook that broke one big group

Enrollment started as one selling-plan group per store, holding every enrolled product. Simple, and it worked until we subscribed to the group-update webhook.

We needed that subscription: a merchant can detach a product from the group by hand in the platform's admin, and we have to notice and repair it. But the payload has a shape that changes everything.

The group-update webhook tells you what is still in the group. It does not tell you what was removed.

So there is no delta to read. To find out what changed, you diff the payload's membership list against your own record of that group's membership. With one group holding the whole catalog, that means diffing fifteen thousand product IDs against fourteen thousand nine hundred and ninety-nine to discover the single product that left — on every group mutation, including the ones we caused ourselves.

ONE BIG GROUPSHARDEDone selling-plan group15,000 enrolled productsmerchant detaches one by handgroup-update webhookpayload = the 14,999still in the groupdiff 15,000 against 14,999to learn which one left — every mutation,including our own writeswork grows with the catalogshard A50 / 50shard B50 / 50shard C49 / 50shard Dopens nextsame webhookpayload = the 49still in that sharddiff at most 50bounded by shard size, not catalog size —and only the touched shard is re-checkedwork is constant
Same webhook, same merchant action. The only thing that changed is how much membership one group is allowed to hold.

The cost is not just CPU on a list comparison. Reconciling a 15,000-product group means paginated reads back from the platform to establish what membership actually is now, against a rate limit, inside a webhook handler with a delivery deadline. On a large store that reconcile timed out — which means the repair never ran, which means a merchant's hand-detached product silently stayed broken.

Sharding fixes the shape of the problem rather than the constant. Cap membership at fifty products per group and the diff is bounded by the shard, not the catalog. A group-update webhook now carries at most fifty IDs, and only the shard that was touched gets re-checked. Per-webhook work stops being a function of how big the merchant is.

openShardIdone pointershard A50 / 50fullshard B50 / 50fullshard C31 / 50acceptingnext shardopens at 50names the one shard accepting writesslot claims run in a store transaction, so concurrent enrollment can't oversubscribe a sharda reverse index maps product → shard, so "which shard is this product in?" never leaves our own store
Shards fill in order. One pointer names the group currently accepting writes; a reverse index answers the opposite question.

Fifty is also roughly what the platform's rate-limit bucket absorbs in one batch, so enrollment paces itself structurally instead of leaning on retry-and-backoff. A shard whose remote group was deleted out from under us is self-healed — forgotten, with a fresh shard opened in its place.

The state that makes all of this navigable is small:

stores/<shop>/
├── config/
│   ├── allProductsEnabled          false      ← whole-store mode
│   ├── openShardId                 <group>    ← the shard accepting writes
│   ├── threshold                   0          ← flip at or below this qty
│   └── enrolledProducts/<product>/
│       ├── allVariantsEnabled      true
│       └── enrolledVariants/<id>   true       ← variant-level opt-in
├── productGroups/<product>         <group>    ← reverse index
├── sellingPlanGroups/<group>/
│   ├── count                       31
│   └── products/<product>          true
└── pendingGroupDeletes/<group>     true       ← two-phase teardown

One naming lesson worth passing on: that pointer was originally called sellingPlanGroupId, which reads like the group for the store rather than the shard currently accepting writes. More than one bug came from code that believed the name over the behaviour. It is openShardId now.

The race worth the whole post

Removing a product from the dashboard appeared to work perfectly. The platform confirmed the removal, all ten variants flipped to DENY, the metafield cleared. Then the product came back — re-attached to its group, every variant pre-order again.

Before

  1. Dashboard: remove product
  2. Intent service → platform: detach from group
  3. Intent service → platform: 10 variants → DENY
  4. Platform: looks done ✓
  5. Platform → convergence fn: product updated — fired by our own DENY writes
  6. Fn asks the store: still enrolled? → yes
  7. Fn faithfully re-attaches and flips all 10 back
  8. Intent service clears the store — too late

The cleanup triggered its own undo.

After — invert the order

  1. Dashboard: remove product
  2. Intent service clears the store first — desired state now says not enrolled
  3. Intent service → platform: detach, 10 variants → DENY
  4. Platform → convergence fn: product updated
  5. Fn asks the store: still enrolled? → no
  6. Fn no-ops ✓

Both removal paths — whole product and individual variants — were inverted, not just the one that showed the bug.

When a write to an external system will echo back to you as an event, update your own source of truth first. Any convergence loop that reads state the write path hasn't finished updating will cheerfully undo it.

Why a realtime document store and not Postgres

Fair question, and the honest answer starts with the boring part: it was already provisioned, so it cost no infrastructure lead time. That is a real engineering reason and pretending otherwise would be revisionism. It also happened to fit, for three reasons that held up:

  1. The two services live in different clouds. A shared relational database means one of them reaches cross-cloud into the other's network — peering or a public endpoint, credential distribution, an egress path to own and monitor. A store that is an authenticated HTTPS endpoint is equally close to both. For a database whose entire job is to be shared, that is an architectural property, not a convenience.
  2. The request pattern is hostile to a connection pool. The convergence side is a stateless function scaling horizontally, plus a queue worker invoked once per variant: many short-lived instances, each wanting a connection, none living long enough to amortise it. The usual fix is another moving part — a pooler or a data proxy. There is no pool to exhaust here.
  3. Every read is a known path, and the shape is a tree. Look at the state above: no joins, no aggregates, no reporting. Nested and sparse. In Postgres that is either a jsonb column — a document store with extra steps — or four tables and a migration for a schema that changed three times during the build. The one guarantee I actually needed was an atomic transaction on a single path, for the shard slot claim. That I got.

The cost is real and worth naming. There is no ad-hoc queryability: “which stores have shards over forty products” is a scan in application code. More pointedly, there is no way to express enrolled in one map XOR present in the other as a database constraint — and that is precisely the invariant the removal race violated. In Postgres it is a CHECK and the bug is unrepresentable. That is the trade I made. If enrollment ever needs reporting, it moves.


What generalizes