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,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
|
+
)
|