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.
- taskqueue_toolkit/queue/delivery.py +36 -0
- taskqueue_toolkit/queue/dsn.py +26 -0
- taskqueue_toolkit/queue/factory.py +47 -5
- taskqueue_toolkit/queue/pubsub.py +106 -25
- taskqueue_toolkit/queue/rabbitmq.py +147 -39
- taskqueue_toolkit/queue/redis_streams.py +93 -29
- taskqueue_toolkit/queue/sns.py +85 -21
- taskqueue_toolkit/queue/sqs.py +79 -19
- taskqueue_toolkit/queue/task_queue.py +23 -10
- taskqueue_toolkit/testing/pubsub.py +21 -7
- taskqueue_toolkit/testing/rabbitmq.py +56 -18
- taskqueue_toolkit/testing/redis_streams.py +17 -2
- taskqueue_toolkit/testing/sns.py +15 -4
- taskqueue_toolkit/testing/sqs.py +14 -3
- {taskqueue_toolkit-0.2.0.dist-info → taskqueue_toolkit-0.3.0.dist-info}/METADATA +74 -2
- taskqueue_toolkit-0.3.0.dist-info/RECORD +28 -0
- {taskqueue_toolkit-0.2.0.dist-info → taskqueue_toolkit-0.3.0.dist-info}/WHEEL +1 -1
- taskqueue_toolkit-0.2.0.dist-info/RECORD +0 -27
- {taskqueue_toolkit-0.2.0.dist-info → taskqueue_toolkit-0.3.0.dist-info}/licenses/LICENSE +0 -0
|
@@ -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
|
taskqueue_toolkit/queue/dsn.py
CHANGED
|
@@ -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
|
|
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
|
-
|
|
21
|
-
|
|
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
|
-
|
|
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=
|
|
79
|
-
subscriber_factory: SubscriberFactory = field(default=
|
|
80
|
-
_publisher_client:
|
|
81
|
-
_subscriber_client:
|
|
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) ->
|
|
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) ->
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
159
|
-
#
|
|
160
|
-
#
|
|
161
|
-
#
|
|
162
|
-
#
|
|
163
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
20
|
-
|
|
21
|
-
nack(requeue=True)
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
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
|
-
|
|
43
|
-
|
|
44
|
-
|
|
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=
|
|
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
|
-
#
|
|
64
|
-
#
|
|
65
|
-
#
|
|
66
|
-
#
|
|
67
|
-
#
|
|
68
|
-
#
|
|
69
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|