From 0b6a57dc6c918435a3829bfbf942ce1d407847fc Mon Sep 17 00:00:00 2001 From: tegwick Date: Fri, 14 Aug 2026 00:34:09 +0200 Subject: [PATCH] Keep failed outbox events retryable --- src/user_engine/adapters/postgres.py | 3 ++- tests/test_postgres_store_adapter.py | 13 +++++++++++++ workplans/USER-WP-0021-portal-product-expansion.md | 8 ++++++++ 3 files changed, 23 insertions(+), 1 deletion(-) diff --git a/src/user_engine/adapters/postgres.py b/src/user_engine/adapters/postgres.py index 2b185ec..7be6900 100644 --- a/src/user_engine/adapters/postgres.py +++ b/src/user_engine/adapters/postgres.py @@ -419,7 +419,8 @@ class PostgresUserEngineStore: """ SELECT payload FROM user_engine_outbox_events - WHERE claimed_at IS NULL AND delivered_at IS NULL AND failed_at IS NULL + WHERE delivered_at IS NULL + AND COALESCE(payload->>'dead_lettered_at', '') = '' ORDER BY occurred_at, event_id """ ) diff --git a/tests/test_postgres_store_adapter.py b/tests/test_postgres_store_adapter.py index 20cb20a..ce009da 100644 --- a/tests/test_postgres_store_adapter.py +++ b/tests/test_postgres_store_adapter.py @@ -24,6 +24,17 @@ class PostgresStoreAdapterTests(unittest.TestCase): self.assertFalse(store.ready) self.assertIsNone(store.schema_version) + def test_pending_query_keeps_failed_non_dead_letter_events_retryable(self): + connection = _FakePostgresConnection() + store = PostgresUserEngineStore(connection) + + store.pending_outbox() + + normalized = " ".join(connection.last_sql.lower().split()) + self.assertIn("delivered_at is null", normalized) + self.assertIn("payload->>'dead_lettered_at'", normalized) + self.assertNotIn("failed_at is null", normalized) + class _FakePostgresConnection: def __init__(self) -> None: @@ -37,6 +48,7 @@ class _FakePostgresConnection: list[dict[str, Any]], list[dict[str, Any]], ] | None = None + self.last_sql = "" def cursor(self) -> "_FakePostgresCursor": return _FakePostgresCursor(self) @@ -70,6 +82,7 @@ class _FakePostgresCursor: self._rows: list[Any] = [] def execute(self, sql: str, params: Iterable[Any] | None = None) -> None: + self.connection.last_sql = sql normalized = " ".join(sql.lower().split()) values = tuple(params or ()) diff --git a/workplans/USER-WP-0021-portal-product-expansion.md b/workplans/USER-WP-0021-portal-product-expansion.md index 8e77172..5effa04 100644 --- a/workplans/USER-WP-0021-portal-product-expansion.md +++ b/workplans/USER-WP-0021-portal-product-expansion.md @@ -221,3 +221,11 @@ fails closed when its dedicated token is absent. Tests assert that the two Authorization headers differ. The full suite passes 132 tests with three external-provider skips. Production rollout still waits on governed delivery of both scoped credentials. + +2026-08-14 live retry finding: the PostgreSQL adapter excluded every row with +`failed_at` set, while the in-memory contract correctly retains failures until +`dead_lettered_at`. A first receiver error therefore required manual replay +instead of receiving the documented bounded automatic retry. The query now +selects every undelivered, non-dead-letter event; regression coverage checks +that failed rows are not filtered out. The full suite passes 133 tests with +three external-provider skips.