Skip to content

Commit da2653e

Browse files
committed
Add ConsumerSettleStrategy with DirectReplyTo support
Replace ConsumerBuilder.presettled(bool) with settle_strategy(), accepting EXPLICIT_SETTLE/PRESETTLED/DIRECT_REPLY_TO per the updated step_060_consumer_strategy.md spec. DIRECT_REPLY_TO attaches to RabbitMQ's direct-reply-to pseudo-queue with a dynamic source, resolves the broker-generated address after attach (re-resolving it on every reconnect, since it's session-scoped), and build() rejects combining it with queue() or single_active_consumer_state_changed(). Signed-off-by: Gabriele Santomaggio <G.santomaggio@gmail.com>
1 parent 489484e commit da2653e

12 files changed

Lines changed: 631 additions & 89 deletions

README.md

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -51,11 +51,11 @@ finally:
5151

5252
## Examples
5353

54-
[`docs/examples/README.md`](docs/examples/README.md) catalogues eleven runnable
54+
[`docs/examples/README.md`](docs/examples/README.md) catalogues twelve runnable
5555
scripts, in reading order — from `basic_connection.py` through publishing and
56-
consuming to auto-reconnection, presettled consumers, rejection reasons,
57-
single-active-consumer notifications and stream filtering. Each one talks to a
58-
local broker:
56+
consuming to auto-reconnection, presettled consumers, direct reply-to,
57+
rejection reasons, single-active-consumer notifications and stream filtering.
58+
Each one talks to a local broker:
5959

6060
```sh
6161
PYTHONPATH=. .venv/bin/python docs/examples/basic_connection.py

docs/examples/README.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,8 @@ one layer at a time, and the rest build on them.
2020
| [`auto_reconnection.py`](auto_reconnection.py) | A connection surviving a forced socket drop: publishers and consumers built before the drop keep working afterwards, and `RecoveryConfiguration(topology=True)` re-declares the topology the broker lost. |
2121
| [`console_application.py`](console_application.py) | The whole client end to end — declare, publish, consume, report — as a scriptable smoke test with meaningful exit codes. `--help` lists its options. |
2222
| [`performance_test.py`](performance_test.py) | A throughput/latency generator: many publishers and consumers on one or more connections, with periodic rate and latency reporting. |
23-
| [`presettled_consumer_example.py`](presettled_consumer_example.py) | At-most-once consumption: `presettled()` attaches with `snd-settle-mode = settled`, so the broker settles every delivery itself, the handler never touches its `Context`, and `unsettled_message_count` stays at `0` for the consumer's whole life — including across a forced disconnect that auto-reconnection recovers from. |
23+
| [`presettled_consumer_example.py`](presettled_consumer_example.py) | At-most-once consumption: `settle_strategy(ConsumerSettleStrategy.PRESETTLED)` attaches with `snd-settle-mode = settled`, so the broker settles every delivery itself, the handler never touches its `Context`, and `unsettled_message_count` stays at `0` for the consumer's whole life — including across a forced disconnect that auto-reconnection recovers from. |
24+
| [`direct_reply_to_example.py`](direct_reply_to_example.py) | Request/reply without a dedicated reply queue: `settle_strategy(ConsumerSettleStrategy.DIRECT_REPLY_TO)` attaches to no queue at all and reads back a broker-generated pseudo-queue address from `consumer.queue`; a request naming it as `properties.reply_to` gets answered by an ordinary queue-bound consumer publishing to that address. The pseudo-queue is session-scoped, so a forced disconnect and auto-reconnection leave the requester with a brand new address, which the script re-reads before sending its second request. |
2425
| [`rejection_reason_example.py`](rejection_reason_example.py) | Why a publish was refused: a quorum queue with `max_length(5)` and the `reject-publish` overflow strategy returns a `REJECTED` outcome carrying `RejectionDetails.reason` and `.rejected_by_queue` (needs RabbitMQ 4.3+; the script says so when the broker supplies neither). |
2526
| [`quorum_single_active_consumer.py`](quorum_single_active_consumer.py) | Single active consumer notifications on a quorum queue (needs RabbitMQ 4.3+): two consumers on a queue declared with `single_active_consumer(True)` each register a `single_active_consumer_state_changed` handler, and the broker tells one of them it is active and the other that it is standby. Only the active one receives messages; closing it promotes the standby one, which then starts receiving. |
2627
| [`stream_filtering.py`](stream_filtering.py) | Reading a stream queue from a chosen offset and narrowing what arrives (needs RabbitMQ 4.2+ for the SQL filter): the same batch of messages is read back three times from `offset(FIRST)` — once with `filter().subject(...).property("region", ...)`, once with the equivalent `filter().sql(...)`, and once with the cheap, probabilistic `filter_values(...)` bloom filter, whose publisher side is just an `x-stream-filter-value` message annotation. The script reports whether the broker really enforces the SQL filter. |

docs/examples/console_application.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -670,8 +670,8 @@ def build_consumer(connection: Connection, options: Options, counters: Counters)
670670
"""Attach the consumer that counts every delivery (§3.3).
671671
672672
Built before the publisher, so nothing this run publishes can arrive before
673-
something is already listening. ``initial_credits`` and ``presettled`` are
674-
left at ``ConsumerBuilder``'s own defaults.
673+
something is already listening. ``initial_credits`` and ``settle_strategy``
674+
are left at ``ConsumerBuilder``'s own defaults (``EXPLICIT_SETTLE``).
675675
676676
Args:
677677
connection: The connection to attach on.
Lines changed: 219 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,219 @@
1+
"""Direct reply-to: RabbitMQ's request/reply pseudo-queue (step_060_consumer_strategy.md §7).
2+
3+
Run against a local broker::
4+
5+
PYTHONPATH=. .venv/bin/python docs/examples/direct_reply_to_example.py
6+
7+
``consumer_builder().settle_strategy(ConsumerSettleStrategy.DIRECT_REPLY_TO)``
8+
attaches a receiver link to no caller-supplied queue at all: the broker
9+
dynamically generates a private, per-attach pseudo-queue address
10+
(``/queues/amq.rabbitmq.reply-to.<opaque-suffix>``) and returns it in the
11+
``attach`` reply, read back here as ``consumer.queue``. A caller who wants a
12+
reply puts that address in an outgoing request's ``properties.reply_to``;
13+
whoever answers just needs to publish to it — no dedicated, exclusive reply
14+
queue to declare and clean up per requester. Like a presettled consumer, its
15+
``Context`` methods all raise :class:`~src.ConsumerError`: the broker considers
16+
every delivery on this link already settled the instant it sends it.
17+
18+
This script plays both roles, on two separate connections:
19+
20+
* the **requester** (``requester_connection``) builds one ``DIRECT_REPLY_TO``
21+
consumer and never declares a queue of its own;
22+
* the **responder** (``responder_connection``) declares an ordinary request
23+
queue, consumes it with a normal ``ExplicitSettle`` consumer, and replies to
24+
whatever address arrived in the request's ``reply_to`` — an ordinary
25+
anonymous :class:`~src.Publisher` (step_020_publishers.md §3.3) also sends
26+
the request itself, targeting the request queue.
27+
28+
Because the pseudo-queue is scoped to the exact connection and session that
29+
attached it (§3.3 point 5), it does not survive a reconnect: the second half of
30+
this script forces the requester's socket down, lets auto-reconnection redial
31+
and re-attach, and shows ``consumer.queue`` reading back a *different*
32+
broker-generated address afterward — a caller must always re-read it after
33+
recovery, never cache it. Tearing down the live socket
34+
(``connection._socket.shutdown``) reaches into the client on purpose;
35+
application code never does this.
36+
"""
37+
38+
from __future__ import annotations
39+
40+
import logging
41+
import queue
42+
import socket
43+
import time
44+
import uuid
45+
46+
from src import (
47+
Connection,
48+
ConnectionParameters,
49+
ConnectionState,
50+
Consumer,
51+
ConsumerSettleStrategy,
52+
Context,
53+
Message,
54+
Publisher,
55+
RecoveryConfiguration,
56+
queue_address,
57+
)
58+
from src.wire import Properties
59+
60+
#: How long the example waits for a reply it expects.
61+
TIMEOUT_SECONDS = 15.0
62+
63+
#: How long it waits for auto-reconnection to finish.
64+
RECOVERY_TIMEOUT_SECONDS = 60.0
65+
66+
POLL_INTERVAL_SECONDS = 0.05
67+
68+
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)-7s %(name)s: %(message)s")
69+
logger = logging.getLogger("example")
70+
71+
72+
class ReplyBox:
73+
"""Records every reply the requester's direct-reply-to consumer receives."""
74+
75+
def __init__(self) -> None:
76+
"""Start with nothing received."""
77+
self.replies: queue.Queue[str] = queue.Queue()
78+
79+
def on_message(self, context: Context, message: Message) -> None:
80+
"""Log and record one reply, without touching ``context`` — it is presettled."""
81+
body = message.body_as_string()
82+
logger.info("requester received reply %r (presettled=%s)", body, context.is_presettled)
83+
self.replies.put(body)
84+
85+
def drain_one(self) -> str:
86+
"""Return the next reply body, or raise once the wait times out."""
87+
return self.replies.get(timeout=TIMEOUT_SECONDS)
88+
89+
90+
def wait_for(connection: Connection, state: ConnectionState, timeout: float) -> bool:
91+
"""Whether ``connection`` is seen in ``state`` within ``timeout`` seconds."""
92+
deadline = time.monotonic() + timeout
93+
while time.monotonic() < deadline:
94+
if connection.state is state:
95+
return True
96+
time.sleep(POLL_INTERVAL_SECONDS)
97+
return False
98+
99+
100+
def build_responder(connection: Connection, request_queue: str) -> Consumer:
101+
"""Attach the queue-bound consumer that answers every request with a reply."""
102+
103+
def on_request(context: Context, message: Message) -> None:
104+
reply_to = message.properties.reply_to if message.properties else None
105+
if not reply_to:
106+
logger.warning("a request with no reply_to arrived; discarding it")
107+
context.discard()
108+
return
109+
logger.info("responder received request %r, replying to %r", message.body_as_string(), reply_to)
110+
reply_publisher = connection.publisher_builder().build() # anonymous: reply_to is per-request
111+
try:
112+
reply_publisher.publish(
113+
Message(
114+
f"pong-for-{message.body_as_string()}",
115+
properties=Properties(
116+
to=reply_to,
117+
correlation_id=message.properties.message_id if message.properties else None,
118+
),
119+
),
120+
timeout=TIMEOUT_SECONDS,
121+
)
122+
finally:
123+
reply_publisher.close()
124+
context.accept()
125+
126+
return connection.consumer_builder().queue(request_queue).message_handler(on_request).build()
127+
128+
129+
def send_request(connection: Connection, request_queue: str, reply_to: str, body: str) -> None:
130+
"""Publish one request naming ``reply_to``, from a fresh anonymous publisher.
131+
132+
Anonymous (step_020_publishers.md §3.3) so each request can carry its own
133+
``to`` alongside its ``reply_to`` — a queue-bound publisher could not set
134+
the former, since its target is fixed at ``build()`` time.
135+
"""
136+
publisher: Publisher = connection.publisher_builder().build()
137+
try:
138+
publisher.publish(
139+
Message(
140+
body,
141+
properties=Properties(
142+
message_id=str(uuid.uuid4()),
143+
to=queue_address(request_queue),
144+
reply_to=reply_to,
145+
),
146+
),
147+
timeout=TIMEOUT_SECONDS,
148+
)
149+
finally:
150+
publisher.close()
151+
logger.info("requester sent %r to %r with reply_to=%r", body, request_queue, reply_to)
152+
153+
154+
def direct_reply_to() -> None:
155+
"""Round-trip a request/reply exchange over direct-reply-to, across a forced disconnect."""
156+
requester_connection = Connection(
157+
ConnectionParameters(
158+
container_id=f"example-direct-reply-to-requester-{uuid.uuid4().hex[:8]}",
159+
on_unexpected_close=lambda error: logger.error("the requester connection died for good: %s", error),
160+
recovery_configuration=RecoveryConfiguration(), # activated=True, topology=False
161+
)
162+
)
163+
responder_connection = Connection(
164+
ConnectionParameters(container_id=f"example-direct-reply-to-responder-{uuid.uuid4().hex[:8]}")
165+
)
166+
request_queue = f"example-direct-reply-to-{uuid.uuid4().hex[:8]}"
167+
reply_box = ReplyBox()
168+
try:
169+
responder_connection.management().queue(request_queue).declare()
170+
logger.info("declared the request queue %r", request_queue)
171+
172+
responder = build_responder(responder_connection, request_queue)
173+
requester = (
174+
requester_connection.consumer_builder()
175+
.message_handler(reply_box.on_message)
176+
.settle_strategy(ConsumerSettleStrategy.DIRECT_REPLY_TO)
177+
.build()
178+
)
179+
try:
180+
first_address = requester.queue
181+
assert first_address is not None, "build() only returns once DIRECT_REPLY_TO's address is resolved"
182+
logger.info("requester attached to the broker-generated pseudo-queue %r", first_address)
183+
send_request(responder_connection, request_queue, first_address, "hello")
184+
logger.info("requester received %r", reply_box.drain_one())
185+
186+
logger.info("simulating a network failure on the requester connection")
187+
requester_connection._socket.shutdown(socket.SHUT_RDWR)
188+
if not wait_for(requester_connection, ConnectionState.RECONNECTING, timeout=10.0):
189+
logger.warning("never observed RECONNECTING — detection and recovery both landed inside one poll")
190+
if not wait_for(requester_connection, ConnectionState.OPEN, RECOVERY_TIMEOUT_SECONDS):
191+
logger.error("the requester connection never recovered")
192+
return
193+
logger.info(
194+
"state is back to %s, the receiver link re-attached underneath the same Consumer object",
195+
requester_connection.state.value,
196+
)
197+
198+
second_address = requester.queue
199+
assert second_address is not None, "the re-attach must have resolved a fresh address"
200+
logger.info("requester re-attached to a fresh pseudo-queue %r", second_address)
201+
assert second_address != first_address, (
202+
"a direct-reply-to pseudo-queue is session-scoped and must not survive a reconnect"
203+
)
204+
send_request(responder_connection, request_queue, second_address, "hello-again")
205+
logger.info("requester received %r", reply_box.drain_one())
206+
finally:
207+
requester.close()
208+
responder.close()
209+
logger.info("closed the requester and the responder")
210+
211+
responder_connection.management().queue(request_queue).delete()
212+
logger.info("deleted the request queue %r", request_queue)
213+
finally:
214+
requester_connection.close()
215+
responder_connection.close()
216+
217+
218+
if __name__ == "__main__":
219+
direct_reply_to()

docs/examples/performance_test.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -984,9 +984,9 @@ def build_consumer(
984984
985985
Built before the publisher, so nothing this run publishes can arrive before
986986
something is already listening. ``initial_credits`` comes from
987-
``--initial-credits``; ``presettled`` is left at ``ConsumerBuilder``'s own
988-
default of ``False``, since a presettled run's latency would exclude the
989-
disposition round-trip this program means to measure (§10).
987+
``--initial-credits``; ``settle_strategy`` is left at ``ConsumerBuilder``'s
988+
own default of ``EXPLICIT_SETTLE``, since a presettled run's latency would
989+
exclude the disposition round-trip this program means to measure (§10).
990990
991991
Args:
992992
connection: The connection to attach on.

docs/examples/presettled_consumer_example.py

Lines changed: 22 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,15 @@
1-
"""Presettled consumption: at-most-once delivery with no dispositions (step_060 §6).
1+
"""Presettled consumption: at-most-once delivery with no dispositions (step_060_consumer_strategy.md §7).
22
33
Run against a local broker::
44
55
PYTHONPATH=. .venv/bin/python docs/examples/presettled_consumer_example.py
66
7-
``consumer_builder().presettled()`` attaches the receiver link with
8-
``snd-settle-mode = settled``. The broker then considers every delivery settled
9-
the moment it puts it on the wire: it forgets the message immediately, and the
10-
client never sends a ``disposition`` back. That is the whole trade — one frame
11-
per message instead of two, and no redelivery if the consumer dies holding one.
7+
``consumer_builder().settle_strategy(ConsumerSettleStrategy.PRESETTLED)`` attaches
8+
the receiver link with ``snd-settle-mode = settled``. The broker then considers
9+
every delivery settled the moment it puts it on the wire: it forgets the message
10+
immediately, and the client never sends a ``disposition`` back. That is the
11+
whole trade — one frame per message instead of two, and no redelivery if the
12+
consumer dies holding one.
1213
1314
Two consequences show up directly in the API and are asserted below:
1415
@@ -20,12 +21,13 @@
2021
* ``consumer.unsettled_message_count`` stays at ``0`` for the consumer's whole
2122
life, because nothing is ever outstanding.
2223
23-
The second half of the script is the reconnection requirement of step_060 §6:
24-
the socket is torn down underneath the connection, the default
25-
``RecoveryConfiguration`` redials and re-attaches both links, and the *same*
26-
publisher and consumer objects keep working — still presettled, still settling
27-
nothing. Tearing down the live socket (``connection._socket.shutdown``) reaches
28-
into the client on purpose; application code never does this.
24+
The second half of the script is the reconnection requirement of
25+
step_060_consumer_strategy.md §7: the socket is torn down underneath the
26+
connection, the default ``RecoveryConfiguration`` redials and re-attaches both
27+
links, and the *same* publisher and consumer objects keep working — still
28+
presettled, still settling nothing. Tearing down the live socket
29+
(``connection._socket.shutdown``) reaches into the client on purpose;
30+
application code never does this.
2931
"""
3032

3133
from __future__ import annotations
@@ -41,6 +43,7 @@
4143
ConnectionParameters,
4244
ConnectionState,
4345
Consumer,
46+
ConsumerSettleStrategy,
4447
Context,
4548
Message,
4649
Publisher,
@@ -117,7 +120,13 @@ def presettled_consumer() -> None:
117120
connection.management().queue(name).quorum().queue().declare()
118121
logger.info("declared the quorum queue %r", name)
119122

120-
consumer = connection.consumer_builder().queue(name).message_handler(tally.on_message).presettled().build()
123+
consumer = (
124+
connection.consumer_builder()
125+
.queue(name)
126+
.message_handler(tally.on_message)
127+
.settle_strategy(ConsumerSettleStrategy.PRESETTLED)
128+
.build()
129+
)
121130
logger.info("consuming from %r presettled=%s", consumer.queue, consumer.is_presettled)
122131

123132
publisher = connection.publisher_builder().queue(name).build()

src/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
from .consumer import (
1515
Consumer,
1616
ConsumerBuilder,
17+
ConsumerSettleStrategy,
1718
Context,
1819
MessageHandler,
1920
QuorumConsumerOptions,
@@ -157,6 +158,7 @@
157158
"RejectionDetails",
158159
"Consumer",
159160
"ConsumerBuilder",
161+
"ConsumerSettleStrategy",
160162
"Context",
161163
"MessageHandler",
162164
"QuorumConsumerOptions",

src/constants.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,9 @@
1616
# --- Single active consumer (quorum queues) ---
1717
RABBITMQ_ACTIVE_PROPERTY = "rabbitmq:active"
1818

19+
# --- Direct reply-to (step_060 §3.3) ---
20+
DIRECT_REPLY_TO_CAPABILITY = "rabbitmq:volatile-queue"
21+
1922
# --- Stream offset / filtering symbols (Source.filter map keys) ---
2023
STREAM_OFFSET_SPEC_FILTER = "rabbitmq:stream-offset-spec"
2124
STREAM_FILTER_VALUES_FILTER = "rabbitmq:stream-filter"

0 commit comments

Comments
 (0)