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
|
@@ -0,0 +1,338 @@
|
|
|
1
|
+
"""Endpoint management operations."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import re
|
|
7
|
+
import uuid
|
|
8
|
+
from collections.abc import Iterable, Mapping
|
|
9
|
+
from datetime import datetime, timedelta, timezone
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
from pgdispatch.backends.base import Operation, Query
|
|
13
|
+
from pgdispatch.core.common import (
|
|
14
|
+
ENDPOINT_COLUMNS,
|
|
15
|
+
UNSET,
|
|
16
|
+
OwnerFilter,
|
|
17
|
+
Unset,
|
|
18
|
+
first_or_not_found,
|
|
19
|
+
owner_condition,
|
|
20
|
+
validate_key,
|
|
21
|
+
validate_limit,
|
|
22
|
+
)
|
|
23
|
+
from pgdispatch.core.matching import validate_pattern
|
|
24
|
+
from pgdispatch.core.secrets import SecretStore
|
|
25
|
+
from pgdispatch.core.signing import generate_secret, validate_secret
|
|
26
|
+
from pgdispatch.errors import ValidationError
|
|
27
|
+
from pgdispatch.models import Endpoint
|
|
28
|
+
from pgdispatch.security.ssrf import validate_endpoint_url
|
|
29
|
+
|
|
30
|
+
__all__ = [
|
|
31
|
+
"UrlPolicy",
|
|
32
|
+
"create_endpoint",
|
|
33
|
+
"delete_endpoint",
|
|
34
|
+
"disable_endpoint",
|
|
35
|
+
"enable_endpoint",
|
|
36
|
+
"get_endpoint",
|
|
37
|
+
"get_secret",
|
|
38
|
+
"list_endpoints",
|
|
39
|
+
"rotate_secret",
|
|
40
|
+
"update_endpoint",
|
|
41
|
+
]
|
|
42
|
+
|
|
43
|
+
MAX_DESCRIPTION_LENGTH = 1024
|
|
44
|
+
MAX_EVENT_TYPES = 100
|
|
45
|
+
MAX_HEADERS = 20
|
|
46
|
+
|
|
47
|
+
_HEADER_NAME = re.compile(r"^[!#$%&'*+\-.^_`|~0-9A-Za-z]{1,128}$")
|
|
48
|
+
_RESERVED_HEADERS = frozenset(
|
|
49
|
+
{
|
|
50
|
+
"connection",
|
|
51
|
+
"content-length",
|
|
52
|
+
"content-type",
|
|
53
|
+
"host",
|
|
54
|
+
"transfer-encoding",
|
|
55
|
+
"user-agent",
|
|
56
|
+
"webhook-id",
|
|
57
|
+
"webhook-signature",
|
|
58
|
+
"webhook-timestamp",
|
|
59
|
+
}
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
class UrlPolicy:
|
|
64
|
+
"""Which endpoint URLs are acceptable (see :mod:`pgdispatch.security.ssrf`)."""
|
|
65
|
+
|
|
66
|
+
def __init__(self, *, allow_http: bool, allow_private_ips: bool) -> None:
|
|
67
|
+
self.allow_http = allow_http
|
|
68
|
+
self.allow_private_ips = allow_private_ips
|
|
69
|
+
|
|
70
|
+
def check(self, url: str) -> str:
|
|
71
|
+
return validate_endpoint_url(
|
|
72
|
+
url, allow_http=self.allow_http, allow_private_ips=self.allow_private_ips
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def create_endpoint(
|
|
77
|
+
*,
|
|
78
|
+
url: str,
|
|
79
|
+
event_types: Iterable[str],
|
|
80
|
+
owner_id: str | None,
|
|
81
|
+
description: str | None,
|
|
82
|
+
headers: Mapping[str, str] | None,
|
|
83
|
+
rate_limit_rps: float | None,
|
|
84
|
+
secret: str | None,
|
|
85
|
+
enabled: bool,
|
|
86
|
+
url_policy: UrlPolicy,
|
|
87
|
+
secret_store: SecretStore,
|
|
88
|
+
) -> Operation[Endpoint]:
|
|
89
|
+
plaintext = validate_secret(secret) if secret is not None else generate_secret()
|
|
90
|
+
entry = secret_store.seal(plaintext, now=_now())
|
|
91
|
+
rows = yield Query(
|
|
92
|
+
f"""
|
|
93
|
+
INSERT INTO {{schema}}.endpoints AS e
|
|
94
|
+
(id, owner_id, url, description, event_types, secrets, headers, rate_limit_rps,
|
|
95
|
+
is_enabled)
|
|
96
|
+
VALUES (CAST(:id AS uuid), CAST(:owner_id AS text), CAST(:url AS text),
|
|
97
|
+
CAST(:description AS text), CAST(:event_types AS text[]),
|
|
98
|
+
CAST(:secrets AS jsonb), CAST(:headers AS jsonb),
|
|
99
|
+
CAST(:rate_limit_rps AS double precision), CAST(:enabled AS boolean))
|
|
100
|
+
RETURNING {ENDPOINT_COLUMNS}
|
|
101
|
+
""",
|
|
102
|
+
{
|
|
103
|
+
"id": str(uuid.uuid4()),
|
|
104
|
+
"owner_id": validate_key(owner_id, "owner_id") if owner_id is not None else None,
|
|
105
|
+
"url": url_policy.check(url),
|
|
106
|
+
"description": _validate_description(description),
|
|
107
|
+
"event_types": _validate_event_types(event_types),
|
|
108
|
+
"secrets": SecretStore.dump([entry]),
|
|
109
|
+
"headers": _dump_headers(headers or {}),
|
|
110
|
+
"rate_limit_rps": _validate_rate_limit(rate_limit_rps),
|
|
111
|
+
"enabled": enabled,
|
|
112
|
+
},
|
|
113
|
+
)
|
|
114
|
+
return Endpoint.from_row(rows[0], secret=plaintext)
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def get_endpoint(endpoint_id: uuid.UUID, *, owner_id: OwnerFilter) -> Operation[Endpoint]:
|
|
118
|
+
params: dict[str, Any] = {"id": str(endpoint_id)}
|
|
119
|
+
owner_sql = owner_condition("e.owner_id", owner_id, params)
|
|
120
|
+
rows = yield Query(
|
|
121
|
+
f"""
|
|
122
|
+
SELECT {ENDPOINT_COLUMNS} FROM {{schema}}.endpoints AS e
|
|
123
|
+
WHERE e.id = CAST(:id AS uuid) AND {owner_sql}
|
|
124
|
+
""",
|
|
125
|
+
params,
|
|
126
|
+
)
|
|
127
|
+
return Endpoint.from_row(first_or_not_found(rows, "endpoint", endpoint_id))
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def list_endpoints(
|
|
131
|
+
*,
|
|
132
|
+
owner_id: OwnerFilter,
|
|
133
|
+
enabled: bool | None,
|
|
134
|
+
limit: int,
|
|
135
|
+
after: uuid.UUID | None,
|
|
136
|
+
) -> Operation[list[Endpoint]]:
|
|
137
|
+
"""List endpoints ordered by id; pass the last id as ``after`` to get the next page."""
|
|
138
|
+
params: dict[str, Any] = {"limit": validate_limit(limit)}
|
|
139
|
+
conditions = [owner_condition("e.owner_id", owner_id, params)]
|
|
140
|
+
if enabled is not None:
|
|
141
|
+
params["enabled"] = enabled
|
|
142
|
+
conditions.append("e.is_enabled = CAST(:enabled AS boolean)")
|
|
143
|
+
if after is not None:
|
|
144
|
+
params["after"] = str(after)
|
|
145
|
+
conditions.append("e.id > CAST(:after AS uuid)")
|
|
146
|
+
rows = yield Query(
|
|
147
|
+
f"""
|
|
148
|
+
SELECT {ENDPOINT_COLUMNS} FROM {{schema}}.endpoints AS e
|
|
149
|
+
WHERE {" AND ".join(conditions)}
|
|
150
|
+
ORDER BY e.id
|
|
151
|
+
LIMIT CAST(:limit AS integer)
|
|
152
|
+
""",
|
|
153
|
+
params,
|
|
154
|
+
)
|
|
155
|
+
return [Endpoint.from_row(row) for row in rows]
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def update_endpoint(
|
|
159
|
+
endpoint_id: uuid.UUID,
|
|
160
|
+
*,
|
|
161
|
+
owner_id: OwnerFilter,
|
|
162
|
+
url: str | Unset,
|
|
163
|
+
event_types: Iterable[str] | Unset,
|
|
164
|
+
description: str | Unset | None,
|
|
165
|
+
headers: Mapping[str, str] | Unset,
|
|
166
|
+
rate_limit_rps: float | Unset | None,
|
|
167
|
+
url_policy: UrlPolicy,
|
|
168
|
+
) -> Operation[Endpoint]:
|
|
169
|
+
params: dict[str, Any] = {"id": str(endpoint_id)}
|
|
170
|
+
assignments = ["updated_at = now()"]
|
|
171
|
+
if url is not UNSET:
|
|
172
|
+
params["url"] = url_policy.check(url)
|
|
173
|
+
assignments.append("url = CAST(:url AS text)")
|
|
174
|
+
if event_types is not UNSET:
|
|
175
|
+
params["event_types"] = _validate_event_types(event_types)
|
|
176
|
+
assignments.append("event_types = CAST(:event_types AS text[])")
|
|
177
|
+
if description is not UNSET:
|
|
178
|
+
params["description"] = _validate_description(description)
|
|
179
|
+
assignments.append("description = CAST(:description AS text)")
|
|
180
|
+
if headers is not UNSET:
|
|
181
|
+
params["headers"] = _dump_headers(headers)
|
|
182
|
+
assignments.append("headers = CAST(:headers AS jsonb)")
|
|
183
|
+
if rate_limit_rps is not UNSET:
|
|
184
|
+
params["rate_limit_rps"] = _validate_rate_limit(rate_limit_rps)
|
|
185
|
+
assignments.append("rate_limit_rps = CAST(:rate_limit_rps AS double precision)")
|
|
186
|
+
return (yield from _update(endpoint_id, ", ".join(assignments), params, owner_id))
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def enable_endpoint(endpoint_id: uuid.UUID, *, owner_id: OwnerFilter) -> Operation[Endpoint]:
|
|
190
|
+
"""Enable the endpoint and reset its circuit breaker."""
|
|
191
|
+
assignments = """
|
|
192
|
+
is_enabled = true, disabled_reason = NULL, circuit_state = 'closed',
|
|
193
|
+
consecutive_failures = 0, failing_since = NULL, circuit_opened_at = NULL,
|
|
194
|
+
circuit_retry_at = NULL, circuit_trips = 0, updated_at = now()
|
|
195
|
+
"""
|
|
196
|
+
return (yield from _update(endpoint_id, assignments, {"id": str(endpoint_id)}, owner_id))
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def disable_endpoint(
|
|
200
|
+
endpoint_id: uuid.UUID, *, reason: str | None, owner_id: OwnerFilter
|
|
201
|
+
) -> Operation[Endpoint]:
|
|
202
|
+
"""Stop deliveries to the endpoint. Pending deliveries resume when it is enabled again."""
|
|
203
|
+
params: dict[str, Any] = {"id": str(endpoint_id), "reason": reason or "disabled manually"}
|
|
204
|
+
assignments = "is_enabled = false, disabled_reason = CAST(:reason AS text), updated_at = now()"
|
|
205
|
+
return (yield from _update(endpoint_id, assignments, params, owner_id))
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
def delete_endpoint(endpoint_id: uuid.UUID, *, owner_id: OwnerFilter) -> Operation[None]:
|
|
209
|
+
"""Delete the endpoint together with its deliveries and their attempts."""
|
|
210
|
+
params: dict[str, Any] = {"id": str(endpoint_id)}
|
|
211
|
+
owner_sql = owner_condition("e.owner_id", owner_id, params)
|
|
212
|
+
rows = yield Query(
|
|
213
|
+
f"""
|
|
214
|
+
DELETE FROM {{schema}}.endpoints AS e
|
|
215
|
+
WHERE e.id = CAST(:id AS uuid) AND {owner_sql}
|
|
216
|
+
RETURNING e.id
|
|
217
|
+
""",
|
|
218
|
+
params,
|
|
219
|
+
)
|
|
220
|
+
first_or_not_found(rows, "endpoint", endpoint_id)
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
def rotate_secret(
|
|
224
|
+
endpoint_id: uuid.UUID,
|
|
225
|
+
*,
|
|
226
|
+
grace_period: timedelta,
|
|
227
|
+
secret: str | None,
|
|
228
|
+
owner_id: OwnerFilter,
|
|
229
|
+
secret_store: SecretStore,
|
|
230
|
+
) -> Operation[Endpoint]:
|
|
231
|
+
"""Replace the signing secret; the old one keeps signing for ``grace_period``."""
|
|
232
|
+
if grace_period < timedelta(0):
|
|
233
|
+
msg = "grace_period must not be negative"
|
|
234
|
+
raise ValidationError(msg)
|
|
235
|
+
new_secret = validate_secret(secret) if secret is not None else generate_secret()
|
|
236
|
+
params: dict[str, Any] = {"id": str(endpoint_id)}
|
|
237
|
+
owner_sql = owner_condition("e.owner_id", owner_id, params)
|
|
238
|
+
rows = yield Query(
|
|
239
|
+
f"""
|
|
240
|
+
SELECT e.secrets::text AS secrets FROM {{schema}}.endpoints AS e
|
|
241
|
+
WHERE e.id = CAST(:id AS uuid) AND {owner_sql}
|
|
242
|
+
FOR UPDATE
|
|
243
|
+
""",
|
|
244
|
+
params,
|
|
245
|
+
)
|
|
246
|
+
current = SecretStore.load(first_or_not_found(rows, "endpoint", endpoint_id)["secrets"])
|
|
247
|
+
rotated = secret_store.rotate(current, new_secret, now=_now(), grace_period=grace_period)
|
|
248
|
+
rows = yield Query(
|
|
249
|
+
f"""
|
|
250
|
+
UPDATE {{schema}}.endpoints AS e
|
|
251
|
+
SET secrets = CAST(:secrets AS jsonb), updated_at = now()
|
|
252
|
+
WHERE e.id = CAST(:id AS uuid)
|
|
253
|
+
RETURNING {ENDPOINT_COLUMNS}
|
|
254
|
+
""",
|
|
255
|
+
{"id": str(endpoint_id), "secrets": SecretStore.dump(rotated)},
|
|
256
|
+
)
|
|
257
|
+
return Endpoint.from_row(rows[0], secret=new_secret)
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
def get_secret(
|
|
261
|
+
endpoint_id: uuid.UUID, *, owner_id: OwnerFilter, secret_store: SecretStore
|
|
262
|
+
) -> Operation[str]:
|
|
263
|
+
"""Return the newest signing secret, e.g. for a "reveal secret" button."""
|
|
264
|
+
params: dict[str, Any] = {"id": str(endpoint_id)}
|
|
265
|
+
owner_sql = owner_condition("e.owner_id", owner_id, params)
|
|
266
|
+
rows = yield Query(
|
|
267
|
+
f"""
|
|
268
|
+
SELECT e.secrets::text AS secrets FROM {{schema}}.endpoints AS e
|
|
269
|
+
WHERE e.id = CAST(:id AS uuid) AND {owner_sql}
|
|
270
|
+
""",
|
|
271
|
+
params,
|
|
272
|
+
)
|
|
273
|
+
entries = SecretStore.load(first_or_not_found(rows, "endpoint", endpoint_id)["secrets"])
|
|
274
|
+
return secret_store.open(entries[0])
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
def _update(
|
|
278
|
+
endpoint_id: uuid.UUID, assignments: str, params: dict[str, Any], owner_id: OwnerFilter
|
|
279
|
+
) -> Operation[Endpoint]:
|
|
280
|
+
owner_sql = owner_condition("e.owner_id", owner_id, params)
|
|
281
|
+
rows = yield Query(
|
|
282
|
+
f"""
|
|
283
|
+
UPDATE {{schema}}.endpoints AS e SET {assignments}
|
|
284
|
+
WHERE e.id = CAST(:id AS uuid) AND {owner_sql}
|
|
285
|
+
RETURNING {ENDPOINT_COLUMNS}
|
|
286
|
+
""",
|
|
287
|
+
params,
|
|
288
|
+
)
|
|
289
|
+
return Endpoint.from_row(first_or_not_found(rows, "endpoint", endpoint_id))
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
def _validate_event_types(event_types: Iterable[str]) -> list[str]:
|
|
293
|
+
if isinstance(event_types, str):
|
|
294
|
+
msg = "event_types must be a list of patterns, e.g. ['payment.*']"
|
|
295
|
+
raise ValidationError(msg)
|
|
296
|
+
patterns = list(dict.fromkeys(validate_pattern(pattern) for pattern in event_types))
|
|
297
|
+
if not patterns:
|
|
298
|
+
msg = "event_types must contain at least one pattern; use ['*'] for all events"
|
|
299
|
+
raise ValidationError(msg)
|
|
300
|
+
if len(patterns) > MAX_EVENT_TYPES:
|
|
301
|
+
msg = f"an endpoint can subscribe to at most {MAX_EVENT_TYPES} patterns"
|
|
302
|
+
raise ValidationError(msg)
|
|
303
|
+
return patterns
|
|
304
|
+
|
|
305
|
+
|
|
306
|
+
def _validate_description(description: str | None) -> str | None:
|
|
307
|
+
if description is not None and len(description) > MAX_DESCRIPTION_LENGTH:
|
|
308
|
+
msg = f"description must be at most {MAX_DESCRIPTION_LENGTH} characters"
|
|
309
|
+
raise ValidationError(msg)
|
|
310
|
+
return description
|
|
311
|
+
|
|
312
|
+
|
|
313
|
+
def _validate_rate_limit(rate_limit_rps: float | None) -> float | None:
|
|
314
|
+
if rate_limit_rps is not None and not rate_limit_rps > 0:
|
|
315
|
+
msg = "rate_limit_rps must be a positive number"
|
|
316
|
+
raise ValidationError(msg)
|
|
317
|
+
return rate_limit_rps
|
|
318
|
+
|
|
319
|
+
|
|
320
|
+
def _dump_headers(headers: Mapping[str, str]) -> str:
|
|
321
|
+
if len(headers) > MAX_HEADERS:
|
|
322
|
+
msg = f"an endpoint can have at most {MAX_HEADERS} custom headers"
|
|
323
|
+
raise ValidationError(msg)
|
|
324
|
+
for name, value in headers.items():
|
|
325
|
+
if not isinstance(name, str) or not _HEADER_NAME.match(name):
|
|
326
|
+
msg = f"invalid header name {name!r}"
|
|
327
|
+
raise ValidationError(msg)
|
|
328
|
+
if name.lower() in _RESERVED_HEADERS:
|
|
329
|
+
msg = f"header {name!r} is set by pgdispatch and cannot be overridden"
|
|
330
|
+
raise ValidationError(msg)
|
|
331
|
+
if not isinstance(value, str) or any(char in value for char in "\r\n\0"):
|
|
332
|
+
msg = f"invalid value for header {name!r}"
|
|
333
|
+
raise ValidationError(msg)
|
|
334
|
+
return json.dumps(dict(headers))
|
|
335
|
+
|
|
336
|
+
|
|
337
|
+
def _now() -> datetime:
|
|
338
|
+
return datetime.now(timezone.utc)
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
"""Reading published events."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import uuid
|
|
6
|
+
from datetime import datetime
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
from pgdispatch.backends.base import Operation, Query
|
|
10
|
+
from pgdispatch.core.common import OwnerFilter, first_or_not_found, owner_condition, validate_limit
|
|
11
|
+
from pgdispatch.core.matching import validate_event_type
|
|
12
|
+
from pgdispatch.models import Event
|
|
13
|
+
|
|
14
|
+
__all__ = ["get_event", "list_events"]
|
|
15
|
+
|
|
16
|
+
_EVENT_COLUMNS = (
|
|
17
|
+
"ev.id, ev.event_type, ev.owner_id, ev.payload::text AS payload, "
|
|
18
|
+
"ev.idempotency_key, ev.created_at"
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def get_event(event_id: uuid.UUID, *, owner_id: OwnerFilter) -> Operation[Event]:
|
|
23
|
+
params: dict[str, Any] = {"id": str(event_id)}
|
|
24
|
+
owner_sql = owner_condition("ev.owner_id", owner_id, params)
|
|
25
|
+
rows = yield Query(
|
|
26
|
+
f"""
|
|
27
|
+
SELECT {_EVENT_COLUMNS} FROM {{schema}}.events AS ev
|
|
28
|
+
WHERE ev.id = CAST(:id AS uuid) AND {owner_sql}
|
|
29
|
+
""",
|
|
30
|
+
params,
|
|
31
|
+
)
|
|
32
|
+
return Event.from_row(first_or_not_found(rows, "event", event_id))
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def list_events(
|
|
36
|
+
*,
|
|
37
|
+
owner_id: OwnerFilter,
|
|
38
|
+
event_type: str | None,
|
|
39
|
+
since: datetime | None,
|
|
40
|
+
before: uuid.UUID | None,
|
|
41
|
+
limit: int,
|
|
42
|
+
) -> Operation[list[Event]]:
|
|
43
|
+
"""Newest events first; pass the last id as ``before`` to get the next page."""
|
|
44
|
+
params: dict[str, Any] = {"limit": validate_limit(limit)}
|
|
45
|
+
conditions = [owner_condition("ev.owner_id", owner_id, params)]
|
|
46
|
+
if event_type is not None:
|
|
47
|
+
params["event_type"] = validate_event_type(event_type)
|
|
48
|
+
conditions.append("ev.event_type = CAST(:event_type AS text)")
|
|
49
|
+
if since is not None:
|
|
50
|
+
params["since"] = since
|
|
51
|
+
conditions.append("ev.created_at >= CAST(:since AS timestamptz)")
|
|
52
|
+
if before is not None:
|
|
53
|
+
# Event ids are UUIDv7, so they sort chronologically.
|
|
54
|
+
params["before"] = str(before)
|
|
55
|
+
conditions.append("ev.id < CAST(:before AS uuid)")
|
|
56
|
+
rows = yield Query(
|
|
57
|
+
f"""
|
|
58
|
+
SELECT {_EVENT_COLUMNS} FROM {{schema}}.events AS ev
|
|
59
|
+
WHERE {" AND ".join(conditions)}
|
|
60
|
+
ORDER BY ev.id DESC
|
|
61
|
+
LIMIT CAST(:limit AS integer)
|
|
62
|
+
""",
|
|
63
|
+
params,
|
|
64
|
+
)
|
|
65
|
+
return [Event.from_row(row) for row in rows]
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
"""Queue statistics and retention clean-up."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from datetime import timedelta
|
|
7
|
+
|
|
8
|
+
from pgdispatch.backends.base import Operation, Query
|
|
9
|
+
|
|
10
|
+
__all__ = ["QueueStats", "delete_expired_events", "queue_stats"]
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@dataclass(frozen=True, slots=True)
|
|
14
|
+
class QueueStats:
|
|
15
|
+
"""A snapshot of the delivery queue, as shown by ``pgdispatch status``."""
|
|
16
|
+
|
|
17
|
+
pending: int
|
|
18
|
+
"""Deliveries waiting for an attempt, on enabled endpoints."""
|
|
19
|
+
due: int
|
|
20
|
+
"""Deliveries a worker could send right now (not held back by a breaker or ordering)."""
|
|
21
|
+
in_flight: int
|
|
22
|
+
"""Deliveries being sent right now."""
|
|
23
|
+
failed_last_24h: int
|
|
24
|
+
oldest_due_age: timedelta
|
|
25
|
+
"""How long the most overdue sendable delivery has been waiting: the delivery lag.
|
|
26
|
+
|
|
27
|
+
Deliveries held back on purpose (an open circuit breaker, an earlier
|
|
28
|
+
delivery with the same ordering key still retrying) do not count, so the
|
|
29
|
+
lag reflects worker capacity, not one customer's failing endpoint.
|
|
30
|
+
"""
|
|
31
|
+
endpoints_enabled: int
|
|
32
|
+
endpoints_disabled: int
|
|
33
|
+
circuits_open: int
|
|
34
|
+
"""Enabled endpoints whose circuit breaker is open or half-open."""
|
|
35
|
+
active_workers: int
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
_STATS = """
|
|
39
|
+
WITH active AS (
|
|
40
|
+
SELECT d.status, d.next_attempt_at,
|
|
41
|
+
-- A delivery is "due" only if a worker could send it right now:
|
|
42
|
+
-- its time has come, its endpoint's breaker is closed, and it is
|
|
43
|
+
-- not waiting behind an earlier delivery with the same ordering key.
|
|
44
|
+
d.status = 'pending'
|
|
45
|
+
AND d.next_attempt_at <= now()
|
|
46
|
+
AND e.circuit_state = 'closed'
|
|
47
|
+
AND (
|
|
48
|
+
d.ordering_key IS NULL
|
|
49
|
+
OR NOT EXISTS (
|
|
50
|
+
SELECT 1 FROM {schema}.deliveries AS prior
|
|
51
|
+
WHERE prior.endpoint_id = d.endpoint_id
|
|
52
|
+
AND prior.ordering_key = d.ordering_key
|
|
53
|
+
AND prior.id < d.id
|
|
54
|
+
AND prior.status IN ('pending', 'in_flight')
|
|
55
|
+
)
|
|
56
|
+
) AS is_due
|
|
57
|
+
FROM {schema}.deliveries AS d
|
|
58
|
+
JOIN {schema}.endpoints AS e ON e.id = d.endpoint_id
|
|
59
|
+
WHERE d.status IN ('pending', 'in_flight') AND e.is_enabled
|
|
60
|
+
)
|
|
61
|
+
SELECT
|
|
62
|
+
(SELECT count(*) FROM active WHERE status = 'pending') AS pending,
|
|
63
|
+
(SELECT count(*) FROM active WHERE is_due) AS due,
|
|
64
|
+
(SELECT count(*) FROM active WHERE status = 'in_flight') AS in_flight,
|
|
65
|
+
(SELECT count(*) FROM {schema}.deliveries
|
|
66
|
+
WHERE status = 'failed' AND finished_at > now() - interval '24 hours') AS failed_last_24h,
|
|
67
|
+
(SELECT coalesce(extract(epoch FROM now() - min(next_attempt_at)), 0)::double precision
|
|
68
|
+
FROM active WHERE is_due) AS oldest_due_age,
|
|
69
|
+
(SELECT count(*) FROM {schema}.endpoints WHERE is_enabled) AS endpoints_enabled,
|
|
70
|
+
(SELECT count(*) FROM {schema}.endpoints WHERE NOT is_enabled) AS endpoints_disabled,
|
|
71
|
+
(SELECT count(*) FROM {schema}.endpoints
|
|
72
|
+
WHERE is_enabled AND circuit_state <> 'closed') AS circuits_open,
|
|
73
|
+
(SELECT count(*) FROM {schema}.workers
|
|
74
|
+
WHERE heartbeat_at > now() - CAST(:worker_ttl AS double precision) * interval '1 second')
|
|
75
|
+
AS active_workers
|
|
76
|
+
"""
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def queue_stats(*, worker_ttl: timedelta) -> Operation[QueueStats]:
|
|
80
|
+
rows = yield Query(_STATS, {"worker_ttl": worker_ttl.total_seconds()})
|
|
81
|
+
row = rows[0]
|
|
82
|
+
return QueueStats(
|
|
83
|
+
pending=row["pending"],
|
|
84
|
+
due=row["due"],
|
|
85
|
+
in_flight=row["in_flight"],
|
|
86
|
+
failed_last_24h=row["failed_last_24h"],
|
|
87
|
+
oldest_due_age=timedelta(seconds=row["oldest_due_age"]),
|
|
88
|
+
endpoints_enabled=row["endpoints_enabled"],
|
|
89
|
+
endpoints_disabled=row["endpoints_disabled"],
|
|
90
|
+
circuits_open=row["circuits_open"],
|
|
91
|
+
active_workers=row["active_workers"],
|
|
92
|
+
)
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def delete_expired_events(*, older_than: timedelta, batch_size: int) -> Operation[int]:
|
|
96
|
+
"""Delete one batch of events older than ``older_than``; return how many were deleted.
|
|
97
|
+
|
|
98
|
+
Deliveries and attempts go with their event (``ON DELETE CASCADE``). Events
|
|
99
|
+
with a delivery that a worker is sending right now are left for the next run.
|
|
100
|
+
"""
|
|
101
|
+
rows = yield Query(
|
|
102
|
+
"""
|
|
103
|
+
WITH doomed AS (
|
|
104
|
+
SELECT ev.id FROM {schema}.events AS ev
|
|
105
|
+
WHERE ev.created_at < now() - CAST(:age AS double precision) * interval '1 second'
|
|
106
|
+
AND NOT EXISTS (
|
|
107
|
+
SELECT 1 FROM {schema}.deliveries AS d
|
|
108
|
+
WHERE d.event_id = ev.id AND d.status = 'in_flight' AND d.locked_until > now()
|
|
109
|
+
)
|
|
110
|
+
ORDER BY ev.created_at
|
|
111
|
+
LIMIT CAST(:batch_size AS integer)
|
|
112
|
+
FOR UPDATE SKIP LOCKED
|
|
113
|
+
), deleted AS (
|
|
114
|
+
DELETE FROM {schema}.events AS ev USING doomed WHERE ev.id = doomed.id RETURNING 1
|
|
115
|
+
)
|
|
116
|
+
SELECT count(*) AS deleted FROM deleted
|
|
117
|
+
""",
|
|
118
|
+
{"age": older_than.total_seconds(), "batch_size": batch_size},
|
|
119
|
+
)
|
|
120
|
+
deleted: int = rows[0]["deleted"]
|
|
121
|
+
return deleted
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
"""Event types and the subscription patterns that select them.
|
|
2
|
+
|
|
3
|
+
An event type is a dot-separated name such as ``payment.paid``. An endpoint
|
|
4
|
+
subscribes with patterns:
|
|
5
|
+
|
|
6
|
+
* ``*`` matches every event type;
|
|
7
|
+
* ``payment.paid`` matches exactly that type;
|
|
8
|
+
* ``payment.*`` matches every type that starts with ``payment.``
|
|
9
|
+
(``payment.paid``, ``payment.refund.created``), but not ``payment`` itself.
|
|
10
|
+
|
|
11
|
+
Because wildcards are only allowed as a trailing segment, the set of patterns
|
|
12
|
+
that can match a given event type is small and known in advance. Publishing
|
|
13
|
+
uses :func:`candidate_patterns` to find subscribers with a single array-overlap
|
|
14
|
+
check (``event_types && candidates``), which PostgreSQL can serve from an index.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import re
|
|
20
|
+
|
|
21
|
+
from pgdispatch.errors import ValidationError
|
|
22
|
+
|
|
23
|
+
__all__ = [
|
|
24
|
+
"MAX_EVENT_TYPE_LENGTH",
|
|
25
|
+
"candidate_patterns",
|
|
26
|
+
"matches",
|
|
27
|
+
"validate_event_type",
|
|
28
|
+
"validate_pattern",
|
|
29
|
+
]
|
|
30
|
+
|
|
31
|
+
MAX_EVENT_TYPE_LENGTH = 255
|
|
32
|
+
WILDCARD = "*"
|
|
33
|
+
|
|
34
|
+
_SEGMENT = r"[A-Za-z0-9_\-]+"
|
|
35
|
+
_EVENT_TYPE = re.compile(rf"{_SEGMENT}(?:\.{_SEGMENT})*")
|
|
36
|
+
_PATTERN = re.compile(rf"\*|{_SEGMENT}(?:\.{_SEGMENT})*(?:\.\*)?")
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def validate_event_type(event_type: str) -> str:
|
|
40
|
+
"""Return ``event_type`` unchanged or raise :class:`ValidationError`."""
|
|
41
|
+
if len(event_type) > MAX_EVENT_TYPE_LENGTH or not _EVENT_TYPE.fullmatch(event_type):
|
|
42
|
+
msg = (
|
|
43
|
+
f"invalid event type {event_type!r}: use dot-separated segments of letters, "
|
|
44
|
+
"digits, '_' and '-', e.g. 'payment.paid'"
|
|
45
|
+
)
|
|
46
|
+
raise ValidationError(msg)
|
|
47
|
+
return event_type
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def validate_pattern(pattern: str) -> str:
|
|
51
|
+
"""Return ``pattern`` unchanged or raise :class:`ValidationError`."""
|
|
52
|
+
if len(pattern) > MAX_EVENT_TYPE_LENGTH or not _PATTERN.fullmatch(pattern):
|
|
53
|
+
msg = (
|
|
54
|
+
f"invalid event type pattern {pattern!r}: expected '*', an event type such as "
|
|
55
|
+
"'payment.paid', or a prefix wildcard such as 'payment.*'"
|
|
56
|
+
)
|
|
57
|
+
raise ValidationError(msg)
|
|
58
|
+
return pattern
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def candidate_patterns(event_type: str) -> list[str]:
|
|
62
|
+
"""List every pattern that matches ``event_type``.
|
|
63
|
+
|
|
64
|
+
>>> candidate_patterns("payment.refund.created")
|
|
65
|
+
['*', 'payment.refund.created', 'payment.*', 'payment.refund.*']
|
|
66
|
+
"""
|
|
67
|
+
segments = event_type.split(".")
|
|
68
|
+
prefixes = [".".join(segments[:size]) + ".*" for size in range(1, len(segments))]
|
|
69
|
+
return [WILDCARD, event_type, *prefixes]
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def matches(pattern: str, event_type: str) -> bool:
|
|
73
|
+
"""Tell whether ``pattern`` selects ``event_type``."""
|
|
74
|
+
if pattern in (WILDCARD, event_type):
|
|
75
|
+
return True
|
|
76
|
+
return pattern.endswith(".*") and event_type.startswith(pattern[:-1])
|