Shopify Inventory Sync Lab

Shopify retries webhooks. It does not promise order.

A store’s stock level is built from a feed that repeats itself and arrives shuffled. This page replays one such feed through two implementations of the same job — the obvious one, and the one that survives it — and reports how far each ends up from the truth.

The feed

1,023real stock movements
1,365webhook deliveries
342repeat deliveries
496delivered out of order

25 SKUs, including 118 stock counts from the ERP. Repeats are what Shopify sends when a handler times out or answers anything other than 2xx; reordering is what independent connections do on their own. The scenario is seeded, so this run reproduces exactly.

The result

naive

Drifted

16 / 25 SKUs wrong

157 units of drift (4.92%). Every delivery was applied, including the repeats.

Stock is a number that each delivery mutates, so a repeat is counted again and a late stock count erases what came after it.

ledger

Exact

0 / 25 SKUs wrong

0 units of drift (0.00%). 342 repeat deliveries recognised and ignored.

Stock is derived from the set of events observed, so a repeat changes nothing and arrival order cannot matter.

Where naive ends up

SKUTrue quantityReportedDrift
SKU-019221195-26
SKU-016212187-25
SKU-0124524-21
SKU-023139121-18
SKU-003169153-16
SKU-008117105-12
SKU-025198-11
SKU-001170160-10

A positive drift is stock the store believes it has and does not — overselling, then apologising. A negative drift is stock sitting unsold. Note how small the percentage looks: the periodic ERP counts keep resetting the error, which is why a bug like this survives in production for months before anyone traces it.

Why the ledger holds

Stock is not stored as a number that gets mutated. It is stored as the set of events observed, and the quantity is derived from that set. Two properties fall out of that, and they are exactly the two a webhook consumer needs.

Repeats are free. The event id is derived from the webhook id, so a redelivery carries an id already in the set and appending it changes nothing. There is no window in which a retry can be counted twice — not because a check runs first, but because there is nothing left to count twice.

Order does not matter. The quantity is a pure function of the set, not of the sequence. A cancellation that overtakes its own order, or a stock count that lands after the sales it already includes, both arrive at the same answer.

$ npm run chaos
$ npm run chaos -- --seed 7 --skus 50 --events 80
Exits non-zero if the ledger is off by a single unit, so it runs as a CI gate rather than a demo. Writes docs/chaos/latest.md.

What this does not cover

The store here is in memory, so a restart forgets everything. In a real deployment the ledger is a table with a unique index on the event id and the projection is materialised; the semantics are the same and the handler is unchanged.

The feed is generated rather than captured from a live shop. It reproduces the three failure modes that actually bite — redelivery, reordering, and a late absolute count — but not a specific merchant’s traffic. And a scenario cannot prove the absence of bugs; it can only keep this class of bug from coming back unnoticed.