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,47 @@
|
|
|
1
|
+
"""Per-endpoint rate limiting without Redis.
|
|
2
|
+
|
|
3
|
+
Each worker keeps a GCRA (generic cell rate algorithm, an exact token bucket)
|
|
4
|
+
per rate-limited endpoint in memory. An endpoint's ``rate_limit_rps`` is split
|
|
5
|
+
evenly between the workers that are currently alive, which the workers learn
|
|
6
|
+
from their heartbeats. The result is approximate during scale-up and
|
|
7
|
+
scale-down, but needs no shared state beyond PostgreSQL.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import time
|
|
13
|
+
from collections.abc import Callable, Hashable
|
|
14
|
+
|
|
15
|
+
__all__ = ["RateLimiter"]
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class RateLimiter:
|
|
19
|
+
def __init__(self, clock: Callable[[], float] = time.monotonic) -> None:
|
|
20
|
+
self._clock = clock
|
|
21
|
+
# Theoretical arrival time of the next request, per key.
|
|
22
|
+
self._next_slot: dict[Hashable, float] = {}
|
|
23
|
+
|
|
24
|
+
def reserve(self, key: Hashable, rate: float) -> float:
|
|
25
|
+
"""Reserve a slot for one request; return how many seconds to wait before sending.
|
|
26
|
+
|
|
27
|
+
Bursts of up to ``max(1, rate)`` requests (one second's worth) pass
|
|
28
|
+
without waiting.
|
|
29
|
+
"""
|
|
30
|
+
now = self._clock()
|
|
31
|
+
interval = 1.0 / rate
|
|
32
|
+
burst_allowance = interval * (max(1.0, rate) - 1)
|
|
33
|
+
next_slot = max(self._next_slot.get(key, now), now)
|
|
34
|
+
delay = max(0.0, next_slot - burst_allowance - now)
|
|
35
|
+
self._next_slot[key] = next_slot + interval
|
|
36
|
+
return delay
|
|
37
|
+
|
|
38
|
+
def release(self, key: Hashable, rate: float) -> None:
|
|
39
|
+
"""Give back a slot reserved with :meth:`reserve` that will not be used."""
|
|
40
|
+
if key in self._next_slot:
|
|
41
|
+
self._next_slot[key] -= 1.0 / rate
|
|
42
|
+
|
|
43
|
+
def prune(self) -> None:
|
|
44
|
+
"""Forget keys that have been idle long enough to have a full burst again."""
|
|
45
|
+
now = self._clock()
|
|
46
|
+
for key in [key for key, slot in self._next_slot.items() if slot < now]:
|
|
47
|
+
del self._next_slot[key]
|
|
@@ -0,0 +1,435 @@
|
|
|
1
|
+
"""Writing delivery results back to PostgreSQL in batches.
|
|
2
|
+
|
|
3
|
+
Send tasks hand their results to the recorder instead of writing them one by
|
|
4
|
+
one. The recorder flushes whatever has accumulated in a single transaction:
|
|
5
|
+
|
|
6
|
+
1. lock the affected deliveries (in id order, so concurrent flushes cannot deadlock),
|
|
7
|
+
2. update them, fenced by ``attempt_count`` so a stale result never wins,
|
|
8
|
+
3. bulk-insert the attempt log,
|
|
9
|
+
4. update the circuit breaker of every endpoint involved.
|
|
10
|
+
|
|
11
|
+
Under load this turns thousands of tiny transactions into a few large ones,
|
|
12
|
+
which is what keeps PostgreSQL from becoming the bottleneck.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import asyncio
|
|
18
|
+
import json
|
|
19
|
+
import logging
|
|
20
|
+
from collections import defaultdict
|
|
21
|
+
from collections.abc import Callable, Mapping, Sequence
|
|
22
|
+
from dataclasses import dataclass, field
|
|
23
|
+
from datetime import datetime
|
|
24
|
+
from typing import Any
|
|
25
|
+
|
|
26
|
+
import asyncpg
|
|
27
|
+
|
|
28
|
+
from pgdispatch.backends.asyncpg import AsyncpgConnection
|
|
29
|
+
from pgdispatch.backends.base import Row, render_schema, to_numeric_style
|
|
30
|
+
from pgdispatch.core.circuit import (
|
|
31
|
+
BreakerPolicy,
|
|
32
|
+
BreakerState,
|
|
33
|
+
BreakerUpdate,
|
|
34
|
+
record_failure,
|
|
35
|
+
record_success,
|
|
36
|
+
)
|
|
37
|
+
from pgdispatch.core.common import ENDPOINT_COLUMNS
|
|
38
|
+
from pgdispatch.hooks import Hooks
|
|
39
|
+
from pgdispatch.metrics import Metrics
|
|
40
|
+
from pgdispatch.models import CircuitState, Delivery, DeliveryStatus, Endpoint, to_uuid
|
|
41
|
+
from pgdispatch.worker.fetcher import ClaimedDelivery
|
|
42
|
+
from pgdispatch.worker.sender import SendOutcome
|
|
43
|
+
|
|
44
|
+
__all__ = ["DeliveryRecord", "Recorder"]
|
|
45
|
+
|
|
46
|
+
logger = logging.getLogger("pgdispatch.worker")
|
|
47
|
+
|
|
48
|
+
MAX_FLUSH_SIZE = 1000
|
|
49
|
+
GONE_REASON = "the endpoint answered 410 Gone"
|
|
50
|
+
AUTO_DISABLE_REASON = "the endpoint has been failing continuously since {since}"
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
@dataclass(frozen=True, slots=True)
|
|
54
|
+
class DeliveryRecord:
|
|
55
|
+
"""The result of processing one claimed delivery."""
|
|
56
|
+
|
|
57
|
+
delivery: ClaimedDelivery
|
|
58
|
+
status: DeliveryStatus
|
|
59
|
+
"""New status: ``pending`` means "try again in ``retry_in`` seconds"."""
|
|
60
|
+
retry_in: float = 0.0
|
|
61
|
+
outcome: SendOutcome | None = None
|
|
62
|
+
"""``None`` when no request was made and the claim is simply released."""
|
|
63
|
+
request_headers: Mapping[str, str] = field(default_factory=dict)
|
|
64
|
+
attempted_at: datetime | None = None
|
|
65
|
+
|
|
66
|
+
@property
|
|
67
|
+
def released(self) -> bool:
|
|
68
|
+
return self.outcome is None
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
_LOCK_DELIVERIES = """
|
|
72
|
+
SELECT id FROM {schema}.deliveries
|
|
73
|
+
WHERE id = ANY(CAST(:ids AS bigint[]))
|
|
74
|
+
ORDER BY id
|
|
75
|
+
FOR UPDATE
|
|
76
|
+
"""
|
|
77
|
+
|
|
78
|
+
_UPDATE_DELIVERIES = """
|
|
79
|
+
UPDATE {schema}.deliveries AS d
|
|
80
|
+
SET status = u.status,
|
|
81
|
+
attempt_count = CASE WHEN u.released THEN d.attempt_count - 1 ELSE d.attempt_count END,
|
|
82
|
+
next_attempt_at = CASE
|
|
83
|
+
WHEN u.status = 'pending' THEN now() + u.retry_in * interval '1 second'
|
|
84
|
+
ELSE d.next_attempt_at
|
|
85
|
+
END,
|
|
86
|
+
locked_until = NULL,
|
|
87
|
+
last_status_code = CASE WHEN u.released THEN d.last_status_code ELSE u.status_code END,
|
|
88
|
+
last_error = CASE WHEN u.released THEN d.last_error ELSE u.error END,
|
|
89
|
+
finished_at = CASE WHEN u.status IN ('succeeded', 'failed', 'cancelled') THEN now() END,
|
|
90
|
+
updated_at = now()
|
|
91
|
+
FROM unnest(
|
|
92
|
+
CAST(:ids AS bigint[]), CAST(:attempts AS integer[]), CAST(:statuses AS text[]),
|
|
93
|
+
CAST(:retry_in AS double precision[]), CAST(:status_codes AS integer[]),
|
|
94
|
+
CAST(:errors AS text[]), CAST(:released AS boolean[])
|
|
95
|
+
) AS u(id, attempt, status, retry_in, status_code, error, released)
|
|
96
|
+
WHERE d.id = u.id AND d.status = 'in_flight' AND d.attempt_count = u.attempt
|
|
97
|
+
RETURNING d.id
|
|
98
|
+
"""
|
|
99
|
+
|
|
100
|
+
_INSERT_ATTEMPTS = """
|
|
101
|
+
INSERT INTO {schema}.attempts
|
|
102
|
+
(delivery_id, attempt_number, request_headers, response_status, response_body,
|
|
103
|
+
duration_ms, error, created_at)
|
|
104
|
+
SELECT * FROM unnest(
|
|
105
|
+
CAST(:ids AS bigint[]), CAST(:numbers AS integer[]), CAST(:headers AS jsonb[]),
|
|
106
|
+
CAST(:statuses AS integer[]), CAST(:bodies AS text[]), CAST(:durations AS integer[]),
|
|
107
|
+
CAST(:errors AS text[]), CAST(:created AS timestamptz[])
|
|
108
|
+
)
|
|
109
|
+
"""
|
|
110
|
+
|
|
111
|
+
# Endpoints that only saw successes and are already healthy need no update;
|
|
112
|
+
# skipping them keeps hot, healthy endpoints free of row-lock contention.
|
|
113
|
+
_LOCK_ENDPOINTS = """
|
|
114
|
+
SELECT now() AS now, e.id, e.is_enabled, e.circuit_state, e.consecutive_failures,
|
|
115
|
+
e.failing_since, e.circuit_opened_at, e.circuit_retry_at, e.circuit_trips
|
|
116
|
+
FROM {schema}.endpoints AS e
|
|
117
|
+
WHERE e.id = ANY(CAST(:ids AS uuid[]))
|
|
118
|
+
AND (
|
|
119
|
+
e.id = ANY(CAST(:failing_ids AS uuid[]))
|
|
120
|
+
OR e.circuit_state <> 'closed'
|
|
121
|
+
OR e.consecutive_failures > 0
|
|
122
|
+
)
|
|
123
|
+
ORDER BY e.id
|
|
124
|
+
FOR UPDATE
|
|
125
|
+
"""
|
|
126
|
+
|
|
127
|
+
_UPDATE_ENDPOINTS = f"""
|
|
128
|
+
UPDATE {{schema}}.endpoints AS e
|
|
129
|
+
SET circuit_state = u.state,
|
|
130
|
+
consecutive_failures = u.failures,
|
|
131
|
+
failing_since = u.failing_since,
|
|
132
|
+
circuit_opened_at = u.opened_at,
|
|
133
|
+
circuit_retry_at = u.retry_at,
|
|
134
|
+
circuit_trips = u.trips,
|
|
135
|
+
is_enabled = e.is_enabled AND u.disable_reason IS NULL,
|
|
136
|
+
disabled_reason = CASE
|
|
137
|
+
WHEN e.is_enabled AND u.disable_reason IS NOT NULL THEN u.disable_reason
|
|
138
|
+
ELSE e.disabled_reason
|
|
139
|
+
END,
|
|
140
|
+
updated_at = now()
|
|
141
|
+
FROM unnest(
|
|
142
|
+
CAST(:ids AS uuid[]), CAST(:states AS text[]), CAST(:failures AS integer[]),
|
|
143
|
+
CAST(:failing_since AS timestamptz[]), CAST(:opened_at AS timestamptz[]),
|
|
144
|
+
CAST(:retry_at AS timestamptz[]), CAST(:trips AS integer[]), CAST(:disable_reasons AS text[])
|
|
145
|
+
) AS u(id, state, failures, failing_since, opened_at, retry_at, trips, disable_reason)
|
|
146
|
+
WHERE e.id = u.id
|
|
147
|
+
RETURNING {ENDPOINT_COLUMNS}
|
|
148
|
+
"""
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
@dataclass(frozen=True, slots=True)
|
|
152
|
+
class _Entry:
|
|
153
|
+
record: DeliveryRecord
|
|
154
|
+
written: asyncio.Future[None]
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
@dataclass(slots=True)
|
|
158
|
+
class _Notifications:
|
|
159
|
+
"""Hook calls to make once the flush has committed."""
|
|
160
|
+
|
|
161
|
+
failed: list[Delivery] = field(default_factory=list)
|
|
162
|
+
circuits_opened: list[Endpoint] = field(default_factory=list)
|
|
163
|
+
disabled: list[tuple[Endpoint, str]] = field(default_factory=list)
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
class Recorder:
|
|
167
|
+
def __init__(
|
|
168
|
+
self,
|
|
169
|
+
pool: asyncpg.Pool,
|
|
170
|
+
*,
|
|
171
|
+
schema: str,
|
|
172
|
+
policy: BreakerPolicy,
|
|
173
|
+
hooks: Hooks,
|
|
174
|
+
metrics: Metrics,
|
|
175
|
+
on_flush: Callable[[], None],
|
|
176
|
+
) -> None:
|
|
177
|
+
self._pool = pool
|
|
178
|
+
self._schema = schema
|
|
179
|
+
self._policy = policy
|
|
180
|
+
self._hooks = hooks
|
|
181
|
+
self._metrics = metrics
|
|
182
|
+
self._on_flush = on_flush
|
|
183
|
+
self._queue: asyncio.Queue[_Entry | None] = asyncio.Queue()
|
|
184
|
+
self._give_up_at: float | None = None
|
|
185
|
+
|
|
186
|
+
def add(self, record: DeliveryRecord) -> asyncio.Future[None]:
|
|
187
|
+
"""Queue a result; the returned future completes once it has been written."""
|
|
188
|
+
written: asyncio.Future[None] = asyncio.get_running_loop().create_future()
|
|
189
|
+
self._queue.put_nowait(_Entry(record, written))
|
|
190
|
+
return written
|
|
191
|
+
|
|
192
|
+
async def run(self) -> None:
|
|
193
|
+
"""Flush records until :meth:`close` is called and everything is written."""
|
|
194
|
+
closing = False
|
|
195
|
+
while not closing:
|
|
196
|
+
first = await self._queue.get()
|
|
197
|
+
if first is None:
|
|
198
|
+
break
|
|
199
|
+
entries = [first]
|
|
200
|
+
while len(entries) < MAX_FLUSH_SIZE and not self._queue.empty():
|
|
201
|
+
entry = self._queue.get_nowait()
|
|
202
|
+
if entry is None:
|
|
203
|
+
closing = True
|
|
204
|
+
break
|
|
205
|
+
entries.append(entry)
|
|
206
|
+
await self._flush_with_retries([entry.record for entry in entries])
|
|
207
|
+
for entry in entries:
|
|
208
|
+
if not entry.written.done():
|
|
209
|
+
entry.written.set_result(None)
|
|
210
|
+
self._on_flush()
|
|
211
|
+
|
|
212
|
+
def close(self, *, timeout: float) -> None:
|
|
213
|
+
"""Ask :meth:`run` to finish after writing everything queued so far."""
|
|
214
|
+
self._give_up_at = asyncio.get_running_loop().time() + timeout
|
|
215
|
+
self._queue.put_nowait(None)
|
|
216
|
+
|
|
217
|
+
async def _flush_with_retries(self, batch: Sequence[DeliveryRecord]) -> None:
|
|
218
|
+
delay = 0.5
|
|
219
|
+
while True:
|
|
220
|
+
try:
|
|
221
|
+
notifications = await self._flush(batch)
|
|
222
|
+
except (asyncpg.PostgresError, asyncpg.InterfaceError, OSError):
|
|
223
|
+
loop = asyncio.get_running_loop()
|
|
224
|
+
if self._give_up_at is not None and loop.time() + delay > self._give_up_at:
|
|
225
|
+
logger.exception(
|
|
226
|
+
"pgdispatch could not record %d results before shutdown; "
|
|
227
|
+
"their leases will expire and they will be sent again",
|
|
228
|
+
len(batch),
|
|
229
|
+
)
|
|
230
|
+
return
|
|
231
|
+
logger.warning(
|
|
232
|
+
"pgdispatch failed to record results, retrying in %.1fs", delay, exc_info=True
|
|
233
|
+
)
|
|
234
|
+
await asyncio.sleep(delay)
|
|
235
|
+
delay = min(delay * 2, 10.0)
|
|
236
|
+
except Exception:
|
|
237
|
+
# A bug must not stall the worker: the leases of these deliveries
|
|
238
|
+
# expire and they are sent again.
|
|
239
|
+
logger.exception("pgdispatch failed to record %d results", len(batch))
|
|
240
|
+
return
|
|
241
|
+
else:
|
|
242
|
+
await self._notify(notifications)
|
|
243
|
+
return
|
|
244
|
+
|
|
245
|
+
async def _flush(self, batch: Sequence[DeliveryRecord]) -> _Notifications:
|
|
246
|
+
notifications = _Notifications()
|
|
247
|
+
async with self._pool.acquire() as connection, connection.transaction():
|
|
248
|
+
# Losing the last few commits in a database crash only means those
|
|
249
|
+
# deliveries are sent again once their leases expire, which
|
|
250
|
+
# at-least-once delivery allows; in exchange there is no fsync wait.
|
|
251
|
+
await connection.execute("SET LOCAL synchronous_commit TO off")
|
|
252
|
+
rows = await self._fetch(
|
|
253
|
+
connection, _LOCK_DELIVERIES, ids=[record.delivery.id for record in batch]
|
|
254
|
+
)
|
|
255
|
+
existing = {row["id"] for row in rows}
|
|
256
|
+
applied = await self._update_deliveries(connection, batch)
|
|
257
|
+
await self._insert_attempts(connection, [r for r in batch if r.delivery.id in existing])
|
|
258
|
+
await self._update_endpoints(connection, batch, notifications)
|
|
259
|
+
|
|
260
|
+
for record in batch:
|
|
261
|
+
if record.status is DeliveryStatus.FAILED and record.delivery.id in applied:
|
|
262
|
+
self._metrics.deliveries_failed.inc()
|
|
263
|
+
notifications.failed.append(_failed_delivery(record))
|
|
264
|
+
return notifications
|
|
265
|
+
|
|
266
|
+
async def _update_deliveries(
|
|
267
|
+
self, connection: AsyncpgConnection, batch: Sequence[DeliveryRecord]
|
|
268
|
+
) -> set[int]:
|
|
269
|
+
rows = await self._fetch(
|
|
270
|
+
connection,
|
|
271
|
+
_UPDATE_DELIVERIES,
|
|
272
|
+
ids=[r.delivery.id for r in batch],
|
|
273
|
+
attempts=[r.delivery.attempt_count for r in batch],
|
|
274
|
+
statuses=[r.status.value for r in batch],
|
|
275
|
+
retry_in=[r.retry_in for r in batch],
|
|
276
|
+
status_codes=[r.outcome.status_code if r.outcome else None for r in batch],
|
|
277
|
+
errors=[r.outcome.error if r.outcome else None for r in batch],
|
|
278
|
+
released=[r.released for r in batch],
|
|
279
|
+
)
|
|
280
|
+
applied = {row["id"] for row in rows}
|
|
281
|
+
stale = len(batch) - len(applied)
|
|
282
|
+
if stale:
|
|
283
|
+
# Possible if a lease expired mid-request (e.g. a long pause) and
|
|
284
|
+
# another worker already retried the delivery.
|
|
285
|
+
logger.warning("pgdispatch skipped %d stale delivery results", stale)
|
|
286
|
+
return applied
|
|
287
|
+
|
|
288
|
+
async def _insert_attempts(
|
|
289
|
+
self, connection: AsyncpgConnection, batch: Sequence[DeliveryRecord]
|
|
290
|
+
) -> None:
|
|
291
|
+
attempts = [r for r in batch if r.outcome is not None]
|
|
292
|
+
if not attempts:
|
|
293
|
+
return
|
|
294
|
+
await self._fetch(
|
|
295
|
+
connection,
|
|
296
|
+
_INSERT_ATTEMPTS,
|
|
297
|
+
ids=[r.delivery.id for r in attempts],
|
|
298
|
+
numbers=[r.delivery.attempt_count for r in attempts],
|
|
299
|
+
headers=[json.dumps(dict(r.request_headers)) for r in attempts],
|
|
300
|
+
statuses=[r.outcome.status_code for r in attempts if r.outcome],
|
|
301
|
+
bodies=[r.outcome.response_body for r in attempts if r.outcome],
|
|
302
|
+
durations=[r.outcome.duration_ms for r in attempts if r.outcome],
|
|
303
|
+
errors=[r.outcome.error for r in attempts if r.outcome],
|
|
304
|
+
created=[r.attempted_at for r in attempts],
|
|
305
|
+
)
|
|
306
|
+
|
|
307
|
+
async def _update_endpoints(
|
|
308
|
+
self,
|
|
309
|
+
connection: AsyncpgConnection,
|
|
310
|
+
batch: Sequence[DeliveryRecord],
|
|
311
|
+
notifications: _Notifications,
|
|
312
|
+
) -> None:
|
|
313
|
+
outcomes: dict[Any, list[DeliveryRecord]] = defaultdict(list)
|
|
314
|
+
for record in batch:
|
|
315
|
+
if record.outcome is not None:
|
|
316
|
+
outcomes[record.delivery.endpoint_id].append(record)
|
|
317
|
+
if not outcomes:
|
|
318
|
+
return
|
|
319
|
+
failing = {
|
|
320
|
+
record.delivery.endpoint_id
|
|
321
|
+
for record in batch
|
|
322
|
+
if record.outcome is not None and not record.outcome.succeeded
|
|
323
|
+
}
|
|
324
|
+
rows = await self._fetch(
|
|
325
|
+
connection,
|
|
326
|
+
_LOCK_ENDPOINTS,
|
|
327
|
+
ids=[str(i) for i in outcomes],
|
|
328
|
+
failing_ids=[str(i) for i in failing],
|
|
329
|
+
)
|
|
330
|
+
|
|
331
|
+
changes: list[tuple[Row, BreakerUpdate, str | None]] = []
|
|
332
|
+
for row in rows:
|
|
333
|
+
before = _breaker_state(row)
|
|
334
|
+
update = BreakerUpdate(before)
|
|
335
|
+
opened = False
|
|
336
|
+
gone = False
|
|
337
|
+
for record in outcomes[to_uuid(row["id"])]:
|
|
338
|
+
assert record.outcome is not None # noqa: S101 - filtered above
|
|
339
|
+
gone = gone or record.outcome.gone
|
|
340
|
+
if record.outcome.succeeded:
|
|
341
|
+
update = record_success(update.state)
|
|
342
|
+
else:
|
|
343
|
+
update = record_failure(update.state, now=row["now"], policy=self._policy)
|
|
344
|
+
opened = opened or update.opened
|
|
345
|
+
opened = opened and update.state.state is CircuitState.OPEN
|
|
346
|
+
update = BreakerUpdate(update.state, opened=opened, disable=update.disable)
|
|
347
|
+
reason = _disable_reason(row, update, gone=gone)
|
|
348
|
+
if update.state != before or reason is not None:
|
|
349
|
+
changes.append((row, update, reason))
|
|
350
|
+
if not changes:
|
|
351
|
+
return
|
|
352
|
+
|
|
353
|
+
updated = await self._fetch(
|
|
354
|
+
connection,
|
|
355
|
+
_UPDATE_ENDPOINTS,
|
|
356
|
+
ids=[str(row["id"]) for row, _, _ in changes],
|
|
357
|
+
states=[update.state.state.value for _, update, _ in changes],
|
|
358
|
+
failures=[update.state.consecutive_failures for _, update, _ in changes],
|
|
359
|
+
failing_since=[update.state.failing_since for _, update, _ in changes],
|
|
360
|
+
opened_at=[update.state.opened_at for _, update, _ in changes],
|
|
361
|
+
retry_at=[update.state.retry_at for _, update, _ in changes],
|
|
362
|
+
trips=[update.state.trips for _, update, _ in changes],
|
|
363
|
+
disable_reasons=[reason for _, _, reason in changes],
|
|
364
|
+
)
|
|
365
|
+
endpoints = {endpoint.id: endpoint for endpoint in map(Endpoint.from_row, updated)}
|
|
366
|
+
for row, update, reason in changes:
|
|
367
|
+
endpoint = endpoints[to_uuid(row["id"])]
|
|
368
|
+
if update.opened:
|
|
369
|
+
self._log_circuit_opened(endpoint, update)
|
|
370
|
+
notifications.circuits_opened.append(endpoint)
|
|
371
|
+
if reason is not None and row["is_enabled"]:
|
|
372
|
+
logger.warning(
|
|
373
|
+
"pgdispatch disabled endpoint %s: %s",
|
|
374
|
+
endpoint.id,
|
|
375
|
+
reason,
|
|
376
|
+
extra={"endpoint_id": str(endpoint.id)},
|
|
377
|
+
)
|
|
378
|
+
notifications.disabled.append((endpoint, reason))
|
|
379
|
+
|
|
380
|
+
async def _notify(self, notifications: _Notifications) -> None:
|
|
381
|
+
for delivery in notifications.failed:
|
|
382
|
+
await self._hooks.delivery_failed(delivery)
|
|
383
|
+
for endpoint in notifications.circuits_opened:
|
|
384
|
+
await self._hooks.circuit_opened(endpoint)
|
|
385
|
+
for endpoint, reason in notifications.disabled:
|
|
386
|
+
await self._hooks.endpoint_disabled(endpoint, reason)
|
|
387
|
+
|
|
388
|
+
async def _fetch(self, connection: AsyncpgConnection, sql: str, **params: Any) -> list[Row]:
|
|
389
|
+
statement, names = to_numeric_style(render_schema(sql, self._schema))
|
|
390
|
+
records = await connection.fetch(statement, *(params[name] for name in names))
|
|
391
|
+
return [dict(record) for record in records]
|
|
392
|
+
|
|
393
|
+
@staticmethod
|
|
394
|
+
def _log_circuit_opened(endpoint: Endpoint, update: BreakerUpdate) -> None:
|
|
395
|
+
logger.warning(
|
|
396
|
+
"pgdispatch opened the circuit breaker of endpoint %s after %d consecutive failures",
|
|
397
|
+
endpoint.id,
|
|
398
|
+
update.state.consecutive_failures,
|
|
399
|
+
extra={
|
|
400
|
+
"endpoint_id": str(endpoint.id),
|
|
401
|
+
"consecutive_failures": update.state.consecutive_failures,
|
|
402
|
+
"retry_at": update.state.retry_at.isoformat() if update.state.retry_at else None,
|
|
403
|
+
},
|
|
404
|
+
)
|
|
405
|
+
|
|
406
|
+
|
|
407
|
+
def _breaker_state(row: Row) -> BreakerState:
|
|
408
|
+
return BreakerState(
|
|
409
|
+
state=CircuitState(row["circuit_state"]),
|
|
410
|
+
consecutive_failures=row["consecutive_failures"],
|
|
411
|
+
failing_since=row["failing_since"],
|
|
412
|
+
opened_at=row["circuit_opened_at"],
|
|
413
|
+
retry_at=row["circuit_retry_at"],
|
|
414
|
+
trips=row["circuit_trips"],
|
|
415
|
+
)
|
|
416
|
+
|
|
417
|
+
|
|
418
|
+
def _disable_reason(row: Row, update: BreakerUpdate, *, gone: bool) -> str | None:
|
|
419
|
+
if not row["is_enabled"]:
|
|
420
|
+
return None
|
|
421
|
+
if gone:
|
|
422
|
+
return GONE_REASON
|
|
423
|
+
if update.disable and update.state.failing_since is not None:
|
|
424
|
+
return AUTO_DISABLE_REASON.format(since=update.state.failing_since.isoformat())
|
|
425
|
+
return None
|
|
426
|
+
|
|
427
|
+
|
|
428
|
+
def _failed_delivery(record: DeliveryRecord) -> Delivery:
|
|
429
|
+
outcome = record.outcome
|
|
430
|
+
return record.delivery.to_delivery(
|
|
431
|
+
status=DeliveryStatus.FAILED,
|
|
432
|
+
status_code=outcome.status_code if outcome else None,
|
|
433
|
+
error=outcome.error if outcome else None,
|
|
434
|
+
finished_at=record.attempted_at,
|
|
435
|
+
)
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
"""Running a worker as the main program of a process (CLI, Django command)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import contextlib
|
|
7
|
+
import logging
|
|
8
|
+
import signal
|
|
9
|
+
|
|
10
|
+
from pgdispatch.worker.worker import Worker
|
|
11
|
+
|
|
12
|
+
__all__ = ["run_until_signalled"]
|
|
13
|
+
|
|
14
|
+
logger = logging.getLogger("pgdispatch.worker")
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
async def run_until_signalled(worker: Worker, *, metrics_port: int | None = None) -> None:
|
|
18
|
+
"""Run ``worker`` until SIGINT or SIGTERM, then shut it down gracefully."""
|
|
19
|
+
if metrics_port is not None:
|
|
20
|
+
from prometheus_client import start_http_server
|
|
21
|
+
|
|
22
|
+
start_http_server(metrics_port)
|
|
23
|
+
logger.info("pgdispatch metrics are served on port %d", metrics_port)
|
|
24
|
+
|
|
25
|
+
loop = asyncio.get_running_loop()
|
|
26
|
+
for signum in (signal.SIGINT, signal.SIGTERM):
|
|
27
|
+
try:
|
|
28
|
+
loop.add_signal_handler(signum, worker.stop)
|
|
29
|
+
except (NotImplementedError, RuntimeError): # pragma: no cover - Windows
|
|
30
|
+
signal.signal(signum, lambda *_: loop.call_soon_threadsafe(worker.stop))
|
|
31
|
+
try:
|
|
32
|
+
await worker.run()
|
|
33
|
+
finally:
|
|
34
|
+
for signum in (signal.SIGINT, signal.SIGTERM):
|
|
35
|
+
with contextlib.suppress(NotImplementedError, RuntimeError, ValueError):
|
|
36
|
+
loop.remove_signal_handler(signum)
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
"""Sending one HTTP request and classifying the receiver's answer."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import enum
|
|
7
|
+
import time
|
|
8
|
+
from collections.abc import Mapping
|
|
9
|
+
from dataclasses import dataclass
|
|
10
|
+
from datetime import timedelta
|
|
11
|
+
|
|
12
|
+
import aiohttp
|
|
13
|
+
|
|
14
|
+
from pgdispatch.core.retry import parse_retry_after
|
|
15
|
+
from pgdispatch.security.ssrf import BlockedAddressError, SafeResolver, check_url_host
|
|
16
|
+
|
|
17
|
+
__all__ = ["AttemptResult", "SendOutcome", "Sender", "create_session"]
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class AttemptResult(str, enum.Enum):
|
|
21
|
+
"""Coarse result of an attempt; used as the ``result`` metric label."""
|
|
22
|
+
|
|
23
|
+
SUCCESS = "success"
|
|
24
|
+
HTTP_ERROR = "http_error"
|
|
25
|
+
TIMEOUT = "timeout"
|
|
26
|
+
NETWORK = "network"
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@dataclass(frozen=True, slots=True)
|
|
30
|
+
class SendOutcome:
|
|
31
|
+
result: AttemptResult
|
|
32
|
+
status_code: int | None
|
|
33
|
+
response_body: str | None
|
|
34
|
+
error: str | None
|
|
35
|
+
duration_ms: int
|
|
36
|
+
retry_after: timedelta | None = None
|
|
37
|
+
|
|
38
|
+
@property
|
|
39
|
+
def succeeded(self) -> bool:
|
|
40
|
+
return self.result is AttemptResult.SUCCESS
|
|
41
|
+
|
|
42
|
+
@property
|
|
43
|
+
def gone(self) -> bool:
|
|
44
|
+
"""The receiver answered ``410 Gone``: it asks us to stop sending."""
|
|
45
|
+
return self.status_code == 410
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def create_session(
|
|
49
|
+
*, concurrency: int, allow_private_ips: bool, timeout: timedelta
|
|
50
|
+
) -> aiohttp.ClientSession:
|
|
51
|
+
"""The HTTP session used by the worker.
|
|
52
|
+
|
|
53
|
+
Unless ``allow_private_ips`` is set, host names are resolved through
|
|
54
|
+
:class:`~pgdispatch.security.ssrf.SafeResolver`. Environment proxies are
|
|
55
|
+
ignored (``trust_env=False``) because they would bypass that filter; use
|
|
56
|
+
``outbound_proxy`` instead.
|
|
57
|
+
"""
|
|
58
|
+
connector = aiohttp.TCPConnector(
|
|
59
|
+
limit=concurrency,
|
|
60
|
+
resolver=None if allow_private_ips else SafeResolver(),
|
|
61
|
+
)
|
|
62
|
+
return aiohttp.ClientSession(
|
|
63
|
+
connector=connector,
|
|
64
|
+
timeout=aiohttp.ClientTimeout(total=timeout.total_seconds()),
|
|
65
|
+
trust_env=False,
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
class Sender:
|
|
70
|
+
"""POSTs signed bodies; never follows redirects and never raises for HTTP failures."""
|
|
71
|
+
|
|
72
|
+
def __init__(
|
|
73
|
+
self,
|
|
74
|
+
session: aiohttp.ClientSession,
|
|
75
|
+
*,
|
|
76
|
+
timeout: timedelta,
|
|
77
|
+
store_response_body: bool,
|
|
78
|
+
max_response_body_bytes: int,
|
|
79
|
+
allow_private_ips: bool,
|
|
80
|
+
proxy: str | None = None,
|
|
81
|
+
) -> None:
|
|
82
|
+
self._session = session
|
|
83
|
+
self._timeout = timeout.total_seconds()
|
|
84
|
+
self._store_body = store_response_body
|
|
85
|
+
self._max_body = max_response_body_bytes
|
|
86
|
+
self._check_host = not allow_private_ips and proxy is None
|
|
87
|
+
self._proxy = proxy
|
|
88
|
+
|
|
89
|
+
async def send(self, url: str, headers: Mapping[str, str], body: bytes) -> SendOutcome:
|
|
90
|
+
started = time.perf_counter()
|
|
91
|
+
try:
|
|
92
|
+
if self._check_host:
|
|
93
|
+
check_url_host(url)
|
|
94
|
+
# The session timeout covers the exchange; this also bounds DNS and
|
|
95
|
+
# anything else that might hang.
|
|
96
|
+
status, raw_body, retry_after = await asyncio.wait_for(
|
|
97
|
+
self._post(url, headers, body), self._timeout
|
|
98
|
+
)
|
|
99
|
+
except asyncio.TimeoutError:
|
|
100
|
+
return self._failure(
|
|
101
|
+
AttemptResult.TIMEOUT, f"timed out after {self._timeout:g}s", started
|
|
102
|
+
)
|
|
103
|
+
except BlockedAddressError as exc:
|
|
104
|
+
return self._failure(AttemptResult.NETWORK, f"blocked: {exc}", started)
|
|
105
|
+
except (aiohttp.ClientError, OSError) as exc:
|
|
106
|
+
return self._failure(AttemptResult.NETWORK, _describe(exc), started)
|
|
107
|
+
|
|
108
|
+
duration_ms = _elapsed_ms(started)
|
|
109
|
+
text = _decode(raw_body) if self._store_body else None
|
|
110
|
+
if 200 <= status < 300:
|
|
111
|
+
return SendOutcome(AttemptResult.SUCCESS, status, text, None, duration_ms)
|
|
112
|
+
if 300 <= status < 400:
|
|
113
|
+
error = f"HTTP {status}: redirects are not followed"
|
|
114
|
+
else:
|
|
115
|
+
error = f"HTTP {status}"
|
|
116
|
+
return SendOutcome(
|
|
117
|
+
AttemptResult.HTTP_ERROR,
|
|
118
|
+
status,
|
|
119
|
+
text,
|
|
120
|
+
error,
|
|
121
|
+
duration_ms,
|
|
122
|
+
retry_after=parse_retry_after(retry_after) if status in (429, 503) else None,
|
|
123
|
+
)
|
|
124
|
+
|
|
125
|
+
async def _post(
|
|
126
|
+
self, url: str, headers: Mapping[str, str], body: bytes
|
|
127
|
+
) -> tuple[int, bytes, str | None]:
|
|
128
|
+
async with self._session.post(
|
|
129
|
+
url, data=body, headers=headers, allow_redirects=False, proxy=self._proxy
|
|
130
|
+
) as response:
|
|
131
|
+
# Read at most max_body bytes: a hostile receiver must not be able
|
|
132
|
+
# to make the worker buffer an endless response.
|
|
133
|
+
content = b""
|
|
134
|
+
while len(content) < self._max_body:
|
|
135
|
+
chunk = await response.content.read(self._max_body - len(content))
|
|
136
|
+
if not chunk:
|
|
137
|
+
break
|
|
138
|
+
content += chunk
|
|
139
|
+
return response.status, content, response.headers.get("Retry-After")
|
|
140
|
+
|
|
141
|
+
@staticmethod
|
|
142
|
+
def _failure(result: AttemptResult, error: str, started: float) -> SendOutcome:
|
|
143
|
+
return SendOutcome(result, None, None, error, _elapsed_ms(started))
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def _describe(exc: BaseException) -> str:
|
|
147
|
+
os_error = getattr(exc, "os_error", None)
|
|
148
|
+
if isinstance(os_error, BlockedAddressError):
|
|
149
|
+
return f"blocked: {os_error}"
|
|
150
|
+
message = str(exc) or type(exc).__name__
|
|
151
|
+
return f"{type(exc).__name__}: {message}"
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def _decode(raw: bytes) -> str:
|
|
155
|
+
# PostgreSQL text cannot hold NUL bytes.
|
|
156
|
+
return raw.decode("utf-8", errors="replace").replace("\x00", "")
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def _elapsed_ms(started: float) -> int:
|
|
160
|
+
return round((time.perf_counter() - started) * 1000)
|