Skip to content

Commit 08da536

Browse files
authored
fix: align the fake client with the real one and return fetched rows oldest first (#233)
1 parent 60fc26b commit 08da536

5 files changed

Lines changed: 214 additions & 48 deletions

File tree

‎docs/usage/observability.md‎

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -158,9 +158,9 @@ publish-scope metrics do not fire in test mode. Middleware
158158
consume-scope metrics still fire, because handlers are still consumed
159159
through the normal middleware stack.
160160

161-
The recorder-seam `published` event provides synthetic publish-side
162-
coverage in test mode via `FakeOutboxProducer`. The synthetic events use
163-
`duration_seconds=0.0` since the in-memory client has no real write to
164-
time.
161+
The recorder-seam `published` event still fires in test mode, with the
162+
same tags as in production: `status="error"` and `exception_type` when
163+
encoding fails, and a measured `duration_seconds` for the in-memory
164+
insert.
165165

166166
`TestKafkaBroker` and `TestRabbitBroker` behave the same way, for the same reason.

‎faststream_outbox/client.py‎

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -141,6 +141,11 @@ async def validate_schema(self, *, check_autovacuum: bool = False) -> None: ...
141141
async def ping(self) -> bool: ...
142142

143143

144+
_NO_DLQ_TABLE_MSG = (
145+
"delete_with_lease received a dlq_payload but the client has no dlq_table; refusing to drop the audit row silently"
146+
)
147+
148+
144149
def _build_fetch_stmt(outbox: "Table") -> "Update":
145150
"""Build the claim statement once; queues, limit, lease TTL and the new token are bind parameters.
146151
@@ -261,7 +266,8 @@ async def fetch(
261266
async with conn.begin():
262267
result = await conn.execute(self._fetch_stmt, params)
263268
rows = result.mappings().all()
264-
return [_row_to_message(dict(row)) for row in rows]
269+
# UPDATE ... RETURNING has no ORDER BY, so the claimed rows come back in arbitrary order.
270+
return sorted((_row_to_message(dict(row)) for row in rows), key=lambda m: (m.next_attempt_at, m.id))
265271

266272
async def delete_with_lease(
267273
self,
@@ -312,10 +318,7 @@ async def delete_with_lease(
312318
# P10: a dlq_payload with no dlq_table would silently degrade to a plain
313319
# DELETE — losing the audit row. If the broker/client wiring ever desyncs,
314320
# fail loudly rather than drop forensics.
315-
msg = (
316-
"delete_with_lease received a dlq_payload but the client has no dlq_table; "
317-
"refusing to drop the audit row silently"
318-
)
321+
msg = _NO_DLQ_TABLE_MSG
319322
raise RuntimeError(msg)
320323
params = {
321324
"message_id": message_id,

‎faststream_outbox/testing.py‎

Lines changed: 75 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,8 @@
1212
"""
1313

1414
import datetime as dt
15+
import json
16+
import time
1517
import typing
1618
import uuid
1719
from contextlib import contextmanager
@@ -23,11 +25,11 @@
2325
from faststream_outbox._scheduling import resolve_next_attempt_client_side, validate_activate_args
2426
from faststream_outbox._time import utcnow
2527
from faststream_outbox.broker import OutboxBroker
26-
from faststream_outbox.client import AbstractOutboxClient
28+
from faststream_outbox.client import _NO_DLQ_TABLE_MSG, AbstractOutboxClient
2729
from faststream_outbox.envelope import _encode_payload
2830
from faststream_outbox.message import OutboxInnerMessage
2931
from faststream_outbox.metrics import _safe_emit
30-
from faststream_outbox.response import _REQUEST_UNSUPPORTED_MSG, OutboxPublishCommand
32+
from faststream_outbox.response import _MAX_QUEUE_LENGTH, _REQUEST_UNSUPPORTED_MSG, OutboxPublishCommand
3133
from faststream_outbox.schema import _DLQ_INJECTED_COLUMNS, _DLQ_PROJECTION
3234

3335

@@ -82,10 +84,30 @@ def _lease_token_matches(row_token: "uuid.UUID | None", claim_token: "uuid.UUID
8284
return claim_token is not None and row_token == claim_token
8385

8486

87+
def _validate_like_postgres(*, queue: str, timer_id: str | None, headers: dict[str, str] | None) -> None:
88+
"""Reject what the INSERT would fail on: over-long ``String(255)`` values and headers JSONB can't hold."""
89+
if len(queue) > _MAX_QUEUE_LENGTH:
90+
msg = f"queue must be at most {_MAX_QUEUE_LENGTH} characters (got {len(queue)})"
91+
raise ValueError(msg)
92+
if timer_id is not None and len(timer_id) > _MAX_QUEUE_LENGTH:
93+
msg = f"timer_id must be at most {_MAX_QUEUE_LENGTH} characters (got {len(timer_id)})"
94+
raise ValueError(msg)
95+
try:
96+
json.dumps(headers)
97+
except (TypeError, ValueError) as exc:
98+
msg = f"headers must be JSON-serializable: {exc}"
99+
raise ValueError(msg) from exc
100+
101+
85102
class FakeOutboxClient(AbstractOutboxClient):
86-
"""In-memory ``OutboxClient`` substitute. Same surface, list-of-rows storage."""
103+
"""In-memory ``OutboxClient`` substitute. Same surface, list-of-rows storage.
87104
88-
def __init__(self) -> None:
105+
``dlq_enabled=False`` mirrors an ``OutboxClient`` built without a ``dlq_table``: a
106+
``dlq_payload`` is refused instead of producing an audit row.
107+
"""
108+
109+
def __init__(self, *, dlq_enabled: bool = True) -> None:
110+
self._dlq_enabled = dlq_enabled
89111
self._rows: list[_FakeRow] = []
90112
self._next_id = 1
91113
# Populated when ``delete_with_lease`` receives a ``dlq_payload``. Mirrors
@@ -108,6 +130,7 @@ def feed(
108130
if next_attempt_at is not None and next_attempt_at.tzinfo is None:
109131
msg = "feed() requires next_attempt_at to be timezone-aware"
110132
raise ValueError(msg)
133+
_validate_like_postgres(queue=queue, timer_id=timer_id, headers=headers)
111134
# Mirror the real client's partial-unique-on-(queue, timer_id) behavior:
112135
# re-feeding a timer that already exists is a no-op.
113136
if timer_id is not None and any(r.queue == queue and r.timer_id == timer_id for r in self._rows):
@@ -185,6 +208,8 @@ async def delete_with_lease(
185208
for i, row in enumerate(self._rows):
186209
if row.id == message_id and _lease_token_matches(row.acquired_token, acquired_token):
187210
if dlq_payload is not None:
211+
if not self._dlq_enabled:
212+
raise RuntimeError(_NO_DLQ_TABLE_MSG)
188213
# Mirror the real CTE side-effect: DLQ row materializes in the same
189214
# call as the DELETE, before the row is removed. Built from the shared
190215
# _DLQ_PROJECTION so the fake can't drift from the real CTE; failed_at
@@ -336,19 +361,26 @@ async def _sync_dispatch(fake_client: FakeOutboxClient, broker: OutboxBroker, qu
336361
await subscriber.dispatch_one(_to_inner(fake_row))
337362

338363

339-
def _emit_published(broker: OutboxBroker, queue: str, *, count: int, size_bytes: int) -> None:
340-
"""Emit the producer-side ``published`` metric on the broker's recorder (test-broker parity)."""
341-
_safe_emit(
342-
broker.config.broker_config.metrics_recorder,
343-
"published",
344-
{
345-
"queue": queue,
346-
"status": "success",
347-
"count": count,
348-
"size_bytes": size_bytes,
349-
"duration_seconds": 0.0,
350-
},
351-
)
364+
def _emit_published(
365+
broker: OutboxBroker,
366+
queue: str,
367+
*,
368+
count: int,
369+
size_bytes: int,
370+
start_perf: float,
371+
exc: Exception | None = None,
372+
) -> None:
373+
"""Emit the producer-side ``published`` metric with the same tags as ``OutboxProducer``."""
374+
tags: dict[str, typing.Any] = {
375+
"queue": queue,
376+
"status": "success" if exc is None else "error",
377+
"count": count,
378+
"size_bytes": size_bytes,
379+
"duration_seconds": time.perf_counter() - start_perf,
380+
}
381+
if exc is not None:
382+
tags["exception_type"] = type(exc).__name__
383+
_safe_emit(broker.config.broker_config.metrics_recorder, "published", tags)
352384

353385

354386
def _notify_subscriber(broker: OutboxBroker, queue: str) -> None:
@@ -376,9 +408,18 @@ async def _fake_publish_one(
376408
run_loops: bool,
377409
) -> int | None:
378410
"""Shared single-row insert for ``FakeOutboxProducer.publish`` and the ``broker.publish`` patch (P29)."""
379-
payload, hdrs = _encode_payload(body, headers=headers, correlation_id=correlation_id, serializer=serializer)
380-
row_id = fake_client.feed(queue=queue, payload=payload, headers=hdrs, next_attempt_at=next_at, timer_id=timer_id)
381-
_emit_published(broker, queue, count=0 if row_id is None else 1, size_bytes=len(payload))
411+
start_perf = time.perf_counter()
412+
size_bytes = 0
413+
try:
414+
payload, hdrs = _encode_payload(body, headers=headers, correlation_id=correlation_id, serializer=serializer)
415+
size_bytes = len(payload)
416+
row_id = fake_client.feed(
417+
queue=queue, payload=payload, headers=hdrs, next_attempt_at=next_at, timer_id=timer_id
418+
)
419+
except Exception as exc:
420+
_emit_published(broker, queue, count=0, size_bytes=size_bytes, start_perf=start_perf, exc=exc)
421+
raise
422+
_emit_published(broker, queue, count=0 if row_id is None else 1, size_bytes=size_bytes, start_perf=start_perf)
382423
if run_loops:
383424
_notify_subscriber(broker, queue) # P30: the loops own dispatch; just wake them
384425
elif row_id is not None:
@@ -405,15 +446,24 @@ async def _fake_publish_many(
405446
(atomic batch INSERT -> published -> subscriber fetch), so a handler never observes a
406447
half-inserted batch and the event order isn't inverted.
407448
"""
449+
start_perf = time.perf_counter()
408450
total_size = 0
451+
encoded: list[tuple[bytes, dict[str, str] | None]] = []
452+
try:
453+
for body in bodies:
454+
payload, hdrs = _encode_payload(body, headers=headers, serializer=serializer)
455+
total_size += len(payload)
456+
_validate_like_postgres(queue=queue, timer_id=None, headers=hdrs)
457+
encoded.append((payload, hdrs))
458+
except Exception as exc:
459+
_emit_published(broker, queue, count=0, size_bytes=total_size, start_perf=start_perf, exc=exc)
460+
raise
409461
landed_ids: list[int] = []
410-
for body in bodies:
411-
payload, hdrs = _encode_payload(body, headers=headers, serializer=serializer)
412-
total_size += len(payload)
462+
for payload, hdrs in encoded:
413463
row_id = fake_client.feed(queue=queue, payload=payload, headers=hdrs, next_attempt_at=next_at)
414464
if row_id is not None:
415465
landed_ids.append(row_id)
416-
_emit_published(broker, queue, count=len(landed_ids), size_bytes=total_size)
466+
_emit_published(broker, queue, count=len(landed_ids), size_bytes=total_size, start_perf=start_perf)
417467
if run_loops:
418468
_notify_subscriber(broker, queue)
419469
else:
@@ -583,7 +633,7 @@ class TestOutboxBroker(TestBroker[OutboxBroker, OutboxBroker], broker=OutboxBrok
583633

584634
def __init__(self, broker: OutboxBroker, *, run_loops: bool = False, **kwargs: typing.Any) -> None:
585635
super().__init__(broker, **kwargs)
586-
self.fake_client = FakeOutboxClient()
636+
self.fake_client = FakeOutboxClient(dlq_enabled=broker.config.broker_config.dlq_table is not None)
587637
self.fake_producer = None # set when the producer slot is swapped (see _patch_producer)
588638
self.run_loops = run_loops
589639
self._outbox_broker = broker # for feed()'s P30 wakeup

‎tests/test_client_contract.py‎

Lines changed: 47 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -25,10 +25,11 @@
2525
authority stays a documented invariant (AGENTS.md), not an assertion here.
2626
"""
2727

28+
import contextlib
2829
import datetime as dt
2930
import os
3031
import uuid
31-
from collections.abc import AsyncIterator
32+
from collections.abc import AsyncGenerator, AsyncIterator
3233

3334
import pytest
3435
from sqlalchemy import MetaData, Table, insert, select, text
@@ -51,8 +52,8 @@ class _FakeHarness:
5152

5253
kind = "fake"
5354

54-
def __init__(self) -> None:
55-
self._client = FakeOutboxClient()
55+
def __init__(self, *, with_dlq: bool = True) -> None:
56+
self._client = FakeOutboxClient(dlq_enabled=with_dlq)
5657

5758
async def seed(
5859
self,
@@ -139,11 +140,11 @@ class _RealHarness:
139140

140141
kind = "real"
141142

142-
def __init__(self, engine: AsyncEngine, table: Table, dlq: Table) -> None:
143+
def __init__(self, engine: AsyncEngine, table: Table, dlq: Table, *, with_dlq: bool = True) -> None:
143144
self._engine = engine
144145
self._table = table
145146
self._dlq = dlq
146-
self._client = OutboxClient(engine, table, dlq_table=dlq)
147+
self._client = OutboxClient(engine, table, dlq_table=dlq if with_dlq else None)
147148

148149
async def seed(
149150
self,
@@ -237,11 +238,10 @@ async def dlq_rows(self) -> list[dict[str, object]]:
237238
_Harness = _FakeHarness | _RealHarness
238239

239240

240-
@pytest.fixture(params=["fake", "real"])
241-
async def contract(request: pytest.FixtureRequest) -> AsyncIterator[_Harness]:
242-
"""Yield a harness over each adapter. The ``real`` param auto-skips without Postgres."""
243-
if request.param == "fake":
244-
yield _FakeHarness()
241+
@contextlib.asynccontextmanager
242+
async def _harness(kind: str, *, with_dlq: bool) -> AsyncGenerator[_Harness]:
243+
if kind == "fake":
244+
yield _FakeHarness(with_dlq=with_dlq)
245245
return
246246
engine = create_async_engine(PG_DSN, future=True)
247247
try:
@@ -257,13 +257,27 @@ async def contract(request: pytest.FixtureRequest) -> AsyncIterator[_Harness]:
257257
async with engine.begin() as conn:
258258
await conn.run_sync(metadata.create_all)
259259
try:
260-
yield _RealHarness(engine, table, dlq)
260+
yield _RealHarness(engine, table, dlq, with_dlq=with_dlq)
261261
finally:
262262
async with engine.begin() as conn:
263263
await conn.run_sync(metadata.drop_all)
264264
await engine.dispose()
265265

266266

267+
@pytest.fixture(params=["fake", "real"])
268+
async def contract(request: pytest.FixtureRequest) -> AsyncIterator[_Harness]:
269+
"""Yield a harness over each adapter. The ``real`` param auto-skips without Postgres."""
270+
async with _harness(request.param, with_dlq=True) as harness:
271+
yield harness
272+
273+
274+
@pytest.fixture(params=["fake", "real"])
275+
async def contract_without_dlq(request: pytest.FixtureRequest) -> AsyncIterator[_Harness]:
276+
"""Like ``contract``, but the client has no DLQ table."""
277+
async with _harness(request.param, with_dlq=False) as harness:
278+
yield harness
279+
280+
267281
# --- fetch -----------------------------------------------------------------
268282

269283

@@ -304,10 +318,20 @@ async def test_fetch_skips_fresh_lease(contract: _Harness) -> None:
304318
assert msgs == []
305319

306320

321+
async def test_fetch_returns_rows_oldest_first(contract: _Harness) -> None:
322+
rids = [await contract.seed(queue="orders", next_attempt_offset=-float(i)) for i in range(1, 21)]
323+
rids += [await contract.seed(queue="audit", next_attempt_offset=-float(i) - 0.5) for i in range(1, 21)]
324+
rids.append(
325+
await contract.seed(
326+
queue="orders", next_attempt_offset=-0.25, leased_token=uuid.uuid4(), acquired_age=120.0, deliveries_count=1
327+
)
328+
)
329+
msgs = await contract.fetch(["orders", "audit"], limit=41, lease_ttl_seconds=60.0)
330+
assert sorted(m.id for m in msgs) == sorted(rids)
331+
assert msgs == sorted(msgs, key=lambda m: (m.next_attempt_at, m.id))
332+
333+
307334
async def test_fetch_selects_oldest_under_limit(contract: _Harness) -> None:
308-
# FIFO *selection* under contention is the contract; within-batch *return order*
309-
# is unspecified for the real client (F2-09 — the outer UPDATE…RETURNING is
310-
# unordered), so assert on the chosen set, not its order.
311335
rid_a = await contract.seed(queue="orders", next_attempt_offset=-10.0)
312336
rid_b = await contract.seed(queue="orders", next_attempt_offset=-20.0)
313337
rid_c = await contract.seed(queue="orders", next_attempt_offset=-30.0)
@@ -386,6 +410,15 @@ async def test_delete_noop_on_unleased_row(contract: _Harness) -> None:
386410
assert await contract.get_row(rid) is not None
387411

388412

413+
async def test_delete_with_dlq_payload_but_no_dlq_table_refuses(contract_without_dlq: _Harness) -> None:
414+
token = uuid.uuid4()
415+
rid = await contract_without_dlq.seed(queue="orders", leased_token=token, acquired_age=1.0, deliveries_count=1)
416+
with pytest.raises(RuntimeError, match="no dlq_table"):
417+
await contract_without_dlq.delete(rid, token, dlq_payload={"failure_reason": "boom", "last_exception": None})
418+
assert await contract_without_dlq.get_row(rid) is not None
419+
assert await contract_without_dlq.dlq_rows() == []
420+
421+
389422
async def test_delete_with_dlq_materializes_audit_row(contract: _Harness) -> None:
390423
"""INVARIANT: the DLQ archive is written atomically with the delete, in both adapters.
391424

0 commit comments

Comments
 (0)