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.
- pgdispatch/__init__.py +69 -0
- pgdispatch/__main__.py +5 -0
- pgdispatch/_version.py +1 -0
- pgdispatch/backends/__init__.py +6 -0
- pgdispatch/backends/asyncpg.py +103 -0
- pgdispatch/backends/base.py +142 -0
- pgdispatch/cli.py +420 -0
- pgdispatch/client.py +490 -0
- pgdispatch/config.py +223 -0
- pgdispatch/core/__init__.py +1 -0
- pgdispatch/core/circuit.py +94 -0
- pgdispatch/core/common.py +87 -0
- pgdispatch/core/deliveries.py +216 -0
- pgdispatch/core/endpoints.py +338 -0
- pgdispatch/core/events.py +65 -0
- pgdispatch/core/maintenance.py +121 -0
- pgdispatch/core/matching.py +76 -0
- pgdispatch/core/payload.py +108 -0
- pgdispatch/core/publisher.py +232 -0
- pgdispatch/core/retry.py +90 -0
- pgdispatch/core/secrets.py +138 -0
- pgdispatch/core/signing.py +141 -0
- pgdispatch/django/__init__.py +37 -0
- pgdispatch/django/apps.py +10 -0
- pgdispatch/django/client.py +402 -0
- pgdispatch/django/management/__init__.py +0 -0
- pgdispatch/django/management/commands/__init__.py +0 -0
- pgdispatch/django/management/commands/pgdispatch_worker.py +32 -0
- pgdispatch/django/migrations/0001_initial.py +30 -0
- pgdispatch/django/migrations/__init__.py +0 -0
- pgdispatch/errors.py +43 -0
- pgdispatch/fastapi/__init__.py +23 -0
- pgdispatch/fastapi/router.py +243 -0
- pgdispatch/hooks.py +80 -0
- pgdispatch/metrics.py +80 -0
- pgdispatch/migrations/0001_initial.sql +104 -0
- pgdispatch/migrations/__init__.py +93 -0
- pgdispatch/models.py +208 -0
- pgdispatch/py.typed +0 -0
- pgdispatch/security/__init__.py +1 -0
- pgdispatch/security/ssrf.py +141 -0
- pgdispatch/sqlalchemy.py +130 -0
- pgdispatch/worker/__init__.py +5 -0
- pgdispatch/worker/fetcher.py +183 -0
- pgdispatch/worker/ratelimit.py +47 -0
- pgdispatch/worker/recorder.py +435 -0
- pgdispatch/worker/runner.py +36 -0
- pgdispatch/worker/sender.py +160 -0
- pgdispatch/worker/worker.py +557 -0
- pgdispatch-0.1.0.dist-info/METADATA +329 -0
- pgdispatch-0.1.0.dist-info/RECORD +54 -0
- pgdispatch-0.1.0.dist-info/WHEEL +4 -0
- pgdispatch-0.1.0.dist-info/entry_points.txt +2 -0
- 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
|
pgdispatch/sqlalchemy.py
ADDED
|
@@ -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,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
|
+
)
|