lorebase-store 0.2.3__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 (163) hide show
  1. lorebase_store/__init__.py +1 -0
  2. lorebase_store/_migrations/env.py +106 -0
  3. lorebase_store/_migrations/script.py.mako +27 -0
  4. lorebase_store/_migrations/versions/0001_initial_schema.py +1071 -0
  5. lorebase_store/_migrations/versions/0002_merge_attempt_details.py +74 -0
  6. lorebase_store/_migrations/versions/0003_webhooks.py +127 -0
  7. lorebase_store/_migrations/versions/0004_defer_revision_references.py +38 -0
  8. lorebase_store/_migrations/versions/0005_security_definer_reads.py +396 -0
  9. lorebase_store/_migrations/versions/0006_api_key_usage.py +48 -0
  10. lorebase_store/_migrations/versions/0007_document_extraction.py +154 -0
  11. lorebase_store/_migrations/versions/0008_definer_argument_checks.py +178 -0
  12. lorebase_store/_migrations/versions/0009_api_key_networks.py +33 -0
  13. lorebase_store/_migrations/versions/0010_overlay_branches.py +582 -0
  14. lorebase_store/_migrations/versions/0011_branch_scoped_search_index.py +229 -0
  15. lorebase_store/_migrations/versions/0012_overlay_neighborhood_lookups.py +185 -0
  16. lorebase_store/_migrations/versions/0013_web_sessions.py +72 -0
  17. lorebase_store/_migrations/versions/0014_embedding_jobs.py +70 -0
  18. lorebase_store/_migrations/versions/0015_append_only_audit_log.py +83 -0
  19. lorebase_store/_migrations/versions/0016_sealed_webhook_keys.py +55 -0
  20. lorebase_store/_migrations/versions/0017_background_branch_copies.py +77 -0
  21. lorebase_store/_migrations/versions/0018_search_lexicon.py +477 -0
  22. lorebase_store/_migrations/versions/0019_vault_bindings.py +56 -0
  23. lorebase_store/_migrations/versions/0020_outbox_consumers.py +150 -0
  24. lorebase_store/_migrations/versions/0021_schema_suggestions.py +114 -0
  25. lorebase_store/_migrations/versions/0022_read_audit_and_shared_rate_limits.py +133 -0
  26. lorebase_store/_migrations/versions/0023_agent_trace.py +96 -0
  27. lorebase_store/_migrations/versions/0024_embedding_template_keys.py +205 -0
  28. lorebase_store/_migrations/versions/0025_snapshot_refs.py +35 -0
  29. lorebase_store/_migrations/versions/0026_plain_reverse_link_index.py +112 -0
  30. lorebase_store/_migrations/versions/0027_vault_rotation_and_tombstones.py +145 -0
  31. lorebase_store/_migrations/versions/0028_operation_deferred_events.py +71 -0
  32. lorebase_store/_migrations/versions/0029_agent_trace_sessions.py +108 -0
  33. lorebase_store/_migrations/versions/0030_path_fanout.py +31 -0
  34. lorebase_store/_migrations/versions/0031_branch_builds.py +91 -0
  35. lorebase_store/_migrations/versions/0032_lineage_revisions.py +35 -0
  36. lorebase_store/_migrations/versions/0033_vault_moved_tokens.py +30 -0
  37. lorebase_store/_migrations/versions/0034_inference_endpoint_modes.py +73 -0
  38. lorebase_store/_migrations/versions/0035_search_truncation.py +104 -0
  39. lorebase_store/_migrations/versions/0036_lineage_maps.py +325 -0
  40. lorebase_store/_migrations/versions/0037_bm25_search.py +346 -0
  41. lorebase_store/auth/__init__.py +52 -0
  42. lorebase_store/auth/api_keys.py +161 -0
  43. lorebase_store/auth/audit.py +382 -0
  44. lorebase_store/auth/checkpoints.py +185 -0
  45. lorebase_store/auth/grants.py +161 -0
  46. lorebase_store/auth/principals.py +158 -0
  47. lorebase_store/auth/scope.py +70 -0
  48. lorebase_store/auth/sessions.py +153 -0
  49. lorebase_store/auth/unit.py +53 -0
  50. lorebase_store/auth/usage.py +70 -0
  51. lorebase_store/blobs.py +73 -0
  52. lorebase_store/bm25.py +346 -0
  53. lorebase_store/bulk.py +147 -0
  54. lorebase_store/bundle.py +1830 -0
  55. lorebase_store/changefeed.py +261 -0
  56. lorebase_store/commit/__init__.py +42 -0
  57. lorebase_store/commit/changes.py +67 -0
  58. lorebase_store/commit/errors.py +60 -0
  59. lorebase_store/commit/migrations.py +335 -0
  60. lorebase_store/commit/pipeline.py +754 -0
  61. lorebase_store/commit/schema.py +123 -0
  62. lorebase_store/db.py +58 -0
  63. lorebase_store/definer.py +50 -0
  64. lorebase_store/digests.py +99 -0
  65. lorebase_store/embeddings.py +444 -0
  66. lorebase_store/endpoint_modes.py +59 -0
  67. lorebase_store/extraction/__init__.py +61 -0
  68. lorebase_store/extraction/items.py +187 -0
  69. lorebase_store/extraction/materialize.py +364 -0
  70. lorebase_store/extraction/proposal.py +65 -0
  71. lorebase_store/extraction/review.py +168 -0
  72. lorebase_store/gc.py +970 -0
  73. lorebase_store/indexes.py +1033 -0
  74. lorebase_store/jobs.py +651 -0
  75. lorebase_store/keyring.py +72 -0
  76. lorebase_store/layout.py +335 -0
  77. lorebase_store/materialize/__init__.py +103 -0
  78. lorebase_store/materialize/apply.py +436 -0
  79. lorebase_store/materialize/background.py +1184 -0
  80. lorebase_store/materialize/branch.py +268 -0
  81. lorebase_store/materialize/build.py +536 -0
  82. lorebase_store/materialize/derived.py +540 -0
  83. lorebase_store/materialize/lineage_backfill.py +502 -0
  84. lorebase_store/materialize/links.py +266 -0
  85. lorebase_store/materialize/overlay.py +292 -0
  86. lorebase_store/materialize/paths.py +315 -0
  87. lorebase_store/materialize/rebuild.py +326 -0
  88. lorebase_store/materialize/rows.py +153 -0
  89. lorebase_store/materialize/sensitivity.py +66 -0
  90. lorebase_store/materialize/snapshot.py +205 -0
  91. lorebase_store/materialize/stale.py +444 -0
  92. lorebase_store/materialize/verify.py +321 -0
  93. lorebase_store/merging/__init__.py +39 -0
  94. lorebase_store/merging/attempts.py +334 -0
  95. lorebase_store/merging/lineage.py +123 -0
  96. lorebase_store/merging/service.py +652 -0
  97. lorebase_store/migrations.py +133 -0
  98. lorebase_store/models/__init__.py +136 -0
  99. lorebase_store/models/base.py +64 -0
  100. lorebase_store/models/enums.py +132 -0
  101. lorebase_store/models/governance.py +268 -0
  102. lorebase_store/models/ingestion.py +288 -0
  103. lorebase_store/models/materialized.py +277 -0
  104. lorebase_store/models/proposals.py +195 -0
  105. lorebase_store/models/queues.py +87 -0
  106. lorebase_store/models/tracing.py +116 -0
  107. lorebase_store/models/versioning.py +222 -0
  108. lorebase_store/models/webhooks.py +96 -0
  109. lorebase_store/nodestore.py +384 -0
  110. lorebase_store/objectstore.py +162 -0
  111. lorebase_store/outbox_events.py +166 -0
  112. lorebase_store/profiling/__init__.py +58 -0
  113. lorebase_store/profiling/delimited.py +109 -0
  114. lorebase_store/profiling/postgres.py +211 -0
  115. lorebase_store/profiling/record_files.py +127 -0
  116. lorebase_store/profiling/records.py +92 -0
  117. lorebase_store/profiling/sql_databases.py +298 -0
  118. lorebase_store/ratelimit.py +129 -0
  119. lorebase_store/reads.py +1491 -0
  120. lorebase_store/replicas.py +434 -0
  121. lorebase_store/repositories/__init__.py +152 -0
  122. lorebase_store/repositories/changesets.py +59 -0
  123. lorebase_store/repositories/deferred_events.py +142 -0
  124. lorebase_store/repositories/documents.py +305 -0
  125. lorebase_store/repositories/domains.py +97 -0
  126. lorebase_store/repositories/events.py +205 -0
  127. lorebase_store/repositories/index_view.py +489 -0
  128. lorebase_store/repositories/invocations.py +68 -0
  129. lorebase_store/repositories/materializations.py +276 -0
  130. lorebase_store/repositories/materialized.py +1129 -0
  131. lorebase_store/repositories/merges.py +417 -0
  132. lorebase_store/repositories/proposal_items.py +199 -0
  133. lorebase_store/repositories/proposals.py +184 -0
  134. lorebase_store/repositories/provenance.py +77 -0
  135. lorebase_store/repositories/refs.py +452 -0
  136. lorebase_store/repositories/revisions.py +273 -0
  137. lorebase_store/repositories/scope.py +33 -0
  138. lorebase_store/repositories/source_runs.py +123 -0
  139. lorebase_store/repositories/suggestions.py +638 -0
  140. lorebase_store/runtime.py +87 -0
  141. lorebase_store/schema_cache.py +50 -0
  142. lorebase_store/settings.py +879 -0
  143. lorebase_store/storage.py +1478 -0
  144. lorebase_store/telemetry.py +142 -0
  145. lorebase_store/testing.py +108 -0
  146. lorebase_store/tracing.py +505 -0
  147. lorebase_store/traverse.py +578 -0
  148. lorebase_store/vault/__init__.py +50 -0
  149. lorebase_store/vault/keys.py +216 -0
  150. lorebase_store/vault/quotes.py +133 -0
  151. lorebase_store/vault/rotation.py +209 -0
  152. lorebase_store/vault/settings.py +152 -0
  153. lorebase_store/vault/store.py +987 -0
  154. lorebase_store/vault/transfer.py +558 -0
  155. lorebase_store/webhooks/__init__.py +24 -0
  156. lorebase_store/webhooks/delivery.py +637 -0
  157. lorebase_store/webhooks/keys.py +126 -0
  158. lorebase_store/webhooks/repository.py +142 -0
  159. lorebase_store/webhooks/settings.py +138 -0
  160. lorebase_store/webhooks/signing.py +50 -0
  161. lorebase_store-0.2.3.dist-info/METADATA +13 -0
  162. lorebase_store-0.2.3.dist-info/RECORD +163 -0
  163. lorebase_store-0.2.3.dist-info/WHEEL +4 -0
@@ -0,0 +1,637 @@
1
+ """Webhook delivery from the outbox (design 10.5): `deliver_pending`, one worker pass.
2
+
3
+ A pass, safe to run concurrently in several workers (rows are claimed with `SKIP LOCKED`):
4
+
5
+ 1. fan out: the oldest outbox events pending for the `webhooks` consumer become one delivery
6
+ per matching webhook (event type or `operation.*`, branch, created before the event), and
7
+ stop being pending for it, in one transaction (`repositories.events`: other consumers of the
8
+ outbox keep their own pending rows);
9
+ 2. claim the due deliveries, leasing them for `lease_s`;
10
+ 3. send each one as an HMAC-signed JSON POST (`webhooks.signing`) through the injected
11
+ `Sender`, up to `concurrency` at a time. Each attempt, host lookup included, is cut off
12
+ after `timeout_s`, so a target that accepts and never answers, drops packets or closes
13
+ midway fails its attempt instead of holding the pass. No attempt starts after
14
+ `pass_timeout_s`: the deliveries left are released to the next pass without counting an
15
+ attempt. A 2xx answer delivers a delivery, anything else is retried after
16
+ `min(backoff_base_s * backoff_factor ** (attempts - 1), backoff_max_s)` until
17
+ `max_attempts`, then marked dead. Targets outside `allowed_schemes`, or resolving to
18
+ private addresses unless `allow_private_targets`, are marked dead at once. The host is
19
+ resolved once per attempt and the `Sender` connects to those checked addresses only, so a
20
+ second lookup (DNS rebinding) cannot redirect the request to an internal service. The body
21
+ is rendered for the webhook when it is claimed (`outbox_events.disclose`): sensitive values
22
+ are masked unless the webhook is cleared for their tags, vault values are opened then.
23
+ Every attempt of an event carries the same `idempotency_header` (`evt_<sequence>`):
24
+ delivery is at least once (a pass that dies after sending and before recording sends
25
+ again), so receivers drop repeats by it. Deliveries are not ordered: retries interleave
26
+ with newer events; payloads carry `sequence` (the outbox id) for receivers that need one;
27
+ 4. prune finished deliveries older than `retention_s`, and outbox events by `store.outbox`
28
+ (`OutboxRepository.prune`): batches of `prune_batch`, each in its own transaction, until
29
+ nothing is left or `prune_budget_s` is spent; the events still prunable are reported by
30
+ `lorebase.outbox.prune.backlog` (counted up to `PRUNE_BACKLOG_CAP`).
31
+
32
+ Before these steps, with `encryption_key` set, up to `batch` signing keys stored usable (or
33
+ under a previous encryption key) are sealed under it (`webhooks.keys`). A delivery whose key
34
+ cannot be opened (sealed under a key that is no longer configured) fails its attempt with an
35
+ error naming the setting.
36
+
37
+ The store makes no HTTP calls itself: the caller (Scribe, or a test) passes the `Sender`.
38
+ Reads span every Domain, so the pass runs with row-level security bypassed.
39
+ """
40
+
41
+ from __future__ import annotations
42
+
43
+ import asyncio
44
+ import ipaddress
45
+ import json
46
+ import logging
47
+ import socket
48
+ import time
49
+ from collections import defaultdict
50
+ from collections.abc import Awaitable, Callable, Mapping, Sequence
51
+ from dataclasses import dataclass
52
+ from datetime import UTC, datetime, timedelta
53
+ from typing import Any, Protocol
54
+ from urllib.parse import urlsplit
55
+
56
+ from sqlalchemy import and_, delete, select, tuple_, update
57
+ from sqlalchemy.dialects.postgresql import insert
58
+ from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
59
+
60
+ from lorebase_core.model import Schema
61
+ from lorebase_store.auth.scope import rls_scope
62
+ from lorebase_store.changefeed import EventRef, branch_schema, describe
63
+ from lorebase_store.commit.pipeline import COMMITTED_EVENT
64
+ from lorebase_store.outbox_events import cleared_tokens, disclose, subscribed
65
+ from lorebase_store.repositories.deferred_events import expire as expire_deferred
66
+ from lorebase_store.repositories.events import OutboxRepository, OutboxRow
67
+ from lorebase_store.settings import OutboxSettings, WriteSettings
68
+ from lorebase_store.telemetry import (
69
+ OUTBOX_HANDOFFS,
70
+ OUTBOX_PRUNE_BACKLOG,
71
+ OUTBOX_PRUNED,
72
+ WEBHOOK_OUTCOMES,
73
+ WEBHOOK_TIMEOUTS,
74
+ )
75
+ from lorebase_store.vault import VaultKeys, VaultValue, open_values
76
+ from lorebase_store.webhooks.keys import SigningKeys, StoredKey, seal_stored_keys
77
+ from lorebase_store.webhooks.repository import WEBHOOK_DELIVERIES, WEBHOOKS, webhook_row
78
+ from lorebase_store.webhooks.settings import WebhookSettings
79
+ from lorebase_store.webhooks.signing import signature
80
+
81
+ logger = logging.getLogger("lorebase.store.webhooks")
82
+
83
+ WEBHOOKS_CONSUMER = "webhooks"
84
+ """The outbox consumer of webhook delivery (registered by revision 0020)."""
85
+ PENDING, DELIVERED, DEAD = "pending", "delivered", "dead"
86
+ _OUTCOMES = {DELIVERED: "delivered", PENDING: "retrying", DEAD: "dead"}
87
+ """Metric outcome of an attempt by the status it leaves the delivery in."""
88
+ ERROR_LENGTH = 500
89
+ """Characters of an error message kept on a delivery."""
90
+
91
+
92
+ class Sender(Protocol):
93
+ """Sends one POST and answers its HTTP status; transport failures raise.
94
+
95
+ `addresses` are the checked addresses of the URL's host: the sender connects to one of
96
+ them (keeping the host name for the `Host` header and TLS) and never resolves the host
97
+ again, and it does not follow redirects. Empty when private targets are allowed: the
98
+ sender then resolves the host itself.
99
+ """
100
+
101
+ async def __call__(
102
+ self,
103
+ url: str,
104
+ body: bytes,
105
+ headers: Mapping[str, str],
106
+ timeout_s: float,
107
+ addresses: Sequence[str],
108
+ ) -> int: ...
109
+
110
+
111
+ Resolver = Callable[[str], Awaitable[Sequence[str]]]
112
+
113
+
114
+ class TargetPolicy(Protocol):
115
+ """What the target checks read from settings (`WebhookSettings`, and the HTTP sinks of
116
+ audit checkpoints)."""
117
+
118
+ @property
119
+ def allowed_schemes(self) -> tuple[str, ...]: ...
120
+
121
+ @property
122
+ def allow_private_targets(self) -> bool: ...
123
+
124
+
125
+ Clock = Callable[[], datetime]
126
+
127
+
128
+ def utc_now() -> datetime:
129
+ return datetime.now(UTC)
130
+
131
+
132
+ async def resolve_host(host: str) -> Sequence[str]:
133
+ """Addresses of a host name."""
134
+ infos = await asyncio.get_running_loop().getaddrinfo(host, None, type=socket.SOCK_STREAM)
135
+ return sorted({str(info[4][0]) for info in infos})
136
+
137
+
138
+ def _private(address: str) -> bool:
139
+ """Whether an address is not a plain public unicast one.
140
+
141
+ Anything outside the globally routable ranges is refused, not only RFC 1918 and loopback:
142
+ shared address space (100.64.0.0/10, where some clouds serve instance metadata), IPv6
143
+ forms that wrap an IPv4 address (mapped, 6to4, Teredo, NAT64 under the reserved ::/8),
144
+ site-local, multicast and reserved ranges.
145
+ """
146
+ ip = ipaddress.ip_address(address.split("%", 1)[0])
147
+ site_local = isinstance(ip, ipaddress.IPv6Address) and ip.is_site_local
148
+ return not ip.is_global or ip.is_multicast or ip.is_reserved or site_local
149
+
150
+
151
+ def scheme_problem(url: str, settings: TargetPolicy) -> str | None:
152
+ """Why `url` cannot be a webhook target by its form alone; None when it can."""
153
+ parts = urlsplit(url)
154
+ if parts.scheme not in settings.allowed_schemes:
155
+ return f"scheme must be one of {', '.join(settings.allowed_schemes)}"
156
+ if not parts.hostname:
157
+ return "has no host"
158
+ if not settings.allow_private_targets:
159
+ try:
160
+ if _private(parts.hostname):
161
+ return "targets a private address"
162
+ except ValueError:
163
+ if parts.hostname == "localhost" or parts.hostname.endswith(".localhost"):
164
+ return "targets a private address"
165
+ return None
166
+
167
+
168
+ @dataclass(frozen=True, slots=True)
169
+ class Target:
170
+ """A checked target: why it is refused, or the addresses a request may connect to."""
171
+
172
+ problem: str | None
173
+ addresses: tuple[str, ...] = ()
174
+
175
+
176
+ async def check_target(url: str, settings: TargetPolicy, resolve: Resolver) -> Target:
177
+ """Resolve the host of `url` once and check its form and every address it resolves to.
178
+
179
+ The addresses returned are the only ones a request to `url` may use: resolving the host
180
+ again could answer differently (DNS rebinding). None are returned when private targets
181
+ are allowed.
182
+ """
183
+ problem = scheme_problem(url, settings)
184
+ if problem is not None or settings.allow_private_targets:
185
+ return Target(problem)
186
+ host = urlsplit(url).hostname or ""
187
+ try:
188
+ addresses = tuple(await resolve(host))
189
+ except OSError as error:
190
+ return Target(f"cannot resolve host: {error}")
191
+ if not addresses:
192
+ return Target("cannot resolve host: no addresses")
193
+ try:
194
+ if any(_private(address) for address in addresses):
195
+ return Target("resolves to a private address")
196
+ except ValueError:
197
+ return Target("resolves to an invalid address")
198
+ return Target(None, addresses)
199
+
200
+
201
+ async def target_problem(url: str, settings: TargetPolicy, resolve: Resolver) -> str | None:
202
+ """Why `url` may not be called now (form, or private addresses after resolution)."""
203
+ return (await check_target(url, settings, resolve)).problem
204
+
205
+
206
+ def backoff(attempts: int, settings: WebhookSettings) -> timedelta:
207
+ """Delay before the next attempt after `attempts` failed ones."""
208
+ seconds = settings.backoff_base_s * settings.backoff_factor ** max(attempts - 1, 0)
209
+ return timedelta(seconds=min(seconds, settings.backoff_max_s))
210
+
211
+
212
+ @dataclass
213
+ class DeliveryReport:
214
+ events: int = 0
215
+ """Outbox events handed off."""
216
+ queued: int = 0
217
+ """Deliveries created from them."""
218
+ attempted: int = 0
219
+ delivered: int = 0
220
+ retrying: int = 0
221
+ dead: int = 0
222
+ pruned: int = 0
223
+ sealed: int = 0
224
+ timed_out: int = 0
225
+ """Attempts cut off by `timeout_s` (counted in `retrying` or `dead` as well)."""
226
+ released: int = 0
227
+ """Claimed deliveries not attempted before `pass_timeout_s`, left to the next pass."""
228
+
229
+
230
+ @dataclass(frozen=True, slots=True)
231
+ class _Claimed:
232
+ domain_id: str
233
+ id: int
234
+ url: str
235
+ key: bytes | None
236
+ """The signing key; None when it is sealed under a key that is not configured."""
237
+ event_type: str
238
+ payload: dict[str, Any]
239
+ """The body as this webhook receives it (`disclose`d)."""
240
+ attempts: int
241
+ event_id: int = 0
242
+
243
+
244
+ @dataclass(frozen=True, slots=True)
245
+ class _Outcome:
246
+ claimed: _Claimed
247
+ status: str
248
+ http_status: int | None
249
+ error: str | None
250
+ timed_out: bool = False
251
+
252
+
253
+ async def _payload(
254
+ session: AsyncSession,
255
+ event: OutboxRow,
256
+ schemas: dict[tuple[str, str], Schema | None],
257
+ settings: WebhookSettings,
258
+ writes: WriteSettings,
259
+ ) -> dict[str, Any]:
260
+ if event.event_type != COMMITTED_EVENT:
261
+ return {**event.payload, "type": event.event_type, "sequence": event.id}
262
+ branch = str(event.payload.get("branch", ""))
263
+ if (event.domain_id, branch) not in schemas:
264
+ schemas[(event.domain_id, branch)] = await branch_schema(
265
+ session, event.domain_id, branch, writes=writes
266
+ )
267
+ documents = await describe(
268
+ session,
269
+ event.domain_id,
270
+ [EventRef(bytes.fromhex(event.payload["revision"]), branch, event.id)],
271
+ schemas={branch: schemas[(event.domain_id, branch)]},
272
+ keys_max=settings.changed_keys_max,
273
+ )
274
+ if documents:
275
+ return documents[0]
276
+ return {**event.payload, "type": event.event_type, "sequence": event.id}
277
+
278
+
279
+ async def _fan_out(
280
+ sessions: async_sessionmaker[AsyncSession],
281
+ settings: WebhookSettings,
282
+ writes: WriteSettings,
283
+ now: datetime,
284
+ report: DeliveryReport,
285
+ ) -> None:
286
+ async with sessions() as session, session.begin(), rls_scope(session, bypass=True):
287
+ outbox = OutboxRepository(session)
288
+ events = await outbox.claim(WEBHOOKS_CONSUMER, settings.batch)
289
+ if not events:
290
+ return
291
+ result = await session.execute(
292
+ select(*WEBHOOKS.c).where(WEBHOOKS.c.domain_id.in_({e.domain_id for e in events}))
293
+ )
294
+ hooks = defaultdict(list)
295
+ for row in result:
296
+ hooks[row.domain_id].append(webhook_row(row))
297
+ schemas: dict[tuple[str, str], Schema | None] = {}
298
+ rows: list[dict[str, Any]] = []
299
+ for event in events:
300
+ branch = event.payload.get("branch")
301
+ targets = [
302
+ hook
303
+ for hook in hooks.get(event.domain_id, [])
304
+ if subscribed(event.event_type, hook.event_types)
305
+ and (hook.branches is None or branch in hook.branches)
306
+ and hook.created_at <= event.created_at
307
+ ]
308
+ if not targets:
309
+ continue
310
+ payload = await _payload(session, event, schemas, settings, writes)
311
+ rows.extend(
312
+ {
313
+ "domain_id": event.domain_id,
314
+ "webhook_id": hook.id,
315
+ "event_id": event.id,
316
+ "event_type": event.event_type,
317
+ "payload": payload,
318
+ "next_attempt_at": now,
319
+ }
320
+ for hook in targets
321
+ )
322
+ if rows:
323
+ await session.execute(insert(WEBHOOK_DELIVERIES).values(rows).on_conflict_do_nothing())
324
+ await outbox.acknowledge(WEBHOOKS_CONSUMER, [e.id for e in events])
325
+ report.events += len(events)
326
+ report.queued += len(rows)
327
+ OUTBOX_HANDOFFS.add(len(events), {"consumer": WEBHOOKS_CONSUMER})
328
+
329
+
330
+ async def _claim(
331
+ sessions: async_sessionmaker[AsyncSession],
332
+ settings: WebhookSettings,
333
+ signing_keys: SigningKeys,
334
+ now: datetime,
335
+ vault: VaultKeys | None = None,
336
+ ) -> list[_Claimed]:
337
+ d, w = WEBHOOK_DELIVERIES, WEBHOOKS
338
+ async with sessions() as session, session.begin(), rls_scope(session, bypass=True):
339
+ due = list(
340
+ await session.execute(
341
+ select(d.c.domain_id, d.c.id)
342
+ .where(d.c.status == PENDING, d.c.next_attempt_at <= now)
343
+ .order_by(d.c.next_attempt_at, d.c.id)
344
+ .limit(settings.batch)
345
+ .with_for_update(skip_locked=True)
346
+ )
347
+ )
348
+ if not due:
349
+ return []
350
+ keys = [(row.domain_id, row.id) for row in due]
351
+ await session.execute(
352
+ update(d)
353
+ .where(tuple_(d.c.domain_id, d.c.id).in_(keys))
354
+ .values(next_attempt_at=now + timedelta(seconds=settings.lease_s))
355
+ )
356
+ result = await session.execute(
357
+ select(
358
+ d.c.domain_id,
359
+ d.c.id,
360
+ w.c.url,
361
+ w.c.secret_hash,
362
+ w.c.sealed_key,
363
+ w.c.clearances,
364
+ d.c.event_type,
365
+ d.c.event_id,
366
+ d.c.payload,
367
+ d.c.attempts,
368
+ )
369
+ .join(w, and_(w.c.domain_id == d.c.domain_id, w.c.id == d.c.webhook_id))
370
+ .where(tuple_(d.c.domain_id, d.c.id).in_(keys))
371
+ .order_by(d.c.id)
372
+ )
373
+ rows = list(result)
374
+ values = await _vault_values(session, rows, vault)
375
+ claimed = []
376
+ for row in rows:
377
+ opened = signing_keys.open(StoredKey(row.secret_hash, row.sealed_key))
378
+ payload = disclose(
379
+ row.payload,
380
+ tuple(row.clearances or ()),
381
+ values.get(row.domain_id, {}),
382
+ masked=settings.masked_value,
383
+ erased=settings.erased_value,
384
+ )
385
+ claimed.append(
386
+ _Claimed(
387
+ row.domain_id,
388
+ row.id,
389
+ row.url,
390
+ None if opened is None else opened.key,
391
+ row.event_type,
392
+ payload,
393
+ row.attempts,
394
+ row.event_id,
395
+ )
396
+ )
397
+ return claimed
398
+
399
+
400
+ async def _vault_values(
401
+ session: AsyncSession, rows: Sequence[Any], vault: VaultKeys | None
402
+ ) -> dict[str, dict[str, VaultValue]]:
403
+ """Per Domain, the vault values the claimed deliveries disclose to their webhooks."""
404
+ tokens: dict[str, set[str]] = defaultdict(set)
405
+ for row in rows:
406
+ tokens[row.domain_id] |= cleared_tokens(row.payload, tuple(row.clearances or ()))
407
+ return {
408
+ domain_id: await open_values(session, domain_id, wanted, vault)
409
+ for domain_id, wanted in tokens.items()
410
+ if wanted
411
+ }
412
+
413
+
414
+ async def _attempt(
415
+ claimed: _Claimed,
416
+ settings: WebhookSettings,
417
+ send: Sender,
418
+ clock: Clock,
419
+ resolve: Resolver,
420
+ ) -> _Outcome:
421
+ if claimed.key is None:
422
+ return _Outcome(
423
+ claimed,
424
+ _after_failure(claimed, settings),
425
+ None,
426
+ "the signing key cannot be opened: it was sealed under a key that is neither "
427
+ "webhooks.encryption_key nor in webhooks.previous_encryption_keys",
428
+ )
429
+ body = json.dumps(claimed.payload, ensure_ascii=False, separators=(",", ":")).encode()
430
+ headers = {
431
+ "Content-Type": "application/json",
432
+ settings.signature_header: signature(claimed.key, int(clock().timestamp()), body),
433
+ settings.event_header: claimed.event_type,
434
+ settings.delivery_header: str(claimed.id),
435
+ settings.idempotency_header: f"evt_{claimed.event_id}",
436
+ }
437
+ try:
438
+ # One deadline for the whole attempt: the lookup and every address the sender tries.
439
+ async with asyncio.timeout(settings.timeout_s):
440
+ target = await check_target(claimed.url, settings, resolve)
441
+ if target.problem is not None:
442
+ return _Outcome(claimed, DEAD, None, f"target refused: {target.problem}")
443
+ status = await send(claimed.url, body, headers, settings.timeout_s, target.addresses)
444
+ except TimeoutError:
445
+ message = f"timed out after {settings.timeout_s:g}s"
446
+ return _Outcome(claimed, _after_failure(claimed, settings), None, message, timed_out=True)
447
+ except Exception as error: # any transport failure is retried
448
+ message = f"{type(error).__name__}: {error}"[:ERROR_LENGTH]
449
+ return _Outcome(claimed, _after_failure(claimed, settings), None, message)
450
+ if 200 <= status < 300:
451
+ return _Outcome(claimed, DELIVERED, status, None)
452
+ return _Outcome(claimed, _after_failure(claimed, settings), status, f"HTTP {status}")
453
+
454
+
455
+ async def _attempt_all(
456
+ claimed: Sequence[_Claimed],
457
+ settings: WebhookSettings,
458
+ send: Sender,
459
+ clock: Clock,
460
+ resolve: Resolver,
461
+ ) -> tuple[list[_Outcome], list[_Claimed]]:
462
+ """Attempt the claimed deliveries, `concurrency` at a time, starting none after
463
+ `pass_timeout_s`; returns the outcomes and the deliveries not attempted."""
464
+ loop = asyncio.get_running_loop()
465
+ deadline = loop.time() + settings.pass_timeout_s
466
+ gate = asyncio.Semaphore(settings.concurrency)
467
+
468
+ async def one(delivery: _Claimed) -> _Outcome | None:
469
+ async with gate:
470
+ if loop.time() >= deadline:
471
+ return None
472
+ return await _attempt(delivery, settings, send, clock, resolve)
473
+
474
+ results = await asyncio.gather(*(one(delivery) for delivery in claimed))
475
+ outcomes = [result for result in results if result is not None]
476
+ left = [delivery for delivery, result in zip(claimed, results, strict=True) if result is None]
477
+ return outcomes, left
478
+
479
+
480
+ def _after_failure(claimed: _Claimed, settings: WebhookSettings) -> str:
481
+ return DEAD if claimed.attempts + 1 >= settings.max_attempts else PENDING
482
+
483
+
484
+ async def _record(
485
+ sessions: async_sessionmaker[AsyncSession],
486
+ outcomes: Sequence[_Outcome],
487
+ settings: WebhookSettings,
488
+ now: datetime,
489
+ ) -> None:
490
+ d = WEBHOOK_DELIVERIES
491
+ async with sessions() as session, session.begin(), rls_scope(session, bypass=True):
492
+ for outcome in outcomes:
493
+ attempts = outcome.claimed.attempts + 1
494
+ await session.execute(
495
+ update(d)
496
+ .where(d.c.domain_id == outcome.claimed.domain_id, d.c.id == outcome.claimed.id)
497
+ .values(
498
+ status=outcome.status,
499
+ attempts=attempts,
500
+ last_status=outcome.http_status,
501
+ last_error=outcome.error,
502
+ next_attempt_at=now + backoff(attempts, settings),
503
+ delivered_at=now if outcome.status == DELIVERED else None,
504
+ updated_at=now,
505
+ )
506
+ )
507
+
508
+
509
+ async def _release(
510
+ sessions: async_sessionmaker[AsyncSession], left: Sequence[_Claimed], now: datetime
511
+ ) -> None:
512
+ """Make claimed deliveries that were not attempted due again, ending their lease."""
513
+ d = WEBHOOK_DELIVERIES
514
+ keys = [(delivery.domain_id, delivery.id) for delivery in left]
515
+ async with sessions() as session, session.begin(), rls_scope(session, bypass=True):
516
+ await session.execute(
517
+ update(d)
518
+ .where(tuple_(d.c.domain_id, d.c.id).in_(keys), d.c.status == PENDING)
519
+ .values(next_attempt_at=now)
520
+ )
521
+
522
+
523
+ PRUNE_BACKLOG_CAP = 100_000 # prunable events counted for lorebase.outbox.prune.backlog
524
+
525
+
526
+ async def _prune(
527
+ sessions: async_sessionmaker[AsyncSession],
528
+ settings: WebhookSettings,
529
+ outbox: OutboxSettings,
530
+ now: datetime,
531
+ monotonic: Callable[[], float] = time.monotonic,
532
+ ) -> int:
533
+ before = now - timedelta(seconds=settings.retention_s)
534
+ d = WEBHOOK_DELIVERIES
535
+ async with sessions() as session, session.begin(), rls_scope(session, bypass=True):
536
+ result = await session.execute(
537
+ delete(d)
538
+ .where(d.c.status.in_([DELIVERED, DEAD]), d.c.updated_at < before)
539
+ .returning(d.c.id)
540
+ )
541
+ pruned = len(result.all())
542
+ cutoffs = {
543
+ "before": now - timedelta(seconds=outbox.retention_s),
544
+ "expire_before": now - timedelta(seconds=outbox.max_age_s),
545
+ }
546
+ deadline = monotonic() + outbox.prune_budget_s
547
+ handed = expired = 0
548
+ drained = False
549
+ while True: # batches, each in its own transaction, until drained or out of budget
550
+ async with sessions() as session, session.begin(), rls_scope(session, bypass=True):
551
+ batch_handed, batch_expired = await OutboxRepository(session).prune(
552
+ **cutoffs, limit=outbox.prune_batch
553
+ )
554
+ # Counted per committed batch, so a later failing batch keeps these counts.
555
+ if batch_handed:
556
+ OUTBOX_PRUNED.add(batch_handed, {"reason": "retention"})
557
+ if batch_expired:
558
+ OUTBOX_PRUNED.add(batch_expired, {"reason": "expired"})
559
+ handed += batch_handed
560
+ expired += batch_expired
561
+ drained = max(batch_handed, batch_expired) < outbox.prune_batch
562
+ if drained or monotonic() >= deadline:
563
+ break
564
+ async with sessions() as session, session.begin(), rls_scope(session, bypass=True):
565
+ stale = await expire_deferred(session, cutoffs["expire_before"], limit=outbox.prune_batch)
566
+ if stale:
567
+ logger.warning(
568
+ "deferred Operation events expired before their Proposal merged",
569
+ extra={"expired": stale, "max_age_s": outbox.max_age_s},
570
+ )
571
+ backlog = 0
572
+ if not drained: # out of budget: count what is left (a drained pass left nothing)
573
+ async with sessions() as session, session.begin(), rls_scope(session, bypass=True):
574
+ backlog = await OutboxRepository(session).prunable(**cutoffs, cap=PRUNE_BACKLOG_CAP)
575
+ OUTBOX_PRUNE_BACKLOG.set(backlog)
576
+ if expired:
577
+ logger.warning(
578
+ "outbox events expired before every consumer handed them off",
579
+ extra={"expired": expired, "max_age_s": outbox.max_age_s},
580
+ )
581
+ return pruned + handed + expired
582
+
583
+
584
+ async def deliver_pending(
585
+ sessions: async_sessionmaker[AsyncSession],
586
+ settings: WebhookSettings,
587
+ send: Sender,
588
+ *,
589
+ writes: WriteSettings,
590
+ outbox: OutboxSettings | None = None,
591
+ vault: VaultKeys | None = None,
592
+ clock: Clock = utc_now,
593
+ resolve: Resolver = resolve_host,
594
+ monotonic: Callable[[], float] = time.monotonic,
595
+ ) -> DeliveryReport:
596
+ """One delivery pass (see module doc); call it periodically. `outbox` sets the retention
597
+ of outbox events (default: code defaults); `vault` opens the vault values that webhooks
598
+ cleared for them receive (without it they receive `masked_value`). `monotonic` times the
599
+ pruning budget (`outbox.prune_budget_s`)."""
600
+ report = DeliveryReport()
601
+ keys = SigningKeys.from_settings(settings)
602
+ if keys.sealing:
603
+ async with sessions() as session, session.begin(), rls_scope(session, bypass=True):
604
+ report.sealed = await seal_stored_keys(session, keys, limit=settings.batch)
605
+ await _fan_out(sessions, settings, writes, clock(), report)
606
+ claimed = await _claim(sessions, settings, keys, clock(), vault)
607
+ outcomes, left = await _attempt_all(claimed, settings, send, clock, resolve)
608
+ if outcomes:
609
+ await _record(sessions, outcomes, settings, clock())
610
+ if left:
611
+ await _release(sessions, left, clock())
612
+ logger.warning(
613
+ "webhook pass out of time",
614
+ extra={"released": len(left), "pass_timeout_s": settings.pass_timeout_s},
615
+ )
616
+ report.attempted = len(outcomes)
617
+ report.released = len(left)
618
+ report.timed_out = sum(o.timed_out for o in outcomes)
619
+ report.delivered = sum(o.status == DELIVERED for o in outcomes)
620
+ report.dead = sum(o.status == DEAD for o in outcomes)
621
+ report.retrying = sum(o.status == PENDING for o in outcomes)
622
+ for outcome in outcomes:
623
+ WEBHOOK_OUTCOMES.add(1, {"outcome": _OUTCOMES[outcome.status]})
624
+ if outcome.timed_out:
625
+ WEBHOOK_TIMEOUTS.add(1)
626
+ if outcome.status != DELIVERED:
627
+ logger.warning(
628
+ "webhook delivery failed",
629
+ extra={
630
+ "delivery": outcome.claimed.id,
631
+ "domain": outcome.claimed.domain_id,
632
+ "status": outcome.status,
633
+ "error": outcome.error,
634
+ },
635
+ )
636
+ report.pruned = await _prune(sessions, settings, outbox or OutboxSettings(), clock(), monotonic)
637
+ return report