From c852bec308318b5afa6f18dab651069cc31fce3c Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 2 Sep 2026 11:57:52 +0900 Subject: [PATCH 01/66] test(naming): require semantic activity stream parameters --- tests/test_activity_stream.py | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/tests/test_activity_stream.py b/tests/test_activity_stream.py index 40ea78f4f..0829d5a73 100644 --- a/tests/test_activity_stream.py +++ b/tests/test_activity_stream.py @@ -7,7 +7,11 @@ from __future__ import annotations +from inspect import signature + from backend.app.activity_stream import ( + create_valkey_client, + publish_activity_event, publish_activity_event_sync, ticket_created_summary, ticket_status_changed_summary, @@ -31,6 +35,20 @@ def xadd(self, key: str, fields: dict[str, str], maxlen=None, approximate=None): return entry_id +def test_activity_stream_owned_parameters_use_semantic_names() -> None: + """Organization-owned activity helpers expose bounded-context vocabulary.""" + assert list(signature(create_valkey_client).parameters) == ["valkey_url"] + expected_event_parameters = [ + "valkey_client", + "post_id", + "event_type", + "actor_account_id", + "activity_summary", + ] + assert list(signature(publish_activity_event).parameters) == expected_event_parameters + assert list(signature(publish_activity_event_sync).parameters) == expected_event_parameters + + def test_ticket_created_summary_matches_the_live_api_wording() -> None: assert ticket_created_summary("Send Northridge Grid the revised quote") == ( "Ticket created: Send Northridge Grid the revised quote" From 88273a1b6e465f265178ce8934b75b3334ec6f40 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 2 Sep 2026 11:59:47 +0900 Subject: [PATCH 02/66] test(naming): cover activity stream reader parameters --- tests/test_activity_stream.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/tests/test_activity_stream.py b/tests/test_activity_stream.py index 0829d5a73..67a6d6fca 100644 --- a/tests/test_activity_stream.py +++ b/tests/test_activity_stream.py @@ -13,6 +13,7 @@ create_valkey_client, publish_activity_event, publish_activity_event_sync, + read_activity_events, ticket_created_summary, ticket_status_changed_summary, ) @@ -47,6 +48,11 @@ def test_activity_stream_owned_parameters_use_semantic_names() -> None: ] assert list(signature(publish_activity_event).parameters) == expected_event_parameters assert list(signature(publish_activity_event_sync).parameters) == expected_event_parameters + assert list(signature(read_activity_events).parameters) == [ + "valkey_client", + "post_id", + "event_count", + ] def test_ticket_created_summary_matches_the_live_api_wording() -> None: From 8d892422b56f660d94dcc834d63be5a69e330391 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 2 Sep 2026 12:00:22 +0900 Subject: [PATCH 03/66] refactor(activity): qualify Valkey stream identifiers --- backend/app/activity_stream.py | 65 +++++++++++++++++++++------------- 1 file changed, 40 insertions(+), 25 deletions(-) diff --git a/backend/app/activity_stream.py b/backend/app/activity_stream.py index 56311382b..3ac1af83a 100644 --- a/backend/app/activity_stream.py +++ b/backend/app/activity_stream.py @@ -17,9 +17,9 @@ from lineageweave.observability import traced -def create_valkey_client(url: str) -> redis.Redis: - """One shared async client for the process, mirroring db.create_pool.""" - return redis.from_url(url, decode_responses=True) +def create_valkey_client(valkey_url: str) -> redis.Redis: + """Create the shared async Valkey client for the process.""" + return redis.from_url(valkey_url, decode_responses=True) def get_valkey(request: Request) -> redis.Redis: @@ -47,20 +47,25 @@ def ticket_status_changed_summary(status_label: str) -> str: return f"Ticket status changed to {status_label}" -def _activity_fields(event_type: str, actor_account_id: str, summary: str) -> dict[str, str]: +def _activity_fields( + event_type: str, + actor_account_id: str, + activity_summary: str, +) -> dict[str, str]: + """Translate semantic activity values to the established Valkey wire fields.""" return { "event_type": event_type, "actor_account_id": actor_account_id, - "summary": summary, + "summary": activity_summary, } async def publish_activity_event( - client: redis.Redis, + valkey_client: redis.Redis, post_id: str, event_type: str, actor_account_id: str, - summary: str, + activity_summary: str, ) -> str: """``XADD`` one event onto the post's stream. Returns the entry id. @@ -72,55 +77,65 @@ async def publish_activity_event( "lineageweave.valkey.activity_xadd", {"db.system": "redis", "db.operation.name": "xadd", "lineageweave.stream.kind": "activity"}, ): - return await client.xadd( + return await valkey_client.xadd( _stream_key(post_id), - _activity_fields(event_type, actor_account_id, summary), + _activity_fields(event_type, actor_account_id, activity_summary), maxlen=1000, approximate=True, ) def publish_activity_event_sync( - client: Any, + valkey_client: Any, post_id: str, event_type: str, actor_account_id: str, - summary: str, + activity_summary: str, ) -> str | None: - """Sync ``XADD`` for ``make seed``. Returns None if ``summary`` is already on the stream.""" - key = _stream_key(post_id) + """Sync ``XADD`` for ``make seed``; skip an existing activity summary.""" + stream_key = _stream_key(post_id) with traced( "lineageweave.valkey.activity_xrevrange", {"db.system": "redis", "db.operation.name": "xrevrange", "lineageweave.stream.kind": "activity"}, ): - existing = client.xrevrange(key, count=50) - if any(fields.get("summary") == summary for _entry_id, fields in existing): + existing_entries = valkey_client.xrevrange(stream_key, count=50) + if any( + activity_fields.get("summary") == activity_summary + for _entry_id, activity_fields in existing_entries + ): return None with traced( "lineageweave.valkey.activity_xadd", {"db.system": "redis", "db.operation.name": "xadd", "lineageweave.stream.kind": "activity"}, ): - return client.xadd( - key, - _activity_fields(event_type, str(actor_account_id), summary), + return valkey_client.xadd( + stream_key, + _activity_fields(event_type, str(actor_account_id), activity_summary), maxlen=1000, approximate=True, ) -async def read_activity_events(client: redis.Redis, post_id: str, count: int = 50) -> list[dict[str, Any]]: - """The post's most recent events, newest first.""" +async def read_activity_events( + valkey_client: redis.Redis, + post_id: str, + event_count: int = 50, +) -> list[dict[str, Any]]: + """Read the post's most recent activity events, newest first.""" with traced( "lineageweave.valkey.activity_xrevrange", {"db.system": "redis", "db.operation.name": "xrevrange", "lineageweave.stream.kind": "activity"}, ): - entries = await client.xrevrange(_stream_key(post_id), count=count) + stream_entries = await valkey_client.xrevrange( + _stream_key(post_id), + count=event_count, + ) return [ { "event_id": entry_id, - "event_type": fields["event_type"], - "actor_account_id": fields["actor_account_id"], - "summary": fields["summary"], + "event_type": activity_fields["event_type"], + "actor_account_id": activity_fields["actor_account_id"], + "summary": activity_fields["summary"], } - for entry_id, fields in entries + for entry_id, activity_fields in stream_entries ] From 3ac2c00e3368005a8d4c8bb23e95768bf62486be Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 2 Sep 2026 12:55:15 +0900 Subject: [PATCH 04/66] test(activity): expose reseed window idempotency gap --- tests/test_activity_stream.py | 38 +++++++++++++++++++++++++++++++++-- 1 file changed, 36 insertions(+), 2 deletions(-) diff --git a/tests/test_activity_stream.py b/tests/test_activity_stream.py index 67a6d6fca..bf2201348 100644 --- a/tests/test_activity_stream.py +++ b/tests/test_activity_stream.py @@ -25,9 +25,10 @@ class _FakeStream: def __init__(self) -> None: self.entries: list[tuple[str, dict[str, str]]] = [] - def xrevrange(self, key: str, count: int = 50): + def xrevrange(self, key: str, count: int | None = None): del key - return list(reversed(self.entries[-count:])) + entries = list(reversed(self.entries)) + return entries if count is None else entries[:count] def xadd(self, key: str, fields: dict[str, str], maxlen=None, approximate=None): del key, maxlen, approximate @@ -91,6 +92,39 @@ def test_publish_activity_event_sync_skips_a_matching_summary() -> None: assert "Send Northridge Grid the revised quote" in client.entries[0][1]["summary"] +def test_publish_activity_event_sync_scans_the_retained_stream_for_reseed_idempotency() -> None: + """A retained seed event stays idempotent after more than 50 newer events.""" + client = _FakeStream() + seed_summary = ticket_created_summary("Send Northridge Grid the revised quote") + assert publish_activity_event_sync( + client, + "post-1", + "ticket_created", + "acct-1", + seed_summary, + ) == "1-0" + + for index in range(51): + client.xadd( + "activity:post-1", + { + "event_type": "ticket_status_changed", + "actor_account_id": "acct-1", + "summary": f"newer activity {index}", + }, + ) + + entry_count = len(client.entries) + assert publish_activity_event_sync( + client, + "post-1", + "ticket_created", + "acct-1", + seed_summary, + ) is None + assert len(client.entries) == entry_count + + def test_valkey_child_span_shares_parent_trace_id(monkeypatch) -> None: """Same-process Valkey work inherits the parent TraceId.""" from opentelemetry import trace From 8c414486fed80b907a19661478b73374ffc1c4ff Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 2 Sep 2026 12:56:30 +0900 Subject: [PATCH 05/66] fix(activity): preserve reseed idempotency across retained stream --- backend/app/activity_stream.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/backend/app/activity_stream.py b/backend/app/activity_stream.py index 3ac1af83a..a25330ef0 100644 --- a/backend/app/activity_stream.py +++ b/backend/app/activity_stream.py @@ -92,13 +92,13 @@ def publish_activity_event_sync( actor_account_id: str, activity_summary: str, ) -> str | None: - """Sync ``XADD`` for ``make seed``; skip an existing activity summary.""" + """Sync ``XADD`` for ``make seed``; skip an existing retained summary.""" stream_key = _stream_key(post_id) with traced( "lineageweave.valkey.activity_xrevrange", {"db.system": "redis", "db.operation.name": "xrevrange", "lineageweave.stream.kind": "activity"}, ): - existing_entries = valkey_client.xrevrange(stream_key, count=50) + existing_entries = valkey_client.xrevrange(stream_key) if any( activity_fields.get("summary") == activity_summary for _entry_id, activity_fields in existing_entries From bb3cae07c3843f75f8fbe01654b9df85da2ebc4b Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 2 Sep 2026 13:52:28 +0900 Subject: [PATCH 06/66] test(activity): preserve distinct same-summary events --- tests/test_activity_stream.py | 30 ++++++++++++++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/tests/test_activity_stream.py b/tests/test_activity_stream.py index bf2201348..cceb8fa60 100644 --- a/tests/test_activity_stream.py +++ b/tests/test_activity_stream.py @@ -92,6 +92,36 @@ def test_publish_activity_event_sync_skips_a_matching_summary() -> None: assert "Send Northridge Grid the revised quote" in client.entries[0][1]["summary"] +def test_publish_activity_event_sync_keeps_distinct_events_with_same_summary() -> None: + """Reseeding must not collapse different activity facts onto summary text.""" + client = _FakeStream() + shared_summary = "Assignment updated" + client.xadd( + "activity:post-1", + { + "event_type": "ticket_status_changed", + "actor_account_id": "acct-2", + "summary": shared_summary, + }, + ) + + created = publish_activity_event_sync( + client, + "post-1", + "ticket_created", + "acct-1", + shared_summary, + ) + + assert created == "1-1" + assert len(client.entries) == 2 + assert client.entries[-1][1] == { + "event_type": "ticket_created", + "actor_account_id": "acct-1", + "summary": shared_summary, + } + + def test_publish_activity_event_sync_scans_the_retained_stream_for_reseed_idempotency() -> None: """A retained seed event stays idempotent after more than 50 newer events.""" client = _FakeStream() From ef15ddb8f98ce2aea9e16fc55623ddc3a91d48f5 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 2 Sep 2026 13:52:47 +0900 Subject: [PATCH 07/66] fix(activity): deduplicate exact retained event identity --- backend/app/activity_stream.py | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/backend/app/activity_stream.py b/backend/app/activity_stream.py index a25330ef0..6aeab385a 100644 --- a/backend/app/activity_stream.py +++ b/backend/app/activity_stream.py @@ -92,15 +92,23 @@ def publish_activity_event_sync( actor_account_id: str, activity_summary: str, ) -> str | None: - """Sync ``XADD`` for ``make seed``; skip an existing retained summary.""" + """Sync ``XADD`` for ``make seed``; skip the same retained activity fact.""" stream_key = _stream_key(post_id) + expected_fields = _activity_fields( + event_type, + str(actor_account_id), + activity_summary, + ) with traced( "lineageweave.valkey.activity_xrevrange", {"db.system": "redis", "db.operation.name": "xrevrange", "lineageweave.stream.kind": "activity"}, ): existing_entries = valkey_client.xrevrange(stream_key) if any( - activity_fields.get("summary") == activity_summary + all( + activity_fields.get(field_name) == expected_value + for field_name, expected_value in expected_fields.items() + ) for _entry_id, activity_fields in existing_entries ): return None @@ -110,7 +118,7 @@ def publish_activity_event_sync( ): return valkey_client.xadd( stream_key, - _activity_fields(event_type, str(actor_account_id), activity_summary), + expected_fields, maxlen=1000, approximate=True, ) From 28db59c774fe6618222e9278b4d6be46e0688009 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 2 Sep 2026 14:48:09 +0900 Subject: [PATCH 08/66] docs(activity): document Valkey boundary invariants --- backend/app/activity_stream.py | 66 ++++++++++++++++++++++++++-------- 1 file changed, 51 insertions(+), 15 deletions(-) diff --git a/backend/app/activity_stream.py b/backend/app/activity_stream.py index 6aeab385a..6f7850c13 100644 --- a/backend/app/activity_stream.py +++ b/backend/app/activity_stream.py @@ -18,31 +18,49 @@ def create_valkey_client(valkey_url: str) -> redis.Redis: - """Create the shared async Valkey client for the process.""" + """Create the process-wide async client for the configured Valkey endpoint. + + ``valkey_url`` is deployment configuration, not a per-request destination. + Connection establishment stays lazy in redis-py; startup therefore owns the + client lifecycle while request handlers only consume the stored client. + """ return redis.from_url(valkey_url, decode_responses=True) def get_valkey(request: Request) -> redis.Redis: - """FastAPI dependency: the client stored on ``app.state`` at startup.""" + """Return the Valkey client installed on FastAPI application state. + + Startup is responsible for creating ``app.state.valkey``. This dependency + deliberately does not construct a fallback client from request data or + environment state because that would create a second connection authority. + """ return request.app.state.valkey def _stream_key(post_id: str) -> str: + """Map one canonical post id to its stable activity-stream wire key. + + The prefix is part of the persisted Valkey contract. Keep key construction + centralized so producers and readers cannot silently diverge on namespace. + """ return f"activity:{post_id}" def ticket_created_summary(ticket_title: str) -> str: - """The ``summary`` field ``ticket_created`` producers must share.""" + """Build the stable human-readable summary for a ticket-created event. + + The summary is display text and only one field of reseed identity; callers + must not use it alone to decide whether two activity facts are the same. + """ return f"Ticket created: {ticket_title}" def ticket_status_changed_summary(status_label: str) -> str: - """The ``summary`` field ``ticket_status_changed`` producers must share. + """Build the stable summary for a ticket-status transition. - ``status_label`` is the ``common_lookup_value`` label (Open, In - progress, Closed), never the raw code. A missing lookup already - fell back to the code in ``_attach_status_labels``; this helper - does not invent a name. + ``status_label`` is the ``common_lookup_value`` label (Open, In progress, + Closed), never the raw code. A missing lookup already fell back to the code + in ``_attach_status_labels``; this helper does not invent a replacement. """ return f"Ticket status changed to {status_label}" @@ -52,7 +70,12 @@ def _activity_fields( actor_account_id: str, activity_summary: str, ) -> dict[str, str]: - """Translate semantic activity values to the established Valkey wire fields.""" + """Translate semantic activity values to the established Valkey wire shape. + + Internal names may become more specific, but the persisted ``summary`` key + is compatibility-sensitive. This adapter is the only intentional mapping + between the bounded-context name and that historical field name. + """ return { "event_type": event_type, "actor_account_id": actor_account_id, @@ -67,11 +90,12 @@ async def publish_activity_event( actor_account_id: str, activity_summary: str, ) -> str: - """``XADD`` one event onto the post's stream. Returns the entry id. + """Append one activity fact to the post stream and return its entry id. - Approximately trimmed to the most recent 1000 entries (``maxlen``, - ``approximate=True``) so one very active post's stream can't grow - without bound -- the panel only ever shows the most recent 50 anyway. + The ordinary runtime path never performs reseed deduplication: each accepted + application event is appended once by its caller. Approximate trimming keeps + one very active post from growing without bound while preserving the recent + activity window consumed by the UI. """ with traced( "lineageweave.valkey.activity_xadd", @@ -92,7 +116,14 @@ def publish_activity_event_sync( actor_account_id: str, activity_summary: str, ) -> str | None: - """Sync ``XADD`` for ``make seed``; skip the same retained activity fact.""" + """Append a seed/admin activity unless the same retained fact already exists. + + This synchronous path scans the retained stream because ``make seed`` must + be replay-safe even after more than fifty newer events. Identity is the + established tuple ``event_type`` + ``actor_account_id`` + ``summary``; + summary text alone is insufficient because distinct facts can share text. + Returns ``None`` only when that exact retained wire identity already exists. + """ stream_key = _stream_key(post_id) expected_fields = _activity_fields( event_type, @@ -129,7 +160,12 @@ async def read_activity_events( post_id: str, event_count: int = 50, ) -> list[dict[str, Any]]: - """Read the post's most recent activity events, newest first.""" + """Read the newest retained activity events for one post. + + ``event_count`` bounds the buyer-facing read; this function does not broaden + the query to other posts or reconstruct missing facts. Returned dictionaries + preserve the established event id/type/actor/summary wire fields. + """ with traced( "lineageweave.valkey.activity_xrevrange", {"db.system": "redis", "db.operation.name": "xrevrange", "lineageweave.stream.kind": "activity"}, From 819a4df365991e763186043640bf279775ed5240 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 2 Sep 2026 14:50:31 +0900 Subject: [PATCH 09/66] test(activity): isolate reseed identity dimensions --- tests/test_activity_stream.py | 63 ++++++++++++++++++++++++++++++++++- 1 file changed, 62 insertions(+), 1 deletion(-) diff --git a/tests/test_activity_stream.py b/tests/test_activity_stream.py index cceb8fa60..2a8d67ed0 100644 --- a/tests/test_activity_stream.py +++ b/tests/test_activity_stream.py @@ -22,15 +22,20 @@ class _FakeStream: + """Small in-memory stand-in for the Valkey stream methods under contract.""" + def __init__(self) -> None: + """Start with no retained activity entries.""" self.entries: list[tuple[str, dict[str, str]]] = [] def xrevrange(self, key: str, count: int | None = None): + """Return newest-first entries with the same optional count boundary.""" del key entries = list(reversed(self.entries)) return entries if count is None else entries[:count] def xadd(self, key: str, fields: dict[str, str], maxlen=None, approximate=None): + """Append a copied wire record and return a deterministic fake entry id.""" del key, maxlen, approximate entry_id = f"1-{len(self.entries)}" self.entries.append((entry_id, dict(fields))) @@ -57,19 +62,22 @@ def test_activity_stream_owned_parameters_use_semantic_names() -> None: def test_ticket_created_summary_matches_the_live_api_wording() -> None: + """Ticket creation retains the wording consumed by the live Activity UI.""" assert ticket_created_summary("Send Northridge Grid the revised quote") == ( "Ticket created: Send Northridge Grid the revised quote" ) def test_ticket_status_changed_summary_uses_the_lookup_label() -> None: + """Status activity uses the resolved label rather than leaking its raw code.""" assert ticket_status_changed_summary("In progress") == ( "Ticket status changed to In progress" ) assert "in_progress" not in ticket_status_changed_summary("In progress") -def test_publish_activity_event_sync_skips_a_matching_summary() -> None: +def test_publish_activity_event_sync_skips_a_matching_activity_fact() -> None: + """An exact retained event-type, actor, and summary tuple is replay-idempotent.""" client = _FakeStream() first = publish_activity_event_sync( client, @@ -122,6 +130,56 @@ def test_publish_activity_event_sync_keeps_distinct_events_with_same_summary() - } +def test_publish_activity_event_sync_keeps_same_event_type_for_different_actor() -> None: + """Actor identity independently distinguishes otherwise equal reseed facts.""" + client = _FakeStream() + shared_summary = "Assignment updated" + client.xadd( + "activity:post-1", + { + "event_type": "ticket_status_changed", + "actor_account_id": "acct-2", + "summary": shared_summary, + }, + ) + + created = publish_activity_event_sync( + client, + "post-1", + "ticket_status_changed", + "acct-1", + shared_summary, + ) + + assert created == "1-1" + assert client.entries[-1][1]["actor_account_id"] == "acct-1" + + +def test_publish_activity_event_sync_keeps_same_actor_for_different_event_type() -> None: + """Event type independently distinguishes otherwise equal reseed facts.""" + client = _FakeStream() + shared_summary = "Assignment updated" + client.xadd( + "activity:post-1", + { + "event_type": "ticket_status_changed", + "actor_account_id": "acct-1", + "summary": shared_summary, + }, + ) + + created = publish_activity_event_sync( + client, + "post-1", + "ticket_created", + "acct-1", + shared_summary, + ) + + assert created == "1-1" + assert client.entries[-1][1]["event_type"] == "ticket_created" + + def test_publish_activity_event_sync_scans_the_retained_stream_for_reseed_idempotency() -> None: """A retained seed event stays idempotent after more than 50 newer events.""" client = _FakeStream() @@ -163,7 +221,10 @@ def test_valkey_child_span_shares_parent_trace_id(monkeypatch) -> None: captured: dict[str, str] = {} class _Client(_FakeStream): + """Capture tracing identity at the fake Valkey append boundary.""" + def xadd(self, key: str, fields: dict[str, str], maxlen=None, approximate=None): + """Record the current child span before delegating to the fake stream.""" span = trace.get_current_span() captured["trace_id"] = format(span.get_span_context().trace_id, "032x") captured["span_id"] = format(span.get_span_context().span_id, "016x") From b689091ebb4dca2690c403d8bfe2aa78c6cfb96a Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 2 Sep 2026 14:54:49 +0900 Subject: [PATCH 10/66] test(activity): reproduce concurrent reseed duplication --- tests/test_activity_stream.py | 39 +++++++++++++++++++++++++++++++++++ 1 file changed, 39 insertions(+) diff --git a/tests/test_activity_stream.py b/tests/test_activity_stream.py index 2a8d67ed0..ac2e960c0 100644 --- a/tests/test_activity_stream.py +++ b/tests/test_activity_stream.py @@ -42,6 +42,24 @@ def xadd(self, key: str, fields: dict[str, str], maxlen=None, approximate=None): return entry_id +class _RaceInjectingStream(_FakeStream): + """Inject one concurrent seed after the caller reads its stale snapshot.""" + + def __init__(self, concurrent_fields: dict[str, str]) -> None: + """Remember the fact another seed execution will publish once.""" + super().__init__() + self.concurrent_fields = dict(concurrent_fields) + self.injected = False + + def xrevrange(self, key: str, count: int | None = None): + """Return the old snapshot, then simulate another process appending it.""" + entries = super().xrevrange(key, count=count) + if not self.injected: + self.injected = True + super().xadd(key, self.concurrent_fields) + return entries + + def test_activity_stream_owned_parameters_use_semantic_names() -> None: """Organization-owned activity helpers expose bounded-context vocabulary.""" assert list(signature(create_valkey_client).parameters) == ["valkey_url"] @@ -213,6 +231,27 @@ def test_publish_activity_event_sync_scans_the_retained_stream_for_reseed_idempo assert len(client.entries) == entry_count +def test_publish_activity_event_sync_retries_when_another_seed_wins_the_race() -> None: + """Concurrent make-seed executions must not append the same activity twice.""" + expected_fields = { + "event_type": "ticket_created", + "actor_account_id": "acct-1", + "summary": ticket_created_summary("Send Northridge Grid the revised quote"), + } + client = _RaceInjectingStream(expected_fields) + + created = publish_activity_event_sync( + client, + "post-1", + expected_fields["event_type"], + expected_fields["actor_account_id"], + expected_fields["summary"], + ) + + assert created is None + assert client.entries == [("1-0", expected_fields)] + + def test_valkey_child_span_shares_parent_trace_id(monkeypatch) -> None: """Same-process Valkey work inherits the parent TraceId.""" from opentelemetry import trace From 5b9b1e9fee75d15a8e4f140caa603b50137f6590 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 2 Sep 2026 14:55:51 +0900 Subject: [PATCH 11/66] test(activity): model watched reseed transaction --- tests/test_activity_stream.py | 65 +++++++++++++++++++++++++++++++++-- 1 file changed, 63 insertions(+), 2 deletions(-) diff --git a/tests/test_activity_stream.py b/tests/test_activity_stream.py index ac2e960c0..ac0a06c02 100644 --- a/tests/test_activity_stream.py +++ b/tests/test_activity_stream.py @@ -9,6 +9,8 @@ from inspect import signature +from redis.exceptions import WatchError + from backend.app.activity_stream import ( create_valkey_client, publish_activity_event, @@ -25,8 +27,9 @@ class _FakeStream: """Small in-memory stand-in for the Valkey stream methods under contract.""" def __init__(self) -> None: - """Start with no retained activity entries.""" + """Start with no retained activity entries or key mutations.""" self.entries: list[tuple[str, dict[str, str]]] = [] + self.version = 0 def xrevrange(self, key: str, count: int | None = None): """Return newest-first entries with the same optional count boundary.""" @@ -35,12 +38,70 @@ def xrevrange(self, key: str, count: int | None = None): return entries if count is None else entries[:count] def xadd(self, key: str, fields: dict[str, str], maxlen=None, approximate=None): - """Append a copied wire record and return a deterministic fake entry id.""" + """Append a copied wire record and advance the watched key version.""" del key, maxlen, approximate entry_id = f"1-{len(self.entries)}" self.entries.append((entry_id, dict(fields))) + self.version += 1 return entry_id + def pipeline(self): + """Create the WATCH/MULTI pipeline used by synchronous reseeding.""" + return _FakePipeline(self) + + +class _FakePipeline: + """Model the redis-py WATCH/MULTI behavior needed by the reseed contract.""" + + def __init__(self, stream: _FakeStream) -> None: + """Bind the transaction to one fake stream client.""" + self.stream = stream + self.watched_version: int | None = None + self.pending_xadd: tuple[str, dict[str, str], object, object] | None = None + self.in_multi = False + + def __enter__(self): + """Return this transaction context.""" + return self + + def __exit__(self, exc_type, exc, traceback) -> bool: + """Never suppress transaction exceptions.""" + del exc_type, exc, traceback + return False + + def watch(self, key: str) -> None: + """Remember the stream version at the optimistic-lock boundary.""" + del key + self.watched_version = self.stream.version + + def unwatch(self) -> None: + """Release the optimistic lock after finding an existing fact.""" + self.watched_version = None + + def xrevrange(self, key: str, count: int | None = None): + """Read immediately while the key is watched, as redis-py does.""" + return self.stream.xrevrange(key, count=count) + + def multi(self) -> None: + """Begin queuing the append for optimistic execution.""" + self.in_multi = True + + def xadd(self, key: str, fields: dict[str, str], maxlen=None, approximate=None): + """Queue one append after MULTI rather than mutating immediately.""" + if not self.in_multi: + return self.stream.xadd(key, fields, maxlen=maxlen, approximate=approximate) + self.pending_xadd = (key, dict(fields), maxlen, approximate) + return self + + def execute(self): + """Reject a stale watched snapshot or atomically apply the queued append.""" + if self.watched_version != self.stream.version: + raise WatchError("synthetic concurrent stream mutation") + if self.pending_xadd is None: + return [] + key, fields, maxlen, approximate = self.pending_xadd + return [self.stream.xadd(key, fields, maxlen=maxlen, approximate=approximate)] + class _RaceInjectingStream(_FakeStream): """Inject one concurrent seed after the caller reads its stale snapshot.""" From 7ebf1fe6c4bc46496bf3f86e96c90fc502d631d9 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 2 Sep 2026 14:56:13 +0900 Subject: [PATCH 12/66] fix(activity): make seed dedupe atomic under concurrency --- backend/app/activity_stream.py | 91 +++++++++++++++++++++++----------- 1 file changed, 61 insertions(+), 30 deletions(-) diff --git a/backend/app/activity_stream.py b/backend/app/activity_stream.py index 6f7850c13..62c93ad3f 100644 --- a/backend/app/activity_stream.py +++ b/backend/app/activity_stream.py @@ -13,6 +13,7 @@ import redis.asyncio as redis from fastapi import Request +from redis.exceptions import WatchError from lineageweave.observability import traced @@ -83,6 +84,17 @@ def _activity_fields( } +def _matches_activity_fields( + activity_fields: dict[str, str], + expected_fields: dict[str, str], +) -> bool: + """Return whether a retained wire record has the legacy reseed identity.""" + return all( + activity_fields.get(field_name) == expected_value + for field_name, expected_value in expected_fields.items() + ) + + async def publish_activity_event( valkey_client: redis.Redis, post_id: str, @@ -116,13 +128,15 @@ def publish_activity_event_sync( actor_account_id: str, activity_summary: str, ) -> str | None: - """Append a seed/admin activity unless the same retained fact already exists. - - This synchronous path scans the retained stream because ``make seed`` must - be replay-safe even after more than fifty newer events. Identity is the - established tuple ``event_type`` + ``actor_account_id`` + ``summary``; - summary text alone is insufficient because distinct facts can share text. - Returns ``None`` only when that exact retained wire identity already exists. + """Append one replay-safe seed/admin activity with optimistic concurrency. + + ``make seed`` can run in more than one process. A plain ``XREVRANGE`` then + ``XADD`` check is racy because two callers can read the same old snapshot + and both append. The synchronous seed path therefore WATCHes the post stream, + reads the retained identity tuple, and commits the append with MULTI/EXEC. + A concurrent mutation invalidates the watched snapshot and the operation + retries against the new stream state. Ordinary async event publication is + intentionally unchanged and remains append-only. """ stream_key = _stream_key(post_id) expected_fields = _activity_fields( @@ -130,29 +144,46 @@ def publish_activity_event_sync( str(actor_account_id), activity_summary, ) - with traced( - "lineageweave.valkey.activity_xrevrange", - {"db.system": "redis", "db.operation.name": "xrevrange", "lineageweave.stream.kind": "activity"}, - ): - existing_entries = valkey_client.xrevrange(stream_key) - if any( - all( - activity_fields.get(field_name) == expected_value - for field_name, expected_value in expected_fields.items() - ) - for _entry_id, activity_fields in existing_entries - ): - return None - with traced( - "lineageweave.valkey.activity_xadd", - {"db.system": "redis", "db.operation.name": "xadd", "lineageweave.stream.kind": "activity"}, - ): - return valkey_client.xadd( - stream_key, - expected_fields, - maxlen=1000, - approximate=True, - ) + + while True: + with valkey_client.pipeline() as transaction: + try: + transaction.watch(stream_key) + with traced( + "lineageweave.valkey.activity_xrevrange", + { + "db.system": "redis", + "db.operation.name": "xrevrange", + "lineageweave.stream.kind": "activity", + }, + ): + existing_entries = transaction.xrevrange(stream_key) + + if any( + _matches_activity_fields(activity_fields, expected_fields) + for _entry_id, activity_fields in existing_entries + ): + transaction.unwatch() + return None + + transaction.multi() + transaction.xadd( + stream_key, + expected_fields, + maxlen=1000, + approximate=True, + ) + with traced( + "lineageweave.valkey.activity_xadd", + { + "db.system": "redis", + "db.operation.name": "xadd", + "lineageweave.stream.kind": "activity", + }, + ): + return transaction.execute()[0] + except WatchError: + continue async def read_activity_events( From 40b823f2e9b504ad005715932bb87a589597e956 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 2 Sep 2026 15:46:43 +0900 Subject: [PATCH 13/66] test(activity): reproduce unbounded WATCH retries --- tests/test_activity_stream_retry_limit.py | 78 +++++++++++++++++++++++ 1 file changed, 78 insertions(+) create mode 100644 tests/test_activity_stream_retry_limit.py diff --git a/tests/test_activity_stream_retry_limit.py b/tests/test_activity_stream_retry_limit.py new file mode 100644 index 000000000..4218a0bc0 --- /dev/null +++ b/tests/test_activity_stream_retry_limit.py @@ -0,0 +1,78 @@ +"""Bound the optimistic retry loop used by synchronous activity reseeding.""" + +from __future__ import annotations + +import pytest +from redis.exceptions import WatchError + +from backend.app.activity_stream import publish_activity_event_sync + + +class _ConflictStream: + """Force repeated WATCH conflicts without requiring a live Valkey process.""" + + def __init__(self, conflict_attempts: int) -> None: + """Allow exactly ``conflict_attempts`` synthetic optimistic conflicts.""" + self.conflict_attempts = conflict_attempts + self.execute_attempts = 0 + + def pipeline(self): + """Return a transaction facade for one optimistic reseed attempt.""" + return _ConflictPipeline(self) + + +class _ConflictPipeline: + """Model the transaction surface while forcing bounded WATCH conflicts.""" + + def __init__(self, stream: _ConflictStream) -> None: + """Bind the transaction to the shared conflict counter.""" + self.stream = stream + + def __enter__(self): + """Return the transaction facade.""" + return self + + def __exit__(self, exc_type, exc, traceback) -> bool: + """Never suppress transaction exceptions.""" + del exc_type, exc, traceback + return False + + def watch(self, key: str) -> None: + """Accept the production WATCH call without external state.""" + del key + + def xrevrange(self, key: str): + """Expose an empty retained stream so the append path is exercised.""" + del key + return [] + + def multi(self) -> None: + """Accept the production MULTI transition.""" + + def xadd(self, key: str, fields: dict[str, str], maxlen=None, approximate=None): + """Accept the queued append without mutating external state.""" + del key, fields, maxlen, approximate + return self + + def execute(self): + """Raise WATCH conflicts, then fail if production retries past the bound.""" + self.stream.execute_attempts += 1 + if self.stream.execute_attempts <= self.stream.conflict_attempts: + raise WatchError("synthetic persistent activity-stream conflict") + raise AssertionError("activity reseed retried beyond the expected bound") + + +def test_publish_activity_event_sync_fails_after_bounded_watch_conflicts() -> None: + """Persistent contention must fail clearly instead of spinning forever.""" + client = _ConflictStream(conflict_attempts=8) + + with pytest.raises(RuntimeError, match=r"activity:post-1.*8"): + publish_activity_event_sync( + client, + "post-1", + "ticket_created", + "acct-1", + "Ticket created: bounded retry", + ) + + assert client.execute_attempts == 8 From a35925bb6578d46138ecf75b0a5d86309a57e566 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 2 Sep 2026 15:47:27 +0900 Subject: [PATCH 14/66] fix(activity): bound WATCH conflict retries --- backend/app/activity_stream.py | 23 ++++++++++++++++------- 1 file changed, 16 insertions(+), 7 deletions(-) diff --git a/backend/app/activity_stream.py b/backend/app/activity_stream.py index 62c93ad3f..7479fe3b5 100644 --- a/backend/app/activity_stream.py +++ b/backend/app/activity_stream.py @@ -17,6 +17,8 @@ from lineageweave.observability import traced +_SYNC_ACTIVITY_WATCH_RETRY_LIMIT = 8 + def create_valkey_client(valkey_url: str) -> redis.Redis: """Create the process-wide async client for the configured Valkey endpoint. @@ -134,9 +136,10 @@ def publish_activity_event_sync( ``XADD`` check is racy because two callers can read the same old snapshot and both append. The synchronous seed path therefore WATCHes the post stream, reads the retained identity tuple, and commits the append with MULTI/EXEC. - A concurrent mutation invalidates the watched snapshot and the operation - retries against the new stream state. Ordinary async event publication is - intentionally unchanged and remains append-only. + A concurrent mutation invalidates the watched snapshot and retries against + fresh stream state, but persistent contention fails after a bounded number + of attempts rather than leaving an operator command spinning indefinitely. + Ordinary async event publication remains append-only. """ stream_key = _stream_key(post_id) expected_fields = _activity_fields( @@ -145,7 +148,7 @@ def publish_activity_event_sync( activity_summary, ) - while True: + for watch_attempt in range(1, _SYNC_ACTIVITY_WATCH_RETRY_LIMIT + 1): with valkey_client.pipeline() as transaction: try: transaction.watch(stream_key) @@ -182,8 +185,14 @@ def publish_activity_event_sync( }, ): return transaction.execute()[0] - except WatchError: - continue + except WatchError as watch_error: + if watch_attempt == _SYNC_ACTIVITY_WATCH_RETRY_LIMIT: + raise RuntimeError( + f"Activity reseed for {stream_key} exceeded " + f"{_SYNC_ACTIVITY_WATCH_RETRY_LIMIT} WATCH retries" + ) from watch_error + + raise RuntimeError(f"Activity reseed for {stream_key} exhausted its retry loop") async def read_activity_events( @@ -213,4 +222,4 @@ async def read_activity_events( "summary": activity_fields["summary"], } for entry_id, activity_fields in stream_entries - ] + ] \ No newline at end of file From 6a2d60dee5650474f9b3415789d5224bb8cda6f1 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 2 Sep 2026 17:48:21 +0900 Subject: [PATCH 15/66] test(activity): keep reseed contention errors identifier-safe --- tests/test_activity_stream_retry_limit.py | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/tests/test_activity_stream_retry_limit.py b/tests/test_activity_stream_retry_limit.py index 4218a0bc0..edcf13c77 100644 --- a/tests/test_activity_stream_retry_limit.py +++ b/tests/test_activity_stream_retry_limit.py @@ -63,10 +63,10 @@ def execute(self): def test_publish_activity_event_sync_fails_after_bounded_watch_conflicts() -> None: - """Persistent contention must fail clearly instead of spinning forever.""" + """Contention fails clearly without disclosing the post-scoped stream key.""" client = _ConflictStream(conflict_attempts=8) - with pytest.raises(RuntimeError, match=r"activity:post-1.*8"): + with pytest.raises(RuntimeError) as error_info: publish_activity_event_sync( client, "post-1", @@ -75,4 +75,9 @@ def test_publish_activity_event_sync_fails_after_bounded_watch_conflicts() -> No "Ticket created: bounded retry", ) + error_message = str(error_info.value) + assert "activity reseed" in error_message.lower() + assert "8" in error_message + assert "post-1" not in error_message + assert "activity:post-1" not in error_message assert client.execute_attempts == 8 From 2905bd52ea19742913e06a47a821ce0d19feb45d Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 2 Sep 2026 17:48:57 +0900 Subject: [PATCH 16/66] fix(activity): keep contention errors identifier-safe --- backend/app/activity_stream.py | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/backend/app/activity_stream.py b/backend/app/activity_stream.py index 7479fe3b5..24e7daa5e 100644 --- a/backend/app/activity_stream.py +++ b/backend/app/activity_stream.py @@ -139,7 +139,9 @@ def publish_activity_event_sync( A concurrent mutation invalidates the watched snapshot and retries against fresh stream state, but persistent contention fails after a bounded number of attempts rather than leaving an operator command spinning indefinitely. - Ordinary async event publication remains append-only. + Ordinary async event publication remains append-only. Contention failures + identify the operation and retry limit without embedding the post-scoped + Valkey key in exception text that may be exported by logging or telemetry. """ stream_key = _stream_key(post_id) expected_fields = _activity_fields( @@ -188,11 +190,13 @@ def publish_activity_event_sync( except WatchError as watch_error: if watch_attempt == _SYNC_ACTIVITY_WATCH_RETRY_LIMIT: raise RuntimeError( - f"Activity reseed for {stream_key} exceeded " + "Activity reseed exceeded " f"{_SYNC_ACTIVITY_WATCH_RETRY_LIMIT} WATCH retries" ) from watch_error - raise RuntimeError(f"Activity reseed for {stream_key} exhausted its retry loop") + raise RuntimeError( + "Activity reseed exhausted its bounded WATCH retry loop" + ) async def read_activity_events( From 81c3a49ac97f80e1a0183ac37ca4c84074079ecb Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 2 Sep 2026 17:57:35 +0900 Subject: [PATCH 17/66] test(activity): reject identifier-bearing contention causes --- tests/test_activity_stream_retry_limit.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/tests/test_activity_stream_retry_limit.py b/tests/test_activity_stream_retry_limit.py index edcf13c77..ae4aaff9a 100644 --- a/tests/test_activity_stream_retry_limit.py +++ b/tests/test_activity_stream_retry_limit.py @@ -58,12 +58,14 @@ def execute(self): """Raise WATCH conflicts, then fail if production retries past the bound.""" self.stream.execute_attempts += 1 if self.stream.execute_attempts <= self.stream.conflict_attempts: - raise WatchError("synthetic persistent activity-stream conflict") + raise WatchError( + "synthetic conflict while watching activity:post-1 for post-1" + ) raise AssertionError("activity reseed retried beyond the expected bound") def test_publish_activity_event_sync_fails_after_bounded_watch_conflicts() -> None: - """Contention fails clearly without disclosing the post-scoped stream key.""" + """Contention failure must not retain the raw key through exception chaining.""" client = _ConflictStream(conflict_attempts=8) with pytest.raises(RuntimeError) as error_info: @@ -80,4 +82,6 @@ def test_publish_activity_event_sync_fails_after_bounded_watch_conflicts() -> No assert "8" in error_message assert "post-1" not in error_message assert "activity:post-1" not in error_message + assert error_info.value.__cause__ is None + assert error_info.value.__context__ is None assert client.execute_attempts == 8 From c744fe9655219dfc5a77910352c2c9999af8a132 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 2 Sep 2026 17:58:16 +0900 Subject: [PATCH 18/66] fix(activity): suppress identifier-bearing conflict causes --- backend/app/activity_stream.py | 20 +++++++++++++------- 1 file changed, 13 insertions(+), 7 deletions(-) diff --git a/backend/app/activity_stream.py b/backend/app/activity_stream.py index 24e7daa5e..5bd7cd887 100644 --- a/backend/app/activity_stream.py +++ b/backend/app/activity_stream.py @@ -141,7 +141,8 @@ def publish_activity_event_sync( of attempts rather than leaving an operator command spinning indefinitely. Ordinary async event publication remains append-only. Contention failures identify the operation and retry limit without embedding the post-scoped - Valkey key in exception text that may be exported by logging or telemetry. + Valkey key in exception text or an exception cause that may be exported by + logging or telemetry. """ stream_key = _stream_key(post_id) expected_fields = _activity_fields( @@ -149,6 +150,7 @@ def publish_activity_event_sync( str(actor_account_id), activity_summary, ) + watch_retry_exhausted = False for watch_attempt in range(1, _SYNC_ACTIVITY_WATCH_RETRY_LIMIT + 1): with valkey_client.pipeline() as transaction: @@ -187,12 +189,16 @@ def publish_activity_event_sync( }, ): return transaction.execute()[0] - except WatchError as watch_error: + except WatchError: if watch_attempt == _SYNC_ACTIVITY_WATCH_RETRY_LIMIT: - raise RuntimeError( - "Activity reseed exceeded " - f"{_SYNC_ACTIVITY_WATCH_RETRY_LIMIT} WATCH retries" - ) from watch_error + watch_retry_exhausted = True + break + + if watch_retry_exhausted: + raise RuntimeError( + "Activity reseed exceeded " + f"{_SYNC_ACTIVITY_WATCH_RETRY_LIMIT} WATCH retries" + ) raise RuntimeError( "Activity reseed exhausted its bounded WATCH retry loop" @@ -226,4 +232,4 @@ async def read_activity_events( "summary": activity_fields["summary"], } for entry_id, activity_fields in stream_entries - ] \ No newline at end of file + ] From 8a883bf7356083bde919092d2140f7c604083ec5 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 2 Sep 2026 18:19:22 +0900 Subject: [PATCH 19/66] test(activity): reject false failure telemetry on WATCH retry --- ...est_activity_stream_watch_observability.py | 47 +++++++++++++++++++ 1 file changed, 47 insertions(+) create mode 100644 tests/test_activity_stream_watch_observability.py diff --git a/tests/test_activity_stream_watch_observability.py b/tests/test_activity_stream_watch_observability.py new file mode 100644 index 000000000..167cf9d6e --- /dev/null +++ b/tests/test_activity_stream_watch_observability.py @@ -0,0 +1,47 @@ +"""Observability regressions for replay-safe Valkey activity reseeding.""" + +from __future__ import annotations + +from opentelemetry.trace import StatusCode + +from backend.app.activity_stream import ( + publish_activity_event_sync, + ticket_created_summary, +) +from tests.test_activity_stream import _RaceInjectingStream +from tests.test_observability import attach_inmemory_tracer + + +def test_recoverable_watch_conflict_is_not_exported_as_failed_xadd(monkeypatch) -> None: + """A successfully retried optimistic conflict must not become failure telemetry. + + ``WATCH`` conflicts are expected concurrency control for synchronous seed/admin + replay. If another seed wins with the same activity fact, the loser retries, + observes that fact, and returns successfully. The transient conflict therefore + must not mark the Valkey XADD span as an operation failure or emit an exception + event that can be mistaken for a buyer-visible/infrastructure incident. + """ + exporter = attach_inmemory_tracer(monkeypatch) + expected_fields = { + "event_type": "ticket_created", + "actor_account_id": "acct-1", + "summary": ticket_created_summary("Send Northridge Grid the revised quote"), + } + client = _RaceInjectingStream(expected_fields) + + assert publish_activity_event_sync( + client, + "post-1", + expected_fields["event_type"], + expected_fields["actor_account_id"], + expected_fields["summary"], + ) is None + + xadd_spans = [ + span + for span in exporter.get_finished_spans() + if span.name == "lineageweave.valkey.activity_xadd" + ] + assert len(xadd_spans) == 1 + assert xadd_spans[0].status.status_code is StatusCode.UNSET + assert all(event.name != "exception" for event in xadd_spans[0].events) From 91a1e2febb74503dffe556e0b86f2933affbcea3 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 2 Sep 2026 18:20:06 +0900 Subject: [PATCH 20/66] fix(activity): keep recovered WATCH retries out of failure telemetry --- backend/app/activity_stream.py | 26 ++++++++++++++++++++------ 1 file changed, 20 insertions(+), 6 deletions(-) diff --git a/backend/app/activity_stream.py b/backend/app/activity_stream.py index 5bd7cd887..a990ecf20 100644 --- a/backend/app/activity_stream.py +++ b/backend/app/activity_stream.py @@ -139,10 +139,12 @@ def publish_activity_event_sync( A concurrent mutation invalidates the watched snapshot and retries against fresh stream state, but persistent contention fails after a bounded number of attempts rather than leaving an operator command spinning indefinitely. - Ordinary async event publication remains append-only. Contention failures - identify the operation and retry limit without embedding the post-scoped - Valkey key in exception text or an exception cause that may be exported by - logging or telemetry. + A recovered WATCH conflict is expected concurrency control, not a failed + Valkey operation, so it does not leave the bounded XADD span in an error + state. Ordinary async event publication remains append-only. Contention + failures identify the operation and retry limit without embedding the + post-scoped Valkey key in exception text or an exception cause that may be + exported by logging or telemetry. """ stream_key = _stream_key(post_id) expected_fields = _activity_fields( @@ -180,6 +182,8 @@ def publish_activity_event_sync( maxlen=1000, approximate=True, ) + watch_conflicted = False + committed_entries: list[str] = [] with traced( "lineageweave.valkey.activity_xadd", { @@ -188,7 +192,17 @@ def publish_activity_event_sync( "lineageweave.stream.kind": "activity", }, ): - return transaction.execute()[0] + try: + committed_entries = transaction.execute() + except WatchError: + watch_conflicted = True + + if watch_conflicted: + if watch_attempt == _SYNC_ACTIVITY_WATCH_RETRY_LIMIT: + watch_retry_exhausted = True + break + continue + return committed_entries[0] except WatchError: if watch_attempt == _SYNC_ACTIVITY_WATCH_RETRY_LIMIT: watch_retry_exhausted = True @@ -232,4 +246,4 @@ async def read_activity_events( "summary": activity_fields["summary"], } for entry_id, activity_fields in stream_entries - ] + ] \ No newline at end of file From 625833c5c1b826003285fa4491c69b8f92c6e9cc Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 2 Sep 2026 18:23:43 +0900 Subject: [PATCH 21/66] test(activity): require terminal WATCH exhaustion failure span --- ...est_activity_stream_watch_observability.py | 44 +++++++++++++++++++ 1 file changed, 44 insertions(+) diff --git a/tests/test_activity_stream_watch_observability.py b/tests/test_activity_stream_watch_observability.py index 167cf9d6e..ed6702c45 100644 --- a/tests/test_activity_stream_watch_observability.py +++ b/tests/test_activity_stream_watch_observability.py @@ -9,6 +9,7 @@ ticket_created_summary, ) from tests.test_activity_stream import _RaceInjectingStream +from tests.test_activity_stream_retry_limit import _ConflictStream from tests.test_observability import attach_inmemory_tracer @@ -45,3 +46,46 @@ def test_recoverable_watch_conflict_is_not_exported_as_failed_xadd(monkeypatch) assert len(xadd_spans) == 1 assert xadd_spans[0].status.status_code is StatusCode.UNSET assert all(event.name != "exception" for event in xadd_spans[0].events) + + +def test_exhausted_watch_conflict_marks_only_terminal_xadd_as_failure(monkeypatch) -> None: + """Persistent contention must stay visible without flagging recoverable retries. + + Seven optimistic conflicts are normal retry attempts. The eighth exhausts the + bounded operator contract and is therefore the one XADD attempt that should be + exported as an error. Its exported exception data must stay limited to the safe + RuntimeError type; the raw post-scoped stream key remains absent. + """ + exporter = attach_inmemory_tracer(monkeypatch) + client = _ConflictStream(conflict_attempts=8) + + try: + publish_activity_event_sync( + client, + "post-1", + "ticket_created", + "acct-1", + "Ticket created: bounded retry", + ) + except RuntimeError as error: + assert error.__cause__ is None + assert error.__context__ is None + else: # pragma: no cover - persistent contention is required to fail closed + raise AssertionError("persistent WATCH contention must fail closed") + + xadd_spans = [ + span + for span in exporter.get_finished_spans() + if span.name == "lineageweave.valkey.activity_xadd" + ] + assert len(xadd_spans) == 8 + assert all( + span.status.status_code is StatusCode.UNSET for span in xadd_spans[:-1] + ) + terminal_span = xadd_spans[-1] + assert terminal_span.status.status_code is StatusCode.ERROR + exception_events = [event for event in terminal_span.events if event.name == "exception"] + assert len(exception_events) == 1 + assert exception_events[0].attributes["exception.type"] == "RuntimeError" + assert "post-1" not in str(exception_events[0].attributes) + assert "activity:post-1" not in str(exception_events[0].attributes) From f7fc32a03a9c82ed5ca25d45cbc231ebb4113a89 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 2 Sep 2026 18:24:14 +0900 Subject: [PATCH 22/66] fix(activity): mark only terminal WATCH exhaustion as failure --- backend/app/activity_stream.py | 20 +++++++++++++------- 1 file changed, 13 insertions(+), 7 deletions(-) diff --git a/backend/app/activity_stream.py b/backend/app/activity_stream.py index a990ecf20..d287dc85a 100644 --- a/backend/app/activity_stream.py +++ b/backend/app/activity_stream.py @@ -141,10 +141,11 @@ def publish_activity_event_sync( of attempts rather than leaving an operator command spinning indefinitely. A recovered WATCH conflict is expected concurrency control, not a failed Valkey operation, so it does not leave the bounded XADD span in an error - state. Ordinary async event publication remains append-only. Contention - failures identify the operation and retry limit without embedding the - post-scoped Valkey key in exception text or an exception cause that may be - exported by logging or telemetry. + state. Exhausting the retry budget marks only the terminal XADD attempt as a + safe RuntimeError failure span. Ordinary async event publication remains + append-only. Contention failures identify the operation and retry limit + without embedding the post-scoped Valkey key in exception text or an + exception cause that may be exported by logging or telemetry. """ stream_key = _stream_key(post_id) expected_fields = _activity_fields( @@ -196,11 +197,16 @@ def publish_activity_event_sync( committed_entries = transaction.execute() except WatchError: watch_conflicted = True + if ( + watch_conflicted + and watch_attempt == _SYNC_ACTIVITY_WATCH_RETRY_LIMIT + ): + raise RuntimeError( + "Activity reseed exceeded " + f"{_SYNC_ACTIVITY_WATCH_RETRY_LIMIT} WATCH retries" + ) if watch_conflicted: - if watch_attempt == _SYNC_ACTIVITY_WATCH_RETRY_LIMIT: - watch_retry_exhausted = True - break continue return committed_entries[0] except WatchError: From 8e8d6d2efad7b783e7af3d4ceda4bc918729c980 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 2 Sep 2026 18:49:58 +0900 Subject: [PATCH 23/66] test(activity): reject numeric actor identity aliases --- tests/test_activity_stream_identity_types.py | 30 ++++++++++++++++++++ 1 file changed, 30 insertions(+) create mode 100644 tests/test_activity_stream_identity_types.py diff --git a/tests/test_activity_stream_identity_types.py b/tests/test_activity_stream_identity_types.py new file mode 100644 index 000000000..432f93f38 --- /dev/null +++ b/tests/test_activity_stream_identity_types.py @@ -0,0 +1,30 @@ +"""Fail-closed type boundaries for Valkey activity identity fields.""" + +from __future__ import annotations + +import pytest + +from backend.app.activity_stream import ( + publish_activity_event_sync, + ticket_created_summary, +) + + +class _UnexpectedValkeyAccess: + """Fail if malformed identity reaches the Valkey transaction boundary.""" + + def pipeline(self): + """Prove validation happens before any stream read or mutation.""" + raise AssertionError("malformed activity identity reached Valkey") + + +def test_sync_activity_rejects_numeric_actor_identity_before_valkey_access() -> None: + """A numeric actor id must not alias the canonical string identity ``\"7\"``.""" + with pytest.raises(TypeError, match="actor_account_id must be a string"): + publish_activity_event_sync( + _UnexpectedValkeyAccess(), + "post-1", + "ticket_created", + 7, # type: ignore[arg-type] + ticket_created_summary("Send Northridge Grid the revised quote"), + ) From c22e6c29473e2ab697a3886f713f533822fc6802 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 2 Sep 2026 18:50:28 +0900 Subject: [PATCH 24/66] fix(activity): preserve exact actor identity types --- backend/app/activity_stream.py | 24 +++++++++++++++++++----- 1 file changed, 19 insertions(+), 5 deletions(-) diff --git a/backend/app/activity_stream.py b/backend/app/activity_stream.py index d287dc85a..550bb708a 100644 --- a/backend/app/activity_stream.py +++ b/backend/app/activity_stream.py @@ -68,6 +68,18 @@ def ticket_status_changed_summary(status_label: str) -> str: return f"Ticket status changed to {status_label}" +def _activity_text(value: Any, field_name: str) -> str: + """Require one exact string before it participates in activity identity. + + Valkey accepts several scalar types, so implicit coercion can collapse a + malformed numeric identity such as ``7`` onto the distinct canonical string + identity ``"7"``. Reject that alias before any stream read or mutation. + """ + if type(value) is not str: + raise TypeError(f"{field_name} must be a string") + return value + + def _activity_fields( event_type: str, actor_account_id: str, @@ -77,12 +89,14 @@ def _activity_fields( Internal names may become more specific, but the persisted ``summary`` key is compatibility-sensitive. This adapter is the only intentional mapping - between the bounded-context name and that historical field name. + between the bounded-context name and that historical field name. Identity + and display fields cross the Valkey boundary as exact strings rather than + being coerced from other scalar types. """ return { - "event_type": event_type, - "actor_account_id": actor_account_id, - "summary": activity_summary, + "event_type": _activity_text(event_type, "event_type"), + "actor_account_id": _activity_text(actor_account_id, "actor_account_id"), + "summary": _activity_text(activity_summary, "activity_summary"), } @@ -150,7 +164,7 @@ def publish_activity_event_sync( stream_key = _stream_key(post_id) expected_fields = _activity_fields( event_type, - str(actor_account_id), + actor_account_id, activity_summary, ) watch_retry_exhausted = False From 2707911b0654f40c809b2b7f7f6e3bb2a757a0ec Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 2 Sep 2026 18:50:57 +0900 Subject: [PATCH 25/66] test(activity): reject numeric post identity aliases --- tests/test_activity_stream_identity_types.py | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/tests/test_activity_stream_identity_types.py b/tests/test_activity_stream_identity_types.py index 432f93f38..d141bbf6b 100644 --- a/tests/test_activity_stream_identity_types.py +++ b/tests/test_activity_stream_identity_types.py @@ -28,3 +28,15 @@ def test_sync_activity_rejects_numeric_actor_identity_before_valkey_access() -> 7, # type: ignore[arg-type] ticket_created_summary("Send Northridge Grid the revised quote"), ) + + +def test_sync_activity_rejects_numeric_post_identity_before_valkey_access() -> None: + """A numeric post id must not alias the canonical string stream identity ``\"7\"``.""" + with pytest.raises(TypeError, match="post_id must be a string"): + publish_activity_event_sync( + _UnexpectedValkeyAccess(), + 7, # type: ignore[arg-type] + "ticket_created", + "acct-1", + ticket_created_summary("Send Northridge Grid the revised quote"), + ) From 2e010e29476c80c0383210515997470d3e1cd35d Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 2 Sep 2026 18:51:27 +0900 Subject: [PATCH 26/66] fix(activity): preserve exact post identity types --- backend/app/activity_stream.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/backend/app/activity_stream.py b/backend/app/activity_stream.py index 550bb708a..1f01d7153 100644 --- a/backend/app/activity_stream.py +++ b/backend/app/activity_stream.py @@ -45,7 +45,11 @@ def _stream_key(post_id: str) -> str: The prefix is part of the persisted Valkey contract. Keep key construction centralized so producers and readers cannot silently diverge on namespace. + Exact string admission prevents a malformed scalar such as integer ``7`` + from aliasing the distinct canonical string identity ``"7"``. """ + if type(post_id) is not str: + raise TypeError("post_id must be a string") return f"activity:{post_id}" From e9dcc6b6888ccf76b55d57884d98ffcb379f7e1f Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 2 Sep 2026 18:53:17 +0900 Subject: [PATCH 27/66] test(activity): cover async identity admission --- tests/test_activity_stream_identity_types.py | 28 +++++++++++++++++++- 1 file changed, 27 insertions(+), 1 deletion(-) diff --git a/tests/test_activity_stream_identity_types.py b/tests/test_activity_stream_identity_types.py index d141bbf6b..41c0998fa 100644 --- a/tests/test_activity_stream_identity_types.py +++ b/tests/test_activity_stream_identity_types.py @@ -2,22 +2,34 @@ from __future__ import annotations +import asyncio + import pytest from backend.app.activity_stream import ( + publish_activity_event, publish_activity_event_sync, ticket_created_summary, ) class _UnexpectedValkeyAccess: - """Fail if malformed identity reaches the Valkey transaction boundary.""" + """Fail if malformed identity reaches the synchronous Valkey boundary.""" def pipeline(self): """Prove validation happens before any stream read or mutation.""" raise AssertionError("malformed activity identity reached Valkey") +class _UnexpectedAsyncValkeyAccess: + """Fail if malformed identity reaches the ordinary async append boundary.""" + + async def xadd(self, *args, **kwargs): + """Prove runtime publication validates identity before issuing XADD.""" + del args, kwargs + raise AssertionError("malformed activity identity reached async Valkey") + + def test_sync_activity_rejects_numeric_actor_identity_before_valkey_access() -> None: """A numeric actor id must not alias the canonical string identity ``\"7\"``.""" with pytest.raises(TypeError, match="actor_account_id must be a string"): @@ -40,3 +52,17 @@ def test_sync_activity_rejects_numeric_post_identity_before_valkey_access() -> N "acct-1", ticket_created_summary("Send Northridge Grid the revised quote"), ) + + +def test_async_activity_rejects_numeric_actor_identity_before_xadd() -> None: + """Ordinary runtime publication shares the exact actor-identity admission rule.""" + with pytest.raises(TypeError, match="actor_account_id must be a string"): + asyncio.run( + publish_activity_event( + _UnexpectedAsyncValkeyAccess(), # type: ignore[arg-type] + "post-1", + "ticket_created", + 7, # type: ignore[arg-type] + ticket_created_summary("Send Northridge Grid the revised quote"), + ) + ) From 45db51d1195f45fc4c6f0cbc909764eb07d4f516 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 2 Sep 2026 19:48:55 +0900 Subject: [PATCH 28/66] test(activity): bound buyer-facing stream reads --- tests/test_activity_stream_read_budget.py | 61 +++++++++++++++++++++++ 1 file changed, 61 insertions(+) create mode 100644 tests/test_activity_stream_read_budget.py diff --git a/tests/test_activity_stream_read_budget.py b/tests/test_activity_stream_read_budget.py new file mode 100644 index 000000000..29f0eb4bb --- /dev/null +++ b/tests/test_activity_stream_read_budget.py @@ -0,0 +1,61 @@ +"""Buyer-facing activity reads stay bounded before reaching Valkey.""" + +from __future__ import annotations + +from typing import Any + +import pytest + +from backend.app.activity_stream import read_activity_events + + +class _ReadClient: + """Record whether an invalid read request reaches the Valkey boundary.""" + + def __init__(self) -> None: + self.calls = 0 + + async def xrevrange( + self, + key: str, + *, + count: int, + ) -> list[tuple[str, dict[str, str]]]: + del key, count + self.calls += 1 + return [] + + +@pytest.mark.asyncio +@pytest.mark.parametrize("event_count", (True, 1.0, "50")) +async def test_activity_read_count_requires_an_exact_integer(event_count: Any) -> None: + """Scalar coercion must not create an implicit buyer-facing read budget.""" + client = _ReadClient() + + with pytest.raises(TypeError, match="event_count must be an integer"): + await read_activity_events(client, "post-1", event_count=event_count) + + assert client.calls == 0 + + +@pytest.mark.asyncio +@pytest.mark.parametrize("event_count", (0, -1, 1001)) +async def test_activity_read_count_is_bounded_to_the_retained_window( + event_count: int, +) -> None: + """Invalid counts fail before an unbounded or nonsensical Valkey read.""" + client = _ReadClient() + + with pytest.raises(ValueError, match="event_count must be between 1 and 1000"): + await read_activity_events(client, "post-1", event_count=event_count) + + assert client.calls == 0 + + +@pytest.mark.asyncio +async def test_activity_read_count_accepts_the_retained_window_ceiling() -> None: + """The largest supported request remains an explicit bounded Valkey read.""" + client = _ReadClient() + + assert await read_activity_events(client, "post-1", event_count=1000) == [] + assert client.calls == 1 From 433ec176cdb38e29612c609c93888f0478b6c1b9 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 2 Sep 2026 19:49:32 +0900 Subject: [PATCH 29/66] fix(activity): enforce retained-window read budget --- backend/app/activity_stream.py | 29 +++++++++++++++++++++++++---- 1 file changed, 25 insertions(+), 4 deletions(-) diff --git a/backend/app/activity_stream.py b/backend/app/activity_stream.py index 1f01d7153..3db900ca1 100644 --- a/backend/app/activity_stream.py +++ b/backend/app/activity_stream.py @@ -18,6 +18,7 @@ from lineageweave.observability import traced _SYNC_ACTIVITY_WATCH_RETRY_LIMIT = 8 +_MAX_ACTIVITY_READ_COUNT = 1000 def create_valkey_client(valkey_url: str) -> redis.Redis: @@ -84,6 +85,23 @@ def _activity_text(value: Any, field_name: str) -> str: return value +def _activity_event_count(value: Any) -> int: + """Validate one bounded buyer-facing activity-stream read count. + + Redis accepts integer-like values at a lower protocol layer, but the product + read contract must not coerce booleans, floats, or strings into a request + budget. The upper bound matches the retained stream window so callers cannot + request work beyond the product's own retention contract. + """ + if type(value) is not int: + raise TypeError("event_count must be an integer") + if not 1 <= value <= _MAX_ACTIVITY_READ_COUNT: + raise ValueError( + f"event_count must be between 1 and {_MAX_ACTIVITY_READ_COUNT}" + ) + return value + + def _activity_fields( event_type: str, actor_account_id: str, @@ -250,17 +268,20 @@ async def read_activity_events( ) -> list[dict[str, Any]]: """Read the newest retained activity events for one post. - ``event_count`` bounds the buyer-facing read; this function does not broaden - the query to other posts or reconstruct missing facts. Returned dictionaries - preserve the established event id/type/actor/summary wire fields. + ``event_count`` is an exact 1..1000 buyer-facing read budget matching the + retained stream window. Invalid values fail before Valkey access; this + function does not broaden the query to other posts or reconstruct missing + facts. Returned dictionaries preserve the established + event-id/type/actor/summary wire fields. """ + bounded_event_count = _activity_event_count(event_count) with traced( "lineageweave.valkey.activity_xrevrange", {"db.system": "redis", "db.operation.name": "xrevrange", "lineageweave.stream.kind": "activity"}, ): stream_entries = await valkey_client.xrevrange( _stream_key(post_id), - count=event_count, + count=bounded_event_count, ) return [ { From a2aaf24eef1031c4b225e7fc3f5006a0a3d4fff1 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 2 Sep 2026 20:51:30 +0900 Subject: [PATCH 30/66] test(activity): expose UUID stream-key alias --- tests/test_activity_stream_identity_types.py | 46 +++++++++++++++++++- 1 file changed, 45 insertions(+), 1 deletion(-) diff --git a/tests/test_activity_stream_identity_types.py b/tests/test_activity_stream_identity_types.py index 41c0998fa..ef84763e3 100644 --- a/tests/test_activity_stream_identity_types.py +++ b/tests/test_activity_stream_identity_types.py @@ -1,4 +1,4 @@ -"""Fail-closed type boundaries for Valkey activity identity fields.""" +"""Fail-closed type and canonical-key boundaries for Valkey activity identity fields.""" from __future__ import annotations @@ -30,6 +30,20 @@ async def xadd(self, *args, **kwargs): raise AssertionError("malformed activity identity reached async Valkey") +class _RecordingAsyncValkey: + """Record stream keys so equivalent source-post UUID spellings cannot split identity.""" + + def __init__(self) -> None: + """Start without observed Valkey keys.""" + self.keys: list[str] = [] + + async def xadd(self, key: str, *args, **kwargs) -> str: + """Capture the selected stream key and return a stable fake entry id.""" + del args, kwargs + self.keys.append(key) + return f"1-{len(self.keys) - 1}" + + def test_sync_activity_rejects_numeric_actor_identity_before_valkey_access() -> None: """A numeric actor id must not alias the canonical string identity ``\"7\"``.""" with pytest.raises(TypeError, match="actor_account_id must be a string"): @@ -66,3 +80,33 @@ def test_async_activity_rejects_numeric_actor_identity_before_xadd() -> None: ticket_created_summary("Send Northridge Grid the revised quote"), ) ) + + +def test_activity_stream_key_collapses_equivalent_source_post_uuid_spellings() -> None: + """One PostgreSQL UUID identity must never fork into case-variant Valkey streams.""" + client = _RecordingAsyncValkey() + canonical_post_id = "550e8400-e29b-41d4-a716-446655440000" + uppercase_post_id = canonical_post_id.upper() + + async def publish_both_spellings() -> None: + await publish_activity_event( + client, # type: ignore[arg-type] + canonical_post_id, + "ticket_created", + "acct-1", + "Ticket created: canonical UUID", + ) + await publish_activity_event( + client, # type: ignore[arg-type] + uppercase_post_id, + "ticket_created", + "acct-1", + "Ticket created: equivalent UUID spelling", + ) + + asyncio.run(publish_both_spellings()) + + assert client.keys == [ + f"activity:{canonical_post_id}", + f"activity:{canonical_post_id}", + ] From 223d210e7599ecf8dbb347b001bdc15beec67aaa Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 2 Sep 2026 20:52:19 +0900 Subject: [PATCH 31/66] fix(activity): canonicalize source-post UUID stream keys --- backend/app/activity_stream.py | 24 ++++++++++++++++-------- 1 file changed, 16 insertions(+), 8 deletions(-) diff --git a/backend/app/activity_stream.py b/backend/app/activity_stream.py index 3db900ca1..15b83a5e1 100644 --- a/backend/app/activity_stream.py +++ b/backend/app/activity_stream.py @@ -10,6 +10,7 @@ from __future__ import annotations from typing import Any +from uuid import UUID import redis.asyncio as redis from fastapi import Request @@ -42,16 +43,23 @@ def get_valkey(request: Request) -> redis.Redis: def _stream_key(post_id: str) -> str: - """Map one canonical post id to its stable activity-stream wire key. - - The prefix is part of the persisted Valkey contract. Keep key construction - centralized so producers and readers cannot silently diverge on namespace. - Exact string admission prevents a malformed scalar such as integer ``7`` - from aliasing the distinct canonical string identity ``"7"``. + """Map one source-post identity to its stable activity-stream wire key. + + ``source_post.post_id`` is PostgreSQL ``uuid``. PostgreSQL accepts more than + one textual spelling for the same UUID, so using raw request text as the + Valkey suffix can fork one database identity into case- or format-variant + streams. Syntactically valid UUID strings therefore converge on Python's + canonical lowercase hyphenated representation before key construction. + Non-UUID fixture/legacy strings retain exact spelling, and non-string values + are still rejected rather than being stringified onto another identity. """ if type(post_id) is not str: raise TypeError("post_id must be a string") - return f"activity:{post_id}" + try: + canonical_post_id = str(UUID(post_id)) + except ValueError: + canonical_post_id = post_id + return f"activity:{canonical_post_id}" def ticket_created_summary(ticket_title: str) -> str: @@ -291,4 +299,4 @@ async def read_activity_events( "summary": activity_fields["summary"], } for entry_id, activity_fields in stream_entries - ] \ No newline at end of file + ] From d5a4ba541bd325de9083b3ad5e19a32e718c28da Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 2 Sep 2026 21:47:26 +0900 Subject: [PATCH 32/66] test(activity): preserve legacy UUID stream aliases --- tests/test_activity_stream_identity_types.py | 58 ++++++++++++++++++++ 1 file changed, 58 insertions(+) diff --git a/tests/test_activity_stream_identity_types.py b/tests/test_activity_stream_identity_types.py index ef84763e3..3eab9eb5e 100644 --- a/tests/test_activity_stream_identity_types.py +++ b/tests/test_activity_stream_identity_types.py @@ -9,6 +9,7 @@ from backend.app.activity_stream import ( publish_activity_event, publish_activity_event_sync, + read_activity_events, ticket_created_summary, ) @@ -44,6 +45,20 @@ async def xadd(self, key: str, *args, **kwargs) -> str: return f"1-{len(self.keys) - 1}" +class _LegacyAliasReadValkey: + """Expose canonical and pre-canonical UUID streams to the current reader.""" + + def __init__(self, entries_by_key: dict[str, list[tuple[str, dict[str, str]]]]) -> None: + """Keep immutable-looking fixtures and record each bounded read.""" + self.entries_by_key = entries_by_key + self.reads: list[tuple[str, int]] = [] + + async def xrevrange(self, key: str, *, count: int): + """Return the newest fake entries for one exact requested stream key.""" + self.reads.append((key, count)) + return list(self.entries_by_key.get(key, ()))[:count] + + def test_sync_activity_rejects_numeric_actor_identity_before_valkey_access() -> None: """A numeric actor id must not alias the canonical string identity ``\"7\"``.""" with pytest.raises(TypeError, match="actor_account_id must be a string"): @@ -110,3 +125,46 @@ async def publish_both_spellings() -> None: f"activity:{canonical_post_id}", f"activity:{canonical_post_id}", ] + + +def test_activity_read_preserves_precanonical_uuid_alias_events() -> None: + """A legacy alternate UUID stream remains visible while new writes converge.""" + canonical_post_id = "550e8400-e29b-41d4-a716-446655440000" + uppercase_post_id = canonical_post_id.upper() + canonical_key = f"activity:{canonical_post_id}" + legacy_key = f"activity:{uppercase_post_id}" + client = _LegacyAliasReadValkey( + { + canonical_key: [ + ( + "200-0", + { + "event_type": "ticket_status_changed", + "actor_account_id": "acct-1", + "summary": "Ticket status changed to Closed", + }, + ) + ], + legacy_key: [ + ( + "100-0", + { + "event_type": "ticket_created", + "actor_account_id": "acct-1", + "summary": "Ticket created: legacy uppercase route", + }, + ) + ], + } + ) + + events = asyncio.run( + read_activity_events( + client, # type: ignore[arg-type] + uppercase_post_id, + event_count=10, + ) + ) + + assert [event["event_id"] for event in events] == ["200-0", "100-0"] + assert client.reads == [(canonical_key, 10), (legacy_key, 10)] From a0eec577b45c5cc3cb62e1d3b5c038c9b561b5eb Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 2 Sep 2026 21:48:12 +0900 Subject: [PATCH 33/66] test(activity): cover canonical read of uppercase legacy stream --- tests/test_activity_stream_identity_types.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tests/test_activity_stream_identity_types.py b/tests/test_activity_stream_identity_types.py index 3eab9eb5e..aadcc3a85 100644 --- a/tests/test_activity_stream_identity_types.py +++ b/tests/test_activity_stream_identity_types.py @@ -127,8 +127,8 @@ async def publish_both_spellings() -> None: ] -def test_activity_read_preserves_precanonical_uuid_alias_events() -> None: - """A legacy alternate UUID stream remains visible while new writes converge.""" +def test_activity_read_preserves_precanonical_uppercase_uuid_alias_events() -> None: + """Canonical reads retain events written to the historical uppercase UUID key.""" canonical_post_id = "550e8400-e29b-41d4-a716-446655440000" uppercase_post_id = canonical_post_id.upper() canonical_key = f"activity:{canonical_post_id}" @@ -161,7 +161,7 @@ def test_activity_read_preserves_precanonical_uuid_alias_events() -> None: events = asyncio.run( read_activity_events( client, # type: ignore[arg-type] - uppercase_post_id, + canonical_post_id, event_count=10, ) ) From f4044cf4da7348557373f3e647fc336e36715ba2 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 2 Sep 2026 21:49:29 +0900 Subject: [PATCH 34/66] fix(activity): retain historical uppercase UUID stream reads --- backend/app/activity_stream.py | 62 +++++++++++++++++++++++++++++++--- 1 file changed, 57 insertions(+), 5 deletions(-) diff --git a/backend/app/activity_stream.py b/backend/app/activity_stream.py index 15b83a5e1..a27632aa5 100644 --- a/backend/app/activity_stream.py +++ b/backend/app/activity_stream.py @@ -9,6 +9,7 @@ from __future__ import annotations +import asyncio from typing import Any from uuid import UUID @@ -62,6 +63,44 @@ def _stream_key(post_id: str) -> str: return f"activity:{canonical_post_id}" +def _activity_read_stream_keys(post_id: str) -> tuple[str, ...]: + """Return the canonical stream plus bounded pre-canonical compatibility aliases. + + New writes converge on the canonical UUID key. Before that invariant existed, + an uppercase UUID route spelling could create an independent raw stream. The + read model therefore probes the canonical key and the historical uppercase + spelling concurrently; if the caller supplies another non-canonical spelling, + that exact legacy key is included as well. The set is de-duplicated and capped + at three keys so compatibility cannot turn one post read into an unbounded key + scan. This is a read-only bridge: it does not create new alias streams. + """ + canonical_key = _stream_key(post_id) + if type(post_id) is not str: + raise TypeError("post_id must be a string") + try: + canonical_post_id = str(UUID(post_id)) + except ValueError: + return (canonical_key,) + + candidate_keys = ( + canonical_key, + f"activity:{canonical_post_id.upper()}", + f"activity:{post_id}", + ) + return tuple(dict.fromkeys(candidate_keys)) + + +def _activity_stream_entry_order(entry_id: str) -> tuple[int, int]: + """Parse a Valkey stream entry id into its chronological numeric order.""" + milliseconds, separator, sequence = entry_id.partition("-") + if separator != "-": + raise ValueError("Valkey activity event id is malformed") + try: + return int(milliseconds), int(sequence) + except ValueError as exc: + raise ValueError("Valkey activity event id is malformed") from exc + + def ticket_created_summary(ticket_title: str) -> str: """Build the stable human-readable summary for a ticket-created event. @@ -279,18 +318,31 @@ async def read_activity_events( ``event_count`` is an exact 1..1000 buyer-facing read budget matching the retained stream window. Invalid values fail before Valkey access; this function does not broaden the query to other posts or reconstruct missing - facts. Returned dictionaries preserve the established - event-id/type/actor/summary wire fields. + facts. UUID reads include a bounded compatibility bridge for historical + uppercase/exact-route alias streams while all current writers remain + canonical-only. Results from those streams are merged by the native Valkey + stream-entry chronology before the buyer-facing limit is applied. """ bounded_event_count = _activity_event_count(event_count) + stream_keys = _activity_read_stream_keys(post_id) with traced( "lineageweave.valkey.activity_xrevrange", {"db.system": "redis", "db.operation.name": "xrevrange", "lineageweave.stream.kind": "activity"}, ): - stream_entries = await valkey_client.xrevrange( - _stream_key(post_id), - count=bounded_event_count, + stream_results = await asyncio.gather( + *( + valkey_client.xrevrange( + stream_key, + count=bounded_event_count, + ) + for stream_key in stream_keys + ) ) + stream_entries = sorted( + (entry for stream_result in stream_results for entry in stream_result), + key=lambda entry: _activity_stream_entry_order(entry[0]), + reverse=True, + )[:bounded_event_count] return [ { "event_id": entry_id, From cf58ffb6b7bdc8de6af8673032e921f5dce307e8 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 2 Sep 2026 21:53:48 +0900 Subject: [PATCH 35/66] test(activity): reject cross-stream sequence chronology --- tests/test_activity_stream_identity_types.py | 42 ++++++++++++++++++++ 1 file changed, 42 insertions(+) diff --git a/tests/test_activity_stream_identity_types.py b/tests/test_activity_stream_identity_types.py index aadcc3a85..10d10c87a 100644 --- a/tests/test_activity_stream_identity_types.py +++ b/tests/test_activity_stream_identity_types.py @@ -168,3 +168,45 @@ def test_activity_read_preserves_precanonical_uppercase_uuid_alias_events() -> N assert [event["event_id"] for event in events] == ["200-0", "100-0"] assert client.reads == [(canonical_key, 10), (legacy_key, 10)] + + +def test_activity_read_does_not_compare_cross_stream_sequence_numbers() -> None: + """Same-millisecond alias ties use deterministic stream precedence, not local sequence.""" + canonical_post_id = "550e8400-e29b-41d4-a716-446655440000" + uppercase_post_id = canonical_post_id.upper() + canonical_key = f"activity:{canonical_post_id}" + legacy_key = f"activity:{uppercase_post_id}" + client = _LegacyAliasReadValkey( + { + canonical_key: [ + ( + "500-0", + { + "event_type": "ticket_status_changed", + "actor_account_id": "acct-1", + "summary": "Canonical current stream", + }, + ) + ], + legacy_key: [ + ( + "500-99", + { + "event_type": "ticket_created", + "actor_account_id": "acct-1", + "summary": "Historical alias stream", + }, + ) + ], + } + ) + + events = asyncio.run( + read_activity_events( + client, # type: ignore[arg-type] + canonical_post_id, + event_count=1, + ) + ) + + assert [event["summary"] for event in events] == ["Canonical current stream"] From bdf76ac786be9b0e76f1baaca81cc09f3d48c5a9 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 2 Sep 2026 21:54:45 +0900 Subject: [PATCH 36/66] fix(activity): define cross-stream tie ordering --- backend/app/activity_stream.py | 40 +++++++++++++++++++++++++++++----- 1 file changed, 34 insertions(+), 6 deletions(-) diff --git a/backend/app/activity_stream.py b/backend/app/activity_stream.py index a27632aa5..8181c4deb 100644 --- a/backend/app/activity_stream.py +++ b/backend/app/activity_stream.py @@ -91,7 +91,7 @@ def _activity_read_stream_keys(post_id: str) -> tuple[str, ...]: def _activity_stream_entry_order(entry_id: str) -> tuple[int, int]: - """Parse a Valkey stream entry id into its chronological numeric order.""" + """Parse one Valkey stream entry id into its stream-local numeric order.""" milliseconds, separator, sequence = entry_id.partition("-") if separator != "-": raise ValueError("Valkey activity event id is malformed") @@ -101,6 +101,24 @@ def _activity_stream_entry_order(entry_id: str) -> tuple[int, int]: raise ValueError("Valkey activity event id is malformed") from exc +def _activity_compatibility_merge_order( + entry_id: str, + stream_index: int, +) -> tuple[int, int, int]: + """Order compatibility reads without inventing cross-stream sequence chronology. + + Redis stream sequence numbers are ordered only inside one stream. Historical + alias streams can therefore contain the same millisecond with unrelated + sequence counters. The millisecond remains comparable; an equal-millisecond + tie uses the declared stream precedence (canonical first, then bounded legacy + aliases), and the sequence number only orders entries that came from that + same stream. This fallback is deterministic but deliberately does not claim + to reconstruct unknowable sub-millisecond chronology across old streams. + """ + milliseconds, sequence = _activity_stream_entry_order(entry_id) + return milliseconds, -stream_index, sequence + + def ticket_created_summary(ticket_title: str) -> str: """Build the stable human-readable summary for a ticket-created event. @@ -320,8 +338,11 @@ async def read_activity_events( function does not broaden the query to other posts or reconstruct missing facts. UUID reads include a bounded compatibility bridge for historical uppercase/exact-route alias streams while all current writers remain - canonical-only. Results from those streams are merged by the native Valkey - stream-entry chronology before the buyer-facing limit is applied. + canonical-only. Cross-stream chronology is comparable at millisecond + precision only; equal-millisecond historical ties use deterministic + canonical-first stream precedence rather than pretending stream-local + sequence counters form a global clock. The final buyer limit is applied + only after this bounded merge. """ bounded_event_count = _activity_event_count(event_count) stream_keys = _activity_read_stream_keys(post_id) @@ -339,8 +360,15 @@ async def read_activity_events( ) ) stream_entries = sorted( - (entry for stream_result in stream_results for entry in stream_result), - key=lambda entry: _activity_stream_entry_order(entry[0]), + ( + (entry, stream_index) + for stream_index, stream_result in enumerate(stream_results) + for entry in stream_result + ), + key=lambda item: _activity_compatibility_merge_order( + item[0][0], + item[1], + ), reverse=True, )[:bounded_event_count] return [ @@ -350,5 +378,5 @@ async def read_activity_events( "actor_account_id": activity_fields["actor_account_id"], "summary": activity_fields["summary"], } - for entry_id, activity_fields in stream_entries + for (entry_id, activity_fields), _stream_index in stream_entries ] From 874bb6f506153be6f11222a09ed87bf47ba10a71 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 2 Sep 2026 22:15:13 +0900 Subject: [PATCH 37/66] test(activity): expose cross-stream event identity collision --- tests/test_activity_stream_identity_types.py | 46 ++++++++++++++++++++ 1 file changed, 46 insertions(+) diff --git a/tests/test_activity_stream_identity_types.py b/tests/test_activity_stream_identity_types.py index 10d10c87a..72e85f5d8 100644 --- a/tests/test_activity_stream_identity_types.py +++ b/tests/test_activity_stream_identity_types.py @@ -210,3 +210,49 @@ def test_activity_read_does_not_compare_cross_stream_sequence_numbers() -> None: ) assert [event["summary"] for event in events] == ["Canonical current stream"] + + +def test_activity_read_namespaces_colliding_legacy_stream_entry_ids() -> None: + """Independent Valkey streams must not emit duplicate buyer event identities.""" + canonical_post_id = "550e8400-e29b-41d4-a716-446655440000" + uppercase_post_id = canonical_post_id.upper() + canonical_key = f"activity:{canonical_post_id}" + legacy_key = f"activity:{uppercase_post_id}" + client = _LegacyAliasReadValkey( + { + canonical_key: [ + ( + "600-0", + { + "event_type": "ticket_status_changed", + "actor_account_id": "acct-1", + "summary": "Canonical current stream", + }, + ) + ], + legacy_key: [ + ( + "600-0", + { + "event_type": "ticket_created", + "actor_account_id": "acct-1", + "summary": "Historical alias stream", + }, + ) + ], + } + ) + + events = asyncio.run( + read_activity_events( + client, # type: ignore[arg-type] + canonical_post_id, + event_count=2, + ) + ) + + assert [event["event_id"] for event in events] == [ + "600-0", + "legacy-1:600-0", + ] + assert len({event["event_id"] for event in events}) == 2 From a3d0a015e789468cb415ff696ba6bd3631caae00 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 2 Sep 2026 22:16:12 +0900 Subject: [PATCH 38/66] fix(activity): namespace legacy read event identities --- backend/app/activity_stream.py | 23 ++++++++++++++++++++--- 1 file changed, 20 insertions(+), 3 deletions(-) diff --git a/backend/app/activity_stream.py b/backend/app/activity_stream.py index 8181c4deb..cbcd7b9f3 100644 --- a/backend/app/activity_stream.py +++ b/backend/app/activity_stream.py @@ -119,6 +119,21 @@ def _activity_compatibility_merge_order( return milliseconds, -stream_index, sequence +def _activity_public_event_id(entry_id: str, stream_index: int) -> str: + """Return an opaque event identity unique across bounded compatibility streams. + + Valkey stream IDs are unique only inside one stream, so a canonical stream and + a historical alias can legitimately both contain ``600-0``. Canonical events + keep their established public ID. Alias events receive a bounded stream-index + namespace that contains no post identifier or raw stream key, preventing + duplicate React/API identities without inventing event chronology or mutating + persisted records. + """ + if stream_index == 0: + return entry_id + return f"legacy-{stream_index}:{entry_id}" + + def ticket_created_summary(ticket_title: str) -> str: """Build the stable human-readable summary for a ticket-created event. @@ -342,7 +357,9 @@ async def read_activity_events( precision only; equal-millisecond historical ties use deterministic canonical-first stream precedence rather than pretending stream-local sequence counters form a global clock. The final buyer limit is applied - only after this bounded merge. + only after this bounded merge. Canonical entries retain their historical + public ``event_id``; alias entries are namespaced by bounded stream ordinal + because Valkey stream IDs are not globally unique across independent keys. """ bounded_event_count = _activity_event_count(event_count) stream_keys = _activity_read_stream_keys(post_id) @@ -373,10 +390,10 @@ async def read_activity_events( )[:bounded_event_count] return [ { - "event_id": entry_id, + "event_id": _activity_public_event_id(entry_id, stream_index), "event_type": activity_fields["event_type"], "actor_account_id": activity_fields["actor_account_id"], "summary": activity_fields["summary"], } - for (entry_id, activity_fields), _stream_index in stream_entries + for (entry_id, activity_fields), stream_index in stream_entries ] From f9645d01eda570e32b0f229c0f8af1a23faee06e Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 2 Sep 2026 23:46:57 +0900 Subject: [PATCH 39/66] test(activity): align legacy alias event identity --- tests/test_activity_stream_identity_types.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_activity_stream_identity_types.py b/tests/test_activity_stream_identity_types.py index 72e85f5d8..9689c5f33 100644 --- a/tests/test_activity_stream_identity_types.py +++ b/tests/test_activity_stream_identity_types.py @@ -166,7 +166,7 @@ def test_activity_read_preserves_precanonical_uppercase_uuid_alias_events() -> N ) ) - assert [event["event_id"] for event in events] == ["200-0", "100-0"] + assert [event["event_id"] for event in events] == ["200-0", "legacy-1:100-0"] assert client.reads == [(canonical_key, 10), (legacy_key, 10)] From 14f0e50be9b581f9b21f2ed9437db00c07c81bb6 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 3 Sep 2026 11:53:58 +0900 Subject: [PATCH 40/66] test(activity): use repository-native async execution --- tests/test_activity_stream_read_budget.py | 16 +++++++--------- 1 file changed, 7 insertions(+), 9 deletions(-) diff --git a/tests/test_activity_stream_read_budget.py b/tests/test_activity_stream_read_budget.py index 29f0eb4bb..c0c5feac7 100644 --- a/tests/test_activity_stream_read_budget.py +++ b/tests/test_activity_stream_read_budget.py @@ -2,6 +2,7 @@ from __future__ import annotations +import asyncio from typing import Any import pytest @@ -26,36 +27,33 @@ async def xrevrange( return [] -@pytest.mark.asyncio @pytest.mark.parametrize("event_count", (True, 1.0, "50")) -async def test_activity_read_count_requires_an_exact_integer(event_count: Any) -> None: +def test_activity_read_count_requires_an_exact_integer(event_count: Any) -> None: """Scalar coercion must not create an implicit buyer-facing read budget.""" client = _ReadClient() with pytest.raises(TypeError, match="event_count must be an integer"): - await read_activity_events(client, "post-1", event_count=event_count) + asyncio.run(read_activity_events(client, "post-1", event_count=event_count)) assert client.calls == 0 -@pytest.mark.asyncio @pytest.mark.parametrize("event_count", (0, -1, 1001)) -async def test_activity_read_count_is_bounded_to_the_retained_window( +def test_activity_read_count_is_bounded_to_the_retained_window( event_count: int, ) -> None: """Invalid counts fail before an unbounded or nonsensical Valkey read.""" client = _ReadClient() with pytest.raises(ValueError, match="event_count must be between 1 and 1000"): - await read_activity_events(client, "post-1", event_count=event_count) + asyncio.run(read_activity_events(client, "post-1", event_count=event_count)) assert client.calls == 0 -@pytest.mark.asyncio -async def test_activity_read_count_accepts_the_retained_window_ceiling() -> None: +def test_activity_read_count_accepts_the_retained_window_ceiling() -> None: """The largest supported request remains an explicit bounded Valkey read.""" client = _ReadClient() - assert await read_activity_events(client, "post-1", event_count=1000) == [] + assert asyncio.run(read_activity_events(client, "post-1", event_count=1000)) == [] assert client.calls == 1 From c3ffdc05901580cc0a3c5373406c8b9894cd7977 Mon Sep 17 00:00:00 2001 From: Codex Date: Fri, 4 Sep 2026 23:18:00 +0900 Subject: [PATCH 41/66] fix(activity): preserve all retained UUID stream aliases Record the canonical identity contract in ADR 0363 and build a durable startup alias index from the finite retained Valkey streams. Signed-off-by: Codex --- backend/app/activity_stream.py | 67 ++++++++++++++----- backend/app/main.py | 2 + ...0363-canonical-activity-stream-identity.md | 52 ++++++++++++++ docs/adr/README.md | 1 + tests/test_activity_stream_identity_types.py | 55 +++++++++++++++ 5 files changed, 162 insertions(+), 15 deletions(-) create mode 100644 docs/adr/0363-canonical-activity-stream-identity.md diff --git a/backend/app/activity_stream.py b/backend/app/activity_stream.py index cbcd7b9f3..d584f106c 100644 --- a/backend/app/activity_stream.py +++ b/backend/app/activity_stream.py @@ -21,6 +21,8 @@ _SYNC_ACTIVITY_WATCH_RETRY_LIMIT = 8 _MAX_ACTIVITY_READ_COUNT = 1000 +_ACTIVITY_STREAM_PREFIX = "activity:" +_ACTIVITY_ALIAS_INDEX_PREFIX = "activity-aliases:" def create_valkey_client(valkey_url: str) -> redis.Redis: @@ -60,19 +62,57 @@ def _stream_key(post_id: str) -> str: canonical_post_id = str(UUID(post_id)) except ValueError: canonical_post_id = post_id - return f"activity:{canonical_post_id}" + return f"{_ACTIVITY_STREAM_PREFIX}{canonical_post_id}" -def _activity_read_stream_keys(post_id: str) -> tuple[str, ...]: +def _activity_alias_index_key(post_id: str) -> str: + """Return the durable legacy-alias index for one canonical post UUID.""" + return f"{_ACTIVITY_ALIAS_INDEX_PREFIX}{str(UUID(post_id))}" + + +async def index_legacy_activity_stream_aliases(valkey_client: redis.Redis) -> int: + """Index every existing UUID stream alias before canonical-only reads begin. + + PostgreSQL accepts UUID input spellings with braces, upper-case digits, and + non-standard hyphen placement. Older routes used that raw spelling in the + Valkey key, so enumerating a few common variants cannot preserve all valid + history. Startup performs one cursor scan and records the finite aliases + that actually exist. New writes remain canonical-only. + """ + indexed = 0 + async for raw_key in valkey_client.scan_iter(match=f"{_ACTIVITY_STREAM_PREFIX}*"): + stream_key = raw_key.decode() if isinstance(raw_key, bytes) else raw_key + if not isinstance(stream_key, str) or stream_key.startswith(_ACTIVITY_ALIAS_INDEX_PREFIX): + continue + raw_post_id = stream_key.removeprefix(_ACTIVITY_STREAM_PREFIX) + try: + canonical_post_id = str(UUID(raw_post_id)) + except ValueError: + continue + canonical_key = f"{_ACTIVITY_STREAM_PREFIX}{canonical_post_id}" + if stream_key == canonical_key: + continue + indexed += int( + await valkey_client.sadd( + f"{_ACTIVITY_ALIAS_INDEX_PREFIX}{canonical_post_id}", + stream_key, + ) + ) + return indexed + + +async def _activity_read_stream_keys( + valkey_client: redis.Redis, + post_id: str, +) -> tuple[str, ...]: """Return the canonical stream plus bounded pre-canonical compatibility aliases. New writes converge on the canonical UUID key. Before that invariant existed, an uppercase UUID route spelling could create an independent raw stream. The - read model therefore probes the canonical key and the historical uppercase - spelling concurrently; if the caller supplies another non-canonical spelling, - that exact legacy key is included as well. The set is de-duplicated and capped - at three keys so compatibility cannot turn one post read into an unbounded key - scan. This is a read-only bridge: it does not create new alias streams. + startup alias index therefore supplies the finite UUID-equivalent stream keys + that actually exist. The request never scans unrelated keys or guesses a + subset of PostgreSQL's accepted UUID spellings. This is a read-only bridge: + it does not create new alias streams. """ canonical_key = _stream_key(post_id) if type(post_id) is not str: @@ -82,11 +122,8 @@ def _activity_read_stream_keys(post_id: str) -> tuple[str, ...]: except ValueError: return (canonical_key,) - candidate_keys = ( - canonical_key, - f"activity:{canonical_post_id.upper()}", - f"activity:{post_id}", - ) + indexed_aliases = await valkey_client.smembers(_activity_alias_index_key(post_id)) + candidate_keys = (canonical_key, *sorted(indexed_aliases)) return tuple(dict.fromkeys(candidate_keys)) @@ -352,8 +389,8 @@ async def read_activity_events( retained stream window. Invalid values fail before Valkey access; this function does not broaden the query to other posts or reconstruct missing facts. UUID reads include a bounded compatibility bridge for historical - uppercase/exact-route alias streams while all current writers remain - canonical-only. Cross-stream chronology is comparable at millisecond + UUID-equivalent alias streams recorded by startup while all current writers + remain canonical-only. Cross-stream chronology is comparable at millisecond precision only; equal-millisecond historical ties use deterministic canonical-first stream precedence rather than pretending stream-local sequence counters form a global clock. The final buyer limit is applied @@ -362,7 +399,7 @@ async def read_activity_events( because Valkey stream IDs are not globally unique across independent keys. """ bounded_event_count = _activity_event_count(event_count) - stream_keys = _activity_read_stream_keys(post_id) + stream_keys = await _activity_read_stream_keys(valkey_client, post_id) with traced( "lineageweave.valkey.activity_xrevrange", {"db.system": "redis", "db.operation.name": "xrevrange", "lineageweave.stream.kind": "activity"}, diff --git a/backend/app/main.py b/backend/app/main.py index 122165990..af8db62ff 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -42,6 +42,7 @@ from backend.app.activity_stream import ( create_valkey_client, get_valkey, + index_legacy_activity_stream_aliases, publish_activity_event, read_activity_events, ticket_created_summary, @@ -297,6 +298,7 @@ async def lifespan(app: FastAPI): pool = await create_pool(settings.database_url) app.state.pool = pool valkey = create_valkey_client(settings.valkey_url) + await index_legacy_activity_stream_aliases(valkey) app.state.valkey = valkey analysis_worker = asyncio.create_task( run_analysis_run_worker( diff --git a/docs/adr/0363-canonical-activity-stream-identity.md b/docs/adr/0363-canonical-activity-stream-identity.md new file mode 100644 index 000000000..ab2202ab8 --- /dev/null +++ b/docs/adr/0363-canonical-activity-stream-identity.md @@ -0,0 +1,52 @@ +# ADR 0363: Canonical activity-stream identity + +- Status: Proposed +- Date: 2026-09-04 + +## Context + +`source_post.post_id` is a PostgreSQL `uuid`, while historical HTTP requests +could spell the same UUID with upper-case digits, braces, omitted hyphens, or +other PostgreSQL-accepted hyphen placement. Earlier activity writes embedded +the request spelling in a Valkey stream key. Canonicalizing only new writes can +therefore strand authorized history, and enumerating selected spellings cannot +cover PostgreSQL's input grammar. + +The activity feed is a retained operational projection. It is not a source of +record truth, and its compatibility work must not broaden a request into a +keyspace scan. + +## Decision + +New activity writes use PostgreSQL's canonical lower-case, hyphenated UUID +form. Before the application begins serving reads, startup scans the existing +activity-key namespace once and records every actually present, UUID-equivalent +legacy key in a durable canonical-post alias set. A request reads the canonical +stream and only the aliases in that set. Non-UUID synthetic and legacy keys +retain exact spelling. + +Cross-stream ordering uses the persisted millisecond component and declared +canonical-first precedence for equal milliseconds. Stream-local sequence +numbers never become a global chronology. Alias event identifiers remain +namespaced so two streams cannot emit the same public identity. + +WATCH retries, retained-window read limits, and alias-index startup are fixed +product contracts. Failure to build the alias index makes the application +unready; the product does not silently hide historical activity. + +## Consequences + +- Canonical reads retain every historical UUID spelling that actually exists. +- Request-time work is limited to the canonical stream and its durable aliases. +- Startup performs a cursor scan and must finish before readiness. +- The alias index is additional derived Valkey state and can be rebuilt from + retained activity keys. + +## Alternatives considered + +- Enumerate common UUID spellings: rejected because PostgreSQL accepts more + forms than a finite hand-picked list would honestly cover. +- Scan the activity keyspace on each request: rejected because latency and work + would scale with unrelated posts. +- Drop legacy aliases: rejected because canonicalization would hide retained + authorized history. diff --git a/docs/adr/README.md b/docs/adr/README.md index 8979111c1..6f1203b7c 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -27,6 +27,7 @@ decision from them. | [`operability/http-concurrency-evidence.md`](../operability/http-concurrency-evidence.md) | [0204](0204-analysis-run-short-transaction-delivery.md), [0212](0212-single-query-authorized-post-filter-options.md), [0213](0213-global-ask-embedding-pool-release.md) | | [`operability/mcp-concurrency-evidence.md`](../operability/mcp-concurrency-evidence.md) | [0218](0218-current-contract-mcp-global-ask.md) | | Evidence operations Dashboard (`/`) | [0206](0206-evidence-operations-dashboard.md) | +| Activity stream (`backend/app/activity_stream.py`) | [0363](0363-canonical-activity-stream-identity.md) | | [`temporal-topic-context-influence-research.md`](../temporal-topic-context-influence-research.md) | [0210](0210-temporal-topic-context-influence-dashboard.md) | | [`python-mathematical-compute-boundary-audit.md`](../doctoring/python-mathematical-compute-boundary-audit.md) | [0208](0208-externalize-local-mathematical-compute.md) | | [`WORKER_FUNCTION_TAXONOMY_REFERENCES.md`](../doctoring/WORKER_FUNCTION_TAXONOMY_REFERENCES.md) | [0232](0232-worker-function-taxonomy-in-the-published-ontology.md) | diff --git a/tests/test_activity_stream_identity_types.py b/tests/test_activity_stream_identity_types.py index 9689c5f33..7842aba30 100644 --- a/tests/test_activity_stream_identity_types.py +++ b/tests/test_activity_stream_identity_types.py @@ -7,6 +7,7 @@ import pytest from backend.app.activity_stream import ( + index_legacy_activity_stream_aliases, publish_activity_event, publish_activity_event_sync, read_activity_events, @@ -58,6 +59,44 @@ async def xrevrange(self, key: str, *, count: int): self.reads.append((key, count)) return list(self.entries_by_key.get(key, ()))[:count] + async def smembers(self, key: str) -> set[str]: + """Return the durable aliases indexed for the canonical UUID.""" + canonical_post_id = "550e8400-e29b-41d4-a716-446655440000" + assert key == f"activity-aliases:{canonical_post_id}" + return { + stream_key + for stream_key in self.entries_by_key + if stream_key != f"activity:{canonical_post_id}" + } + + +class _LegacyAliasIndexValkey: + """Model cursor discovery and durable alias-set writes at startup.""" + + def __init__(self) -> None: + """Expose canonical, compact, braced, and non-UUID retained streams.""" + self.keys = ( + "activity:550e8400-e29b-41d4-a716-446655440000", + "activity:550e8400e29b41d4a716446655440000", + "activity:{550E8400-E29B-41D4-A716-446655440000}", + "activity:synthetic-post", + "activity-aliases:550e8400-e29b-41d4-a716-446655440000", + ) + self.members: dict[str, set[str]] = {} + + async def scan_iter(self, *, match: str): + """Yield the finite retained keyspace selected by the namespace pattern.""" + assert match == "activity:*" + for key in self.keys: + yield key + + async def sadd(self, key: str, member: str) -> int: + """Record one discovered alias with Redis-compatible added count.""" + members = self.members.setdefault(key, set()) + before = len(members) + members.add(member) + return int(len(members) != before) + def test_sync_activity_rejects_numeric_actor_identity_before_valkey_access() -> None: """A numeric actor id must not alias the canonical string identity ``\"7\"``.""" @@ -127,6 +166,22 @@ async def publish_both_spellings() -> None: ] +def test_startup_indexes_every_existing_uuid_alias_without_guessing_forms() -> None: + """Compact and braced historical keys become durable canonical-read aliases.""" + client = _LegacyAliasIndexValkey() + + indexed = asyncio.run(index_legacy_activity_stream_aliases(client)) # type: ignore[arg-type] + + canonical_post_id = "550e8400-e29b-41d4-a716-446655440000" + assert indexed == 2 + assert client.members == { + f"activity-aliases:{canonical_post_id}": { + "activity:550e8400e29b41d4a716446655440000", + "activity:{550E8400-E29B-41D4-A716-446655440000}", + } + } + + def test_activity_read_preserves_precanonical_uppercase_uuid_alias_events() -> None: """Canonical reads retain events written to the historical uppercase UUID key.""" canonical_post_id = "550e8400-e29b-41d4-a716-446655440000" From a1d841215e9de35d8c9835e1cfd1ee368ce74a24 Mon Sep 17 00:00:00 2001 From: Codex Date: Fri, 4 Sep 2026 23:26:56 +0900 Subject: [PATCH 42/66] fix(activity): bound compatibility stream merge Accept the governing ADR, require single-service writer cutover, and incrementally merge retained alias streams within the established event budget. Signed-off-by: Codex --- backend/app/activity_stream.py | 55 +++++++++++-------- ...0363-canonical-activity-stream-identity.md | 16 +++++- tests/test_activity_stream_identity_types.py | 22 ++++++-- 3 files changed, 63 insertions(+), 30 deletions(-) diff --git a/backend/app/activity_stream.py b/backend/app/activity_stream.py index d584f106c..050d38452 100644 --- a/backend/app/activity_stream.py +++ b/backend/app/activity_stream.py @@ -122,7 +122,11 @@ async def _activity_read_stream_keys( except ValueError: return (canonical_key,) - indexed_aliases = await valkey_client.smembers(_activity_alias_index_key(post_id)) + indexed_aliases: list[str] = [] + async for alias in valkey_client.sscan_iter(_activity_alias_index_key(post_id)): + indexed_aliases.append(alias) + if len(indexed_aliases) > _MAX_ACTIVITY_READ_COUNT - 1: + raise RuntimeError("Activity history has too many retained compatibility streams") candidate_keys = (canonical_key, *sorted(indexed_aliases)) return tuple(dict.fromkeys(candidate_keys)) @@ -400,31 +404,38 @@ async def read_activity_events( """ bounded_event_count = _activity_event_count(event_count) stream_keys = await _activity_read_stream_keys(valkey_client, post_id) + next_entries: list[tuple[tuple[str, dict[str, str]], int]] = [] + stream_results: list[tuple[str, dict[str, str]] | None] = [None] * len(stream_keys) with traced( "lineageweave.valkey.activity_xrevrange", {"db.system": "redis", "db.operation.name": "xrevrange", "lineageweave.stream.kind": "activity"}, ): - stream_results = await asyncio.gather( - *( - valkey_client.xrevrange( - stream_key, - count=bounded_event_count, - ) - for stream_key in stream_keys - ) + first_pages = await asyncio.gather( + *(valkey_client.xrevrange(stream_key, count=1) for stream_key in stream_keys) ) - stream_entries = sorted( - ( - (entry, stream_index) - for stream_index, stream_result in enumerate(stream_results) - for entry in stream_result - ), - key=lambda item: _activity_compatibility_merge_order( - item[0][0], - item[1], - ), - reverse=True, - )[:bounded_event_count] + for stream_index, page in enumerate(first_pages): + if page: + stream_results[stream_index] = page[0] + + while len(next_entries) < bounded_event_count: + available = [ + (entry, stream_index) + for stream_index, entry in enumerate(stream_results) + if entry is not None + ] + if not available: + break + newest_entry, newest_stream_index = max( + available, + key=lambda item: _activity_compatibility_merge_order(item[0][0], item[1]), + ) + next_entries.append((newest_entry, newest_stream_index)) + next_page = await valkey_client.xrevrange( + stream_keys[newest_stream_index], + max=f"({newest_entry[0]}", + count=1, + ) + stream_results[newest_stream_index] = next_page[0] if next_page else None return [ { "event_id": _activity_public_event_id(entry_id, stream_index), @@ -432,5 +443,5 @@ async def read_activity_events( "actor_account_id": activity_fields["actor_account_id"], "summary": activity_fields["summary"], } - for (entry_id, activity_fields), stream_index in stream_entries + for (entry_id, activity_fields), stream_index in next_entries ] diff --git a/docs/adr/0363-canonical-activity-stream-identity.md b/docs/adr/0363-canonical-activity-stream-identity.md index ab2202ab8..b77a2e7c1 100644 --- a/docs/adr/0363-canonical-activity-stream-identity.md +++ b/docs/adr/0363-canonical-activity-stream-identity.md @@ -1,6 +1,6 @@ # ADR 0363: Canonical activity-stream identity -- Status: Proposed +- Status: Accepted - Date: 2026-09-04 ## Context @@ -34,10 +34,22 @@ WATCH retries, retained-window read limits, and alias-index startup are fixed product contracts. Failure to build the alias index makes the application unready; the product does not silently hide historical activity. +The supported deployment is the repository's single `lineageweave` Compose +backend service. Deployment stops the preceding backend before starting the +replacement, so no pre-canonical writer may overlap the alias scan. A rolling +multi-version replica deployment is unavailable until it has a separate +writer-fencing contract. + +Reads enumerate at most the retained-window number of streams and then perform +a newest-first incremental merge. They fetch one entry per stream initially +and at most one further entry per returned event. More retained aliases fail +closed instead of creating unbounded fan-out or silently sampling history. + ## Consequences - Canonical reads retain every historical UUID spelling that actually exists. -- Request-time work is limited to the canonical stream and its durable aliases. +- Request-time records and calls are bounded by the retained stream and output + limits; excessive alias cardinality is explicitly unavailable. - Startup performs a cursor scan and must finish before readiness. - The alias index is additional derived Valkey state and can be rebuilt from retained activity keys. diff --git a/tests/test_activity_stream_identity_types.py b/tests/test_activity_stream_identity_types.py index 7842aba30..21ceb9b73 100644 --- a/tests/test_activity_stream_identity_types.py +++ b/tests/test_activity_stream_identity_types.py @@ -54,20 +54,25 @@ def __init__(self, entries_by_key: dict[str, list[tuple[str, dict[str, str]]]]) self.entries_by_key = entries_by_key self.reads: list[tuple[str, int]] = [] - async def xrevrange(self, key: str, *, count: int): + async def xrevrange(self, key: str, *, count: int, max: str = "+"): """Return the newest fake entries for one exact requested stream key.""" self.reads.append((key, count)) - return list(self.entries_by_key.get(key, ()))[:count] + entries = list(self.entries_by_key.get(key, ())) + if max.startswith("("): + boundary = max[1:] + entries = [entry for entry in entries if entry[0] != boundary] + return entries[:count] - async def smembers(self, key: str) -> set[str]: + async def sscan_iter(self, key: str): """Return the durable aliases indexed for the canonical UUID.""" canonical_post_id = "550e8400-e29b-41d4-a716-446655440000" assert key == f"activity-aliases:{canonical_post_id}" - return { + for stream_key in { stream_key for stream_key in self.entries_by_key if stream_key != f"activity:{canonical_post_id}" - } + }: + yield stream_key class _LegacyAliasIndexValkey: @@ -222,7 +227,12 @@ def test_activity_read_preserves_precanonical_uppercase_uuid_alias_events() -> N ) assert [event["event_id"] for event in events] == ["200-0", "legacy-1:100-0"] - assert client.reads == [(canonical_key, 10), (legacy_key, 10)] + assert client.reads == [ + (canonical_key, 1), + (legacy_key, 1), + (canonical_key, 1), + (legacy_key, 1), + ] def test_activity_read_does_not_compare_cross_stream_sequence_numbers() -> None: From 345aaa6e7930f546dd04b55750b69b3e4720552e Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 5 Sep 2026 00:03:52 +0900 Subject: [PATCH 43/66] docs(adr): keep activity identity decision proposed --- docs/adr/0363-canonical-activity-stream-identity.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/adr/0363-canonical-activity-stream-identity.md b/docs/adr/0363-canonical-activity-stream-identity.md index b77a2e7c1..2ad3327c2 100644 --- a/docs/adr/0363-canonical-activity-stream-identity.md +++ b/docs/adr/0363-canonical-activity-stream-identity.md @@ -1,6 +1,6 @@ # ADR 0363: Canonical activity-stream identity -- Status: Accepted +- Status: Proposed - Date: 2026-09-04 ## Context From 64340c0a86f876f32d0a37ef17321f3d9b993818 Mon Sep 17 00:00:00 2001 From: Codex Date: Sat, 5 Sep 2026 16:32:27 +0900 Subject: [PATCH 44/66] fix(activity): align read double with Redis stream contract --- backend/app/activity_stream.py | 2 +- tests/test_activity_stream_read_budget.py | 7 ++++--- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/backend/app/activity_stream.py b/backend/app/activity_stream.py index 050d38452..f2f6519f3 100644 --- a/backend/app/activity_stream.py +++ b/backend/app/activity_stream.py @@ -118,7 +118,7 @@ async def _activity_read_stream_keys( if type(post_id) is not str: raise TypeError("post_id must be a string") try: - canonical_post_id = str(UUID(post_id)) + UUID(post_id) except ValueError: return (canonical_key,) diff --git a/tests/test_activity_stream_read_budget.py b/tests/test_activity_stream_read_budget.py index c0c5feac7..a36535bb8 100644 --- a/tests/test_activity_stream_read_budget.py +++ b/tests/test_activity_stream_read_budget.py @@ -19,10 +19,11 @@ def __init__(self) -> None: async def xrevrange( self, key: str, - *, - count: int, + max: str = "+", + min: str = "-", + count: int | None = None, ) -> list[tuple[str, dict[str, str]]]: - del key, count + del key, max, min, count self.calls += 1 return [] From 75d846078e9e838c629bd1a7f7452cfa087524b7 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 5 Sep 2026 20:51:35 +0900 Subject: [PATCH 45/66] test(activity): bound canonical stream read round trips --- tests/test_activity_stream_read_budget.py | 39 +++++++++++++++++++++++ 1 file changed, 39 insertions(+) diff --git a/tests/test_activity_stream_read_budget.py b/tests/test_activity_stream_read_budget.py index a36535bb8..3735936ea 100644 --- a/tests/test_activity_stream_read_budget.py +++ b/tests/test_activity_stream_read_budget.py @@ -28,6 +28,35 @@ async def xrevrange( return [] +class _PopulatedReadClient: + """Expose a canonical stream while recording its bounded Valkey reads.""" + + def __init__(self) -> None: + self.calls: list[tuple[str, str, int | None]] = [] + self.entries = [ + ("300-0", {"event_type": "third", "actor_account_id": "actor", "summary": "third"}), + ("200-0", {"event_type": "second", "actor_account_id": "actor", "summary": "second"}), + ("100-0", {"event_type": "first", "actor_account_id": "actor", "summary": "first"}), + ] + + async def xrevrange( + self, + key: str, + max: str = "+", + min: str = "-", + count: int | None = None, + ) -> list[tuple[str, dict[str, str]]]: + del min + self.calls.append((key, max, count)) + if max == "+": + eligible = self.entries + else: + upper = max.removeprefix("(") + upper_ms = int(upper.partition("-")[0]) + eligible = [entry for entry in self.entries if int(entry[0].partition("-")[0]) < upper_ms] + return eligible[:count] + + @pytest.mark.parametrize("event_count", (True, 1.0, "50")) def test_activity_read_count_requires_an_exact_integer(event_count: Any) -> None: """Scalar coercion must not create an implicit buyer-facing read budget.""" @@ -58,3 +87,13 @@ def test_activity_read_count_accepts_the_retained_window_ceiling() -> None: assert asyncio.run(read_activity_events(client, "post-1", event_count=1000)) == [] assert client.calls == 1 + + +def test_canonical_activity_read_uses_one_bounded_valkey_round_trip() -> None: + """The normal canonical stream must not pay one network round trip per event.""" + client = _PopulatedReadClient() + + events = asyncio.run(read_activity_events(client, "post-1", event_count=3)) + + assert [event["event_id"] for event in events] == ["300-0", "200-0", "100-0"] + assert client.calls == [("activity:post-1", "+", 3)] From 3b122bf6e8c3a3a404db19dd6b756831972aac69 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 5 Sep 2026 20:52:16 +0900 Subject: [PATCH 46/66] test(activity): exercise canonical UUID read path --- tests/test_activity_stream_read_budget.py | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/tests/test_activity_stream_read_budget.py b/tests/test_activity_stream_read_budget.py index 3735936ea..7d7d39ee3 100644 --- a/tests/test_activity_stream_read_budget.py +++ b/tests/test_activity_stream_read_budget.py @@ -29,7 +29,7 @@ async def xrevrange( class _PopulatedReadClient: - """Expose a canonical stream while recording its bounded Valkey reads.""" + """Expose one canonical UUID stream and no retained compatibility aliases.""" def __init__(self) -> None: self.calls: list[tuple[str, str, int | None]] = [] @@ -39,6 +39,11 @@ def __init__(self) -> None: ("100-0", {"event_type": "first", "actor_account_id": "actor", "summary": "first"}), ] + async def sscan_iter(self, key: str): + del key + if False: + yield "" + async def xrevrange( self, key: str, @@ -90,10 +95,11 @@ def test_activity_read_count_accepts_the_retained_window_ceiling() -> None: def test_canonical_activity_read_uses_one_bounded_valkey_round_trip() -> None: - """The normal canonical stream must not pay one network round trip per event.""" + """The normal UUID stream must not pay one network round trip per event.""" client = _PopulatedReadClient() + post_id = "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA" - events = asyncio.run(read_activity_events(client, "post-1", event_count=3)) + events = asyncio.run(read_activity_events(client, post_id, event_count=3)) assert [event["event_id"] for event in events] == ["300-0", "200-0", "100-0"] - assert client.calls == [("activity:post-1", "+", 3)] + assert client.calls == [("activity:aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa", "+", 3)] From e17473834616b175f13ee2117e228758c984b2ae Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 5 Sep 2026 20:53:39 +0900 Subject: [PATCH 47/66] fix(activity): collapse canonical stream reads --- backend/app/activity_stream.py | 25 ++++++++++++++++++++++++- 1 file changed, 24 insertions(+), 1 deletion(-) diff --git a/backend/app/activity_stream.py b/backend/app/activity_stream.py index f2f6519f3..cf8f59999 100644 --- a/backend/app/activity_stream.py +++ b/backend/app/activity_stream.py @@ -404,6 +404,29 @@ async def read_activity_events( """ bounded_event_count = _activity_event_count(event_count) stream_keys = await _activity_read_stream_keys(valkey_client, post_id) + + if len(stream_keys) == 1: + with traced( + "lineageweave.valkey.activity_xrevrange", + { + "db.system": "redis", + "db.operation.name": "xrevrange", + "lineageweave.stream.kind": "activity", + }, + ): + canonical_entries = await valkey_client.xrevrange( + stream_keys[0], count=bounded_event_count + ) + return [ + { + "event_id": entry_id, + "event_type": activity_fields["event_type"], + "actor_account_id": activity_fields["actor_account_id"], + "summary": activity_fields["summary"], + } + for entry_id, activity_fields in canonical_entries + ] + next_entries: list[tuple[tuple[str, dict[str, str]], int]] = [] stream_results: list[tuple[str, dict[str, str]] | None] = [None] * len(stream_keys) with traced( @@ -444,4 +467,4 @@ async def read_activity_events( "summary": activity_fields["summary"], } for (entry_id, activity_fields), stream_index in next_entries - ] + ] \ No newline at end of file From ce5331328d01639efc52079d74128fd65f40f303 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 5 Sep 2026 20:54:39 +0900 Subject: [PATCH 48/66] docs(activity): record canonical read fast path --- .../0363-canonical-activity-stream-identity.md | 18 ++++++++++++++---- 1 file changed, 14 insertions(+), 4 deletions(-) diff --git a/docs/adr/0363-canonical-activity-stream-identity.md b/docs/adr/0363-canonical-activity-stream-identity.md index 2ad3327c2..45396b4e8 100644 --- a/docs/adr/0363-canonical-activity-stream-identity.md +++ b/docs/adr/0363-canonical-activity-stream-identity.md @@ -40,14 +40,21 @@ replacement, so no pre-canonical writer may overlap the alias scan. A rolling multi-version replica deployment is unavailable until it has a separate writer-fencing contract. -Reads enumerate at most the retained-window number of streams and then perform -a newest-first incremental merge. They fetch one entry per stream initially -and at most one further entry per returned event. More retained aliases fail -closed instead of creating unbounded fan-out or silently sampling history. +Reads enumerate at most the retained-window number of streams. When no retained +compatibility alias exists, the ordinary canonical path fetches the requested +bounded window with one `XREVRANGE count=N`; it must not turn an N-event panel +into N sequential Valkey round trips. When aliases exist, the compatibility +path performs a newest-first incremental merge, fetching one entry per stream +initially and at most one further entry per returned event. More retained +aliases fail closed instead of creating unbounded fan-out or silently sampling +history. This keeps the common path latency bounded without pretending legacy +stream-local sequence numbers form one global order. ## Consequences - Canonical reads retain every historical UUID spelling that actually exists. +- The alias-free buyer path performs one bounded stream read after alias lookup; + compatibility merging pays incremental reads only when retained aliases exist. - Request-time records and calls are bounded by the retained stream and output limits; excessive alias cardinality is explicitly unavailable. - Startup performs a cursor scan and must finish before readiness. @@ -58,6 +65,9 @@ closed instead of creating unbounded fan-out or silently sampling history. - Enumerate common UUID spellings: rejected because PostgreSQL accepts more forms than a finite hand-picked list would honestly cover. +- Always use one-entry incremental reads, including the canonical-only path: + rejected because it turns the normal activity panel into one sequential Valkey + round trip per returned event without adding compatibility information. - Scan the activity keyspace on each request: rejected because latency and work would scale with unrelated posts. - Drop legacy aliases: rejected because canonicalization would hide retained From 7dcc5c16bc61393fc6a533e216baee8c43363894 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 5 Sep 2026 23:55:04 +0900 Subject: [PATCH 49/66] test(activity): count alias lookup in read round-trip budget --- tests/test_activity_stream_read_budget.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/tests/test_activity_stream_read_budget.py b/tests/test_activity_stream_read_budget.py index 7d7d39ee3..da752ffd7 100644 --- a/tests/test_activity_stream_read_budget.py +++ b/tests/test_activity_stream_read_budget.py @@ -33,6 +33,7 @@ class _PopulatedReadClient: def __init__(self) -> None: self.calls: list[tuple[str, str, int | None]] = [] + self.round_trips = 0 self.entries = [ ("300-0", {"event_type": "third", "actor_account_id": "actor", "summary": "third"}), ("200-0", {"event_type": "second", "actor_account_id": "actor", "summary": "second"}), @@ -40,7 +41,9 @@ def __init__(self) -> None: ] async def sscan_iter(self, key: str): + """Count the alias-index exchange that precedes the canonical stream read.""" del key + self.round_trips += 1 if False: yield "" @@ -52,6 +55,7 @@ async def xrevrange( count: int | None = None, ) -> list[tuple[str, dict[str, str]]]: del min + self.round_trips += 1 self.calls.append((key, max, count)) if max == "+": eligible = self.entries @@ -95,7 +99,7 @@ def test_activity_read_count_accepts_the_retained_window_ceiling() -> None: def test_canonical_activity_read_uses_one_bounded_valkey_round_trip() -> None: - """The normal UUID stream must not pay one network round trip per event.""" + """The normal UUID path counts alias admission and data read as real I/O.""" client = _PopulatedReadClient() post_id = "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA" @@ -103,3 +107,4 @@ def test_canonical_activity_read_uses_one_bounded_valkey_round_trip() -> None: assert [event["event_id"] for event in events] == ["300-0", "200-0", "100-0"] assert client.calls == [("activity:aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa", "+", 3)] + assert client.round_trips == 1 From 02417fcf92969ffe7924f2722b192f40ff953e0d Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 6 Sep 2026 00:00:54 +0900 Subject: [PATCH 50/66] fix(activity): pipeline canonical alias probe with bounded read --- backend/app/activity_stream.py | 69 ++++++++++++++++++++++++++++++++++ 1 file changed, 69 insertions(+) diff --git a/backend/app/activity_stream.py b/backend/app/activity_stream.py index cf8f59999..b07993229 100644 --- a/backend/app/activity_stream.py +++ b/backend/app/activity_stream.py @@ -131,6 +131,59 @@ async def _activity_read_stream_keys( return tuple(dict.fromkeys(candidate_keys)) +async def _activity_canonical_entries_if_alias_free( + valkey_client: redis.Redis, + post_id: str, + event_count: int, +) -> list[tuple[str, dict[str, str]]] | None: + """Read alias admission and canonical events in one UUID-path network exchange. + + The retained-alias set exists only to bridge pre-canonical UUID spellings. + A normal UUID read must not pay one network wait for that empty compatibility + set and a second wait for the canonical stream. Redis pipelines preserve the + two independent commands while sending them together. If the first bounded + SSCAN page proves that the alias set is empty, its paired canonical XREVRANGE + is the complete fast-path result. Any observed alias or nonzero scan cursor + falls back to the bounded compatibility merger so retained history is never + hidden merely to reduce latency. + + Minimal Redis-compatible test adapters without pipeline support return + ``None`` and exercise the established fallback; production clients created + by :func:`create_valkey_client` always provide the async pipeline contract. + """ + canonical_key = _stream_key(post_id) + try: + UUID(post_id) + except ValueError: + return None + + pipeline_factory = getattr(valkey_client, "pipeline", None) + if pipeline_factory is None: + return None + + async with pipeline_factory(transaction=False) as read_pipeline: + read_pipeline.sscan( + _activity_alias_index_key(post_id), + cursor=0, + count=_MAX_ACTIVITY_READ_COUNT, + ) + read_pipeline.xrevrange(canonical_key, count=event_count) + with traced( + "lineageweave.valkey.activity_read_pipeline", + { + "db.system": "redis", + "db.operation.name": "pipeline", + "lineageweave.stream.kind": "activity", + }, + ): + alias_page, canonical_entries = await read_pipeline.execute() + + alias_cursor, aliases = alias_page + if int(alias_cursor) == 0 and not aliases: + return canonical_entries + return None + + def _activity_stream_entry_order(entry_id: str) -> tuple[int, int]: """Parse one Valkey stream entry id into its stream-local numeric order.""" milliseconds, separator, sequence = entry_id.partition("-") @@ -403,6 +456,22 @@ async def read_activity_events( because Valkey stream IDs are not globally unique across independent keys. """ bounded_event_count = _activity_event_count(event_count) + pipelined_canonical_entries = await _activity_canonical_entries_if_alias_free( + valkey_client, + post_id, + bounded_event_count, + ) + if pipelined_canonical_entries is not None: + return [ + { + "event_id": entry_id, + "event_type": activity_fields["event_type"], + "actor_account_id": activity_fields["actor_account_id"], + "summary": activity_fields["summary"], + } + for entry_id, activity_fields in pipelined_canonical_entries + ] + stream_keys = await _activity_read_stream_keys(valkey_client, post_id) if len(stream_keys) == 1: From 2b594942c12c37fe68459c02fbf47a2d11a727e4 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 6 Sep 2026 00:02:05 +0900 Subject: [PATCH 51/66] test(activity): model one-exchange pipeline fast path --- tests/test_activity_stream_read_budget.py | 49 ++++++++++++++++++++++- 1 file changed, 48 insertions(+), 1 deletion(-) diff --git a/tests/test_activity_stream_read_budget.py b/tests/test_activity_stream_read_budget.py index da752ffd7..a33af887b 100644 --- a/tests/test_activity_stream_read_budget.py +++ b/tests/test_activity_stream_read_budget.py @@ -40,8 +40,13 @@ def __init__(self) -> None: ("100-0", {"event_type": "first", "actor_account_id": "actor", "summary": "first"}), ] + def pipeline(self, *, transaction: bool): + """Queue alias admission and canonical data read onto one network exchange.""" + assert transaction is False + return _ReadPipeline(self) + async def sscan_iter(self, key: str): - """Count the alias-index exchange that precedes the canonical stream read.""" + """Count any compatibility fallback alias scan as another exchange.""" del key self.round_trips += 1 if False: @@ -66,6 +71,48 @@ async def xrevrange( return eligible[:count] +class _ReadPipeline: + """Model two queued Redis commands completed by one pipeline exchange.""" + + def __init__(self, client: _PopulatedReadClient) -> None: + self.client = client + self.alias_key: str | None = None + self.read_request: tuple[str, str, int | None] | None = None + + async def __aenter__(self): + return self + + async def __aexit__(self, exc_type, exc, traceback) -> bool: + del exc_type, exc, traceback + return False + + def sscan(self, key: str, *, cursor: int, count: int): + assert cursor == 0 + assert count == 1000 + self.alias_key = key + return self + + def xrevrange( + self, + key: str, + max: str = "+", + min: str = "-", + count: int | None = None, + ): + del min + self.read_request = (key, max, count) + return self + + async def execute(self): + assert self.alias_key == "activity-aliases:aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa" + assert self.read_request is not None + key, max_value, count = self.read_request + self.client.round_trips += 1 + self.client.calls.append((key, max_value, count)) + eligible = self.client.entries + return [(0, []), eligible[:count]] + + @pytest.mark.parametrize("event_count", (True, 1.0, "50")) def test_activity_read_count_requires_an_exact_integer(event_count: Any) -> None: """Scalar coercion must not create an implicit buyer-facing read budget.""" From 1b0c6a21451fc5b258113412e04deb6f38e7ad0c Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 6 Sep 2026 00:05:16 +0900 Subject: [PATCH 52/66] docs(adr): bind canonical activity read to one pipeline exchange --- ...0363-canonical-activity-stream-identity.md | 50 ++++++++++++++----- 1 file changed, 38 insertions(+), 12 deletions(-) diff --git a/docs/adr/0363-canonical-activity-stream-identity.md b/docs/adr/0363-canonical-activity-stream-identity.md index 45396b4e8..6b75fe3f5 100644 --- a/docs/adr/0363-canonical-activity-stream-identity.md +++ b/docs/adr/0363-canonical-activity-stream-identity.md @@ -40,22 +40,30 @@ replacement, so no pre-canonical writer may overlap the alias scan. A rolling multi-version replica deployment is unavailable until it has a separate writer-fencing contract. -Reads enumerate at most the retained-window number of streams. When no retained -compatibility alias exists, the ordinary canonical path fetches the requested -bounded window with one `XREVRANGE count=N`; it must not turn an N-event panel -into N sequential Valkey round trips. When aliases exist, the compatibility -path performs a newest-first incremental merge, fetching one entry per stream -initially and at most one further entry per returned event. More retained -aliases fail closed instead of creating unbounded fan-out or silently sampling -history. This keeps the common path latency bounded without pretending legacy -stream-local sequence numbers form one global order. +Reads enumerate at most the retained-window number of streams. For a UUID read, +the common path queues the first bounded alias-index `SSCAN` page and canonical +`XREVRANGE count=N` in one non-transactional redis-py pipeline. When that scan +returns cursor zero with no aliases, the paired canonical page is the complete +result, so alias admission and data retrieval consume one network exchange rather +than two sequential waits. A nonzero cursor or any retained alias falls back to +the compatibility reader; it never treats an incomplete alias scan as evidence +that the canonical stream is the only history. + +When aliases exist, the compatibility path performs a newest-first incremental +merge, fetching one entry per stream initially and at most one further entry per +returned event. More retained aliases fail closed instead of creating unbounded +fan-out or silently sampling history. This keeps the common path latency bounded +without pretending legacy stream-local sequence numbers form one global order. ## Consequences - Canonical reads retain every historical UUID spelling that actually exists. -- The alias-free buyer path performs one bounded stream read after alias lookup; - compatibility merging pays incremental reads only when retained aliases exist. -- Request-time records and calls are bounded by the retained stream and output +- The alias-free UUID buyer path admits compatibility metadata and fetches the + bounded canonical window in one redis-py pipeline network exchange. +- Compatibility merging may pay an additional bounded probe before its existing + incremental reads; legacy-history preservation takes precedence over the + alias-free fast path once an alias or unfinished scan is observed. +- Request-time records and calls remain bounded by the retained stream and output limits; excessive alias cardinality is explicitly unavailable. - Startup performs a cursor scan and must finish before readiness. - The alias index is additional derived Valkey state and can be rebuilt from @@ -65,6 +73,9 @@ stream-local sequence numbers form one global order. - Enumerate common UUID spellings: rejected because PostgreSQL accepts more forms than a finite hand-picked list would honestly cover. +- Perform an alias-index lookup and only afterward issue the canonical + `XREVRANGE`: rejected because the normal UUID path then has at least two + sequential Valkey network waits even when the alias set is empty. - Always use one-entry incremental reads, including the canonical-only path: rejected because it turns the normal activity panel into one sequential Valkey round trip per returned event without adding compatibility information. @@ -72,3 +83,18 @@ stream-local sequence numbers form one global order. would scale with unrelated posts. - Drop legacy aliases: rejected because canonicalization would hide retained authorized history. + +## Implementation evidence + +- Review `5121693984` identified that the earlier read-budget test counted only + `XREVRANGE` and omitted the preceding alias-index `SSCAN` network wait. +- RED `7dcc5c16bc61393fc6a533e216baee8c43363894` counts both direct Valkey waits + and therefore rejects the earlier two-exchange canonical UUID path. +- Production repair `02417fcf92969ffe7924f2722b192f40ff953e0d` + pipelines bounded alias admission with the canonical page while preserving the + existing compatibility fallback. +- Test convergence `2b594942c12c37fe68459c02fbf47a2d11a727e4` models the pipeline as one + network exchange and retains the exact canonical stream/count assertion. +- redis-py asyncio pipeline documentation specifies that pipeline commands are + buffered and executed together when awaited through `execute()`; transactions + remain optional and are not required for these independent read commands. From 4cfa402acadb43c3f60e4dba7bad8917230b9460 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 6 Sep 2026 00:26:47 +0900 Subject: [PATCH 53/66] test(activity): bound startup alias-index writes --- ...test_activity_stream_alias_index_budget.py | 82 +++++++++++++++++++ 1 file changed, 82 insertions(+) create mode 100644 tests/test_activity_stream_alias_index_budget.py diff --git a/tests/test_activity_stream_alias_index_budget.py b/tests/test_activity_stream_alias_index_budget.py new file mode 100644 index 000000000..e6e1d82eb --- /dev/null +++ b/tests/test_activity_stream_alias_index_budget.py @@ -0,0 +1,82 @@ +"""Startup legacy-activity alias indexing stays bounded at the Valkey wire.""" + +from __future__ import annotations + +import asyncio + +from backend.app.activity_stream import index_legacy_activity_stream_aliases + + +class _AliasIndexPipeline: + """Model independent alias-set writes completed by one pipeline exchange.""" + + def __init__(self, client: _AliasIndexClient) -> None: + self.client = client + self.pending: list[tuple[str, str]] = [] + + async def __aenter__(self): + return self + + async def __aexit__(self, exc_type, exc, traceback) -> bool: + del exc_type, exc, traceback + return False + + def sadd(self, key: str, member: str): + self.pending.append((key, member)) + return self + + async def execute(self) -> list[int]: + self.client.pipeline_round_trips += 1 + added: list[int] = [] + for key, member in self.pending: + members = self.client.members.setdefault(key, set()) + before = len(members) + members.add(member) + added.append(int(len(members) != before)) + return added + + +class _AliasIndexClient: + """Expose two historical aliases while rejecting per-alias network writes.""" + + def __init__(self) -> None: + self.keys = ( + "activity:550e8400-e29b-41d4-a716-446655440000", + "activity:550e8400e29b41d4a716446655440000", + "activity:{550E8400-E29B-41D4-A716-446655440000}", + ) + self.members: dict[str, set[str]] = {} + self.direct_sadd_calls = 0 + self.pipeline_round_trips = 0 + + async def scan_iter(self, *, match: str): + assert match == "activity:*" + for key in self.keys: + yield key + + async def sadd(self, key: str, member: str) -> int: + del key, member + self.direct_sadd_calls += 1 + raise AssertionError("startup alias indexing must not await one SADD per alias") + + def pipeline(self, *, transaction: bool): + assert transaction is False + return _AliasIndexPipeline(self) + + +def test_startup_alias_index_batches_independent_sadd_writes() -> None: + """Two retained aliases must not add two serial Valkey write round trips.""" + client = _AliasIndexClient() + + indexed = asyncio.run(index_legacy_activity_stream_aliases(client)) # type: ignore[arg-type] + + canonical_post_id = "550e8400-e29b-41d4-a716-446655440000" + assert indexed == 2 + assert client.direct_sadd_calls == 0 + assert client.pipeline_round_trips == 1 + assert client.members == { + f"activity-aliases:{canonical_post_id}": { + "activity:550e8400e29b41d4a716446655440000", + "activity:{550E8400-E29B-41D4-A716-446655440000}", + } + } From c4a25ce1f03ee225874a56c3f102ffa8b48a0621 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 6 Sep 2026 00:29:57 +0900 Subject: [PATCH 54/66] fix(activity): batch startup alias-index writes --- backend/app/activity_stream.py | 39 +++++++++++++++++++++++++++++++--- 1 file changed, 36 insertions(+), 3 deletions(-) diff --git a/backend/app/activity_stream.py b/backend/app/activity_stream.py index b07993229..c28b6953e 100644 --- a/backend/app/activity_stream.py +++ b/backend/app/activity_stream.py @@ -21,6 +21,7 @@ _SYNC_ACTIVITY_WATCH_RETRY_LIMIT = 8 _MAX_ACTIVITY_READ_COUNT = 1000 +_ACTIVITY_ALIAS_INDEX_WRITE_BATCH_SIZE = 128 _ACTIVITY_STREAM_PREFIX = "activity:" _ACTIVITY_ALIAS_INDEX_PREFIX = "activity-aliases:" @@ -70,6 +71,28 @@ def _activity_alias_index_key(post_id: str) -> str: return f"{_ACTIVITY_ALIAS_INDEX_PREFIX}{str(UUID(post_id))}" +async def _flush_activity_alias_index_writes( + valkey_client: redis.Redis, + writes: list[tuple[str, str]], +) -> int: + """Persist one bounded batch of independent alias-index set insertions.""" + if not writes: + return 0 + + pipeline_factory = getattr(valkey_client, "pipeline", None) + if pipeline_factory is None: + indexed = 0 + for index_key, stream_key in writes: + indexed += int(await valkey_client.sadd(index_key, stream_key)) + return indexed + + async with pipeline_factory(transaction=False) as write_pipeline: + for index_key, stream_key in writes: + write_pipeline.sadd(index_key, stream_key) + results = await write_pipeline.execute() + return sum(int(result) for result in results) + + async def index_legacy_activity_stream_aliases(valkey_client: redis.Redis) -> int: """Index every existing UUID stream alias before canonical-only reads begin. @@ -77,9 +100,12 @@ async def index_legacy_activity_stream_aliases(valkey_client: redis.Redis) -> in non-standard hyphen placement. Older routes used that raw spelling in the Valkey key, so enumerating a few common variants cannot preserve all valid history. Startup performs one cursor scan and records the finite aliases - that actually exist. New writes remain canonical-only. + that actually exist. New writes remain canonical-only. Independent alias-set + writes are flushed through bounded non-transactional pipeline batches so + rollout readiness does not add one serial network wait per historical alias. """ indexed = 0 + pending_writes: list[tuple[str, str]] = [] async for raw_key in valkey_client.scan_iter(match=f"{_ACTIVITY_STREAM_PREFIX}*"): stream_key = raw_key.decode() if isinstance(raw_key, bytes) else raw_key if not isinstance(stream_key, str) or stream_key.startswith(_ACTIVITY_ALIAS_INDEX_PREFIX): @@ -92,12 +118,19 @@ async def index_legacy_activity_stream_aliases(valkey_client: redis.Redis) -> in canonical_key = f"{_ACTIVITY_STREAM_PREFIX}{canonical_post_id}" if stream_key == canonical_key: continue - indexed += int( - await valkey_client.sadd( + pending_writes.append( + ( f"{_ACTIVITY_ALIAS_INDEX_PREFIX}{canonical_post_id}", stream_key, ) ) + if len(pending_writes) >= _ACTIVITY_ALIAS_INDEX_WRITE_BATCH_SIZE: + indexed += await _flush_activity_alias_index_writes( + valkey_client, + pending_writes, + ) + pending_writes.clear() + indexed += await _flush_activity_alias_index_writes(valkey_client, pending_writes) return indexed From 25685ef066697ac13cf00db4e25550bfbf061504 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 6 Sep 2026 00:31:11 +0900 Subject: [PATCH 55/66] docs(adr): bound startup alias-index write batches --- ...0363-canonical-activity-stream-identity.md | 28 ++++++++++++++++++- 1 file changed, 27 insertions(+), 1 deletion(-) diff --git a/docs/adr/0363-canonical-activity-stream-identity.md b/docs/adr/0363-canonical-activity-stream-identity.md index 6b75fe3f5..44e487e96 100644 --- a/docs/adr/0363-canonical-activity-stream-identity.md +++ b/docs/adr/0363-canonical-activity-stream-identity.md @@ -34,6 +34,15 @@ WATCH retries, retained-window read limits, and alias-index startup are fixed product contracts. Failure to build the alias index makes the application unready; the product does not silently hide historical activity. +Alias discovery may find many independent legacy keys. Startup therefore keeps +at most 128 pending alias-set writes in memory and flushes each bounded group +through a non-transactional redis-py pipeline. `SADD` result integers are summed +exactly, preserving the existing count of newly indexed aliases. Minimal test +adapters without pipeline support retain the sequential compatibility fallback; +the production client created by `create_valkey_client` uses the batched path. +The batching changes transport cost only: discovery, durability, and readiness +semantics remain unchanged. + The supported deployment is the repository's single `lineageweave` Compose backend service. Deployment stops the preceding backend before starting the replacement, so no pre-canonical writer may overlap the alias scan. A rolling @@ -58,6 +67,9 @@ without pretending legacy stream-local sequence numbers form one global order. ## Consequences - Canonical reads retain every historical UUID spelling that actually exists. +- Startup alias writes use at most 128 pending `(index key, legacy stream key)` + tuples per production pipeline exchange instead of one awaited network write + per alias. - The alias-free UUID buyer path admits compatibility metadata and fetches the bounded canonical window in one redis-py pipeline network exchange. - Compatibility merging may pay an additional bounded probe before its existing @@ -73,6 +85,12 @@ without pretending legacy stream-local sequence numbers form one global order. - Enumerate common UUID spellings: rejected because PostgreSQL accepts more forms than a finite hand-picked list would honestly cover. +- Await one `SADD` for every discovered alias: rejected because rollout/readiness + latency then adds one serial Valkey write round trip per historical alias even + though those writes are independent. +- Buffer every discovered alias and issue one unbounded pipeline at the end: + rejected because startup memory and command-buffer size would scale with the + entire retained keyspace rather than a fixed batch. - Perform an alias-index lookup and only afterward issue the canonical `XREVRANGE`: rejected because the normal UUID path then has at least two sequential Valkey network waits even when the alias set is empty. @@ -95,6 +113,14 @@ without pretending legacy stream-local sequence numbers form one global order. existing compatibility fallback. - Test convergence `2b594942c12c37fe68459c02fbf47a2d11a727e4` models the pipeline as one network exchange and retains the exact canonical stream/count assertion. +- Review `5121832019` identified the independent startup N+1 where alias + discovery awaited one `SADD` network write for every retained legacy key. +- RED `4cfa402acadb43c3f60e4dba7bad8917230b9460` rejects direct per-alias `SADD` + on a two-alias fixture while preserving exact durable members and added count. +- Production repair `c4a25ce1f03ee225874a56c3f102ffa8b48a0621` introduces a bounded 128-write + non-transactional pipeline batch with a compatibility fallback for minimal + adapters that do not expose redis-py pipeline support. - redis-py asyncio pipeline documentation specifies that pipeline commands are buffered and executed together when awaited through `execute()`; transactions - remain optional and are not required for these independent read commands. + remain optional and are not required for these independent read or set-write + commands. From b757fc79e6066876bbf7d473098bad49dcfee363 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 6 Sep 2026 00:32:47 +0900 Subject: [PATCH 56/66] test(activity): prove alias-index batch ceiling --- ...test_activity_stream_alias_index_budget.py | 27 ++++++++++++++++--- 1 file changed, 24 insertions(+), 3 deletions(-) diff --git a/tests/test_activity_stream_alias_index_budget.py b/tests/test_activity_stream_alias_index_budget.py index e6e1d82eb..43762f4a6 100644 --- a/tests/test_activity_stream_alias_index_budget.py +++ b/tests/test_activity_stream_alias_index_budget.py @@ -3,6 +3,7 @@ from __future__ import annotations import asyncio +from uuid import UUID from backend.app.activity_stream import index_legacy_activity_stream_aliases @@ -27,6 +28,7 @@ def sadd(self, key: str, member: str): async def execute(self) -> list[int]: self.client.pipeline_round_trips += 1 + self.client.pipeline_batch_sizes.append(len(self.pending)) added: list[int] = [] for key, member in self.pending: members = self.client.members.setdefault(key, set()) @@ -37,10 +39,10 @@ async def execute(self) -> list[int]: class _AliasIndexClient: - """Expose two historical aliases while rejecting per-alias network writes.""" + """Expose historical aliases while rejecting per-alias network writes.""" - def __init__(self) -> None: - self.keys = ( + def __init__(self, keys: tuple[str, ...] | None = None) -> None: + self.keys = keys or ( "activity:550e8400-e29b-41d4-a716-446655440000", "activity:550e8400e29b41d4a716446655440000", "activity:{550E8400-E29B-41D4-A716-446655440000}", @@ -48,6 +50,7 @@ def __init__(self) -> None: self.members: dict[str, set[str]] = {} self.direct_sadd_calls = 0 self.pipeline_round_trips = 0 + self.pipeline_batch_sizes: list[int] = [] async def scan_iter(self, *, match: str): assert match == "activity:*" @@ -74,9 +77,27 @@ def test_startup_alias_index_batches_independent_sadd_writes() -> None: assert indexed == 2 assert client.direct_sadd_calls == 0 assert client.pipeline_round_trips == 1 + assert client.pipeline_batch_sizes == [2] assert client.members == { f"activity-aliases:{canonical_post_id}": { "activity:550e8400e29b41d4a716446655440000", "activity:{550E8400-E29B-41D4-A716-446655440000}", } } + + +def test_startup_alias_index_flushes_before_exceeding_the_batch_ceiling() -> None: + """The 129th independent alias starts a second bounded pipeline exchange.""" + aliases = tuple( + f"activity:{str(UUID(int=index + 1)).upper()}" + for index in range(129) + ) + client = _AliasIndexClient(aliases) + + indexed = asyncio.run(index_legacy_activity_stream_aliases(client)) # type: ignore[arg-type] + + assert indexed == 129 + assert client.direct_sadd_calls == 0 + assert client.pipeline_round_trips == 2 + assert client.pipeline_batch_sizes == [128, 1] + assert sum(len(members) for members in client.members.values()) == 129 From c9ca17dac95361096afb9e05f3ecabd0da00b625 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 6 Sep 2026 00:33:08 +0900 Subject: [PATCH 57/66] test(activity): make batch-boundary aliases unambiguously legacy --- tests/test_activity_stream_alias_index_budget.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_activity_stream_alias_index_budget.py b/tests/test_activity_stream_alias_index_budget.py index 43762f4a6..144dd0078 100644 --- a/tests/test_activity_stream_alias_index_budget.py +++ b/tests/test_activity_stream_alias_index_budget.py @@ -89,7 +89,7 @@ def test_startup_alias_index_batches_independent_sadd_writes() -> None: def test_startup_alias_index_flushes_before_exceeding_the_batch_ceiling() -> None: """The 129th independent alias starts a second bounded pipeline exchange.""" aliases = tuple( - f"activity:{str(UUID(int=index + 1)).upper()}" + f"activity:{{{str(UUID(int=index + 1)).upper()}}}" for index in range(129) ) client = _AliasIndexClient(aliases) From 7d44315c81bd942db58af75ea9af9e9885c46dc5 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 6 Sep 2026 00:52:58 +0900 Subject: [PATCH 58/66] test(activity): reject per-event compatibility read waits --- ...st_activity_stream_compatibility_budget.py | 107 ++++++++++++++++++ 1 file changed, 107 insertions(+) create mode 100644 tests/test_activity_stream_compatibility_budget.py diff --git a/tests/test_activity_stream_compatibility_budget.py b/tests/test_activity_stream_compatibility_budget.py new file mode 100644 index 000000000..be7884a5b --- /dev/null +++ b/tests/test_activity_stream_compatibility_budget.py @@ -0,0 +1,107 @@ +"""Retained UUID-alias reads stay bounded at the Valkey wire.""" + +from __future__ import annotations + +import asyncio + +from backend.app.activity_stream import read_activity_events + + +class _CompatibilityPipeline: + """Model queued SSCAN/XREVRANGE commands as one network exchange.""" + + def __init__(self, client: _CompatibilityReadClient) -> None: + self.client = client + self.commands: list[tuple[str, tuple[object, ...], dict[str, object]]] = [] + + async def __aenter__(self): + return self + + async def __aexit__(self, exc_type, exc, traceback) -> bool: + del exc_type, exc, traceback + return False + + def sscan(self, key: str, *, cursor: int, count: int): + self.commands.append(("sscan", (key,), {"cursor": cursor, "count": count})) + return self + + def xrevrange(self, key: str, *, count: int, max: str = "+"): + self.commands.append(("xrevrange", (key,), {"count": count, "max": max})) + return self + + async def execute(self): + self.client.round_trips += 1 + self.client.pipeline_batch_sizes.append(len(self.commands)) + results: list[object] = [] + for command, args, kwargs in self.commands: + key = str(args[0]) + if command == "sscan": + results.append((0, [self.client.legacy_key])) + continue + entries = self.client.entries_by_key[key] + max_value = str(kwargs["max"]) + if max_value.startswith("("): + boundary = max_value[1:] + entries = [entry for entry in entries if entry[0] != boundary] + results.append(entries[: int(kwargs["count"])]) + return results + + +class _CompatibilityReadClient: + """Expose one canonical stream and one retained UUID-equivalent alias.""" + + def __init__(self) -> None: + canonical_post_id = "550e8400-e29b-41d4-a716-446655440000" + self.canonical_key = f"activity:{canonical_post_id}" + self.legacy_key = "activity:{550E8400-E29B-41D4-A716-446655440000}" + self.round_trips = 0 + self.pipeline_batch_sizes: list[int] = [] + self.entries_by_key = { + self.canonical_key: [ + ("400-0", {"event_type": "status", "actor_account_id": "acct", "summary": "canonical 400"}), + ("200-0", {"event_type": "status", "actor_account_id": "acct", "summary": "canonical 200"}), + ], + self.legacy_key: [ + ("300-0", {"event_type": "created", "actor_account_id": "acct", "summary": "legacy 300"}), + ("100-0", {"event_type": "created", "actor_account_id": "acct", "summary": "legacy 100"}), + ], + } + + def pipeline(self, *, transaction: bool): + assert transaction is False + return _CompatibilityPipeline(self) + + async def sscan_iter(self, key: str): + assert key == "activity-aliases:550e8400-e29b-41d4-a716-446655440000" + self.round_trips += 1 + yield self.legacy_key + + async def xrevrange(self, key: str, *, count: int, max: str = "+"): + self.round_trips += 1 + entries = self.entries_by_key[key] + if max.startswith("("): + boundary = max[1:] + entries = [entry for entry in entries if entry[0] != boundary] + return entries[:count] + + +def test_retained_alias_read_batches_bounded_history_before_local_merge() -> None: + """A four-event compatibility read must not pay one network wait per event.""" + client = _CompatibilityReadClient() + + events = asyncio.run( + read_activity_events( + client, # type: ignore[arg-type] + "550E8400-E29B-41D4-A716-446655440000", + event_count=4, + ) + ) + + assert [event["summary"] for event in events] == [ + "canonical 400", + "legacy 300", + "canonical 200", + "legacy 100", + ] + assert client.round_trips <= 3 + assert client.pipeline_batch_sizes[-1] == 2 From 2ee36cf7844526a5f27803100f2b469e31573a98 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 6 Sep 2026 00:55:51 +0900 Subject: [PATCH 59/66] perf(activity): batch retained-alias history reads --- backend/app/activity_stream.py | 75 +++++++++++++++++++++++++++++++++- 1 file changed, 74 insertions(+), 1 deletion(-) diff --git a/backend/app/activity_stream.py b/backend/app/activity_stream.py index c28b6953e..0ec3bb632 100644 --- a/backend/app/activity_stream.py +++ b/backend/app/activity_stream.py @@ -529,6 +529,79 @@ async def read_activity_events( for entry_id, activity_fields in canonical_entries ] + pipeline_factory = getattr(valkey_client, "pipeline", None) + if pipeline_factory is not None: + prefetch_count = max( + 1, + min( + bounded_event_count, + _MAX_ACTIVITY_READ_COUNT // len(stream_keys), + ), + ) + async with pipeline_factory(transaction=False) as read_pipeline: + for stream_key in stream_keys: + read_pipeline.xrevrange(stream_key, count=prefetch_count) + with traced( + "lineageweave.valkey.activity_read_pipeline", + { + "db.system": "redis", + "db.operation.name": "pipeline", + "lineageweave.stream.kind": "activity", + }, + ): + stream_pages = await read_pipeline.execute() + + next_entries: list[tuple[tuple[str, dict[str, str]], int]] = [] + stream_positions = [0] * len(stream_keys) + with traced( + "lineageweave.valkey.activity_xrevrange", + { + "db.system": "redis", + "db.operation.name": "xrevrange", + "lineageweave.stream.kind": "activity", + }, + ): + while len(next_entries) < bounded_event_count: + available = [ + (stream_pages[stream_index][stream_position], stream_index) + for stream_index, stream_position in enumerate(stream_positions) + if stream_position < len(stream_pages[stream_index]) + ] + if not available: + break + newest_entry, newest_stream_index = max( + available, + key=lambda item: _activity_compatibility_merge_order( + item[0][0], item[1] + ), + ) + next_entries.append((newest_entry, newest_stream_index)) + stream_positions[newest_stream_index] += 1 + + if ( + len(next_entries) < bounded_event_count + and stream_positions[newest_stream_index] + >= len(stream_pages[newest_stream_index]) + and len(stream_pages[newest_stream_index]) == prefetch_count + ): + next_page = await valkey_client.xrevrange( + stream_keys[newest_stream_index], + max=f"({newest_entry[0]}", + count=prefetch_count, + ) + stream_pages[newest_stream_index] = next_page + stream_positions[newest_stream_index] = 0 + + return [ + { + "event_id": _activity_public_event_id(entry_id, stream_index), + "event_type": activity_fields["event_type"], + "actor_account_id": activity_fields["actor_account_id"], + "summary": activity_fields["summary"], + } + for (entry_id, activity_fields), stream_index in next_entries + ] + next_entries: list[tuple[tuple[str, dict[str, str]], int]] = [] stream_results: list[tuple[str, dict[str, str]] | None] = [None] * len(stream_keys) with traced( @@ -569,4 +642,4 @@ async def read_activity_events( "summary": activity_fields["summary"], } for (entry_id, activity_fields), stream_index in next_entries - ] \ No newline at end of file + ] From a352efa2dad90aef7f344a3afd515f94bb6288f4 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 6 Sep 2026 00:56:52 +0900 Subject: [PATCH 60/66] docs(adr): bound retained-alias activity prefetch --- ...0363-canonical-activity-stream-identity.md | 42 ++++++++++++++----- 1 file changed, 31 insertions(+), 11 deletions(-) diff --git a/docs/adr/0363-canonical-activity-stream-identity.md b/docs/adr/0363-canonical-activity-stream-identity.md index 44e487e96..4469cfe76 100644 --- a/docs/adr/0363-canonical-activity-stream-identity.md +++ b/docs/adr/0363-canonical-activity-stream-identity.md @@ -58,11 +58,16 @@ than two sequential waits. A nonzero cursor or any retained alias falls back to the compatibility reader; it never treats an incomplete alias scan as evidence that the canonical stream is the only history. -When aliases exist, the compatibility path performs a newest-first incremental -merge, fetching one entry per stream initially and at most one further entry per -returned event. More retained aliases fail closed instead of creating unbounded -fan-out or silently sampling history. This keeps the common path latency bounded -without pretending legacy stream-local sequence numbers form one global order. +When aliases exist, production redis-py clients prefetch a bounded slice from +every admitted stream in one non-transactional pipeline before merging locally. +The per-stream page size is `min(event_count, 1000 // stream_count)`, with a +minimum of one, so initial retained-entry buffering never exceeds the same +1,000-entry product ceiling even when alias cardinality is high. If one stream +supplies more than its prefetched share, only that exhausted stream is refilled +with another bounded page. Minimal adapters without pipeline support retain the +older one-entry incremental merger. Both paths preserve complete retained +history, canonical-first equal-millisecond ordering, and the final 1..1000 +buyer-facing output budget. ## Consequences @@ -72,9 +77,12 @@ without pretending legacy stream-local sequence numbers form one global order. per alias. - The alias-free UUID buyer path admits compatibility metadata and fetches the bounded canonical window in one redis-py pipeline network exchange. -- Compatibility merging may pay an additional bounded probe before its existing - incremental reads; legacy-history preservation takes precedence over the - alias-free fast path once an alias or unfinished scan is observed. +- The retained-alias production path batches its first bounded history pages in + one pipeline exchange instead of issuing one network command per stream and + one additional command per returned event. +- Compatibility prefetch retains at most 1,000 stream entries at a time before + output construction; skewed history refills only the stream that exhausted + its bounded page. - Request-time records and calls remain bounded by the retained stream and output limits; excessive alias cardinality is explicitly unavailable. - Startup performs a cursor scan and must finish before readiness. @@ -94,9 +102,12 @@ without pretending legacy stream-local sequence numbers form one global order. - Perform an alias-index lookup and only afterward issue the canonical `XREVRANGE`: rejected because the normal UUID path then has at least two sequential Valkey network waits even when the alias set is empty. -- Always use one-entry incremental reads, including the canonical-only path: - rejected because it turns the normal activity panel into one sequential Valkey - round trip per returned event without adding compatibility information. +- Fetch one compatibility entry per stream and then await one `XREVRANGE` per + returned event: rejected because an authorized 50-event legacy-history panel + still pays roughly one sequential Valkey wait per result. +- Fetch `event_count` entries from every compatibility stream in one pipeline: + rejected because worst-case buffering becomes `event_count * stream_count`, + up to one million retained records under the existing limits. - Scan the activity keyspace on each request: rejected because latency and work would scale with unrelated posts. - Drop legacy aliases: rejected because canonicalization would hide retained @@ -120,6 +131,15 @@ without pretending legacy stream-local sequence numbers form one global order. - Production repair `c4a25ce1f03ee225874a56c3f102ffa8b48a0621` introduces a bounded 128-write non-transactional pipeline batch with a compatibility fallback for minimal adapters that do not expose redis-py pipeline support. +- Review `5121915874` identified the retained-alias buyer-path N+1 left after the + canonical fast-path repairs: initial probes were concurrent, but each emitted + event still triggered another awaited `XREVRANGE count=1`. +- RED `7d44315c81bd942db58af75ea9af9e9885c46dc5` requires a four-event, + two-stream compatibility read to preserve exact merge order without paying one + network exchange per event. +- Production repair `2ee36cf7844526a5f27803100f2b469e31573a98` batches bounded per-stream + history pages through one non-transactional pipeline and refills only an + exhausted stream, with total initial buffering capped at 1,000 entries. - redis-py asyncio pipeline documentation specifies that pipeline commands are buffered and executed together when awaited through `execute()`; transactions remain optional and are not required for these independent read or set-write From 15746e2be27effca16c71f445f8d77f5d55fe6b8 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 6 Sep 2026 01:53:12 +0900 Subject: [PATCH 61/66] test(activity): reject redundant alias admission read --- tests/test_activity_stream_compatibility_budget.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/tests/test_activity_stream_compatibility_budget.py b/tests/test_activity_stream_compatibility_budget.py index be7884a5b..bf9a73167 100644 --- a/tests/test_activity_stream_compatibility_budget.py +++ b/tests/test_activity_stream_compatibility_budget.py @@ -85,8 +85,8 @@ async def xrevrange(self, key: str, *, count: int, max: str = "+"): return entries[:count] -def test_retained_alias_read_batches_bounded_history_before_local_merge() -> None: - """A four-event compatibility read must not pay one network wait per event.""" +def test_retained_alias_read_reuses_complete_admission_page_before_local_merge() -> None: + """A complete alias admission page must not be discarded and scanned again.""" client = _CompatibilityReadClient() events = asyncio.run( @@ -103,5 +103,5 @@ def test_retained_alias_read_batches_bounded_history_before_local_merge() -> Non "canonical 200", "legacy 100", ] - assert client.round_trips <= 3 - assert client.pipeline_batch_sizes[-1] == 2 + assert client.round_trips == 2 + assert client.pipeline_batch_sizes == [2, 1] From 07fa552f9fecbe1cda79a9fccc7eeacd82c4fba5 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 6 Sep 2026 01:55:40 +0900 Subject: [PATCH 62/66] fix(activity): reuse alias admission read --- backend/app/activity_stream.py | 96 +++++++++++++++++++++------------- 1 file changed, 59 insertions(+), 37 deletions(-) diff --git a/backend/app/activity_stream.py b/backend/app/activity_stream.py index 0ec3bb632..0e158d332 100644 --- a/backend/app/activity_stream.py +++ b/backend/app/activity_stream.py @@ -164,20 +164,19 @@ async def _activity_read_stream_keys( return tuple(dict.fromkeys(candidate_keys)) -async def _activity_canonical_entries_if_alias_free( +async def _activity_initial_uuid_read( valkey_client: redis.Redis, post_id: str, event_count: int, -) -> list[tuple[str, dict[str, str]]] | None: - """Read alias admission and canonical events in one UUID-path network exchange. +) -> tuple[list[tuple[str, dict[str, str]]], tuple[str, ...]] | None: + """Read complete alias admission and canonical events in one UUID exchange. The retained-alias set exists only to bridge pre-canonical UUID spellings. - A normal UUID read must not pay one network wait for that empty compatibility - set and a second wait for the canonical stream. Redis pipelines preserve the - two independent commands while sending them together. If the first bounded - SSCAN page proves that the alias set is empty, its paired canonical XREVRANGE - is the complete fast-path result. Any observed alias or nonzero scan cursor - falls back to the bounded compatibility merger so retained history is never + Redis pipelines preserve the independent SSCAN and canonical XREVRANGE while + sending them together. When the bounded SSCAN page is complete, its aliases + are authoritative for this request and are returned with the already-fetched + canonical page instead of being discarded and scanned again. A nonzero scan + cursor falls back to the established bounded iterator so history is never hidden merely to reduce latency. Minimal Redis-compatible test adapters without pipeline support return @@ -212,9 +211,11 @@ async def _activity_canonical_entries_if_alias_free( alias_page, canonical_entries = await read_pipeline.execute() alias_cursor, aliases = alias_page - if int(alias_cursor) == 0 and not aliases: - return canonical_entries - return None + if int(alias_cursor) != 0: + return None + candidate_keys = (canonical_key, *sorted(aliases)) + stream_keys = tuple(dict.fromkeys(candidate_keys)) + return canonical_entries, stream_keys[1:] def _activity_stream_entry_order(entry_id: str) -> tuple[int, int]: @@ -489,23 +490,29 @@ async def read_activity_events( because Valkey stream IDs are not globally unique across independent keys. """ bounded_event_count = _activity_event_count(event_count) - pipelined_canonical_entries = await _activity_canonical_entries_if_alias_free( + initial_uuid_read = await _activity_initial_uuid_read( valkey_client, post_id, bounded_event_count, ) - if pipelined_canonical_entries is not None: - return [ - { - "event_id": entry_id, - "event_type": activity_fields["event_type"], - "actor_account_id": activity_fields["actor_account_id"], - "summary": activity_fields["summary"], - } - for entry_id, activity_fields in pipelined_canonical_entries - ] - - stream_keys = await _activity_read_stream_keys(valkey_client, post_id) + prefetched_canonical_entries: list[tuple[str, dict[str, str]]] | None = None + if initial_uuid_read is not None: + canonical_entries, indexed_aliases = initial_uuid_read + if not indexed_aliases: + return [ + { + "event_id": entry_id, + "event_type": activity_fields["event_type"], + "actor_account_id": activity_fields["actor_account_id"], + "summary": activity_fields["summary"], + } + for entry_id, activity_fields in canonical_entries + ] + canonical_key = _stream_key(post_id) + stream_keys = (canonical_key, *indexed_aliases) + prefetched_canonical_entries = canonical_entries + else: + stream_keys = await _activity_read_stream_keys(valkey_client, post_id) if len(stream_keys) == 1: with traced( @@ -538,18 +545,33 @@ async def read_activity_events( _MAX_ACTIVITY_READ_COUNT // len(stream_keys), ), ) - async with pipeline_factory(transaction=False) as read_pipeline: - for stream_key in stream_keys: - read_pipeline.xrevrange(stream_key, count=prefetch_count) - with traced( - "lineageweave.valkey.activity_read_pipeline", - { - "db.system": "redis", - "db.operation.name": "pipeline", - "lineageweave.stream.kind": "activity", - }, - ): - stream_pages = await read_pipeline.execute() + if prefetched_canonical_entries is not None: + stream_pages = [prefetched_canonical_entries[:prefetch_count]] + async with pipeline_factory(transaction=False) as read_pipeline: + for stream_key in stream_keys[1:]: + read_pipeline.xrevrange(stream_key, count=prefetch_count) + with traced( + "lineageweave.valkey.activity_read_pipeline", + { + "db.system": "redis", + "db.operation.name": "pipeline", + "lineageweave.stream.kind": "activity", + }, + ): + stream_pages.extend(await read_pipeline.execute()) + else: + async with pipeline_factory(transaction=False) as read_pipeline: + for stream_key in stream_keys: + read_pipeline.xrevrange(stream_key, count=prefetch_count) + with traced( + "lineageweave.valkey.activity_read_pipeline", + { + "db.system": "redis", + "db.operation.name": "pipeline", + "lineageweave.stream.kind": "activity", + }, + ): + stream_pages = await read_pipeline.execute() next_entries: list[tuple[tuple[str, dict[str, str]], int]] = [] stream_positions = [0] * len(stream_keys) From 3a468e10c1572f9a3d1871a7f24c029648a1312d Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 6 Sep 2026 01:56:22 +0900 Subject: [PATCH 63/66] docs(adr): reuse complete activity alias admission --- ...0363-canonical-activity-stream-identity.md | 52 +++++++++++++------ 1 file changed, 35 insertions(+), 17 deletions(-) diff --git a/docs/adr/0363-canonical-activity-stream-identity.md b/docs/adr/0363-canonical-activity-stream-identity.md index 4469cfe76..3518f6719 100644 --- a/docs/adr/0363-canonical-activity-stream-identity.md +++ b/docs/adr/0363-canonical-activity-stream-identity.md @@ -52,22 +52,26 @@ writer-fencing contract. Reads enumerate at most the retained-window number of streams. For a UUID read, the common path queues the first bounded alias-index `SSCAN` page and canonical `XREVRANGE count=N` in one non-transactional redis-py pipeline. When that scan -returns cursor zero with no aliases, the paired canonical page is the complete -result, so alias admission and data retrieval consume one network exchange rather -than two sequential waits. A nonzero cursor or any retained alias falls back to -the compatibility reader; it never treats an incomplete alias scan as evidence -that the canonical stream is the only history. +returns cursor zero, its alias members and paired canonical page are both reused +by the same request. An empty member set completes the canonical-only response +in that one exchange. A complete non-empty member set enters compatibility +merge without scanning the alias index again or re-fetching the canonical page; +only retained alias pages are fetched in the next pipeline exchange. A nonzero +cursor falls back to the bounded compatibility iterator and never treats an +incomplete alias scan as complete history. When aliases exist, production redis-py clients prefetch a bounded slice from -every admitted stream in one non-transactional pipeline before merging locally. -The per-stream page size is `min(event_count, 1000 // stream_count)`, with a -minimum of one, so initial retained-entry buffering never exceeds the same -1,000-entry product ceiling even when alias cardinality is high. If one stream -supplies more than its prefetched share, only that exhausted stream is refilled -with another bounded page. Minimal adapters without pipeline support retain the -older one-entry incremental merger. Both paths preserve complete retained -history, canonical-first equal-millisecond ordering, and the final 1..1000 -buyer-facing output budget. +every admitted stream before merging locally. The per-stream page size is +`min(event_count, 1000 // stream_count)`, with a minimum of one. When the first +UUID pipeline already fetched the canonical page, that page is trimmed to this +per-stream budget before retained aliases are fetched, and the alias-only second +pipeline fills the remaining stream pages. Thus resident compatibility pages +still stay within the same 1,000-entry product ceiling. If one stream supplies +more than its prefetched share, only that exhausted stream is refilled with +another bounded page. Minimal adapters without pipeline support retain the older +one-entry incremental merger. Both paths preserve complete retained history, +canonical-first equal-millisecond ordering, and the final 1..1000 buyer-facing +output budget. ## Consequences @@ -77,9 +81,11 @@ buyer-facing output budget. per alias. - The alias-free UUID buyer path admits compatibility metadata and fetches the bounded canonical window in one redis-py pipeline network exchange. -- The retained-alias production path batches its first bounded history pages in - one pipeline exchange instead of issuing one network command per stream and - one additional command per returned event. +- A complete retained-alias admission page is reused rather than discarded: the + one-alias compatibility fixture now needs two network exchanges, not three. +- The retained-alias production path batches bounded history pages instead of + issuing one network command per stream and one additional command per returned + event. - Compatibility prefetch retains at most 1,000 stream entries at a time before output construction; skewed history refills only the stream that exhausted its bounded page. @@ -102,6 +108,9 @@ buyer-facing output budget. - Perform an alias-index lookup and only afterward issue the canonical `XREVRANGE`: rejected because the normal UUID path then has at least two sequential Valkey network waits even when the alias set is empty. +- Discard a complete non-empty first alias page and rescan it before compatibility + merge: rejected because it adds a deterministic network exchange and repeats + admission work without improving history correctness. - Fetch one compatibility entry per stream and then await one `XREVRANGE` per returned event: rejected because an authorized 50-event legacy-history panel still pays roughly one sequential Valkey wait per result. @@ -140,6 +149,15 @@ buyer-facing output budget. - Production repair `2ee36cf7844526a5f27803100f2b469e31573a98` batches bounded per-stream history pages through one non-transactional pipeline and refills only an exhausted stream, with total initial buffering capped at 1,000 entries. +- Review `5122107164` found that the retained-alias path still discarded the + complete first SSCAN page and canonical XREVRANGE, then repeated alias + admission and canonical retrieval before merging. +- RED `15746e2be27effca16c71f445f8d77f5d55fe6b8` requires the one-alias fixture + to preserve exact four-event ordering in exactly two network exchanges, with + pipeline batches `[2, 1]`. +- Production repair `07fa552f9fecbe1cda79a9fccc7eeacd82c4fba5` carries a complete first + alias page plus the canonical page into compatibility merge and fetches only + retained alias pages in the second pipeline exchange. - redis-py asyncio pipeline documentation specifies that pipeline commands are buffered and executed together when awaited through `execute()`; transactions remain optional and are not required for these independent read or set-write From a9c080c1f2fa030bd2b0cec0e2fb6b43e4328bbc Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 6 Sep 2026 01:59:10 +0900 Subject: [PATCH 64/66] test(activity): keep reused alias admission fanout bounded --- ...st_activity_stream_compatibility_budget.py | 29 +++++++++++++++++-- 1 file changed, 27 insertions(+), 2 deletions(-) diff --git a/tests/test_activity_stream_compatibility_budget.py b/tests/test_activity_stream_compatibility_budget.py index bf9a73167..783c3c5a6 100644 --- a/tests/test_activity_stream_compatibility_budget.py +++ b/tests/test_activity_stream_compatibility_budget.py @@ -4,6 +4,8 @@ import asyncio +import pytest + from backend.app.activity_stream import read_activity_events @@ -36,7 +38,7 @@ async def execute(self): for command, args, kwargs in self.commands: key = str(args[0]) if command == "sscan": - results.append((0, [self.client.legacy_key])) + results.append((0, self.client.aliases)) continue entries = self.client.entries_by_key[key] max_value = str(kwargs["max"]) @@ -54,6 +56,7 @@ def __init__(self) -> None: canonical_post_id = "550e8400-e29b-41d4-a716-446655440000" self.canonical_key = f"activity:{canonical_post_id}" self.legacy_key = "activity:{550E8400-E29B-41D4-A716-446655440000}" + self.aliases = [self.legacy_key] self.round_trips = 0 self.pipeline_batch_sizes: list[int] = [] self.entries_by_key = { @@ -74,7 +77,8 @@ def pipeline(self, *, transaction: bool): async def sscan_iter(self, key: str): assert key == "activity-aliases:550e8400-e29b-41d4-a716-446655440000" self.round_trips += 1 - yield self.legacy_key + for alias in self.aliases: + yield alias async def xrevrange(self, key: str, *, count: int, max: str = "+"): self.round_trips += 1 @@ -105,3 +109,24 @@ def test_retained_alias_read_reuses_complete_admission_page_before_local_merge() ] assert client.round_trips == 2 assert client.pipeline_batch_sizes == [2, 1] + + +def test_complete_admission_page_preserves_total_stream_fanout_ceiling() -> None: + """Reusing SSCAN results must not bypass the canonical-plus-alias stream cap.""" + client = _CompatibilityReadClient() + client.aliases = [f"activity:legacy-{index}" for index in range(1000)] + + with pytest.raises( + RuntimeError, + match="Activity history has too many retained compatibility streams", + ): + asyncio.run( + read_activity_events( + client, # type: ignore[arg-type] + "550E8400-E29B-41D4-A716-446655440000", + event_count=4, + ) + ) + + assert client.round_trips == 1 + assert client.pipeline_batch_sizes == [2] From 8ef3a42608739e5a32b5841e4a14494df5326a3f Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 6 Sep 2026 02:00:19 +0900 Subject: [PATCH 65/66] fix(activity): preserve reused alias fanout ceiling --- backend/app/activity_stream.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/backend/app/activity_stream.py b/backend/app/activity_stream.py index 0e158d332..a30e589e9 100644 --- a/backend/app/activity_stream.py +++ b/backend/app/activity_stream.py @@ -215,6 +215,8 @@ async def _activity_initial_uuid_read( return None candidate_keys = (canonical_key, *sorted(aliases)) stream_keys = tuple(dict.fromkeys(candidate_keys)) + if len(stream_keys) > _MAX_ACTIVITY_READ_COUNT: + raise RuntimeError("Activity history has too many retained compatibility streams") return canonical_entries, stream_keys[1:] From 61ed3a3712d252e3c179a71d297c52f05e1bac20 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 6 Sep 2026 02:00:51 +0900 Subject: [PATCH 66/66] docs(adr): preserve reused alias fanout ceiling --- ...0363-canonical-activity-stream-identity.md | 20 ++++++++++++++++--- 1 file changed, 17 insertions(+), 3 deletions(-) diff --git a/docs/adr/0363-canonical-activity-stream-identity.md b/docs/adr/0363-canonical-activity-stream-identity.md index 3518f6719..cb229092a 100644 --- a/docs/adr/0363-canonical-activity-stream-identity.md +++ b/docs/adr/0363-canonical-activity-stream-identity.md @@ -56,9 +56,11 @@ returns cursor zero, its alias members and paired canonical page are both reused by the same request. An empty member set completes the canonical-only response in that one exchange. A complete non-empty member set enters compatibility merge without scanning the alias index again or re-fetching the canonical page; -only retained alias pages are fetched in the next pipeline exchange. A nonzero -cursor falls back to the bounded compatibility iterator and never treats an -incomplete alias scan as complete history. +only retained alias pages are fetched in the next pipeline exchange. Reusing a +complete page does not bypass the existing fan-out ceiling: canonical stream plus +retained aliases may contain at most 1,000 distinct stream keys. A nonzero cursor +falls back to the bounded compatibility iterator and never treats an incomplete +alias scan as complete history. When aliases exist, production redis-py clients prefetch a bounded slice from every admitted stream before merging locally. The per-stream page size is @@ -83,6 +85,8 @@ output budget. bounded canonical window in one redis-py pipeline network exchange. - A complete retained-alias admission page is reused rather than discarded: the one-alias compatibility fixture now needs two network exchanges, not three. +- Reused admission pages fail closed above 1,000 distinct canonical-plus-alias + stream keys before any retained-alias fan-out is issued. - The retained-alias production path batches bounded history pages instead of issuing one network command per stream and one additional command per returned event. @@ -111,6 +115,9 @@ output budget. - Discard a complete non-empty first alias page and rescan it before compatibility merge: rejected because it adds a deterministic network exchange and repeats admission work without improving history correctness. +- Reuse a complete alias page without reapplying the total-stream ceiling: + rejected because 1,000 aliases plus the canonical stream would silently expand + request fan-out beyond the bounded contract formerly enforced by the iterator. - Fetch one compatibility entry per stream and then await one `XREVRANGE` per returned event: rejected because an authorized 50-event legacy-history panel still pays roughly one sequential Valkey wait per result. @@ -158,6 +165,13 @@ output budget. - Production repair `07fa552f9fecbe1cda79a9fccc7eeacd82c4fba5` carries a complete first alias page plus the canonical page into compatibility merge and fetches only retained alias pages in the second pipeline exchange. +- Follow-up review `5122123572` found that this reuse path initially skipped the + iterator's existing total-stream fan-out guard. +- RED `a9c080c1f2fa030bd2b0cec0e2fb6b43e4328bbc` supplies a complete first page + with 1,000 aliases and requires fail-closed behavior after the first two-command + exchange, before a retained-alias pipeline is issued. +- Production repair `8ef3a42608739e5a32b5841e4a14494df5326a3f` reapplies the 1,000 distinct + canonical-plus-alias stream ceiling to reused admission pages. - redis-py asyncio pipeline documentation specifies that pipeline commands are buffered and executed together when awaited through `execute()`; transactions remain optional and are not required for these independent read or set-write