Munchable scans a barcode and tells you whether the product suits the gut conditions you have. The verdict is computed on the device, by a deterministic rules engine that ships inside the app, because the list of conditions someone has is the most sensitive thing in the product and the cleanest way to protect it is to never send it anywhere.
That decision buys a lot of privacy and creates one problem. The engine's knowledge of ingredients is data, and data improves daily. If knowledge only reaches a phone in an app release, then every correction waits on a store review, and users on an old build quietly get worse answers than users on a new one.
So the knowledge is delivered separately from the app. This is how that works, including the two things that went wrong on the way.
One snapshot, two halves, one version
The payload is an overlay: a set of rows the engine merges on top of the vocabulary compiled into the build. It has two halves that do different jobs.
The first half is what the engine can name. Aliases, extensions, and the noise list that throws away label boilerplate. This is what turns "dehydrated garlic" and "garlic powder" into the same thing.
The second half is what the engine can say. These are the ingredient ids that curation has placed into a rule set, so a name the engine merely recognises becomes a name it has an opinion about.
Both travel in the same response under the same version number, and that is deliberate. Version them apart and a device can end up holding a rule keyed on an ingredient id its naming half has never heard of. The engine would drop the row on load, correctly, and the coverage would simply fail to arrive with no error anywhere. One snapshot, one version, no skew.
The version is not a counter
There is no version table and no bump step. The version is an aggregate:
coalesce(
(extract(epoch from max(updated_at)) * 1000)::numeric(20,3)::text,
'0'
)
Epoch milliseconds with three decimal places, as a string, taken over the active rows of both tables, with the newer of the two winning. That has some properties worth stealing:
- Nobody can forget to bump it. Any write to either table moves it.
- Retiring a row moves it too, so a device learns that a row went away, which a count of rows would not tell it.
- Microsecond precision means two writes in the same millisecond still produce different versions.
- The comparison is numeric, never lexical, on every side.
"1774.5"versus"999.9"is a trap that string comparison walks straight into.
The 815 millisecond FILTER clause
Only active rows count toward the version, and the first implementation expressed that the natural way:
max(updated_at) FILTER (WHERE status = 'active')
Postgres will turn a bare max(col) into "walk to the end of the index and read one entry". It will not do that when the aggregate carries a FILTER clause, because the filter has to be evaluated per row. So the plan reverted to a full scan: 815 ms on our plan, across 40 thousand rows, on every product lookup whose caches had gone cold.
Moving the same predicate into a WHERE clause restores the index path. The result set is identical. The query now touches three index pages.
-- three index pages
SELECT max(updated_at) FROM catalog.taxonomy_entries WHERE status = 'active';
It is a good reminder that an aggregate and a filtered aggregate are not the same operation to the planner, even when they return the same number.
Three caches, and the one that deleted itself
Reads go memory, then Redis, then Postgres. Module memory holds the snapshot for 60 seconds per instance. Redis holds it gzipped for a day. Postgres is the truth.
A write does not refill the caches. It evicts the snapshot, publishes the new version, and lets the next reader rebuild. That is one fewer moving part than a write-through cache, and the write still returns the new version so the route that wrote can stamp it on its response.
The Redis tier had a size guard, since the hosted Redis we use takes about a megabyte per request. The guard was written against the uncompressed JSON string, with a 512 KB ceiling. The snapshot passed that for a long time, and then the overlay crossed roughly twenty thousand rows and it never passed again.
The failure mode is the unpleasant kind. Nothing errored. The cache function silently took its else branch, which deletes the key, so the Redis tier stopped existing, and every instance whose 60 second memory cache had expired paid a full table read of about seven seconds to answer a request. The endpoint was up the whole time. It was just slow in a way no alert was watching for.
The fix was to measure the guard against the bytes actually stored, which is after gzip, where the same snapshot is a few hundred KB. The comment above the constant now says that if this is ever exceeded again the answer is to shard the key rather than raise the number, because the real ceiling belongs to the transport and raising it past that does not make the request succeed.
The conditional request, and why it is not the whole story
The endpoint is conditional in the ordinary way. Every response carries ETag: "<version>" and Cache-Control: private, max-age=3600, and a client sending a matching If-None-Match gets a 304.
A six-hourly poll with a 304 is cheap, but it is still up to six hours of a device not knowing that something changed. So every API response in the product, not only this one, carries an x-taxonomy-version header. The client watches that header on responses it was making anyway, and a version newer than the one it holds schedules a single forced refresh, debounced so a burst of responses cannot become a burst of fetches. The label capture flow explicitly waits for that refresh before it checks a fresh read, because the read it just took is exactly the kind of thing recent curation was likely about.
The header turns "poll and hope" into "you have already been told".
Serving a device something older than what it just saw
Multiple instances, each with its own 60 second memory cache, create a nasty little race. A device talks to instance B, sees version 1774, then asks instance A for the snapshot. Instance A's memory cache still holds 1773. Without care it hands back a snapshot older than the version the device has already been promised, and the device either stores a regression or spins.
The read therefore takes the client's claim as an input:
const snapshot = await readOverlay({ atLeast: clientVersion });
A client claiming a newer version than the cache makes the store skip both cache tiers and read the table. If it is still ahead after that, because of clock skew or a retired row, it gets the server's snapshot and keeps its own higher string for future comparisons. The rule is simple: a cache is allowed to be stale, and is never allowed to contradict something a client was already told.
The device does not trust any of this
Everything above is delivery. None of it is trust. The snapshot is validated with the engine's own guards when it is read out of the table, so only rows the engine accepts reach a cache, and then the engine validates every row again when the device loads it. Rows the table holds but the engine refuses are recorded on a fresh read and retired by a scheduled job, so a bad row cannot sit there being re-rejected forever.
On the device, a cached overlay that will not parse is deleted and the bundled vocabulary is used instead. The hydrate step always resolves as hydrated, whatever it found, because a launch gate that can block on bad cached data is a launch gate that can brick the app in the field.
The scoring half also reads fail-soft on the server: migrations here are applied by hand, so a deploy that lands before its migration falls back to an empty scoring layer and serves the hand-written maps, logging once. That is precisely the behaviour of the previous release rather than an outage on the product lookup path.
Where to see the output
The endpoint itself needs an account, but what it delivers is visible without one.
- The ingredient answers index is one page per ingredient and condition where the engine has something to say. A page only exists when both halves of the snapshot cover that ingredient, so that index is the naming half and the scoring half intersected, rendered.
- Is garlic low FODMAP? shows both halves at work on one ingredient. The "What it is called on a label" section is the naming half. The verdict and the reason string above it are the scoring half, and the reason is generated by the same engine the phone runs, not written by hand and not written by a model.
- The conditions index is the rule sets those ids get placed into.
If you want the general version: when an offline-first client has to be told about server-side data, give the snapshot one version derived from the data itself, make the version cheap enough to stamp on every unrelated response, and let a client's claim about what it has already seen override your caches.
Top comments (0)