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/errors.py ADDED
@@ -0,0 +1,43 @@
1
+ """Exceptions raised by pgdispatch.
2
+
3
+ Every exception derives from :class:`PgDispatchError`, so callers can catch the
4
+ whole family with a single ``except`` clause.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+
10
+ class PgDispatchError(Exception):
11
+ """Base class for all pgdispatch errors."""
12
+
13
+
14
+ class ConfigurationError(PgDispatchError):
15
+ """The library is configured inconsistently or an optional dependency is missing."""
16
+
17
+
18
+ class ValidationError(PgDispatchError, ValueError):
19
+ """An argument passed to the public API is invalid."""
20
+
21
+
22
+ class PayloadTooLargeError(ValidationError):
23
+ """The serialized event payload exceeds ``max_payload_bytes``."""
24
+
25
+
26
+ class UnsafeURLError(ValidationError):
27
+ """An endpoint URL is not allowed, e.g. it points to a private network."""
28
+
29
+
30
+ class NotFoundError(PgDispatchError, LookupError):
31
+ """The requested endpoint, event or delivery does not exist (or is not visible to the owner)."""
32
+
33
+
34
+ class ConflictError(PgDispatchError):
35
+ """The operation cannot be applied in the object's current state."""
36
+
37
+
38
+ class InvalidSignatureError(PgDispatchError):
39
+ """A webhook signature could not be verified."""
40
+
41
+
42
+ class MigrationError(PgDispatchError):
43
+ """The database schema could not be migrated."""
@@ -0,0 +1,23 @@
1
+ """FastAPI integration: a ready-made API for your customers to manage their endpoints.
2
+
3
+ ::
4
+
5
+ from fastapi import Depends, FastAPI
6
+ from pgdispatch.fastapi import router
7
+
8
+ async def current_merchant(user: User = Depends(current_user)) -> str:
9
+ return user.merchant_id # every request is scoped to this owner
10
+
11
+ app = FastAPI(lifespan=webhooks.lifespan())
12
+ app.include_router(router(webhooks, owner=current_merchant), prefix="/webhooks")
13
+
14
+ The ``owner`` dependency is mandatory: it authenticates the caller and returns
15
+ the owner id that every operation is restricted to, so one customer can never
16
+ see or touch another customer's endpoints.
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ from pgdispatch.fastapi.router import router
22
+
23
+ __all__ = ["router"]
@@ -0,0 +1,243 @@
1
+ """The endpoint-management router. See :mod:`pgdispatch.fastapi`."""
2
+
3
+ # FastAPI inspects annotations at runtime, and the route handlers below are
4
+ # closures over the ``owner`` dependency, so annotations must stay eager here.
5
+
6
+ import uuid
7
+ from collections.abc import Awaitable, Callable, Iterator, Sequence
8
+ from contextlib import contextmanager
9
+ from datetime import datetime, timedelta
10
+ from typing import Any
11
+
12
+ try:
13
+ from fastapi import APIRouter, Depends, HTTPException, Query, status
14
+ except ImportError as exc: # pragma: no cover - depends on the environment
15
+ msg = 'pgdispatch.fastapi requires FastAPI: pip install "pgdispatch[fastapi]"'
16
+ raise ImportError(msg) from exc
17
+ from pydantic import BaseModel, ConfigDict, Field
18
+
19
+ from pgdispatch.client import Webhooks
20
+ from pgdispatch.errors import ConflictError, NotFoundError, ValidationError
21
+ from pgdispatch.models import CircuitState, DeliveryStatus
22
+
23
+ __all__ = ["router"]
24
+
25
+ # Starlette renamed the 422 constant; the number is stable across versions.
26
+ _UNPROCESSABLE = 422
27
+
28
+ OwnerDependency = Callable[..., Awaitable[str | None] | str | None]
29
+
30
+
31
+ class EndpointCreate(BaseModel):
32
+ url: str = Field(examples=["https://shop.example.com/webhooks"])
33
+ event_types: list[str] = Field(default=["*"], examples=[["order.*", "payment.paid"]])
34
+ description: str | None = None
35
+ headers: dict[str, str] = Field(default_factory=dict)
36
+ rate_limit_rps: float | None = Field(default=None, gt=0)
37
+
38
+
39
+ class EndpointUpdate(BaseModel):
40
+ """Only the fields present in the request are changed."""
41
+
42
+ url: str | None = None
43
+ event_types: list[str] | None = None
44
+ description: str | None = None
45
+ headers: dict[str, str] | None = None
46
+ rate_limit_rps: float | None = Field(default=None, gt=0)
47
+
48
+
49
+ class EndpointOut(BaseModel):
50
+ model_config = ConfigDict(from_attributes=True)
51
+
52
+ id: uuid.UUID
53
+ url: str
54
+ description: str | None
55
+ event_types: list[str]
56
+ headers: dict[str, str]
57
+ rate_limit_rps: float | None
58
+ is_enabled: bool
59
+ disabled_reason: str | None
60
+ circuit_state: CircuitState
61
+ created_at: datetime
62
+ updated_at: datetime
63
+
64
+
65
+ class EndpointWithSecret(EndpointOut):
66
+ secret: str = Field(description="The signing secret. Store it now; it is shown only once.")
67
+
68
+
69
+ class SecretOut(BaseModel):
70
+ secret: str
71
+
72
+
73
+ class RotateSecret(BaseModel):
74
+ grace_period_seconds: int = Field(default=24 * 3600, ge=0, le=7 * 24 * 3600)
75
+
76
+
77
+ class TestEventOut(BaseModel):
78
+ event_id: uuid.UUID
79
+
80
+
81
+ class AttemptOut(BaseModel):
82
+ model_config = ConfigDict(from_attributes=True)
83
+
84
+ attempt_number: int
85
+ response_status: int | None
86
+ response_body: str | None
87
+ duration_ms: int | None
88
+ error: str | None
89
+ created_at: datetime
90
+
91
+
92
+ class DeliveryOut(BaseModel):
93
+ model_config = ConfigDict(from_attributes=True)
94
+
95
+ id: int
96
+ event_id: uuid.UUID
97
+ endpoint_id: uuid.UUID
98
+ event_type: str
99
+ status: DeliveryStatus
100
+ attempt_count: int
101
+ next_attempt_at: datetime
102
+ last_status_code: int | None
103
+ last_error: str | None
104
+ created_at: datetime
105
+ finished_at: datetime | None
106
+
107
+
108
+ class DeliveryDetail(DeliveryOut):
109
+ attempts: list[AttemptOut]
110
+
111
+
112
+ def router(
113
+ webhooks: Webhooks,
114
+ *,
115
+ owner: OwnerDependency,
116
+ tags: Sequence[str] = ("webhooks",),
117
+ ) -> APIRouter:
118
+ """Build an :class:`~fastapi.APIRouter` for managing endpoints and deliveries.
119
+
120
+ :param owner: A FastAPI dependency that authenticates the request and
121
+ returns the owner id (tenant) to scope every operation to.
122
+ """
123
+ api = APIRouter(tags=list(tags))
124
+ owner_id = Depends(owner)
125
+
126
+ @api.get("/endpoints", response_model=list[EndpointOut])
127
+ async def list_endpoints(
128
+ after: uuid.UUID | None = None,
129
+ limit: int = Query(default=100, ge=1, le=1000),
130
+ owner_id: str | None = owner_id,
131
+ ) -> Any:
132
+ return await webhooks.endpoints.list(owner_id=owner_id, after=after, limit=limit)
133
+
134
+ @api.post("/endpoints", response_model=EndpointWithSecret, status_code=status.HTTP_201_CREATED)
135
+ async def create_endpoint(body: EndpointCreate, owner_id: str | None = owner_id) -> Any:
136
+ with _errors():
137
+ return await webhooks.endpoints.create(
138
+ body.url,
139
+ event_types=body.event_types,
140
+ owner_id=owner_id,
141
+ description=body.description,
142
+ headers=body.headers,
143
+ rate_limit_rps=body.rate_limit_rps,
144
+ )
145
+
146
+ @api.get("/endpoints/{endpoint_id}", response_model=EndpointOut)
147
+ async def get_endpoint(endpoint_id: uuid.UUID, owner_id: str | None = owner_id) -> Any:
148
+ with _errors():
149
+ return await webhooks.endpoints.get(endpoint_id, owner_id=owner_id)
150
+
151
+ @api.patch("/endpoints/{endpoint_id}", response_model=EndpointOut)
152
+ async def update_endpoint(
153
+ endpoint_id: uuid.UUID, body: EndpointUpdate, owner_id: str | None = owner_id
154
+ ) -> Any:
155
+ changes = body.model_dump(exclude_unset=True)
156
+ if changes.get("url", "") is None or changes.get("event_types", []) is None:
157
+ raise HTTPException(_UNPROCESSABLE, "url and event_types cannot be null")
158
+ if "headers" in changes and changes["headers"] is None:
159
+ changes["headers"] = {}
160
+ with _errors():
161
+ return await webhooks.endpoints.update(endpoint_id, owner_id=owner_id, **changes)
162
+
163
+ @api.delete("/endpoints/{endpoint_id}", status_code=status.HTTP_204_NO_CONTENT)
164
+ async def delete_endpoint(endpoint_id: uuid.UUID, owner_id: str | None = owner_id) -> None:
165
+ with _errors():
166
+ await webhooks.endpoints.delete(endpoint_id, owner_id=owner_id)
167
+
168
+ @api.post("/endpoints/{endpoint_id}/enable", response_model=EndpointOut)
169
+ async def enable_endpoint(endpoint_id: uuid.UUID, owner_id: str | None = owner_id) -> Any:
170
+ with _errors():
171
+ return await webhooks.endpoints.enable(endpoint_id, owner_id=owner_id)
172
+
173
+ @api.post("/endpoints/{endpoint_id}/disable", response_model=EndpointOut)
174
+ async def disable_endpoint(endpoint_id: uuid.UUID, owner_id: str | None = owner_id) -> Any:
175
+ with _errors():
176
+ return await webhooks.endpoints.disable(
177
+ endpoint_id, reason="disabled by the owner", owner_id=owner_id
178
+ )
179
+
180
+ @api.get("/endpoints/{endpoint_id}/secret", response_model=SecretOut)
181
+ async def get_secret(endpoint_id: uuid.UUID, owner_id: str | None = owner_id) -> Any:
182
+ with _errors():
183
+ return {"secret": await webhooks.endpoints.get_secret(endpoint_id, owner_id=owner_id)}
184
+
185
+ @api.post("/endpoints/{endpoint_id}/rotate-secret", response_model=EndpointWithSecret)
186
+ async def rotate_secret(
187
+ endpoint_id: uuid.UUID, body: RotateSecret | None = None, owner_id: str | None = owner_id
188
+ ) -> Any:
189
+ grace = timedelta(seconds=(body or RotateSecret()).grace_period_seconds)
190
+ with _errors():
191
+ return await webhooks.endpoints.rotate_secret(
192
+ endpoint_id, grace_period=grace, owner_id=owner_id
193
+ )
194
+
195
+ @api.post("/endpoints/{endpoint_id}/test", response_model=TestEventOut, status_code=202)
196
+ async def send_test(endpoint_id: uuid.UUID, owner_id: str | None = owner_id) -> Any:
197
+ with _errors():
198
+ result = await webhooks.endpoints.send_test(endpoint_id, owner_id=owner_id)
199
+ return {"event_id": result.event_id}
200
+
201
+ @api.get("/deliveries", response_model=list[DeliveryOut])
202
+ async def list_deliveries(
203
+ endpoint_id: uuid.UUID | None = None,
204
+ status_filter: DeliveryStatus | None = Query(default=None, alias="status"),
205
+ before: int | None = None,
206
+ limit: int = Query(default=50, ge=1, le=1000),
207
+ owner_id: str | None = owner_id,
208
+ ) -> Any:
209
+ return await webhooks.deliveries.list(
210
+ endpoint_id=endpoint_id,
211
+ status=status_filter,
212
+ before=before,
213
+ limit=limit,
214
+ owner_id=owner_id,
215
+ )
216
+
217
+ @api.get("/deliveries/{delivery_id}", response_model=DeliveryDetail)
218
+ async def get_delivery(delivery_id: int, owner_id: str | None = owner_id) -> Any:
219
+ with _errors():
220
+ delivery = await webhooks.deliveries.get(delivery_id, owner_id=owner_id)
221
+ attempts = await webhooks.deliveries.attempts(delivery_id, owner_id=owner_id)
222
+ detail = DeliveryOut.model_validate(delivery).model_dump()
223
+ return {**detail, "attempts": [AttemptOut.model_validate(a) for a in attempts]}
224
+
225
+ @api.post("/deliveries/{delivery_id}/retry", response_model=DeliveryOut)
226
+ async def retry_delivery(delivery_id: int, owner_id: str | None = owner_id) -> Any:
227
+ with _errors():
228
+ return await webhooks.deliveries.retry(delivery_id, owner_id=owner_id)
229
+
230
+ return api
231
+
232
+
233
+ @contextmanager
234
+ def _errors() -> Iterator[None]:
235
+ """Translate library errors into HTTP responses."""
236
+ try:
237
+ yield
238
+ except NotFoundError as exc:
239
+ raise HTTPException(status.HTTP_404_NOT_FOUND, str(exc)) from exc
240
+ except ConflictError as exc:
241
+ raise HTTPException(status.HTTP_409_CONFLICT, str(exc)) from exc
242
+ except ValidationError as exc:
243
+ raise HTTPException(_UNPROCESSABLE, str(exc)) from exc
pgdispatch/hooks.py ADDED
@@ -0,0 +1,80 @@
1
+ """Callbacks that let the application react to delivery problems.
2
+
3
+ Register them with decorators::
4
+
5
+ @webhooks.hooks.on_endpoint_disabled
6
+ async def notify_customer(endpoint: Endpoint, reason: str) -> None:
7
+ await mailer.send(endpoint.owner_id, f"Your webhook endpoint was disabled: {reason}")
8
+
9
+ Callbacks may be plain functions or coroutines. They run in the worker after
10
+ the state change is committed; an exception in a callback is logged and never
11
+ affects delivery.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import inspect
17
+ import logging
18
+ from collections.abc import Awaitable, Callable
19
+ from typing import Any, TypeVar
20
+
21
+ from pgdispatch.models import Delivery, Endpoint
22
+
23
+ __all__ = ["Hooks"]
24
+
25
+ logger = logging.getLogger("pgdispatch.hooks")
26
+
27
+ DeliveryFailedHook = Callable[[Delivery], Awaitable[None] | None]
28
+ EndpointDisabledHook = Callable[[Endpoint, str], Awaitable[None] | None]
29
+ CircuitOpenedHook = Callable[[Endpoint], Awaitable[None] | None]
30
+
31
+ F = TypeVar("F", bound=Callable[..., Any])
32
+
33
+
34
+ class Hooks:
35
+ """Registry of application callbacks."""
36
+
37
+ def __init__(self) -> None:
38
+ self._delivery_failed: list[DeliveryFailedHook] = []
39
+ self._endpoint_disabled: list[EndpointDisabledHook] = []
40
+ self._circuit_opened: list[CircuitOpenedHook] = []
41
+
42
+ def on_delivery_failed(self, callback: F) -> F:
43
+ """Called with the :class:`Delivery` when its last attempt fails."""
44
+ self._delivery_failed.append(callback)
45
+ return callback
46
+
47
+ def on_endpoint_disabled(self, callback: F) -> F:
48
+ """Called with the :class:`Endpoint` and the reason when the worker disables it."""
49
+ self._endpoint_disabled.append(callback)
50
+ return callback
51
+
52
+ def on_circuit_opened(self, callback: F) -> F:
53
+ """Called with the :class:`Endpoint` when its circuit breaker opens."""
54
+ self._circuit_opened.append(callback)
55
+ return callback
56
+
57
+ async def delivery_failed(self, delivery: Delivery) -> None:
58
+ for callback in self._delivery_failed:
59
+ await _call(callback, delivery)
60
+
61
+ async def endpoint_disabled(self, endpoint: Endpoint, reason: str) -> None:
62
+ for callback in self._endpoint_disabled:
63
+ await _call(callback, endpoint, reason)
64
+
65
+ async def circuit_opened(self, endpoint: Endpoint) -> None:
66
+ for callback in self._circuit_opened:
67
+ await _call(callback, endpoint)
68
+
69
+
70
+ async def _call(callback: Callable[..., Any], *args: Any) -> None:
71
+ try:
72
+ result = callback(*args)
73
+ if inspect.isawaitable(result):
74
+ await result
75
+ except Exception:
76
+ logger.exception("pgdispatch hook %s raised an exception", _name(callback))
77
+
78
+
79
+ def _name(callback: Callable[..., Any]) -> str:
80
+ return getattr(callback, "__qualname__", repr(callback))
pgdispatch/metrics.py ADDED
@@ -0,0 +1,80 @@
1
+ """Prometheus metrics.
2
+
3
+ Metrics are registered in ``prometheus_client``'s default registry, so they
4
+ appear on your application's existing ``/metrics`` endpoint; the standalone
5
+ worker can expose them with ``--metrics-port``. Endpoint ids are deliberately
6
+ not used as labels: thousands of endpoints would explode cardinality. Use the
7
+ CLI or the tables for per-endpoint details.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ from prometheus_client import REGISTRY, CollectorRegistry, Counter, Gauge, Histogram
13
+
14
+ __all__ = ["Metrics", "get_metrics"]
15
+
16
+ _DURATION_BUCKETS = (0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1.0, 2.5, 5.0, 10.0, 15.0, 30.0)
17
+ _BATCH_BUCKETS = (1, 2, 5, 10, 25, 50, 100, 250, 500, 1000)
18
+
19
+
20
+ class Metrics:
21
+ """The metric objects of one registry."""
22
+
23
+ def __init__(self, registry: CollectorRegistry) -> None:
24
+ self.events_published = Counter(
25
+ "pgdispatch_events_published",
26
+ "Events published, by event type.",
27
+ ["event_type"],
28
+ registry=registry,
29
+ )
30
+ self.delivery_attempts = Counter(
31
+ "pgdispatch_delivery_attempts",
32
+ "HTTP delivery attempts, by result (success, http_error, timeout, network).",
33
+ ["result"],
34
+ registry=registry,
35
+ )
36
+ self.delivery_duration = Histogram(
37
+ "pgdispatch_delivery_duration_seconds",
38
+ "Duration of HTTP delivery attempts.",
39
+ ["result"],
40
+ buckets=_DURATION_BUCKETS,
41
+ registry=registry,
42
+ )
43
+ self.deliveries_failed = Counter(
44
+ "pgdispatch_deliveries_failed",
45
+ "Deliveries that exhausted all their attempts.",
46
+ registry=registry,
47
+ )
48
+ self.deliveries_pending = Gauge(
49
+ "pgdispatch_deliveries_pending",
50
+ "Deliveries waiting for an attempt on enabled endpoints.",
51
+ registry=registry,
52
+ )
53
+ self.oldest_pending_age = Gauge(
54
+ "pgdispatch_oldest_pending_age_seconds",
55
+ "How long the most overdue delivery has been waiting (delivery lag).",
56
+ registry=registry,
57
+ )
58
+ self.endpoints_circuit_open = Gauge(
59
+ "pgdispatch_endpoints_circuit_open",
60
+ "Enabled endpoints whose circuit breaker is open or half-open.",
61
+ registry=registry,
62
+ )
63
+ self.worker_batch_size = Histogram(
64
+ "pgdispatch_worker_batch_size",
65
+ "Deliveries claimed per database round-trip.",
66
+ buckets=_BATCH_BUCKETS,
67
+ registry=registry,
68
+ )
69
+
70
+
71
+ _instances: dict[int, Metrics] = {}
72
+
73
+
74
+ def get_metrics(registry: CollectorRegistry | None = None) -> Metrics:
75
+ """Return the metrics bound to ``registry``, creating them on first use."""
76
+ registry = registry or REGISTRY
77
+ key = id(registry)
78
+ if key not in _instances:
79
+ _instances[key] = Metrics(registry)
80
+ return _instances[key]
@@ -0,0 +1,104 @@
1
+ -- pgdispatch schema, version 1.
2
+ --
3
+ -- Statements end with a semicolon at the end of a line; the migrator splits on
4
+ -- that, so keep function bodies and dollar quoting out of migrations.
5
+
6
+ CREATE TABLE {schema}.endpoints (
7
+ id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
8
+ owner_id text,
9
+ url text NOT NULL,
10
+ description text,
11
+ event_types text[] NOT NULL DEFAULT '{*}',
12
+ secrets jsonb NOT NULL,
13
+ headers jsonb NOT NULL DEFAULT '{}',
14
+ rate_limit_rps double precision CHECK (rate_limit_rps > 0),
15
+ is_enabled boolean NOT NULL DEFAULT true,
16
+ disabled_reason text,
17
+ circuit_state text NOT NULL DEFAULT 'closed'
18
+ CHECK (circuit_state IN ('closed', 'open', 'half_open')),
19
+ consecutive_failures integer NOT NULL DEFAULT 0,
20
+ failing_since timestamptz,
21
+ circuit_opened_at timestamptz,
22
+ circuit_retry_at timestamptz,
23
+ circuit_trips integer NOT NULL DEFAULT 0,
24
+ created_at timestamptz NOT NULL DEFAULT now(),
25
+ updated_at timestamptz NOT NULL DEFAULT now()
26
+ );
27
+
28
+ CREATE INDEX endpoints_owner_idx ON {schema}.endpoints (owner_id) WHERE is_enabled;
29
+
30
+ CREATE INDEX endpoints_event_types_idx ON {schema}.endpoints USING gin (event_types) WHERE is_enabled;
31
+
32
+ -- Workers look for tripped breakers whose cooldown has elapsed on every poll.
33
+ CREATE INDEX endpoints_circuit_idx ON {schema}.endpoints (circuit_retry_at)
34
+ WHERE is_enabled AND circuit_state <> 'closed';
35
+
36
+ CREATE TABLE {schema}.events (
37
+ id uuid PRIMARY KEY,
38
+ event_type text NOT NULL,
39
+ owner_id text,
40
+ payload jsonb NOT NULL,
41
+ idempotency_key text,
42
+ created_at timestamptz NOT NULL DEFAULT now()
43
+ );
44
+
45
+ CREATE UNIQUE INDEX events_idempotency_key_idx ON {schema}.events (idempotency_key)
46
+ WHERE idempotency_key IS NOT NULL;
47
+
48
+ CREATE INDEX events_created_at_idx ON {schema}.events (created_at);
49
+
50
+ CREATE TABLE {schema}.deliveries (
51
+ id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
52
+ event_id uuid NOT NULL REFERENCES {schema}.events (id) ON DELETE CASCADE,
53
+ endpoint_id uuid NOT NULL REFERENCES {schema}.endpoints (id) ON DELETE CASCADE,
54
+ ordering_key text,
55
+ status text NOT NULL DEFAULT 'pending'
56
+ CHECK (status IN ('pending', 'in_flight', 'succeeded', 'failed', 'cancelled')),
57
+ attempt_count integer NOT NULL DEFAULT 0,
58
+ -- attempt_count when the current retry round began; a manual retry starts
59
+ -- a new round with the full schedule while attempt numbers keep growing.
60
+ schedule_offset integer NOT NULL DEFAULT 0,
61
+ next_attempt_at timestamptz NOT NULL DEFAULT now(),
62
+ locked_until timestamptz,
63
+ last_status_code integer,
64
+ last_error text,
65
+ created_at timestamptz NOT NULL DEFAULT now(),
66
+ updated_at timestamptz NOT NULL DEFAULT now(),
67
+ finished_at timestamptz
68
+ );
69
+
70
+ -- The hot path: workers claim due deliveries in next_attempt_at order.
71
+ CREATE INDEX deliveries_due_idx ON {schema}.deliveries (next_attempt_at)
72
+ WHERE status IN ('pending', 'in_flight');
73
+
74
+ -- Ordered delivery: "is there an earlier unfinished delivery with this key?"
75
+ CREATE INDEX deliveries_ordering_idx ON {schema}.deliveries (endpoint_id, ordering_key, id)
76
+ WHERE status IN ('pending', 'in_flight') AND ordering_key IS NOT NULL;
77
+
78
+ CREATE INDEX deliveries_endpoint_idx ON {schema}.deliveries (endpoint_id, id);
79
+
80
+ CREATE INDEX deliveries_event_idx ON {schema}.deliveries (event_id);
81
+
82
+ CREATE TABLE {schema}.attempts (
83
+ id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
84
+ delivery_id bigint NOT NULL REFERENCES {schema}.deliveries (id) ON DELETE CASCADE,
85
+ attempt_number integer NOT NULL,
86
+ request_headers jsonb,
87
+ response_status integer,
88
+ response_body text,
89
+ duration_ms integer,
90
+ error text,
91
+ created_at timestamptz NOT NULL DEFAULT now()
92
+ );
93
+
94
+ CREATE INDEX attempts_delivery_idx ON {schema}.attempts (delivery_id);
95
+
96
+ CREATE TABLE {schema}.workers (
97
+ id uuid PRIMARY KEY,
98
+ hostname text NOT NULL,
99
+ pid integer NOT NULL,
100
+ version text NOT NULL,
101
+ concurrency integer NOT NULL,
102
+ started_at timestamptz NOT NULL DEFAULT now(),
103
+ heartbeat_at timestamptz NOT NULL DEFAULT now()
104
+ );
@@ -0,0 +1,93 @@
1
+ """Versioned SQL migrations for the pgdispatch schema.
2
+
3
+ Migrations are plain SQL files named ``NNNN_description.sql``. They are applied
4
+ in order, each in the caller's transaction, and recorded in
5
+ ``{schema}.schema_migrations``. A transaction-scoped advisory lock makes
6
+ concurrent ``migrate`` calls (several pods starting at once) safe.
7
+
8
+ The same migrator powers ``pgdispatch migrate``, ``Webhooks.migrate()``, the
9
+ Django app and the Alembic helper.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import re
15
+ from dataclasses import dataclass
16
+ from functools import cache
17
+ from importlib import resources
18
+
19
+ from pgdispatch.backends.base import Operation, Query
20
+
21
+ __all__ = ["Migration", "drop_schema", "load_migrations", "migrate"]
22
+
23
+ _FILE_NAME = re.compile(r"^(\d{4})_(\w+)\.sql$")
24
+ _STATEMENT_END = re.compile(r";[ \t]*$", re.MULTILINE)
25
+
26
+
27
+ @dataclass(frozen=True, slots=True)
28
+ class Migration:
29
+ version: int
30
+ name: str
31
+ statements: tuple[str, ...]
32
+
33
+
34
+ @cache
35
+ def load_migrations() -> tuple[Migration, ...]:
36
+ """All migrations shipped with the package, oldest first."""
37
+ migrations = []
38
+ for entry in resources.files(__package__).iterdir():
39
+ match = _FILE_NAME.match(entry.name)
40
+ if match is None:
41
+ continue
42
+ migrations.append(
43
+ Migration(
44
+ version=int(match.group(1)),
45
+ name=match.group(2),
46
+ statements=_split_statements(entry.read_text(encoding="utf-8")),
47
+ )
48
+ )
49
+ return tuple(sorted(migrations, key=lambda migration: migration.version))
50
+
51
+
52
+ def migrate(*, target: int | None = None) -> Operation[list[int]]:
53
+ """Apply pending migrations up to ``target`` (all by default); return the applied versions."""
54
+ yield Query("SELECT pg_advisory_xact_lock(hashtext('{schema}.migrate'))")
55
+ yield Query("CREATE SCHEMA IF NOT EXISTS {schema}")
56
+ yield Query(
57
+ """
58
+ CREATE TABLE IF NOT EXISTS {schema}.schema_migrations (
59
+ version integer PRIMARY KEY,
60
+ name text NOT NULL,
61
+ applied_at timestamptz NOT NULL DEFAULT now()
62
+ )
63
+ """
64
+ )
65
+ rows = yield Query("SELECT version FROM {schema}.schema_migrations")
66
+ applied = {row["version"] for row in rows}
67
+
68
+ newly_applied = []
69
+ for migration in load_migrations():
70
+ if migration.version in applied or (target is not None and migration.version > target):
71
+ continue
72
+ for statement in migration.statements:
73
+ yield Query(statement)
74
+ yield Query(
75
+ "INSERT INTO {schema}.schema_migrations (version, name) "
76
+ "VALUES (CAST(:version AS integer), CAST(:name AS text))",
77
+ {"version": migration.version, "name": migration.name},
78
+ )
79
+ newly_applied.append(migration.version)
80
+ return newly_applied
81
+
82
+
83
+ def drop_schema() -> Operation[None]:
84
+ """Remove the schema with all data. Used by ``migrate zero`` in Django and by tests."""
85
+ yield Query("DROP SCHEMA IF EXISTS {schema} CASCADE")
86
+
87
+
88
+ def _split_statements(sql: str) -> tuple[str, ...]:
89
+ without_comments = "\n".join(
90
+ line for line in sql.splitlines() if not line.lstrip().startswith("--")
91
+ )
92
+ parts = _STATEMENT_END.split(without_comments)
93
+ return tuple(part.strip() for part in parts if part.strip())