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,452 @@
1
+ """Refs: branch and tag heads, compare-and-swap moves, the branch lock and the ref log.
2
+
3
+ Design 6.5: writers to one branch are serialized by a transaction-scoped advisory lock on a
4
+ stable 64-bit hash of `(domain_id, branch)`; the head moves by compare-and-swap on
5
+ `(head, version)`.
6
+
7
+ A branch being deleted in the background (`deleted_at` set, `mark_deleted`) is invisible to
8
+ every method here except `get_deleting`, `delete` and `dependents`: reads, listings, counts
9
+ and overlay lookups skip it, and its name stays taken until `delete` removes the row.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import hashlib
15
+ from dataclasses import dataclass
16
+ from datetime import datetime
17
+
18
+ from sqlalchemy import Table, delete, func, insert, select, text, update
19
+ from sqlalchemy.exc import DBAPIError
20
+ from sqlalchemy.ext.asyncio import AsyncSession
21
+
22
+ from lorebase_store.jobs import (
23
+ INDEX_JOB,
24
+ MATERIALIZE_JOB,
25
+ SENSITIVITY_REFRESH_JOB,
26
+ cancel_queued_jobs,
27
+ )
28
+ from lorebase_store.layout import BranchLayout, layout_from
29
+ from lorebase_store.models import metadata
30
+ from lorebase_store.models.enums import Materialization, RefKind
31
+ from lorebase_store.repositories.deferred_events import DeferredEventRepository
32
+
33
+ REFS: Table = metadata.tables["refs"]
34
+ REF_LOG: Table = metadata.tables["ref_log"]
35
+
36
+ _LIVE = REFS.c.deleted_at.is_(None)
37
+ """Refs whose deletion is not in progress."""
38
+
39
+ LOCK_NOT_AVAILABLE = "55P03"
40
+ """SQLSTATE raised when `lock_timeout` expires."""
41
+
42
+ _LOCK_KEY_SCOPE = b"lb1\x00ref-lock\x00"
43
+
44
+
45
+ def advisory_lock_key(domain_id: str, name: str) -> int:
46
+ """Stable signed 64-bit advisory lock key of a ref."""
47
+ digest = hashlib.sha256(
48
+ _LOCK_KEY_SCOPE + domain_id.encode("utf-8") + b"\x00" + name.encode("utf-8")
49
+ ).digest()
50
+ return int.from_bytes(digest[:8], "big", signed=True)
51
+
52
+
53
+ def sqlstate(error: BaseException) -> str | None:
54
+ """SQLSTATE of a database error raised through SQLAlchemy, if any."""
55
+ current: BaseException | None = error
56
+ while current is not None:
57
+ code = getattr(current, "sqlstate", None)
58
+ if isinstance(code, str):
59
+ return code
60
+ current = getattr(current, "orig", None) or current.__cause__
61
+ return None
62
+
63
+
64
+ class RefNotFoundError(LookupError):
65
+ """The ref does not exist."""
66
+
67
+
68
+ class RefMovedError(Exception):
69
+ """Compare-and-swap failed: the ref no longer has the expected head and version."""
70
+
71
+ def __init__(self, domain_id: str, name: str, expected_head: bytes, expected_version: int):
72
+ super().__init__(
73
+ f"ref {name!r} of Domain {domain_id!r} moved from {expected_head.hex()} "
74
+ f"version {expected_version}"
75
+ )
76
+ self.domain_id = domain_id
77
+ self.name = name
78
+ self.expected_head = expected_head
79
+ self.expected_version = expected_version
80
+
81
+
82
+ class LockTimeoutError(Exception):
83
+ """The branch lock was not acquired within the configured time."""
84
+
85
+
86
+ @dataclass(frozen=True, slots=True)
87
+ class RefRow:
88
+ domain_id: str
89
+ name: str
90
+ kind: RefKind
91
+ head: bytes
92
+ protected: bool
93
+ materialization: Materialization
94
+ version: int
95
+ created_at: datetime
96
+ created_by: str
97
+ base_branch: str | None = None
98
+ base_revision: bytes | None = None
99
+ base_generation: int | None = None
100
+ deleted_at: datetime | None = None
101
+
102
+ @property
103
+ def layout(self) -> BranchLayout:
104
+ """Where the branch's materialized rows live (its own, or an overlay on a base)."""
105
+ return layout_from(
106
+ self.domain_id,
107
+ self.name,
108
+ self.materialization,
109
+ self.base_branch,
110
+ self.base_generation,
111
+ )
112
+
113
+
114
+ @dataclass(frozen=True, slots=True)
115
+ class RefLogRow:
116
+ ref_name: str
117
+ seq: int
118
+ old_head: bytes | None
119
+ new_head: bytes
120
+ reason: str
121
+ actor: str
122
+ at: datetime
123
+
124
+
125
+ class RefRepository:
126
+ """Refs of one Domain."""
127
+
128
+ def __init__(self, session: AsyncSession, domain_id: str) -> None:
129
+ self._session = session
130
+ self._domain = domain_id
131
+
132
+ async def get(self, name: str) -> RefRow | None:
133
+ result = await self._session.execute(
134
+ select(REFS).where(REFS.c.domain_id == self._domain, REFS.c.name == name, _LIVE)
135
+ )
136
+ found = result.mappings().one_or_none()
137
+ return None if found is None else RefRow(**found)
138
+
139
+ async def get_deleting(self, name: str, *, key_share: bool = False) -> RefRow | None:
140
+ """The ref named `name` whether or not its deletion is in progress. With `key_share`,
141
+ the row is locked `FOR KEY SHARE`, so it cannot be deleted before the caller's
142
+ transaction ends (commits still move it)."""
143
+ query = select(REFS).where(REFS.c.domain_id == self._domain, REFS.c.name == name)
144
+ if key_share:
145
+ query = query.with_for_update(key_share=True)
146
+ found = (await self._session.execute(query)).mappings().one_or_none()
147
+ return None if found is None else RefRow(**found)
148
+
149
+ async def require(self, name: str) -> RefRow:
150
+ found = await self.get(name)
151
+ if found is None:
152
+ raise RefNotFoundError(f"{self._domain}:{name}")
153
+ return found
154
+
155
+ async def all(self, kind: RefKind | None = None) -> list[RefRow]:
156
+ query = select(REFS).where(REFS.c.domain_id == self._domain, _LIVE).order_by(REFS.c.name)
157
+ if kind is not None:
158
+ query = query.where(REFS.c.kind == kind)
159
+ result = await self._session.execute(query)
160
+ return [RefRow(**row) for row in result.mappings()]
161
+
162
+ async def page(self, kind: RefKind, *, limit: int, after: str | None = None) -> list[RefRow]:
163
+ """Refs of one kind ordered by name, starting after `after`."""
164
+ query = (
165
+ select(REFS)
166
+ .where(REFS.c.domain_id == self._domain, REFS.c.kind == kind, _LIVE)
167
+ .order_by(REFS.c.name)
168
+ .limit(limit)
169
+ )
170
+ if after is not None:
171
+ query = query.where(REFS.c.name > after)
172
+ result = await self._session.execute(query)
173
+ return [RefRow(**row) for row in result.mappings()]
174
+
175
+ async def count(self, kind: RefKind, materialization: Materialization | None = None) -> int:
176
+ """Number of refs of one kind, optionally with one materialization mode."""
177
+ query = (
178
+ select(func.count())
179
+ .select_from(REFS)
180
+ .where(REFS.c.domain_id == self._domain, REFS.c.kind == kind, _LIVE)
181
+ )
182
+ if materialization is not None:
183
+ query = query.where(REFS.c.materialization == materialization)
184
+ return int(await self._session.scalar(query) or 0)
185
+
186
+ async def set_protected(self, name: str, protected: bool) -> RefRow | None:
187
+ """Set the protection flag of a branch; returns the ref, `None` when it does not exist."""
188
+ await self._session.execute(
189
+ update(REFS)
190
+ .where(REFS.c.domain_id == self._domain, REFS.c.name == name)
191
+ .values(protected=protected)
192
+ )
193
+ return await self.get(name)
194
+
195
+ async def delete(self, name: str) -> bool:
196
+ """Remove a ref (also one whose deletion is in progress). Materialized rows of a branch
197
+ go with it; the ref log is kept.
198
+
199
+ In the same transaction, the queued index builds, materializations and sensitivity
200
+ refreshes of the branch are cancelled: there is nothing left to build. Queued drops
201
+ stay, they clean up the branch's indexes. Operation events still deferred on the branch
202
+ (not merged) go too.
203
+ """
204
+ result = await self._session.execute(
205
+ delete(REFS)
206
+ .where(REFS.c.domain_id == self._domain, REFS.c.name == name)
207
+ .returning(REFS.c.name)
208
+ )
209
+ if result.scalar_one_or_none() is None:
210
+ return False
211
+ await self._cancel_jobs(name)
212
+ await DeferredEventRepository(self._session, self._domain).discard(name)
213
+ return True
214
+
215
+ async def mark_deleted(self, name: str, *, at: datetime) -> bool:
216
+ """Hide a branch whose rows a background job deletes (see the module doc); cancels
217
+ its queued index builds and materializations like `delete`."""
218
+ result = await self._session.execute(
219
+ update(REFS)
220
+ .where(REFS.c.domain_id == self._domain, REFS.c.name == name, _LIVE)
221
+ .values(deleted_at=at)
222
+ .returning(REFS.c.name)
223
+ )
224
+ if result.scalar_one_or_none() is None:
225
+ return False
226
+ await self._cancel_jobs(name)
227
+ return True
228
+
229
+ async def dependents(self, base: str) -> list[RefRow]:
230
+ """Every ref naming `base` as its base branch, deletions in progress included."""
231
+ result = await self._session.execute(
232
+ select(REFS)
233
+ .where(REFS.c.domain_id == self._domain, REFS.c.base_branch == base)
234
+ .order_by(REFS.c.name)
235
+ )
236
+ return [RefRow(**row) for row in result.mappings()]
237
+
238
+ async def _cancel_jobs(self, name: str) -> None:
239
+ await cancel_queued_jobs(
240
+ self._session,
241
+ INDEX_JOB,
242
+ domain_id=self._domain,
243
+ match={"branch": name, "action": "create"},
244
+ reason="branch deleted",
245
+ )
246
+ await cancel_queued_jobs(
247
+ self._session,
248
+ MATERIALIZE_JOB,
249
+ domain_id=self._domain,
250
+ match={"branch": name},
251
+ reason="branch deleted",
252
+ )
253
+ await cancel_queued_jobs(
254
+ self._session,
255
+ SENSITIVITY_REFRESH_JOB,
256
+ domain_id=self._domain,
257
+ match={"branch": name},
258
+ reason="branch deleted",
259
+ )
260
+
261
+ async def create(
262
+ self,
263
+ name: str,
264
+ head: bytes,
265
+ *,
266
+ created_at: datetime,
267
+ created_by: str,
268
+ kind: RefKind = RefKind.BRANCH,
269
+ materialization: Materialization = Materialization.FULL,
270
+ protected: bool = False,
271
+ reason: str = "create",
272
+ base_branch: str | None = None,
273
+ base_revision: bytes | None = None,
274
+ base_generation: int | None = None,
275
+ log: bool = True,
276
+ ) -> RefRow:
277
+ """Insert a ref at version 1 and, unless `log` is false, log its creation (overlays
278
+ name their base)."""
279
+ row = RefRow(
280
+ domain_id=self._domain,
281
+ name=name,
282
+ kind=kind,
283
+ head=head,
284
+ protected=protected,
285
+ materialization=materialization,
286
+ version=1,
287
+ created_at=created_at,
288
+ created_by=created_by,
289
+ base_branch=base_branch,
290
+ base_revision=base_revision,
291
+ base_generation=base_generation,
292
+ )
293
+ await self._session.execute(
294
+ insert(REFS).values(
295
+ domain_id=row.domain_id,
296
+ name=row.name,
297
+ kind=row.kind,
298
+ head=row.head,
299
+ protected=row.protected,
300
+ materialization=row.materialization,
301
+ version=row.version,
302
+ created_at=row.created_at,
303
+ created_by=row.created_by,
304
+ base_branch=row.base_branch,
305
+ base_revision=row.base_revision,
306
+ base_generation=row.base_generation,
307
+ )
308
+ )
309
+ if log:
310
+ await self._append_log(name, None, head, reason=reason, actor=created_by, at=created_at)
311
+ return row
312
+
313
+ async def overlays_of(self, base: str) -> list[RefRow]:
314
+ """Overlay branches whose base is `base`, by name."""
315
+ result = await self._session.execute(
316
+ select(REFS)
317
+ .where(
318
+ REFS.c.domain_id == self._domain,
319
+ REFS.c.base_branch == base,
320
+ REFS.c.materialization == Materialization.OVERLAY,
321
+ _LIVE,
322
+ )
323
+ .order_by(REFS.c.name)
324
+ )
325
+ return [RefRow(**row) for row in result.mappings()]
326
+
327
+ async def set_materialization(
328
+ self,
329
+ name: str,
330
+ materialization: Materialization,
331
+ *,
332
+ base_branch: str | None = None,
333
+ base_revision: bytes | None = None,
334
+ base_generation: int | None = None,
335
+ ) -> None:
336
+ """Change how a branch is materialized (promotion of an overlay, re-fork). The head
337
+ and version stay: commits in flight on the branch keep their compare-and-swap."""
338
+ await self._session.execute(
339
+ update(REFS)
340
+ .where(REFS.c.domain_id == self._domain, REFS.c.name == name)
341
+ .values(
342
+ materialization=materialization,
343
+ base_branch=base_branch,
344
+ base_revision=base_revision,
345
+ base_generation=base_generation,
346
+ )
347
+ )
348
+
349
+ async def lock(self, name: str, *, timeout_ms: int) -> None:
350
+ """Take the transaction-scoped advisory lock of a ref, waiting at most `timeout_ms`.
351
+
352
+ `timeout_ms = 0` waits without limit. Raises `LockTimeoutError`; the transaction is then
353
+ aborted and must be rolled back.
354
+ """
355
+ key = advisory_lock_key(self._domain, name)
356
+ if timeout_ms <= 0:
357
+ await self._session.execute(text("SELECT pg_advisory_xact_lock(:key)"), {"key": key})
358
+ return
359
+ previous = await self._session.scalar(text("SELECT current_setting('lock_timeout')"), {})
360
+ await self._set_lock_timeout(f"{timeout_ms}ms")
361
+ try:
362
+ await self._session.execute(text("SELECT pg_advisory_xact_lock(:key)"), {"key": key})
363
+ except DBAPIError as error:
364
+ if sqlstate(error) == LOCK_NOT_AVAILABLE:
365
+ raise LockTimeoutError(
366
+ f"branch {name!r} of Domain {self._domain!r} stayed locked for {timeout_ms} ms"
367
+ ) from error
368
+ raise
369
+ await self._set_lock_timeout(str(previous))
370
+
371
+ async def compare_and_swap(
372
+ self,
373
+ name: str,
374
+ *,
375
+ expected_head: bytes,
376
+ expected_version: int,
377
+ new_head: bytes,
378
+ actor: str,
379
+ at: datetime,
380
+ reason: str,
381
+ ) -> int:
382
+ """Move the ref from `(expected_head, expected_version)` to `new_head`.
383
+
384
+ Returns the new version; raises `RefMovedError` when the ref has moved meanwhile.
385
+ """
386
+ result = await self._session.execute(
387
+ update(REFS)
388
+ .where(
389
+ REFS.c.domain_id == self._domain,
390
+ REFS.c.name == name,
391
+ REFS.c.head == expected_head,
392
+ REFS.c.version == expected_version,
393
+ )
394
+ .values(head=new_head, version=REFS.c.version + 1)
395
+ .returning(REFS.c.version)
396
+ )
397
+ version = result.scalar_one_or_none()
398
+ if version is None:
399
+ raise RefMovedError(self._domain, name, expected_head, expected_version)
400
+ await self._append_log(name, expected_head, new_head, reason=reason, actor=actor, at=at)
401
+ return int(version)
402
+
403
+ async def log(self, name: str) -> list[RefLogRow]:
404
+ """Head movements of a ref, oldest first."""
405
+ result = await self._session.execute(
406
+ select(
407
+ REF_LOG.c.ref_name,
408
+ REF_LOG.c.seq,
409
+ REF_LOG.c.old_head,
410
+ REF_LOG.c.new_head,
411
+ REF_LOG.c.reason,
412
+ REF_LOG.c.actor,
413
+ REF_LOG.c.at,
414
+ )
415
+ .where(REF_LOG.c.domain_id == self._domain, REF_LOG.c.ref_name == name)
416
+ .order_by(REF_LOG.c.seq)
417
+ )
418
+ return [RefLogRow(**row) for row in result.mappings()]
419
+
420
+ async def _append_log(
421
+ self,
422
+ name: str,
423
+ old_head: bytes | None,
424
+ new_head: bytes,
425
+ *,
426
+ reason: str,
427
+ actor: str,
428
+ at: datetime,
429
+ ) -> None:
430
+ # The ref log outlives deleted refs, so the sequence continues after the last entry.
431
+ next_seq = (
432
+ select(func.coalesce(func.max(REF_LOG.c.seq), 0) + 1)
433
+ .where(REF_LOG.c.domain_id == self._domain, REF_LOG.c.ref_name == name)
434
+ .scalar_subquery()
435
+ )
436
+ await self._session.execute(
437
+ insert(REF_LOG).values(
438
+ domain_id=self._domain,
439
+ ref_name=name,
440
+ seq=next_seq,
441
+ old_head=old_head,
442
+ new_head=new_head,
443
+ reason=reason,
444
+ actor=actor,
445
+ at=at,
446
+ )
447
+ )
448
+
449
+ async def _set_lock_timeout(self, value: str) -> None:
450
+ await self._session.execute(
451
+ text("SELECT set_config('lock_timeout', :value, true)"), {"value": value}
452
+ )