My agent caches every Amazon product it reads, in Postgres, with a TTL and a price history. The cache is not only about speed:
@moduledoc """
Product cache and price history.
The cache protects as much as it optimises: never asking Amazon twice for the same
ASIN directly reduces exposure to blocking.
"""
Twenty lines of write path, and I got it wrong three times in a row, where each fix is what made the next bug possible. The third is the one worth writing down: two behaviours that are each correct, and that only became a bug once they sat next to each other.
Twenty products, and nobody to write them down
The first symptom was a page that stayed empty. My agent has a small LiveView showing what it has recently observed, with price sparklines, and a successful search left it blank.
Only the two single-product tools, amazon_get_price and
amazon_get_product_details, ever wrote to the cache. I had wired the cache
onto the tools that read one product, and never asked what the tools reading
twenty were supposed to do with what they saw.
That sounds like a one-line fix. A search hit has an ASIN, a title, a URL, a price most of the time. Write each hit, done.
A search hit is not a product
Writing them with the existing store/3 would have been wrong, and this is
where the actual subject of this post starts.
store/3 is a full overwrite, which is exactly right for the tool it was
written for: both product-detail tools parse the whole page and hand over a
complete product. A search hit is a different animal. It has no brand, no
seller, no feature bullets, because the results page never showed them.
Overwriting is therefore a data loss with extra steps. Search a product you had already fetched in detail, and the rich entry becomes a thin one. Same ASIN, same key, less knowledge than before, and the LiveView I was trying to fill would now show less than it did.
So search hits go through a second write path that merges instead of replacing, built on Postgres’s jsonb concatenation:
merge_query =
from product in Product,
update: [
set: [
payload: fragment("? || ?", product.payload, type(^payload, :map)),
fetched_at: ^fetched_at_on_merge,
updated_at: ^now
]
]
product =
%Product{}
|> Ecto.Changeset.change(asin: asin, domain: domain, payload: payload, fetched_at: now)
|> Repo.insert!(
on_conflict: merge_query,
conflict_target: [:asin, :domain],
returning: true
)
Inside an ON CONFLICT DO UPDATE, the query’s binding refers to the row
already in the table, so product.payload on the left of the || is the
existing payload and the parameter on the right is the incoming one. Right
wins on conflict, everything else survives. One statement, no read followed
by a write, nothing to race against.
Two facts a jsonb || cannot tell apart
The merge was still wrong, and it was wrong on nearly every real search.
|| does not distinguish a key that is absent from a key that is present
with a null value. A present null on the right wins over a real value on
the left, precisely as any absent key would lose. Both shapes look the same
to it, and they are not the same fact at all. One says “I have nothing to
say about the price”. The other says “the price is nothing”.
My search parser only ever produces the second one. It builds every hit with
the same keys, filling "price", "currency", "rating",
"reviews_count" and "image_url" with nil when it could not parse them,
never omitting them. That is a perfectly reasonable thing for a parser to
do, and it meant that every search hit whose price could not be read erased
the price of a richer entry cached for the same ASIN. The bug I had just
avoided with the merge, reintroduced through the door the merge left open.

The fix is one line, and where it lives matters more than what it does:
def merge(asin, domain, payload) do
now = DateTime.utc_now()
payload = drop_nil_values(payload)
Not in the search tool that had the problem. In merge/3, before anything
else, so no future caller can reintroduce this with nils of its own. A rule
that lives at the call site is a rule that holds until somebody writes a
second call site.
The test that agreed with me instead of testing me
Here is the part I keep thinking about.
I had already written a test called “merging preserves an existing key the new payload does not carry”. It passed. It kept passing while the bug was live, and it was not a bad test, it just answered a question I had not asked carefully enough:
{:ok, _first} =
Catalog.store(@asin, @domain, %{
"asin" => @asin,
"title" => "Un livre",
"brand" => "Une maison d'édition",
"seller" => "Un vendeur"
})
{:ok, _second} = Catalog.merge(@asin, @domain, %{"asin" => @asin, "price" => 999})
assert {:ok, payload} = Catalog.fetch_fresh(@asin, @domain)
assert payload["brand"] == "Une maison d'édition"
assert payload["seller"] == "Un vendeur"
The keys under test are brand and seller, and they are absent from
the incoming payload. So the test proved that an absent key survives a
merge, which was true before the bug, during it, and after the fix. Nothing
in the file exercised a key present with an explicit nil, and that is the
only shape my search parser can produce. It always emits the key. The test
covered a payload shape my own system never builds, and passing told me
nothing about the one it builds on every single search.
So the regression test is now written in the shape reality actually has:
{:ok, _second} =
Catalog.merge(@asin, @domain, %{
"asin" => @asin,
"price" => nil,
"currency" => nil,
"image_url" => nil
})
assert {:ok, payload} = Catalog.fetch_fresh(@asin, @domain)
assert payload["price"] == 999
When a test for “the value survives” passes, it is worth checking which of the two shapes it survived. Only one of them was ever going to show up.
The stamp that renewed itself forever
Now the good one, and the reason this post exists.
merge/3 bumped fetched_at to now on every merge, which is what a write
does. It also deliberately kept the existing price when the incoming payload
had none, which is what the previous fix was for. Each behaviour is correct
in isolation. I wrote them a few minutes apart and never looked at them
together.
Together they say: a product I search regularly gets its freshness stamp renewed forever, without its price ever being re-read.
And fetched_at is what the TTL is checked against:
def fetch_fresh(asin, domain) do
cutoff = DateTime.add(DateTime.utc_now(), -Config.cache_ttl_minutes() * 60, :second)
query =
from product in Product,
where:
product.asin == ^asin and product.domain == ^domain and product.fetched_at > ^cutoff
So the cache expiry had silently switched itself off, and not for a random subset. For exactly the products I looked at most often, which are the ones whose prices I actually wanted to be current. A cache that never expires the entries you use is not a cache with a bug in it, it is a stale answer machine with a TTL painted on the side.
There is a second cost, and I think it is the worse one. fetched_at is
handed to the model as evidence of how current a result is. A price check
that answers “observed two minutes ago” when the number was read three weeks
ago has stopped being a slow cache and become a false statement about the
world, made to the one reader who cannot check it. It is the same shape as
the tool descriptions in last week’s
post, reached from a
completely different direction.
What a timestamp is allowed to mean
The fix required deciding what the column means, and then making every write
path obey that single sentence. fetched_at means “when this data was
actually observed”.
On an insert it is always now: a search hit is a thin observation, but it is
a genuine first observation of that ASIN. On a merge into an existing row it
advances only when the incoming payload actually carries a fresh price, the
same is_integer/1 test that decides whether a price point gets recorded at
all. Otherwise the row keeps the stamp it had. store/3 is untouched: a full
product-page fetch has genuinely observed everything, so it sets the stamp
unconditionally.
In Ecto that conditional lives in a dynamic:
fetched_at_on_merge =
if fresh_price?(payload), do: dynamic(^now), else: dynamic([product], product.fetched_at)
Two branches, and the difference between them is not a value but a
reference: either a literal timestamp, or the column’s own current value. It
is the same trick as "payload" = c0."payload" || $7, applied to a column
where I want the update to be a no-op.
I like this because of what the database receives. Same function, one
insert! call, and two genuinely different statements. With a fresh price:
INSERT INTO "catalog_products" AS c0 (...) VALUES ($1,$2,$3,$4,$5,$6)
ON CONFLICT ("asin","domain") DO UPDATE
SET "payload" = c0."payload" || $7::jsonb, "fetched_at" = $8, "updated_at" = $9
Without one:
INSERT INTO "catalog_products" AS c0 (...) VALUES ($1,$2,$3,$4,$5,$6)
ON CONFLICT ("asin","domain") DO UPDATE
SET "payload" = c0."payload" || $7::jsonb, "fetched_at" = c0."fetched_at", "updated_at" = $8
"fetched_at" = c0."fetched_at" is the whole fix, and it is still one round
trip, still atomic, with no branch in my code that reads the row first to
decide what to write. Both statements come from the query log of an actual
merge rather than being reconstructed from the Elixir, with the column list
and the RETURNING clause elided for width.
What a write knows
All three bugs come from one question I never asked while writing the code: what does this write actually know?
A thin write knows less than the row it lands on, so it must not be allowed
to replace it, which is the entire difference between store/3 and
merge/3. It also has to say which of its empty fields are genuinely empty
and which it simply never saw, because jsonb || cannot tell those apart
and lets a nil win by default. That decision belongs inside the write path
rather than at the call site, where the next caller would have to remember
it.
The timestamp is the one I would still get wrong today if I were not watching for it. A stamp is a claim about an event, and “update the timestamp on write” is such an ordinary reflex that it never feels like a claim at all. That is why this one went unnoticed for so long: nothing in that code looked like a bug, because separately, none of it was one.
The seven tests that hold this down read like the specification the three bugs turned out to be:
merging into an empty cache stores the given payload
merging preserves an existing key the new payload does not carry
merging overwrites a key the new payload does carry
merging records a price point when the new payload carries a price
merging a payload where a key is present but explicitly nil does not erase the existing value
merging a payload with no fresh price does not renew a stale entry's freshness
merging a payload that carries a fresh price renews freshness
Comments, questions, or just a reaction?
Send an email to ~bounga/bounga.org-discuss@lists.sr.ht. It is public and archived, so other readers can follow along and answer too.