pgdispatch 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.
Files changed (54) hide show
  1. pgdispatch/__init__.py +69 -0
  2. pgdispatch/__main__.py +5 -0
  3. pgdispatch/_version.py +1 -0
  4. pgdispatch/backends/__init__.py +6 -0
  5. pgdispatch/backends/asyncpg.py +103 -0
  6. pgdispatch/backends/base.py +142 -0
  7. pgdispatch/cli.py +420 -0
  8. pgdispatch/client.py +490 -0
  9. pgdispatch/config.py +223 -0
  10. pgdispatch/core/__init__.py +1 -0
  11. pgdispatch/core/circuit.py +94 -0
  12. pgdispatch/core/common.py +87 -0
  13. pgdispatch/core/deliveries.py +216 -0
  14. pgdispatch/core/endpoints.py +338 -0
  15. pgdispatch/core/events.py +65 -0
  16. pgdispatch/core/maintenance.py +121 -0
  17. pgdispatch/core/matching.py +76 -0
  18. pgdispatch/core/payload.py +108 -0
  19. pgdispatch/core/publisher.py +232 -0
  20. pgdispatch/core/retry.py +90 -0
  21. pgdispatch/core/secrets.py +138 -0
  22. pgdispatch/core/signing.py +141 -0
  23. pgdispatch/django/__init__.py +37 -0
  24. pgdispatch/django/apps.py +10 -0
  25. pgdispatch/django/client.py +402 -0
  26. pgdispatch/django/management/__init__.py +0 -0
  27. pgdispatch/django/management/commands/__init__.py +0 -0
  28. pgdispatch/django/management/commands/pgdispatch_worker.py +32 -0
  29. pgdispatch/django/migrations/0001_initial.py +30 -0
  30. pgdispatch/django/migrations/__init__.py +0 -0
  31. pgdispatch/errors.py +43 -0
  32. pgdispatch/fastapi/__init__.py +23 -0
  33. pgdispatch/fastapi/router.py +243 -0
  34. pgdispatch/hooks.py +80 -0
  35. pgdispatch/metrics.py +80 -0
  36. pgdispatch/migrations/0001_initial.sql +104 -0
  37. pgdispatch/migrations/__init__.py +93 -0
  38. pgdispatch/models.py +208 -0
  39. pgdispatch/py.typed +0 -0
  40. pgdispatch/security/__init__.py +1 -0
  41. pgdispatch/security/ssrf.py +141 -0
  42. pgdispatch/sqlalchemy.py +130 -0
  43. pgdispatch/worker/__init__.py +5 -0
  44. pgdispatch/worker/fetcher.py +183 -0
  45. pgdispatch/worker/ratelimit.py +47 -0
  46. pgdispatch/worker/recorder.py +435 -0
  47. pgdispatch/worker/runner.py +36 -0
  48. pgdispatch/worker/sender.py +160 -0
  49. pgdispatch/worker/worker.py +557 -0
  50. pgdispatch-0.1.0.dist-info/METADATA +329 -0
  51. pgdispatch-0.1.0.dist-info/RECORD +54 -0
  52. pgdispatch-0.1.0.dist-info/WHEEL +4 -0
  53. pgdispatch-0.1.0.dist-info/entry_points.txt +2 -0
  54. pgdispatch-0.1.0.dist-info/licenses/LICENSE +21 -0
pgdispatch/models.py ADDED
@@ -0,0 +1,208 @@
1
+ """Immutable records returned by the public API."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import uuid
7
+ from collections.abc import Mapping
8
+ from dataclasses import dataclass, field
9
+ from datetime import datetime
10
+ from enum import Enum
11
+ from typing import Any
12
+
13
+ __all__ = [
14
+ "Attempt",
15
+ "CircuitState",
16
+ "Delivery",
17
+ "DeliveryStatus",
18
+ "Endpoint",
19
+ "Event",
20
+ "PublishResult",
21
+ ]
22
+
23
+
24
+ class DeliveryStatus(str, Enum):
25
+ """Lifecycle of a delivery."""
26
+
27
+ PENDING = "pending"
28
+ """Waiting for its next attempt."""
29
+ IN_FLIGHT = "in_flight"
30
+ """Claimed by a worker that is sending it right now."""
31
+ SUCCEEDED = "succeeded"
32
+ """The endpoint answered with a 2xx status."""
33
+ FAILED = "failed"
34
+ """All attempts were exhausted; can be retried manually."""
35
+ CANCELLED = "cancelled"
36
+ """Abandoned, e.g. because the endpoint answered ``410 Gone``."""
37
+
38
+ @property
39
+ def is_final(self) -> bool:
40
+ return self in (DeliveryStatus.SUCCEEDED, DeliveryStatus.FAILED, DeliveryStatus.CANCELLED)
41
+
42
+
43
+ class CircuitState(str, Enum):
44
+ """State of an endpoint's circuit breaker."""
45
+
46
+ CLOSED = "closed"
47
+ """Healthy: deliveries flow normally."""
48
+ OPEN = "open"
49
+ """Failing: no deliveries are sent until the cooldown elapses."""
50
+ HALF_OPEN = "half_open"
51
+ """Cooldown elapsed: a single probe delivery decides whether to close again."""
52
+
53
+
54
+ @dataclass(frozen=True, slots=True)
55
+ class Endpoint:
56
+ """A receiver URL with its own signing secret and settings."""
57
+
58
+ id: uuid.UUID
59
+ url: str
60
+ owner_id: str | None
61
+ description: str | None
62
+ event_types: tuple[str, ...]
63
+ headers: Mapping[str, str]
64
+ rate_limit_rps: float | None
65
+ is_enabled: bool
66
+ disabled_reason: str | None
67
+ circuit_state: CircuitState
68
+ consecutive_failures: int
69
+ failing_since: datetime | None
70
+ created_at: datetime
71
+ updated_at: datetime
72
+ secret: str | None = field(default=None, repr=False)
73
+ """The signing secret. Only populated by ``create``, ``rotate_secret`` and ``get_secret``."""
74
+
75
+ @classmethod
76
+ def from_row(cls, row: Mapping[str, Any], *, secret: str | None = None) -> Endpoint:
77
+ return cls(
78
+ id=to_uuid(row["id"]),
79
+ url=row["url"],
80
+ owner_id=row["owner_id"],
81
+ description=row["description"],
82
+ event_types=tuple(row["event_types"]),
83
+ headers=load_json(row["headers"]),
84
+ rate_limit_rps=row["rate_limit_rps"],
85
+ is_enabled=row["is_enabled"],
86
+ disabled_reason=row["disabled_reason"],
87
+ circuit_state=CircuitState(row["circuit_state"]),
88
+ consecutive_failures=row["consecutive_failures"],
89
+ failing_since=row["failing_since"],
90
+ created_at=row["created_at"],
91
+ updated_at=row["updated_at"],
92
+ secret=secret,
93
+ )
94
+
95
+
96
+ @dataclass(frozen=True, slots=True)
97
+ class Event:
98
+ """An immutable fact that happened in your system."""
99
+
100
+ id: uuid.UUID
101
+ event_type: str
102
+ owner_id: str | None
103
+ payload: Any
104
+ idempotency_key: str | None
105
+ created_at: datetime
106
+
107
+ @classmethod
108
+ def from_row(cls, row: Mapping[str, Any]) -> Event:
109
+ return cls(
110
+ id=to_uuid(row["id"]),
111
+ event_type=row["event_type"],
112
+ owner_id=row["owner_id"],
113
+ payload=json.loads(row["payload"]),
114
+ idempotency_key=row["idempotency_key"],
115
+ created_at=row["created_at"],
116
+ )
117
+
118
+
119
+ @dataclass(frozen=True, slots=True)
120
+ class Delivery:
121
+ """The task of delivering one event to one endpoint."""
122
+
123
+ id: int
124
+ event_id: uuid.UUID
125
+ endpoint_id: uuid.UUID
126
+ event_type: str
127
+ ordering_key: str | None
128
+ status: DeliveryStatus
129
+ attempt_count: int
130
+ next_attempt_at: datetime
131
+ last_status_code: int | None
132
+ last_error: str | None
133
+ created_at: datetime
134
+ finished_at: datetime | None
135
+
136
+ @classmethod
137
+ def from_row(cls, row: Mapping[str, Any]) -> Delivery:
138
+ return cls(
139
+ id=row["id"],
140
+ event_id=to_uuid(row["event_id"]),
141
+ endpoint_id=to_uuid(row["endpoint_id"]),
142
+ event_type=row["event_type"],
143
+ ordering_key=row["ordering_key"],
144
+ status=DeliveryStatus(row["status"]),
145
+ attempt_count=row["attempt_count"],
146
+ next_attempt_at=row["next_attempt_at"],
147
+ last_status_code=row["last_status_code"],
148
+ last_error=row["last_error"],
149
+ created_at=row["created_at"],
150
+ finished_at=row["finished_at"],
151
+ )
152
+
153
+
154
+ @dataclass(frozen=True, slots=True)
155
+ class Attempt:
156
+ """A single HTTP request made for a delivery, with the receiver's answer."""
157
+
158
+ id: int
159
+ delivery_id: int
160
+ attempt_number: int
161
+ request_headers: Mapping[str, str]
162
+ response_status: int | None
163
+ response_body: str | None
164
+ duration_ms: int | None
165
+ error: str | None
166
+ created_at: datetime
167
+
168
+ @property
169
+ def succeeded(self) -> bool:
170
+ return self.response_status is not None and 200 <= self.response_status < 300
171
+
172
+ @classmethod
173
+ def from_row(cls, row: Mapping[str, Any]) -> Attempt:
174
+ return cls(
175
+ id=row["id"],
176
+ delivery_id=row["delivery_id"],
177
+ attempt_number=row["attempt_number"],
178
+ request_headers=load_json(row["request_headers"]) or {},
179
+ response_status=row["response_status"],
180
+ response_body=row["response_body"],
181
+ duration_ms=row["duration_ms"],
182
+ error=row["error"],
183
+ created_at=row["created_at"],
184
+ )
185
+
186
+
187
+ @dataclass(frozen=True, slots=True)
188
+ class PublishResult:
189
+ """Outcome of :meth:`Webhooks.publish <pgdispatch.Webhooks.publish>`."""
190
+
191
+ event_id: uuid.UUID
192
+ deliveries: int
193
+ """Number of deliveries created, one per subscribed endpoint."""
194
+ duplicate: bool = False
195
+ """``True`` if an event with the same idempotency key already existed; nothing was created."""
196
+
197
+
198
+ def to_uuid(value: Any) -> uuid.UUID:
199
+ # asyncpg returns its own UUID subclass; normalise for equality and hashing.
200
+ return value if type(value) is uuid.UUID else uuid.UUID(str(value))
201
+
202
+
203
+ def load_json(value: Any) -> Any:
204
+ # Columns are selected as text so every driver behaves the same, but accept
205
+ # already-decoded values too.
206
+ if isinstance(value, (str, bytes)):
207
+ return json.loads(value)
208
+ return value
pgdispatch/py.typed ADDED
File without changes
@@ -0,0 +1 @@
1
+ """Outbound request safety."""
@@ -0,0 +1,141 @@
1
+ """Server-side request forgery (SSRF) protection.
2
+
3
+ Endpoint URLs are supplied by your customers, so without protection anyone
4
+ could point the worker at ``http://169.254.169.254/`` (cloud metadata),
5
+ ``http://localhost:5432`` or an internal admin panel. pgdispatch defends in two
6
+ places:
7
+
8
+ 1. :func:`validate_endpoint_url` runs when an endpoint is created or updated:
9
+ only ``https`` (``http`` with ``allow_http``), no credentials in the URL, and
10
+ no literal private addresses.
11
+ 2. :class:`SafeResolver` runs on every connection: the host name is resolved,
12
+ *all* resulting addresses must be public, and aiohttp connects to exactly
13
+ the addresses that were checked. Re-resolving the name later is impossible,
14
+ which closes the DNS-rebinding hole. TLS still verifies the certificate
15
+ against the original host name. Literal IP hosts never reach a resolver, so
16
+ :func:`check_url_host` vets them before each request.
17
+
18
+ Redirects are never followed, so a public endpoint cannot bounce the worker to
19
+ a private one either.
20
+ """
21
+
22
+ from __future__ import annotations
23
+
24
+ import ipaddress
25
+ import socket
26
+ from urllib.parse import urlsplit
27
+
28
+ import aiohttp
29
+ from aiohttp.abc import AbstractResolver, ResolveResult
30
+
31
+ from pgdispatch.errors import UnsafeURLError
32
+
33
+ __all__ = [
34
+ "BlockedAddressError",
35
+ "SafeResolver",
36
+ "check_url_host",
37
+ "is_public_address",
38
+ "validate_endpoint_url",
39
+ ]
40
+
41
+ IPAddress = ipaddress.IPv4Address | ipaddress.IPv6Address
42
+
43
+ MAX_URL_LENGTH = 2048
44
+ _LOCAL_HOST_SUFFIXES = (".localhost", ".local", ".internal", ".localdomain")
45
+
46
+
47
+ class BlockedAddressError(OSError):
48
+ """The endpoint host resolves to an address the worker must not connect to."""
49
+
50
+
51
+ def is_public_address(address: IPAddress) -> bool:
52
+ """Tell whether ``address`` is a globally routable unicast address.
53
+
54
+ Rejects private, loopback, link-local, multicast, reserved, shared (CGNAT)
55
+ and documentation ranges, and IPv6 addresses that embed a non-public IPv4
56
+ address (IPv4-mapped, 6to4, Teredo).
57
+ """
58
+ if isinstance(address, ipaddress.IPv6Address):
59
+ embedded = address.ipv4_mapped or address.sixtofour
60
+ if embedded is not None:
61
+ return is_public_address(embedded)
62
+ if address.teredo is not None:
63
+ return all(is_public_address(part) for part in address.teredo)
64
+ return address.is_global and not address.is_multicast
65
+
66
+
67
+ def validate_endpoint_url(
68
+ url: str, *, allow_http: bool = False, allow_private_ips: bool = False
69
+ ) -> str:
70
+ """Return ``url`` if it may be used as an endpoint, else raise :class:`UnsafeURLError`."""
71
+ if len(url) > MAX_URL_LENGTH:
72
+ msg = f"URL is longer than {MAX_URL_LENGTH} characters"
73
+ raise UnsafeURLError(msg)
74
+ try:
75
+ parts = urlsplit(url)
76
+ port = parts.port
77
+ except ValueError as exc:
78
+ msg = f"invalid URL: {exc}"
79
+ raise UnsafeURLError(msg) from exc
80
+
81
+ allowed_schemes = ("https", "http") if allow_http else ("https",)
82
+ if parts.scheme not in allowed_schemes:
83
+ hint = "" if allow_http else " (plain http needs allow_http=True, for development only)"
84
+ msg = f"URL scheme must be {' or '.join(allowed_schemes)}{hint}"
85
+ raise UnsafeURLError(msg)
86
+ if parts.username is not None or parts.password is not None:
87
+ msg = "URL must not contain credentials; use custom endpoint headers instead"
88
+ raise UnsafeURLError(msg)
89
+ host = parts.hostname
90
+ if not host:
91
+ msg = "URL must contain a host name"
92
+ raise UnsafeURLError(msg)
93
+ if port == 0:
94
+ msg = "URL port must not be 0"
95
+ raise UnsafeURLError(msg)
96
+
97
+ if not allow_private_ips:
98
+ address = _parse_ip(host)
99
+ if address is not None and not is_public_address(address):
100
+ msg = f"URL points to a non-public address ({address})"
101
+ raise UnsafeURLError(msg)
102
+ if address is None and (host == "localhost" or host.endswith(_LOCAL_HOST_SUFFIXES)):
103
+ msg = f"URL points to a local host name ({host})"
104
+ raise UnsafeURLError(msg)
105
+ return url
106
+
107
+
108
+ class SafeResolver(AbstractResolver):
109
+ """An aiohttp resolver that refuses host names resolving to non-public addresses."""
110
+
111
+ def __init__(self, inner: AbstractResolver | None = None) -> None:
112
+ self._inner = inner or aiohttp.ThreadedResolver()
113
+
114
+ async def resolve(
115
+ self, host: str, port: int = 0, family: socket.AddressFamily = socket.AF_INET
116
+ ) -> list[ResolveResult]:
117
+ results = await self._inner.resolve(host, port, family)
118
+ for result in results:
119
+ address = ipaddress.ip_address(result["host"].split("%", 1)[0])
120
+ if not is_public_address(address):
121
+ msg = f"refusing to connect to {host}: it resolves to non-public address {address}"
122
+ raise BlockedAddressError(msg)
123
+ return results
124
+
125
+ async def close(self) -> None:
126
+ await self._inner.close()
127
+
128
+
129
+ def check_url_host(url: str) -> None:
130
+ """Raise :class:`BlockedAddressError` if ``url`` has a literal non-public IP host."""
131
+ address = _parse_ip(urlsplit(url).hostname or "")
132
+ if address is not None and not is_public_address(address):
133
+ msg = f"refusing to connect to non-public address {address}"
134
+ raise BlockedAddressError(msg)
135
+
136
+
137
+ def _parse_ip(host: str) -> IPAddress | None:
138
+ try:
139
+ return ipaddress.ip_address(host.strip("[]").split("%", 1)[0])
140
+ except ValueError:
141
+ return None
@@ -0,0 +1,130 @@
1
+ """SQLAlchemy 2.0 (async) integration.
2
+
3
+ ::
4
+
5
+ from pgdispatch import Webhooks
6
+ from pgdispatch.sqlalchemy import SQLAlchemyBackend
7
+
8
+ webhooks = Webhooks(SQLAlchemyBackend(engine))
9
+
10
+ async with session.begin():
11
+ session.add(order)
12
+ await webhooks.publish(session, "order.created", {"id": order.id})
13
+
14
+ Any async PostgreSQL driver works for publishing (``asyncpg``, ``psycopg``).
15
+ The worker always uses asyncpg with connection settings derived from the
16
+ engine URL; pass ``worker_dsn`` to override them.
17
+
18
+ Alembic users add the pgdispatch schema to their own migration chain::
19
+
20
+ from alembic import op
21
+ from pgdispatch.sqlalchemy import run_migrations
22
+
23
+ def upgrade() -> None:
24
+ run_migrations(op.get_bind())
25
+ """
26
+
27
+ from __future__ import annotations
28
+
29
+ from collections.abc import AsyncIterator, Mapping
30
+ from contextlib import asynccontextmanager
31
+ from functools import lru_cache
32
+ from typing import Any, cast
33
+
34
+ import asyncpg
35
+
36
+ try:
37
+ from sqlalchemy import Connection, CursorResult, TextClause, text
38
+ from sqlalchemy.engine import URL
39
+ from sqlalchemy.ext.asyncio import AsyncConnection, AsyncEngine, AsyncSession
40
+ except ImportError as exc: # pragma: no cover - depends on the environment
41
+ msg = 'pgdispatch.sqlalchemy requires SQLAlchemy: pip install "pgdispatch[sqlalchemy]"'
42
+ raise ImportError(msg) from exc
43
+
44
+ from pgdispatch.backends.asyncpg import WORKER_POOL_SIZE
45
+ from pgdispatch.backends.base import Row, run_sync
46
+ from pgdispatch.migrations import migrate
47
+
48
+ __all__ = ["SQLAlchemyBackend", "SQLAlchemyExecutor", "run_migrations"]
49
+
50
+
51
+ @lru_cache(maxsize=512)
52
+ def _compiled(sql: str) -> TextClause:
53
+ return text(sql)
54
+
55
+
56
+ class SQLAlchemyExecutor:
57
+ """Runs statements on an ``AsyncSession`` or ``AsyncConnection``, inside its transaction."""
58
+
59
+ def __init__(self, connection: AsyncSession | AsyncConnection) -> None:
60
+ self._connection = connection
61
+
62
+ async def fetch(self, sql: str, params: Mapping[str, Any]) -> list[Row]:
63
+ result = cast(
64
+ "CursorResult[Any]", await self._connection.execute(_compiled(sql), dict(params))
65
+ )
66
+ if not result.returns_rows:
67
+ return []
68
+ return [dict(row) for row in result.mappings()]
69
+
70
+
71
+ class _SyncExecutor:
72
+ def __init__(self, connection: Connection) -> None:
73
+ self._connection = connection
74
+
75
+ def fetch(self, sql: str, params: Mapping[str, Any]) -> list[Row]:
76
+ result = cast("CursorResult[Any]", self._connection.execute(_compiled(sql), dict(params)))
77
+ if not result.returns_rows:
78
+ return []
79
+ return [dict(row) for row in result.mappings()]
80
+
81
+
82
+ class SQLAlchemyBackend:
83
+ """Use pgdispatch with an SQLAlchemy ``AsyncEngine``."""
84
+
85
+ def __init__(self, engine: AsyncEngine, *, worker_dsn: str | None = None) -> None:
86
+ self.engine = engine
87
+ self._worker_dsn = worker_dsn or asyncpg_dsn(engine.url)
88
+
89
+ def executor(self, connection: Any) -> SQLAlchemyExecutor:
90
+ if not isinstance(connection, (AsyncSession, AsyncConnection)):
91
+ msg = (
92
+ "SQLAlchemyBackend.publish() expects an AsyncSession or AsyncConnection, "
93
+ f"got {type(connection).__name__}"
94
+ )
95
+ raise TypeError(msg)
96
+ return SQLAlchemyExecutor(connection)
97
+
98
+ @asynccontextmanager
99
+ async def transaction(self) -> AsyncIterator[SQLAlchemyExecutor]:
100
+ async with self.engine.begin() as connection:
101
+ yield SQLAlchemyExecutor(connection)
102
+
103
+ async def create_worker_pool(self) -> asyncpg.Pool:
104
+ pool: asyncpg.Pool = await asyncpg.create_pool(
105
+ self._worker_dsn, min_size=1, max_size=WORKER_POOL_SIZE
106
+ )
107
+ return pool
108
+
109
+ async def close(self) -> None:
110
+ """The engine belongs to the application, so there is nothing to close."""
111
+
112
+
113
+ def run_migrations(connection: Connection, *, schema: str = "pgdispatch") -> list[int]:
114
+ """Apply pgdispatch migrations on a synchronous connection.
115
+
116
+ In an Alembic revision pass ``op.get_bind()``.
117
+ """
118
+ return run_sync(migrate(), _SyncExecutor(connection), schema=schema)
119
+
120
+
121
+ def asyncpg_dsn(url: URL) -> str:
122
+ """Turn an SQLAlchemy URL into a DSN that asyncpg understands.
123
+
124
+ Driver-specific query parameters are dropped; ``sslmode``/``ssl`` are kept.
125
+ """
126
+ query = {key: value for key, value in url.query.items() if key in ("ssl", "sslmode")}
127
+ if "ssl" in query and "sslmode" not in query:
128
+ query["sslmode"] = query.pop("ssl")
129
+ plain = url.set(drivername="postgresql", query=query)
130
+ return plain.render_as_string(hide_password=False)
@@ -0,0 +1,5 @@
1
+ """The background delivery worker."""
2
+
3
+ from pgdispatch.worker.worker import Worker
4
+
5
+ __all__ = ["Worker"]
@@ -0,0 +1,183 @@
1
+ """Claiming due deliveries with ``FOR UPDATE SKIP LOCKED``.
2
+
3
+ Claiming is a *lease*, not a long-held lock: a single short transaction marks
4
+ deliveries ``in_flight`` with ``locked_until = now() + lease`` and commits
5
+ before any HTTP request is made. If the worker dies, the lease simply expires
6
+ and another worker picks the delivery up. ``attempt_count`` is incremented at
7
+ claim time; it doubles as a fencing token when the result is recorded, so a
8
+ worker whose lease expired cannot overwrite a newer attempt's outcome.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import json
14
+ import uuid
15
+ from collections.abc import Mapping
16
+ from dataclasses import dataclass
17
+ from datetime import datetime
18
+ from typing import Any
19
+
20
+ from pgdispatch.backends.base import Row
21
+ from pgdispatch.models import Delivery, DeliveryStatus, to_uuid
22
+
23
+ __all__ = ["CLAIM", "CLAIM_PROBES", "ClaimedDelivery"]
24
+
25
+ # A delivery is due when it is pending (or its lease expired) and its time has
26
+ # come. With an ordering key it must also be the oldest unfinished delivery
27
+ # with that key for its endpoint.
28
+ _DUE = """
29
+ d.status IN ('pending', 'in_flight')
30
+ AND (d.status = 'pending' OR d.locked_until < now())
31
+ AND d.next_attempt_at <= now()
32
+ AND (
33
+ d.ordering_key IS NULL
34
+ OR NOT EXISTS (
35
+ SELECT 1 FROM {schema}.deliveries AS prior
36
+ WHERE prior.endpoint_id = d.endpoint_id
37
+ AND prior.ordering_key = d.ordering_key
38
+ AND prior.id < d.id
39
+ AND prior.status IN ('pending', 'in_flight')
40
+ )
41
+ )
42
+ """
43
+
44
+ _LEASE = """
45
+ UPDATE {schema}.deliveries AS d
46
+ SET status = 'in_flight',
47
+ locked_until = now() + CAST(:lease AS double precision) * interval '1 second',
48
+ attempt_count = d.attempt_count + 1,
49
+ updated_at = now()
50
+ FROM picked, {schema}.events AS ev, {schema}.endpoints AS e
51
+ WHERE d.id = picked.id AND ev.id = d.event_id AND e.id = d.endpoint_id
52
+ RETURNING d.id, d.event_id, d.endpoint_id, d.ordering_key, d.attempt_count, d.schedule_offset,
53
+ d.next_attempt_at, d.created_at, ev.event_type, ev.payload::text AS payload,
54
+ ev.created_at AS event_created_at, e.url, e.secrets::text AS secrets,
55
+ e.headers::text AS headers, e.rate_limit_rps
56
+ """
57
+
58
+ CLAIM = f"""
59
+ WITH due AS (
60
+ SELECT d.id, d.endpoint_id, d.next_attempt_at
61
+ FROM {{schema}}.deliveries AS d
62
+ JOIN {{schema}}.endpoints AS e ON e.id = d.endpoint_id
63
+ WHERE {_DUE}
64
+ AND e.is_enabled
65
+ AND e.circuit_state = 'closed'
66
+ AND NOT (d.endpoint_id = ANY(CAST(:saturated AS uuid[])))
67
+ ORDER BY d.next_attempt_at
68
+ LIMIT CAST(:limit AS integer)
69
+ FOR UPDATE OF d SKIP LOCKED
70
+ ), ranked AS (
71
+ SELECT due.id, due.endpoint_id,
72
+ row_number() OVER (
73
+ PARTITION BY due.endpoint_id ORDER BY due.next_attempt_at, due.id
74
+ ) AS position
75
+ FROM due
76
+ ), picked AS (
77
+ -- Respect the per-endpoint concurrency limit across this batch and the
78
+ -- requests this worker already has in flight.
79
+ SELECT ranked.id
80
+ FROM ranked
81
+ LEFT JOIN unnest(CAST(:busy_ids AS uuid[]), CAST(:busy_counts AS integer[]))
82
+ AS busy(endpoint_id, in_flight) ON busy.endpoint_id = ranked.endpoint_id
83
+ WHERE ranked.position <= CAST(:endpoint_limit AS integer) - coalesce(busy.in_flight, 0)
84
+ )
85
+ {_LEASE}
86
+ """
87
+ """Claim due deliveries of endpoints whose circuit breaker is closed."""
88
+
89
+ CLAIM_PROBES = f"""
90
+ WITH probe AS (
91
+ UPDATE {{schema}}.endpoints AS e
92
+ SET circuit_state = 'half_open',
93
+ circuit_retry_at = now() + CAST(:lease AS double precision) * interval '1 second',
94
+ updated_at = now()
95
+ WHERE e.id IN (
96
+ SELECT c.id FROM {{schema}}.endpoints AS c
97
+ WHERE c.is_enabled
98
+ AND c.circuit_state <> 'closed'
99
+ AND c.circuit_retry_at <= now()
100
+ AND EXISTS (
101
+ SELECT 1 FROM {{schema}}.deliveries AS d
102
+ WHERE d.endpoint_id = c.id AND {_DUE}
103
+ )
104
+ ORDER BY c.circuit_retry_at
105
+ LIMIT CAST(:limit AS integer)
106
+ FOR UPDATE SKIP LOCKED
107
+ )
108
+ RETURNING e.id
109
+ ), picked AS (
110
+ SELECT candidate.id
111
+ FROM probe
112
+ CROSS JOIN LATERAL (
113
+ SELECT d.id FROM {{schema}}.deliveries AS d
114
+ WHERE d.endpoint_id = probe.id AND {_DUE}
115
+ ORDER BY d.next_attempt_at
116
+ LIMIT 1
117
+ FOR UPDATE SKIP LOCKED
118
+ ) AS candidate
119
+ )
120
+ {_LEASE}
121
+ """
122
+ """Move endpoints whose cooldown elapsed to half-open and claim one probe delivery each.
123
+
124
+ The endpoint itself is leased too (``circuit_retry_at``): if the worker dies
125
+ during the probe, another probe becomes possible once the lease expires.
126
+ """
127
+
128
+
129
+ @dataclass(frozen=True, slots=True)
130
+ class ClaimedDelivery:
131
+ """Everything a worker needs to send one delivery."""
132
+
133
+ id: int
134
+ event_id: uuid.UUID
135
+ endpoint_id: uuid.UUID
136
+ ordering_key: str | None
137
+ attempt_count: int
138
+ schedule_offset: int
139
+ next_attempt_at: datetime
140
+ created_at: datetime
141
+ event_type: str
142
+ payload: str
143
+ event_created_at: datetime
144
+ url: str
145
+ secrets: str
146
+ headers: Mapping[str, str]
147
+ rate_limit_rps: float | None
148
+
149
+ @property
150
+ def attempts_in_round(self) -> int:
151
+ """Attempts made since the retry schedule (re)started, including this one."""
152
+ return self.attempt_count - self.schedule_offset
153
+
154
+ @classmethod
155
+ def from_row(cls, row: Row) -> ClaimedDelivery:
156
+ values: dict[str, Any] = dict(row)
157
+ values["event_id"] = to_uuid(row["event_id"])
158
+ values["endpoint_id"] = to_uuid(row["endpoint_id"])
159
+ values["headers"] = json.loads(row["headers"])
160
+ return cls(**values)
161
+
162
+ def to_delivery(
163
+ self,
164
+ *,
165
+ status: DeliveryStatus,
166
+ status_code: int | None,
167
+ error: str | None,
168
+ finished_at: datetime | None,
169
+ ) -> Delivery:
170
+ return Delivery(
171
+ id=self.id,
172
+ event_id=self.event_id,
173
+ endpoint_id=self.endpoint_id,
174
+ event_type=self.event_type,
175
+ ordering_key=self.ordering_key,
176
+ status=status,
177
+ attempt_count=self.attempt_count,
178
+ next_attempt_at=self.next_attempt_at,
179
+ last_status_code=status_code,
180
+ last_error=error,
181
+ created_at=self.created_at,
182
+ finished_at=finished_at,
183
+ )