state-hub/tests/test_message_feed.py
tegwick 4ffc723673
All checks were successful
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / container-smoke (push) Successful in 1s
Build and Publish Multi-Context Image / build-and-push (push) Successful in 38s
Inbox change feed v1 (STATE-WP-0098-T05); close out open waits
- migration c4d5e6f7a8b9: inbox revision sequence, per-transaction revision
  trigger with advisory lock, message tombstones
- GET /messages/feed/snapshot and /messages/feed?after= per the accepted
  contract; archived_at is now first-write-wins
- STATE-WP-0098 blocked: T02/T04 qualified waits on owners and HUB-WP-0011-T01
- STATE-WP-0094-T05: correct the cited CCR, wait on railiance-platform
- legacy-meter review evidence 2026-10-14

Not deployed; the migration deploy is a separate confirmed step.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

Assistant: claude-code
Assistant-Model: opus
Assistant-Process: 356991@bnt-lap001
Assistant-Session: 5d99ea69-ed55-4a1e-8d97-f4f312db247f
2026-10-14 02:48:06 +02:00

412 lines
16 KiB
Python

"""STATE-WP-0098-T05: inbox change feed (docs/inbox-change-feed-contract.md)."""
from __future__ import annotations
import importlib.util
import os
import uuid
from pathlib import Path
import pytest
import pytest_asyncio
import sqlalchemy
from alembic.migration import MigrationContext
from alembic.operations import Operations
from sqlalchemy import select, text
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
from api.models.agent_message import AgentMessage, MessageReceipt
from api.services.message_receipts import upsert_receipt
CONTRACT = "statehub.inbox-feed.v1"
@pytest_asyncio.fixture(autouse=True)
async def _clean_tombstones(test_engine):
# conftest deletes agent_messages after each test, which (correctly) leaves
# tombstones behind; start every feed test from an empty tombstone table.
async with test_engine.begin() as conn:
await conn.execute(text("DELETE FROM message_tombstones"))
yield
@pytest.fixture
def factory(test_engine):
return async_sessionmaker(test_engine, class_=AsyncSession, expire_on_commit=False)
async def _send(client, to_agent="state-hub", **payload):
body = {"from_agent": "the-custodian", "subject": "s", "body": "b", "to_agent": to_agent, **payload}
resp = await client.post("/messages/", json=body)
assert resp.status_code == 201, resp.text
return resp.json()
async def _revisions(factory, model=AgentMessage):
async with factory() as session:
if model is AgentMessage:
rows = await session.execute(select(AgentMessage.id, AgentMessage.revision))
return {str(i): r for i, r in rows.all()}
rows = await session.execute(
select(MessageReceipt.message_id, MessageReceipt.agent, MessageReceipt.revision)
)
return {f"{m}:{a}": r for m, a, r in rows.all()}
async def _snapshot(client):
resp = await client.get("/messages/feed/snapshot")
assert resp.status_code == 200, resp.text
assert resp.headers["cache-control"] == "no-store"
return resp.json()
async def _page(client, after, limit=500, expect=200, **params):
resp = await client.get("/messages/feed", params={"after": after, "limit": limit, **params})
assert resp.status_code == expect, resp.text
assert resp.headers["cache-control"] == "no-store"
return resp.json()
async def _drain(client, after, limit=2):
pages = []
while True:
page = await _page(client, after, limit=limit)
pages.append(page)
after = page["next_cursor"]
if not page["has_more"]:
return pages
def _rev(cursor):
return int(cursor.split(":", 1)[1])
@pytest.mark.asyncio
async def test_one_revision_per_transaction(factory):
async with factory() as session:
a = AgentMessage(from_agent="x", to_agent="y", subject="a", body="a")
b = AgentMessage(from_agent="x", to_agent="broadcast", subject="b", body="b", kind="news")
session.add_all([a, b])
await session.flush()
await upsert_receipt(session, [b.id], "reader-1", delivered=True)
await session.commit()
assert a.revision == b.revision # eager_defaults: RETURNING, no lazy load
revs = await _revisions(factory)
receipt_revs = await _revisions(factory, MessageReceipt)
assert set(revs.values()) == {a.revision}
assert set(receipt_revs.values()) == {a.revision}
async with factory() as session:
c = AgentMessage(from_agent="x", to_agent="y", subject="c", body="c")
session.add(c)
await session.commit()
assert c.revision > a.revision
@pytest.mark.asyncio
async def test_no_revision_bump_on_noop_update(client, factory):
direct = await _send(client)
news = await _send(client, to_agent="broadcast")
assert (await client.patch(f"/messages/{direct['id']}/read", json={})).status_code == 200
resp = await client.patch(f"/messages/{news['id']}/read", json={"reader": "reader-1"})
assert resp.status_code == 200
before = await _revisions(factory)
receipts_before = await _revisions(factory, MessageReceipt)
# COALESCE upsert that changes nothing, and a raw UPDATE setting equal values.
async with factory() as session:
await upsert_receipt(session, [uuid.UUID(news["id"])], "reader-1", read=True, delivered=False)
await session.execute(
text("UPDATE agent_messages SET read_at = read_at, subject = subject")
)
await session.commit()
assert await _revisions(factory) == before
assert await _revisions(factory, MessageReceipt) == receipts_before
# A real change bumps exactly the touched receipt.
async with factory() as session:
await upsert_receipt(session, [uuid.UUID(news["id"])], "reader-1", acknowledged=True)
await session.commit()
after = await _revisions(factory, MessageReceipt)
key = f"{news['id']}:reader-1"
assert after[key] > receipts_before[key]
@pytest.mark.asyncio
async def test_archive_is_first_write_wins(client, factory):
msg = await _send(client)
first = await client.patch(f"/messages/{msg['id']}/archive")
assert first.status_code == 200
rev = (await _revisions(factory))[msg["id"]]
second = await client.patch(f"/messages/{msg['id']}/archive")
assert second.status_code == 200
assert second.json()["archived_at"] == first.json()["archived_at"]
assert (await _revisions(factory))[msg["id"]] == rev
@pytest.mark.asyncio
async def test_delete_writes_tombstone_in_same_revision(client, factory):
root = await _send(client)
reply = (
await client.post(f"/messages/{root['id']}/reply", json={"from_agent": "state-hub", "body": "r"})
).json()
news = await _send(client, to_agent="broadcast")
await upsert_receipt_via_api(client, news["id"])
cursor = (await _snapshot(client))["cursor"]
async with factory() as session:
await session.execute(text("SET LOCAL statehub.delete_reason = 'test cleanup'"))
await session.execute(
text("DELETE FROM agent_messages WHERE id IN (:a, :b)"),
{"a": root["id"], "b": news["id"]},
)
await session.commit()
page = await _page(client, cursor)
revisions = {c["revision"] for c in page["changes"]}
assert len(revisions) == 1
by_entity = {(c["entity"], c["key"]): c for c in page["changes"]}
root_tomb = by_entity[("tombstone", root["id"])]
assert root_tomb["op"] == "delete"
assert root_tomb["state"]["reason"] == "test cleanup"
assert "body" not in root_tomb["state"]
assert ("tombstone", news["id"]) in by_entity
# The cascade SET NULL on the reply arrives as an upsert in the same revision.
reply_change = by_entity[("message", reply["id"])]
assert reply_change["state"]["thread_id"] is None
# Cascaded receipts are gone from the snapshot.
snap = await _snapshot(client)
assert snap["receipts"] == []
assert {t["message_id"] for t in snap["tombstones"]} == {root["id"], news["id"]}
async def upsert_receipt_via_api(client, message_id):
resp = await client.post(f"/messages/{message_id}/ack", json={"agent": "reader-1"})
assert resp.status_code in (200, 201), resp.text
@pytest.mark.asyncio
async def test_snapshot_and_feed_parity(client, factory):
first = await _snapshot(client)
assert first["contract"] == CONTRACT
assert first["messages"] == [] and first["count"] == 0
m1 = await _send(client)
m2 = await _send(client, to_agent="broadcast", kind="standing")
await _send(client, to_agent="broadcast", kind="standing", supersedes_id=m2["id"])
await client.patch(f"/messages/{m1['id']}/read", json={})
await client.get("/messages/", params={"to_agent": "reader-2", "unread_only": "true"})
await upsert_receipt_via_api(client, m2["id"])
await client.post(f"/messages/{m1['id']}/reply", json={"from_agent": "state-hub", "body": "r"})
doomed = await _send(client)
async with factory() as session:
await session.execute(text("DELETE FROM agent_messages WHERE id = :i"), {"i": doomed["id"]})
await session.commit()
snap = await _snapshot(client)
pages = await _drain(client, "v1:0", limit=2)
assert len(pages) > 1
messages, receipts, tombstones = {}, {}, {}
seen = []
for page in pages:
assert page["contract"] == CONTRACT
for change in page["changes"]:
seen.append(change["revision"])
if change["entity"] == "message":
messages[change["key"]] = change["state"]
elif change["entity"] == "receipt":
receipts[change["key"]] = change["state"]
else:
tombstones[change["key"]] = change["state"]
messages.pop(change["key"], None)
for key in [k for k in receipts if k.startswith(change["key"] + ":")]:
receipts.pop(key)
assert seen == sorted(seen)
assert pages[-1]["next_cursor"] == snap["cursor"] == pages[-1]["high_water"]
assert sorted(messages.values(), key=lambda m: m["id"]) == snap["messages"]
assert sorted(receipts.values(), key=lambda r: (r["message_id"], r["agent"])) == snap["receipts"]
assert list(tombstones.values()) == snap["tombstones"]
assert snap["count"] == len(snap["messages"]) == 4
assert len(snap["content_hash"]) == 64
# Same state, same hash; a change moves it.
assert (await _snapshot(client))["content_hash"] == snap["content_hash"]
await client.patch(f"/messages/{m2['id']}/read", json={"reader": "reader-3"})
assert (await _snapshot(client))["content_hash"] != snap["content_hash"]
@pytest.mark.asyncio
async def test_page_never_splits_a_revision(factory, client):
async with factory() as session:
session.add_all(
[AgentMessage(from_agent="x", to_agent="y", subject=str(i), body="b") for i in range(3)]
)
await session.commit()
await _send(client)
page = await _page(client, "v1:0", limit=1)
assert len(page["changes"]) == 3
assert len({c["revision"] for c in page["changes"]}) == 1
assert page["has_more"] is True
assert page["next_cursor"] == f"v1:{page['changes'][0]['revision']}"
last = await _page(client, page["next_cursor"], limit=1)
assert len(last["changes"]) == 1 and last["has_more"] is False
assert last["next_cursor"] == last["high_water"]
@pytest.mark.asyncio
async def test_replay_and_empty_page_heartbeat(client):
await _send(client)
await _send(client)
snap = await _snapshot(client)
a = await _page(client, "v1:0")
b = await _page(client, "v1:0")
assert a["changes"] == b["changes"] and a["next_cursor"] == b["next_cursor"]
empty = await _page(client, snap["cursor"])
assert empty["changes"] == [] and empty["has_more"] is False
assert empty["next_cursor"] == snap["cursor"] == empty["high_water"]
assert empty["source_time"] and "source_instance" in empty
@pytest.mark.asyncio
async def test_cursor_older_than_tombstone_horizon_is_410(client, factory):
await _send(client)
async with factory() as session:
await session.execute(
text(
"INSERT INTO message_tombstones (message_id, revision, deleted_at) "
"VALUES (:i, nextval('inbox_revision_seq'), now() - interval '91 days')"
),
{"i": str(uuid.uuid4())},
)
await session.commit()
fresh = await _send(client)
snap = await _snapshot(client)
compacted = _rev(snap["compacted_before"])
assert compacted > 0
assert snap["tombstones"] == [] # out of horizon, not served
gone = await _page(client, f"v1:{compacted - 1}", expect=410)
assert gone["resnapshot_required"] is True
ok = await _page(client, f"v1:{compacted}")
assert [c["key"] for c in ok["changes"]] == [fresh["id"]]
assert ok["compacted_before"] == snap["compacted_before"]
@pytest.mark.asyncio
async def test_foreign_cursor_is_409_and_malformed_is_400(client):
await _send(client)
snap = await _snapshot(client)
body = await _page(client, "v2:1", expect=409)
assert body["resnapshot_required"] is True
await _page(client, snap["cursor"], source_instance="some-other-hub", expect=409)
await _page(client, f"v1:{_rev(snap['cursor']) + 1000}", expect=409)
await _page(client, "garbage", expect=400)
if snap["source_instance"] is not None:
await _page(client, snap["cursor"], source_instance=snap["source_instance"])
@pytest.mark.asyncio
async def test_feed_routes_do_not_collide_with_message_routes(client):
resp = await client.post("/messages/feed/snapshot")
assert resp.status_code == 405
assert (await client.get("/messages/feed")).status_code == 422 # after is required
def _load_migration():
path = Path(__file__).parent.parent / "migrations/versions/c4d5e6f7a8b9_inbox_feed_revision.py"
spec = importlib.util.spec_from_file_location("inbox_feed_migration", path)
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
return module
_FUNCS = "('inbox_feed_revision','inbox_feed_stamp_revision','inbox_feed_tombstone')"
def test_migration_downgrade_upgrade_and_ddl_parity():
"""Run the migration on the test DB inside a rolled-back transaction."""
from api.models import agent_message
migration = _load_migration()
assert migration.FUNCTIONS_SQL == agent_message.INBOX_FEED_FUNCTIONS_SQL
url = os.getenv(
"TEST_DATABASE_URL", "postgresql+asyncpg://custodian:changeme@127.0.0.1:5432/custodian_test"
).replace("+asyncpg", "+psycopg2")
engine = sqlalchemy.create_engine(url)
try:
with engine.connect() as conn:
trans = conn.begin()
try:
conn.execute(
text(
"INSERT INTO agent_messages (id, from_agent, to_agent, subject, body) "
"VALUES (gen_random_uuid(), 'x', 'y', 's', 'b'), "
"(gen_random_uuid(), 'x', 'y', 's2', 'b')"
)
)
ops = Operations(MigrationContext.configure(conn))
migration.op = ops
migration.downgrade()
cols = {c["name"] for c in sqlalchemy.inspect(conn).get_columns("agent_messages")}
assert "revision" not in cols
assert not sqlalchemy.inspect(conn).has_table("message_tombstones")
migration.upgrade()
revs = conn.execute(text("SELECT DISTINCT revision FROM agent_messages")).scalars().all()
assert len(revs) == 1 and revs[0] is not None # one backfill revision
triggers = set(
conn.execute(
text(
"SELECT tgname FROM pg_trigger WHERE NOT tgisinternal "
"AND tgrelid IN ('agent_messages'::regclass, 'message_receipts'::regclass)"
)
).scalars()
)
assert triggers == {
"agent_messages_inbox_revision",
"agent_messages_inbox_tombstone",
"message_receipts_inbox_revision",
}
conn.execute(text("DELETE FROM agent_messages"))
assert conn.execute(text("SELECT count(*) FROM message_tombstones")).scalar() == 2
finally:
trans.rollback()
finally:
engine.dispose()
@pytest.mark.asyncio
async def test_snapshot_transaction_is_repeatable_read_read_only(factory):
from api.routers.message_feed import _begin_consistent_read
async with factory() as session:
await _begin_consistent_read(session)
assert (await session.execute(text("SHOW transaction_isolation"))).scalar() == "repeatable read"
assert (await session.execute(text("SHOW transaction_read_only"))).scalar() == "on"
@pytest.mark.asyncio
async def test_advisory_lock_serializes_writers_in_revision_order(factory):
import asyncio
first = factory()
second = factory()
try:
a = AgentMessage(from_agent="x", to_agent="y", subject="a", body="a")
first.add(a)
await first.flush() # holds the inbox lock until commit
async def write_b():
b = AgentMessage(from_agent="x", to_agent="y", subject="b", body="b")
second.add(b)
await second.commit()
return b
task = asyncio.create_task(write_b())
await asyncio.sleep(0.5)
assert not task.done() # blocked on pg_advisory_xact_lock
await first.commit()
b = await asyncio.wait_for(task, timeout=10)
assert b.revision > a.revision
finally:
await first.close()
await second.close()