taskqueue-toolkit 0.1.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/__init__.py +0 -0
- taskqueue_toolkit/outbox/__init__.py +0 -0
- taskqueue_toolkit/outbox/orm.py +47 -0
- taskqueue_toolkit/outbox/relay.py +59 -0
- taskqueue_toolkit/outbox/repository.py +107 -0
- taskqueue_toolkit/py.typed +0 -0
- taskqueue_toolkit/queue/__init__.py +0 -0
- taskqueue_toolkit/queue/aws_session.py +19 -0
- taskqueue_toolkit/queue/dsn.py +147 -0
- taskqueue_toolkit/queue/factory.py +94 -0
- taskqueue_toolkit/queue/pubsub.py +162 -0
- taskqueue_toolkit/queue/rabbitmq.py +74 -0
- taskqueue_toolkit/queue/redis_streams.py +117 -0
- taskqueue_toolkit/queue/registry.py +53 -0
- taskqueue_toolkit/queue/sns.py +162 -0
- taskqueue_toolkit/queue/sqs.py +103 -0
- taskqueue_toolkit/queue/task_queue.py +62 -0
- taskqueue_toolkit/testing/__init__.py +58 -0
- taskqueue_toolkit/testing/pubsub.py +149 -0
- taskqueue_toolkit/testing/rabbitmq.py +135 -0
- taskqueue_toolkit/testing/redis_streams.py +141 -0
- taskqueue_toolkit/testing/sns.py +155 -0
- taskqueue_toolkit/testing/sqs.py +109 -0
- taskqueue_toolkit-0.1.0.dist-info/METADATA +163 -0
- taskqueue_toolkit-0.1.0.dist-info/RECORD +27 -0
- taskqueue_toolkit-0.1.0.dist-info/WHEEL +4 -0
- taskqueue_toolkit-0.1.0.dist-info/licenses/LICENSE +202 -0
|
File without changes
|
|
File without changes
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import uuid
|
|
4
|
+
from datetime import datetime
|
|
5
|
+
|
|
6
|
+
from sqlalchemy import DateTime, LargeBinary, String, Text
|
|
7
|
+
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class Base(DeclarativeBase):
|
|
11
|
+
pass
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
# Values for OutboxRow.status. Not a DB enum on purpose — SQLite (handy for
|
|
15
|
+
# tests) and Postgres would need separate enum handling, and a plain indexed
|
|
16
|
+
# string is enough for the states polled by a relay.
|
|
17
|
+
#
|
|
18
|
+
# pending -> claimed -> published (happy path)
|
|
19
|
+
# pending -> claimed -> pending (publish failed, retry)
|
|
20
|
+
# pending -> claimed -> failed (publish failed too many times)
|
|
21
|
+
#
|
|
22
|
+
# "claimed" exists so a relay can release its SELECT ... FOR UPDATE SKIP
|
|
23
|
+
# LOCKED lock immediately after marking rows claimed, rather than holding a
|
|
24
|
+
# transaction open across a network call to the broker — the lock's only
|
|
25
|
+
# job is to make the claim itself atomic across concurrent relays.
|
|
26
|
+
STATUS_PENDING = "pending"
|
|
27
|
+
STATUS_CLAIMED = "claimed"
|
|
28
|
+
STATUS_PUBLISHED = "published"
|
|
29
|
+
STATUS_FAILED = "failed"
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class OutboxRow(Base):
|
|
33
|
+
__tablename__ = "outbox"
|
|
34
|
+
|
|
35
|
+
id: Mapped[uuid.UUID] = mapped_column(primary_key=True)
|
|
36
|
+
# Free-form, caller-assigned identifier (e.g. an aggregate/entity id) —
|
|
37
|
+
# not interpreted by this package, only indexed so a caller can look up
|
|
38
|
+
# "what's pending for this project/order/tenant" without decoding every
|
|
39
|
+
# payload. Optional: a caller with no natural correlation key just
|
|
40
|
+
# leaves it unset.
|
|
41
|
+
correlation_id: Mapped[str | None] = mapped_column(String(255), index=True, nullable=True)
|
|
42
|
+
payload: Mapped[bytes] = mapped_column(LargeBinary)
|
|
43
|
+
status: Mapped[str] = mapped_column(String(20), default=STATUS_PENDING, index=True)
|
|
44
|
+
attempts: Mapped[int] = mapped_column(default=0)
|
|
45
|
+
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True))
|
|
46
|
+
published_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
|
47
|
+
last_error: Mapped[str | None] = mapped_column(Text, nullable=True)
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import logging
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
|
|
7
|
+
from taskqueue_toolkit.outbox.repository import OutboxRepository
|
|
8
|
+
from taskqueue_toolkit.queue.task_queue import TaskQueue
|
|
9
|
+
|
|
10
|
+
logger = logging.getLogger(__name__)
|
|
11
|
+
|
|
12
|
+
_DEFAULT_BATCH_SIZE = 20
|
|
13
|
+
_DEFAULT_POLL_INTERVAL_SECONDS = 2.0
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@dataclass(slots=True)
|
|
17
|
+
class OutboxRelay[T]:
|
|
18
|
+
"""Delivers outbox entries to the broker.
|
|
19
|
+
|
|
20
|
+
This is the only bridge between the outbox's storage (the durability
|
|
21
|
+
guarantee — an entry committed in the same transaction as the state
|
|
22
|
+
change it follows from is never lost even if the broker is briefly
|
|
23
|
+
unreachable) and TaskQueue[T] (the actual delivery mechanism, whichever
|
|
24
|
+
broker is configured). Neither side ever talks to the other directly.
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
outbox: OutboxRepository[T]
|
|
28
|
+
task_queue: TaskQueue[T]
|
|
29
|
+
batch_size: int = _DEFAULT_BATCH_SIZE
|
|
30
|
+
|
|
31
|
+
async def relay_once(self) -> int:
|
|
32
|
+
"""Claim and publish one batch of pending entries. Returns how many
|
|
33
|
+
were claimed, so a caller can decide whether to poll again
|
|
34
|
+
immediately (batch was full) or wait (batch was empty/partial)."""
|
|
35
|
+
entries = await self.outbox.claim_pending(self.batch_size)
|
|
36
|
+
for entry in entries:
|
|
37
|
+
try:
|
|
38
|
+
await self.task_queue.publish(entry.task)
|
|
39
|
+
except Exception as exc: # noqa: BLE001
|
|
40
|
+
logger.warning(
|
|
41
|
+
"outbox entry failed to publish",
|
|
42
|
+
extra={"outbox_id": str(entry.id)},
|
|
43
|
+
exc_info=exc,
|
|
44
|
+
)
|
|
45
|
+
await self.outbox.mark_failed(entry.id, str(exc))
|
|
46
|
+
else:
|
|
47
|
+
await self.outbox.mark_published(entry.id)
|
|
48
|
+
logger.info("outbox entry published", extra={"outbox_id": str(entry.id)})
|
|
49
|
+
return len(entries)
|
|
50
|
+
|
|
51
|
+
async def run_forever(
|
|
52
|
+
self, poll_interval_seconds: float = _DEFAULT_POLL_INTERVAL_SECONDS
|
|
53
|
+
) -> None:
|
|
54
|
+
"""Poll indefinitely. Intended to run as a background task/process,
|
|
55
|
+
not inside a request handler."""
|
|
56
|
+
while True:
|
|
57
|
+
claimed = await self.relay_once()
|
|
58
|
+
if claimed < self.batch_size:
|
|
59
|
+
await asyncio.sleep(poll_interval_seconds)
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import uuid
|
|
4
|
+
from collections.abc import Callable
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from datetime import UTC, datetime
|
|
7
|
+
|
|
8
|
+
from sqlalchemy import select
|
|
9
|
+
from sqlalchemy.ext.asyncio import AsyncSession
|
|
10
|
+
|
|
11
|
+
from taskqueue_toolkit.outbox.orm import (
|
|
12
|
+
STATUS_CLAIMED,
|
|
13
|
+
STATUS_FAILED,
|
|
14
|
+
STATUS_PENDING,
|
|
15
|
+
STATUS_PUBLISHED,
|
|
16
|
+
OutboxRow,
|
|
17
|
+
)
|
|
18
|
+
from taskqueue_toolkit.queue.task_queue import Decoder, Encoder
|
|
19
|
+
|
|
20
|
+
_MAX_ATTEMPTS = 5
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@dataclass(frozen=True, slots=True)
|
|
24
|
+
class OutboxEntry[T]:
|
|
25
|
+
id: uuid.UUID
|
|
26
|
+
task: T
|
|
27
|
+
attempts: int
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@dataclass(slots=True)
|
|
31
|
+
class OutboxRepository[T]:
|
|
32
|
+
session: AsyncSession
|
|
33
|
+
encode: Encoder[T]
|
|
34
|
+
decode: Decoder[T]
|
|
35
|
+
# Optional: derive a caller-meaningful correlation_id (e.g. an aggregate
|
|
36
|
+
# id) from the task, purely for indexed lookups — this package never
|
|
37
|
+
# interprets it itself. Leave unset if the task type has no natural key.
|
|
38
|
+
correlation_id: Callable[[T], str] | None = None
|
|
39
|
+
|
|
40
|
+
async def enqueue(self, task: T) -> None:
|
|
41
|
+
"""Add a task to the outbox within the caller's transaction.
|
|
42
|
+
|
|
43
|
+
Deliberately does not commit — the caller writes this in the same
|
|
44
|
+
transaction as whatever state change the task follows from, so
|
|
45
|
+
either both land or neither does. The relay is what actually
|
|
46
|
+
delivers the task to the broker later.
|
|
47
|
+
"""
|
|
48
|
+
self.session.add(
|
|
49
|
+
OutboxRow(
|
|
50
|
+
id=uuid.uuid4(),
|
|
51
|
+
correlation_id=self.correlation_id(task) if self.correlation_id else None,
|
|
52
|
+
payload=self.encode(task),
|
|
53
|
+
status=STATUS_PENDING,
|
|
54
|
+
attempts=0,
|
|
55
|
+
created_at=datetime.now(UTC),
|
|
56
|
+
)
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
async def claim_pending(self, limit: int) -> list[OutboxEntry[T]]:
|
|
60
|
+
"""Atomically mark up to `limit` pending entries as claimed and
|
|
61
|
+
return them, skipping rows already locked by another relay
|
|
62
|
+
(SKIP LOCKED) so multiple relay instances can run concurrently
|
|
63
|
+
without claiming the same task twice. The row lock is only held for
|
|
64
|
+
this one UPDATE — not across the network call to the broker that
|
|
65
|
+
follows — callers report the outcome via mark_published()/
|
|
66
|
+
mark_failed() afterwards.
|
|
67
|
+
|
|
68
|
+
Only verified against Postgres (this package's own test suite races
|
|
69
|
+
multiple relays against a real Postgres to confirm no double-claim).
|
|
70
|
+
MySQL 8.0+/MariaDB 10.6+ support the same SKIP LOCKED syntax via
|
|
71
|
+
SQLAlchemy but aren't tested here — treat that combination as
|
|
72
|
+
unverified. Older MySQL/MariaDB and SQLite don't give this method
|
|
73
|
+
its concurrency guarantee at all.
|
|
74
|
+
"""
|
|
75
|
+
result = await self.session.execute(
|
|
76
|
+
select(OutboxRow)
|
|
77
|
+
.where(OutboxRow.status == STATUS_PENDING)
|
|
78
|
+
.order_by(OutboxRow.created_at)
|
|
79
|
+
.limit(limit)
|
|
80
|
+
.with_for_update(skip_locked=True)
|
|
81
|
+
)
|
|
82
|
+
rows = result.scalars().all()
|
|
83
|
+
entries = [
|
|
84
|
+
OutboxEntry(id=row.id, task=self.decode(row.payload), attempts=row.attempts)
|
|
85
|
+
for row in rows
|
|
86
|
+
]
|
|
87
|
+
for row in rows:
|
|
88
|
+
row.status = STATUS_CLAIMED
|
|
89
|
+
await self.session.commit()
|
|
90
|
+
return entries
|
|
91
|
+
|
|
92
|
+
async def mark_published(self, entry_id: uuid.UUID) -> None:
|
|
93
|
+
row = await self.session.get(OutboxRow, entry_id)
|
|
94
|
+
if row is None:
|
|
95
|
+
return
|
|
96
|
+
row.status = STATUS_PUBLISHED
|
|
97
|
+
row.published_at = datetime.now(UTC)
|
|
98
|
+
await self.session.commit()
|
|
99
|
+
|
|
100
|
+
async def mark_failed(self, entry_id: uuid.UUID, error: str) -> None:
|
|
101
|
+
row = await self.session.get(OutboxRow, entry_id)
|
|
102
|
+
if row is None:
|
|
103
|
+
return
|
|
104
|
+
row.attempts += 1
|
|
105
|
+
row.last_error = error
|
|
106
|
+
row.status = STATUS_FAILED if row.attempts >= _MAX_ATTEMPTS else STATUS_PENDING
|
|
107
|
+
await self.session.commit()
|
|
File without changes
|
|
File without changes
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from taskqueue_toolkit.queue.dsn import SnsDsn, SqsDsn
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def aws_session_kwargs(dsn: SqsDsn | SnsDsn) -> dict[str, str]:
|
|
7
|
+
"""boto3-style client() kwargs shared by every AWS-backed TaskQueue
|
|
8
|
+
implementation (SQS, SNS). Only includes keys that were actually set, so
|
|
9
|
+
real AWS auth (IAM role, env vars picked up by botocore itself) still
|
|
10
|
+
works when the DSN leaves them out — only LocalStack testing needs them.
|
|
11
|
+
"""
|
|
12
|
+
kwargs = {"region_name": dsn.region}
|
|
13
|
+
if dsn.endpoint_url:
|
|
14
|
+
kwargs["endpoint_url"] = dsn.endpoint_url
|
|
15
|
+
if dsn.access_key_id:
|
|
16
|
+
kwargs["aws_access_key_id"] = dsn.access_key_id
|
|
17
|
+
if dsn.secret_access_key:
|
|
18
|
+
kwargs["aws_secret_access_key"] = dsn.secret_access_key
|
|
19
|
+
return kwargs
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from urllib.parse import parse_qs, urlsplit
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class UnsupportedDsnSchemeError(Exception):
|
|
8
|
+
def __init__(self, scheme: str) -> None:
|
|
9
|
+
super().__init__(
|
|
10
|
+
f"Unsupported task queue DSN scheme: {scheme!r} "
|
|
11
|
+
"(expected one of amqp, redis, sqs, sns, pubsub)"
|
|
12
|
+
)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _query(raw_query: str) -> dict[str, str]:
|
|
16
|
+
return {key: values[0] for key, values in parse_qs(raw_query).items()}
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@dataclass(frozen=True, slots=True)
|
|
20
|
+
class RabbitMqDsn:
|
|
21
|
+
url: str
|
|
22
|
+
queue_name: str
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@dataclass(frozen=True, slots=True)
|
|
26
|
+
class RedisStreamsDsn:
|
|
27
|
+
url: str
|
|
28
|
+
stream_name: str
|
|
29
|
+
group: str
|
|
30
|
+
consumer: str
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@dataclass(frozen=True, slots=True)
|
|
34
|
+
class SqsDsn:
|
|
35
|
+
region: str
|
|
36
|
+
queue_name: str
|
|
37
|
+
endpoint_url: str
|
|
38
|
+
access_key_id: str
|
|
39
|
+
secret_access_key: str
|
|
40
|
+
use_iam_role: bool
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
@dataclass(frozen=True, slots=True)
|
|
44
|
+
class SnsDsn:
|
|
45
|
+
region: str
|
|
46
|
+
topic_name: str
|
|
47
|
+
endpoint_url: str
|
|
48
|
+
access_key_id: str
|
|
49
|
+
secret_access_key: str
|
|
50
|
+
use_iam_role: bool
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
@dataclass(frozen=True, slots=True)
|
|
54
|
+
class PubsubDsn:
|
|
55
|
+
project_id: str
|
|
56
|
+
topic_name: str
|
|
57
|
+
subscription_name: str
|
|
58
|
+
emulator_host: str
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
TaskQueueDsn = RabbitMqDsn | RedisStreamsDsn | SqsDsn | SnsDsn | PubsubDsn
|
|
62
|
+
|
|
63
|
+
_DEFAULT_QUEUE_NAME = "tasks"
|
|
64
|
+
_DEFAULT_STREAM_GROUP = "workers"
|
|
65
|
+
_DEFAULT_STREAM_CONSUMER = "worker-1"
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def parse_task_queue_dsn(dsn: str) -> TaskQueueDsn:
|
|
69
|
+
"""Parse a single connection string into a broker-specific, typed config.
|
|
70
|
+
|
|
71
|
+
The scheme picks the broker (and therefore which TaskQueue
|
|
72
|
+
implementation to build) — everything else the connection needs travels
|
|
73
|
+
in the same string: host/credentials in the URL itself, broker-specific
|
|
74
|
+
extras (queue/topic/group names, region, emulator endpoint, ...) as
|
|
75
|
+
query params. One string per deployment instead of a broker-name setting
|
|
76
|
+
plus a parallel block of per-broker settings.
|
|
77
|
+
|
|
78
|
+
Examples:
|
|
79
|
+
amqp://guest:guest@host:5672/?queue=my.tasks
|
|
80
|
+
redis://:password@host:6379/0?stream=my.tasks&group=workers&consumer=worker-1
|
|
81
|
+
sqs://eu-west-3/my.tasks?endpoint_url=...&access_key_id=...&secret_access_key=...
|
|
82
|
+
sns://eu-west-3/my-tasks?endpoint_url=...&access_key_id=...&secret_access_key=...
|
|
83
|
+
pubsub://project-id/my-tasks?subscription=my-tasks-subscriber&emulator_host=localhost:8085
|
|
84
|
+
"""
|
|
85
|
+
parts = urlsplit(dsn)
|
|
86
|
+
query = _query(parts.query)
|
|
87
|
+
|
|
88
|
+
if parts.scheme in ("amqp", "amqps"):
|
|
89
|
+
return RabbitMqDsn(
|
|
90
|
+
url=dsn.split("?", 1)[0],
|
|
91
|
+
queue_name=query.get("queue", _DEFAULT_QUEUE_NAME),
|
|
92
|
+
)
|
|
93
|
+
|
|
94
|
+
if parts.scheme in ("redis", "rediss"):
|
|
95
|
+
return RedisStreamsDsn(
|
|
96
|
+
url=dsn.split("?", 1)[0],
|
|
97
|
+
stream_name=query.get("stream", _DEFAULT_QUEUE_NAME),
|
|
98
|
+
group=query.get("group", _DEFAULT_STREAM_GROUP),
|
|
99
|
+
consumer=query.get("consumer", _DEFAULT_STREAM_CONSUMER),
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
if parts.scheme == "sqs":
|
|
103
|
+
return SqsDsn(
|
|
104
|
+
region=parts.hostname or "",
|
|
105
|
+
queue_name=parts.path.lstrip("/"),
|
|
106
|
+
endpoint_url=query.get("endpoint_url", ""),
|
|
107
|
+
access_key_id=query.get("access_key_id", ""),
|
|
108
|
+
secret_access_key=query.get("secret_access_key", ""),
|
|
109
|
+
use_iam_role=query.get("iam_role", "").lower() == "true",
|
|
110
|
+
)
|
|
111
|
+
|
|
112
|
+
if parts.scheme == "sns":
|
|
113
|
+
return SnsDsn(
|
|
114
|
+
region=parts.hostname or "",
|
|
115
|
+
topic_name=parts.path.lstrip("/"),
|
|
116
|
+
endpoint_url=query.get("endpoint_url", ""),
|
|
117
|
+
access_key_id=query.get("access_key_id", ""),
|
|
118
|
+
secret_access_key=query.get("secret_access_key", ""),
|
|
119
|
+
use_iam_role=query.get("iam_role", "").lower() == "true",
|
|
120
|
+
)
|
|
121
|
+
|
|
122
|
+
if parts.scheme == "pubsub":
|
|
123
|
+
topic_name = parts.path.lstrip("/")
|
|
124
|
+
return PubsubDsn(
|
|
125
|
+
project_id=parts.hostname or "",
|
|
126
|
+
topic_name=topic_name,
|
|
127
|
+
subscription_name=query.get("subscription", f"{topic_name}-subscriber"),
|
|
128
|
+
emulator_host=query.get("emulator_host", ""),
|
|
129
|
+
)
|
|
130
|
+
|
|
131
|
+
raise UnsupportedDsnSchemeError(parts.scheme)
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def missing_production_aws_auth(dsn: TaskQueueDsn) -> bool:
|
|
135
|
+
"""True when an sqs/sns DSN has no way to authenticate against real AWS:
|
|
136
|
+
no LocalStack endpoint, no explicit keys, and no iam_role=true opt-in.
|
|
137
|
+
Not applicable (returns False) to every other DSN type. Split out from
|
|
138
|
+
parsing so a caller's own production fail-fast check can call it without
|
|
139
|
+
needing to know anything about AWS-specific DSN fields.
|
|
140
|
+
"""
|
|
141
|
+
if not isinstance(dsn, SqsDsn | SnsDsn):
|
|
142
|
+
return False
|
|
143
|
+
return (
|
|
144
|
+
not dsn.endpoint_url
|
|
145
|
+
and not dsn.use_iam_role
|
|
146
|
+
and (not dsn.access_key_id or not dsn.secret_access_key)
|
|
147
|
+
)
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import assert_never
|
|
4
|
+
from urllib.parse import urlsplit
|
|
5
|
+
|
|
6
|
+
from taskqueue_toolkit.queue.dsn import (
|
|
7
|
+
PubsubDsn,
|
|
8
|
+
RabbitMqDsn,
|
|
9
|
+
RedisStreamsDsn,
|
|
10
|
+
SnsDsn,
|
|
11
|
+
SqsDsn,
|
|
12
|
+
UnsupportedDsnSchemeError,
|
|
13
|
+
missing_production_aws_auth,
|
|
14
|
+
parse_task_queue_dsn,
|
|
15
|
+
)
|
|
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
|
+
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
|
+
from taskqueue_toolkit.queue.task_queue import Decoder, Encoder, TaskQueue
|
|
23
|
+
|
|
24
|
+
_BUILT_IN_SCHEMES = frozenset({"amqp", "amqps", "redis", "rediss", "sqs", "sns", "pubsub"})
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class MissingAwsAuthError(Exception):
|
|
28
|
+
def __init__(self, environment: str) -> None:
|
|
29
|
+
super().__init__(
|
|
30
|
+
f"environment={environment!r} but the task queue DSN has no way "
|
|
31
|
+
"to authenticate against AWS: no endpoint_url (LocalStack), no "
|
|
32
|
+
"access_key_id/secret_access_key, and no iam_role=true. Add "
|
|
33
|
+
"credentials, or add '&iam_role=true' if auth is handled by an "
|
|
34
|
+
"IAM role (ECS task role, EKS service account, EC2 instance "
|
|
35
|
+
"profile) — refusing to build the queue with no AWS auth "
|
|
36
|
+
"configured outside development."
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def create_task_queue[T](
|
|
41
|
+
dsn: str,
|
|
42
|
+
*,
|
|
43
|
+
encode: Encoder[T],
|
|
44
|
+
decode: Decoder[T],
|
|
45
|
+
environment: str = "development",
|
|
46
|
+
) -> TaskQueue[T]:
|
|
47
|
+
"""Build the TaskQueue implementation described by a single DSN. The
|
|
48
|
+
scheme picks the adapter — amqp/amqps -> RabbitMQ, redis/rediss -> Redis
|
|
49
|
+
Streams, sqs/sns/pubsub -> themselves — and everything else the
|
|
50
|
+
connection needs (queue/topic names, region, credentials, emulator
|
|
51
|
+
target, ...) travels in the same string. This is the one place in this
|
|
52
|
+
package allowed to know every concrete adapter exists; callers depend on
|
|
53
|
+
TaskQueue[T] only, so switching broker is a DSN change, not a code
|
|
54
|
+
change.
|
|
55
|
+
|
|
56
|
+
encode/decode are required because this package has no opinion on how
|
|
57
|
+
your task type T serializes — every adapter needs a bytes-in/bytes-out
|
|
58
|
+
pair to actually move a T through its broker.
|
|
59
|
+
|
|
60
|
+
`environment` gates one fail-fast check: outside development, an
|
|
61
|
+
sqs/sns DSN with no LocalStack endpoint, no explicit keys, and no
|
|
62
|
+
iam_role=true opt-in is almost certainly a forgotten credential, not a
|
|
63
|
+
real deployment — refuse to build the adapter rather than let it fail
|
|
64
|
+
confusingly on the first network call.
|
|
65
|
+
|
|
66
|
+
A scheme this package doesn't ship a built-in adapter for is looked up
|
|
67
|
+
in the registry (see registry.register_scheme()) before giving up with
|
|
68
|
+
UnsupportedDsnSchemeError — that's how a broker outside this package's
|
|
69
|
+
five (amqp/redis/sqs/sns/pubsub) gets plugged in.
|
|
70
|
+
"""
|
|
71
|
+
scheme = urlsplit(dsn).scheme
|
|
72
|
+
if scheme not in _BUILT_IN_SCHEMES:
|
|
73
|
+
handler = resolve_registered_scheme(dsn)
|
|
74
|
+
if handler is not None:
|
|
75
|
+
return handler(dsn, encode, decode)
|
|
76
|
+
raise UnsupportedDsnSchemeError(scheme)
|
|
77
|
+
|
|
78
|
+
parsed = parse_task_queue_dsn(dsn)
|
|
79
|
+
|
|
80
|
+
if environment != "development" and missing_production_aws_auth(parsed):
|
|
81
|
+
raise MissingAwsAuthError(environment)
|
|
82
|
+
|
|
83
|
+
if isinstance(parsed, RabbitMqDsn):
|
|
84
|
+
return RabbitMqTaskQueue(dsn=parsed, encode=encode, decode=decode)
|
|
85
|
+
if isinstance(parsed, RedisStreamsDsn):
|
|
86
|
+
return RedisStreamsTaskQueue(dsn=parsed, encode=encode, decode=decode)
|
|
87
|
+
if isinstance(parsed, SqsDsn):
|
|
88
|
+
return SqsTaskQueue(dsn=parsed, encode=encode, decode=decode)
|
|
89
|
+
if isinstance(parsed, SnsDsn):
|
|
90
|
+
return SnsTaskQueue(dsn=parsed, encode=encode, decode=decode)
|
|
91
|
+
if isinstance(parsed, PubsubDsn):
|
|
92
|
+
return PubsubTaskQueue(dsn=parsed, encode=encode, decode=decode)
|
|
93
|
+
|
|
94
|
+
assert_never(parsed)
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import logging
|
|
5
|
+
import os
|
|
6
|
+
from collections.abc import AsyncIterator, Callable
|
|
7
|
+
from contextlib import suppress
|
|
8
|
+
from dataclasses import dataclass, field
|
|
9
|
+
|
|
10
|
+
from google.api_core.exceptions import AlreadyExists
|
|
11
|
+
from google.cloud import pubsub_v1
|
|
12
|
+
|
|
13
|
+
from taskqueue_toolkit.queue.dsn import PubsubDsn
|
|
14
|
+
from taskqueue_toolkit.queue.task_queue import Decoder, Encoder
|
|
15
|
+
|
|
16
|
+
logger = logging.getLogger(__name__)
|
|
17
|
+
|
|
18
|
+
_PULL_TIMEOUT_SECONDS = 10.0
|
|
19
|
+
|
|
20
|
+
PublisherFactory = Callable[[], "pubsub_v1.PublisherClient"]
|
|
21
|
+
SubscriberFactory = Callable[[], "pubsub_v1.SubscriberClient"]
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@dataclass(slots=True)
|
|
25
|
+
class PubsubQueuedTask[T]:
|
|
26
|
+
_subscriber: pubsub_v1.SubscriberClient
|
|
27
|
+
_subscription_path: str
|
|
28
|
+
_ack_id: str
|
|
29
|
+
task: T
|
|
30
|
+
|
|
31
|
+
async def ack(self) -> None:
|
|
32
|
+
await asyncio.to_thread(
|
|
33
|
+
self._subscriber.acknowledge,
|
|
34
|
+
subscription=self._subscription_path,
|
|
35
|
+
ack_ids=[self._ack_id],
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
async def nack(self, *, requeue: bool) -> None:
|
|
39
|
+
if requeue:
|
|
40
|
+
# No dedicated "nack" RPC — resetting the ack deadline to 0
|
|
41
|
+
# makes the message immediately re-deliverable, same idea as
|
|
42
|
+
# SQS's change_message_visibility(VisibilityTimeout=0).
|
|
43
|
+
await asyncio.to_thread(
|
|
44
|
+
self._subscriber.modify_ack_deadline,
|
|
45
|
+
subscription=self._subscription_path,
|
|
46
|
+
ack_ids=[self._ack_id],
|
|
47
|
+
ack_deadline_seconds=0,
|
|
48
|
+
)
|
|
49
|
+
else:
|
|
50
|
+
await asyncio.to_thread(
|
|
51
|
+
self._subscriber.acknowledge,
|
|
52
|
+
subscription=self._subscription_path,
|
|
53
|
+
ack_ids=[self._ack_id],
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
@dataclass(slots=True)
|
|
58
|
+
class PubsubTaskQueue[T]:
|
|
59
|
+
"""Pub/Sub is fan-out only, like SNS — no receive on a topic directly.
|
|
60
|
+
|
|
61
|
+
consume() needs a subscription to pull from, so this adapter provisions
|
|
62
|
+
one dedicated subscription per topic (create_topic and create_subscription
|
|
63
|
+
both raise AlreadyExists — not silently no-op like AWS's idempotent
|
|
64
|
+
create_topic/subscribe — so that's caught explicitly rather than relied
|
|
65
|
+
on to be a no-op).
|
|
66
|
+
|
|
67
|
+
The official client (google-cloud-pubsub) is synchronous gRPC, not
|
|
68
|
+
asyncio-native — every call here runs through asyncio.to_thread rather
|
|
69
|
+
than reimplementing an async gRPC stack for a single adapter.
|
|
70
|
+
"""
|
|
71
|
+
|
|
72
|
+
dsn: PubsubDsn
|
|
73
|
+
encode: Encoder[T]
|
|
74
|
+
decode: Decoder[T]
|
|
75
|
+
# Overridable for tests (inject fake clients) or a shared client the
|
|
76
|
+
# 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)
|
|
81
|
+
|
|
82
|
+
def _apply_emulator_host(self) -> None:
|
|
83
|
+
# The official client only knows to target an emulator via this env
|
|
84
|
+
# var (it swaps in an insecure channel + anonymous credentials as a
|
|
85
|
+
# unit) — there's no supported constructor kwarg that does the same
|
|
86
|
+
# thing as reliably. Setting it here, from the DSN, right before the
|
|
87
|
+
# first client is built keeps the emulator target fully described by
|
|
88
|
+
# the DSN instead of requiring a second, separate env var.
|
|
89
|
+
if self.dsn.emulator_host:
|
|
90
|
+
os.environ["PUBSUB_EMULATOR_HOST"] = self.dsn.emulator_host
|
|
91
|
+
|
|
92
|
+
@property
|
|
93
|
+
def _publisher(self) -> pubsub_v1.PublisherClient:
|
|
94
|
+
# Built lazily, not at __init__ time (e.g. via default_factory) —
|
|
95
|
+
# constructing the client eagerly authenticates against GCP
|
|
96
|
+
# immediately, which fails outside an environment with real or
|
|
97
|
+
# emulated credentials even if this instance is never actually used.
|
|
98
|
+
if self._publisher_client is None:
|
|
99
|
+
self._apply_emulator_host()
|
|
100
|
+
self._publisher_client = self.publisher_factory()
|
|
101
|
+
return self._publisher_client
|
|
102
|
+
|
|
103
|
+
@property
|
|
104
|
+
def _subscriber(self) -> pubsub_v1.SubscriberClient:
|
|
105
|
+
if self._subscriber_client is None:
|
|
106
|
+
self._apply_emulator_host()
|
|
107
|
+
self._subscriber_client = self.subscriber_factory()
|
|
108
|
+
return self._subscriber_client
|
|
109
|
+
|
|
110
|
+
def _topic_path(self) -> str:
|
|
111
|
+
return str(self._publisher.topic_path(self.dsn.project_id, self.dsn.topic_name))
|
|
112
|
+
|
|
113
|
+
def _subscription_path(self) -> str:
|
|
114
|
+
return str(
|
|
115
|
+
self._subscriber.subscription_path(self.dsn.project_id, self.dsn.subscription_name)
|
|
116
|
+
)
|
|
117
|
+
|
|
118
|
+
async def _ensure_topic(self) -> str:
|
|
119
|
+
topic_path = self._topic_path()
|
|
120
|
+
with suppress(AlreadyExists):
|
|
121
|
+
await asyncio.to_thread(self._publisher.create_topic, name=topic_path)
|
|
122
|
+
return topic_path
|
|
123
|
+
|
|
124
|
+
async def _ensure_subscription(self, topic_path: str) -> str:
|
|
125
|
+
subscription_path = self._subscription_path()
|
|
126
|
+
with suppress(AlreadyExists):
|
|
127
|
+
await asyncio.to_thread(
|
|
128
|
+
self._subscriber.create_subscription, name=subscription_path, topic=topic_path
|
|
129
|
+
)
|
|
130
|
+
return subscription_path
|
|
131
|
+
|
|
132
|
+
async def publish(self, task: T) -> None:
|
|
133
|
+
topic_path = await self._ensure_topic()
|
|
134
|
+
# Pub/Sub fan-out only delivers to subscriptions that already exist
|
|
135
|
+
# at publish time — a message published before the subscription is
|
|
136
|
+
# provisioned is silently dropped, same trap as SNS. Ensuring it
|
|
137
|
+
# here, not just in consume(), keeps the first publish after a cold
|
|
138
|
+
# deploy from being lost.
|
|
139
|
+
await self._ensure_subscription(topic_path)
|
|
140
|
+
|
|
141
|
+
future = self._publisher.publish(topic_path, self.encode(task))
|
|
142
|
+
await asyncio.to_thread(future.result, timeout=10)
|
|
143
|
+
logger.info("task published", extra={"topic": self.dsn.topic_name})
|
|
144
|
+
|
|
145
|
+
async def consume(self) -> AsyncIterator[PubsubQueuedTask[T]]:
|
|
146
|
+
topic_path = await self._ensure_topic()
|
|
147
|
+
subscription_path = await self._ensure_subscription(topic_path)
|
|
148
|
+
|
|
149
|
+
while True:
|
|
150
|
+
response = await asyncio.to_thread(
|
|
151
|
+
self._subscriber.pull,
|
|
152
|
+
subscription=subscription_path,
|
|
153
|
+
max_messages=1,
|
|
154
|
+
timeout=_PULL_TIMEOUT_SECONDS,
|
|
155
|
+
)
|
|
156
|
+
for received in response.received_messages:
|
|
157
|
+
yield PubsubQueuedTask(
|
|
158
|
+
_subscriber=self._subscriber,
|
|
159
|
+
_subscription_path=subscription_path,
|
|
160
|
+
_ack_id=received.ack_id,
|
|
161
|
+
task=self.decode(received.message.data),
|
|
162
|
+
)
|