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,434 @@
|
|
|
1
|
+
"""Read replicas: round-robin routing of reads behind a freshness guard.
|
|
2
|
+
|
|
3
|
+
Design 6.5, 6.6, 8.1 and 15.3. Writes always go to the primary (commits are serialized per
|
|
4
|
+
branch by an advisory lock, which only exists there). Reads may go to a streaming replica, which
|
|
5
|
+
trails the primary by a small and variable delay. The guard keeps that delay from being
|
|
6
|
+
observable where it matters:
|
|
7
|
+
|
|
8
|
+
- A read that must see a given branch head (`expected_head`, or the head read from the primary
|
|
9
|
+
when `read_your_writes` is requested) runs a probe on the replica first. The probe reads the
|
|
10
|
+
`refs` row of the branch in the same transaction the read will use. If the row does not hold
|
|
11
|
+
that head the replica has not replayed the commit yet and the read goes to the next replica or,
|
|
12
|
+
at last, to the primary. A commit is one transaction (design 6.6), so a replica showing the
|
|
13
|
+
head shows everything the commit wrote. The check is an equality: when the branch has moved
|
|
14
|
+
on since, the read is conservatively served by the primary.
|
|
15
|
+
- Every probe also reads the replication lag. A replica above `database.read_max_lag_ms` serves
|
|
16
|
+
nothing, pinned or not.
|
|
17
|
+
- With `database.read_fallback_to_primary` off, a read no replica can serve raises
|
|
18
|
+
`ReplicaUnavailableError` instead of loading the primary.
|
|
19
|
+
|
|
20
|
+
Replay lag is estimated like common monitoring tools do: zero when the standby has replayed all
|
|
21
|
+
WAL it received, otherwise the age of the last replayed transaction
|
|
22
|
+
(`pg_last_xact_replay_timestamp`, so primary and standby clocks should agree). It is advisory:
|
|
23
|
+
correctness of pinned reads rests on the head check, not on the lag figure.
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
from __future__ import annotations
|
|
27
|
+
|
|
28
|
+
import asyncio
|
|
29
|
+
import itertools
|
|
30
|
+
import logging
|
|
31
|
+
from collections.abc import AsyncIterator, Callable, Sequence
|
|
32
|
+
from contextlib import asynccontextmanager
|
|
33
|
+
from dataclasses import dataclass
|
|
34
|
+
from enum import StrEnum
|
|
35
|
+
|
|
36
|
+
from sqlalchemy import make_url, text
|
|
37
|
+
from sqlalchemy.exc import SQLAlchemyError
|
|
38
|
+
from sqlalchemy.ext.asyncio import AsyncEngine, AsyncSession, async_sessionmaker
|
|
39
|
+
|
|
40
|
+
from lorebase_store.db import create_engine
|
|
41
|
+
from lorebase_store.repositories.scope import scope_to_domain
|
|
42
|
+
from lorebase_store.settings import DatabaseSettings
|
|
43
|
+
|
|
44
|
+
logger = logging.getLogger("lorebase.store.replicas")
|
|
45
|
+
|
|
46
|
+
STATE_COLUMNS = """
|
|
47
|
+
pg_is_in_recovery() AS in_recovery,
|
|
48
|
+
CASE
|
|
49
|
+
WHEN NOT pg_is_in_recovery() THEN 0
|
|
50
|
+
WHEN pg_last_wal_receive_lsn() = pg_last_wal_replay_lsn() THEN 0
|
|
51
|
+
ELSE extract(epoch FROM now() - pg_last_xact_replay_timestamp()) * 1000
|
|
52
|
+
END::double precision AS lag_ms,
|
|
53
|
+
pg_last_wal_replay_lsn()::text AS replay_lsn
|
|
54
|
+
"""
|
|
55
|
+
"""Columns describing a server's replication state; `lag_ms` is null when it cannot be known."""
|
|
56
|
+
|
|
57
|
+
STATE_SQL = f"SELECT {STATE_COLUMNS}"
|
|
58
|
+
|
|
59
|
+
_HEAD_OF_BRANCH = """
|
|
60
|
+
(SELECT r.head FROM refs r
|
|
61
|
+
WHERE r.domain_id = :domain_id AND r.deleted_at IS NULL
|
|
62
|
+
AND r.name = coalesce(
|
|
63
|
+
CAST(:branch AS text),
|
|
64
|
+
(SELECT d.default_branch FROM domains d WHERE d.id = :domain_id)
|
|
65
|
+
)) AS head
|
|
66
|
+
"""
|
|
67
|
+
_GUARD_SQL = text(f"SELECT {STATE_COLUMNS}, {_HEAD_OF_BRANCH}")
|
|
68
|
+
_PRIMARY_HEAD_SQL = text(f"SELECT {_HEAD_OF_BRANCH}")
|
|
69
|
+
|
|
70
|
+
_PROBE_ERRORS = (SQLAlchemyError, OSError, TimeoutError)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
class ReadSource(StrEnum):
|
|
74
|
+
"""Which server served a read."""
|
|
75
|
+
|
|
76
|
+
PRIMARY = "primary"
|
|
77
|
+
REPLICA = "replica"
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
class RouteReason(StrEnum):
|
|
81
|
+
"""Why a read went where it did."""
|
|
82
|
+
|
|
83
|
+
REPLICA_FRESH = "replica_fresh"
|
|
84
|
+
NO_REPLICAS = "no_replicas"
|
|
85
|
+
NOT_PREFERRED = "not_preferred"
|
|
86
|
+
STALE = "stale"
|
|
87
|
+
LAGGING = "lagging"
|
|
88
|
+
UNREACHABLE = "unreachable"
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
class ReplicaUnavailableError(Exception):
|
|
92
|
+
"""No replica could serve the read and falling back to the primary is disabled."""
|
|
93
|
+
|
|
94
|
+
def __init__(self, reason: RouteReason) -> None:
|
|
95
|
+
super().__init__(f"no read replica can serve this read ({reason.value})")
|
|
96
|
+
self.reason = reason
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
@dataclass(frozen=True, slots=True)
|
|
100
|
+
class ReadRoute:
|
|
101
|
+
"""The routing decision of one read."""
|
|
102
|
+
|
|
103
|
+
source: ReadSource
|
|
104
|
+
reason: RouteReason
|
|
105
|
+
replica: str | None = None
|
|
106
|
+
"""Name of the serving replica, or of the last one tried when a fallback happened."""
|
|
107
|
+
lag_ms: float | None = None
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
@dataclass(frozen=True, slots=True)
|
|
111
|
+
class ReadSession:
|
|
112
|
+
"""A session for reading and how it was chosen. The caller owns neither commits nor writes."""
|
|
113
|
+
|
|
114
|
+
session: AsyncSession
|
|
115
|
+
route: ReadRoute
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
@dataclass(frozen=True, slots=True)
|
|
119
|
+
class ReplicaState:
|
|
120
|
+
"""Replication state of one replica as last probed."""
|
|
121
|
+
|
|
122
|
+
name: str
|
|
123
|
+
endpoint: str
|
|
124
|
+
"""`host:port` of the replica; never contains credentials."""
|
|
125
|
+
reachable: bool
|
|
126
|
+
in_recovery: bool | None = None
|
|
127
|
+
"""False: the configured server is not a standby (a primary serves reads correctly)."""
|
|
128
|
+
lag_ms: float | None = None
|
|
129
|
+
replay_lsn: str | None = None
|
|
130
|
+
error: str | None = None
|
|
131
|
+
"""Exception class name when the replica could not be probed."""
|
|
132
|
+
|
|
133
|
+
def lagging(self, max_lag_ms: int) -> bool:
|
|
134
|
+
"""The lag is unknown or above the limit (a limit of 0 disables the check)."""
|
|
135
|
+
return max_lag_ms > 0 and (self.lag_ms is None or self.lag_ms > max_lag_ms)
|
|
136
|
+
|
|
137
|
+
def usable(self, max_lag_ms: int) -> bool:
|
|
138
|
+
"""The replica is reachable and within the lag limit."""
|
|
139
|
+
return self.reachable and not self.lagging(max_lag_ms)
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
@dataclass(frozen=True, slots=True)
|
|
143
|
+
class Replica:
|
|
144
|
+
"""One replica: its pool and a label for logs and metrics."""
|
|
145
|
+
|
|
146
|
+
name: str
|
|
147
|
+
endpoint: str
|
|
148
|
+
engine: AsyncEngine
|
|
149
|
+
sessions: async_sessionmaker[AsyncSession]
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def _endpoint(url: str) -> str:
|
|
153
|
+
parsed = make_url(url)
|
|
154
|
+
return f"{parsed.host or 'localhost'}:{parsed.port or 5432}"
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
class ReplicaSet:
|
|
158
|
+
"""Replicas in round-robin order, with the state each was last seen in."""
|
|
159
|
+
|
|
160
|
+
def __init__(self, replicas: Sequence[Replica]) -> None:
|
|
161
|
+
if not replicas:
|
|
162
|
+
raise ValueError("a replica set needs at least one replica")
|
|
163
|
+
self._replicas = tuple(replicas)
|
|
164
|
+
self._cursor = itertools.count()
|
|
165
|
+
self._states: dict[str, ReplicaState] = {}
|
|
166
|
+
|
|
167
|
+
@classmethod
|
|
168
|
+
def from_settings(cls, database: DatabaseSettings) -> ReplicaSet | None:
|
|
169
|
+
"""Replicas named by `database.read_dsn`; `None` when none is configured."""
|
|
170
|
+
replicas = []
|
|
171
|
+
for index, dsn in enumerate(database.read_dsn):
|
|
172
|
+
engine = create_engine(database, url=dsn.get_secret_value())
|
|
173
|
+
replicas.append(
|
|
174
|
+
Replica(
|
|
175
|
+
name=f"replica-{index}",
|
|
176
|
+
endpoint=_endpoint(dsn.get_secret_value()),
|
|
177
|
+
engine=engine,
|
|
178
|
+
sessions=async_sessionmaker(engine, expire_on_commit=False),
|
|
179
|
+
)
|
|
180
|
+
)
|
|
181
|
+
return cls(replicas) if replicas else None
|
|
182
|
+
|
|
183
|
+
def __len__(self) -> int:
|
|
184
|
+
return len(self._replicas)
|
|
185
|
+
|
|
186
|
+
@property
|
|
187
|
+
def replicas(self) -> tuple[Replica, ...]:
|
|
188
|
+
"""Every replica, in configuration order."""
|
|
189
|
+
return self._replicas
|
|
190
|
+
|
|
191
|
+
def rotation(self) -> list[Replica]:
|
|
192
|
+
"""Every replica once, starting at the next one in round-robin order."""
|
|
193
|
+
start = next(self._cursor) % len(self._replicas)
|
|
194
|
+
return [*self._replicas[start:], *self._replicas[:start]]
|
|
195
|
+
|
|
196
|
+
def last_states(self) -> list[ReplicaState]:
|
|
197
|
+
"""State of each replica at its latest probe (reads probe too); unprobed ones are
|
|
198
|
+
reported as not reachable."""
|
|
199
|
+
return [
|
|
200
|
+
self._states.get(r.name) or ReplicaState(r.name, r.endpoint, reachable=False)
|
|
201
|
+
for r in self._replicas
|
|
202
|
+
]
|
|
203
|
+
|
|
204
|
+
def record(self, state: ReplicaState) -> None:
|
|
205
|
+
"""Remember a probe result; logs when a replica becomes unreachable or recovers."""
|
|
206
|
+
previous = self._states.get(state.name)
|
|
207
|
+
if previous is not None and previous.reachable != state.reachable:
|
|
208
|
+
if state.reachable:
|
|
209
|
+
logger.info("read replica %s (%s) is reachable again", state.name, state.endpoint)
|
|
210
|
+
else:
|
|
211
|
+
logger.warning(
|
|
212
|
+
"read replica %s (%s) is unreachable: %s",
|
|
213
|
+
state.name,
|
|
214
|
+
state.endpoint,
|
|
215
|
+
state.error,
|
|
216
|
+
)
|
|
217
|
+
self._states[state.name] = state
|
|
218
|
+
|
|
219
|
+
async def probe(self, timeout_s: float) -> list[ReplicaState]:
|
|
220
|
+
"""Probe every replica concurrently, each within `timeout_s`."""
|
|
221
|
+
states = await asyncio.gather(*(_probe_state(r, timeout_s) for r in self._replicas))
|
|
222
|
+
for state in states:
|
|
223
|
+
self.record(state)
|
|
224
|
+
return list(states)
|
|
225
|
+
|
|
226
|
+
async def dispose(self) -> None:
|
|
227
|
+
"""Close every pool."""
|
|
228
|
+
await asyncio.gather(*(r.engine.dispose() for r in self._replicas))
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
async def _probe_state(replica: Replica, timeout_s: float) -> ReplicaState:
|
|
232
|
+
try:
|
|
233
|
+
async with asyncio.timeout(timeout_s), replica.sessions() as session:
|
|
234
|
+
row = (await session.execute(text(STATE_SQL))).one()
|
|
235
|
+
except _PROBE_ERRORS as exc:
|
|
236
|
+
return ReplicaState(
|
|
237
|
+
replica.name, replica.endpoint, reachable=False, error=type(exc).__name__
|
|
238
|
+
)
|
|
239
|
+
return ReplicaState(
|
|
240
|
+
replica.name,
|
|
241
|
+
replica.endpoint,
|
|
242
|
+
reachable=True,
|
|
243
|
+
in_recovery=row.in_recovery,
|
|
244
|
+
lag_ms=row.lag_ms,
|
|
245
|
+
replay_lsn=row.replay_lsn,
|
|
246
|
+
)
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
RouteObserver = Callable[[ReadRoute], None]
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
class ReadSessionFactory:
|
|
253
|
+
"""Opens read sessions on a replica when it is fresh enough, otherwise on the primary."""
|
|
254
|
+
|
|
255
|
+
def __init__(
|
|
256
|
+
self,
|
|
257
|
+
primary: async_sessionmaker[AsyncSession],
|
|
258
|
+
replicas: ReplicaSet | None = None,
|
|
259
|
+
*,
|
|
260
|
+
max_lag_ms: int = 0,
|
|
261
|
+
fallback_to_primary: bool = True,
|
|
262
|
+
probe_timeout_s: float = 2.0,
|
|
263
|
+
on_route: RouteObserver | None = None,
|
|
264
|
+
) -> None:
|
|
265
|
+
self._primary = primary
|
|
266
|
+
self._replicas = replicas
|
|
267
|
+
self._max_lag_ms = max_lag_ms
|
|
268
|
+
self._fallback = fallback_to_primary
|
|
269
|
+
self._probe_timeout_s = probe_timeout_s
|
|
270
|
+
self._on_route = on_route
|
|
271
|
+
|
|
272
|
+
@classmethod
|
|
273
|
+
def from_settings(
|
|
274
|
+
cls,
|
|
275
|
+
primary: async_sessionmaker[AsyncSession],
|
|
276
|
+
database: DatabaseSettings,
|
|
277
|
+
*,
|
|
278
|
+
on_route: RouteObserver | None = None,
|
|
279
|
+
) -> ReadSessionFactory:
|
|
280
|
+
"""Factory for the `database` settings; it reads from the primary alone when no
|
|
281
|
+
replica is configured."""
|
|
282
|
+
return cls(
|
|
283
|
+
primary,
|
|
284
|
+
ReplicaSet.from_settings(database),
|
|
285
|
+
max_lag_ms=database.read_max_lag_ms,
|
|
286
|
+
fallback_to_primary=database.read_fallback_to_primary,
|
|
287
|
+
probe_timeout_s=database.read_probe_timeout_ms / 1000,
|
|
288
|
+
on_route=on_route,
|
|
289
|
+
)
|
|
290
|
+
|
|
291
|
+
@property
|
|
292
|
+
def replicas(self) -> ReplicaSet | None:
|
|
293
|
+
"""The replicas, `None` when reads always use the primary."""
|
|
294
|
+
return self._replicas
|
|
295
|
+
|
|
296
|
+
@property
|
|
297
|
+
def max_lag_ms(self) -> int:
|
|
298
|
+
"""Lag above which a replica serves no reads (0: not checked)."""
|
|
299
|
+
return self._max_lag_ms
|
|
300
|
+
|
|
301
|
+
@property
|
|
302
|
+
def fallback_to_primary(self) -> bool:
|
|
303
|
+
return self._fallback
|
|
304
|
+
|
|
305
|
+
@property
|
|
306
|
+
def probe_timeout_s(self) -> float:
|
|
307
|
+
return self._probe_timeout_s
|
|
308
|
+
|
|
309
|
+
async def dispose(self) -> None:
|
|
310
|
+
"""Close the replica pools (the primary's belongs to its owner)."""
|
|
311
|
+
if self._replicas is not None:
|
|
312
|
+
await self._replicas.dispose()
|
|
313
|
+
|
|
314
|
+
@asynccontextmanager
|
|
315
|
+
async def open(
|
|
316
|
+
self,
|
|
317
|
+
*,
|
|
318
|
+
domain_id: str | None = None,
|
|
319
|
+
branch: str | None = None,
|
|
320
|
+
expected_head: bytes | None = None,
|
|
321
|
+
read_your_writes: bool = False,
|
|
322
|
+
prefer_replica: bool = True,
|
|
323
|
+
) -> AsyncIterator[ReadSession]:
|
|
324
|
+
"""A session to read from, closed (rolled back) on exit.
|
|
325
|
+
|
|
326
|
+
`domain_id` and `branch` (default: the Domain's default branch) identify the ref the read
|
|
327
|
+
depends on. `expected_head` is a Revision id the branch must be at on the replica; with
|
|
328
|
+
`read_your_writes` and no `expected_head`, the current head is read from the primary.
|
|
329
|
+
`prefer_replica=False` reads from the primary. The session already has an open
|
|
330
|
+
transaction scoped to `domain_id`.
|
|
331
|
+
|
|
332
|
+
Raises `ReplicaUnavailableError` when no replica can serve the read and falling back is
|
|
333
|
+
disabled.
|
|
334
|
+
"""
|
|
335
|
+
if expected_head is not None and domain_id is None:
|
|
336
|
+
raise ValueError("expected_head needs a domain_id")
|
|
337
|
+
if self._replicas is None:
|
|
338
|
+
session, route = self._primary(), ReadRoute(ReadSource.PRIMARY, RouteReason.NO_REPLICAS)
|
|
339
|
+
elif not prefer_replica:
|
|
340
|
+
session, route = (
|
|
341
|
+
self._primary(),
|
|
342
|
+
ReadRoute(ReadSource.PRIMARY, RouteReason.NOT_PREFERRED),
|
|
343
|
+
)
|
|
344
|
+
else:
|
|
345
|
+
if expected_head is None and read_your_writes and domain_id is not None:
|
|
346
|
+
expected_head = await self._primary_head(domain_id, branch)
|
|
347
|
+
session, route = await self._choose(self._replicas, domain_id, branch, expected_head)
|
|
348
|
+
if self._on_route is not None:
|
|
349
|
+
self._on_route(route)
|
|
350
|
+
try:
|
|
351
|
+
if route.source is ReadSource.PRIMARY and domain_id is not None:
|
|
352
|
+
await scope_to_domain(session, domain_id)
|
|
353
|
+
yield ReadSession(session, route)
|
|
354
|
+
finally:
|
|
355
|
+
await session.close()
|
|
356
|
+
|
|
357
|
+
async def _primary_head(self, domain_id: str, branch: str | None) -> bytes | None:
|
|
358
|
+
async with self._primary() as session:
|
|
359
|
+
await scope_to_domain(session, domain_id)
|
|
360
|
+
result = await session.execute(
|
|
361
|
+
_PRIMARY_HEAD_SQL, {"domain_id": domain_id, "branch": branch}
|
|
362
|
+
)
|
|
363
|
+
head: bytes | None = result.scalar_one_or_none()
|
|
364
|
+
return head
|
|
365
|
+
|
|
366
|
+
async def _choose(
|
|
367
|
+
self,
|
|
368
|
+
replicas: ReplicaSet,
|
|
369
|
+
domain_id: str | None,
|
|
370
|
+
branch: str | None,
|
|
371
|
+
expected_head: bytes | None,
|
|
372
|
+
) -> tuple[AsyncSession, ReadRoute]:
|
|
373
|
+
last = RouteReason.UNREACHABLE
|
|
374
|
+
last_replica: str | None = None
|
|
375
|
+
last_lag: float | None = None
|
|
376
|
+
for replica in replicas.rotation():
|
|
377
|
+
session = replica.sessions()
|
|
378
|
+
try:
|
|
379
|
+
async with asyncio.timeout(self._probe_timeout_s):
|
|
380
|
+
state, head = await self._probe(replica, session, domain_id, branch)
|
|
381
|
+
except _PROBE_ERRORS as exc:
|
|
382
|
+
await _close_quietly(session)
|
|
383
|
+
replicas.record(
|
|
384
|
+
ReplicaState(
|
|
385
|
+
replica.name, replica.endpoint, reachable=False, error=type(exc).__name__
|
|
386
|
+
)
|
|
387
|
+
)
|
|
388
|
+
last, last_replica, last_lag = RouteReason.UNREACHABLE, replica.name, None
|
|
389
|
+
continue
|
|
390
|
+
replicas.record(state)
|
|
391
|
+
if state.lagging(self._max_lag_ms):
|
|
392
|
+
reason = RouteReason.LAGGING
|
|
393
|
+
elif expected_head is not None and head != expected_head:
|
|
394
|
+
reason = RouteReason.STALE
|
|
395
|
+
else:
|
|
396
|
+
route = ReadRoute(
|
|
397
|
+
ReadSource.REPLICA, RouteReason.REPLICA_FRESH, replica.name, state.lag_ms
|
|
398
|
+
)
|
|
399
|
+
return session, route
|
|
400
|
+
await _close_quietly(session)
|
|
401
|
+
last, last_replica, last_lag = reason, replica.name, state.lag_ms
|
|
402
|
+
if not self._fallback:
|
|
403
|
+
raise ReplicaUnavailableError(last)
|
|
404
|
+
return self._primary(), ReadRoute(ReadSource.PRIMARY, last, last_replica, last_lag)
|
|
405
|
+
|
|
406
|
+
@staticmethod
|
|
407
|
+
async def _probe(
|
|
408
|
+
replica: Replica, session: AsyncSession, domain_id: str | None, branch: str | None
|
|
409
|
+
) -> tuple[ReplicaState, bytes | None]:
|
|
410
|
+
if domain_id is None:
|
|
411
|
+
row = (await session.execute(text(STATE_SQL))).one()
|
|
412
|
+
head = None
|
|
413
|
+
else:
|
|
414
|
+
await scope_to_domain(session, domain_id)
|
|
415
|
+
row = (
|
|
416
|
+
await session.execute(_GUARD_SQL, {"domain_id": domain_id, "branch": branch})
|
|
417
|
+
).one()
|
|
418
|
+
head = row.head
|
|
419
|
+
state = ReplicaState(
|
|
420
|
+
replica.name,
|
|
421
|
+
replica.endpoint,
|
|
422
|
+
reachable=True,
|
|
423
|
+
in_recovery=row.in_recovery,
|
|
424
|
+
lag_ms=row.lag_ms,
|
|
425
|
+
replay_lsn=row.replay_lsn,
|
|
426
|
+
)
|
|
427
|
+
return state, head
|
|
428
|
+
|
|
429
|
+
|
|
430
|
+
async def _close_quietly(session: AsyncSession) -> None:
|
|
431
|
+
try:
|
|
432
|
+
await asyncio.shield(session.close())
|
|
433
|
+
except Exception:
|
|
434
|
+
logger.debug("closing a replica session failed", exc_info=True)
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
"""Typed repositories: the only place where the store issues SQL (besides node and object stores).
|
|
2
|
+
|
|
3
|
+
Every repository is scoped to one Domain (and, for materialized rows, one branch) and runs in
|
|
4
|
+
the caller's transaction; row-level security is set with `scope_to_domain`.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from lorebase_store.repositories.changesets import ChangesetRepository
|
|
8
|
+
from lorebase_store.repositories.documents import (
|
|
9
|
+
ChunkRepository,
|
|
10
|
+
ChunkRow,
|
|
11
|
+
DocumentRepository,
|
|
12
|
+
DocumentRow,
|
|
13
|
+
ExtractionCacheRepository,
|
|
14
|
+
ExtractionJobRepository,
|
|
15
|
+
ExtractionJobRow,
|
|
16
|
+
)
|
|
17
|
+
from lorebase_store.repositories.domains import DomainNotFoundError, DomainRepository, DomainRow
|
|
18
|
+
from lorebase_store.repositories.events import OutboxRepository
|
|
19
|
+
from lorebase_store.repositories.index_view import (
|
|
20
|
+
LinkState,
|
|
21
|
+
MaterializedIndexView,
|
|
22
|
+
RecordState,
|
|
23
|
+
)
|
|
24
|
+
from lorebase_store.repositories.invocations import InvocationRepository, InvocationRow
|
|
25
|
+
from lorebase_store.repositories.materializations import (
|
|
26
|
+
BranchMaterializationRepository,
|
|
27
|
+
MaterializationPhase,
|
|
28
|
+
MaterializationState,
|
|
29
|
+
MaterializationStatus,
|
|
30
|
+
MaterializationStrategy,
|
|
31
|
+
copy_fence,
|
|
32
|
+
)
|
|
33
|
+
from lorebase_store.repositories.materialized import (
|
|
34
|
+
HistoryRow,
|
|
35
|
+
HistoryStep,
|
|
36
|
+
LinkRow,
|
|
37
|
+
MaterializedRepository,
|
|
38
|
+
RecordRow,
|
|
39
|
+
SchemaRow,
|
|
40
|
+
)
|
|
41
|
+
from lorebase_store.repositories.merges import (
|
|
42
|
+
EVERY_PARTITION,
|
|
43
|
+
ConflictRepository,
|
|
44
|
+
ConflictRow,
|
|
45
|
+
MergeAttemptRepository,
|
|
46
|
+
MergeAttemptRow,
|
|
47
|
+
PartitionFilter,
|
|
48
|
+
ResolutionRepository,
|
|
49
|
+
ResolutionRow,
|
|
50
|
+
)
|
|
51
|
+
from lorebase_store.repositories.proposal_items import (
|
|
52
|
+
ItemFilter,
|
|
53
|
+
ItemRow,
|
|
54
|
+
ProposalItemRepository,
|
|
55
|
+
)
|
|
56
|
+
from lorebase_store.repositories.proposals import (
|
|
57
|
+
ProposalFilter,
|
|
58
|
+
ProposalRepository,
|
|
59
|
+
ProposalRow,
|
|
60
|
+
ReviewRepository,
|
|
61
|
+
ReviewRow,
|
|
62
|
+
)
|
|
63
|
+
from lorebase_store.repositories.provenance import ProvenanceRepository, ProvenanceRow
|
|
64
|
+
from lorebase_store.repositories.refs import (
|
|
65
|
+
LockTimeoutError,
|
|
66
|
+
RefLogRow,
|
|
67
|
+
RefMovedError,
|
|
68
|
+
RefNotFoundError,
|
|
69
|
+
RefRepository,
|
|
70
|
+
RefRow,
|
|
71
|
+
advisory_lock_key,
|
|
72
|
+
)
|
|
73
|
+
from lorebase_store.repositories.revisions import (
|
|
74
|
+
PgRevisionGraph,
|
|
75
|
+
RevisionRepository,
|
|
76
|
+
RevisionRow,
|
|
77
|
+
)
|
|
78
|
+
from lorebase_store.repositories.scope import scope_to_domain
|
|
79
|
+
from lorebase_store.repositories.source_runs import SourceRunRepository, SourceRunRow
|
|
80
|
+
from lorebase_store.repositories.suggestions import (
|
|
81
|
+
MergeOutcome,
|
|
82
|
+
RecordOutcome,
|
|
83
|
+
SchemaSuggestionRepository,
|
|
84
|
+
SuggestionRow,
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
__all__ = [
|
|
88
|
+
"EVERY_PARTITION",
|
|
89
|
+
"BranchMaterializationRepository",
|
|
90
|
+
"ChangesetRepository",
|
|
91
|
+
"ChunkRepository",
|
|
92
|
+
"ChunkRow",
|
|
93
|
+
"ConflictRepository",
|
|
94
|
+
"ConflictRow",
|
|
95
|
+
"DocumentRepository",
|
|
96
|
+
"DocumentRow",
|
|
97
|
+
"DomainNotFoundError",
|
|
98
|
+
"DomainRepository",
|
|
99
|
+
"DomainRow",
|
|
100
|
+
"ExtractionCacheRepository",
|
|
101
|
+
"ExtractionJobRepository",
|
|
102
|
+
"ExtractionJobRow",
|
|
103
|
+
"HistoryRow",
|
|
104
|
+
"HistoryStep",
|
|
105
|
+
"InvocationRepository",
|
|
106
|
+
"InvocationRow",
|
|
107
|
+
"ItemFilter",
|
|
108
|
+
"ItemRow",
|
|
109
|
+
"LinkRow",
|
|
110
|
+
"LinkState",
|
|
111
|
+
"LockTimeoutError",
|
|
112
|
+
"MaterializationPhase",
|
|
113
|
+
"MaterializationState",
|
|
114
|
+
"MaterializationStatus",
|
|
115
|
+
"MaterializationStrategy",
|
|
116
|
+
"MaterializedIndexView",
|
|
117
|
+
"MaterializedRepository",
|
|
118
|
+
"MergeAttemptRepository",
|
|
119
|
+
"MergeAttemptRow",
|
|
120
|
+
"MergeOutcome",
|
|
121
|
+
"OutboxRepository",
|
|
122
|
+
"PartitionFilter",
|
|
123
|
+
"PgRevisionGraph",
|
|
124
|
+
"ProposalFilter",
|
|
125
|
+
"ProposalItemRepository",
|
|
126
|
+
"ProposalRepository",
|
|
127
|
+
"ProposalRow",
|
|
128
|
+
"ProvenanceRepository",
|
|
129
|
+
"ProvenanceRow",
|
|
130
|
+
"RecordOutcome",
|
|
131
|
+
"RecordRow",
|
|
132
|
+
"RecordState",
|
|
133
|
+
"RefLogRow",
|
|
134
|
+
"RefMovedError",
|
|
135
|
+
"RefNotFoundError",
|
|
136
|
+
"RefRepository",
|
|
137
|
+
"RefRow",
|
|
138
|
+
"ResolutionRepository",
|
|
139
|
+
"ResolutionRow",
|
|
140
|
+
"ReviewRepository",
|
|
141
|
+
"ReviewRow",
|
|
142
|
+
"RevisionRepository",
|
|
143
|
+
"RevisionRow",
|
|
144
|
+
"SchemaRow",
|
|
145
|
+
"SchemaSuggestionRepository",
|
|
146
|
+
"SourceRunRepository",
|
|
147
|
+
"SourceRunRow",
|
|
148
|
+
"SuggestionRow",
|
|
149
|
+
"advisory_lock_key",
|
|
150
|
+
"copy_fence",
|
|
151
|
+
"scope_to_domain",
|
|
152
|
+
]
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
"""Changesets: per-Revision element changes against the first parent (design 6.3)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Sequence
|
|
6
|
+
|
|
7
|
+
from sqlalchemy import Table, select
|
|
8
|
+
from sqlalchemy.ext.asyncio import AsyncSession
|
|
9
|
+
|
|
10
|
+
from lorebase_core.revision import ChangesetEntry
|
|
11
|
+
from lorebase_store.bulk import OnConflict, bulk_write
|
|
12
|
+
from lorebase_store.models import metadata
|
|
13
|
+
from lorebase_store.settings import WriteSettings
|
|
14
|
+
|
|
15
|
+
CHANGESETS: Table = metadata.tables["changesets"]
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class ChangesetRepository:
|
|
19
|
+
"""Changeset rows of one Domain."""
|
|
20
|
+
|
|
21
|
+
def __init__(self, session: AsyncSession, domain_id: str, *, writes: WriteSettings) -> None:
|
|
22
|
+
self._session = session
|
|
23
|
+
self._domain = domain_id
|
|
24
|
+
self._writes = writes
|
|
25
|
+
|
|
26
|
+
async def insert(self, revision_id: bytes, entries: Sequence[ChangesetEntry]) -> None:
|
|
27
|
+
rows = [
|
|
28
|
+
{
|
|
29
|
+
"domain_id": self._domain,
|
|
30
|
+
"revision_id": revision_id,
|
|
31
|
+
"partition": entry.partition,
|
|
32
|
+
"element_key": entry.key,
|
|
33
|
+
"old_hash": entry.old_hash,
|
|
34
|
+
"new_hash": entry.new_hash,
|
|
35
|
+
"provenance": dict(entry.provenance) or None,
|
|
36
|
+
}
|
|
37
|
+
for entry in entries
|
|
38
|
+
]
|
|
39
|
+
await bulk_write(
|
|
40
|
+
self._session, CHANGESETS, rows, writes=self._writes, on_conflict=OnConflict.ERROR
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
async def entries(self, revision_id: bytes) -> list[ChangesetEntry]:
|
|
44
|
+
"""Entries of a Revision ordered by partition and element key."""
|
|
45
|
+
result = await self._session.execute(
|
|
46
|
+
select(
|
|
47
|
+
CHANGESETS.c.partition,
|
|
48
|
+
CHANGESETS.c.element_key,
|
|
49
|
+
CHANGESETS.c.old_hash,
|
|
50
|
+
CHANGESETS.c.new_hash,
|
|
51
|
+
CHANGESETS.c.provenance,
|
|
52
|
+
)
|
|
53
|
+
.where(CHANGESETS.c.domain_id == self._domain, CHANGESETS.c.revision_id == revision_id)
|
|
54
|
+
.order_by(CHANGESETS.c.partition, CHANGESETS.c.element_key)
|
|
55
|
+
)
|
|
56
|
+
return [
|
|
57
|
+
ChangesetEntry(partition, key, old, new, provenance or {})
|
|
58
|
+
for partition, key, old, new, provenance in result
|
|
59
|
+
]
|