fix(advice): stale advice must not overwrite a correction

The review found a race between the background advice thread and the
brewer correcting a reading.

`set_reading_advice` wrote unconditionally by id.  The advice is
generated in the background, so if the brewer corrects a reading while
the advice for the OLD gravity is still being generated, the slow thread
lands afterwards and overwrites the fresh advice with advice about a
reading that no longer exists.  The reading ends up with the final
gravity from the correction and the advice from before it.

Reproduced against the live database:

    wrote advice for 1050
    wrote advice for 1020
    late stale write matched: 0
    final sg: 1020 | final advice: 'ADVICE FOR THE CORRECTION'

The write is now conditional on the reading still holding the gravity
the advice was generated for, so a late thread matches nothing.

Also
----
`finish_reason` is now checked.  A reply that stopped at the token
ceiling is incomplete - it may be advice that stops mid-sentence - and
it was being stored as if it were the whole answer.  It is now marked
as cut off.

173 tests (up from 171).
This commit is contained in:
2026-11-09 17:45:16 +10:00
parent b414240bb5
commit 3e1e4f42b7
4 changed files with 122 additions and 7 deletions
+81
View File
@@ -546,6 +546,87 @@ def test_the_shared_repository_is_built_only_once():
assert len(set(instances)) == 1, "the threads got different repositories"
def test_stale_advice_cannot_overwrite_a_correction(repo):
"""A slow advice thread must not overwrite a correction.
The advice is generated in the background. If the brewer corrects a
reading while the advice for the old gravity is still being
generated, the slow thread lands afterwards - and without a guard the
reading ends up with the final gravity from the correction and the
advice from before it.
The write is conditional on the reading still holding the gravity the
advice was generated for.
"""
seed_readings(repo, [("1", "Ale", "1050", "2026-01-01", False, "a comment")])
doc = repo.readings.find_one({"beer_run_id": "1"})
reading_id = str(doc["_id"])
# The advice for the ORIGINAL 1050 arrives late...
modified = repo.set_reading_advice(reading_id, "ADVICE FOR THE 1050",
True, expected_sg=1050)
assert modified == 1
# ...but the brewer has already corrected it to 1020, and the fresh
# advice has landed.
repo.upsert_reading(beer_run_id="1", beer_type="Ale", sg=1020,
reading_date="2026-01-01", final_reading=False,
comment="a comment")
repo.set_reading_advice(reading_id, "ADVICE FOR THE 1020", True,
expected_sg=1020)
# Now the late thread for the old 1050 finally lands.
repo.set_reading_advice(reading_id, "STALE ADVICE FOR THE 1050", True,
expected_sg=1050)
after = repo.readings.find_one({"beer_run_id": "1"})
assert after["sg"] == "1020"
assert after["advice"] == "ADVICE FOR THE 1020", (
f"the stale advice overwrote the correction: {after['advice']!r}"
)
def test_a_reply_cut_off_at_the_token_ceiling_is_marked(repo):
"""A reply that stopped at the token limit is incomplete.
It may be advice that stops mid-sentence, so it must not be stored
as if it were the whole answer.
"""
captured = {}
def fake_urlopen(request, timeout=None):
captured["body"] = json.loads(request.data.decode())
class Response:
def read(self):
return json.dumps({
"choices": [{
"finish_reason": "length",
"message": {"content": "The gravity looks fine so far, but"},
}],
}).encode()
def __enter__(self):
return self
def __exit__(self, *args):
return False
return Response()
import brew_advice as ba
original = ba.urllib.request.urlopen
ba.urllib.request.urlopen = fake_urlopen
try:
ok, text = ba.generate_advice({"canonical_name": "Ale"},
{"date": "2026-01-01"}, [])
finally:
ba.urllib.request.urlopen = original
assert ok is True
assert "cut off" in text
def test_the_reading_id_cannot_be_used_to_inject_a_query(repo):
"""The reading id is coerced before it reaches Mongo.