taskqueue-toolkit 0.1.1__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.
Files changed (32) hide show
  1. {taskqueue_toolkit-0.1.1 → taskqueue_toolkit-0.3.0}/PKG-INFO +74 -2
  2. {taskqueue_toolkit-0.1.1 → taskqueue_toolkit-0.3.0}/README.md +73 -1
  3. {taskqueue_toolkit-0.1.1 → taskqueue_toolkit-0.3.0}/pyproject.toml +1 -1
  4. {taskqueue_toolkit-0.1.1 → taskqueue_toolkit-0.3.0}/pyproject.toml.orig +1 -1
  5. taskqueue_toolkit-0.3.0/src/taskqueue_toolkit/queue/delivery.py +36 -0
  6. {taskqueue_toolkit-0.1.1 → taskqueue_toolkit-0.3.0}/src/taskqueue_toolkit/queue/dsn.py +26 -0
  7. {taskqueue_toolkit-0.1.1 → taskqueue_toolkit-0.3.0}/src/taskqueue_toolkit/queue/factory.py +47 -5
  8. {taskqueue_toolkit-0.1.1 → taskqueue_toolkit-0.3.0}/src/taskqueue_toolkit/queue/pubsub.py +106 -17
  9. taskqueue_toolkit-0.3.0/src/taskqueue_toolkit/queue/rabbitmq.py +218 -0
  10. taskqueue_toolkit-0.3.0/src/taskqueue_toolkit/queue/redis_streams.py +203 -0
  11. {taskqueue_toolkit-0.1.1 → taskqueue_toolkit-0.3.0}/src/taskqueue_toolkit/queue/sns.py +91 -17
  12. {taskqueue_toolkit-0.1.1 → taskqueue_toolkit-0.3.0}/src/taskqueue_toolkit/queue/sqs.py +83 -15
  13. {taskqueue_toolkit-0.1.1 → taskqueue_toolkit-0.3.0}/src/taskqueue_toolkit/queue/task_queue.py +27 -0
  14. {taskqueue_toolkit-0.1.1 → taskqueue_toolkit-0.3.0}/src/taskqueue_toolkit/testing/pubsub.py +47 -16
  15. taskqueue_toolkit-0.3.0/src/taskqueue_toolkit/testing/rabbitmq.py +197 -0
  16. {taskqueue_toolkit-0.1.1 → taskqueue_toolkit-0.3.0}/src/taskqueue_toolkit/testing/redis_streams.py +45 -2
  17. {taskqueue_toolkit-0.1.1 → taskqueue_toolkit-0.3.0}/src/taskqueue_toolkit/testing/sns.py +47 -15
  18. {taskqueue_toolkit-0.1.1 → taskqueue_toolkit-0.3.0}/src/taskqueue_toolkit/testing/sqs.py +47 -14
  19. taskqueue_toolkit-0.1.1/src/taskqueue_toolkit/queue/rabbitmq.py +0 -74
  20. taskqueue_toolkit-0.1.1/src/taskqueue_toolkit/queue/redis_streams.py +0 -117
  21. taskqueue_toolkit-0.1.1/src/taskqueue_toolkit/testing/rabbitmq.py +0 -135
  22. {taskqueue_toolkit-0.1.1 → taskqueue_toolkit-0.3.0}/LICENSE +0 -0
  23. {taskqueue_toolkit-0.1.1 → taskqueue_toolkit-0.3.0}/src/taskqueue_toolkit/__init__.py +0 -0
  24. {taskqueue_toolkit-0.1.1 → taskqueue_toolkit-0.3.0}/src/taskqueue_toolkit/outbox/__init__.py +0 -0
  25. {taskqueue_toolkit-0.1.1 → taskqueue_toolkit-0.3.0}/src/taskqueue_toolkit/outbox/orm.py +0 -0
  26. {taskqueue_toolkit-0.1.1 → taskqueue_toolkit-0.3.0}/src/taskqueue_toolkit/outbox/relay.py +0 -0
  27. {taskqueue_toolkit-0.1.1 → taskqueue_toolkit-0.3.0}/src/taskqueue_toolkit/outbox/repository.py +0 -0
  28. {taskqueue_toolkit-0.1.1 → taskqueue_toolkit-0.3.0}/src/taskqueue_toolkit/py.typed +0 -0
  29. {taskqueue_toolkit-0.1.1 → taskqueue_toolkit-0.3.0}/src/taskqueue_toolkit/queue/__init__.py +0 -0
  30. {taskqueue_toolkit-0.1.1 → taskqueue_toolkit-0.3.0}/src/taskqueue_toolkit/queue/aws_session.py +0 -0
  31. {taskqueue_toolkit-0.1.1 → taskqueue_toolkit-0.3.0}/src/taskqueue_toolkit/queue/registry.py +0 -0
  32. {taskqueue_toolkit-0.1.1 → 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.1.1
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
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "taskqueue-toolkit"
3
- version = "0.1.1"
3
+ version = "0.3.0"
4
4
  description = "Broker-agnostic async task queue (RabbitMQ, SQS, SNS, Redis Streams, Pub/Sub) with a Postgres outbox pattern"
5
5
  readme = "README.md"
6
6
  license = "Apache-2.0"
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "taskqueue-toolkit"
3
- version = "0.1.1"
3
+ version = "0.3.0"
4
4
  description = "Broker-agnostic async task queue (RabbitMQ, SQS, SNS, Redis Streams, Pub/Sub) with a Postgres outbox pattern"
5
5
  readme = "README.md"
6
6
  license = "Apache-2.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
@@ -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,16 +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
- task: T
30
105
 
31
106
  async def ack(self) -> None:
32
107
  await asyncio.to_thread(
@@ -74,10 +149,10 @@ class PubsubTaskQueue[T]:
74
149
  decode: Decoder[T]
75
150
  # Overridable for tests (inject fake clients) or a shared client the
76
151
  # caller already manages; each defaults to the real SDK client.
77
- publisher_factory: PublisherFactory = field(default=pubsub_v1.PublisherClient)
78
- subscriber_factory: SubscriberFactory = field(default=pubsub_v1.SubscriberClient)
79
- _publisher_client: pubsub_v1.PublisherClient | None = field(default=None, init=False)
80
- _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)
81
156
 
82
157
  def _apply_emulator_host(self) -> None:
83
158
  # The official client only knows to target an emulator via this env
@@ -90,7 +165,7 @@ class PubsubTaskQueue[T]:
90
165
  os.environ["PUBSUB_EMULATOR_HOST"] = self.dsn.emulator_host
91
166
 
92
167
  @property
93
- def _publisher(self) -> pubsub_v1.PublisherClient:
168
+ def _publisher(self) -> Publisher:
94
169
  # Built lazily, not at __init__ time (e.g. via default_factory) —
95
170
  # constructing the client eagerly authenticates against GCP
96
171
  # immediately, which fails outside an environment with real or
@@ -101,7 +176,7 @@ class PubsubTaskQueue[T]:
101
176
  return self._publisher_client
102
177
 
103
178
  @property
104
- def _subscriber(self) -> pubsub_v1.SubscriberClient:
179
+ def _subscriber(self) -> Subscriber:
105
180
  if self._subscriber_client is None:
106
181
  self._apply_emulator_host()
107
182
  self._subscriber_client = self.subscriber_factory()
@@ -117,16 +192,22 @@ class PubsubTaskQueue[T]:
117
192
 
118
193
  async def _ensure_topic(self) -> str:
119
194
  topic_path = self._topic_path()
120
- with suppress(AlreadyExists):
195
+ try:
121
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
122
200
  return topic_path
123
201
 
124
202
  async def _ensure_subscription(self, topic_path: str) -> str:
125
203
  subscription_path = self._subscription_path()
126
- with suppress(AlreadyExists):
204
+ try:
127
205
  await asyncio.to_thread(
128
206
  self._subscriber.create_subscription, name=subscription_path, topic=topic_path
129
207
  )
208
+ except Exception as exc:
209
+ if not _is_already_exists(exc):
210
+ raise
130
211
  return subscription_path
131
212
 
132
213
  async def publish(self, task: T) -> None:
@@ -154,9 +235,17 @@ class PubsubTaskQueue[T]:
154
235
  timeout=_PULL_TIMEOUT_SECONDS,
155
236
  )
156
237
  for received in response.received_messages:
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))
157
245
  yield PubsubQueuedTask(
246
+ task=self.decode(received.message.data),
247
+ delivery_count=delivery_count,
158
248
  _subscriber=self._subscriber,
159
249
  _subscription_path=subscription_path,
160
250
  _ack_id=received.ack_id,
161
- task=self.decode(received.message.data),
162
251
  )