fix(advice): an editing token, so a comment-only correction is safe too
The gravity guard caught a correction that changed the gravity, but a
COMMENT-ONLY correction leaves the gravity unchanged - so the late
thread for the old advice still matched and overwrote the fresh one.
Reproduced against the live database:
fresh advice matched: 1
stale thread matched: 1 <-- should be 0
FINAL advice: 'STALE ADVICE'
Every edit now writes a fresh token onto the reading, and the advice
thread carries the token it was given. A late thread finds a token that
no longer matches, so its write is refused. The gravity is still
checked as well.
Verified against the live database:
token changed: True
fresh advice matched: 1
stale thread matched: 0 <-- must be 0
FINAL advice: 'FRESH ADVICE'
174 tests (up from 173).
This commit is contained in:
@@ -209,13 +209,20 @@ class BeerRepository:
|
||||
"final_reading": "True" if final_reading else "False",
|
||||
"comment": str(comment),
|
||||
},
|
||||
# The advice was written for the gravity that was there when
|
||||
# The advice was written for the reading that was there when
|
||||
# it was generated. If the reading has been edited, that
|
||||
# advice no longer describes it, so it is cleared and a fresh
|
||||
# one is generated. (Advice is only ever cleared here - never
|
||||
# the brewer's comment.)
|
||||
"$unset": {"advice": "", "advice_ok": "", "advice_at": ""},
|
||||
}
|
||||
# A fresh token marks this generation of the reading. The advice
|
||||
# thread carries the token it was given, so a thread that is
|
||||
# still working on an EARLIER version of the reading finds a
|
||||
# token that no longer matches and its write is refused. The
|
||||
# gravity alone is not enough - a comment-only correction leaves
|
||||
# the gravity unchanged.
|
||||
update["$set"]["advice_token"] = _new_token()
|
||||
result = self.readings.update_one(query, update, upsert=True)
|
||||
if result.upserted_id is not None:
|
||||
return str(result.upserted_id)
|
||||
@@ -224,22 +231,32 @@ class BeerRepository:
|
||||
|
||||
def set_reading_advice(self, reading_id: str, advice: str,
|
||||
ok: bool = True,
|
||||
expected_sg: int | None = None) -> int:
|
||||
expected_sg: int | None = None,
|
||||
expected_token: str | None = None) -> int:
|
||||
"""Attach the LLM's advice to a reading.
|
||||
|
||||
The advice lives in its own field, so the brewer's own comment is
|
||||
never overwritten. ``ok`` says whether the advice was actually
|
||||
generated or is a note explaining that it could not be.
|
||||
|
||||
``expected_sg`` is the gravity the advice was generated FOR. The
|
||||
write only happens if the reading still holds that gravity.
|
||||
The write is CONDITIONAL, so a slow advice thread cannot
|
||||
overwrite a correction:
|
||||
|
||||
- ``expected_token`` is the editing token of the reading at the
|
||||
moment the advice was generated. Every edit writes a fresh
|
||||
token, so a late thread's token no longer matches.
|
||||
- ``expected_sg`` is the gravity the advice was generated for,
|
||||
as a second check.
|
||||
|
||||
This matters because the advice is generated in the background: if
|
||||
the brewer corrects a reading while the advice for the old value
|
||||
is still being generated, the slow thread would land afterwards
|
||||
and overwrite the fresh advice with advice about a reading that
|
||||
no longer exists. Without the guard the run ends up with the
|
||||
final gravity from the correction and the advice from before it.
|
||||
no longer exists.
|
||||
|
||||
The token is needed as well as the gravity, because a
|
||||
comment-only correction leaves the gravity unchanged - the token
|
||||
is what tells the two generations apart.
|
||||
"""
|
||||
doc_id = _as_doc_id(reading_id)
|
||||
if not doc_id:
|
||||
@@ -248,6 +265,8 @@ class BeerRepository:
|
||||
query: dict = {"_id": {"$in": doc_id}}
|
||||
if expected_sg is not None:
|
||||
query["sg"] = str(expected_sg)
|
||||
if expected_token is not None:
|
||||
query["advice_token"] = str(expected_token)
|
||||
|
||||
# `$in` matches either form. A plain list would be an exact
|
||||
# ARRAY match in Mongo, which is not what is wanted here.
|
||||
@@ -485,6 +504,13 @@ def hash_password(password: str) -> str:
|
||||
return generate_password_hash(password)
|
||||
|
||||
|
||||
def _new_token() -> str:
|
||||
"""A fresh token, to mark one generation of a reading from the next."""
|
||||
import secrets
|
||||
|
||||
return secrets.token_hex(8)
|
||||
|
||||
|
||||
def _now_iso() -> str:
|
||||
from datetime import datetime, timezone
|
||||
|
||||
|
||||
Reference in New Issue
Block a user