1212"""
1313
1414import datetime as dt
15+ import json
16+ import time
1517import typing
1618import uuid
1719from contextlib import contextmanager
2325from faststream_outbox ._scheduling import resolve_next_attempt_client_side , validate_activate_args
2426from faststream_outbox ._time import utcnow
2527from faststream_outbox .broker import OutboxBroker
26- from faststream_outbox .client import AbstractOutboxClient
28+ from faststream_outbox .client import _NO_DLQ_TABLE_MSG , AbstractOutboxClient
2729from faststream_outbox .envelope import _encode_payload
2830from faststream_outbox .message import OutboxInnerMessage
2931from 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
3133from 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+
85102class 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
354386def _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
0 commit comments