lorebase-store 0.2.3__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (163) hide show
  1. lorebase_store/__init__.py +1 -0
  2. lorebase_store/_migrations/env.py +106 -0
  3. lorebase_store/_migrations/script.py.mako +27 -0
  4. lorebase_store/_migrations/versions/0001_initial_schema.py +1071 -0
  5. lorebase_store/_migrations/versions/0002_merge_attempt_details.py +74 -0
  6. lorebase_store/_migrations/versions/0003_webhooks.py +127 -0
  7. lorebase_store/_migrations/versions/0004_defer_revision_references.py +38 -0
  8. lorebase_store/_migrations/versions/0005_security_definer_reads.py +396 -0
  9. lorebase_store/_migrations/versions/0006_api_key_usage.py +48 -0
  10. lorebase_store/_migrations/versions/0007_document_extraction.py +154 -0
  11. lorebase_store/_migrations/versions/0008_definer_argument_checks.py +178 -0
  12. lorebase_store/_migrations/versions/0009_api_key_networks.py +33 -0
  13. lorebase_store/_migrations/versions/0010_overlay_branches.py +582 -0
  14. lorebase_store/_migrations/versions/0011_branch_scoped_search_index.py +229 -0
  15. lorebase_store/_migrations/versions/0012_overlay_neighborhood_lookups.py +185 -0
  16. lorebase_store/_migrations/versions/0013_web_sessions.py +72 -0
  17. lorebase_store/_migrations/versions/0014_embedding_jobs.py +70 -0
  18. lorebase_store/_migrations/versions/0015_append_only_audit_log.py +83 -0
  19. lorebase_store/_migrations/versions/0016_sealed_webhook_keys.py +55 -0
  20. lorebase_store/_migrations/versions/0017_background_branch_copies.py +77 -0
  21. lorebase_store/_migrations/versions/0018_search_lexicon.py +477 -0
  22. lorebase_store/_migrations/versions/0019_vault_bindings.py +56 -0
  23. lorebase_store/_migrations/versions/0020_outbox_consumers.py +150 -0
  24. lorebase_store/_migrations/versions/0021_schema_suggestions.py +114 -0
  25. lorebase_store/_migrations/versions/0022_read_audit_and_shared_rate_limits.py +133 -0
  26. lorebase_store/_migrations/versions/0023_agent_trace.py +96 -0
  27. lorebase_store/_migrations/versions/0024_embedding_template_keys.py +205 -0
  28. lorebase_store/_migrations/versions/0025_snapshot_refs.py +35 -0
  29. lorebase_store/_migrations/versions/0026_plain_reverse_link_index.py +112 -0
  30. lorebase_store/_migrations/versions/0027_vault_rotation_and_tombstones.py +145 -0
  31. lorebase_store/_migrations/versions/0028_operation_deferred_events.py +71 -0
  32. lorebase_store/_migrations/versions/0029_agent_trace_sessions.py +108 -0
  33. lorebase_store/_migrations/versions/0030_path_fanout.py +31 -0
  34. lorebase_store/_migrations/versions/0031_branch_builds.py +91 -0
  35. lorebase_store/_migrations/versions/0032_lineage_revisions.py +35 -0
  36. lorebase_store/_migrations/versions/0033_vault_moved_tokens.py +30 -0
  37. lorebase_store/_migrations/versions/0034_inference_endpoint_modes.py +73 -0
  38. lorebase_store/_migrations/versions/0035_search_truncation.py +104 -0
  39. lorebase_store/_migrations/versions/0036_lineage_maps.py +325 -0
  40. lorebase_store/_migrations/versions/0037_bm25_search.py +346 -0
  41. lorebase_store/auth/__init__.py +52 -0
  42. lorebase_store/auth/api_keys.py +161 -0
  43. lorebase_store/auth/audit.py +382 -0
  44. lorebase_store/auth/checkpoints.py +185 -0
  45. lorebase_store/auth/grants.py +161 -0
  46. lorebase_store/auth/principals.py +158 -0
  47. lorebase_store/auth/scope.py +70 -0
  48. lorebase_store/auth/sessions.py +153 -0
  49. lorebase_store/auth/unit.py +53 -0
  50. lorebase_store/auth/usage.py +70 -0
  51. lorebase_store/blobs.py +73 -0
  52. lorebase_store/bm25.py +346 -0
  53. lorebase_store/bulk.py +147 -0
  54. lorebase_store/bundle.py +1830 -0
  55. lorebase_store/changefeed.py +261 -0
  56. lorebase_store/commit/__init__.py +42 -0
  57. lorebase_store/commit/changes.py +67 -0
  58. lorebase_store/commit/errors.py +60 -0
  59. lorebase_store/commit/migrations.py +335 -0
  60. lorebase_store/commit/pipeline.py +754 -0
  61. lorebase_store/commit/schema.py +123 -0
  62. lorebase_store/db.py +58 -0
  63. lorebase_store/definer.py +50 -0
  64. lorebase_store/digests.py +99 -0
  65. lorebase_store/embeddings.py +444 -0
  66. lorebase_store/endpoint_modes.py +59 -0
  67. lorebase_store/extraction/__init__.py +61 -0
  68. lorebase_store/extraction/items.py +187 -0
  69. lorebase_store/extraction/materialize.py +364 -0
  70. lorebase_store/extraction/proposal.py +65 -0
  71. lorebase_store/extraction/review.py +168 -0
  72. lorebase_store/gc.py +970 -0
  73. lorebase_store/indexes.py +1033 -0
  74. lorebase_store/jobs.py +651 -0
  75. lorebase_store/keyring.py +72 -0
  76. lorebase_store/layout.py +335 -0
  77. lorebase_store/materialize/__init__.py +103 -0
  78. lorebase_store/materialize/apply.py +436 -0
  79. lorebase_store/materialize/background.py +1184 -0
  80. lorebase_store/materialize/branch.py +268 -0
  81. lorebase_store/materialize/build.py +536 -0
  82. lorebase_store/materialize/derived.py +540 -0
  83. lorebase_store/materialize/lineage_backfill.py +502 -0
  84. lorebase_store/materialize/links.py +266 -0
  85. lorebase_store/materialize/overlay.py +292 -0
  86. lorebase_store/materialize/paths.py +315 -0
  87. lorebase_store/materialize/rebuild.py +326 -0
  88. lorebase_store/materialize/rows.py +153 -0
  89. lorebase_store/materialize/sensitivity.py +66 -0
  90. lorebase_store/materialize/snapshot.py +205 -0
  91. lorebase_store/materialize/stale.py +444 -0
  92. lorebase_store/materialize/verify.py +321 -0
  93. lorebase_store/merging/__init__.py +39 -0
  94. lorebase_store/merging/attempts.py +334 -0
  95. lorebase_store/merging/lineage.py +123 -0
  96. lorebase_store/merging/service.py +652 -0
  97. lorebase_store/migrations.py +133 -0
  98. lorebase_store/models/__init__.py +136 -0
  99. lorebase_store/models/base.py +64 -0
  100. lorebase_store/models/enums.py +132 -0
  101. lorebase_store/models/governance.py +268 -0
  102. lorebase_store/models/ingestion.py +288 -0
  103. lorebase_store/models/materialized.py +277 -0
  104. lorebase_store/models/proposals.py +195 -0
  105. lorebase_store/models/queues.py +87 -0
  106. lorebase_store/models/tracing.py +116 -0
  107. lorebase_store/models/versioning.py +222 -0
  108. lorebase_store/models/webhooks.py +96 -0
  109. lorebase_store/nodestore.py +384 -0
  110. lorebase_store/objectstore.py +162 -0
  111. lorebase_store/outbox_events.py +166 -0
  112. lorebase_store/profiling/__init__.py +58 -0
  113. lorebase_store/profiling/delimited.py +109 -0
  114. lorebase_store/profiling/postgres.py +211 -0
  115. lorebase_store/profiling/record_files.py +127 -0
  116. lorebase_store/profiling/records.py +92 -0
  117. lorebase_store/profiling/sql_databases.py +298 -0
  118. lorebase_store/ratelimit.py +129 -0
  119. lorebase_store/reads.py +1491 -0
  120. lorebase_store/replicas.py +434 -0
  121. lorebase_store/repositories/__init__.py +152 -0
  122. lorebase_store/repositories/changesets.py +59 -0
  123. lorebase_store/repositories/deferred_events.py +142 -0
  124. lorebase_store/repositories/documents.py +305 -0
  125. lorebase_store/repositories/domains.py +97 -0
  126. lorebase_store/repositories/events.py +205 -0
  127. lorebase_store/repositories/index_view.py +489 -0
  128. lorebase_store/repositories/invocations.py +68 -0
  129. lorebase_store/repositories/materializations.py +276 -0
  130. lorebase_store/repositories/materialized.py +1129 -0
  131. lorebase_store/repositories/merges.py +417 -0
  132. lorebase_store/repositories/proposal_items.py +199 -0
  133. lorebase_store/repositories/proposals.py +184 -0
  134. lorebase_store/repositories/provenance.py +77 -0
  135. lorebase_store/repositories/refs.py +452 -0
  136. lorebase_store/repositories/revisions.py +273 -0
  137. lorebase_store/repositories/scope.py +33 -0
  138. lorebase_store/repositories/source_runs.py +123 -0
  139. lorebase_store/repositories/suggestions.py +638 -0
  140. lorebase_store/runtime.py +87 -0
  141. lorebase_store/schema_cache.py +50 -0
  142. lorebase_store/settings.py +879 -0
  143. lorebase_store/storage.py +1478 -0
  144. lorebase_store/telemetry.py +142 -0
  145. lorebase_store/testing.py +108 -0
  146. lorebase_store/tracing.py +505 -0
  147. lorebase_store/traverse.py +578 -0
  148. lorebase_store/vault/__init__.py +50 -0
  149. lorebase_store/vault/keys.py +216 -0
  150. lorebase_store/vault/quotes.py +133 -0
  151. lorebase_store/vault/rotation.py +209 -0
  152. lorebase_store/vault/settings.py +152 -0
  153. lorebase_store/vault/store.py +987 -0
  154. lorebase_store/vault/transfer.py +558 -0
  155. lorebase_store/webhooks/__init__.py +24 -0
  156. lorebase_store/webhooks/delivery.py +637 -0
  157. lorebase_store/webhooks/keys.py +126 -0
  158. lorebase_store/webhooks/repository.py +142 -0
  159. lorebase_store/webhooks/settings.py +138 -0
  160. lorebase_store/webhooks/signing.py +50 -0
  161. lorebase_store-0.2.3.dist-info/METADATA +13 -0
  162. lorebase_store-0.2.3.dist-info/RECORD +163 -0
  163. lorebase_store-0.2.3.dist-info/WHEEL +4 -0
@@ -0,0 +1,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
+ ]