Family Shopping List: Offline Sync and Convergence
HardAI-assisted coding · AI-Assisted Feature Work
Family Shopping List: Offline Sync and Convergence
You've joined the team behind Pantry, a shared shopping list that a family keeps on several phones. Phones are offline most of the time (supermarkets, basements, tunnels) and exchange data whenever two of them happen to meet. There is no server in charge: each phone is a replica that applies edits locally and merges with other replicas in whatever order the syncs happen. The one promise Pantry makes is convergence: once every phone has heard from every other phone, they all show exactly the same list. Support says that promise is currently broken, and the roadmap has three more changes queued behind the fix.
Codebase
| File | What it does |
|---|---|
pantry/api.py | run(replicas, operations) entry point and the Dispatcher that validates and routes each operation |
pantry/replica.py | Replica: one phone's copy of the list. Local edits, rows(), and how state is exchanged |
pantry/mesh.py | Mesh: the replicas of one family and the send / sync / converge exchanges between them |
pantry/stamps.py | ReplicaClock: the stamp a replica puts on each of its writes |
pantry/registers.py | LWWRegister: a value plus the stamp of the write that produced it |
pantry/names.py | Item-name normalization, quantity validation, and display_qty |
pantry/render.py | Turns rows into screen lines and into the digest fingerprint |
pantry/causality.py | Placeholder for version-vector helpers (Parts 3 and 4) |
pantry/workload.py | The storm traffic generator used by Part 4 (do not change the generator) |
pantry/errors.py, pantry/config.py | Error codes and constants |
tests/ | Existing example tests (some currently fail) |
How your code is run
run(replicas, operations) creates one empty replica per id in replicas (2 to 12 distinct, non-empty strings) and executes operations in order, returning one result per operation. Each operation is [name, *args]. Errors are returned as "error: <code>", never raised.
Edits happen on one replica. A replica learns about edits made on other replicas only through send / sync / converge; nothing else is shared.
| Operation | Arguments | Returns | Errors (checked in this order) |
|---|---|---|---|
add | replica, item, qty | true | unknown replica, invalid item, invalid qty, already on list |
remove | replica, item | true | unknown replica, invalid item, not on list |
set_qty | replica, item, qty | true | unknown replica, invalid item, invalid qty, not on list |
check, uncheck | replica, item | true | unknown replica, invalid item, not on list |
send | src, dst | true | unknown replica (src first), same replica |
sync | a, b | true | unknown replica (a first), same replica |
converge | none | true | none |
list | replica | list of lines (see below) | unknown replica |
digest | replica | [line count, crc32] | unknown replica |
wall | replica, seconds | true | unknown replica, invalid arguments (seconds must be an int, not a bool) |
adjust | replica, item, delta | true | Part 3 |
gc, tombstones | replica | an int | Part 3 (unknown replica) |
storm | seed, steps or seed, steps, pool | [applied, rejected] | Part 4 |
- A wrong number of arguments, or an operation that is not a non-empty list starting with a string, returns
"error: invalid arguments"; an unknown operation name returns"error: unknown operation".adjust,gcandtombstonesreturn"error: unsupported operation"until you implement them. - Item names are normalized by
names.normalize_item_name: surrounding whitespace removed, every run of whitespace inside the name collapsed to a single space, then lower-cased. A name that is not a string, is empty after that, or is longer than 40 characters isinvalid item." Whole MILK "and"whole milk"are the same item. - A quantity (
qty) is an int from 1 to 99 (booleans and floats areinvalid qty). - An item is on the list at a replica when that replica currently shows it.
addon an item that is on the list at that replica isalready on list;remove,set_qty,check,uncheck(andadjust) on an item that is not on the list there arenot on list. listreturns one line per item on the list:"[ ] milk x2"(unchecked) or"[x] eggs x12"(checked). Order: unchecked items first, then checked items; inside each group ascending by name in plain string comparison.send src dst:dstabsorbs everythingsrcknows;srcis unchanged.sync a b:aabsorbsb, thenbabsorbsa, thenaabsorbsbonce more (until Part 3 the third step cannot change anything you can observe).converge: with replicas in the order given torun,synceach neighbouring pair going forward (r0,r1,r1,r2, ...), then each pair going backward (r[n-2],r[n-1], ...,r0,r1).digestis a fingerprint of whatlistwould return:[number of lines, crc32 of the lines joined by "\n"].wall replica secondsis the phone's wall clock, pushed by the app whenever it changes.
Part 1 — Phones disagree after syncing (PANT-412, PANT-418)
Goal: make replicas converge, and keep deleted items deleted.
Support reports: (1) "Mom ticked off the milk and Dad unticked it at the same moment. After syncing, my phone said ticked and Dad's said unticked, and every sync swaps them." (2) "Kid's phone clock is set to 2037 and its edits beat everyone's, even edits made hours later." (3) "We deleted bananas, then someone's old phone synced and they came back."
Today every write is stamped with the phone's wall clock and ties go to whoever merges last. Replace that with a logical clock, and stop deleting by erasing.
Rules
- Each item name has one register holding either
(qty, checked)or *removed*, plus a stamp. Writes:addwrites(qty, unchecked);set_qty,checkanduncheckwrite the current value with one field changed;removewrites *removed*. Every write gets a new stamp, even when the value does not change (ticking an already-ticked item still writes). An item is on the list iff its register holds a value. - A stamp is
(counter, replica id). Each replica's counter starts at 0; a write setscounter = counter + 1and stamps(counter, replica id). Stamps compare counter first, then replica ids as plain strings ("mom" > "kid" > "dad"). The larger stamp wins; two different writes never share a stamp, so merging gives the same result in either direction. - A removal is just another write: *removed* competes with edits by stamp. A removed item stays removed on every replica that merges it, until a later
addwrites a larger stamp. Re-adding a removed item must work. - When a replica absorbs another's state, its counter becomes
max(own counter, other's counter), so its next write is stamped after anything it has seen. Do this for every absorb, even when nothing in the other state wins. - Wall clocks no longer decide anything.
wallis still accepted and validated (the app keeps sending it) but has no effect on any outcome. - Item names must be normalized exactly as described above.
Example (replicas = ["mom", "dad"])
[["add", "mom", "Milk", 2],
["sync", "mom", "dad"],
["check", "mom", "milk"],
["uncheck", "dad", "milk"],
["sync", "mom", "dad"],
["list", "mom"],
["list", "dad"]]returns [true, true, true, true, true, ["[x] milk x2"], ["[x] milk x2"]]: both edits have counter 2, and "mom" > "dad", so Mom's tick wins on both phones.
tests/test_api.py::test_sync_makes_both_phones_agree_even_on_a_tie currently fails.
Part 2 — Add wins over remove (PANT-431)
Goal: removing an item must only remove what the removing phone actually saw. If someone else adds the item at the same time, it stays.
Part 1's removal is "a write that competes by stamp", which has two problems: a remove can wipe out an add it never saw, and a stale edit (set_qty, check) with a larger stamp can undo a remove. Replace the presence part of the design with an observed-remove set; the value register still carries quantity and checked state.
Rules (these replace Part 1 rule 1's "on the list iff the register holds a value" and rule 3)
- Every successful
addcreates a tag(replica id, n), wherenis the number of successfuladds that replica has performed, including this one. Tags are never reused. removekills exactly the tags the removing replica currently sees alive for that item (they become tombstones). It writes no register and uses no stamp.- An item is on the list at a replica iff that replica knows at least one tag for it that is not tombstoned. Absorbing a replica unions both tag sets and both tombstone sets. A dead tag stays dead everywhere; a tag the remover never saw stays alive.
addstill writes the register(qty, unchecked);set_qty,check,uncheckstill write the whole(qty, checked)register by stamp, exactly as in Part 1. A register write never brings an item back: only a newadd(a new tag) does. The register is kept even while the item is off the list.- A replica that sees two live tags for an item (two phones added it independently) shows the register winner's quantity for now; Part 3 changes that.
Example (replicas = ["mom", "dad", "kid"])
[["add", "kid", "eggs", 6],
["sync", "kid", "dad"],
["add", "mom", "eggs", 12],
["remove", "dad", "eggs"],
["sync", "dad", "mom"],
["list", "dad"],
["list", "mom"]]returns [true, true, true, true, true, ["[ ] eggs x12"], ["[ ] eggs x12"]]. Dad only saw Kid's tag ("kid", 1) and killed it; Mom's concurrent add ("mom", 1) survives with her register write.
Part 3 — Counters, independent fields, and tombstone collection (PANT-455)
Goal: a product change. (a) Ticking an item and changing its quantity at the same time must keep both. (b) Quantity edits made at the same time must add up instead of one overwriting the other (the app is getting +/- buttons). (c) Tombstones must not pile up forever.
This changes Part 1/2 behaviour: concurrent set_qty calls no longer have a winner, and a register that holds (qty, checked) together is gone.
Rules
- Independent fields.
checkedis its own register per item name:(bool, stamp), initially(false, (0, "")), merged by larger stamp as in Part 1.addwrites it asfalse(new stamp);check/uncheckwrite it (new stamp, even if unchanged) and nothing else. Quantity is no longer a register. From now on onlyadd,checkanduncheckwrite a register and advance the replica's counter;remove,set_qtyandadjustdo not. - Quantity counters. Each live tag owns a PN-counter: for every replica
ra totalp[r]of increments and a totaln[r]of decrements. A tag's value issum(p) - sum(n). An item's stored quantity is the sum of the values of its live tags. Merging two copies of a counter takes the entrywise maximum of everyp[r]andn[r]. A tag created byaddwith quantityqstarts withp[adder] = q. When a tag is tombstoned its counter is discarded, so its quantity leaves the item. Two phones that add the same item independently therefore end up with the sum of both quantities. adjust replica item delta(deltaa non-zero int, not a bool; otherwiseinvalid delta). If the stored quantity at that replica plusdeltawould be below 1 or above 99, the error isinvalid qtyand nothing changes. Otherwise the change is applied to the lowest live tag (ordered by replica id as a string, thenn):delta > 0adds to this replica'sp,delta < 0adds-deltato this replica'sn.set_qtymeansadjustbyqty - stored quantity at this replica. If that difference is 0 nothing changes (and the result is stilltrue). Two phones that bothset_qtythe same item at the same time add their differences; that is the intended product behaviour.- Display.
listshowsmin(99, max(1, stored quantity)). The stored quantity itself is never clamped: after merging, it can be 0, negative, or above 99, and later edits are relative to it. - Events and version vectors. Every successful state-changing operation on a replica
ris an *event*:add,remove(one event, however many tags it kills),adjust,set_qtywith a non-zero difference,check,uncheck. Its dot is(r, k)wherekis the number of eventsrhas performed, including this one. Each replica keeps a version vectorvv:vv[q]is how many ofq's events it has absorbed. A tombstone remembers the dot of the removal that created it. - Acknowledgements. Each replica also keeps, for every *other* replica
y,known[y]: the latest version vector ofyit has heard about, starting at all zeros. When replicaxabsorbs replicas:x.vvbecomes the entrywise maximum ofx.vvands.vv, and for every other replicay(notx),x.known[y]becomes the entrywise maximum ofx.known[y]ands's view ofy, wheres's view of itself iss.vvand its view of anyone else iss.known[...]. (So knowledge travels through third parties.)x's view of itself isx.vv. gc replicadrops every tombstone at that replica whose dot(o, k)satisfiesview[o] >= kfor every replica's view held there (its ownvvand everyknown[y]), and returns how many were dropped.tombstones replicareturns how many tombstones the replica currently stores. Collecting never changes whatlistshows.- A collected tombstone must not come back: when a replica absorbs another's state, it ignores any tombstone whose removal dot its own
vvalready covered before this absorb (it either still has it or has collected it). Collecting too early is the dangerous direction: a phone that never saw the removal can still hold the tag alive and bring the item back.
Rules from Parts 1 and 2 that still apply: stamps and counter updates (they now order the checked register), tags and tombstones, names, ordering, error codes.
Example 1 (replicas = ["mom", "dad"]): both phones set the quantity from 3 at the same time.
[["add", "mom", "milk", 3],
["sync", "mom", "dad"],
["set_qty", "mom", "milk", 5],
["set_qty", "dad", "milk", 7],
["sync", "mom", "dad"],
["list", "mom"],
["list", "dad"]]returns [true, true, true, true, true, ["[ ] milk x9"], ["[ ] milk x9"]] (3 + 2 + 4).
Example 2 (replicas = ["mom", "dad", "kid"]): the removal is not acknowledged by Kid, so nothing is collected even though Dad has seen it.
[["add", "mom", "milk", 3],
["sync", "mom", "dad"],
["sync", "mom", "kid"],
["remove", "mom", "milk"],
["sync", "mom", "dad"],
["gc", "mom"],
["check", "kid", "milk"],
["sync", "kid", "mom"],
["list", "mom"],
["gc", "mom"],
["tombstones", "mom"],
["gc", "dad"],
["send", "mom", "dad"],
["gc", "dad"],
["tombstones", "dad"]]returns [true, true, true, true, true, 0, true, true, [], 1, 0, 0, true, 1, 0]. Mom's first gc finds nothing because she has no proof that Kid saw the removal. Kid's tick arrives while the tombstone still exists, so the milk stays removed. After the second sync, Mom knows everyone has seen it and collects it. Dad has to learn that from Mom (send) before his gc can collect.
Part 4 — Twelve phones, a year of shopping (PANT-470)
Goal: syncing must scale. Pantry now runs with up to 12 phones and long-lived lists; a sync that copies and re-merges the whole state is far too slow.
["storm", seed, steps] (or ["storm", seed, steps, pool]) feeds a deterministic pseudo-random mix of add, remove, adjust, set_qty, check, uncheck, send and sync operations through the normal dispatcher, so a few operations can stand for tens of thousands of events. The generator in pantry/workload.py is fixed (do not change it); seed is an int >= 0, steps an int in 0..50000, pool an int in 1..1000 (default 240) controls how many distinct item names are used. It returns [applied, rejected]: how many generated operations returned a non-error result and how many returned an "error: ..." string. Bad arguments (a non-int or bool, seed < 0, steps or pool out of range, any other argument count) return "error: invalid arguments".
Rules
- Everything from Parts 1 to 3 must behave exactly the same: same lists, same
digests, samegcandtombstonesnumbers. A change that makes things faster but moves any observable result is wrong. - The cost of
sendandsyncmust be proportional to what the receiver has not seen yet, not to the total size of the list and its history. Hidden tests run tens of thousands of generated steps across up to 12 replicas inside a singlerun, with severalconvergecalls, and the whole suite has a few seconds. Snapshotting and re-merging every item on every exchange will time out. - Replicas relay: a replica forwards what it learned from third parties too, not just its own edits. After
convergeevery replica must have the samedigest. gcandtombstonesare still exact and per replica (Part 3 rules 7 to 9).
Notes
names.py,render.py,workload.pyanderrors.pyonly need small changes, if any.- You may use the AI assistant freely; you are expected to verify what it produces.
- Later parts must not break earlier ones; hidden tests re-check earlier behaviour after the later rules are in place, and a scenario never depends on rules a Part has not introduced yet.
- Parts 1 to 3 are a strong result. Part 4 is a stretch.
Example 1
- Input:
- [["mom","dad"],[["add","mom","Milk",2],["sync","mom","dad"],["check","mom","milk"],["uncheck","dad","milk"],["sync","mom","dad"],["list","mom"],["list","dad"]]]
- Output:
- [true,true,true,true,true,["[x] milk x2"],["[x] milk x2"]]
- Explanation
- Mom and Dad each write the checked state at logical time 2; the larger replica id ("mom") wins on both phones.
Example 2
- Input:
- [["mom","dad","kid"],[["add","kid","eggs",6],["sync","kid","dad"],["add","mom","eggs",12],["remove","dad","eggs"],["sync","dad","mom"],["list","dad"],["list","mom"]]]
- Output:
- [true,true,true,true,true,["[ ] eggs x12"],["[ ] eggs x12"]]
- Explanation
- Dad's removal only kills the tag he had seen (Kid's). Mom's concurrent add survives.
Example 3
- Input:
- [["mom","dad"],[["add","mom","milk",3],["sync","mom","dad"],["set_qty","mom","milk",5],["set_qty","dad","milk",7],["sync","mom","dad"],["list","mom"],["list","dad"]]]
- Output:
- [true,true,true,true,true,["[ ] milk x9"],["[ ] milk x9"]]
- Explanation
- From the Part 3 rules the two concurrent edits are +2 and +4 on top of 3, so the quantity is 9.
Guidelines
- Python 3 standard library only
- Everything is deterministic: no wall-clock reads, no randomness, no dependence on dict or set iteration order in any returned value
- Every operation returns exactly one value; errors are returned as "error: <code>" strings, never raised
- Replica ids are distinct non-empty strings; a family has 2 to 12 replicas
- Part 4 inputs generate tens of thousands of edits and syncs from a few
stormoperations; the whole suite has a few seconds