taskqueue-toolkit 0.2.0__py3-none-any.whl → 0.3.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,36 @@
1
+ from __future__ import annotations
2
+
3
+ FIRST_DELIVERY = 1
4
+ """QueuedTask.delivery_count for a message being seen for the first time.
5
+
6
+ The protocol counts deliveries starting at 1, so this is both the floor and
7
+ the value to fall back on whenever a broker's own counter is missing or
8
+ unreadable — see QueuedTask.delivery_count.
9
+ """
10
+
11
+
12
+ def delivery_count_from(raw: object, *, counts_redeliveries: bool = False) -> int:
13
+ """Normalize a broker's own counter onto the protocol's convention.
14
+
15
+ Brokers disagree on what they count. SQS's ApproximateReceiveCount,
16
+ Pub/Sub's delivery_attempt and Redis Streams' times_delivered all count
17
+ *deliveries* starting at 1, which is the convention as-is. RabbitMQ's
18
+ x-delivery-count instead counts *re*deliveries — absent on the first
19
+ delivery, 1 on the second — so pass counts_redeliveries=True to shift
20
+ it by one.
21
+
22
+ A counter that is missing, unparseable, or below its floor reports a
23
+ first delivery: these values only ever gate a consumer's decision to
24
+ stop retrying, so a bad reading should cost one extra attempt rather
25
+ than crash the consumer or silently drop the task.
26
+ """
27
+ floor = 0 if counts_redeliveries else FIRST_DELIVERY
28
+ offset = FIRST_DELIVERY if counts_redeliveries else 0
29
+
30
+ if raw is None or isinstance(raw, bool) or not isinstance(raw, int | float | str):
31
+ return FIRST_DELIVERY
32
+ try:
33
+ count = int(raw)
34
+ except ValueError:
35
+ return FIRST_DELIVERY
36
+ return max(count, floor) + offset
@@ -12,6 +12,18 @@ class UnsupportedDsnSchemeError(Exception):
12
12
  )
13
13
 
14
14
 
15
+ _DEFAULT_QUEUE_TYPE = "quorum"
16
+ _VALID_QUEUE_TYPES = ("quorum", "classic")
17
+
18
+
19
+ class UnsupportedQueueTypeError(Exception):
20
+ def __init__(self, queue_type: str) -> None:
21
+ super().__init__(
22
+ f"Unsupported RabbitMQ queue type: {queue_type!r} "
23
+ f"(expected one of {', '.join(_VALID_QUEUE_TYPES)})"
24
+ )
25
+
26
+
15
27
  def _query(raw_query: str) -> dict[str, str]:
16
28
  return {key: values[0] for key, values in parse_qs(raw_query).items()}
17
29
 
@@ -20,6 +32,15 @@ def _query(raw_query: str) -> dict[str, str]:
20
32
  class RabbitMqDsn:
21
33
  url: str
22
34
  queue_name: str
35
+ # "quorum" (the default) or "classic". Quorum is RabbitMQ's recommended
36
+ # type for task workloads and the only one that maintains the
37
+ # x-delivery-count header QueuedTask.delivery_count reads — under
38
+ # "classic", delivery_count always reports 1. The escape hatch exists
39
+ # because a queue's type cannot be changed in place: pointing this
40
+ # library at an existing classic queue fails with PRECONDITION_FAILED
41
+ # unless you either declare classic here or delete and recreate the
42
+ # queue.
43
+ queue_type: str = _DEFAULT_QUEUE_TYPE
23
44
 
24
45
 
25
46
  @dataclass(frozen=True, slots=True)
@@ -77,6 +98,7 @@ def parse_task_queue_dsn(dsn: str) -> TaskQueueDsn:
77
98
 
78
99
  Examples:
79
100
  amqp://guest:guest@host:5672/?queue=my.tasks
101
+ amqp://guest:guest@host:5672/?queue=my.tasks&queue_type=classic
80
102
  redis://:password@host:6379/0?stream=my.tasks&group=workers&consumer=worker-1
81
103
  sqs://eu-west-3/my.tasks?endpoint_url=...&access_key_id=...&secret_access_key=...
82
104
  sns://eu-west-3/my-tasks?endpoint_url=...&access_key_id=...&secret_access_key=...
@@ -86,9 +108,13 @@ def parse_task_queue_dsn(dsn: str) -> TaskQueueDsn:
86
108
  query = _query(parts.query)
87
109
 
88
110
  if parts.scheme in ("amqp", "amqps"):
111
+ queue_type = query.get("queue_type", _DEFAULT_QUEUE_TYPE)
112
+ if queue_type not in _VALID_QUEUE_TYPES:
113
+ raise UnsupportedQueueTypeError(queue_type)
89
114
  return RabbitMqDsn(
90
115
  url=dsn.split("?", 1)[0],
91
116
  queue_name=query.get("queue", _DEFAULT_QUEUE_NAME),
117
+ queue_type=queue_type,
92
118
  )
93
119
 
94
120
  if parts.scheme in ("redis", "rediss"):
@@ -1,5 +1,7 @@
1
1
  from __future__ import annotations
2
2
 
3
+ from collections.abc import Iterator
4
+ from contextlib import contextmanager
3
5
  from typing import assert_never
4
6
  from urllib.parse import urlsplit
5
7
 
@@ -13,16 +15,46 @@ from taskqueue_toolkit.queue.dsn import (
13
15
  missing_production_aws_auth,
14
16
  parse_task_queue_dsn,
15
17
  )
16
- from taskqueue_toolkit.queue.pubsub import PubsubTaskQueue
17
- from taskqueue_toolkit.queue.rabbitmq import RabbitMqTaskQueue
18
- from taskqueue_toolkit.queue.redis_streams import RedisStreamsTaskQueue
19
18
  from taskqueue_toolkit.queue.registry import resolve_registered_scheme
20
- from taskqueue_toolkit.queue.sns import SnsTaskQueue
21
- from taskqueue_toolkit.queue.sqs import SqsTaskQueue
22
19
  from taskqueue_toolkit.queue.task_queue import Decoder, Encoder, TaskQueue
23
20
 
24
21
  _BUILT_IN_SCHEMES = frozenset({"amqp", "amqps", "redis", "rediss", "sqs", "sns", "pubsub"})
25
22
 
23
+ # Which extra installs the SDK each built-in adapter imports at module
24
+ # level, for the error message when it's missing.
25
+ _EXTRA_FOR_SCHEME = {
26
+ "amqp": "rabbitmq",
27
+ "redis": "redis-streams",
28
+ "sqs": "aws",
29
+ "sns": "aws",
30
+ "pubsub": "pubsub",
31
+ }
32
+
33
+
34
+ class MissingBrokerExtraError(Exception):
35
+ def __init__(self, scheme: str, extra: str) -> None:
36
+ super().__init__(
37
+ f"the {scheme!r} DSN scheme needs a broker SDK that isn't "
38
+ f"installed — install it with: pip install "
39
+ f"'taskqueue-toolkit[{extra}]'"
40
+ )
41
+
42
+
43
+ @contextmanager
44
+ def _adapter_import(scheme: str) -> Iterator[None]:
45
+ """Turn a missing broker SDK into an actionable install instruction.
46
+
47
+ Adapters are imported inside create_task_queue() rather than at module
48
+ level so that installing one extra is enough to use that one broker —
49
+ a top-level import of all five would make every extra mandatory,
50
+ including for callers who only plug in their own broker through
51
+ register_scheme().
52
+ """
53
+ try:
54
+ yield
55
+ except ImportError as exc:
56
+ raise MissingBrokerExtraError(scheme, _EXTRA_FOR_SCHEME[scheme]) from exc
57
+
26
58
 
27
59
  class MissingAwsAuthError(Exception):
28
60
  def __init__(self, environment: str) -> None:
@@ -81,14 +113,24 @@ def create_task_queue[T](
81
113
  raise MissingAwsAuthError(environment)
82
114
 
83
115
  if isinstance(parsed, RabbitMqDsn):
116
+ with _adapter_import("amqp"):
117
+ from taskqueue_toolkit.queue.rabbitmq import RabbitMqTaskQueue
84
118
  return RabbitMqTaskQueue(dsn=parsed, encode=encode, decode=decode)
85
119
  if isinstance(parsed, RedisStreamsDsn):
120
+ with _adapter_import("redis"):
121
+ from taskqueue_toolkit.queue.redis_streams import RedisStreamsTaskQueue
86
122
  return RedisStreamsTaskQueue(dsn=parsed, encode=encode, decode=decode)
87
123
  if isinstance(parsed, SqsDsn):
124
+ with _adapter_import("sqs"):
125
+ from taskqueue_toolkit.queue.sqs import SqsTaskQueue
88
126
  return SqsTaskQueue(dsn=parsed, encode=encode, decode=decode)
89
127
  if isinstance(parsed, SnsDsn):
128
+ with _adapter_import("sns"):
129
+ from taskqueue_toolkit.queue.sns import SnsTaskQueue
90
130
  return SnsTaskQueue(dsn=parsed, encode=encode, decode=decode)
91
131
  if isinstance(parsed, PubsubDsn):
132
+ with _adapter_import("pubsub"):
133
+ from taskqueue_toolkit.queue.pubsub import PubsubTaskQueue
92
134
  return PubsubTaskQueue(dsn=parsed, encode=encode, decode=decode)
93
135
 
94
136
  assert_never(parsed)
@@ -4,12 +4,10 @@ import asyncio
4
4
  import logging
5
5
  import os
6
6
  from collections.abc import AsyncIterator, Callable
7
- from contextlib import suppress
8
7
  from dataclasses import dataclass, field
8
+ from typing import Any, Protocol, cast
9
9
 
10
- from google.api_core.exceptions import AlreadyExists
11
- from google.cloud import pubsub_v1
12
-
10
+ from taskqueue_toolkit.queue.delivery import delivery_count_from
13
11
  from taskqueue_toolkit.queue.dsn import PubsubDsn
14
12
  from taskqueue_toolkit.queue.task_queue import Decoder, Encoder
15
13
 
@@ -17,17 +15,93 @@ logger = logging.getLogger(__name__)
17
15
 
18
16
  _PULL_TIMEOUT_SECONDS = 10.0
19
17
 
20
- PublisherFactory = Callable[[], "pubsub_v1.PublisherClient"]
21
- SubscriberFactory = Callable[[], "pubsub_v1.SubscriberClient"]
18
+
19
+ class MessageSettler(Protocol):
20
+ """The two Pub/Sub calls needed to settle a pulled message.
21
+
22
+ Synchronous, like the official gRPC client — PubsubQueuedTask calls
23
+ these through asyncio.to_thread. Split out from Subscriber because a
24
+ received task holds only this much, not the whole client.
25
+ """
26
+
27
+ def acknowledge(self, *, subscription: str, ack_ids: list[str]) -> Any: ...
28
+
29
+ def modify_ack_deadline(
30
+ self, *, subscription: str, ack_ids: list[str], ack_deadline_seconds: int
31
+ ) -> Any: ...
32
+
33
+
34
+ class Publisher(Protocol):
35
+ """A Pub/Sub publisher, narrowed to the three calls this adapter makes.
36
+
37
+ Synchronous, like the official gRPC client — the adapter drives these
38
+ through asyncio.to_thread.
39
+ """
40
+
41
+ def topic_path(self, project: str, topic: str) -> Any: ...
42
+
43
+ def create_topic(self, *, name: str) -> Any: ...
44
+
45
+ def publish(self, topic_path: str, data: bytes) -> Any: ...
46
+
47
+
48
+ class Subscriber(MessageSettler, Protocol):
49
+ """A Pub/Sub subscriber: settling a message, plus provisioning and
50
+ pulling from the subscription."""
51
+
52
+ def subscription_path(self, project: str, subscription: str) -> Any: ...
53
+
54
+ def create_subscription(self, *, name: str, topic: str) -> Any: ...
55
+
56
+ # Names the three parameters the adapter actually passes, plus
57
+ # **kwargs so a client taking more than these still qualifies.
58
+ def pull(
59
+ self, *, subscription: str, max_messages: int = ..., timeout: float = ..., **kwargs: Any
60
+ ) -> Any: ...
61
+
62
+
63
+ PublisherFactory = Callable[[], Publisher]
64
+ SubscriberFactory = Callable[[], Subscriber]
65
+
66
+
67
+ def _default_publisher() -> Publisher:
68
+ """Default PublisherFactory: the official SDK's publisher client.
69
+
70
+ google-cloud-pubsub is imported here rather than at module level so the
71
+ adapter carries no import-time dependency on it — a caller injecting
72
+ their own factories needs neither the SDK nor its credentials.
73
+ """
74
+ from google.cloud import pubsub_v1
75
+
76
+ return cast("Publisher", pubsub_v1.PublisherClient())
77
+
78
+
79
+ def _default_subscriber() -> Subscriber:
80
+ """Default SubscriberFactory: the official SDK's subscriber client."""
81
+ from google.cloud import pubsub_v1
82
+
83
+ return cast("Subscriber", pubsub_v1.SubscriberClient())
84
+
85
+
86
+ def _is_already_exists(exc: BaseException) -> bool:
87
+ """Whether an exception means "this topic/subscription already exists".
88
+
89
+ Matched by class name rather than catching
90
+ google.api_core.exceptions.AlreadyExists directly: importing that class
91
+ would put a google-cloud-pubsub import back at module level, which is
92
+ the one thing the injectable factories exist to avoid.
93
+ """
94
+ return type(exc).__name__ == "AlreadyExists"
22
95
 
23
96
 
24
97
  @dataclass(slots=True)
25
98
  class PubsubQueuedTask[T]:
26
- _subscriber: pubsub_v1.SubscriberClient
99
+ task: T
100
+ delivery_count: int
101
+ # Settling identifies the message by the ack id it was pulled with.
102
+ _subscriber: MessageSettler
27
103
  _subscription_path: str
28
104
  _ack_id: str
29
- delivery_count: int
30
- task: T
31
105
 
32
106
  async def ack(self) -> None:
33
107
  await asyncio.to_thread(
@@ -75,10 +149,10 @@ class PubsubTaskQueue[T]:
75
149
  decode: Decoder[T]
76
150
  # Overridable for tests (inject fake clients) or a shared client the
77
151
  # caller already manages; each defaults to the real SDK client.
78
- publisher_factory: PublisherFactory = field(default=pubsub_v1.PublisherClient)
79
- subscriber_factory: SubscriberFactory = field(default=pubsub_v1.SubscriberClient)
80
- _publisher_client: pubsub_v1.PublisherClient | None = field(default=None, init=False)
81
- _subscriber_client: pubsub_v1.SubscriberClient | None = field(default=None, init=False)
152
+ publisher_factory: PublisherFactory = field(default=_default_publisher)
153
+ subscriber_factory: SubscriberFactory = field(default=_default_subscriber)
154
+ _publisher_client: Publisher | None = field(default=None, init=False)
155
+ _subscriber_client: Subscriber | None = field(default=None, init=False)
82
156
 
83
157
  def _apply_emulator_host(self) -> None:
84
158
  # The official client only knows to target an emulator via this env
@@ -91,7 +165,7 @@ class PubsubTaskQueue[T]:
91
165
  os.environ["PUBSUB_EMULATOR_HOST"] = self.dsn.emulator_host
92
166
 
93
167
  @property
94
- def _publisher(self) -> pubsub_v1.PublisherClient:
168
+ def _publisher(self) -> Publisher:
95
169
  # Built lazily, not at __init__ time (e.g. via default_factory) —
96
170
  # constructing the client eagerly authenticates against GCP
97
171
  # immediately, which fails outside an environment with real or
@@ -102,7 +176,7 @@ class PubsubTaskQueue[T]:
102
176
  return self._publisher_client
103
177
 
104
178
  @property
105
- def _subscriber(self) -> pubsub_v1.SubscriberClient:
179
+ def _subscriber(self) -> Subscriber:
106
180
  if self._subscriber_client is None:
107
181
  self._apply_emulator_host()
108
182
  self._subscriber_client = self.subscriber_factory()
@@ -118,16 +192,22 @@ class PubsubTaskQueue[T]:
118
192
 
119
193
  async def _ensure_topic(self) -> str:
120
194
  topic_path = self._topic_path()
121
- with suppress(AlreadyExists):
195
+ try:
122
196
  await asyncio.to_thread(self._publisher.create_topic, name=topic_path)
197
+ except Exception as exc:
198
+ if not _is_already_exists(exc):
199
+ raise
123
200
  return topic_path
124
201
 
125
202
  async def _ensure_subscription(self, topic_path: str) -> str:
126
203
  subscription_path = self._subscription_path()
127
- with suppress(AlreadyExists):
204
+ try:
128
205
  await asyncio.to_thread(
129
206
  self._subscriber.create_subscription, name=subscription_path, topic=topic_path
130
207
  )
208
+ except Exception as exc:
209
+ if not _is_already_exists(exc):
210
+ raise
131
211
  return subscription_path
132
212
 
133
213
  async def publish(self, task: T) -> None:
@@ -155,16 +235,17 @@ class PubsubTaskQueue[T]:
155
235
  timeout=_PULL_TIMEOUT_SECONDS,
156
236
  )
157
237
  for received in response.received_messages:
158
- # delivery_attempt is only populated when the subscription
159
- # has a dead-letter policy configured — absent (None)
160
- # otherwise, in which case this reports 1 rather than
161
- # silently misreporting every message as never-redelivered
162
- # forever.
163
- delivery_count = received.delivery_attempt or 1
238
+ # delivery_attempt counts deliveries from 1, matching
239
+ # QueuedTask.delivery_count directly but Pub/Sub only
240
+ # populates it when the subscription has a dead-letter
241
+ # policy. Without one it stays absent and this always
242
+ # reports a first delivery, so a consumer relying on it to
243
+ # stop retrying would retry forever.
244
+ delivery_count = delivery_count_from(getattr(received, "delivery_attempt", None))
164
245
  yield PubsubQueuedTask(
246
+ task=self.decode(received.message.data),
247
+ delivery_count=delivery_count,
165
248
  _subscriber=self._subscriber,
166
249
  _subscription_path=subscription_path,
167
250
  _ack_id=received.ack_id,
168
- delivery_count=delivery_count,
169
- task=self.decode(received.message.data),
170
251
  )
@@ -1,47 +1,142 @@
1
1
  from __future__ import annotations
2
2
 
3
3
  import logging
4
- from collections.abc import AsyncIterator, Awaitable, Callable
4
+ from collections.abc import AsyncIterator, Awaitable, Callable, Mapping
5
5
  from contextlib import suppress
6
6
  from dataclasses import dataclass, field
7
+ from typing import Any, Protocol
7
8
 
8
- import aio_pika
9
- from pamqp.common import FieldTable
10
-
9
+ from taskqueue_toolkit.queue.delivery import delivery_count_from
11
10
  from taskqueue_toolkit.queue.dsn import RabbitMqDsn
12
11
  from taskqueue_toolkit.queue.task_queue import Decoder, Encoder
13
12
 
14
13
  logger = logging.getLogger(__name__)
15
14
 
16
- Connect = Callable[[str], Awaitable["aio_pika.abc.AbstractRobustConnection"]]
15
+ # Structurally what pamqp calls a FieldTable (AMQP queue arguments),
16
+ # spelled out rather than imported so this module carries no import-time
17
+ # dependency on a RabbitMQ SDK — see _connect_robust for the rest of that
18
+ # reasoning.
19
+ FieldTable = Mapping[str, Any]
20
+
21
+
22
+ class Deliverable(Protocol):
23
+ """An incoming message, as this adapter reads it."""
17
24
 
25
+ @property
26
+ def body(self) -> bytes: ...
27
+
28
+ @property
29
+ def headers(self) -> Mapping[str, Any] | None: ...
18
30
 
19
- def _delivery_count(message: aio_pika.abc.AbstractIncomingMessage) -> int:
20
- """1 on first delivery, incremented on every redelivery (a
21
- nack(requeue=True), a crashed/disconnected consumer, ...). Sourced from
22
- x-delivery-count, which RabbitMQ maintains natively for quorum queues —
23
- see the queue declaration in RabbitMqTaskQueue for why the queue type
24
- matters here (classic queues don't track this at all; only a
25
- dead-letter hop shows up there, via x-death, which a plain requeue never
26
- triggers)."""
27
- headers = message.headers or {}
28
- count = headers.get("x-delivery-count")
29
- if count is None:
30
- return 1
31
- # RabbitMQ always sends this header as a long-int field value — the
32
- # broad FieldValue union is what the type carries at rest, not what
33
- # actually arrives here.
34
- return 1 + int(count) # type: ignore[arg-type]
31
+ async def ack(self) -> None: ...
32
+
33
+ async def nack(self, *, requeue: bool = True) -> None: ...
34
+
35
+
36
+ class Consumable(Protocol):
37
+ """A declared queue, iterated to receive messages."""
38
+
39
+ @property
40
+ def name(self) -> str: ...
41
+
42
+ def iterator(self) -> AsyncIterator[Deliverable]: ...
43
+
44
+
45
+ class Publishable(Protocol):
46
+ """An exchange, as this adapter publishes through it.
47
+
48
+ Takes a message object the caller builds — see MessageFactory for why
49
+ that object isn't constructed here.
50
+ """
51
+
52
+ async def publish(self, message: Any, *, routing_key: str) -> Any: ...
53
+
54
+
55
+ # Builds whatever message type the injected client publishes: (body,
56
+ # persistent) -> that client's message object.
57
+ #
58
+ # Kept a parameter rather than calling aio_pika.Message() inline so a
59
+ # different RabbitMQ client (pika, aiormq, ...) can be injected through
60
+ # connect= without also receiving an aio_pika object it can't publish.
61
+ MessageFactory = Callable[[bytes, bool], Any]
62
+
63
+
64
+ class Channel(Protocol):
65
+ """A channel, narrowed to the three members this adapter uses.
66
+
67
+ Signatures stay permissive (positional-or-keyword, *args/**kwargs)
68
+ because real clients accept more parameters here than this adapter ever
69
+ passes — a signature narrowed to just our call sites would reject them.
70
+ """
71
+
72
+ @property
73
+ def default_exchange(self) -> Any: ...
74
+
75
+ def set_qos(self, prefetch_count: int = ..., *args: Any, **kwargs: Any) -> Any: ...
76
+
77
+ def declare_queue(
78
+ self, name: Any = ..., *args: Any, **kwargs: Any
79
+ ) -> Awaitable[Consumable]: ...
80
+
81
+
82
+ class Connection(Protocol):
83
+ """A broker connection, narrowed to what this adapter uses.
84
+
85
+ Declaring the chain structurally rather than borrowing aio_pika's own
86
+ types keeps the dependency to the handful of calls actually made, and
87
+ lets the in-memory double in taskqueue_toolkit.testing satisfy it
88
+ honestly instead of being cast to an SDK interface it doesn't
89
+ implement.
90
+ """
91
+
92
+ # Two deliberate looseneses so real clients qualify. Not `async def`:
93
+ # aio_pika's channel() is a sync call returning an awaitable, and both
94
+ # shapes are awaited identically at the call site. And *args/**kwargs:
95
+ # clients take extra optional parameters here (channel number,
96
+ # publisher confirms, ...) that this adapter never passes, but a
97
+ # narrower signature would reject them.
98
+ def channel(self, *args: Any, **kwargs: Any) -> Awaitable[Channel]: ...
99
+
100
+ async def close(self) -> Any: ...
101
+
102
+
103
+ Connect = Callable[[str], Awaitable[Connection]]
104
+
105
+
106
+ async def _connect_robust(url: str) -> Connection:
107
+ """Default Connect: aio_pika's own robust (auto-reconnecting) connection.
108
+
109
+ Wrapped rather than referenced directly for two reasons: connect_robust
110
+ is an overloaded function, which doesn't match a plain Callable alias;
111
+ and importing aio_pika here rather than at module level keeps the whole
112
+ adapter usable with a different RabbitMQ client, without aio-pika
113
+ installed at all.
114
+ """
115
+ import aio_pika
116
+
117
+ return await aio_pika.connect_robust(url)
118
+
119
+
120
+ def _aio_pika_message(body: bytes, persistent: bool) -> Any:
121
+ """Default MessageFactory: aio_pika's own message type."""
122
+ import aio_pika
123
+
124
+ return aio_pika.Message(
125
+ body=body,
126
+ delivery_mode=(
127
+ aio_pika.DeliveryMode.PERSISTENT if persistent else aio_pika.DeliveryMode.NOT_PERSISTENT
128
+ ),
129
+ )
35
130
 
36
131
 
37
132
  @dataclass(slots=True)
38
133
  class RabbitMqQueuedTask[T]:
39
- _message: aio_pika.abc.AbstractIncomingMessage
40
134
  task: T
41
-
42
- @property
43
- def delivery_count(self) -> int:
44
- return _delivery_count(self._message)
135
+ delivery_count: int
136
+ # AMQP acknowledges by delivery tag on the channel the message arrived
137
+ # on, and only the message object carries that pairing — unlike the
138
+ # other adapters, there is no id to hold onto instead.
139
+ _message: Deliverable
45
140
 
46
141
  async def ack(self) -> None:
47
142
  await self._message.ack()
@@ -57,16 +152,22 @@ class RabbitMqTaskQueue[T]:
57
152
  decode: Decoder[T]
58
153
  # Overridable for tests (inject a fake connection) or to share/pool a
59
154
  # connection strategy the caller already has; defaults to the real SDK.
60
- connect: Connect = field(default=aio_pika.connect_robust)
155
+ connect: Connect = field(default=_connect_robust)
156
+ # Override alongside connect= when injecting a different RabbitMQ
157
+ # client: publish() hands the message this builds straight to that
158
+ # client's exchange, so the two have to agree on the type.
159
+ build_message: MessageFactory = field(default=_aio_pika_message)
61
160
 
62
161
  def _queue_arguments(self) -> FieldTable:
63
- # Quorum queues (not RabbitMQ's classic queue type) are what expose
64
- # x-delivery-count on redelivery a classic queue tracks no such
65
- # counter at all, so delivery_count would always read 1. This is
66
- # also RabbitMQ's own recommended queue type for task-queue-style
67
- # workloads (durable, replicated, no classic-queue mirroring), so
68
- # this isn't a special case adopted only for the counter.
69
- return {"x-queue-type": "quorum"}
162
+ # A queue's type is fixed at declaration and cannot be changed in
163
+ # place, so this has to match whatever the queue already is
164
+ # declaring a different type against an existing queue fails with
165
+ # PRECONDITION_FAILED. Hence queue_type being part of the DSN
166
+ # rather than hardcoded here: quorum by default (RabbitMQ's
167
+ # recommendation for task workloads, and the only type that
168
+ # maintains x-delivery-count), classic for pointing at a queue
169
+ # that already exists as one.
170
+ return {"x-queue-type": self.dsn.queue_type}
70
171
 
71
172
  async def publish(self, task: T) -> None:
72
173
  connection = await self.connect(self.dsn.url)
@@ -76,10 +177,7 @@ class RabbitMqTaskQueue[T]:
76
177
  self.dsn.queue_name, durable=True, arguments=self._queue_arguments()
77
178
  )
78
179
  await channel.default_exchange.publish(
79
- aio_pika.Message(
80
- body=self.encode(task),
81
- delivery_mode=aio_pika.DeliveryMode.PERSISTENT,
82
- ),
180
+ self.build_message(self.encode(task), True),
83
181
  routing_key=queue.name,
84
182
  )
85
183
  logger.info("task published", extra={"queue": self.dsn.queue_name})
@@ -96,7 +194,17 @@ class RabbitMqTaskQueue[T]:
96
194
  )
97
195
 
98
196
  async for message in queue.iterator():
99
- yield RabbitMqQueuedTask(_message=message, task=self.decode(message.body))
197
+ # x-delivery-count counts redeliveries, and only quorum
198
+ # queues maintain it at all — against a classic queue it is
199
+ # always absent and this always reports a first delivery.
200
+ yield RabbitMqQueuedTask(
201
+ task=self.decode(message.body),
202
+ delivery_count=delivery_count_from(
203
+ (message.headers or {}).get("x-delivery-count"),
204
+ counts_redeliveries=True,
205
+ ),
206
+ _message=message,
207
+ )
100
208
  finally:
101
209
  # The consumer (a worker's `async for`) can stop iterating at any
102
210
  # point — graceful shutdown, an unhandled error, etc. — which