lorebase-keeper 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_keeper/__init__.py +1 -0
- lorebase_keeper/__main__.py +456 -0
- lorebase_keeper/agents/__init__.py +1 -0
- lorebase_keeper/agents/budget.py +247 -0
- lorebase_keeper/agents/context.py +529 -0
- lorebase_keeper/agents/explain.py +582 -0
- lorebase_keeper/agents/feed.py +259 -0
- lorebase_keeper/agents/markdown.py +219 -0
- lorebase_keeper/agents/pack.py +403 -0
- lorebase_keeper/agents/settings.py +111 -0
- lorebase_keeper/app.py +373 -0
- lorebase_keeper/auth/__init__.py +4 -0
- lorebase_keeper/auth/api_keys.py +127 -0
- lorebase_keeper/auth/audit.py +356 -0
- lorebase_keeper/auth/cache.py +53 -0
- lorebase_keeper/auth/config.py +618 -0
- lorebase_keeper/auth/deps.py +183 -0
- lorebase_keeper/auth/keyring.py +14 -0
- lorebase_keeper/auth/oidc.py +242 -0
- lorebase_keeper/auth/permissions.py +142 -0
- lorebase_keeper/auth/ports.py +212 -0
- lorebase_keeper/auth/principals.py +377 -0
- lorebase_keeper/auth/provider.py +250 -0
- lorebase_keeper/auth/rbac.py +247 -0
- lorebase_keeper/auth/read_audit.py +357 -0
- lorebase_keeper/auth/runtime.py +113 -0
- lorebase_keeper/auth/sessions.py +452 -0
- lorebase_keeper/bootstrap.py +151 -0
- lorebase_keeper/client_address.py +129 -0
- lorebase_keeper/context.py +226 -0
- lorebase_keeper/db.py +93 -0
- lorebase_keeper/deps.py +47 -0
- lorebase_keeper/errors.py +257 -0
- lorebase_keeper/health.py +61 -0
- lorebase_keeper/kindhealth/__init__.py +6 -0
- lorebase_keeper/kindhealth/config.py +51 -0
- lorebase_keeper/kindhealth/markdown.py +163 -0
- lorebase_keeper/kindhealth/models.py +179 -0
- lorebase_keeper/kindhealth/report.py +154 -0
- lorebase_keeper/kindhealth/scan.py +512 -0
- lorebase_keeper/middleware.py +285 -0
- lorebase_keeper/pagination.py +87 -0
- lorebase_keeper/ratelimit/__init__.py +10 -0
- lorebase_keeper/ratelimit/bucket.py +118 -0
- lorebase_keeper/ratelimit/config.py +283 -0
- lorebase_keeper/ratelimit/enforce.py +393 -0
- lorebase_keeper/ratelimit/limiter.py +149 -0
- lorebase_keeper/ratelimit/quotas.py +204 -0
- lorebase_keeper/ratelimit/shared.py +242 -0
- lorebase_keeper/readiness.py +257 -0
- lorebase_keeper/reading/__init__.py +1 -0
- lorebase_keeper/reading/caching.py +251 -0
- lorebase_keeper/reading/compiler.py +678 -0
- lorebase_keeper/reading/cursors.py +56 -0
- lorebase_keeper/reading/diff.py +408 -0
- lorebase_keeper/reading/graph.py +268 -0
- lorebase_keeper/reading/history.py +152 -0
- lorebase_keeper/reading/points.py +410 -0
- lorebase_keeper/reading/problems.py +74 -0
- lorebase_keeper/reading/record_history.py +722 -0
- lorebase_keeper/reading/records.py +780 -0
- lorebase_keeper/reading/requests.py +107 -0
- lorebase_keeper/reading/runtime.py +145 -0
- lorebase_keeper/reading/search.py +708 -0
- lorebase_keeper/reading/sensitivity.py +882 -0
- lorebase_keeper/reading/settings.py +339 -0
- lorebase_keeper/reading/snapshots.py +148 -0
- lorebase_keeper/reading/tree.py +98 -0
- lorebase_keeper/reading/views.py +264 -0
- lorebase_keeper/reading/visibility.py +93 -0
- lorebase_keeper/routes/__init__.py +1 -0
- lorebase_keeper/routes/admin.py +597 -0
- lorebase_keeper/routes/agents.py +160 -0
- lorebase_keeper/routes/branches.py +438 -0
- lorebase_keeper/routes/derivations.py +215 -0
- lorebase_keeper/routes/documents.py +497 -0
- lorebase_keeper/routes/domain_api.py +59 -0
- lorebase_keeper/routes/domains.py +220 -0
- lorebase_keeper/routes/events.py +187 -0
- lorebase_keeper/routes/kind_health.py +166 -0
- lorebase_keeper/routes/operations.py +392 -0
- lorebase_keeper/routes/proposal_items.py +563 -0
- lorebase_keeper/routes/proposals.py +1107 -0
- lorebase_keeper/routes/read.py +493 -0
- lorebase_keeper/routes/revisions.py +523 -0
- lorebase_keeper/routes/schema.py +682 -0
- lorebase_keeper/routes/schema_inference.py +437 -0
- lorebase_keeper/routes/schema_suggestions.py +328 -0
- lorebase_keeper/routes/sessions.py +286 -0
- lorebase_keeper/routes/source_preview.py +232 -0
- lorebase_keeper/routes/sources.py +200 -0
- lorebase_keeper/routes/trace.py +506 -0
- lorebase_keeper/routes/vault.py +332 -0
- lorebase_keeper/routes/webhooks.py +298 -0
- lorebase_keeper/routes/whoami.py +88 -0
- lorebase_keeper/services/__init__.py +1 -0
- lorebase_keeper/services/agent_writes.py +1035 -0
- lorebase_keeper/services/branches.py +337 -0
- lorebase_keeper/services/config.py +431 -0
- lorebase_keeper/services/derivation_preview.py +279 -0
- lorebase_keeper/services/document_urls.py +114 -0
- lorebase_keeper/services/domains.py +118 -0
- lorebase_keeper/services/errors.py +196 -0
- lorebase_keeper/services/operation_catalog.py +806 -0
- lorebase_keeper/services/operation_events.py +199 -0
- lorebase_keeper/services/operations.py +1229 -0
- lorebase_keeper/services/pii_import.py +222 -0
- lorebase_keeper/services/proposal_reads.py +158 -0
- lorebase_keeper/services/proposals.py +850 -0
- lorebase_keeper/services/reads.py +286 -0
- lorebase_keeper/services/schema.py +601 -0
- lorebase_keeper/services/schema_inference.py +390 -0
- lorebase_keeper/services/schema_suggestions.py +418 -0
- lorebase_keeper/services/source_preview.py +127 -0
- lorebase_keeper/settings.py +313 -0
- lorebase_keeper/tracing/__init__.py +1 -0
- lorebase_keeper/tracing/capture.py +350 -0
- lorebase_keeper/tracing/config.py +157 -0
- lorebase_keeper/tracing/explain.py +97 -0
- lorebase_keeper/tracing/middleware.py +359 -0
- lorebase_keeper/tracing/notes.py +80 -0
- lorebase_keeper/tracing/recorder.py +180 -0
- lorebase_keeper-0.2.3.dist-info/METADATA +21 -0
- lorebase_keeper-0.2.3.dist-info/RECORD +126 -0
- lorebase_keeper-0.2.3.dist-info/WHEEL +4 -0
- lorebase_keeper-0.2.3.dist-info/entry_points.txt +2 -0
|
@@ -0,0 +1,257 @@
|
|
|
1
|
+
"""Readiness checks behind `/readyz`.
|
|
2
|
+
|
|
3
|
+
A check is an async callable with a `name`. The database check connects, runs `SELECT 1` and
|
|
4
|
+
compares the Alembic heads the code expects (`packaged_heads`, from the store) with the revision
|
|
5
|
+
stored in the `alembic_version` table, so a service whose migrations are missing or behind is not
|
|
6
|
+
ready. Check failures are logged in full and reported to callers as a short
|
|
7
|
+
reason that never contains connection strings or credentials.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import asyncio
|
|
13
|
+
import functools
|
|
14
|
+
import importlib
|
|
15
|
+
import logging
|
|
16
|
+
import re
|
|
17
|
+
from collections.abc import Awaitable, Callable, Collection, Mapping, Sequence
|
|
18
|
+
from dataclasses import dataclass
|
|
19
|
+
from enum import StrEnum
|
|
20
|
+
from typing import Any, Protocol
|
|
21
|
+
|
|
22
|
+
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
|
|
23
|
+
|
|
24
|
+
from lorebase_platform.secrets import SecretResolutionError, resolve_secret
|
|
25
|
+
from lorebase_platform.settings import ConfigError, Settings
|
|
26
|
+
from lorebase_store import bm25
|
|
27
|
+
from lorebase_store.migrations import head_revisions
|
|
28
|
+
from lorebase_store.replicas import ReplicaState
|
|
29
|
+
|
|
30
|
+
logger = logging.getLogger("lorebase.keeper.readiness")
|
|
31
|
+
|
|
32
|
+
MigrationHeads = Callable[[], Collection[str]]
|
|
33
|
+
"""Returns the Alembic head revisions the running code expects; empty skips the comparison."""
|
|
34
|
+
|
|
35
|
+
_DRIVER_SUFFIX = re.compile(r"^(postgres(?:ql)?)\+\w+://")
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@functools.cache
|
|
39
|
+
def packaged_heads() -> tuple[str, ...]:
|
|
40
|
+
"""Alembic head revisions of the installed store code (read once: they cannot change)."""
|
|
41
|
+
return head_revisions()
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class CheckStatus(StrEnum):
|
|
45
|
+
"""Outcome of one readiness check."""
|
|
46
|
+
|
|
47
|
+
OK = "ok"
|
|
48
|
+
DEGRADED = "degraded"
|
|
49
|
+
NOT_CONFIGURED = "not_configured"
|
|
50
|
+
FAILED = "failed"
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
@dataclass(frozen=True)
|
|
54
|
+
class CheckResult:
|
|
55
|
+
"""Status of a check and an optional short reason."""
|
|
56
|
+
|
|
57
|
+
status: CheckStatus
|
|
58
|
+
detail: str | None = None
|
|
59
|
+
|
|
60
|
+
@property
|
|
61
|
+
def passed(self) -> bool:
|
|
62
|
+
"""Failed checks make the service not ready; degraded and unconfigured ones do not."""
|
|
63
|
+
return self.status is not CheckStatus.FAILED
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
class ReadinessCheck(Protocol):
|
|
67
|
+
"""One probe of a dependency."""
|
|
68
|
+
|
|
69
|
+
name: str
|
|
70
|
+
|
|
71
|
+
async def __call__(self) -> CheckResult:
|
|
72
|
+
"""Run the probe."""
|
|
73
|
+
...
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
class NotConfiguredCheck:
|
|
77
|
+
"""Placeholder reporting that a dependency is not part of this deployment."""
|
|
78
|
+
|
|
79
|
+
def __init__(self, name: str) -> None:
|
|
80
|
+
self.name = name
|
|
81
|
+
|
|
82
|
+
async def __call__(self) -> CheckResult:
|
|
83
|
+
return CheckResult(CheckStatus.NOT_CONFIGURED)
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def asyncpg_dsn(dsn: str) -> str:
|
|
87
|
+
"""Connection string without a SQLAlchemy driver suffix (`postgresql+asyncpg://` etc.)."""
|
|
88
|
+
return _DRIVER_SUFFIX.sub(r"\1://", dsn)
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _asyncpg_connect(dsn: str, timeout: float) -> Awaitable[Any]:
|
|
92
|
+
asyncpg = importlib.import_module("asyncpg")
|
|
93
|
+
return asyncpg.connect(dsn, timeout=timeout) # type: ignore[no-any-return]
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
class DatabaseCheck:
|
|
97
|
+
"""Postgres is reachable and its migrations match the code."""
|
|
98
|
+
|
|
99
|
+
name = "database"
|
|
100
|
+
|
|
101
|
+
def __init__(
|
|
102
|
+
self,
|
|
103
|
+
dsn: str,
|
|
104
|
+
*,
|
|
105
|
+
timeout_s: float,
|
|
106
|
+
expected_heads: MigrationHeads | None = None,
|
|
107
|
+
connect: Callable[[str, float], Awaitable[Any]] = _asyncpg_connect,
|
|
108
|
+
) -> None:
|
|
109
|
+
self._dsn = asyncpg_dsn(dsn)
|
|
110
|
+
self._timeout_s = timeout_s
|
|
111
|
+
self._expected_heads = expected_heads
|
|
112
|
+
self._connect = connect
|
|
113
|
+
|
|
114
|
+
@property
|
|
115
|
+
def expected_heads(self) -> frozenset[str]:
|
|
116
|
+
"""Revisions the database must be at; empty when migrations are not verified."""
|
|
117
|
+
return frozenset(self._expected_heads()) if self._expected_heads else frozenset()
|
|
118
|
+
|
|
119
|
+
async def __call__(self) -> CheckResult:
|
|
120
|
+
connection = await self._connect(self._dsn, self._timeout_s)
|
|
121
|
+
try:
|
|
122
|
+
await connection.fetchval("SELECT 1")
|
|
123
|
+
expected = self.expected_heads
|
|
124
|
+
if not expected:
|
|
125
|
+
return CheckResult(CheckStatus.OK, "migration revision not verified")
|
|
126
|
+
if await connection.fetchval("SELECT to_regclass('alembic_version')") is None:
|
|
127
|
+
return CheckResult(CheckStatus.FAILED, "migrations have not been applied")
|
|
128
|
+
rows = await connection.fetch("SELECT version_num FROM alembic_version")
|
|
129
|
+
finally:
|
|
130
|
+
await connection.close()
|
|
131
|
+
current = frozenset(row["version_num"] for row in rows)
|
|
132
|
+
if current != expected:
|
|
133
|
+
return CheckResult(
|
|
134
|
+
CheckStatus.FAILED,
|
|
135
|
+
f"migrations at {_join(current)}, expected {_join(expected)}",
|
|
136
|
+
)
|
|
137
|
+
return CheckResult(CheckStatus.OK)
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
class ReplicaProbe(Protocol):
|
|
141
|
+
"""What the replica check needs from the store's replica set."""
|
|
142
|
+
|
|
143
|
+
async def probe(self, timeout_s: float) -> list[ReplicaState]:
|
|
144
|
+
"""Probe every replica."""
|
|
145
|
+
...
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
class ReplicaCheck:
|
|
149
|
+
"""State of the read replicas.
|
|
150
|
+
|
|
151
|
+
Replicas that are unreachable or lag more than `max_lag_ms` degrade the service without
|
|
152
|
+
taking it out of rotation, since reads fall back to the primary, unless `required`: reads
|
|
153
|
+
are served by replicas only (`prefer_replica` with no fallback) and none is usable. The
|
|
154
|
+
detail names each replica by its configuration name and `host:port`, never a credential.
|
|
155
|
+
"""
|
|
156
|
+
|
|
157
|
+
name = "database_replicas"
|
|
158
|
+
|
|
159
|
+
def __init__(
|
|
160
|
+
self,
|
|
161
|
+
replicas: ReplicaProbe,
|
|
162
|
+
*,
|
|
163
|
+
timeout_s: float,
|
|
164
|
+
max_lag_ms: int,
|
|
165
|
+
required: bool,
|
|
166
|
+
) -> None:
|
|
167
|
+
self._replicas = replicas
|
|
168
|
+
self._timeout_s = timeout_s
|
|
169
|
+
self._max_lag_ms = max_lag_ms
|
|
170
|
+
self._required = required
|
|
171
|
+
|
|
172
|
+
async def __call__(self) -> CheckResult:
|
|
173
|
+
states = await self._replicas.probe(self._timeout_s)
|
|
174
|
+
usable = [s for s in states if s.usable(self._max_lag_ms)]
|
|
175
|
+
detail = "; ".join(self._describe(s) for s in states)
|
|
176
|
+
if len(usable) == len(states):
|
|
177
|
+
return CheckResult(CheckStatus.OK, detail)
|
|
178
|
+
if not usable and self._required:
|
|
179
|
+
return CheckResult(CheckStatus.FAILED, detail)
|
|
180
|
+
return CheckResult(CheckStatus.DEGRADED, detail)
|
|
181
|
+
|
|
182
|
+
def _describe(self, state: ReplicaState) -> str:
|
|
183
|
+
where = f"{state.name} ({state.endpoint})"
|
|
184
|
+
if not state.reachable:
|
|
185
|
+
return f"{where}: unreachable ({state.error})"
|
|
186
|
+
if state.lagging(self._max_lag_ms):
|
|
187
|
+
lag = "unknown" if state.lag_ms is None else f"{state.lag_ms:.0f} ms"
|
|
188
|
+
return f"{where}: lagging ({lag})"
|
|
189
|
+
if state.in_recovery is False:
|
|
190
|
+
return f"{where}: ok, not a standby"
|
|
191
|
+
return f"{where}: ok, lag {state.lag_ms or 0:.0f} ms"
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
class SearchBackendCheck:
|
|
195
|
+
"""Whether `search.backend: bm25` is served: degraded, without taking the service out of
|
|
196
|
+
rotation, while the database has no `pg_search` or no valid BM25 index, when lexical
|
|
197
|
+
search ranks the default way (`lorebase_store.bm25`)."""
|
|
198
|
+
|
|
199
|
+
name = "search_backend"
|
|
200
|
+
|
|
201
|
+
def __init__(self, sessions: async_sessionmaker[AsyncSession], *, definer: bool) -> None:
|
|
202
|
+
self._sessions = sessions
|
|
203
|
+
self._definer = definer
|
|
204
|
+
|
|
205
|
+
async def __call__(self) -> CheckResult:
|
|
206
|
+
if not self._definer:
|
|
207
|
+
reason = "store.reads.use_definer_functions is false"
|
|
208
|
+
else:
|
|
209
|
+
async with self._sessions() as session:
|
|
210
|
+
found = await bm25.status(session)
|
|
211
|
+
if found.ready:
|
|
212
|
+
return CheckResult(CheckStatus.OK, "bm25")
|
|
213
|
+
reason = str(found.reason)
|
|
214
|
+
return CheckResult(
|
|
215
|
+
CheckStatus.DEGRADED,
|
|
216
|
+
f"search.backend is bm25 but {reason}; lexical search ranks with postgres",
|
|
217
|
+
)
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
def _join(revisions: Collection[str]) -> str:
|
|
221
|
+
return ", ".join(sorted(revisions)) or "none"
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
async def run_checks(checks: Sequence[ReadinessCheck], timeout_s: float) -> dict[str, CheckResult]:
|
|
225
|
+
"""Run every check concurrently, each within `timeout_s`."""
|
|
226
|
+
|
|
227
|
+
async def run(check: ReadinessCheck) -> CheckResult:
|
|
228
|
+
try:
|
|
229
|
+
return await asyncio.wait_for(check(), timeout_s)
|
|
230
|
+
except TimeoutError:
|
|
231
|
+
return CheckResult(CheckStatus.FAILED, f"no answer within {timeout_s:g}s")
|
|
232
|
+
except Exception as exc:
|
|
233
|
+
logger.warning("readiness check %s failed", check.name, exc_info=True)
|
|
234
|
+
return CheckResult(CheckStatus.FAILED, type(exc).__name__)
|
|
235
|
+
|
|
236
|
+
results = await asyncio.gather(*(run(check) for check in checks))
|
|
237
|
+
return {check.name: result for check, result in zip(checks, results, strict=True)}
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
def default_checks(
|
|
241
|
+
settings: Settings,
|
|
242
|
+
*,
|
|
243
|
+
section: str,
|
|
244
|
+
dsn_key: str,
|
|
245
|
+
timeout_s: float,
|
|
246
|
+
expected_heads: MigrationHeads | None,
|
|
247
|
+
) -> list[ReadinessCheck]:
|
|
248
|
+
"""The database check when the settings hold a connection string, else a placeholder."""
|
|
249
|
+
raw: Mapping[str, Any] = (settings.model_extra or {}).get(section) or {}
|
|
250
|
+
dsn = raw.get(dsn_key)
|
|
251
|
+
if not dsn:
|
|
252
|
+
return [NotConfiguredCheck("database")]
|
|
253
|
+
try:
|
|
254
|
+
resolved = resolve_secret(str(dsn))
|
|
255
|
+
except SecretResolutionError as exc:
|
|
256
|
+
raise ConfigError([f"{section}.{dsn_key}: {exc}"]) from None
|
|
257
|
+
return [DatabaseCheck(resolved, timeout_s=timeout_s, expected_heads=expected_heads)]
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""The read path of Keeper (design 8): Records, Links, queries, search, history and diff."""
|
|
@@ -0,0 +1,251 @@
|
|
|
1
|
+
"""Conditional GET and response caching of reads.
|
|
2
|
+
|
|
3
|
+
Every GET read answers with `ETag` = the Revision it was served from plus a digest of the path
|
|
4
|
+
and the canonical query parameters, `Cache-Control: private, must-revalidate` and the
|
|
5
|
+
`Lorebase-Revision` header; `If-None-Match` with that tag answers 304. Responses are cached
|
|
6
|
+
behind `ResponseCache`, keyed by `(Domain, Revision, path, canonical query)`. Revisions are
|
|
7
|
+
immutable, so a commit never invalidates an entry: it moves the branch to a new Revision and
|
|
8
|
+
therefore to new keys. The cache is consulted after the read is resolved and authorized, so it
|
|
9
|
+
never serves a caller something it could not read. Bodies are masked for the caller's
|
|
10
|
+
clearance, which is part of the key (`variant`), and hold vault references, not vault values:
|
|
11
|
+
`disclose` replaces them on every answer, cached or not (`reading.sensitivity`).
|
|
12
|
+
|
|
13
|
+
Only the in-process `InMemoryResponseCache` exists; a shared adapter (for example over Valkey)
|
|
14
|
+
implements the same protocol.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import hashlib
|
|
20
|
+
import time
|
|
21
|
+
from collections import OrderedDict
|
|
22
|
+
from collections.abc import Awaitable, Callable, Mapping
|
|
23
|
+
from dataclasses import dataclass, field
|
|
24
|
+
from threading import Lock
|
|
25
|
+
from typing import Protocol
|
|
26
|
+
|
|
27
|
+
from fastapi import Request, Response
|
|
28
|
+
from opentelemetry import metrics
|
|
29
|
+
from pydantic import BaseModel
|
|
30
|
+
|
|
31
|
+
from lorebase_keeper.auth.read_audit import note_all, noted_sensitive
|
|
32
|
+
from lorebase_keeper.settings import ReadCacheSettings
|
|
33
|
+
from lorebase_keeper.tracing.notes import note_cache
|
|
34
|
+
|
|
35
|
+
JSON = "application/json"
|
|
36
|
+
|
|
37
|
+
Disclose = Callable[[bytes, str], Awaitable[bytes]]
|
|
38
|
+
"""Rewrites a body of a media type as it is sent (vault references, `reading.sensitivity`)."""
|
|
39
|
+
CACHE_CONTROL = "private, must-revalidate"
|
|
40
|
+
|
|
41
|
+
_LOOKUPS = metrics.get_meter("lorebase.keeper").create_counter(
|
|
42
|
+
"lorebase.keeper.cache.requests",
|
|
43
|
+
unit="{request}",
|
|
44
|
+
description="Read response cache lookups, by result (hit, miss, bypass when disabled).",
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
@dataclass(frozen=True, slots=True)
|
|
49
|
+
class CacheKey:
|
|
50
|
+
domain_id: str
|
|
51
|
+
revision: str
|
|
52
|
+
path: str
|
|
53
|
+
query: tuple[tuple[str, str], ...]
|
|
54
|
+
variant: str = ""
|
|
55
|
+
"""What else the body depends on, such as the Kinds the caller may read."""
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
@dataclass(frozen=True, slots=True)
|
|
59
|
+
class CachedResponse:
|
|
60
|
+
body: bytes
|
|
61
|
+
headers: Mapping[str, str] = field(default_factory=dict)
|
|
62
|
+
time_travel: bool = False
|
|
63
|
+
media_type: str = JSON
|
|
64
|
+
sensitive: frozenset[str] = frozenset()
|
|
65
|
+
"""Sensitive values the first rendering showed or masked (`read_audit.note_sensitive`),
|
|
66
|
+
reported again by every answer from the cache."""
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
class ResponseCache(Protocol):
|
|
70
|
+
"""Storage of read responses (in process now; a shared store later)."""
|
|
71
|
+
|
|
72
|
+
async def get(self, key: CacheKey) -> CachedResponse | None: ...
|
|
73
|
+
|
|
74
|
+
async def put(self, key: CacheKey, value: CachedResponse) -> None: ...
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
class InMemoryResponseCache:
|
|
78
|
+
"""LRU bounded by entries and bytes, with a time to live and a cap on time travel entries."""
|
|
79
|
+
|
|
80
|
+
def __init__(
|
|
81
|
+
self,
|
|
82
|
+
settings: ReadCacheSettings,
|
|
83
|
+
*,
|
|
84
|
+
clock: Callable[[], float] = time.monotonic,
|
|
85
|
+
) -> None:
|
|
86
|
+
self._settings = settings
|
|
87
|
+
self._clock = clock
|
|
88
|
+
self._entries: OrderedDict[CacheKey, tuple[float, CachedResponse]] = OrderedDict()
|
|
89
|
+
self._bytes = 0
|
|
90
|
+
self._time_travel = 0
|
|
91
|
+
self._lock = Lock()
|
|
92
|
+
|
|
93
|
+
def __len__(self) -> int:
|
|
94
|
+
return len(self._entries)
|
|
95
|
+
|
|
96
|
+
@property
|
|
97
|
+
def size_bytes(self) -> int:
|
|
98
|
+
return self._bytes
|
|
99
|
+
|
|
100
|
+
async def get(self, key: CacheKey) -> CachedResponse | None:
|
|
101
|
+
with self._lock:
|
|
102
|
+
found = self._entries.get(key)
|
|
103
|
+
if found is None:
|
|
104
|
+
return None
|
|
105
|
+
expires, value = found
|
|
106
|
+
if expires <= self._clock():
|
|
107
|
+
self._remove(key)
|
|
108
|
+
return None
|
|
109
|
+
self._entries.move_to_end(key)
|
|
110
|
+
return value
|
|
111
|
+
|
|
112
|
+
async def put(self, key: CacheKey, value: CachedResponse) -> None:
|
|
113
|
+
settings = self._settings
|
|
114
|
+
size = len(value.body)
|
|
115
|
+
if size > settings.max_body_bytes or size > settings.max_bytes:
|
|
116
|
+
return
|
|
117
|
+
with self._lock:
|
|
118
|
+
if key in self._entries:
|
|
119
|
+
self._remove(key)
|
|
120
|
+
if value.time_travel and self._time_travel >= settings.time_travel_max_entries:
|
|
121
|
+
return
|
|
122
|
+
self._entries[key] = (self._clock() + settings.ttl_s, value)
|
|
123
|
+
self._bytes += size
|
|
124
|
+
self._time_travel += value.time_travel
|
|
125
|
+
while len(self._entries) > settings.max_entries or self._bytes > settings.max_bytes:
|
|
126
|
+
self._remove(next(iter(self._entries)))
|
|
127
|
+
|
|
128
|
+
def _remove(self, key: CacheKey) -> None:
|
|
129
|
+
_, value = self._entries.pop(key)
|
|
130
|
+
self._bytes -= len(value.body)
|
|
131
|
+
self._time_travel -= value.time_travel
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def build_cache(settings: ReadCacheSettings) -> ResponseCache | None:
|
|
135
|
+
return InMemoryResponseCache(settings) if settings.enabled else None
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def cache_key(request: Request, domain_id: str, revision: str, variant: str = "") -> CacheKey:
|
|
139
|
+
query = tuple(sorted(request.query_params.multi_items()))
|
|
140
|
+
return CacheKey(domain_id, revision, request.url.path, query, variant)
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def entity_tag(key: CacheKey) -> str:
|
|
144
|
+
"""Strong ETag: the Revision plus a digest of the path and canonical query parameters."""
|
|
145
|
+
digest = hashlib.sha256(key.path.encode())
|
|
146
|
+
for name, value in key.query:
|
|
147
|
+
digest.update(b"\x00" + name.encode() + b"\x1f" + value.encode())
|
|
148
|
+
if key.variant:
|
|
149
|
+
digest.update(b"\x1e" + key.variant.encode())
|
|
150
|
+
return f'"{key.revision}.{digest.hexdigest()[:16]}"'
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def _matches(request: Request, tag: str) -> bool:
|
|
154
|
+
header = request.headers.get("if-none-match")
|
|
155
|
+
if header is None:
|
|
156
|
+
return False
|
|
157
|
+
candidates = {t.strip() for t in header.split(",")}
|
|
158
|
+
return "*" in candidates or tag in candidates or f"W/{tag}" in candidates
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def not_modified(
|
|
162
|
+
request: Request,
|
|
163
|
+
*,
|
|
164
|
+
domain_id: str,
|
|
165
|
+
revision: str,
|
|
166
|
+
headers: Mapping[str, str] | None = None,
|
|
167
|
+
variant: str = "",
|
|
168
|
+
) -> Response | None:
|
|
169
|
+
"""The 304 answer of a GET read whose `If-None-Match` names its current tag, else `None`."""
|
|
170
|
+
tag = entity_tag(cache_key(request, domain_id, revision, variant))
|
|
171
|
+
if not _matches(request, tag):
|
|
172
|
+
return None
|
|
173
|
+
common = {"ETag": tag, "Cache-Control": CACHE_CONTROL, **(headers or {})}
|
|
174
|
+
note_cache("not_modified")
|
|
175
|
+
return Response(status_code=304, headers=common)
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
async def respond(
|
|
179
|
+
request: Request,
|
|
180
|
+
cache: ResponseCache | None,
|
|
181
|
+
*,
|
|
182
|
+
domain_id: str,
|
|
183
|
+
revision: str,
|
|
184
|
+
produce: Callable[[], Awaitable[BaseModel]],
|
|
185
|
+
time_travel: bool = False,
|
|
186
|
+
headers: Mapping[str, str] | None = None,
|
|
187
|
+
variant: str = "",
|
|
188
|
+
disclose: Disclose | None = None,
|
|
189
|
+
) -> Response:
|
|
190
|
+
"""Answer a GET read: 304, a cached body, or `produce()` serialized and cached.
|
|
191
|
+
|
|
192
|
+
`revision` names everything the response depends on besides the request (one Revision id,
|
|
193
|
+
or several joined for a diff); `variant` anything else it depends on, such as the Kinds
|
|
194
|
+
the caller may read and its clearance; `headers` are added to every answer; `disclose`
|
|
195
|
+
post-processes every body sent (not the cached one).
|
|
196
|
+
"""
|
|
197
|
+
|
|
198
|
+
async def body() -> tuple[bytes, str]:
|
|
199
|
+
return (await produce()).model_dump_json().encode(), JSON
|
|
200
|
+
|
|
201
|
+
return await respond_with(
|
|
202
|
+
request,
|
|
203
|
+
cache,
|
|
204
|
+
cache_key(request, domain_id, revision, variant),
|
|
205
|
+
body,
|
|
206
|
+
time_travel=time_travel,
|
|
207
|
+
headers=headers,
|
|
208
|
+
disclose=disclose,
|
|
209
|
+
)
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
async def respond_with(
|
|
213
|
+
request: Request,
|
|
214
|
+
cache: ResponseCache | None,
|
|
215
|
+
key: CacheKey,
|
|
216
|
+
produce: Callable[[], Awaitable[tuple[bytes, str]]],
|
|
217
|
+
*,
|
|
218
|
+
time_travel: bool = False,
|
|
219
|
+
headers: Mapping[str, str] | None = None,
|
|
220
|
+
disclose: Disclose | None = None,
|
|
221
|
+
) -> Response:
|
|
222
|
+
"""Answer a read under `key`: 304, a cached body, or `produce()` (body and media type),
|
|
223
|
+
cached. The ETag is `entity_tag(key)`. `disclose`, when given, rewrites bodies as they are
|
|
224
|
+
sent."""
|
|
225
|
+
tag = entity_tag(key)
|
|
226
|
+
common = {"ETag": tag, "Cache-Control": CACHE_CONTROL, **(headers or {})}
|
|
227
|
+
if _matches(request, tag):
|
|
228
|
+
note_cache("not_modified")
|
|
229
|
+
return Response(status_code=304, headers=common)
|
|
230
|
+
cached = None if cache is None else await cache.get(key)
|
|
231
|
+
result = "bypass" if cache is None else "miss" if cached is None else "hit"
|
|
232
|
+
_LOOKUPS.add(1, {"result": result})
|
|
233
|
+
note_cache(result)
|
|
234
|
+
if cached is not None:
|
|
235
|
+
note_all(cached.sensitive)
|
|
236
|
+
return Response(
|
|
237
|
+
await _sent(cached.body, cached.media_type, disclose),
|
|
238
|
+
media_type=cached.media_type,
|
|
239
|
+
headers={**cached.headers, **common},
|
|
240
|
+
)
|
|
241
|
+
body, media_type = await produce()
|
|
242
|
+
if cache is not None:
|
|
243
|
+
await cache.put(
|
|
244
|
+
key,
|
|
245
|
+
CachedResponse(body, dict(headers or {}), time_travel, media_type, noted_sensitive()),
|
|
246
|
+
)
|
|
247
|
+
return Response(await _sent(body, media_type, disclose), media_type=media_type, headers=common)
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
async def _sent(body: bytes, media_type: str, disclose: Disclose | None) -> bytes:
|
|
251
|
+
return body if disclose is None else await disclose(body, media_type)
|