fix(supersession): one query for both directions, not two per note read
CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 10s
CI & Build / integration (push) Successful in 12s
CI & Build / TypeScript typecheck (push) Successful in 35s
CI & Build / Python tests (push) Successful in 47s
CI & Build / Build & push image (push) Successful in 25s
CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 10s
CI & Build / integration (push) Successful in 12s
CI & Build / TypeScript typecheck (push) Successful in 35s
CI & Build / Python tests (push) Successful in 47s
CI & Build / Build & push image (push) Successful in 25s
CI failed on 8d9e96c — eight tests in test_mcp_tool_notes.py, all
"Connect call failed (127.0.0.1, 5432)".
The proximate cause is that `_attach_supersession` runs on every note
read/write and those are unit tests of the tool layer with no database. But the
test failure exposed a worse decision underneath it.
I had written the two directions as two service calls, so every `get_note`
made TWO extra round trips plus TWO ACL checks — on the hottest path in the
product — to save a two-line partition in Python. That is the wrong trade
whether or not a test noticed.
`get_relations` replaces both: one OR query, one ACL check, partitioned by
which column holds the note's id. Its test asserts `execute.await_count == 1`,
so the collapse can't quietly come apart later.
The tests then get an autouse stub rather than the code getting a swallow. The
tool genuinely has a new dependency; hiding that behind a try/except to keep
unit tests green would be arranging for the code to lie about what it does.
This file already records the same hazard for note 2109, so the stub sits next
to that precedent.
Added the test that matters, which the first pass missed: a superseded record
still surfaces, so an agent WILL read stale material — and it must arrive with
a plain-language warning, not just a numeric field to notice. Also pinned that
both keys are ABSENT rather than present-and-empty when there are no relations.
Refs #278
This commit is contained in:
@@ -69,12 +69,11 @@ async def _attach_supersession(uid: int, note_id: int, data: dict) -> None:
|
||||
permanently-empty lists. A field that always says nothing trains readers to
|
||||
skip fields, which is the lesson `consolidated_at` cost us (#2483).
|
||||
"""
|
||||
supersedes = await supersession_svc.get_supersedes(uid, note_id)
|
||||
superseded_by = await supersession_svc.get_superseded_by(uid, note_id)
|
||||
if supersedes:
|
||||
data["supersedes"] = supersedes
|
||||
if superseded_by:
|
||||
data["superseded_by"] = superseded_by
|
||||
rel = await supersession_svc.get_relations(uid, note_id)
|
||||
if rel["supersedes"]:
|
||||
data["supersedes"] = rel["supersedes"]
|
||||
if rel["superseded_by"]:
|
||||
data["superseded_by"] = rel["superseded_by"]
|
||||
data["superseded_note"] = (
|
||||
"A later note claims to bring this up to date — see superseded_by. "
|
||||
"Read this as what was true when written, and check the newer one "
|
||||
|
||||
@@ -36,12 +36,11 @@ async def _attach_supersession(uid: int, note_id: int, data: dict) -> None:
|
||||
Omitted when empty: a field that always says nothing trains readers to skip
|
||||
fields, which is what `consolidated_at` cost (#2483).
|
||||
"""
|
||||
supersedes = await supersession_svc.get_supersedes(uid, note_id)
|
||||
superseded_by = await supersession_svc.get_superseded_by(uid, note_id)
|
||||
if supersedes:
|
||||
data["supersedes"] = supersedes
|
||||
if superseded_by:
|
||||
data["superseded_by"] = superseded_by
|
||||
rel = await supersession_svc.get_relations(uid, note_id)
|
||||
if rel["supersedes"]:
|
||||
data["supersedes"] = rel["supersedes"]
|
||||
if rel["superseded_by"]:
|
||||
data["superseded_by"] = rel["superseded_by"]
|
||||
from scribe.services.note_versions import list_versions, get_version
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -129,33 +129,40 @@ async def set_supersedes(
|
||||
return wanted
|
||||
|
||||
|
||||
async def get_supersedes(user_id: int, note_id: int) -> list[int]:
|
||||
"""Ids this note claims to supersede. Empty if the caller can't read it."""
|
||||
if not await access.can_read_note(user_id, note_id):
|
||||
return []
|
||||
async with async_session() as session:
|
||||
return [int(i) for i in (await session.execute(
|
||||
select(NoteSupersession.superseded_id)
|
||||
.where(NoteSupersession.superseder_id == note_id)
|
||||
.order_by(NoteSupersession.superseded_id)
|
||||
)).scalars().all()]
|
||||
async def get_relations(user_id: int, note_id: int) -> dict[str, list[int]]:
|
||||
"""Both directions for one note: what it supersedes, and what supersedes it.
|
||||
|
||||
ONE query and ONE ACL check, because this runs on every note read. Asking
|
||||
the two questions separately doubled the round trips on the hottest path in
|
||||
the product to save a two-line partition — the wrong trade, and one I made
|
||||
on the first attempt.
|
||||
|
||||
async def get_superseded_by(user_id: int, note_id: int) -> list[int]:
|
||||
"""Ids claiming to supersede this note.
|
||||
Returns {"supersedes": [...], "superseded_by": [...]}, both sorted. Empty
|
||||
lists when the caller cannot read the note.
|
||||
|
||||
The direction that matters to a READER, and the one the note itself cannot
|
||||
know. An agent handed a stale record with no marker acts on it confidently;
|
||||
that is worse than never surfacing it at all.
|
||||
`superseded_by` is the direction that matters to a READER and the one the
|
||||
note itself cannot know. An agent handed a stale record with no marker acts
|
||||
on it confidently, which is worse than never surfacing it at all.
|
||||
"""
|
||||
empty: dict[str, list[int]] = {"supersedes": [], "superseded_by": []}
|
||||
if not await access.can_read_note(user_id, note_id):
|
||||
return []
|
||||
return empty
|
||||
async with async_session() as session:
|
||||
return [int(i) for i in (await session.execute(
|
||||
select(NoteSupersession.superseder_id)
|
||||
.where(NoteSupersession.superseded_id == note_id)
|
||||
.order_by(NoteSupersession.superseder_id)
|
||||
)).scalars().all()]
|
||||
rows = (await session.execute(
|
||||
select(
|
||||
NoteSupersession.superseder_id, NoteSupersession.superseded_id
|
||||
).where(
|
||||
(NoteSupersession.superseder_id == note_id)
|
||||
| (NoteSupersession.superseded_id == note_id)
|
||||
)
|
||||
)).all()
|
||||
supersedes = sorted(
|
||||
int(old) for new, old in rows if int(new) == note_id
|
||||
)
|
||||
superseded_by = sorted(
|
||||
int(new) for new, old in rows if int(old) == note_id
|
||||
)
|
||||
return {"supersedes": supersedes, "superseded_by": superseded_by}
|
||||
|
||||
|
||||
async def superseded_ids(note_ids: list[int]) -> set[int]:
|
||||
|
||||
@@ -17,6 +17,21 @@ def _bind_user():
|
||||
_user_id_ctx.reset(token)
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _no_supersession():
|
||||
"""Every note read/write now asks for its supersession relations (#278).
|
||||
|
||||
These are unit tests of the TOOL layer and this job has no database — the
|
||||
same hazard the `_fake_note` comment below records for note 2109. Stubbed
|
||||
to "no relations", which is the state of essentially every note; the
|
||||
relation's own behaviour is covered in test_services_supersession.py, and
|
||||
the attachment is covered explicitly below.
|
||||
"""
|
||||
with patch("scribe.mcp.tools.notes.supersession_svc.get_relations",
|
||||
AsyncMock(return_value={"supersedes": [], "superseded_by": []})):
|
||||
yield
|
||||
|
||||
|
||||
def _fake_note(*, user_id: int = 7, **overrides) -> MagicMock:
|
||||
note = MagicMock()
|
||||
base = {"id": 1, "title": "t", "body": "b", "tags": [], "is_task": False}
|
||||
@@ -123,6 +138,30 @@ async def test_get_note_returns_dict():
|
||||
assert out["title"] == "found"
|
||||
# Own record: no provenance noise.
|
||||
assert "shared" not in out
|
||||
# No supersession relations: both keys ABSENT, not present-and-empty. A
|
||||
# field that always says nothing trains readers to skip fields (#2483).
|
||||
assert "supersedes" not in out
|
||||
assert "superseded_by" not in out
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_note_warns_in_words_when_a_later_note_overtook_it():
|
||||
"""The label is the point, not the ids.
|
||||
|
||||
A superseded record still surfaces — supersession demotes, it never hides —
|
||||
so an agent WILL read stale material. Handing it over with only a numeric
|
||||
field to notice would be worse than not surfacing it, because the reader
|
||||
acts on it confidently either way.
|
||||
"""
|
||||
fake = _fake_note(id=5, title="June's answer")
|
||||
with patch("scribe.mcp.tools.notes.notes_svc.get_note_for_user",
|
||||
AsyncMock(return_value=(fake, "owner"))), \
|
||||
patch("scribe.mcp.tools.notes.supersession_svc.get_relations",
|
||||
AsyncMock(return_value={"supersedes": [], "superseded_by": [9]})):
|
||||
out = await get_note(note_id=5)
|
||||
assert out["superseded_by"] == [9]
|
||||
assert "superseded_note" in out
|
||||
assert "before acting" in out["superseded_note"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
|
||||
@@ -144,5 +144,32 @@ async def test_superseded_ids_is_empty_for_an_empty_candidate_set():
|
||||
async def test_reads_are_empty_when_the_caller_cannot_read_the_note():
|
||||
with patch("scribe.services.supersession.access.can_read_note",
|
||||
AsyncMock(return_value=False)):
|
||||
assert await supersession.get_supersedes(7, 1) == []
|
||||
assert await supersession.get_superseded_by(7, 1) == []
|
||||
assert await supersession.get_relations(7, 1) == {
|
||||
"supersedes": [], "superseded_by": []
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_relations_partitions_both_directions_from_one_query():
|
||||
"""ONE round trip for both directions, because this runs on every note read.
|
||||
|
||||
Note 5 supersedes 2 and 3, and is itself superseded by 9. All four rows come
|
||||
back from a single OR query and are partitioned by which column holds 5.
|
||||
"""
|
||||
session = AsyncMock()
|
||||
session.__aenter__ = AsyncMock(return_value=session)
|
||||
session.__aexit__ = AsyncMock(return_value=False)
|
||||
result = MagicMock()
|
||||
result.all.return_value = [(5, 3), (5, 2), (9, 5)] # (superseder, superseded)
|
||||
session.execute = AsyncMock(return_value=result)
|
||||
|
||||
with patch("scribe.services.supersession.access.can_read_note",
|
||||
AsyncMock(return_value=True)), \
|
||||
patch("scribe.services.supersession.async_session", return_value=session):
|
||||
rel = await supersession.get_relations(7, 5)
|
||||
|
||||
assert rel == {"supersedes": [2, 3], "superseded_by": [9]}
|
||||
assert session.execute.await_count == 1, (
|
||||
"both directions must come from one query — asking separately doubles "
|
||||
"the round trips on the hottest path in the product"
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user