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,1184 @@
1
+ """Large branches in the background: copies, builds and deletions in batches (store schema
2
+ notes, "Large branches").
3
+
4
+ **Copy.** A copy branch whose source holds more than `store.materialize.async_copy_min_rows`
5
+ rows is created as an overlay of its source (`create_branch`): readable and writable at once,
6
+ with the semantics of an overlay. `queue_materialization` records its progress row
7
+ (`branch_materializations`, status `pending`, strategy `base`) and queues a
8
+ `store.materialize` job, which `run_materialize_job` runs in batches, each in its own
9
+ transaction:
10
+
11
+ 1. `records`, `links`, `history`: copy the next base rows the overlay reads (revision
12
+ generation at or before its fork point; history of Revisions up to it) in primary key
13
+ order, from the cursor saved by the previous batch, with `INSERT ... ON CONFLICT DO
14
+ NOTHING`.
15
+ 2. `switch`: drop the overlay's tombstones, set `materialization` to `full` (head and version
16
+ unchanged) and queue the per-Attribute indexes of a full branch.
17
+ 3. When the overlay's schema changed during the copy (`apply.apply_changeset` records it in
18
+ `reconcile` instead of refusing the change), `prune`, `derived`, `paths` and `search`
19
+ recompute in batches what a schema change recomputes on a full branch (`build`).
20
+
21
+ Every batch holds the branch lock, so commits on the branch and copies on write from its base
22
+ wait for at most one batch (their inserts of the same keys could otherwise deadlock with it).
23
+ Batches are sized by the time they hold that lock (`_Pace`): from `job_batch_min_rows` up to
24
+ `job_batch_rows`, aiming at `job_batch_target_ms`, or at `job_batch_contended_ms` once a
25
+ commit waited for the lock. Between batches commits run freely, and the copy stays correct
26
+ because a copied row is the row the overlay already reads, and every row it shadows is kept:
27
+ - base rows written at or before the fork never change afterwards: a base commit that changes
28
+ or removes one first copies it into the overlay (copy on write), and stamps what it writes
29
+ with a later generation, which the copy skips;
30
+ - a commit on the branch first copies a base row up, then changes it, and a delete leaves a
31
+ tombstone: a batch that meets such a row finds it held and keeps it (`DO NOTHING`), and a
32
+ commit that meets a row a batch copied changes that copy.
33
+ So reads see the same rows before, during and after the copy (the overlay's rows win over the
34
+ base's, history included), and a batch run twice (a retry, a resumed job) copies nothing new.
35
+ A promotion, re-fork or rebuild, which delete rows of the branch, hold the copy fence
36
+ (`copy_fence`) against batches; a re-fork (another fork point) restarts the copy from the first
37
+ row. A batch that waits longer than `commit.lock_timeout_ms` for the branch lock, three times,
38
+ queues the job again after `job_retry_seconds`.
39
+
40
+ **Build.** A branch whose rows cannot come from an overlay's base (a large copy of an overlay
41
+ or of an older Revision, an overlay detached from its base) is `building`: its rows are
42
+ written from the tree of its head (strategy `tree`: `clear`, `tree_history`, `tree_rows`,
43
+ `derived`, `paths`, then `switch` to `full`). Meanwhile reads of its head are served from the
44
+ tree, and commits fail with `BranchMaterializingError` (retry when it is done).
45
+
46
+ **Failures.** A copy whose job fails `store.jobs.max_attempts` times is queued again after a
47
+ delay doubling from `job_retry_seconds` (at most `copy_retry_max_seconds`); after
48
+ `copy_max_failures` failed jobs, an overlay is detached from its base and built from its tree
49
+ instead (outcome `fallback`), so it no longer holds up the deletion of its base. A build that
50
+ fails as often is marked `failed` (outcome `given_up`); queuing it again resumes it.
51
+
52
+ **Delete.** `delete_branch` deletes a branch holding at most `async_delete_min_rows` rows in
53
+ the caller's transaction (promoting overlays based on it first). A larger one is hidden at once
54
+ (`refs.deleted_at`): reads, listings and commits no longer find it, its queued index builds and
55
+ copies are cancelled, and a `store.branch_delete` job deletes its rows in batches, then the
56
+ ref. The name stays taken until then. Overlays based on it keep reading its rows: their copies
57
+ are queued, and the deletion waits for them before it deletes a row.
58
+
59
+ At most `job_concurrency` of these jobs run at once across workers (session advisory locks);
60
+ a job that finds every slot taken, or that waits, queues itself again after
61
+ `job_retry_seconds` and succeeds.
62
+ """
63
+
64
+ from __future__ import annotations
65
+
66
+ import asyncio
67
+ import json
68
+ import logging
69
+ import time
70
+ from collections.abc import AsyncIterator, Callable, Mapping, Sequence
71
+ from contextlib import asynccontextmanager
72
+ from dataclasses import dataclass, replace
73
+ from datetime import UTC, datetime, timedelta
74
+ from enum import StrEnum
75
+ from typing import Any
76
+
77
+ from sqlalchemy import Table, text
78
+ from sqlalchemy.exc import DBAPIError
79
+ from sqlalchemy.ext.asyncio import AsyncEngine, AsyncSession, async_sessionmaker
80
+
81
+ from lorebase_core.tree import PARTITION_SCHEMA
82
+ from lorebase_store.indexes import BranchIndexes, sql_literal
83
+ from lorebase_store.jobs import BRANCH_DELETE_JOB, MATERIALIZE_JOB, JobRow, enqueue_job
84
+ from lorebase_store.layout import MAT_HISTORY, MAT_LINKS, MAT_RECORDS
85
+ from lorebase_store.materialize import build
86
+ from lorebase_store.materialize.overlay import promote_overlays_of
87
+ from lorebase_store.materialize.stale import queue_if_stale
88
+ from lorebase_store.models.enums import Materialization
89
+ from lorebase_store.nodestore import NodeRepository
90
+ from lorebase_store.repositories.materializations import (
91
+ BranchMaterializationRepository,
92
+ MaterializationPhase,
93
+ MaterializationState,
94
+ MaterializationStatus,
95
+ MaterializationStrategy,
96
+ copy_fence,
97
+ )
98
+ from lorebase_store.repositories.materialized import MaterializedRepository
99
+ from lorebase_store.repositories.refs import (
100
+ LockTimeoutError,
101
+ RefRepository,
102
+ RefRow,
103
+ advisory_lock_key,
104
+ sqlstate,
105
+ )
106
+ from lorebase_store.repositories.revisions import RevisionRepository, RevisionRow
107
+ from lorebase_store.repositories.scope import scope_to_domain
108
+ from lorebase_store.runtime import StoreRuntime, default_runtime
109
+ from lorebase_store.settings import MaterializeSettings
110
+ from lorebase_store.storage import constraints_only
111
+ from lorebase_store.telemetry import (
112
+ BRANCH_JOB_BATCH_DURATION,
113
+ BRANCH_JOB_BATCH_ROWS,
114
+ BRANCH_JOB_ROWS,
115
+ BRANCH_JOB_RUNS,
116
+ )
117
+
118
+ log = logging.getLogger(__name__)
119
+
120
+ Clock = Callable[[], datetime]
121
+
122
+ _RETRYABLE = frozenset({"40P01", "40001"})
123
+ """Deadlock and serialization failures: the batch is run again."""
124
+ _ATTEMPTS = 3
125
+ """Runs of a batch that hits a retryable failure, or of a switch that finds the branch busy,
126
+ before the job queues itself again."""
127
+ _CONTENDED_FRACTION = 10
128
+ """A batch that waited for the branch lock longer than `job_batch_contended_ms` divided by
129
+ this found a commit holding it (an uncontended lock is taken in well under that)."""
130
+
131
+
132
+ def _now() -> datetime:
133
+ return datetime.now(UTC)
134
+
135
+
136
+ class BranchDeletion(StrEnum):
137
+ """How `delete_branch` deleted a branch."""
138
+
139
+ DELETED = "deleted"
140
+ """Ref and rows are gone."""
141
+ SCHEDULED = "scheduled"
142
+ """The branch is hidden; a `store.branch_delete` job deletes its rows."""
143
+
144
+
145
+ @dataclass(frozen=True, slots=True)
146
+ class _Copied:
147
+ """A table a background copy reads, in primary key order after (domain, branch)."""
148
+
149
+ phase: MaterializationPhase
150
+ table: Table
151
+ key: tuple[str, ...]
152
+
153
+
154
+ _COPIED = (
155
+ _Copied(MaterializationPhase.RECORDS, MAT_RECORDS, ("kind_id", "key")),
156
+ _Copied(MaterializationPhase.LINKS, MAT_LINKS, ("relation_id", "from_key", "to_key")),
157
+ _Copied(MaterializationPhase.HISTORY, MAT_HISTORY, ("partition", "element_key", "revision_id")),
158
+ )
159
+ _NEXT = {
160
+ MaterializationPhase.RECORDS: MaterializationPhase.LINKS,
161
+ MaterializationPhase.LINKS: MaterializationPhase.HISTORY,
162
+ MaterializationPhase.HISTORY: MaterializationPhase.SWITCH,
163
+ }
164
+ _NEXT_BUILD = {
165
+ MaterializationPhase.CLEAR: MaterializationPhase.TREE_HISTORY,
166
+ MaterializationPhase.TREE_HISTORY: MaterializationPhase.TREE_ROWS,
167
+ MaterializationPhase.TREE_ROWS: MaterializationPhase.DERIVED,
168
+ MaterializationPhase.DERIVED: MaterializationPhase.PATHS,
169
+ MaterializationPhase.PATHS: MaterializationPhase.SWITCH,
170
+ }
171
+ _NEXT_RECONCILE = {
172
+ MaterializationPhase.PRUNE: MaterializationPhase.DERIVED,
173
+ MaterializationPhase.DERIVED: MaterializationPhase.PATHS,
174
+ MaterializationPhase.PATHS: MaterializationPhase.SEARCH,
175
+ MaterializationPhase.SEARCH: MaterializationPhase.DONE,
176
+ }
177
+
178
+
179
+ # Queueing ---------------------------------------------------------------------------------------
180
+
181
+
182
+ async def queue_materialization(
183
+ session: AsyncSession, domain_id: str, ref: RefRow, *, at: datetime
184
+ ) -> MaterializationState:
185
+ """Queue the background copy of the overlay `ref` (in the caller's transaction): its
186
+ progress row starts `pending` (a failed copy of the same fork point keeps its cursor)
187
+ unless a copy is already queued or running, and a `store.materialize` job is queued unless
188
+ one is."""
189
+ if ref.base_branch is None or ref.base_generation is None:
190
+ raise ValueError(f"branch {ref.name!r} is not an overlay")
191
+ states = BranchMaterializationRepository(session, domain_id)
192
+ state = await states.get(ref.name, for_update=True)
193
+ same_fork = (
194
+ state is not None
195
+ and state.strategy is MaterializationStrategy.BASE
196
+ and (state.base_branch, state.base_generation) == (ref.base_branch, ref.base_generation)
197
+ )
198
+ if state is not None and same_fork and state.status is MaterializationStatus.FAILED:
199
+ await states.update(ref.name, at=at, status=MaterializationStatus.PENDING)
200
+ state = await states.get(ref.name)
201
+ assert state is not None
202
+ elif state is None or not same_fork or not state.active:
203
+ state = await states.start(
204
+ ref.name,
205
+ base_branch=ref.base_branch,
206
+ base_generation=ref.base_generation,
207
+ status=MaterializationStatus.PENDING,
208
+ at=at,
209
+ )
210
+ await enqueue_job(
211
+ session, MATERIALIZE_JOB, domain_id=domain_id, payload={"branch": ref.name}, unique=True
212
+ )
213
+ return state
214
+
215
+
216
+ async def queue_build(
217
+ session: AsyncSession,
218
+ domain_id: str,
219
+ branch: str,
220
+ *,
221
+ source: str,
222
+ head: RevisionRow,
223
+ at: datetime,
224
+ failures: int = 0,
225
+ unique: bool = True,
226
+ ) -> MaterializationState:
227
+ """Queue the build of the `building` branch `branch` from the tree of its head (in the
228
+ caller's transaction); `source` is recorded as where it came from."""
229
+ state = await BranchMaterializationRepository(session, domain_id).start(
230
+ branch,
231
+ base_branch=source,
232
+ base_generation=head.generation,
233
+ status=MaterializationStatus.PENDING,
234
+ strategy=MaterializationStrategy.TREE,
235
+ failures=failures,
236
+ at=at,
237
+ )
238
+ await enqueue_job(
239
+ session, MATERIALIZE_JOB, domain_id=domain_id, payload={"branch": branch}, unique=unique
240
+ )
241
+ return state
242
+
243
+
244
+ async def detach_to_build(
245
+ session: AsyncSession,
246
+ domain_id: str,
247
+ branch: str,
248
+ *,
249
+ runtime: StoreRuntime,
250
+ at: datetime,
251
+ failures: int = 0,
252
+ unique: bool = True,
253
+ ) -> bool:
254
+ """Detach the overlay `branch` from its base, in the caller's transaction: it becomes
255
+ `building` (no base, head and version unchanged) and a job writes its rows from the tree
256
+ of its head (`queue_build`). Its base no longer waits for it. Takes the branch lock (after
257
+ the base's, when the caller holds it) and the copy fence. `False` when it is not an
258
+ overlay."""
259
+ refs = RefRepository(session, domain_id)
260
+ await refs.lock(branch, timeout_ms=runtime.settings.commit.lock_timeout_ms)
261
+ await copy_fence(session, domain_id, branch)
262
+ ref = await refs.get(branch)
263
+ if ref is None or ref.materialization is not Materialization.OVERLAY:
264
+ return False
265
+ head = await RevisionRepository(session, domain_id).require(ref.head)
266
+ await refs.set_materialization(branch, Materialization.BUILDING)
267
+ await queue_build(
268
+ session,
269
+ domain_id,
270
+ branch,
271
+ source=ref.base_branch or branch,
272
+ head=head,
273
+ at=at,
274
+ failures=failures,
275
+ unique=unique,
276
+ )
277
+ log.warning(
278
+ "overlay branch %s of Domain %s detached from its base %s; it is rebuilt from its tree",
279
+ branch,
280
+ domain_id,
281
+ ref.base_branch,
282
+ )
283
+ return True
284
+
285
+
286
+ async def delete_branch(
287
+ session: AsyncSession,
288
+ domain_id: str,
289
+ name: str,
290
+ *,
291
+ runtime: StoreRuntime | None = None,
292
+ clock: Clock | None = None,
293
+ ) -> BranchDeletion:
294
+ """Delete branch `name` (see the module doc) in the caller's transaction, after taking its
295
+ lock. Raises `RefNotFoundError` when it does not exist (or is already being deleted)."""
296
+ runtime = runtime or default_runtime()
297
+ now = (clock or _now)()
298
+ settings = runtime.settings.materialize
299
+ refs = RefRepository(session, domain_id)
300
+ await refs.lock(name, timeout_ms=runtime.settings.commit.lock_timeout_ms)
301
+ ref = await refs.require(name)
302
+ rows = MaterializedRepository(
303
+ session, domain_id, name, writes=runtime.settings.writes, layout=ref.layout
304
+ )
305
+ if await rows.own_rows(limit=settings.async_delete_min_rows) <= settings.async_delete_min_rows:
306
+ await promote_overlays_of(session, domain_id, name, runtime=runtime)
307
+ await refs.delete(name)
308
+ return BranchDeletion.DELETED
309
+ overlays = await refs.overlays_of(name)
310
+ await refs.mark_deleted(name, at=now)
311
+ for overlay in overlays:
312
+ await queue_materialization(session, domain_id, overlay, at=now)
313
+ await enqueue_job(
314
+ session, BRANCH_DELETE_JOB, domain_id=domain_id, payload={"branch": name}, unique=True
315
+ )
316
+ log.info(
317
+ "branch %s of Domain %s hidden; its rows are deleted in the background", name, domain_id
318
+ )
319
+ return BranchDeletion.SCHEDULED
320
+
321
+
322
+ # Jobs -------------------------------------------------------------------------------------------
323
+
324
+
325
+ @dataclass
326
+ class _Pace:
327
+ """Rows of the next batch, adapted to the time batches hold the branch lock: halved
328
+ toward the target when a batch held it too long, doubled when it held it for less than
329
+ half the target. The target is `job_batch_contended_ms` after a commit waited for the
330
+ lock (before or during the batch), `job_batch_target_ms` otherwise."""
331
+
332
+ low: int
333
+ high: int
334
+ target_s: float
335
+ contended_s: float
336
+ size: int
337
+
338
+ @classmethod
339
+ def of(cls, settings: MaterializeSettings) -> _Pace:
340
+ low = min(settings.job_batch_min_rows, settings.job_batch_rows)
341
+ return cls(
342
+ low=low,
343
+ high=settings.job_batch_rows,
344
+ target_s=settings.job_batch_target_ms / 1000,
345
+ contended_s=settings.job_batch_contended_ms / 1000,
346
+ size=low,
347
+ )
348
+
349
+ def contended(self, waited_s: float, waiters: int) -> bool:
350
+ return waiters > 0 or waited_s > self.contended_s / _CONTENDED_FRACTION
351
+
352
+ def next(self, held_s: float, *, contended: bool) -> None:
353
+ aim = self.contended_s if contended else self.target_s
354
+ if held_s > aim:
355
+ self.size = max(self.low, int(self.size * aim / held_s))
356
+ elif held_s < aim / 2:
357
+ self.size = min(self.high, self.size * 2)
358
+
359
+
360
+ @dataclass
361
+ class _Run:
362
+ """What one job run did, for its result."""
363
+
364
+ job: str
365
+ branch: str
366
+ pace: _Pace
367
+ batches: int = 0
368
+ rows: int = 0
369
+ promoted: bool = False
370
+
371
+ def done(self, outcome: str, **extra: Any) -> dict[str, Any]:
372
+ BRANCH_JOB_RUNS.add(1, {"job": self.job, "outcome": outcome})
373
+ return {
374
+ "branch": self.branch,
375
+ "status": outcome,
376
+ "batches": self.batches,
377
+ "rows": self.rows,
378
+ **extra,
379
+ }
380
+
381
+
382
+ @dataclass(frozen=True, slots=True)
383
+ class _Step:
384
+ """One batch transaction: rows it read, or the job's result when it was the last; how
385
+ long it waited for the branch lock and whether a commit waited for it."""
386
+
387
+ rows: int = 0
388
+ finished: dict[str, Any] | None = None
389
+ again: bool = False
390
+ waited_s: float = 0.0
391
+ contended: bool = False
392
+
393
+
394
+ def _payload_branch(job: JobRow) -> tuple[str, str]:
395
+ if job.domain_id is None:
396
+ raise ValueError(f"a {job.type} job needs a Domain")
397
+ branch = job.payload.get("branch")
398
+ if not isinstance(branch, str) or not branch:
399
+ raise ValueError(f"a {job.type} job needs a `branch`")
400
+ return job.domain_id, branch
401
+
402
+
403
+ class _Slot(StrEnum):
404
+ ACQUIRED = "acquired"
405
+ BUSY = "busy"
406
+ """Every one of `job_concurrency` slots is taken."""
407
+ RUNNING = "running"
408
+ """Another job works on the same branch."""
409
+
410
+
411
+ _TRY_LOCK = text("SELECT pg_try_advisory_lock(:key)")
412
+ _UNLOCK_ALL = text("SELECT pg_advisory_unlock_all()")
413
+ _WAITERS = text(
414
+ "SELECT count(*) FROM pg_locks WHERE locktype = 'advisory' AND NOT granted "
415
+ "AND classid = CAST((CAST(:key AS bigint) >> 32) & 4294967295 AS oid) "
416
+ "AND objid = CAST(CAST(:key AS bigint) & 4294967295 AS oid) AND objsubid = 1"
417
+ )
418
+
419
+
420
+ @asynccontextmanager
421
+ async def _slot(
422
+ engine: AsyncEngine, domain_id: str, branch: str, settings: MaterializeSettings
423
+ ) -> AsyncIterator[_Slot]:
424
+ """Hold, on a connection of its own, the branch's job lock and one of the
425
+ `job_concurrency` slots shared by every worker (session advisory locks: a worker that
426
+ dies releases them with its connection)."""
427
+ async with engine.connect() as connection:
428
+ auto = await connection.execution_options(isolation_level="AUTOCOMMIT")
429
+ try:
430
+ branch_key = advisory_lock_key(domain_id, f"\x00branch-job\x00{branch}")
431
+ if not (await auto.execute(_TRY_LOCK, {"key": branch_key})).scalar_one():
432
+ yield _Slot.RUNNING
433
+ return
434
+ for slot in range(settings.job_concurrency):
435
+ key = advisory_lock_key("", f"\x00branch-job-slot\x00{slot}")
436
+ if (await auto.execute(_TRY_LOCK, {"key": key})).scalar_one():
437
+ yield _Slot.ACQUIRED
438
+ return
439
+ yield _Slot.BUSY
440
+ finally:
441
+ await auto.execute(_UNLOCK_ALL)
442
+
443
+
444
+ async def _again(
445
+ engine: AsyncEngine,
446
+ job: JobRow,
447
+ settings: MaterializeSettings,
448
+ clock: Clock,
449
+ *,
450
+ delay_s: float | None = None,
451
+ ) -> int:
452
+ """Queue the job once more after `job_retry_seconds` (or `delay_s`); the running one then
453
+ succeeds (or fails)."""
454
+ sessions = async_sessionmaker(engine, expire_on_commit=False)
455
+ delay = settings.job_retry_seconds if delay_s is None else delay_s
456
+ async with sessions() as session, session.begin():
457
+ return await enqueue_job(
458
+ session,
459
+ job.type,
460
+ domain_id=job.domain_id,
461
+ payload=job.payload,
462
+ priority=job.priority,
463
+ run_after=clock() + timedelta(seconds=delay),
464
+ )
465
+
466
+
467
+ async def _lock_branch(
468
+ session: AsyncSession, refs: RefRepository, branch: str, runtime: StoreRuntime
469
+ ) -> float:
470
+ """Take the branch lock; returns how long it took."""
471
+ started = time.perf_counter()
472
+ await refs.lock(branch, timeout_ms=runtime.settings.commit.lock_timeout_ms)
473
+ return time.perf_counter() - started
474
+
475
+
476
+ async def _waiters(session: AsyncSession, domain_id: str, branch: str) -> int:
477
+ """Transactions waiting for the branch lock this transaction holds (commits)."""
478
+ key = advisory_lock_key(domain_id, branch)
479
+ return int((await session.execute(_WAITERS, {"key": key})).scalar_one())
480
+
481
+
482
+ async def _batches(
483
+ engine: AsyncEngine,
484
+ job: JobRow,
485
+ run: _Run,
486
+ step: Callable[[AsyncSession], Any],
487
+ settings: MaterializeSettings,
488
+ clock: Clock,
489
+ ) -> dict[str, Any]:
490
+ """Run `step` in one transaction after another until it finishes the job, sizing each
491
+ batch by the time the previous one held the branch lock (`_Pace`)."""
492
+ sessions = async_sessionmaker(engine, expire_on_commit=False)
493
+ failures = 0
494
+ while True:
495
+ started = time.perf_counter()
496
+ try:
497
+ async with sessions() as session, session.begin():
498
+ outcome: _Step = await step(session)
499
+ except LockTimeoutError:
500
+ failures += 1
501
+ run.pace.next(float("inf"), contended=True)
502
+ if failures >= _ATTEMPTS:
503
+ queued = await _again(engine, job, settings, clock)
504
+ return run.done("deferred", detail="the branch stayed locked", again=queued)
505
+ await asyncio.sleep(min(settings.job_retry_seconds, 1.0))
506
+ continue
507
+ except DBAPIError as error:
508
+ failures += 1
509
+ if sqlstate(error) not in _RETRYABLE or failures >= _ATTEMPTS:
510
+ raise
511
+ log.info("%s job on branch %s: batch retried after %s", run.job, run.branch, error)
512
+ continue
513
+ elapsed = time.perf_counter() - started
514
+ BRANCH_JOB_BATCH_DURATION.record(elapsed, {"job": run.job})
515
+ failures = 0
516
+ if outcome.rows:
517
+ BRANCH_JOB_BATCH_ROWS.record(run.pace.size, {"job": run.job})
518
+ run.batches += 1
519
+ run.rows += outcome.rows
520
+ BRANCH_JOB_ROWS.add(outcome.rows, {"job": run.job})
521
+ run.pace.next(max(elapsed - outcome.waited_s, 0.0), contended=outcome.contended)
522
+ if outcome.finished is not None:
523
+ if outcome.again:
524
+ outcome.finished["again"] = await _again(engine, job, settings, clock)
525
+ return outcome.finished
526
+
527
+
528
+ async def run_materialize_job(
529
+ engine: AsyncEngine,
530
+ job: JobRow,
531
+ *,
532
+ runtime: StoreRuntime | None = None,
533
+ clock: Clock | None = None,
534
+ ) -> Mapping[str, Any]:
535
+ """Job handler of `store.materialize`: make the branch named by the payload a full copy
536
+ in batches, from its base (an overlay) or from its tree (a `building` branch), then
537
+ reconcile schema changes made meanwhile (module doc). Resumes where a previous run
538
+ stopped.
539
+
540
+ The result has `promoted` (whether this run made the branch a full copy) and `status`:
541
+ `done`, `deferred` (no free slot; queued again), `skipped` (another job copies it) or
542
+ `cancelled` (the branch is gone or being deleted).
543
+ """
544
+ runtime = runtime or default_runtime()
545
+ now = clock or _now
546
+ domain_id, branch = _payload_branch(job)
547
+ settings = runtime.settings.materialize
548
+ run = _Run("materialize", branch, _Pace.of(settings))
549
+ async with _slot(engine, domain_id, branch, settings) as slot:
550
+ if slot is _Slot.RUNNING:
551
+ return run.done("skipped", promoted=False, detail="another job copies the branch")
552
+ if slot is _Slot.BUSY:
553
+ queued = await _again(engine, job, settings, now)
554
+ return run.done("deferred", promoted=False, detail="no free slot", again=queued)
555
+
556
+ async def step(session: AsyncSession) -> _Step:
557
+ return await _materialize_step(session, domain_id, branch, run, runtime, now)
558
+
559
+ try:
560
+ return await _batches(engine, job, run, step, settings, now)
561
+ except Exception as error:
562
+ BRANCH_JOB_RUNS.add(1, {"job": run.job, "outcome": "failed"})
563
+ final = job.attempts >= runtime.settings.jobs.max_attempts
564
+ await _record_failure(engine, job, error, final=final, runtime=runtime, clock=now)
565
+ raise
566
+
567
+
568
+ async def _record_failure(
569
+ engine: AsyncEngine,
570
+ job: JobRow,
571
+ error: Exception,
572
+ *,
573
+ final: bool,
574
+ runtime: StoreRuntime,
575
+ clock: Clock,
576
+ ) -> None:
577
+ """Keep the error on the progress row. Once the job has no attempt left: queue the copy
578
+ again with backoff while it has failed fewer than `copy_max_failures` times; then detach
579
+ an overlay and build it from its tree; a build is marked `failed`."""
580
+ domain_id, branch = _payload_branch(job)
581
+ settings = runtime.settings.materialize
582
+ sessions = async_sessionmaker(engine, expire_on_commit=False)
583
+ message = f"{type(error).__name__}: {error}"[:2000]
584
+ try:
585
+ async with sessions() as session, session.begin():
586
+ await scope_to_domain(session, domain_id)
587
+ states = BranchMaterializationRepository(session, domain_id)
588
+ state = await states.get(branch, for_update=True)
589
+ if state is None:
590
+ return
591
+ if not final:
592
+ await states.update(
593
+ branch, at=clock(), status=MaterializationStatus.PENDING, error=message
594
+ )
595
+ return
596
+ failures = state.failures + 1
597
+ fallback = failures >= settings.copy_max_failures
598
+ if not fallback:
599
+ delay = min(
600
+ settings.job_retry_seconds * 2**failures, settings.copy_retry_max_seconds
601
+ )
602
+ await states.update(
603
+ branch,
604
+ at=clock(),
605
+ status=MaterializationStatus.PENDING,
606
+ error=message,
607
+ failures=failures,
608
+ )
609
+ await enqueue_job(
610
+ session,
611
+ MATERIALIZE_JOB,
612
+ domain_id=domain_id,
613
+ payload=job.payload,
614
+ priority=job.priority,
615
+ run_after=clock() + timedelta(seconds=delay),
616
+ )
617
+ BRANCH_JOB_RUNS.add(1, {"job": "materialize", "outcome": "retried"})
618
+ log.warning(
619
+ "copy of branch %s of Domain %s failed (%d of %d): retried in %.0f s",
620
+ branch,
621
+ domain_id,
622
+ failures,
623
+ settings.copy_max_failures,
624
+ delay,
625
+ )
626
+ return
627
+ # Only a copy from the base can fall back to a build; a build, or the
628
+ # reconciliation of a branch already full, has nothing else to try.
629
+ detach = (
630
+ state.strategy is MaterializationStrategy.BASE
631
+ and state.phase not in _NEXT_RECONCILE
632
+ )
633
+ status = MaterializationStatus.PENDING if detach else MaterializationStatus.FAILED
634
+ await states.update(branch, at=clock(), status=status, error=message, failures=failures)
635
+ if detach:
636
+ await _fall_back(engine, job, failures=failures, runtime=runtime, clock=clock)
637
+ return
638
+ BRANCH_JOB_RUNS.add(1, {"job": "materialize", "outcome": "given_up"})
639
+ log.error(
640
+ "build of branch %s of Domain %s failed %d times; queue a store.materialize job "
641
+ "for it again once the cause is fixed: %s",
642
+ branch,
643
+ domain_id,
644
+ failures,
645
+ message,
646
+ )
647
+ except Exception:
648
+ log.exception("could not record the failure of the copy of branch %s", branch)
649
+
650
+
651
+ async def _fall_back(
652
+ engine: AsyncEngine, job: JobRow, *, failures: int, runtime: StoreRuntime, clock: Clock
653
+ ) -> None:
654
+ """Detach the overlay whose copy kept failing (`detach_to_build`); when its lock stays
655
+ taken, queue the copy again so that its next failure tries once more."""
656
+ domain_id, branch = _payload_branch(job)
657
+ sessions = async_sessionmaker(engine, expire_on_commit=False)
658
+ try:
659
+ async with sessions() as session, session.begin():
660
+ await scope_to_domain(session, domain_id)
661
+ detached = await detach_to_build(
662
+ session,
663
+ domain_id,
664
+ branch,
665
+ runtime=runtime,
666
+ at=clock(),
667
+ failures=failures,
668
+ unique=False,
669
+ )
670
+ except LockTimeoutError:
671
+ await _again(engine, job, runtime.settings.materialize, clock)
672
+ return
673
+ if not detached:
674
+ # No longer an overlay (made full or deleted meanwhile): nothing left to fall back from.
675
+ async with sessions() as session, session.begin():
676
+ await scope_to_domain(session, domain_id)
677
+ states = BranchMaterializationRepository(session, domain_id)
678
+ state = await states.get(branch, for_update=True)
679
+ if state is not None and state.active:
680
+ await states.update(branch, at=clock(), status=MaterializationStatus.FAILED)
681
+ BRANCH_JOB_RUNS.add(1, {"job": "materialize", "outcome": "given_up"})
682
+ return
683
+ BRANCH_JOB_RUNS.add(1, {"job": "materialize", "outcome": "fallback"})
684
+ log.error(
685
+ "copy of overlay branch %s of Domain %s failed %d times: detached from its base and "
686
+ "rebuilt from its tree",
687
+ branch,
688
+ domain_id,
689
+ failures,
690
+ )
691
+
692
+
693
+ async def _materialize_step(
694
+ session: AsyncSession,
695
+ domain_id: str,
696
+ branch: str,
697
+ run: _Run,
698
+ runtime: StoreRuntime,
699
+ clock: Clock,
700
+ ) -> _Step:
701
+ await scope_to_domain(session, domain_id)
702
+ refs = RefRepository(session, domain_id)
703
+ # The branch lock: no commit on the branch, and no copy on write into it from its base,
704
+ # runs during the batch (their inserts of the same keys could deadlock with it). Then the
705
+ # fence, before reading the ref: a promotion, re-fork or rebuild in progress ends first,
706
+ # and this batch sees what it left.
707
+ waited = await _lock_branch(session, refs, branch, runtime)
708
+ await copy_fence(session, domain_id, branch, shared=True)
709
+ # KEY SHARE: the ref (and the rows that reference it) cannot be deleted meanwhile.
710
+ ref = await refs.get_deleting(branch, key_share=True)
711
+ if ref is None or ref.deleted_at is not None:
712
+ return _Step(
713
+ finished=run.done("cancelled", promoted=False, detail="the branch no longer exists")
714
+ )
715
+ states = BranchMaterializationRepository(session, domain_id)
716
+ state = await states.get(branch, for_update=True)
717
+ if ref.materialization is Materialization.BUILDING:
718
+ outcome = await _build_step(session, domain_id, ref, state, run, runtime, clock)
719
+ elif ref.materialization is Materialization.OVERLAY and ref.base_branch is not None:
720
+ outcome = await _copy_step(session, domain_id, ref, state, run, runtime, clock)
721
+ elif state is not None and state.active and state.phase in _NEXT_RECONCILE:
722
+ outcome = await _reconcile_step(session, domain_id, ref, state, run, runtime, clock)
723
+ else:
724
+ if state is not None and state.status is not MaterializationStatus.DONE:
725
+ await states.finish(branch, at=clock())
726
+ outcome = _Step(finished=run.done("done", promoted=run.promoted))
727
+ if outcome.finished is not None:
728
+ return outcome
729
+ waiters = await _waiters(session, domain_id, branch)
730
+ return replace(outcome, waited_s=waited, contended=run.pace.contended(waited, waiters))
731
+
732
+
733
+ async def _copy_step(
734
+ session: AsyncSession,
735
+ domain_id: str,
736
+ ref: RefRow,
737
+ state: MaterializationState | None,
738
+ run: _Run,
739
+ runtime: StoreRuntime,
740
+ clock: Clock,
741
+ ) -> _Step:
742
+ """One batch of the copy of an overlay from its base (strategy `base`)."""
743
+ branch = ref.name
744
+ assert ref.base_branch is not None
745
+ assert ref.base_generation is not None
746
+ states = BranchMaterializationRepository(session, domain_id)
747
+ now = clock()
748
+ if (
749
+ state is None
750
+ or state.strategy is not MaterializationStrategy.BASE
751
+ or (state.base_branch, state.base_generation) != (ref.base_branch, ref.base_generation)
752
+ ):
753
+ # First run, or re-forked since: start from the first row of the new fork point.
754
+ state = await states.start(
755
+ branch,
756
+ base_branch=ref.base_branch,
757
+ base_generation=ref.base_generation,
758
+ status=MaterializationStatus.RUNNING,
759
+ failures=0 if state is None else state.failures,
760
+ reconcile=None if state is None else state.reconcile,
761
+ at=now,
762
+ )
763
+ if state.rows_total is None:
764
+ total = await _base_rows(session, domain_id, ref.base_branch)
765
+ await states.update(branch, at=now, rows_total=total)
766
+ if state.status is not MaterializationStatus.RUNNING:
767
+ await states.update(branch, at=now, status=MaterializationStatus.RUNNING)
768
+ if state.phase is MaterializationPhase.SWITCH:
769
+ # The total was an estimate (`_base_rows`); every row has been read now.
770
+ await states.update(branch, at=now, rows_total=state.rows_done)
771
+ await _switch(session, domain_id, branch, runtime)
772
+ run.promoted = True
773
+ log.info("branch %s of Domain %s is now a full copy", branch, domain_id)
774
+ if state.reconcile is not None:
775
+ # Its schema changed while it was copied: recompute in batches what the change
776
+ # recomputes on a full branch.
777
+ await states.update(branch, at=clock(), phase=MaterializationPhase.PRUNE, cursor=None)
778
+ return _Step()
779
+ await states.finish(branch, at=clock())
780
+ return _Step(finished=run.done("done", promoted=True))
781
+ copied = next((c for c in _COPIED if c.phase is state.phase), None)
782
+ if copied is None:
783
+ # A phase of another strategy: start over from the base.
784
+ await states.update(branch, at=now, phase=MaterializationPhase.RECORDS, cursor=None)
785
+ return _Step()
786
+ read, cursor = await _copy_batch(
787
+ session,
788
+ copied,
789
+ domain_id=domain_id,
790
+ branch=branch,
791
+ base=ref.base_branch,
792
+ generation=ref.base_generation,
793
+ after=_decode(copied, state.cursor),
794
+ limit=run.pace.size,
795
+ )
796
+ if cursor is None:
797
+ await states.update(
798
+ branch, at=now, phase=_NEXT[state.phase], cursor=None, rows_done=state.rows_done + read
799
+ )
800
+ else:
801
+ await states.update(
802
+ branch, at=now, cursor=_encode(cursor), rows_done=state.rows_done + read
803
+ )
804
+ return _Step(rows=read)
805
+
806
+
807
+ async def _tree_entries(
808
+ session: AsyncSession, domain_id: str, head: RevisionRow, runtime: StoreRuntime
809
+ ) -> int:
810
+ """Records and Links in the tree of `head`."""
811
+ settings = runtime.settings
812
+ nodes = NodeRepository(
813
+ session, domain_id, writes=settings.writes, batch=settings.nodes.prefetch_batch
814
+ )
815
+ tree = await nodes.load_tree(head.root)
816
+ return sum(ref.count for name, ref in tree.partitions.items() if name != PARTITION_SCHEMA)
817
+
818
+
819
+ async def _build_step(
820
+ session: AsyncSession,
821
+ domain_id: str,
822
+ ref: RefRow,
823
+ state: MaterializationState | None,
824
+ run: _Run,
825
+ runtime: StoreRuntime,
826
+ clock: Clock,
827
+ ) -> _Step:
828
+ """One batch of the build of a `building` branch from the tree of its head (strategy
829
+ `tree`)."""
830
+ branch = ref.name
831
+ states = BranchMaterializationRepository(session, domain_id)
832
+ now = clock()
833
+ head = await RevisionRepository(session, domain_id).require(ref.head)
834
+ if state is None or state.strategy is not MaterializationStrategy.TREE:
835
+ state = await states.start(
836
+ branch,
837
+ base_branch=branch if state is None else state.base_branch,
838
+ base_generation=head.generation,
839
+ status=MaterializationStatus.RUNNING,
840
+ strategy=MaterializationStrategy.TREE,
841
+ failures=0 if state is None else state.failures,
842
+ at=now,
843
+ )
844
+ if state.rows_total is None:
845
+ await states.update(
846
+ branch, at=now, rows_total=await _tree_entries(session, domain_id, head, runtime)
847
+ )
848
+ if state.status is not MaterializationStatus.RUNNING:
849
+ await states.update(branch, at=now, status=MaterializationStatus.RUNNING)
850
+ limit = run.pace.size
851
+ phase = state.phase
852
+ if phase is MaterializationPhase.CLEAR:
853
+ batch = await build.clear_batch(session, domain_id, branch, limit=limit)
854
+ elif phase is MaterializationPhase.TREE_HISTORY:
855
+ batch = await build.history_batch(
856
+ session, domain_id, branch, head, state.cursor, limit=limit, runtime=runtime
857
+ )
858
+ elif phase is MaterializationPhase.TREE_ROWS:
859
+ batch = await build.rows_batch(
860
+ session, domain_id, branch, head, state.cursor, limit=limit, runtime=runtime
861
+ )
862
+ elif phase is MaterializationPhase.DERIVED:
863
+ batch = await build.derived_batch(
864
+ session,
865
+ domain_id,
866
+ branch,
867
+ head,
868
+ state.cursor,
869
+ limit=limit,
870
+ runtime=runtime,
871
+ expression_limits=runtime.schema.expression_limits,
872
+ )
873
+ elif phase is MaterializationPhase.PATHS:
874
+ batch = await build.paths_batch(
875
+ session, domain_id, branch, state.cursor, limit=limit, runtime=runtime
876
+ )
877
+ elif phase is MaterializationPhase.SWITCH:
878
+ await _finish_build(session, domain_id, branch, runtime)
879
+ await states.finish(branch, at=clock())
880
+ run.promoted = True
881
+ log.info("branch %s of Domain %s is built from its tree", branch, domain_id)
882
+ return _Step(finished=run.done("done", promoted=True))
883
+ else:
884
+ # A phase of another strategy: start the build over.
885
+ await states.update(branch, at=now, phase=MaterializationPhase.CLEAR, cursor=None)
886
+ return _Step()
887
+ done = state.rows_done + (batch.rows if phase is MaterializationPhase.TREE_ROWS else 0)
888
+ if batch.cursor is None:
889
+ await states.update(branch, at=now, phase=_NEXT_BUILD[phase], cursor=None, rows_done=done)
890
+ else:
891
+ await states.update(branch, at=now, cursor=batch.cursor, rows_done=done)
892
+ return _Step(rows=max(batch.rows, 1))
893
+
894
+
895
+ async def _finish_build(
896
+ session: AsyncSession, domain_id: str, branch: str, runtime: StoreRuntime
897
+ ) -> None:
898
+ """Make the built branch `full` and queue its per-Attribute indexes."""
899
+ await RefRepository(session, domain_id).set_materialization(branch, Materialization.FULL)
900
+ await _sync_indexes(session, domain_id, branch, runtime)
901
+
902
+
903
+ async def _reconcile_step(
904
+ session: AsyncSession,
905
+ domain_id: str,
906
+ ref: RefRow,
907
+ state: MaterializationState,
908
+ run: _Run,
909
+ runtime: StoreRuntime,
910
+ clock: Clock,
911
+ ) -> _Step:
912
+ """One batch of the reconciliation of schema changes made while the branch was copied."""
913
+ branch = ref.name
914
+ states = BranchMaterializationRepository(session, domain_id)
915
+ now = clock()
916
+ limit = run.pace.size
917
+ phase = state.phase
918
+ if phase is MaterializationPhase.PRUNE:
919
+ batch = await build.prune_batch(
920
+ session,
921
+ domain_id,
922
+ branch,
923
+ state.cursor,
924
+ limit=limit,
925
+ runtime=runtime,
926
+ expression_limits=runtime.schema.expression_limits,
927
+ )
928
+ elif phase is MaterializationPhase.DERIVED:
929
+ head = await RevisionRepository(session, domain_id).require(ref.head)
930
+ batch = await build.derived_batch(
931
+ session,
932
+ domain_id,
933
+ branch,
934
+ head,
935
+ state.cursor,
936
+ limit=limit,
937
+ runtime=runtime,
938
+ expression_limits=runtime.schema.expression_limits,
939
+ )
940
+ elif phase is MaterializationPhase.PATHS:
941
+ batch = await build.paths_batch(
942
+ session, domain_id, branch, state.cursor, limit=limit, runtime=runtime
943
+ )
944
+ else:
945
+ kinds = (state.reconcile or {}).get("refresh_kinds", [])
946
+ batch = await build.search_batch(
947
+ session, domain_id, branch, kinds, state.cursor, limit=limit, runtime=runtime
948
+ )
949
+ if batch.cursor is not None:
950
+ await states.update(branch, at=now, cursor=batch.cursor)
951
+ return _Step(rows=max(batch.rows, 1))
952
+ following = _NEXT_RECONCILE[phase]
953
+ if following is MaterializationPhase.DONE:
954
+ await states.finish(branch, at=now)
955
+ log.info("branch %s of Domain %s reconciled with its schema", branch, domain_id)
956
+ return _Step(finished=run.done("done", promoted=run.promoted, reconciled=True))
957
+ await states.update(branch, at=now, phase=following, cursor=None)
958
+ return _Step(rows=max(batch.rows, 1))
959
+
960
+
961
+ async def _base_rows(session: AsyncSession, domain_id: str, base: str) -> int:
962
+ """Rows a copy reads (the base branch's Records, Links and history entries), as the
963
+ planner estimates them from the table statistics: counting them reads every row of the
964
+ base, which at 10M Records takes longer than the statement timeout. The total feeds the
965
+ progress report only, and is set to the rows read when the copy switches. Rows written
966
+ after the fork point, which the copy skips, are few."""
967
+ total = 0
968
+ for copied in _COPIED:
969
+ # Literals rather than parameters: the estimate is of a plan for these values.
970
+ plan = await session.execute(
971
+ text(
972
+ f"EXPLAIN (FORMAT JSON) SELECT 1 FROM {copied.table.name} "
973
+ f"WHERE domain_id = {sql_literal(domain_id)} AND branch = {sql_literal(base)}"
974
+ )
975
+ )
976
+ found = plan.scalar_one()
977
+ found = json.loads(found) if isinstance(found, str) else found
978
+ total += int(found[0]["Plan"]["Plan Rows"])
979
+ return total
980
+
981
+
982
+ def _quoted(column: str) -> str:
983
+ return f'"{column}"'
984
+
985
+
986
+ def _tuple(prefix: str, size: int) -> str:
987
+ return "(" + ", ".join(f":{prefix}{i}" for i in range(size)) + ")"
988
+
989
+
990
+ def _bind(prefix: str, values: Sequence[Any]) -> dict[str, Any]:
991
+ return {f"{prefix}{i}": value for i, value in enumerate(values)}
992
+
993
+
994
+ def _encode(key: Sequence[Any]) -> list[Any]:
995
+ return [value.hex() if isinstance(value, bytes) else value for value in key]
996
+
997
+
998
+ def _decode(copied: _Copied, cursor: Sequence[Any] | None) -> tuple[Any, ...] | None:
999
+ if not cursor:
1000
+ return None
1001
+ values = list(cursor)
1002
+ if copied.table is MAT_HISTORY:
1003
+ values[-1] = bytes.fromhex(values[-1])
1004
+ return tuple(values)
1005
+
1006
+
1007
+ async def _copy_batch(
1008
+ session: AsyncSession,
1009
+ copied: _Copied,
1010
+ *,
1011
+ domain_id: str,
1012
+ branch: str,
1013
+ base: str,
1014
+ generation: int,
1015
+ after: tuple[Any, ...] | None,
1016
+ limit: int,
1017
+ ) -> tuple[int, tuple[Any, ...] | None]:
1018
+ """Copy into the overlay the next `limit` base rows after `after`, in key order, that it
1019
+ reads, keeping the rows it holds. One statement: the batch is read along the primary key
1020
+ (`ORDER BY ... LIMIT`), inserted, and its last key returned. Returns the base rows read
1021
+ and the last key, `None` when none is left."""
1022
+ table = copied.table.name
1023
+ key = ", ".join(f"s.{c}" for c in copied.key)
1024
+ params: dict[str, Any] = {
1025
+ "domain": domain_id,
1026
+ "base": base,
1027
+ "branch": branch,
1028
+ "generation": generation,
1029
+ "limit": limit,
1030
+ }
1031
+ start = ""
1032
+ if after is not None:
1033
+ start = f" AND ({key}) > {_tuple('a', len(after))}"
1034
+ params.update(_bind("a", after))
1035
+ if copied.table is MAT_HISTORY:
1036
+ # OFFSET 0: a primary key probe of revisions per row, not a join the planner may
1037
+ # turn into a scan of every Revision of the Domain per row.
1038
+ visible = (
1039
+ " AND EXISTS (SELECT 1 FROM revisions r WHERE r.domain_id = :domain "
1040
+ "AND r.id = s.revision_id AND r.generation <= :generation OFFSET 0)"
1041
+ )
1042
+ else:
1043
+ visible = " AND s.revision_generation <= :generation"
1044
+ columns = [c.name for c in copied.table.columns]
1045
+ selected = ", ".join(
1046
+ f"CAST(:branch AS text) AS {_quoted(c)}"
1047
+ if c == "branch"
1048
+ else f"false AS {_quoted(c)}"
1049
+ if c == "deleted"
1050
+ else f"s.{_quoted(c)}"
1051
+ for c in columns
1052
+ )
1053
+ names = ", ".join(_quoted(c) for c in columns)
1054
+ last = ", ".join(copied.key)
1055
+ descending = ", ".join(f"{c} DESC" for c in copied.key)
1056
+ found = (
1057
+ await session.execute(
1058
+ text(
1059
+ f"WITH batch AS MATERIALIZED (SELECT {selected} FROM {table} s "
1060
+ f"WHERE s.domain_id = :domain AND s.branch = :base{start}{visible} "
1061
+ f"ORDER BY {key} LIMIT :limit), "
1062
+ f"copied AS (INSERT INTO {table} ({names}) SELECT {names} FROM batch "
1063
+ "ON CONFLICT DO NOTHING) "
1064
+ f"SELECT (SELECT count(*) FROM batch), {last} FROM batch "
1065
+ f"ORDER BY {descending} LIMIT 1"
1066
+ ),
1067
+ params,
1068
+ )
1069
+ ).one_or_none()
1070
+ if found is None:
1071
+ return 0, None
1072
+ read = int(found[0])
1073
+ return read, (tuple(found[1:]) if read >= limit else None)
1074
+
1075
+
1076
+ async def _switch(
1077
+ session: AsyncSession, domain_id: str, branch: str, runtime: StoreRuntime
1078
+ ) -> None:
1079
+ """Make the overlay `branch`, whose base rows are all copied, a full copy. The caller
1080
+ holds the branch lock and read the ref after taking it."""
1081
+ refs = RefRepository(session, domain_id)
1082
+ await copy_fence(session, domain_id, branch)
1083
+ for table in (MAT_RECORDS, MAT_LINKS):
1084
+ await session.execute(
1085
+ text(
1086
+ f"DELETE FROM {table.name} WHERE domain_id = :domain AND branch = :branch "
1087
+ "AND deleted"
1088
+ ),
1089
+ {"domain": domain_id, "branch": branch},
1090
+ )
1091
+ await refs.set_materialization(branch, Materialization.FULL)
1092
+ await _sync_indexes(session, domain_id, branch, runtime)
1093
+
1094
+
1095
+ async def _sync_indexes(
1096
+ session: AsyncSession, domain_id: str, branch: str, runtime: StoreRuntime
1097
+ ) -> None:
1098
+ """Queue the per-Attribute indexes of a branch that just became full (none is built in
1099
+ the caller's transaction)."""
1100
+ schema = await MaterializedRepository(
1101
+ session, domain_id, branch, writes=runtime.settings.writes
1102
+ ).schema()
1103
+ if schema is not None:
1104
+ # Imported here: the commit package imports the materializer.
1105
+ from lorebase_store.commit.schema import load_compiled
1106
+
1107
+ compiled = load_compiled(schema.compiled)
1108
+ await BranchIndexes(
1109
+ session,
1110
+ domain_id,
1111
+ branch,
1112
+ settings=runtime.settings.indexes,
1113
+ constraints_only=constraints_only(runtime.settings),
1114
+ maintenance=runtime.index_maintenance,
1115
+ defer=True,
1116
+ ).sync(compiled)
1117
+ # Rows copied before the source rewrote them are stale.
1118
+ await queue_if_stale(session, domain_id, branch, compiled, runtime=runtime)
1119
+
1120
+
1121
+ async def run_branch_delete_job(
1122
+ engine: AsyncEngine,
1123
+ job: JobRow,
1124
+ *,
1125
+ runtime: StoreRuntime | None = None,
1126
+ clock: Clock | None = None,
1127
+ ) -> Mapping[str, Any]:
1128
+ """Job handler of `store.branch_delete`: delete the rows of the hidden branch named by
1129
+ the payload in batches, then its ref (module doc).
1130
+
1131
+ The result `status` is `done`, `waiting` (overlays still read the branch; queued again),
1132
+ `deferred` (no free slot; queued again), `skipped` (another job works on the branch) or
1133
+ `cancelled` (the branch is not being deleted).
1134
+ """
1135
+ runtime = runtime or default_runtime()
1136
+ now = clock or _now
1137
+ domain_id, branch = _payload_branch(job)
1138
+ settings = runtime.settings.materialize
1139
+ run = _Run("delete", branch, _Pace.of(settings))
1140
+ async with _slot(engine, domain_id, branch, settings) as slot:
1141
+ if slot is _Slot.RUNNING:
1142
+ return run.done("skipped", detail="another job works on the branch")
1143
+ if slot is _Slot.BUSY:
1144
+ queued = await _again(engine, job, settings, now)
1145
+ return run.done("deferred", detail="no free slot", again=queued)
1146
+
1147
+ async def step(session: AsyncSession) -> _Step:
1148
+ return await _delete_step(session, domain_id, branch, run, settings, now)
1149
+
1150
+ try:
1151
+ return await _batches(engine, job, run, step, settings, now)
1152
+ except Exception:
1153
+ BRANCH_JOB_RUNS.add(1, {"job": run.job, "outcome": "failed"})
1154
+ raise
1155
+
1156
+
1157
+ async def _delete_step(
1158
+ session: AsyncSession,
1159
+ domain_id: str,
1160
+ branch: str,
1161
+ run: _Run,
1162
+ settings: MaterializeSettings,
1163
+ clock: Clock,
1164
+ ) -> _Step:
1165
+ await scope_to_domain(session, domain_id)
1166
+ refs = RefRepository(session, domain_id)
1167
+ ref = await refs.get_deleting(branch)
1168
+ if ref is None:
1169
+ return _Step(finished=run.done("done"))
1170
+ if ref.deleted_at is None:
1171
+ return _Step(finished=run.done("cancelled", detail="the branch is not being deleted"))
1172
+ waiting = await refs.dependents(branch)
1173
+ if waiting:
1174
+ # Their reads go through this branch's rows until they are full copies.
1175
+ for overlay in waiting:
1176
+ if overlay.deleted_at is None and overlay.materialization is Materialization.OVERLAY:
1177
+ await queue_materialization(session, domain_id, overlay, at=clock())
1178
+ return _Step(finished=run.done("waiting", overlays=[r.name for r in waiting]), again=True)
1179
+ batch = await build.clear_batch(session, domain_id, branch, limit=run.pace.size)
1180
+ if batch.rows:
1181
+ return _Step(rows=batch.rows)
1182
+ await refs.delete(branch)
1183
+ log.info("deleted the rows and ref of branch %s of Domain %s", branch, domain_id)
1184
+ return _Step(finished=run.done("done"))