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,557 @@
|
|
|
1
|
+
"""The delivery worker.
|
|
2
|
+
|
|
3
|
+
A worker runs four cooperating loops on one event loop:
|
|
4
|
+
|
|
5
|
+
* **dispatch** — waits for work (``LISTEN`` wake-up or the polling interval),
|
|
6
|
+
claims due deliveries and starts one task per delivery, never exceeding
|
|
7
|
+
``concurrency`` requests in flight;
|
|
8
|
+
* **send tasks** — rate-limit, sign and POST one delivery each, then hand the
|
|
9
|
+
result to the recorder;
|
|
10
|
+
* **recorder** — writes results back in batches (see :mod:`.recorder`);
|
|
11
|
+
* **maintenance** — heartbeat, metrics, ``LISTEN`` health and retention clean-up.
|
|
12
|
+
|
|
13
|
+
Any number of workers, in any number of processes or hosts, can run against
|
|
14
|
+
the same database: they coordinate only through row locks and leases.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import asyncio
|
|
20
|
+
import contextlib
|
|
21
|
+
import logging
|
|
22
|
+
import os
|
|
23
|
+
import socket
|
|
24
|
+
import time
|
|
25
|
+
import uuid
|
|
26
|
+
from collections import Counter
|
|
27
|
+
from collections.abc import Awaitable, Callable
|
|
28
|
+
from datetime import datetime, timedelta, timezone
|
|
29
|
+
from typing import Any
|
|
30
|
+
|
|
31
|
+
import aiohttp
|
|
32
|
+
import asyncpg
|
|
33
|
+
from asyncpg.pool import PoolConnectionProxy
|
|
34
|
+
|
|
35
|
+
from pgdispatch._version import __version__
|
|
36
|
+
from pgdispatch.backends.asyncpg import AsyncpgConnection, AsyncpgExecutor
|
|
37
|
+
from pgdispatch.backends.base import Row, render_schema, run_async, to_numeric_style
|
|
38
|
+
from pgdispatch.config import WebhooksConfig
|
|
39
|
+
from pgdispatch.core.circuit import BreakerPolicy
|
|
40
|
+
from pgdispatch.core.maintenance import delete_expired_events, queue_stats
|
|
41
|
+
from pgdispatch.core.payload import build_body
|
|
42
|
+
from pgdispatch.core.retry import next_retry_delay
|
|
43
|
+
from pgdispatch.core.secrets import SecretStore, fernet_cipher
|
|
44
|
+
from pgdispatch.core.signing import signature_header
|
|
45
|
+
from pgdispatch.hooks import Hooks
|
|
46
|
+
from pgdispatch.metrics import Metrics, get_metrics
|
|
47
|
+
from pgdispatch.models import DeliveryStatus
|
|
48
|
+
from pgdispatch.worker.fetcher import CLAIM, CLAIM_PROBES, ClaimedDelivery
|
|
49
|
+
from pgdispatch.worker.ratelimit import RateLimiter
|
|
50
|
+
from pgdispatch.worker.recorder import DeliveryRecord, Recorder
|
|
51
|
+
from pgdispatch.worker.sender import AttemptResult, Sender, SendOutcome, create_session
|
|
52
|
+
|
|
53
|
+
__all__ = ["PoolFactory", "Worker"]
|
|
54
|
+
|
|
55
|
+
logger = logging.getLogger("pgdispatch.worker")
|
|
56
|
+
|
|
57
|
+
PoolFactory = Callable[[], Awaitable[asyncpg.Pool]]
|
|
58
|
+
|
|
59
|
+
_DATABASE_ERRORS = (asyncpg.PostgresError, asyncpg.InterfaceError, OSError)
|
|
60
|
+
_METRICS_INTERVAL = 15.0
|
|
61
|
+
_REDACTED = "[redacted]"
|
|
62
|
+
_LOGGED_HEADERS = (
|
|
63
|
+
"content-type",
|
|
64
|
+
"user-agent",
|
|
65
|
+
"webhook-id",
|
|
66
|
+
"webhook-timestamp",
|
|
67
|
+
"webhook-signature",
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
_HEARTBEAT = """
|
|
71
|
+
INSERT INTO {schema}.workers (id, hostname, pid, version, concurrency)
|
|
72
|
+
VALUES (CAST(:id AS uuid), CAST(:hostname AS text), CAST(:pid AS integer),
|
|
73
|
+
CAST(:version AS text), CAST(:concurrency AS integer))
|
|
74
|
+
ON CONFLICT (id) DO UPDATE SET heartbeat_at = now()
|
|
75
|
+
"""
|
|
76
|
+
|
|
77
|
+
_ACTIVE_WORKERS = """
|
|
78
|
+
WITH expired AS (
|
|
79
|
+
DELETE FROM {schema}.workers
|
|
80
|
+
WHERE heartbeat_at < now() - CAST(:ttl AS double precision) * 10 * interval '1 second'
|
|
81
|
+
)
|
|
82
|
+
SELECT count(*) AS active FROM {schema}.workers
|
|
83
|
+
WHERE heartbeat_at > now() - CAST(:ttl AS double precision) * interval '1 second'
|
|
84
|
+
"""
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
class Worker:
|
|
88
|
+
"""Delivers webhooks until :meth:`stop` is called.
|
|
89
|
+
|
|
90
|
+
Usually created with :meth:`Webhooks.worker() <pgdispatch.Webhooks.worker>`
|
|
91
|
+
or run with ``pgdispatch worker``. Use it directly for custom setups::
|
|
92
|
+
|
|
93
|
+
worker = Worker.from_dsn("postgresql://...", WebhooksConfig(concurrency=100))
|
|
94
|
+
await worker.run()
|
|
95
|
+
"""
|
|
96
|
+
|
|
97
|
+
def __init__(
|
|
98
|
+
self,
|
|
99
|
+
pool_factory: PoolFactory,
|
|
100
|
+
config: WebhooksConfig | None = None,
|
|
101
|
+
*,
|
|
102
|
+
hooks: Hooks | None = None,
|
|
103
|
+
metrics: Metrics | None = None,
|
|
104
|
+
secret_store: SecretStore | None = None,
|
|
105
|
+
http_session: aiohttp.ClientSession | None = None,
|
|
106
|
+
) -> None:
|
|
107
|
+
self.config = config or WebhooksConfig()
|
|
108
|
+
self.id = uuid.uuid4()
|
|
109
|
+
self._pool_factory = pool_factory
|
|
110
|
+
self._hooks = hooks or Hooks()
|
|
111
|
+
self._metrics = metrics or get_metrics()
|
|
112
|
+
self._secrets = secret_store or _secret_store_from(self.config)
|
|
113
|
+
self._http_session = http_session
|
|
114
|
+
self._limiter = RateLimiter()
|
|
115
|
+
self._policy = BreakerPolicy(
|
|
116
|
+
failure_threshold=self.config.failure_threshold,
|
|
117
|
+
cooldown=self.config.circuit_cooldown,
|
|
118
|
+
max_cooldown=self.config.circuit_max_cooldown,
|
|
119
|
+
auto_disable_after=self.config.auto_disable_after,
|
|
120
|
+
)
|
|
121
|
+
|
|
122
|
+
self._stopping = asyncio.Event()
|
|
123
|
+
self._wake = asyncio.Event()
|
|
124
|
+
self._slot_freed = asyncio.Event()
|
|
125
|
+
self._tasks: set[asyncio.Task[None]] = set()
|
|
126
|
+
self._in_flight: Counter[uuid.UUID] = Counter()
|
|
127
|
+
self._active_workers = 1
|
|
128
|
+
self._listener: PoolConnectionProxy | None = None
|
|
129
|
+
self._running = False
|
|
130
|
+
|
|
131
|
+
@classmethod
|
|
132
|
+
def from_dsn(cls, dsn: str, config: WebhooksConfig | None = None, **kwargs: Any) -> Worker:
|
|
133
|
+
from pgdispatch.backends.asyncpg import AsyncpgBackend
|
|
134
|
+
|
|
135
|
+
return cls(AsyncpgBackend(dsn).create_worker_pool, config, **kwargs)
|
|
136
|
+
|
|
137
|
+
@property
|
|
138
|
+
def running(self) -> bool:
|
|
139
|
+
return self._running
|
|
140
|
+
|
|
141
|
+
def stop(self) -> None:
|
|
142
|
+
"""Stop claiming deliveries and shut down gracefully.
|
|
143
|
+
|
|
144
|
+
Safe to call from a signal handler.
|
|
145
|
+
"""
|
|
146
|
+
self._stopping.set()
|
|
147
|
+
self._wake.set()
|
|
148
|
+
self._slot_freed.set()
|
|
149
|
+
|
|
150
|
+
async def run(self) -> None:
|
|
151
|
+
"""Run until :meth:`stop` is called, then finish in-flight requests and return.
|
|
152
|
+
|
|
153
|
+
A worker runs once: calling :meth:`stop` before or during ``run`` ends it.
|
|
154
|
+
Create a new worker to start again.
|
|
155
|
+
"""
|
|
156
|
+
if self._running:
|
|
157
|
+
msg = "this worker is already running"
|
|
158
|
+
raise RuntimeError(msg)
|
|
159
|
+
pool = await self._pool_factory()
|
|
160
|
+
self._running = True
|
|
161
|
+
session = self._http_session or create_session(
|
|
162
|
+
concurrency=self.config.concurrency,
|
|
163
|
+
allow_private_ips=self.config.allow_private_ips,
|
|
164
|
+
timeout=self.config.request_timeout,
|
|
165
|
+
)
|
|
166
|
+
sender = Sender(
|
|
167
|
+
session,
|
|
168
|
+
timeout=self.config.request_timeout,
|
|
169
|
+
store_response_body=self.config.store_response_body,
|
|
170
|
+
max_response_body_bytes=self.config.max_response_body_bytes,
|
|
171
|
+
allow_private_ips=self.config.allow_private_ips,
|
|
172
|
+
proxy=self.config.outbound_proxy,
|
|
173
|
+
)
|
|
174
|
+
recorder = Recorder(
|
|
175
|
+
pool,
|
|
176
|
+
schema=self.config.db_schema,
|
|
177
|
+
policy=self._policy,
|
|
178
|
+
hooks=self._hooks,
|
|
179
|
+
metrics=self._metrics,
|
|
180
|
+
on_flush=self._wake.set, # results may unblock ordered deliveries
|
|
181
|
+
)
|
|
182
|
+
recorder_task = asyncio.create_task(recorder.run(), name="pgdispatch-recorder")
|
|
183
|
+
maintenance_task: asyncio.Task[None] | None = None
|
|
184
|
+
try:
|
|
185
|
+
await self._heartbeat(pool)
|
|
186
|
+
await self._listen(pool)
|
|
187
|
+
maintenance_task = asyncio.create_task(
|
|
188
|
+
self._maintain(pool), name="pgdispatch-maintenance"
|
|
189
|
+
)
|
|
190
|
+
logger.info(
|
|
191
|
+
"pgdispatch worker %s started (concurrency=%d, schema=%s)",
|
|
192
|
+
self.id,
|
|
193
|
+
self.config.concurrency,
|
|
194
|
+
self.config.db_schema,
|
|
195
|
+
)
|
|
196
|
+
await self._dispatch(pool, sender, recorder)
|
|
197
|
+
finally:
|
|
198
|
+
await self._drain()
|
|
199
|
+
shutdown_timeout = self.config.shutdown_timeout.total_seconds()
|
|
200
|
+
recorder.close(timeout=shutdown_timeout)
|
|
201
|
+
with contextlib.suppress(asyncio.TimeoutError):
|
|
202
|
+
await asyncio.wait_for(recorder_task, shutdown_timeout + 5)
|
|
203
|
+
if maintenance_task is not None:
|
|
204
|
+
maintenance_task.cancel()
|
|
205
|
+
with contextlib.suppress(asyncio.CancelledError):
|
|
206
|
+
await maintenance_task
|
|
207
|
+
await self._shutdown_connections(pool)
|
|
208
|
+
if self._http_session is None:
|
|
209
|
+
await session.close()
|
|
210
|
+
self._running = False
|
|
211
|
+
logger.info("pgdispatch worker %s stopped", self.id)
|
|
212
|
+
|
|
213
|
+
# --- dispatch -------------------------------------------------------------
|
|
214
|
+
|
|
215
|
+
async def _dispatch(self, pool: asyncpg.Pool, sender: Sender, recorder: Recorder) -> None:
|
|
216
|
+
backoff = 0.5
|
|
217
|
+
while not self._stopping.is_set():
|
|
218
|
+
free = self.config.concurrency - len(self._tasks)
|
|
219
|
+
if free <= 0:
|
|
220
|
+
self._slot_freed.clear()
|
|
221
|
+
if not self._stopping.is_set(): # stop() may have fired just now
|
|
222
|
+
await self._slot_freed.wait()
|
|
223
|
+
continue
|
|
224
|
+
|
|
225
|
+
requested = min(free, self.config.batch_size)
|
|
226
|
+
self._wake.clear()
|
|
227
|
+
try:
|
|
228
|
+
claimed = await self._claim(pool, requested)
|
|
229
|
+
except _DATABASE_ERRORS:
|
|
230
|
+
logger.warning(
|
|
231
|
+
"pgdispatch could not claim deliveries, retrying in %.1fs",
|
|
232
|
+
backoff,
|
|
233
|
+
exc_info=True,
|
|
234
|
+
)
|
|
235
|
+
await self._sleep(backoff)
|
|
236
|
+
backoff = min(backoff * 2, 30.0)
|
|
237
|
+
continue
|
|
238
|
+
backoff = 0.5
|
|
239
|
+
|
|
240
|
+
if claimed:
|
|
241
|
+
self._metrics.worker_batch_size.observe(len(claimed))
|
|
242
|
+
for delivery in claimed:
|
|
243
|
+
self._start(delivery, sender, recorder)
|
|
244
|
+
if len(claimed) < requested and not self._stopping.is_set():
|
|
245
|
+
# Nothing more is due right now: wait for a NOTIFY, a finished
|
|
246
|
+
# batch, or the polling interval.
|
|
247
|
+
with contextlib.suppress(asyncio.TimeoutError):
|
|
248
|
+
await asyncio.wait_for(
|
|
249
|
+
self._wake.wait(), self.config.poll_interval.total_seconds()
|
|
250
|
+
)
|
|
251
|
+
|
|
252
|
+
async def _claim(self, pool: asyncpg.Pool, limit: int) -> list[ClaimedDelivery]:
|
|
253
|
+
busy = {endpoint: count for endpoint, count in self._in_flight.items() if count > 0}
|
|
254
|
+
endpoint_limit = self.config.endpoint_concurrency
|
|
255
|
+
lease = self.config.lease_duration.total_seconds()
|
|
256
|
+
async with pool.acquire() as connection, connection.transaction():
|
|
257
|
+
# See Recorder._flush: a lost claim only means an earlier retry.
|
|
258
|
+
await connection.execute("SET LOCAL synchronous_commit TO off")
|
|
259
|
+
rows = await self._fetch(connection, CLAIM_PROBES, lease=lease, limit=limit)
|
|
260
|
+
if len(rows) < limit:
|
|
261
|
+
rows += await self._fetch(
|
|
262
|
+
connection,
|
|
263
|
+
CLAIM,
|
|
264
|
+
lease=lease,
|
|
265
|
+
limit=limit - len(rows),
|
|
266
|
+
saturated=[str(e) for e, count in busy.items() if count >= endpoint_limit],
|
|
267
|
+
busy_ids=[str(e) for e in busy],
|
|
268
|
+
busy_counts=list(busy.values()),
|
|
269
|
+
endpoint_limit=endpoint_limit,
|
|
270
|
+
)
|
|
271
|
+
return [ClaimedDelivery.from_row(row) for row in rows]
|
|
272
|
+
|
|
273
|
+
def _start(self, delivery: ClaimedDelivery, sender: Sender, recorder: Recorder) -> None:
|
|
274
|
+
self._in_flight[delivery.endpoint_id] += 1
|
|
275
|
+
task = asyncio.create_task(self._process(delivery, sender, recorder))
|
|
276
|
+
self._tasks.add(task)
|
|
277
|
+
|
|
278
|
+
def finished(done: asyncio.Task[None]) -> None:
|
|
279
|
+
self._tasks.discard(done)
|
|
280
|
+
self._in_flight[delivery.endpoint_id] -= 1
|
|
281
|
+
if self._in_flight[delivery.endpoint_id] <= 0:
|
|
282
|
+
del self._in_flight[delivery.endpoint_id]
|
|
283
|
+
self._slot_freed.set()
|
|
284
|
+
|
|
285
|
+
task.add_done_callback(finished)
|
|
286
|
+
|
|
287
|
+
async def _drain(self) -> None:
|
|
288
|
+
if not self._tasks:
|
|
289
|
+
return
|
|
290
|
+
logger.info("pgdispatch worker waiting for %d in-flight requests", len(self._tasks))
|
|
291
|
+
_, pending = await asyncio.wait(
|
|
292
|
+
set(self._tasks), timeout=self.config.shutdown_timeout.total_seconds()
|
|
293
|
+
)
|
|
294
|
+
for task in pending:
|
|
295
|
+
task.cancel()
|
|
296
|
+
if pending:
|
|
297
|
+
logger.warning(
|
|
298
|
+
"pgdispatch returned %d unfinished deliveries to the queue", len(pending)
|
|
299
|
+
)
|
|
300
|
+
await asyncio.gather(*pending, return_exceptions=True)
|
|
301
|
+
|
|
302
|
+
# --- sending --------------------------------------------------------------
|
|
303
|
+
|
|
304
|
+
async def _process(self, delivery: ClaimedDelivery, sender: Sender, recorder: Recorder) -> None:
|
|
305
|
+
try:
|
|
306
|
+
record = await self._deliver(delivery, sender)
|
|
307
|
+
except asyncio.CancelledError:
|
|
308
|
+
# Shutting down: hand the delivery back without counting an attempt.
|
|
309
|
+
recorder.add(DeliveryRecord(delivery, DeliveryStatus.PENDING))
|
|
310
|
+
raise
|
|
311
|
+
except Exception as exc:
|
|
312
|
+
logger.exception("pgdispatch failed to process delivery %s", delivery.id)
|
|
313
|
+
outcome = SendOutcome(AttemptResult.NETWORK, None, None, f"internal error: {exc!r}", 0)
|
|
314
|
+
record = self._resolve(delivery, outcome, {}, datetime.now(timezone.utc))
|
|
315
|
+
# Keep the slot until the result is stored, so the next claim already
|
|
316
|
+
# sees the updated circuit breaker and ordering state.
|
|
317
|
+
await recorder.add(record)
|
|
318
|
+
|
|
319
|
+
async def _deliver(self, delivery: ClaimedDelivery, sender: Sender) -> DeliveryRecord:
|
|
320
|
+
if delivery.rate_limit_rps:
|
|
321
|
+
share = delivery.rate_limit_rps / max(1, self._active_workers)
|
|
322
|
+
delay = self._limiter.reserve(delivery.endpoint_id, share)
|
|
323
|
+
if delay > self._max_rate_limit_wait:
|
|
324
|
+
# Waiting would outlive the lease: put the delivery back instead.
|
|
325
|
+
self._limiter.release(delivery.endpoint_id, share)
|
|
326
|
+
return DeliveryRecord(delivery, DeliveryStatus.PENDING, retry_in=delay)
|
|
327
|
+
if delay:
|
|
328
|
+
await asyncio.sleep(delay)
|
|
329
|
+
|
|
330
|
+
attempted_at = datetime.now(timezone.utc)
|
|
331
|
+
body = build_body(delivery.event_type, delivery.event_created_at, delivery.payload)
|
|
332
|
+
msg_id = str(delivery.event_id)
|
|
333
|
+
timestamp = int(time.time())
|
|
334
|
+
secrets = self._secrets.active_secrets(delivery.secrets, now=attempted_at)
|
|
335
|
+
headers = {
|
|
336
|
+
**delivery.headers,
|
|
337
|
+
"content-type": "application/json",
|
|
338
|
+
"user-agent": self.config.user_agent,
|
|
339
|
+
"webhook-id": msg_id,
|
|
340
|
+
"webhook-timestamp": str(timestamp),
|
|
341
|
+
"webhook-signature": signature_header(secrets, msg_id, timestamp, body),
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
outcome = await sender.send(delivery.url, headers, body)
|
|
345
|
+
|
|
346
|
+
result = outcome.result.value
|
|
347
|
+
self._metrics.delivery_attempts.labels(result).inc()
|
|
348
|
+
self._metrics.delivery_duration.labels(result).observe(outcome.duration_ms / 1000)
|
|
349
|
+
self._log_attempt(delivery, outcome)
|
|
350
|
+
return self._resolve(delivery, outcome, _loggable_headers(headers), attempted_at)
|
|
351
|
+
|
|
352
|
+
def _resolve(
|
|
353
|
+
self,
|
|
354
|
+
delivery: ClaimedDelivery,
|
|
355
|
+
outcome: SendOutcome,
|
|
356
|
+
request_headers: dict[str, str],
|
|
357
|
+
attempted_at: datetime,
|
|
358
|
+
) -> DeliveryRecord:
|
|
359
|
+
"""Decide what happens to the delivery after an attempt."""
|
|
360
|
+
|
|
361
|
+
def record(status: DeliveryStatus, retry_in: float = 0.0) -> DeliveryRecord:
|
|
362
|
+
return DeliveryRecord(
|
|
363
|
+
delivery,
|
|
364
|
+
status,
|
|
365
|
+
retry_in=retry_in,
|
|
366
|
+
outcome=outcome,
|
|
367
|
+
request_headers=request_headers,
|
|
368
|
+
attempted_at=attempted_at,
|
|
369
|
+
)
|
|
370
|
+
|
|
371
|
+
if outcome.succeeded:
|
|
372
|
+
return record(DeliveryStatus.SUCCEEDED)
|
|
373
|
+
if outcome.gone:
|
|
374
|
+
return record(DeliveryStatus.CANCELLED)
|
|
375
|
+
delay = next_retry_delay(
|
|
376
|
+
self.config.retry_schedule,
|
|
377
|
+
delivery.attempts_in_round,
|
|
378
|
+
jitter=self.config.retry_jitter,
|
|
379
|
+
)
|
|
380
|
+
if delay is None:
|
|
381
|
+
return record(DeliveryStatus.FAILED)
|
|
382
|
+
if outcome.retry_after is not None:
|
|
383
|
+
# Honour Retry-After, but never beyond the longest regular delay.
|
|
384
|
+
delay = max(delay, min(outcome.retry_after, max(self.config.retry_schedule)))
|
|
385
|
+
return record(DeliveryStatus.PENDING, retry_in=delay.total_seconds())
|
|
386
|
+
|
|
387
|
+
@property
|
|
388
|
+
def _max_rate_limit_wait(self) -> float:
|
|
389
|
+
spare = self.config.lease_duration - self.config.request_timeout - timedelta(seconds=1)
|
|
390
|
+
return max(spare.total_seconds(), 0.0)
|
|
391
|
+
|
|
392
|
+
def _log_attempt(self, delivery: ClaimedDelivery, outcome: SendOutcome) -> None:
|
|
393
|
+
extra = {
|
|
394
|
+
"event_id": str(delivery.event_id),
|
|
395
|
+
"delivery_id": delivery.id,
|
|
396
|
+
"endpoint_id": str(delivery.endpoint_id),
|
|
397
|
+
"attempt": delivery.attempt_count,
|
|
398
|
+
"status_code": outcome.status_code,
|
|
399
|
+
"duration_ms": outcome.duration_ms,
|
|
400
|
+
}
|
|
401
|
+
if outcome.succeeded:
|
|
402
|
+
logger.debug("pgdispatch delivered %s", delivery.id, extra=extra)
|
|
403
|
+
else:
|
|
404
|
+
logger.info(
|
|
405
|
+
"pgdispatch attempt %d of delivery %s failed: %s",
|
|
406
|
+
delivery.attempt_count,
|
|
407
|
+
delivery.id,
|
|
408
|
+
outcome.error,
|
|
409
|
+
extra=extra,
|
|
410
|
+
)
|
|
411
|
+
|
|
412
|
+
# --- LISTEN/NOTIFY ----------------------------------------------------------
|
|
413
|
+
|
|
414
|
+
async def _listen(self, pool: asyncpg.Pool) -> None:
|
|
415
|
+
"""Hold one connection that LISTENs for wake-ups; polling covers any gap."""
|
|
416
|
+
try:
|
|
417
|
+
connection = await pool.acquire()
|
|
418
|
+
except _DATABASE_ERRORS:
|
|
419
|
+
logger.warning("pgdispatch could not LISTEN; falling back to polling", exc_info=True)
|
|
420
|
+
return
|
|
421
|
+
try:
|
|
422
|
+
await connection.add_listener(self.config.notify_channel, self._on_notify)
|
|
423
|
+
except _DATABASE_ERRORS:
|
|
424
|
+
logger.warning("pgdispatch could not LISTEN; falling back to polling", exc_info=True)
|
|
425
|
+
await pool.release(connection)
|
|
426
|
+
return
|
|
427
|
+
self._listener = connection
|
|
428
|
+
|
|
429
|
+
def _on_notify(self, *_: object) -> None:
|
|
430
|
+
self._wake.set()
|
|
431
|
+
|
|
432
|
+
# --- maintenance ------------------------------------------------------------
|
|
433
|
+
|
|
434
|
+
async def _maintain(self, pool: asyncpg.Pool) -> None:
|
|
435
|
+
interval = self.config.heartbeat_interval.total_seconds()
|
|
436
|
+
next_metrics = 0.0
|
|
437
|
+
next_cleanup = time.monotonic() + self.config.cleanup_interval.total_seconds() / 10
|
|
438
|
+
while True:
|
|
439
|
+
await asyncio.sleep(interval)
|
|
440
|
+
now = time.monotonic()
|
|
441
|
+
await self._guard("heartbeat", self._heartbeat(pool))
|
|
442
|
+
if self._listener is None or self._listener.is_closed():
|
|
443
|
+
if self._listener is not None:
|
|
444
|
+
await self._guard("release LISTEN connection", pool.release(self._listener))
|
|
445
|
+
self._listener = None
|
|
446
|
+
await self._listen(pool)
|
|
447
|
+
self._wake.set() # notifications may have been missed meanwhile
|
|
448
|
+
if now >= next_metrics:
|
|
449
|
+
await self._guard("metrics refresh", self._refresh_metrics(pool))
|
|
450
|
+
next_metrics = now + _METRICS_INTERVAL
|
|
451
|
+
if now >= next_cleanup:
|
|
452
|
+
await self._guard("clean-up", self._cleanup(pool))
|
|
453
|
+
next_cleanup = now + self.config.cleanup_interval.total_seconds()
|
|
454
|
+
self._limiter.prune()
|
|
455
|
+
|
|
456
|
+
async def _heartbeat(self, pool: asyncpg.Pool) -> None:
|
|
457
|
+
async with pool.acquire() as connection:
|
|
458
|
+
await self._fetch(
|
|
459
|
+
connection,
|
|
460
|
+
_HEARTBEAT,
|
|
461
|
+
id=str(self.id),
|
|
462
|
+
hostname=socket.gethostname(),
|
|
463
|
+
pid=os.getpid(),
|
|
464
|
+
version=__version__,
|
|
465
|
+
concurrency=self.config.concurrency,
|
|
466
|
+
)
|
|
467
|
+
rows = await self._fetch(connection, _ACTIVE_WORKERS, ttl=self._worker_ttl)
|
|
468
|
+
self._active_workers = max(1, rows[0]["active"])
|
|
469
|
+
|
|
470
|
+
async def _refresh_metrics(self, pool: asyncpg.Pool) -> None:
|
|
471
|
+
async with pool.acquire() as connection:
|
|
472
|
+
stats = await run_async(
|
|
473
|
+
queue_stats(worker_ttl=timedelta(seconds=self._worker_ttl)),
|
|
474
|
+
AsyncpgExecutor(connection),
|
|
475
|
+
schema=self.config.db_schema,
|
|
476
|
+
)
|
|
477
|
+
self._metrics.deliveries_pending.set(stats.pending)
|
|
478
|
+
self._metrics.oldest_pending_age.set(stats.oldest_due_age.total_seconds())
|
|
479
|
+
self._metrics.endpoints_circuit_open.set(stats.circuits_open)
|
|
480
|
+
|
|
481
|
+
async def _cleanup(self, pool: asyncpg.Pool) -> None:
|
|
482
|
+
"""Purge expired data; an advisory lock ensures only one worker does it at a time."""
|
|
483
|
+
lock = f"{self.config.db_schema}.cleanup"
|
|
484
|
+
async with pool.acquire() as connection:
|
|
485
|
+
acquired = await connection.fetchval("SELECT pg_try_advisory_lock(hashtext($1))", lock)
|
|
486
|
+
if not acquired:
|
|
487
|
+
return
|
|
488
|
+
try:
|
|
489
|
+
total = 0
|
|
490
|
+
while not self._stopping.is_set():
|
|
491
|
+
async with connection.transaction():
|
|
492
|
+
deleted = await run_async(
|
|
493
|
+
delete_expired_events(
|
|
494
|
+
older_than=timedelta(days=self.config.retention_days),
|
|
495
|
+
batch_size=self.config.cleanup_batch_size,
|
|
496
|
+
),
|
|
497
|
+
AsyncpgExecutor(connection),
|
|
498
|
+
schema=self.config.db_schema,
|
|
499
|
+
)
|
|
500
|
+
total += deleted
|
|
501
|
+
if deleted < self.config.cleanup_batch_size:
|
|
502
|
+
break
|
|
503
|
+
if total:
|
|
504
|
+
logger.info("pgdispatch deleted %d expired events", total)
|
|
505
|
+
finally:
|
|
506
|
+
await connection.execute("SELECT pg_advisory_unlock(hashtext($1))", lock)
|
|
507
|
+
|
|
508
|
+
async def _shutdown_connections(self, pool: asyncpg.Pool) -> None:
|
|
509
|
+
listener, self._listener = self._listener, None
|
|
510
|
+
if listener is not None and not listener.is_closed():
|
|
511
|
+
await self._guard(
|
|
512
|
+
"UNLISTEN", listener.remove_listener(self.config.notify_channel, self._on_notify)
|
|
513
|
+
)
|
|
514
|
+
await self._guard("release LISTEN connection", pool.release(listener))
|
|
515
|
+
await self._guard(
|
|
516
|
+
"deregistration",
|
|
517
|
+
pool.execute(
|
|
518
|
+
render_schema("DELETE FROM {schema}.workers WHERE id = $1", self.config.db_schema),
|
|
519
|
+
self.id,
|
|
520
|
+
),
|
|
521
|
+
)
|
|
522
|
+
await pool.close()
|
|
523
|
+
|
|
524
|
+
@property
|
|
525
|
+
def _worker_ttl(self) -> float:
|
|
526
|
+
return self.config.heartbeat_interval.total_seconds() * 3
|
|
527
|
+
|
|
528
|
+
# --- helpers ------------------------------------------------------------------
|
|
529
|
+
|
|
530
|
+
async def _sleep(self, seconds: float) -> None:
|
|
531
|
+
with contextlib.suppress(asyncio.TimeoutError):
|
|
532
|
+
await asyncio.wait_for(self._stopping.wait(), seconds)
|
|
533
|
+
|
|
534
|
+
async def _fetch(self, connection: AsyncpgConnection, sql: str, **params: Any) -> list[Row]:
|
|
535
|
+
statement, names = to_numeric_style(render_schema(sql, self.config.db_schema))
|
|
536
|
+
records = await connection.fetch(statement, *(params[name] for name in names))
|
|
537
|
+
return [dict(record) for record in records]
|
|
538
|
+
|
|
539
|
+
@staticmethod
|
|
540
|
+
async def _guard(what: str, awaitable: Awaitable[Any]) -> None:
|
|
541
|
+
try:
|
|
542
|
+
await awaitable
|
|
543
|
+
except _DATABASE_ERRORS:
|
|
544
|
+
logger.warning("pgdispatch %s failed", what, exc_info=True)
|
|
545
|
+
|
|
546
|
+
|
|
547
|
+
def _loggable_headers(headers: dict[str, str]) -> dict[str, str]:
|
|
548
|
+
"""Headers stored in the attempt log; custom header values may be credentials."""
|
|
549
|
+
return {
|
|
550
|
+
name.lower(): value if name.lower() in _LOGGED_HEADERS else _REDACTED
|
|
551
|
+
for name, value in headers.items()
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
|
|
555
|
+
def _secret_store_from(config: WebhooksConfig) -> SecretStore:
|
|
556
|
+
keys = [key.get_secret_value() for key in config.secret_encryption_keys]
|
|
557
|
+
return SecretStore(fernet_cipher(keys) if keys else None)
|