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.
- lorebase_store/__init__.py +1 -0
- lorebase_store/_migrations/env.py +106 -0
- lorebase_store/_migrations/script.py.mako +27 -0
- lorebase_store/_migrations/versions/0001_initial_schema.py +1071 -0
- lorebase_store/_migrations/versions/0002_merge_attempt_details.py +74 -0
- lorebase_store/_migrations/versions/0003_webhooks.py +127 -0
- lorebase_store/_migrations/versions/0004_defer_revision_references.py +38 -0
- lorebase_store/_migrations/versions/0005_security_definer_reads.py +396 -0
- lorebase_store/_migrations/versions/0006_api_key_usage.py +48 -0
- lorebase_store/_migrations/versions/0007_document_extraction.py +154 -0
- lorebase_store/_migrations/versions/0008_definer_argument_checks.py +178 -0
- lorebase_store/_migrations/versions/0009_api_key_networks.py +33 -0
- lorebase_store/_migrations/versions/0010_overlay_branches.py +582 -0
- lorebase_store/_migrations/versions/0011_branch_scoped_search_index.py +229 -0
- lorebase_store/_migrations/versions/0012_overlay_neighborhood_lookups.py +185 -0
- lorebase_store/_migrations/versions/0013_web_sessions.py +72 -0
- lorebase_store/_migrations/versions/0014_embedding_jobs.py +70 -0
- lorebase_store/_migrations/versions/0015_append_only_audit_log.py +83 -0
- lorebase_store/_migrations/versions/0016_sealed_webhook_keys.py +55 -0
- lorebase_store/_migrations/versions/0017_background_branch_copies.py +77 -0
- lorebase_store/_migrations/versions/0018_search_lexicon.py +477 -0
- lorebase_store/_migrations/versions/0019_vault_bindings.py +56 -0
- lorebase_store/_migrations/versions/0020_outbox_consumers.py +150 -0
- lorebase_store/_migrations/versions/0021_schema_suggestions.py +114 -0
- lorebase_store/_migrations/versions/0022_read_audit_and_shared_rate_limits.py +133 -0
- lorebase_store/_migrations/versions/0023_agent_trace.py +96 -0
- lorebase_store/_migrations/versions/0024_embedding_template_keys.py +205 -0
- lorebase_store/_migrations/versions/0025_snapshot_refs.py +35 -0
- lorebase_store/_migrations/versions/0026_plain_reverse_link_index.py +112 -0
- lorebase_store/_migrations/versions/0027_vault_rotation_and_tombstones.py +145 -0
- lorebase_store/_migrations/versions/0028_operation_deferred_events.py +71 -0
- lorebase_store/_migrations/versions/0029_agent_trace_sessions.py +108 -0
- lorebase_store/_migrations/versions/0030_path_fanout.py +31 -0
- lorebase_store/_migrations/versions/0031_branch_builds.py +91 -0
- lorebase_store/_migrations/versions/0032_lineage_revisions.py +35 -0
- lorebase_store/_migrations/versions/0033_vault_moved_tokens.py +30 -0
- lorebase_store/_migrations/versions/0034_inference_endpoint_modes.py +73 -0
- lorebase_store/_migrations/versions/0035_search_truncation.py +104 -0
- lorebase_store/_migrations/versions/0036_lineage_maps.py +325 -0
- lorebase_store/_migrations/versions/0037_bm25_search.py +346 -0
- lorebase_store/auth/__init__.py +52 -0
- lorebase_store/auth/api_keys.py +161 -0
- lorebase_store/auth/audit.py +382 -0
- lorebase_store/auth/checkpoints.py +185 -0
- lorebase_store/auth/grants.py +161 -0
- lorebase_store/auth/principals.py +158 -0
- lorebase_store/auth/scope.py +70 -0
- lorebase_store/auth/sessions.py +153 -0
- lorebase_store/auth/unit.py +53 -0
- lorebase_store/auth/usage.py +70 -0
- lorebase_store/blobs.py +73 -0
- lorebase_store/bm25.py +346 -0
- lorebase_store/bulk.py +147 -0
- lorebase_store/bundle.py +1830 -0
- lorebase_store/changefeed.py +261 -0
- lorebase_store/commit/__init__.py +42 -0
- lorebase_store/commit/changes.py +67 -0
- lorebase_store/commit/errors.py +60 -0
- lorebase_store/commit/migrations.py +335 -0
- lorebase_store/commit/pipeline.py +754 -0
- lorebase_store/commit/schema.py +123 -0
- lorebase_store/db.py +58 -0
- lorebase_store/definer.py +50 -0
- lorebase_store/digests.py +99 -0
- lorebase_store/embeddings.py +444 -0
- lorebase_store/endpoint_modes.py +59 -0
- lorebase_store/extraction/__init__.py +61 -0
- lorebase_store/extraction/items.py +187 -0
- lorebase_store/extraction/materialize.py +364 -0
- lorebase_store/extraction/proposal.py +65 -0
- lorebase_store/extraction/review.py +168 -0
- lorebase_store/gc.py +970 -0
- lorebase_store/indexes.py +1033 -0
- lorebase_store/jobs.py +651 -0
- lorebase_store/keyring.py +72 -0
- lorebase_store/layout.py +335 -0
- lorebase_store/materialize/__init__.py +103 -0
- lorebase_store/materialize/apply.py +436 -0
- lorebase_store/materialize/background.py +1184 -0
- lorebase_store/materialize/branch.py +268 -0
- lorebase_store/materialize/build.py +536 -0
- lorebase_store/materialize/derived.py +540 -0
- lorebase_store/materialize/lineage_backfill.py +502 -0
- lorebase_store/materialize/links.py +266 -0
- lorebase_store/materialize/overlay.py +292 -0
- lorebase_store/materialize/paths.py +315 -0
- lorebase_store/materialize/rebuild.py +326 -0
- lorebase_store/materialize/rows.py +153 -0
- lorebase_store/materialize/sensitivity.py +66 -0
- lorebase_store/materialize/snapshot.py +205 -0
- lorebase_store/materialize/stale.py +444 -0
- lorebase_store/materialize/verify.py +321 -0
- lorebase_store/merging/__init__.py +39 -0
- lorebase_store/merging/attempts.py +334 -0
- lorebase_store/merging/lineage.py +123 -0
- lorebase_store/merging/service.py +652 -0
- lorebase_store/migrations.py +133 -0
- lorebase_store/models/__init__.py +136 -0
- lorebase_store/models/base.py +64 -0
- lorebase_store/models/enums.py +132 -0
- lorebase_store/models/governance.py +268 -0
- lorebase_store/models/ingestion.py +288 -0
- lorebase_store/models/materialized.py +277 -0
- lorebase_store/models/proposals.py +195 -0
- lorebase_store/models/queues.py +87 -0
- lorebase_store/models/tracing.py +116 -0
- lorebase_store/models/versioning.py +222 -0
- lorebase_store/models/webhooks.py +96 -0
- lorebase_store/nodestore.py +384 -0
- lorebase_store/objectstore.py +162 -0
- lorebase_store/outbox_events.py +166 -0
- lorebase_store/profiling/__init__.py +58 -0
- lorebase_store/profiling/delimited.py +109 -0
- lorebase_store/profiling/postgres.py +211 -0
- lorebase_store/profiling/record_files.py +127 -0
- lorebase_store/profiling/records.py +92 -0
- lorebase_store/profiling/sql_databases.py +298 -0
- lorebase_store/ratelimit.py +129 -0
- lorebase_store/reads.py +1491 -0
- lorebase_store/replicas.py +434 -0
- lorebase_store/repositories/__init__.py +152 -0
- lorebase_store/repositories/changesets.py +59 -0
- lorebase_store/repositories/deferred_events.py +142 -0
- lorebase_store/repositories/documents.py +305 -0
- lorebase_store/repositories/domains.py +97 -0
- lorebase_store/repositories/events.py +205 -0
- lorebase_store/repositories/index_view.py +489 -0
- lorebase_store/repositories/invocations.py +68 -0
- lorebase_store/repositories/materializations.py +276 -0
- lorebase_store/repositories/materialized.py +1129 -0
- lorebase_store/repositories/merges.py +417 -0
- lorebase_store/repositories/proposal_items.py +199 -0
- lorebase_store/repositories/proposals.py +184 -0
- lorebase_store/repositories/provenance.py +77 -0
- lorebase_store/repositories/refs.py +452 -0
- lorebase_store/repositories/revisions.py +273 -0
- lorebase_store/repositories/scope.py +33 -0
- lorebase_store/repositories/source_runs.py +123 -0
- lorebase_store/repositories/suggestions.py +638 -0
- lorebase_store/runtime.py +87 -0
- lorebase_store/schema_cache.py +50 -0
- lorebase_store/settings.py +879 -0
- lorebase_store/storage.py +1478 -0
- lorebase_store/telemetry.py +142 -0
- lorebase_store/testing.py +108 -0
- lorebase_store/tracing.py +505 -0
- lorebase_store/traverse.py +578 -0
- lorebase_store/vault/__init__.py +50 -0
- lorebase_store/vault/keys.py +216 -0
- lorebase_store/vault/quotes.py +133 -0
- lorebase_store/vault/rotation.py +209 -0
- lorebase_store/vault/settings.py +152 -0
- lorebase_store/vault/store.py +987 -0
- lorebase_store/vault/transfer.py +558 -0
- lorebase_store/webhooks/__init__.py +24 -0
- lorebase_store/webhooks/delivery.py +637 -0
- lorebase_store/webhooks/keys.py +126 -0
- lorebase_store/webhooks/repository.py +142 -0
- lorebase_store/webhooks/settings.py +138 -0
- lorebase_store/webhooks/signing.py +50 -0
- lorebase_store-0.2.3.dist-info/METADATA +13 -0
- lorebase_store-0.2.3.dist-info/RECORD +163 -0
- 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
|