taskqueue-toolkit 0.2.0__tar.gz → 0.3.0__tar.gz
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-0.2.0 → taskqueue_toolkit-0.3.0}/PKG-INFO +74 -2
- {taskqueue_toolkit-0.2.0 → taskqueue_toolkit-0.3.0}/README.md +73 -1
- {taskqueue_toolkit-0.2.0 → taskqueue_toolkit-0.3.0}/pyproject.toml +1 -1
- {taskqueue_toolkit-0.2.0 → taskqueue_toolkit-0.3.0}/pyproject.toml.orig +1 -1
- taskqueue_toolkit-0.3.0/src/taskqueue_toolkit/queue/delivery.py +36 -0
- {taskqueue_toolkit-0.2.0 → taskqueue_toolkit-0.3.0}/src/taskqueue_toolkit/queue/dsn.py +26 -0
- {taskqueue_toolkit-0.2.0 → taskqueue_toolkit-0.3.0}/src/taskqueue_toolkit/queue/factory.py +47 -5
- {taskqueue_toolkit-0.2.0 → taskqueue_toolkit-0.3.0}/src/taskqueue_toolkit/queue/pubsub.py +106 -25
- taskqueue_toolkit-0.3.0/src/taskqueue_toolkit/queue/rabbitmq.py +218 -0
- {taskqueue_toolkit-0.2.0 → taskqueue_toolkit-0.3.0}/src/taskqueue_toolkit/queue/redis_streams.py +93 -29
- {taskqueue_toolkit-0.2.0 → taskqueue_toolkit-0.3.0}/src/taskqueue_toolkit/queue/sns.py +85 -21
- {taskqueue_toolkit-0.2.0 → taskqueue_toolkit-0.3.0}/src/taskqueue_toolkit/queue/sqs.py +79 -19
- {taskqueue_toolkit-0.2.0 → taskqueue_toolkit-0.3.0}/src/taskqueue_toolkit/queue/task_queue.py +23 -10
- {taskqueue_toolkit-0.2.0 → taskqueue_toolkit-0.3.0}/src/taskqueue_toolkit/testing/pubsub.py +21 -7
- {taskqueue_toolkit-0.2.0 → taskqueue_toolkit-0.3.0}/src/taskqueue_toolkit/testing/rabbitmq.py +56 -18
- {taskqueue_toolkit-0.2.0 → taskqueue_toolkit-0.3.0}/src/taskqueue_toolkit/testing/redis_streams.py +17 -2
- {taskqueue_toolkit-0.2.0 → taskqueue_toolkit-0.3.0}/src/taskqueue_toolkit/testing/sns.py +15 -4
- {taskqueue_toolkit-0.2.0 → taskqueue_toolkit-0.3.0}/src/taskqueue_toolkit/testing/sqs.py +14 -3
- taskqueue_toolkit-0.2.0/src/taskqueue_toolkit/queue/rabbitmq.py +0 -110
- {taskqueue_toolkit-0.2.0 → taskqueue_toolkit-0.3.0}/LICENSE +0 -0
- {taskqueue_toolkit-0.2.0 → taskqueue_toolkit-0.3.0}/src/taskqueue_toolkit/__init__.py +0 -0
- {taskqueue_toolkit-0.2.0 → taskqueue_toolkit-0.3.0}/src/taskqueue_toolkit/outbox/__init__.py +0 -0
- {taskqueue_toolkit-0.2.0 → taskqueue_toolkit-0.3.0}/src/taskqueue_toolkit/outbox/orm.py +0 -0
- {taskqueue_toolkit-0.2.0 → taskqueue_toolkit-0.3.0}/src/taskqueue_toolkit/outbox/relay.py +0 -0
- {taskqueue_toolkit-0.2.0 → taskqueue_toolkit-0.3.0}/src/taskqueue_toolkit/outbox/repository.py +0 -0
- {taskqueue_toolkit-0.2.0 → taskqueue_toolkit-0.3.0}/src/taskqueue_toolkit/py.typed +0 -0
- {taskqueue_toolkit-0.2.0 → taskqueue_toolkit-0.3.0}/src/taskqueue_toolkit/queue/__init__.py +0 -0
- {taskqueue_toolkit-0.2.0 → taskqueue_toolkit-0.3.0}/src/taskqueue_toolkit/queue/aws_session.py +0 -0
- {taskqueue_toolkit-0.2.0 → taskqueue_toolkit-0.3.0}/src/taskqueue_toolkit/queue/registry.py +0 -0
- {taskqueue_toolkit-0.2.0 → taskqueue_toolkit-0.3.0}/src/taskqueue_toolkit/testing/__init__.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: taskqueue-toolkit
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.3.0
|
|
4
4
|
Summary: Broker-agnostic async task queue (RabbitMQ, SQS, SNS, Redis Streams, Pub/Sub) with a Postgres outbox pattern
|
|
5
5
|
Author: Walid Boughdiri
|
|
6
6
|
Author-email: Walid Boughdiri <walid.boughdiri@gmail.com>
|
|
@@ -74,12 +74,47 @@ async for queued in queue.consume():
|
|
|
74
74
|
|
|
75
75
|
| Scheme | Broker | Example |
|
|
76
76
|
|---|---|---|
|
|
77
|
-
| `amqp` / `amqps` | RabbitMQ | `amqp://guest:guest@host:5672/?queue=my.tasks` |
|
|
77
|
+
| `amqp` / `amqps` | RabbitMQ | `amqp://guest:guest@host:5672/?queue=my.tasks&queue_type=quorum` |
|
|
78
78
|
| `redis` / `rediss` | Redis Streams | `redis://:password@host:6379/0?stream=my.tasks&group=workers&consumer=worker-1` |
|
|
79
79
|
| `sqs` | Amazon SQS | `sqs://eu-west-3/my.tasks?endpoint_url=...&access_key_id=...&secret_access_key=...` |
|
|
80
80
|
| `sns` | Amazon SNS (fan-out) | `sns://eu-west-3/my-tasks?endpoint_url=...&access_key_id=...&secret_access_key=...` |
|
|
81
81
|
| `pubsub` | Google Pub/Sub | `pubsub://project-id/my-tasks?subscription=my-tasks-subscriber&emulator_host=localhost:8085` |
|
|
82
82
|
|
|
83
|
+
### Giving up after N attempts
|
|
84
|
+
|
|
85
|
+
Each `QueuedTask` reports `delivery_count` — how many times the broker has delivered that message, counting the current delivery (1 the first time, never 0). A redelivery caused by a crashed consumer or an expired ack deadline counts the same as one caused by an explicit `nack(requeue=True)`, since the broker counts it the same way.
|
|
86
|
+
|
|
87
|
+
```python
|
|
88
|
+
MAX_ATTEMPTS = 3
|
|
89
|
+
|
|
90
|
+
async for queued in queue.consume():
|
|
91
|
+
if queued.delivery_count > MAX_ATTEMPTS:
|
|
92
|
+
logger.error("giving up on task", extra={"attempts": queued.delivery_count})
|
|
93
|
+
await queued.nack(requeue=False)
|
|
94
|
+
continue
|
|
95
|
+
try:
|
|
96
|
+
await handle(queued.task)
|
|
97
|
+
except Exception:
|
|
98
|
+
await queued.nack(requeue=True)
|
|
99
|
+
else:
|
|
100
|
+
await queued.ack()
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
The value comes from each broker's own counter, not from this package. Treat it as a lower bound — SQS documents its counter as approximate, and no broker promises exactness across a failover. **Two setups can't count at all**, and silently report a first delivery forever:
|
|
104
|
+
|
|
105
|
+
| Broker | Counts only when |
|
|
106
|
+
|---|---|
|
|
107
|
+
| RabbitMQ | the queue is a **quorum** queue (the default here) — classic queues maintain no `x-delivery-count` |
|
|
108
|
+
| Pub/Sub | the subscription has a **dead-letter policy** — `delivery_attempt` is unset without one |
|
|
109
|
+
|
|
110
|
+
### RabbitMQ queue type
|
|
111
|
+
|
|
112
|
+
Queues are declared as **quorum** by default: RabbitMQ's recommendation for task workloads, and the only type that maintains the counter `delivery_count` reads. A queue's type is fixed when it's created, so pointing this package at a queue that already exists as classic fails with `PRECONDITION_FAILED` until you either delete and recreate it, or opt out:
|
|
113
|
+
|
|
114
|
+
```
|
|
115
|
+
amqp://guest:guest@host:5672/?queue=my.tasks&queue_type=classic
|
|
116
|
+
```
|
|
117
|
+
|
|
83
118
|
### Adding a broker this package doesn't ship
|
|
84
119
|
|
|
85
120
|
The five schemes above are built in and checked for exhaustiveness at type-check time — `create_task_queue()` can't silently forget one. For a broker outside that list (Kafka, NATS, ...), register a handler once, at import/startup time, instead of forking the package:
|
|
@@ -99,6 +134,43 @@ queue = create_task_queue("kafka://localhost:9092/my-tasks", encode=..., decode=
|
|
|
99
134
|
|
|
100
135
|
A handler receives the raw DSN string plus the `encode`/`decode` pair and returns a `TaskQueue[T]` — parsing and construction happen together, so there's a single call to keep in sync, not a parser and a constructor spread across two files.
|
|
101
136
|
|
|
137
|
+
**No broker SDK required.** `TaskQueue[T]`/`QueuedTask[T]` are protocols with no dependencies of their own, and `create_task_queue()` imports a built-in adapter only when a DSN actually selects it. So a project that brings its own broker client needs none of the extras installed — and asking for a scheme whose SDK is missing names the extra to install rather than raising a bare `ImportError`:
|
|
138
|
+
|
|
139
|
+
```
|
|
140
|
+
MissingBrokerExtraError: the 'amqp' DSN scheme needs a broker SDK that isn't
|
|
141
|
+
installed — install it with: pip install 'taskqueue-toolkit[rabbitmq]'
|
|
142
|
+
```
|
|
143
|
+
|
|
144
|
+
**Reusing a built-in adapter with a different client.** Each adapter types its client against its own narrow protocol (RabbitMQ's `Connection`/`Channel`/`Deliverable`, SQS/SNS's `MessageSettler`, Redis Streams' `EntryAcknowledger`) rather than the SDK's own types, so anything structurally compatible can be injected — that's the same seam the in-memory doubles use.
|
|
145
|
+
|
|
146
|
+
This holds for every adapter: **no broker SDK is imported at module level anywhere in the package**, so all five adapters (and all five in-memory doubles) import and build on a machine with none of them installed. Each SDK is imported only from that adapter's default client factory, the moment a real connection is opened.
|
|
147
|
+
|
|
148
|
+
| Adapter | Inject | To replace |
|
|
149
|
+
|---|---|---|
|
|
150
|
+
| RabbitMQ | `connect=`, `build_message=` | aio-pika |
|
|
151
|
+
| SQS | `client_factory=` | aioboto3 |
|
|
152
|
+
| SNS | `client_factory=` | aioboto3 |
|
|
153
|
+
| Redis Streams | `client_factory=` | redis-py |
|
|
154
|
+
| Pub/Sub | `publisher_factory=`, `subscriber_factory=` | google-cloud-pubsub |
|
|
155
|
+
|
|
156
|
+
For RabbitMQ, override both seams together, since `publish()` hands the message straight to your exchange:
|
|
157
|
+
|
|
158
|
+
```python
|
|
159
|
+
class MyMessage:
|
|
160
|
+
def __init__(self, body: bytes, persistent: bool): ...
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
queue = RabbitMqTaskQueue(
|
|
164
|
+
dsn=dsn,
|
|
165
|
+
encode=...,
|
|
166
|
+
decode=...,
|
|
167
|
+
connect=my_connect, # -> Connection: channel(), close()
|
|
168
|
+
build_message=MyMessage, # (body, persistent) -> your client's message type
|
|
169
|
+
)
|
|
170
|
+
```
|
|
171
|
+
|
|
172
|
+
`connect` must return an object satisfying `Connection` → `Channel` → `Consumable`/`Publishable` → `Deliverable`; those five protocols are the whole contract, and each lists only the members the adapter actually calls.
|
|
173
|
+
|
|
102
174
|
### Outbox pattern
|
|
103
175
|
|
|
104
176
|
```python
|
|
@@ -39,12 +39,47 @@ async for queued in queue.consume():
|
|
|
39
39
|
|
|
40
40
|
| Scheme | Broker | Example |
|
|
41
41
|
|---|---|---|
|
|
42
|
-
| `amqp` / `amqps` | RabbitMQ | `amqp://guest:guest@host:5672/?queue=my.tasks` |
|
|
42
|
+
| `amqp` / `amqps` | RabbitMQ | `amqp://guest:guest@host:5672/?queue=my.tasks&queue_type=quorum` |
|
|
43
43
|
| `redis` / `rediss` | Redis Streams | `redis://:password@host:6379/0?stream=my.tasks&group=workers&consumer=worker-1` |
|
|
44
44
|
| `sqs` | Amazon SQS | `sqs://eu-west-3/my.tasks?endpoint_url=...&access_key_id=...&secret_access_key=...` |
|
|
45
45
|
| `sns` | Amazon SNS (fan-out) | `sns://eu-west-3/my-tasks?endpoint_url=...&access_key_id=...&secret_access_key=...` |
|
|
46
46
|
| `pubsub` | Google Pub/Sub | `pubsub://project-id/my-tasks?subscription=my-tasks-subscriber&emulator_host=localhost:8085` |
|
|
47
47
|
|
|
48
|
+
### Giving up after N attempts
|
|
49
|
+
|
|
50
|
+
Each `QueuedTask` reports `delivery_count` — how many times the broker has delivered that message, counting the current delivery (1 the first time, never 0). A redelivery caused by a crashed consumer or an expired ack deadline counts the same as one caused by an explicit `nack(requeue=True)`, since the broker counts it the same way.
|
|
51
|
+
|
|
52
|
+
```python
|
|
53
|
+
MAX_ATTEMPTS = 3
|
|
54
|
+
|
|
55
|
+
async for queued in queue.consume():
|
|
56
|
+
if queued.delivery_count > MAX_ATTEMPTS:
|
|
57
|
+
logger.error("giving up on task", extra={"attempts": queued.delivery_count})
|
|
58
|
+
await queued.nack(requeue=False)
|
|
59
|
+
continue
|
|
60
|
+
try:
|
|
61
|
+
await handle(queued.task)
|
|
62
|
+
except Exception:
|
|
63
|
+
await queued.nack(requeue=True)
|
|
64
|
+
else:
|
|
65
|
+
await queued.ack()
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
The value comes from each broker's own counter, not from this package. Treat it as a lower bound — SQS documents its counter as approximate, and no broker promises exactness across a failover. **Two setups can't count at all**, and silently report a first delivery forever:
|
|
69
|
+
|
|
70
|
+
| Broker | Counts only when |
|
|
71
|
+
|---|---|
|
|
72
|
+
| RabbitMQ | the queue is a **quorum** queue (the default here) — classic queues maintain no `x-delivery-count` |
|
|
73
|
+
| Pub/Sub | the subscription has a **dead-letter policy** — `delivery_attempt` is unset without one |
|
|
74
|
+
|
|
75
|
+
### RabbitMQ queue type
|
|
76
|
+
|
|
77
|
+
Queues are declared as **quorum** by default: RabbitMQ's recommendation for task workloads, and the only type that maintains the counter `delivery_count` reads. A queue's type is fixed when it's created, so pointing this package at a queue that already exists as classic fails with `PRECONDITION_FAILED` until you either delete and recreate it, or opt out:
|
|
78
|
+
|
|
79
|
+
```
|
|
80
|
+
amqp://guest:guest@host:5672/?queue=my.tasks&queue_type=classic
|
|
81
|
+
```
|
|
82
|
+
|
|
48
83
|
### Adding a broker this package doesn't ship
|
|
49
84
|
|
|
50
85
|
The five schemes above are built in and checked for exhaustiveness at type-check time — `create_task_queue()` can't silently forget one. For a broker outside that list (Kafka, NATS, ...), register a handler once, at import/startup time, instead of forking the package:
|
|
@@ -64,6 +99,43 @@ queue = create_task_queue("kafka://localhost:9092/my-tasks", encode=..., decode=
|
|
|
64
99
|
|
|
65
100
|
A handler receives the raw DSN string plus the `encode`/`decode` pair and returns a `TaskQueue[T]` — parsing and construction happen together, so there's a single call to keep in sync, not a parser and a constructor spread across two files.
|
|
66
101
|
|
|
102
|
+
**No broker SDK required.** `TaskQueue[T]`/`QueuedTask[T]` are protocols with no dependencies of their own, and `create_task_queue()` imports a built-in adapter only when a DSN actually selects it. So a project that brings its own broker client needs none of the extras installed — and asking for a scheme whose SDK is missing names the extra to install rather than raising a bare `ImportError`:
|
|
103
|
+
|
|
104
|
+
```
|
|
105
|
+
MissingBrokerExtraError: the 'amqp' DSN scheme needs a broker SDK that isn't
|
|
106
|
+
installed — install it with: pip install 'taskqueue-toolkit[rabbitmq]'
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
**Reusing a built-in adapter with a different client.** Each adapter types its client against its own narrow protocol (RabbitMQ's `Connection`/`Channel`/`Deliverable`, SQS/SNS's `MessageSettler`, Redis Streams' `EntryAcknowledger`) rather than the SDK's own types, so anything structurally compatible can be injected — that's the same seam the in-memory doubles use.
|
|
110
|
+
|
|
111
|
+
This holds for every adapter: **no broker SDK is imported at module level anywhere in the package**, so all five adapters (and all five in-memory doubles) import and build on a machine with none of them installed. Each SDK is imported only from that adapter's default client factory, the moment a real connection is opened.
|
|
112
|
+
|
|
113
|
+
| Adapter | Inject | To replace |
|
|
114
|
+
|---|---|---|
|
|
115
|
+
| RabbitMQ | `connect=`, `build_message=` | aio-pika |
|
|
116
|
+
| SQS | `client_factory=` | aioboto3 |
|
|
117
|
+
| SNS | `client_factory=` | aioboto3 |
|
|
118
|
+
| Redis Streams | `client_factory=` | redis-py |
|
|
119
|
+
| Pub/Sub | `publisher_factory=`, `subscriber_factory=` | google-cloud-pubsub |
|
|
120
|
+
|
|
121
|
+
For RabbitMQ, override both seams together, since `publish()` hands the message straight to your exchange:
|
|
122
|
+
|
|
123
|
+
```python
|
|
124
|
+
class MyMessage:
|
|
125
|
+
def __init__(self, body: bytes, persistent: bool): ...
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
queue = RabbitMqTaskQueue(
|
|
129
|
+
dsn=dsn,
|
|
130
|
+
encode=...,
|
|
131
|
+
decode=...,
|
|
132
|
+
connect=my_connect, # -> Connection: channel(), close()
|
|
133
|
+
build_message=MyMessage, # (body, persistent) -> your client's message type
|
|
134
|
+
)
|
|
135
|
+
```
|
|
136
|
+
|
|
137
|
+
`connect` must return an object satisfying `Connection` → `Channel` → `Consumable`/`Publishable` → `Deliverable`; those five protocols are the whole contract, and each lists only the members the adapter actually calls.
|
|
138
|
+
|
|
67
139
|
### Outbox pattern
|
|
68
140
|
|
|
69
141
|
```python
|
|
@@ -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
|
|
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
|
)
|