pgdispatch 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (54) hide show
  1. pgdispatch/__init__.py +69 -0
  2. pgdispatch/__main__.py +5 -0
  3. pgdispatch/_version.py +1 -0
  4. pgdispatch/backends/__init__.py +6 -0
  5. pgdispatch/backends/asyncpg.py +103 -0
  6. pgdispatch/backends/base.py +142 -0
  7. pgdispatch/cli.py +420 -0
  8. pgdispatch/client.py +490 -0
  9. pgdispatch/config.py +223 -0
  10. pgdispatch/core/__init__.py +1 -0
  11. pgdispatch/core/circuit.py +94 -0
  12. pgdispatch/core/common.py +87 -0
  13. pgdispatch/core/deliveries.py +216 -0
  14. pgdispatch/core/endpoints.py +338 -0
  15. pgdispatch/core/events.py +65 -0
  16. pgdispatch/core/maintenance.py +121 -0
  17. pgdispatch/core/matching.py +76 -0
  18. pgdispatch/core/payload.py +108 -0
  19. pgdispatch/core/publisher.py +232 -0
  20. pgdispatch/core/retry.py +90 -0
  21. pgdispatch/core/secrets.py +138 -0
  22. pgdispatch/core/signing.py +141 -0
  23. pgdispatch/django/__init__.py +37 -0
  24. pgdispatch/django/apps.py +10 -0
  25. pgdispatch/django/client.py +402 -0
  26. pgdispatch/django/management/__init__.py +0 -0
  27. pgdispatch/django/management/commands/__init__.py +0 -0
  28. pgdispatch/django/management/commands/pgdispatch_worker.py +32 -0
  29. pgdispatch/django/migrations/0001_initial.py +30 -0
  30. pgdispatch/django/migrations/__init__.py +0 -0
  31. pgdispatch/errors.py +43 -0
  32. pgdispatch/fastapi/__init__.py +23 -0
  33. pgdispatch/fastapi/router.py +243 -0
  34. pgdispatch/hooks.py +80 -0
  35. pgdispatch/metrics.py +80 -0
  36. pgdispatch/migrations/0001_initial.sql +104 -0
  37. pgdispatch/migrations/__init__.py +93 -0
  38. pgdispatch/models.py +208 -0
  39. pgdispatch/py.typed +0 -0
  40. pgdispatch/security/__init__.py +1 -0
  41. pgdispatch/security/ssrf.py +141 -0
  42. pgdispatch/sqlalchemy.py +130 -0
  43. pgdispatch/worker/__init__.py +5 -0
  44. pgdispatch/worker/fetcher.py +183 -0
  45. pgdispatch/worker/ratelimit.py +47 -0
  46. pgdispatch/worker/recorder.py +435 -0
  47. pgdispatch/worker/runner.py +36 -0
  48. pgdispatch/worker/sender.py +160 -0
  49. pgdispatch/worker/worker.py +557 -0
  50. pgdispatch-0.1.0.dist-info/METADATA +329 -0
  51. pgdispatch-0.1.0.dist-info/RECORD +54 -0
  52. pgdispatch-0.1.0.dist-info/WHEEL +4 -0
  53. pgdispatch-0.1.0.dist-info/entry_points.txt +2 -0
  54. pgdispatch-0.1.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,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)