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
@@ -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])