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/config.py
ADDED
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
"""Library configuration.
|
|
2
|
+
|
|
3
|
+
All settings live in one immutable :class:`WebhooksConfig`. Values come from, in
|
|
4
|
+
order of precedence: keyword arguments, environment variables prefixed with
|
|
5
|
+
``PGDISPATCH_`` (``PGDISPATCH_CONCURRENCY=100``), and the defaults below. Django
|
|
6
|
+
projects can also use ``settings.PGDISPATCH``.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import re
|
|
12
|
+
from datetime import timedelta
|
|
13
|
+
from typing import Any
|
|
14
|
+
|
|
15
|
+
from pydantic import Field, SecretStr, field_validator, model_validator
|
|
16
|
+
from pydantic_settings import BaseSettings, SettingsConfigDict
|
|
17
|
+
|
|
18
|
+
from pgdispatch._version import __version__
|
|
19
|
+
from pgdispatch.core.retry import DEFAULT_RETRY_SCHEDULE
|
|
20
|
+
|
|
21
|
+
_IDENTIFIER = re.compile(r"^[a-z_][a-z0-9_]{0,62}$")
|
|
22
|
+
_NUMBER = re.compile(r"^\d+(\.\d+)?$")
|
|
23
|
+
_DURATION_PART = re.compile(r"(\d+)(ms|s|m|h|d|w)")
|
|
24
|
+
_DURATION_UNITS = {
|
|
25
|
+
"ms": "milliseconds",
|
|
26
|
+
"s": "seconds",
|
|
27
|
+
"m": "minutes",
|
|
28
|
+
"h": "hours",
|
|
29
|
+
"d": "days",
|
|
30
|
+
"w": "weeks",
|
|
31
|
+
}
|
|
32
|
+
_DURATION_FIELDS = (
|
|
33
|
+
"cleanup_interval",
|
|
34
|
+
"poll_interval",
|
|
35
|
+
"lease_duration",
|
|
36
|
+
"heartbeat_interval",
|
|
37
|
+
"shutdown_timeout",
|
|
38
|
+
"request_timeout",
|
|
39
|
+
"circuit_cooldown",
|
|
40
|
+
"circuit_max_cooldown",
|
|
41
|
+
"auto_disable_after",
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def parse_duration(value: str) -> timedelta:
|
|
46
|
+
"""Parse a duration: ``"30"`` and ``"0.5"`` are seconds; units: ``ms s m h d w``.
|
|
47
|
+
|
|
48
|
+
Examples: ``"250ms"``, ``"30s"``, ``"5m"``, ``"1h30m"``, ``"14d"``.
|
|
49
|
+
"""
|
|
50
|
+
text = value.strip()
|
|
51
|
+
if _NUMBER.match(text):
|
|
52
|
+
return timedelta(seconds=float(text))
|
|
53
|
+
parts = _DURATION_PART.findall(text)
|
|
54
|
+
if not parts or "".join(number + unit for number, unit in parts) != text:
|
|
55
|
+
msg = f"invalid duration {value!r}; use seconds or e.g. 500ms, 30s, 5m, 1h30m, 14d"
|
|
56
|
+
raise ValueError(msg)
|
|
57
|
+
return sum((timedelta(**{_DURATION_UNITS[unit]: int(n)}) for n, unit in parts), timedelta())
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _coerce_duration(value: Any) -> Any:
|
|
61
|
+
# ISO 8601 strings ("PT5M") are left to pydantic.
|
|
62
|
+
if isinstance(value, str) and not value.strip().upper().startswith("P"):
|
|
63
|
+
return parse_duration(value)
|
|
64
|
+
return value
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
class WebhooksConfig(BaseSettings):
|
|
68
|
+
"""Tunable behaviour of publishing, delivery and housekeeping.
|
|
69
|
+
|
|
70
|
+
The defaults are production-ready; most applications never change them.
|
|
71
|
+
Durations accept :class:`~datetime.timedelta`, a number of seconds, a
|
|
72
|
+
short string such as ``"500ms"``, ``"30s"``, ``"5m"``, ``"1h30m"`` or
|
|
73
|
+
``"14d"``, or an ISO 8601 duration (``"PT5M"``).
|
|
74
|
+
"""
|
|
75
|
+
|
|
76
|
+
model_config = SettingsConfigDict(
|
|
77
|
+
env_prefix="PGDISPATCH_",
|
|
78
|
+
frozen=True,
|
|
79
|
+
extra="forbid",
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
# --- storage -------------------------------------------------------------
|
|
83
|
+
db_schema: str = Field(
|
|
84
|
+
default="pgdispatch",
|
|
85
|
+
description="PostgreSQL schema that holds all pgdispatch tables.",
|
|
86
|
+
)
|
|
87
|
+
retention_days: int = Field(
|
|
88
|
+
default=14,
|
|
89
|
+
ge=1,
|
|
90
|
+
description="How long events, deliveries and attempts are kept.",
|
|
91
|
+
)
|
|
92
|
+
cleanup_interval: timedelta = Field(
|
|
93
|
+
default=timedelta(minutes=10),
|
|
94
|
+
description="How often a worker purges data older than the retention period.",
|
|
95
|
+
)
|
|
96
|
+
cleanup_batch_size: int = Field(default=5_000, ge=1)
|
|
97
|
+
|
|
98
|
+
# --- publishing ----------------------------------------------------------
|
|
99
|
+
max_payload_bytes: int = Field(
|
|
100
|
+
default=256 * 1024,
|
|
101
|
+
ge=1,
|
|
102
|
+
description="Upper bound for the JSON-serialized event payload.",
|
|
103
|
+
)
|
|
104
|
+
|
|
105
|
+
# --- worker --------------------------------------------------------------
|
|
106
|
+
concurrency: int = Field(default=50, ge=1, description="Simultaneous HTTP requests per worker.")
|
|
107
|
+
endpoint_concurrency: int = Field(
|
|
108
|
+
default=10,
|
|
109
|
+
ge=1,
|
|
110
|
+
description="Simultaneous HTTP requests per worker to a single endpoint.",
|
|
111
|
+
)
|
|
112
|
+
batch_size: int = Field(default=100, ge=1, description="Deliveries claimed per database query.")
|
|
113
|
+
poll_interval: timedelta = Field(
|
|
114
|
+
default=timedelta(seconds=1),
|
|
115
|
+
description="Fallback polling period in case a NOTIFY wake-up is missed.",
|
|
116
|
+
)
|
|
117
|
+
lease_duration: timedelta = Field(
|
|
118
|
+
default=timedelta(seconds=30),
|
|
119
|
+
description="How long a claimed delivery stays reserved for a worker.",
|
|
120
|
+
)
|
|
121
|
+
heartbeat_interval: timedelta = Field(default=timedelta(seconds=5))
|
|
122
|
+
shutdown_timeout: timedelta = Field(
|
|
123
|
+
default=timedelta(seconds=25),
|
|
124
|
+
description="Grace period for in-flight requests when the worker stops.",
|
|
125
|
+
)
|
|
126
|
+
|
|
127
|
+
# --- HTTP ----------------------------------------------------------------
|
|
128
|
+
request_timeout: timedelta = Field(default=timedelta(seconds=15))
|
|
129
|
+
user_agent: str = Field(default=f"pgdispatch/{__version__}")
|
|
130
|
+
store_response_body: bool = Field(default=True)
|
|
131
|
+
max_response_body_bytes: int = Field(default=4096, ge=0)
|
|
132
|
+
outbound_proxy: str | None = Field(
|
|
133
|
+
default=None,
|
|
134
|
+
description="Send all requests through this proxy; SSRF filtering is then the proxy's job.",
|
|
135
|
+
)
|
|
136
|
+
|
|
137
|
+
# --- retries -------------------------------------------------------------
|
|
138
|
+
retry_schedule: tuple[timedelta, ...] = Field(
|
|
139
|
+
default=DEFAULT_RETRY_SCHEDULE,
|
|
140
|
+
min_length=1,
|
|
141
|
+
description="Delay before each attempt; its length is the maximum number of attempts.",
|
|
142
|
+
)
|
|
143
|
+
retry_jitter: float = Field(default=0.2, ge=0.0, lt=1.0)
|
|
144
|
+
|
|
145
|
+
# --- circuit breaker -----------------------------------------------------
|
|
146
|
+
failure_threshold: int = Field(default=10, ge=1)
|
|
147
|
+
circuit_cooldown: timedelta = Field(default=timedelta(minutes=5))
|
|
148
|
+
circuit_max_cooldown: timedelta = Field(default=timedelta(hours=1))
|
|
149
|
+
auto_disable_after: timedelta | None = Field(
|
|
150
|
+
default=timedelta(days=5),
|
|
151
|
+
description="Disable an endpoint that has been failing continuously for this long.",
|
|
152
|
+
)
|
|
153
|
+
|
|
154
|
+
# --- security ------------------------------------------------------------
|
|
155
|
+
allow_http: bool = Field(default=False, description="Allow plain-HTTP endpoints (development).")
|
|
156
|
+
allow_private_ips: bool = Field(
|
|
157
|
+
default=False,
|
|
158
|
+
description="Disable SSRF protection. Never enable this in production.",
|
|
159
|
+
)
|
|
160
|
+
secret_encryption_keys: tuple[SecretStr, ...] = Field(
|
|
161
|
+
default=(),
|
|
162
|
+
description="Fernet keys used to encrypt signing secrets at rest; the first one encrypts.",
|
|
163
|
+
)
|
|
164
|
+
|
|
165
|
+
@field_validator(*_DURATION_FIELDS, mode="before")
|
|
166
|
+
@classmethod
|
|
167
|
+
def _parse_durations(cls, value: Any) -> Any:
|
|
168
|
+
return _coerce_duration(value)
|
|
169
|
+
|
|
170
|
+
@field_validator("retry_schedule", mode="before")
|
|
171
|
+
@classmethod
|
|
172
|
+
def _parse_schedule(cls, value: Any) -> Any:
|
|
173
|
+
if isinstance(value, (list, tuple)):
|
|
174
|
+
return [_coerce_duration(item) for item in value]
|
|
175
|
+
return value
|
|
176
|
+
|
|
177
|
+
@field_validator("db_schema")
|
|
178
|
+
@classmethod
|
|
179
|
+
def _check_schema(cls, value: str) -> str:
|
|
180
|
+
if not _IDENTIFIER.match(value):
|
|
181
|
+
msg = "db_schema must be a lowercase SQL identifier, e.g. 'pgdispatch'"
|
|
182
|
+
raise ValueError(msg)
|
|
183
|
+
return value
|
|
184
|
+
|
|
185
|
+
@field_validator("retry_schedule")
|
|
186
|
+
@classmethod
|
|
187
|
+
def _check_retry_schedule(cls, value: tuple[timedelta, ...]) -> tuple[timedelta, ...]:
|
|
188
|
+
if any(delay < timedelta(0) for delay in value):
|
|
189
|
+
msg = "retry_schedule delays must not be negative"
|
|
190
|
+
raise ValueError(msg)
|
|
191
|
+
return value
|
|
192
|
+
|
|
193
|
+
@model_validator(mode="after")
|
|
194
|
+
def _check_timeouts(self) -> WebhooksConfig:
|
|
195
|
+
positive = {
|
|
196
|
+
"poll_interval": self.poll_interval,
|
|
197
|
+
"lease_duration": self.lease_duration,
|
|
198
|
+
"heartbeat_interval": self.heartbeat_interval,
|
|
199
|
+
"request_timeout": self.request_timeout,
|
|
200
|
+
"circuit_cooldown": self.circuit_cooldown,
|
|
201
|
+
"cleanup_interval": self.cleanup_interval,
|
|
202
|
+
}
|
|
203
|
+
for name, value in positive.items():
|
|
204
|
+
if value <= timedelta(0):
|
|
205
|
+
msg = f"{name} must be positive"
|
|
206
|
+
raise ValueError(msg)
|
|
207
|
+
if self.lease_duration <= self.request_timeout:
|
|
208
|
+
msg = "lease_duration must be longer than request_timeout"
|
|
209
|
+
raise ValueError(msg)
|
|
210
|
+
if self.circuit_max_cooldown < self.circuit_cooldown:
|
|
211
|
+
msg = "circuit_max_cooldown must not be shorter than circuit_cooldown"
|
|
212
|
+
raise ValueError(msg)
|
|
213
|
+
return self
|
|
214
|
+
|
|
215
|
+
@property
|
|
216
|
+
def notify_channel(self) -> str:
|
|
217
|
+
"""``LISTEN``/``NOTIFY`` channel used to wake workers up."""
|
|
218
|
+
return f"{self.db_schema}_new"
|
|
219
|
+
|
|
220
|
+
@property
|
|
221
|
+
def max_attempts(self) -> int:
|
|
222
|
+
"""Number of attempts before a delivery is marked as failed."""
|
|
223
|
+
return len(self.retry_schedule)
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Framework-independent logic: matching, signing, retries, circuit breaking and SQL operations."""
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
"""Per-endpoint circuit breaker.
|
|
2
|
+
|
|
3
|
+
The breaker protects the worker (and the receiver) from hammering an endpoint
|
|
4
|
+
that is down::
|
|
5
|
+
|
|
6
|
+
closed ──(failure_threshold failures in a row)──▶ open
|
|
7
|
+
open ──(cooldown elapsed, a worker claims a probe)──▶ half_open
|
|
8
|
+
half_open ──(probe succeeds)──▶ closed
|
|
9
|
+
half_open ──(probe fails)──▶ open, with a longer cooldown
|
|
10
|
+
|
|
11
|
+
While a breaker is open its deliveries are not claimed at all, so they keep
|
|
12
|
+
their remaining attempts. The cooldown doubles with every consecutive trip, up
|
|
13
|
+
to ``max_cooldown``. Independently of the breaker, an endpoint that has been
|
|
14
|
+
failing continuously for ``auto_disable_after`` is disabled.
|
|
15
|
+
|
|
16
|
+
This module is pure: it computes the next state, and the worker persists it.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
from dataclasses import dataclass, replace
|
|
22
|
+
from datetime import datetime, timedelta
|
|
23
|
+
|
|
24
|
+
from pgdispatch.models import CircuitState
|
|
25
|
+
|
|
26
|
+
__all__ = ["BreakerPolicy", "BreakerState", "BreakerUpdate", "record_failure", "record_success"]
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@dataclass(frozen=True, slots=True)
|
|
30
|
+
class BreakerPolicy:
|
|
31
|
+
failure_threshold: int
|
|
32
|
+
cooldown: timedelta
|
|
33
|
+
max_cooldown: timedelta
|
|
34
|
+
auto_disable_after: timedelta | None
|
|
35
|
+
|
|
36
|
+
def cooldown_for(self, trips: int) -> timedelta:
|
|
37
|
+
"""Cooldown after the ``trips``-th consecutive opening (1-based)."""
|
|
38
|
+
# Cap the exponent: the cooldown hits max_cooldown long before 2**20.
|
|
39
|
+
factor: int = 2 ** min(trips - 1, 20)
|
|
40
|
+
return min(self.cooldown * factor, self.max_cooldown)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
@dataclass(frozen=True, slots=True)
|
|
44
|
+
class BreakerState:
|
|
45
|
+
state: CircuitState = CircuitState.CLOSED
|
|
46
|
+
consecutive_failures: int = 0
|
|
47
|
+
failing_since: datetime | None = None
|
|
48
|
+
opened_at: datetime | None = None
|
|
49
|
+
retry_at: datetime | None = None
|
|
50
|
+
trips: int = 0
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
@dataclass(frozen=True, slots=True)
|
|
54
|
+
class BreakerUpdate:
|
|
55
|
+
state: BreakerState
|
|
56
|
+
opened: bool = False
|
|
57
|
+
"""The breaker transitioned to ``open`` with this result."""
|
|
58
|
+
disable: bool = False
|
|
59
|
+
"""The endpoint has been failing for longer than ``auto_disable_after``."""
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def record_success(state: BreakerState) -> BreakerUpdate:
|
|
63
|
+
"""Any successful delivery proves the endpoint works and closes the breaker."""
|
|
64
|
+
del state # the outcome does not depend on the previous state
|
|
65
|
+
return BreakerUpdate(BreakerState())
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def record_failure(state: BreakerState, *, now: datetime, policy: BreakerPolicy) -> BreakerUpdate:
|
|
69
|
+
"""Account for a failed attempt."""
|
|
70
|
+
failures = state.consecutive_failures + 1
|
|
71
|
+
failing_since = state.failing_since or now
|
|
72
|
+
disable = (
|
|
73
|
+
policy.auto_disable_after is not None and now - failing_since >= policy.auto_disable_after
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
should_open = state.state is CircuitState.HALF_OPEN or (
|
|
77
|
+
state.state is CircuitState.CLOSED and failures >= policy.failure_threshold
|
|
78
|
+
)
|
|
79
|
+
if not should_open:
|
|
80
|
+
# Closed below the threshold, or already open (a request that was in
|
|
81
|
+
# flight when the breaker opened): keep the current schedule.
|
|
82
|
+
next_state = replace(state, consecutive_failures=failures, failing_since=failing_since)
|
|
83
|
+
return BreakerUpdate(next_state, disable=disable)
|
|
84
|
+
|
|
85
|
+
trips = state.trips + 1
|
|
86
|
+
next_state = BreakerState(
|
|
87
|
+
state=CircuitState.OPEN,
|
|
88
|
+
consecutive_failures=failures,
|
|
89
|
+
failing_since=failing_since,
|
|
90
|
+
opened_at=now,
|
|
91
|
+
retry_at=now + policy.cooldown_for(trips),
|
|
92
|
+
trips=trips,
|
|
93
|
+
)
|
|
94
|
+
return BreakerUpdate(next_state, opened=True, disable=disable)
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
"""Building blocks shared by the database operations."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import enum
|
|
6
|
+
import uuid
|
|
7
|
+
from typing import Any, Final
|
|
8
|
+
|
|
9
|
+
from pgdispatch.backends.base import Row
|
|
10
|
+
from pgdispatch.errors import NotFoundError, ValidationError
|
|
11
|
+
|
|
12
|
+
__all__ = [
|
|
13
|
+
"DELIVERY_SELECT",
|
|
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
|
+
|
|
24
|
+
MAX_KEY_LENGTH = 255
|
|
25
|
+
MAX_PAGE_SIZE = 1000
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class Unset(enum.Enum):
|
|
29
|
+
"""Marker for "argument not given", distinct from an explicit ``None``."""
|
|
30
|
+
|
|
31
|
+
UNSET = "UNSET"
|
|
32
|
+
|
|
33
|
+
def __repr__(self) -> str:
|
|
34
|
+
return "UNSET"
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
UNSET: Final = Unset.UNSET
|
|
38
|
+
|
|
39
|
+
OwnerFilter = str | None | Unset
|
|
40
|
+
"""``UNSET`` (default) ignores ownership; ``None`` or a string restricts results to that owner."""
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
ENDPOINT_COLUMNS = """
|
|
44
|
+
e.id, e.owner_id, e.url, e.description, e.event_types, e.headers::text AS headers,
|
|
45
|
+
e.rate_limit_rps, e.is_enabled, e.disabled_reason, e.circuit_state,
|
|
46
|
+
e.consecutive_failures, e.failing_since, e.created_at, e.updated_at
|
|
47
|
+
"""
|
|
48
|
+
|
|
49
|
+
DELIVERY_SELECT = """
|
|
50
|
+
SELECT d.id, d.event_id, d.endpoint_id, ev.event_type, d.ordering_key, d.status,
|
|
51
|
+
d.attempt_count, d.next_attempt_at, d.last_status_code, d.last_error,
|
|
52
|
+
d.created_at, d.finished_at
|
|
53
|
+
FROM {schema}.deliveries AS d
|
|
54
|
+
JOIN {schema}.events AS ev ON ev.id = d.event_id
|
|
55
|
+
JOIN {schema}.endpoints AS e ON e.id = d.endpoint_id
|
|
56
|
+
"""
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def owner_condition(column: str, owner_id: OwnerFilter, params: dict[str, Any]) -> str:
|
|
60
|
+
"""SQL condition restricting ``column`` to ``owner_id``; adds the bound parameter."""
|
|
61
|
+
if owner_id is UNSET:
|
|
62
|
+
return "TRUE"
|
|
63
|
+
if owner_id is None:
|
|
64
|
+
return f"{column} IS NULL"
|
|
65
|
+
params["owner_id"] = validate_key(owner_id, "owner_id")
|
|
66
|
+
return f"{column} = CAST(:owner_id AS text)"
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def validate_key(value: str, name: str) -> str:
|
|
70
|
+
if not isinstance(value, str) or not value or len(value) > MAX_KEY_LENGTH:
|
|
71
|
+
msg = f"{name} must be a non-empty string of at most {MAX_KEY_LENGTH} characters"
|
|
72
|
+
raise ValidationError(msg)
|
|
73
|
+
return value
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def validate_limit(limit: int) -> int:
|
|
77
|
+
if not 1 <= limit <= MAX_PAGE_SIZE:
|
|
78
|
+
msg = f"limit must be between 1 and {MAX_PAGE_SIZE}"
|
|
79
|
+
raise ValidationError(msg)
|
|
80
|
+
return limit
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def first_or_not_found(rows: list[Row], what: str, identifier: uuid.UUID | int | str) -> Row:
|
|
84
|
+
if not rows:
|
|
85
|
+
msg = f"{what} {identifier} not found"
|
|
86
|
+
raise NotFoundError(msg)
|
|
87
|
+
return rows[0]
|
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
"""Inspecting and retrying deliveries."""
|
|
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 (
|
|
11
|
+
DELIVERY_SELECT,
|
|
12
|
+
OwnerFilter,
|
|
13
|
+
first_or_not_found,
|
|
14
|
+
owner_condition,
|
|
15
|
+
validate_limit,
|
|
16
|
+
)
|
|
17
|
+
from pgdispatch.errors import ConflictError, ValidationError
|
|
18
|
+
from pgdispatch.models import Attempt, Delivery, DeliveryStatus
|
|
19
|
+
|
|
20
|
+
__all__ = [
|
|
21
|
+
"cancel_delivery",
|
|
22
|
+
"get_delivery",
|
|
23
|
+
"list_attempts",
|
|
24
|
+
"list_deliveries",
|
|
25
|
+
"retry_deliveries",
|
|
26
|
+
"retry_delivery",
|
|
27
|
+
]
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def list_deliveries(
|
|
31
|
+
*,
|
|
32
|
+
endpoint_id: uuid.UUID | None,
|
|
33
|
+
event_id: uuid.UUID | None,
|
|
34
|
+
status: DeliveryStatus | str | None,
|
|
35
|
+
since: datetime | None,
|
|
36
|
+
before: int | None,
|
|
37
|
+
limit: int,
|
|
38
|
+
owner_id: OwnerFilter,
|
|
39
|
+
) -> Operation[list[Delivery]]:
|
|
40
|
+
"""Newest deliveries first; pass the last id as ``before`` to get the next page."""
|
|
41
|
+
params: dict[str, Any] = {"limit": validate_limit(limit)}
|
|
42
|
+
conditions = [owner_condition("e.owner_id", owner_id, params)]
|
|
43
|
+
conditions.extend(_filters(params, endpoint_id, event_id, status, since))
|
|
44
|
+
if before is not None:
|
|
45
|
+
params["before"] = before
|
|
46
|
+
conditions.append("d.id < CAST(:before AS bigint)")
|
|
47
|
+
rows = yield Query(
|
|
48
|
+
f"""
|
|
49
|
+
{DELIVERY_SELECT}
|
|
50
|
+
WHERE {" AND ".join(conditions)}
|
|
51
|
+
ORDER BY d.id DESC
|
|
52
|
+
LIMIT CAST(:limit AS integer)
|
|
53
|
+
""",
|
|
54
|
+
params,
|
|
55
|
+
)
|
|
56
|
+
return [Delivery.from_row(row) for row in rows]
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def get_delivery(delivery_id: int, *, owner_id: OwnerFilter) -> Operation[Delivery]:
|
|
60
|
+
params: dict[str, Any] = {"id": delivery_id}
|
|
61
|
+
owner_sql = owner_condition("e.owner_id", owner_id, params)
|
|
62
|
+
rows = yield Query(
|
|
63
|
+
f"{DELIVERY_SELECT} WHERE d.id = CAST(:id AS bigint) AND {owner_sql}",
|
|
64
|
+
params,
|
|
65
|
+
)
|
|
66
|
+
return Delivery.from_row(first_or_not_found(rows, "delivery", delivery_id))
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def list_attempts(delivery_id: int, *, owner_id: OwnerFilter) -> Operation[list[Attempt]]:
|
|
70
|
+
"""All attempts of a delivery, oldest first."""
|
|
71
|
+
yield from get_delivery(delivery_id, owner_id=owner_id) # ownership check
|
|
72
|
+
rows = yield Query(
|
|
73
|
+
"""
|
|
74
|
+
SELECT a.id, a.delivery_id, a.attempt_number, a.request_headers::text AS request_headers,
|
|
75
|
+
a.response_status, a.response_body, a.duration_ms, a.error, a.created_at
|
|
76
|
+
FROM {schema}.attempts AS a
|
|
77
|
+
WHERE a.delivery_id = CAST(:id AS bigint)
|
|
78
|
+
ORDER BY a.id
|
|
79
|
+
""",
|
|
80
|
+
{"id": delivery_id},
|
|
81
|
+
)
|
|
82
|
+
return [Attempt.from_row(row) for row in rows]
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
# A retry restarts the schedule: ``schedule_offset`` marks where the new round begins.
|
|
86
|
+
_RETRY_ASSIGNMENTS = """
|
|
87
|
+
status = 'pending', next_attempt_at = now(), locked_until = NULL,
|
|
88
|
+
schedule_offset = d.attempt_count, finished_at = NULL, updated_at = now()
|
|
89
|
+
"""
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def retry_delivery(delivery_id: int, *, owner_id: OwnerFilter, channel: str) -> Operation[Delivery]:
|
|
93
|
+
"""Schedule a delivery for an immediate attempt with a fresh retry schedule.
|
|
94
|
+
|
|
95
|
+
Works for failed, cancelled and even succeeded deliveries (to resend).
|
|
96
|
+
A delivery that a worker is sending right now cannot be retried.
|
|
97
|
+
"""
|
|
98
|
+
params: dict[str, Any] = {"id": delivery_id, "channel": channel}
|
|
99
|
+
owner_sql = owner_condition("e.owner_id", owner_id, params)
|
|
100
|
+
rows = yield Query(
|
|
101
|
+
f"""
|
|
102
|
+
WITH target AS (
|
|
103
|
+
SELECT d.id, d.status FROM {{schema}}.deliveries AS d
|
|
104
|
+
JOIN {{schema}}.endpoints AS e ON e.id = d.endpoint_id
|
|
105
|
+
WHERE d.id = CAST(:id AS bigint) AND {owner_sql}
|
|
106
|
+
FOR UPDATE OF d
|
|
107
|
+
), updated AS (
|
|
108
|
+
UPDATE {{schema}}.deliveries AS d SET {_RETRY_ASSIGNMENTS}
|
|
109
|
+
FROM target
|
|
110
|
+
WHERE d.id = target.id AND target.status <> 'in_flight'
|
|
111
|
+
RETURNING d.id
|
|
112
|
+
), wake AS (
|
|
113
|
+
SELECT pg_notify(CAST(:channel AS text), '') FROM updated
|
|
114
|
+
)
|
|
115
|
+
SELECT target.status, (SELECT count(*) FROM updated) AS updated,
|
|
116
|
+
(SELECT count(*) FROM wake) AS notified
|
|
117
|
+
FROM target
|
|
118
|
+
""",
|
|
119
|
+
params,
|
|
120
|
+
)
|
|
121
|
+
row = first_or_not_found(rows, "delivery", delivery_id)
|
|
122
|
+
if not row["updated"]:
|
|
123
|
+
msg = f"delivery {delivery_id} is being sent right now and cannot be retried"
|
|
124
|
+
raise ConflictError(msg)
|
|
125
|
+
return (yield from get_delivery(delivery_id, owner_id=owner_id))
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def retry_deliveries(
|
|
129
|
+
*,
|
|
130
|
+
endpoint_id: uuid.UUID | None,
|
|
131
|
+
status: DeliveryStatus | str,
|
|
132
|
+
since: datetime | None,
|
|
133
|
+
owner_id: OwnerFilter,
|
|
134
|
+
channel: str,
|
|
135
|
+
) -> Operation[int]:
|
|
136
|
+
"""Retry every matching delivery (``failed`` by default); return how many were scheduled."""
|
|
137
|
+
params: dict[str, Any] = {"channel": channel}
|
|
138
|
+
conditions = [owner_condition("e.owner_id", owner_id, params)]
|
|
139
|
+
conditions.extend(_filters(params, endpoint_id, None, status, since))
|
|
140
|
+
if DeliveryStatus(status) is DeliveryStatus.IN_FLIGHT:
|
|
141
|
+
msg = "deliveries that are in flight cannot be retried"
|
|
142
|
+
raise ValidationError(msg)
|
|
143
|
+
rows = yield Query(
|
|
144
|
+
f"""
|
|
145
|
+
WITH updated AS (
|
|
146
|
+
UPDATE {{schema}}.deliveries AS d SET {_RETRY_ASSIGNMENTS}
|
|
147
|
+
FROM {{schema}}.endpoints AS e
|
|
148
|
+
WHERE e.id = d.endpoint_id AND {" AND ".join(conditions)}
|
|
149
|
+
RETURNING d.id
|
|
150
|
+
), wake AS (
|
|
151
|
+
SELECT pg_notify(CAST(:channel AS text), '') FROM updated LIMIT 1
|
|
152
|
+
)
|
|
153
|
+
SELECT (SELECT count(*) FROM updated) AS updated, (SELECT count(*) FROM wake) AS notified
|
|
154
|
+
""",
|
|
155
|
+
params,
|
|
156
|
+
)
|
|
157
|
+
count: int = rows[0]["updated"]
|
|
158
|
+
return count
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def cancel_delivery(delivery_id: int, *, owner_id: OwnerFilter) -> Operation[Delivery]:
|
|
162
|
+
"""Give up on a pending delivery."""
|
|
163
|
+
params: dict[str, Any] = {"id": delivery_id}
|
|
164
|
+
owner_sql = owner_condition("e.owner_id", owner_id, params)
|
|
165
|
+
rows = yield Query(
|
|
166
|
+
f"""
|
|
167
|
+
WITH target AS (
|
|
168
|
+
SELECT d.id, d.status FROM {{schema}}.deliveries AS d
|
|
169
|
+
JOIN {{schema}}.endpoints AS e ON e.id = d.endpoint_id
|
|
170
|
+
WHERE d.id = CAST(:id AS bigint) AND {owner_sql}
|
|
171
|
+
FOR UPDATE OF d
|
|
172
|
+
), updated AS (
|
|
173
|
+
UPDATE {{schema}}.deliveries AS d
|
|
174
|
+
SET status = 'cancelled', finished_at = now(), locked_until = NULL,
|
|
175
|
+
last_error = coalesce(d.last_error, 'cancelled manually'), updated_at = now()
|
|
176
|
+
FROM target
|
|
177
|
+
WHERE d.id = target.id AND target.status = 'pending'
|
|
178
|
+
RETURNING d.id
|
|
179
|
+
)
|
|
180
|
+
SELECT target.status, (SELECT count(*) FROM updated) AS updated FROM target
|
|
181
|
+
""",
|
|
182
|
+
params,
|
|
183
|
+
)
|
|
184
|
+
row = first_or_not_found(rows, "delivery", delivery_id)
|
|
185
|
+
if not row["updated"]:
|
|
186
|
+
msg = f"only pending deliveries can be cancelled; delivery {delivery_id} is {row['status']}"
|
|
187
|
+
raise ConflictError(msg)
|
|
188
|
+
return (yield from get_delivery(delivery_id, owner_id=owner_id))
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def _filters(
|
|
192
|
+
params: dict[str, Any],
|
|
193
|
+
endpoint_id: uuid.UUID | None,
|
|
194
|
+
event_id: uuid.UUID | None,
|
|
195
|
+
status: DeliveryStatus | str | None,
|
|
196
|
+
since: datetime | None,
|
|
197
|
+
) -> list[str]:
|
|
198
|
+
conditions = []
|
|
199
|
+
if endpoint_id is not None:
|
|
200
|
+
params["endpoint_id"] = str(endpoint_id)
|
|
201
|
+
conditions.append("d.endpoint_id = CAST(:endpoint_id AS uuid)")
|
|
202
|
+
if event_id is not None:
|
|
203
|
+
params["event_id"] = str(event_id)
|
|
204
|
+
conditions.append("d.event_id = CAST(:event_id AS uuid)")
|
|
205
|
+
if status is not None:
|
|
206
|
+
try:
|
|
207
|
+
params["status"] = DeliveryStatus(status).value
|
|
208
|
+
except ValueError:
|
|
209
|
+
allowed = ", ".join(member.value for member in DeliveryStatus)
|
|
210
|
+
msg = f"invalid status {status!r}; expected one of: {allowed}"
|
|
211
|
+
raise ValidationError(msg) from None
|
|
212
|
+
conditions.append("d.status = CAST(:status AS text)")
|
|
213
|
+
if since is not None:
|
|
214
|
+
params["since"] = since
|
|
215
|
+
conditions.append("d.created_at >= CAST(:since AS timestamptz)")
|
|
216
|
+
return conditions
|