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.
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
+ )