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,266 @@
1
+ """Links of a branch by endpoint, for Relations and path Derivations (design 5.8).
2
+
3
+ `links_of` reads the Links of a Relation from near keys: stored Links, or the Records holding
4
+ the key of a foreign-key Relation. `path_links_of` reads the Links of a path Derivation the
5
+ same way, composed from the Links of its steps (`lorebase_core.model.paths`: every Link of
6
+ the first step, at most `fanout` Links, smallest far keys first, per intermediate Record
7
+ later). Read from its far end, a path is walked backwards and each intermediate Record is
8
+ checked to contribute the far key within its bound, so both directions give the same pairs.
9
+
10
+ `path_affected` lists the Records whose pairs a changeset can change, from the steps followed
11
+ from them: the start Records for the forward direction, the end Records for the inverse one
12
+ (`reverse_steps`, where a changed Link of an intermediate Record also marks the far keys it
13
+ can push in or out of that Record's bound).
14
+
15
+ All reads go through `MaterializedRepository`, so overlays read their resolved rows.
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ from collections import defaultdict
21
+ from collections.abc import Mapping, Sequence
22
+ from typing import TYPE_CHECKING, Any
23
+
24
+ from lorebase_core.model import LinkDirection, PathRelation, Relation, RelationBacking
25
+ from lorebase_core.model.paths import PathHop, bounded
26
+ from lorebase_store.repositories.materialized import MaterializedRepository
27
+
28
+ if TYPE_CHECKING:
29
+ from lorebase_store.materialize.derived import AppliedChanges
30
+
31
+
32
+ def reverse(direction: LinkDirection) -> LinkDirection:
33
+ return LinkDirection.INVERSE if direction is LinkDirection.FORWARD else LinkDirection.FORWARD
34
+
35
+
36
+ def reverse_steps(steps: Sequence[PathHop]) -> tuple[PathHop, ...]:
37
+ """The steps of a path walked from its far end."""
38
+ return tuple(PathHop(step.relation, reverse(step.direction)) for step in reversed(steps))
39
+
40
+
41
+ async def links_of(
42
+ repository: MaterializedRepository,
43
+ relation: Relation,
44
+ direction: LinkDirection,
45
+ keys: Sequence[str],
46
+ *,
47
+ limit: int | None = None,
48
+ ) -> dict[str, list[tuple[str, Any]]]:
49
+ """`near key -> [(far key, link values)]` of `relation` followed in `direction` from
50
+ `keys`: stored Links, or the Records holding the key of a foreign-key Relation; at most
51
+ `limit` Links in all when set."""
52
+ forward = direction is LinkDirection.FORWARD
53
+ out: dict[str, list[tuple[str, Any]]] = defaultdict(list)
54
+ if relation.backing is RelationBacking.FOREIGN_KEY:
55
+ attribute = relation.foreign_key_attribute
56
+ if attribute is None:
57
+ return out
58
+ pairs = await repository.foreign_key_links(
59
+ relation.from_kind, attribute, keys=keys, by_target=not forward, limit=limit
60
+ )
61
+ for from_key, to_key in pairs:
62
+ near, far = (from_key, to_key) if forward else (to_key, from_key)
63
+ out[near].append((far, {}))
64
+ return out
65
+ found = await repository.linked_keys(
66
+ relation.id, near="from_key" if forward else "to_key", keys=keys, limit=limit
67
+ )
68
+ for near, far, values in found:
69
+ out[near].append((far, values))
70
+ return out
71
+
72
+
73
+ class PathCapExceededError(Exception):
74
+ """The first step of a path read with a cap (`path_links_of`) has that many Links."""
75
+
76
+
77
+ async def _fars(
78
+ repository: MaterializedRepository,
79
+ step: PathHop,
80
+ keys: Sequence[str],
81
+ cap: int | None = None,
82
+ ) -> dict[str, list[str]]:
83
+ """Far keys of `keys` along a step; with `cap`, raises `PathCapExceededError` when they have
84
+ `cap` Links or more (reading no more than that)."""
85
+ found = (
86
+ await links_of(repository, step.relation, step.direction, keys, limit=cap) if keys else {}
87
+ )
88
+ if cap is not None and sum(len(items) for items in found.values()) >= cap:
89
+ raise PathCapExceededError(cap)
90
+ return {near: [far for far, _ in items] for near, items in found.items()}
91
+
92
+
93
+ async def path_pairs_from(
94
+ repository: MaterializedRepository,
95
+ path: PathRelation,
96
+ starts: Sequence[str],
97
+ fanout: int,
98
+ cap: int | None = None,
99
+ ) -> dict[str, set[str]]:
100
+ """Far keys reached from each start Record (`lorebase_core.model.paths`). With `cap`,
101
+ the first step is read up to `cap` Links (`_fars`)."""
102
+ reached: dict[str, set[str]] = {start: {start} for start in starts}
103
+ for index, step in enumerate(path.steps):
104
+ found = await _fars(
105
+ repository,
106
+ step,
107
+ sorted({k for keys in reached.values() for k in keys}),
108
+ cap if index == 0 else None,
109
+ )
110
+ nexts = {
111
+ near: (fars if index == 0 else bounded(fars, fanout)) for near, fars in found.items()
112
+ }
113
+ reached = {
114
+ start: {far for near in keys for far in nexts.get(near, ())}
115
+ for start, keys in reached.items()
116
+ }
117
+ return reached
118
+
119
+
120
+ async def path_pairs_to(
121
+ repository: MaterializedRepository,
122
+ path: PathRelation,
123
+ ends: Sequence[str],
124
+ fanout: int,
125
+ cap: int | None = None,
126
+ ) -> dict[str, set[str]]:
127
+ """Start keys reaching each end Record: the steps walked backwards, keeping an
128
+ intermediate Record only when the key it leads to is within its bound. With `cap`, the
129
+ first step walked is read up to `cap` Links (`_fars`)."""
130
+ reached: dict[str, set[str]] = {end: {end} for end in ends}
131
+ last = len(path.steps) - 1
132
+ for index in reversed(range(len(path.steps))):
133
+ step = path.steps[index]
134
+ frontier = sorted({k for keys in reached.values() for k in keys})
135
+ back = await _fars(
136
+ repository,
137
+ PathHop(step.relation, reverse(step.direction)),
138
+ frontier,
139
+ cap if index == last else None,
140
+ )
141
+ if index > 0:
142
+ nears = sorted({near for nears in back.values() for near in nears})
143
+ allowed = {
144
+ near: set(bounded(fars, fanout))
145
+ for near, fars in (await _fars(repository, step, nears)).items()
146
+ }
147
+ back = {
148
+ far: [near for near in nears if far in allowed.get(near, ())]
149
+ for far, nears in back.items()
150
+ }
151
+ reached = {
152
+ end: {near for far in keys for near in back.get(far, ())}
153
+ for end, keys in reached.items()
154
+ }
155
+ return reached
156
+
157
+
158
+ async def path_links_of(
159
+ repository: MaterializedRepository,
160
+ path: PathRelation,
161
+ direction: LinkDirection,
162
+ keys: Sequence[str],
163
+ fanout: int,
164
+ *,
165
+ cap: int | None = None,
166
+ ) -> dict[str, list[tuple[str, Any]]]:
167
+ """`near key -> [(far key, {})]` of a path Derivation followed in `direction` from
168
+ `keys`, far keys in order (the shape of `links_of`). With `cap`, raises
169
+ `PathCapExceededError` when the first step walked has `cap` Links or more, read no further."""
170
+ if direction is LinkDirection.FORWARD:
171
+ reached = await path_pairs_from(repository, path, keys, fanout, cap)
172
+ else:
173
+ reached = await path_pairs_to(repository, path, keys, fanout, cap)
174
+ return {near: [(far, {}) for far in sorted(fars)] for near, fars in reached.items() if fars}
175
+
176
+
177
+ async def path_affected(
178
+ repository: MaterializedRepository,
179
+ steps: Sequence[PathHop],
180
+ applied: AppliedChanges,
181
+ *,
182
+ fanout: int | None = None,
183
+ inverse: bool = False,
184
+ ) -> set[str]:
185
+ """Records at the start of `steps` whose pairs a changeset can change (a superset): a
186
+ Link of step `i` that changed marks its near Record at level `i`; walking the steps
187
+ backwards without bound gives the start Records reaching a marked Record.
188
+
189
+ `inverse` says `steps` are a path walked from its far end (`reverse_steps`): the bound
190
+ of an intermediate Record then sits at the far side of a step, and a Link added or
191
+ removed there can push another far key in or out of that Record's bounded set
192
+ (`_displaced`), which marks it too."""
193
+ starts: set[str] = set()
194
+ for level, step in enumerate(steps):
195
+ marked = await _marked(repository, step, applied)
196
+ if inverse and fanout is not None and level < len(steps) - 1:
197
+ marked |= await _displaced(repository, step, applied, fanout)
198
+ for back in reversed(steps[:level]):
199
+ if not marked:
200
+ break
201
+ found = await links_of(
202
+ repository, back.relation, reverse(back.direction), sorted(marked)
203
+ )
204
+ marked = {far for items in found.values() for far, _ in items}
205
+ starts |= marked
206
+ return starts
207
+
208
+
209
+ async def _displaced(
210
+ repository: MaterializedRepository, step: PathHop, applied: AppliedChanges, fanout: int
211
+ ) -> set[str]:
212
+ """Near Records of a step walked backwards whose membership in the bounded set of an
213
+ intermediate Record (at the step's far side) a changeset can change. With `n` changed
214
+ Links at an intermediate Record holding `c` Links now, its bounded set before and after
215
+ the change lies within its `fanout + n` smallest far keys once `c + n` exceeds
216
+ `fanout`: those are marked (the changed ones are marked by `_marked`)."""
217
+ hop = PathHop(step.relation, reverse(step.direction))
218
+ changes = await _changes(repository, hop, applied)
219
+ if not changes:
220
+ return set()
221
+ current = await _fars(repository, hop, sorted(changes))
222
+ displaced: set[str] = set()
223
+ for near, count in changes.items():
224
+ fars = sorted(set(current.get(near, ())))
225
+ if len(fars) + count > fanout:
226
+ displaced.update(fars[: fanout + count])
227
+ return displaced
228
+
229
+
230
+ async def _marked(
231
+ repository: MaterializedRepository, step: PathHop, applied: AppliedChanges
232
+ ) -> set[str]:
233
+ """Near Records of a step whose Links along it changed."""
234
+ return set(await _changes(repository, step, applied))
235
+
236
+
237
+ async def _changes(
238
+ repository: MaterializedRepository, step: PathHop, applied: AppliedChanges
239
+ ) -> dict[str, int]:
240
+ """Near Records of a step whose Links along it changed, with how many of their Links
241
+ may have changed (an upper bound)."""
242
+ relation = step.relation
243
+ forward = step.direction is LinkDirection.FORWARD
244
+ counts: dict[str, int] = defaultdict(int)
245
+ for f, t in applied.links.get(relation.id, ()):
246
+ counts[f if forward else t] += 1
247
+ attribute = relation.foreign_key_attribute
248
+ if relation.backing is not RelationBacking.FOREIGN_KEY or attribute is None:
249
+ return counts
250
+ source = relation.from_kind
251
+ holders = set(applied.written.get(source, ())) | set(applied.removed.get(source, ()))
252
+ if forward:
253
+ for key in holders:
254
+ counts[key] += 1
255
+ return counts
256
+ previous: Mapping[tuple[str, str], Mapping[str, Any] | None] = applied.previous
257
+ for key in holders: # targets before the change, then after it
258
+ before = previous.get((source, key))
259
+ if before is not None and isinstance(before.get(attribute), str):
260
+ counts[before[attribute]] += 1
261
+ if holders:
262
+ now = await links_of(repository, relation, LinkDirection.FORWARD, sorted(holders))
263
+ for items in now.values():
264
+ for far, _ in items:
265
+ counts[far] += 1
266
+ return counts
@@ -0,0 +1,292 @@
1
+ """Overlay branches (design 6.11 mode `overlay`, AC-16; store schema notes).
2
+
3
+ An overlay starts with no Record or Link rows: creating it inserts its ref (base branch, fork
4
+ Revision and generation) and a copy of the base's compiled schema row, and queues its
5
+ per-Attribute indexes (`lorebase_store.indexes`). Its commits write only the rows they change
6
+ (`MaterializedRepository`), and reads resolve it over the base (`lorebase_store.layout`).
7
+
8
+ An overlay is promoted to a full copy ("materialized"):
9
+ - when a commit leaves it with more than `store.materialize.overlay_max_rows` rows of its own,
10
+ or when its base has moved more than `overlay_max_base_lag_revisions` generations past its
11
+ fork point (checked by its own commits, unless a background copy of it is queued or running;
12
+ a base commit that pushes an overlay past the lag queues a `store.materialize` job for it,
13
+ which copies it in batches, `materialize.background`). Over a base holding more than
14
+ `async_copy_min_rows` rows, the overlay's own commit queues that job too;
15
+ - before a commit or merge that changes its schema (whole-Kind rewrites and index changes
16
+ belong to full branches): inline over a small base; over a large one the copy job takes
17
+ the change over (`apply.defer_schema_change`);
18
+ - before its base is deleted or rebuilt (`promote_overlays_of`): inline over a small base;
19
+ over a large one it is detached and rebuilt from its tree in the background;
20
+ - on request (`promote`).
21
+
22
+ After an overlay is merged into its base, `refork` moves it to the base head and empties it,
23
+ which costs as much as creating it.
24
+ """
25
+
26
+ from __future__ import annotations
27
+
28
+ import logging
29
+ from collections.abc import Callable
30
+ from datetime import UTC, datetime
31
+
32
+ from sqlalchemy import delete
33
+ from sqlalchemy.ext.asyncio import AsyncSession
34
+
35
+ from lorebase_store.indexes import BranchIndexes
36
+ from lorebase_store.jobs import MATERIALIZE_JOB, enqueue_job
37
+ from lorebase_store.layout import MAT_HISTORY, MAT_LINKS, MAT_RECORDS
38
+ from lorebase_store.models.enums import Materialization
39
+ from lorebase_store.repositories.materializations import (
40
+ BranchMaterializationRepository,
41
+ copy_fence,
42
+ )
43
+ from lorebase_store.repositories.materialized import MaterializedRepository
44
+ from lorebase_store.repositories.refs import RefMovedError, RefRepository, RefRow
45
+ from lorebase_store.repositories.revisions import RevisionRepository
46
+ from lorebase_store.repositories.scope import scope_to_domain
47
+ from lorebase_store.runtime import StoreRuntime, default_runtime
48
+ from lorebase_store.settings import MaterializeSettings
49
+ from lorebase_store.storage import constraints_only
50
+
51
+ log = logging.getLogger(__name__)
52
+
53
+ REFORK_REASON = "branch.refork"
54
+
55
+
56
+ class OverlayError(Exception):
57
+ """An overlay operation does not apply to the branch."""
58
+
59
+
60
+ def wants_overlay(name: str, settings: MaterializeSettings) -> bool:
61
+ """Whether a branch created without an explicit mode defaults to an overlay."""
62
+ if settings.branch_mode == "overlay":
63
+ return True
64
+ return any(name.startswith(prefix) for prefix in settings.overlay_branch_prefixes)
65
+
66
+
67
+ async def promote(
68
+ session: AsyncSession,
69
+ domain_id: str,
70
+ branch: str,
71
+ *,
72
+ runtime: StoreRuntime | None = None,
73
+ locked: bool = False,
74
+ ) -> bool:
75
+ """Turn the overlay `branch` into a full copy; `False` when it is not an overlay.
76
+
77
+ Takes the branch lock unless the caller holds it (`locked`). The head and ref version do
78
+ not change, so a commit of the caller's transaction still moves the ref afterwards. A
79
+ background copy of the branch (`materialize.background`) is marked done.
80
+ """
81
+ runtime = runtime or default_runtime()
82
+ refs = RefRepository(session, domain_id)
83
+ if not locked:
84
+ await refs.lock(branch, timeout_ms=runtime.settings.commit.lock_timeout_ms)
85
+ ref = await refs.get(branch)
86
+ if ref is None or ref.materialization is not Materialization.OVERLAY:
87
+ return False
88
+ await copy_fence(session, domain_id, branch)
89
+ repository = MaterializedRepository(
90
+ session, domain_id, branch, writes=runtime.settings.writes, layout=ref.layout
91
+ )
92
+ await repository.complete_overlay()
93
+ await refs.set_materialization(branch, Materialization.FULL)
94
+ states = BranchMaterializationRepository(session, domain_id)
95
+ if await states.get(branch) is not None:
96
+ await states.finish(branch, at=datetime.now(UTC))
97
+ schema = await repository.schema()
98
+ if schema is not None:
99
+ # Imported here: the commit package imports the materializer.
100
+ from lorebase_store.commit.schema import load_compiled
101
+
102
+ await BranchIndexes(
103
+ session,
104
+ domain_id,
105
+ branch,
106
+ settings=runtime.settings.indexes,
107
+ constraints_only=constraints_only(runtime.settings),
108
+ maintenance=runtime.index_maintenance,
109
+ ).sync(load_compiled(schema.compiled))
110
+ log.info("promoted overlay branch %s of Domain %s to a full copy", branch, domain_id)
111
+ return True
112
+
113
+
114
+ async def base_is_large(
115
+ session: AsyncSession, domain_id: str, base: str, *, runtime: StoreRuntime
116
+ ) -> bool:
117
+ """Whether copying the rows of `base` into an overlay is a job for the background: it
118
+ holds more than `store.materialize.async_copy_min_rows` rows."""
119
+ limit = runtime.settings.materialize.async_copy_min_rows
120
+ rows = MaterializedRepository(session, domain_id, base, writes=runtime.settings.writes)
121
+ return await rows.own_rows(limit=limit) > limit
122
+
123
+
124
+ async def promote_overlays_of(
125
+ session: AsyncSession, domain_id: str, base: str, *, runtime: StoreRuntime | None = None
126
+ ) -> list[str]:
127
+ """Make every overlay based on `base` independent of it (before deleting or rebuilding
128
+ it): promoted to a full copy in this transaction when `base` is small, otherwise
129
+ detached and rebuilt from its tree by a background job (`background.detach_to_build`).
130
+ The caller holds the lock of `base`; each overlay's lock is taken after it. Returns the
131
+ overlays handled."""
132
+ runtime = runtime or default_runtime()
133
+ refs = RefRepository(session, domain_id)
134
+ overlays = await refs.overlays_of(base)
135
+ if not overlays:
136
+ return []
137
+ large = await base_is_large(session, domain_id, base, runtime=runtime)
138
+ handled = []
139
+ for ref in overlays:
140
+ if large:
141
+ # Imported here: the background jobs import this module.
142
+ from lorebase_store.materialize.background import detach_to_build
143
+
144
+ done = await detach_to_build(
145
+ session, domain_id, ref.name, runtime=runtime, at=datetime.now(UTC)
146
+ )
147
+ else:
148
+ done = await promote(session, domain_id, ref.name, runtime=runtime)
149
+ if done:
150
+ handled.append(ref.name)
151
+ return handled
152
+
153
+
154
+ async def check_overlay(
155
+ repository: MaterializedRepository, *, generation: int, runtime: StoreRuntime
156
+ ) -> bool:
157
+ """After a commit wrote `repository`'s branch at `generation`: promote it when it is an
158
+ overlay over its row or lag limits; queue promotions of overlays based on it that the
159
+ commit pushed past the lag limit. Returns whether the branch itself was promoted."""
160
+ settings = runtime.settings.materialize
161
+ layout = await repository.layout()
162
+ session = repository.session
163
+ if layout.overlay:
164
+ assert layout.base is not None
165
+ if await BranchMaterializationRepository(session, layout.domain_id).active(layout.branch):
166
+ # Its background copy makes it a full copy without a long transaction.
167
+ return False
168
+ refs = RefRepository(session, layout.domain_id)
169
+ base = await refs.get(layout.base)
170
+ base_generation = None
171
+ if base is not None:
172
+ found = await RevisionRepository(session, layout.domain_id).generations([base.head])
173
+ base_generation = found.get(base.head)
174
+ lagging = (
175
+ base_generation is not None
176
+ and layout.base_generation is not None
177
+ and base_generation - layout.base_generation > settings.overlay_max_base_lag_revisions
178
+ )
179
+ if lagging or await repository.own_rows(limit=settings.overlay_max_rows) > (
180
+ settings.overlay_max_rows
181
+ ):
182
+ if await base_is_large(session, layout.domain_id, layout.base, runtime=runtime):
183
+ # Imported here: the background jobs import this module.
184
+ from lorebase_store.materialize.background import queue_materialization
185
+
186
+ ref = await refs.get(layout.branch)
187
+ if ref is not None:
188
+ await queue_materialization(
189
+ session, layout.domain_id, ref, at=datetime.now(UTC)
190
+ )
191
+ return False
192
+ promoted = await promote(
193
+ session, layout.domain_id, layout.branch, runtime=runtime, locked=True
194
+ )
195
+ repository.forget_layout()
196
+ return promoted
197
+ return False
198
+ for name, base_generation in await repository.dependents():
199
+ if generation - base_generation > settings.overlay_max_base_lag_revisions:
200
+ await enqueue_job(
201
+ session,
202
+ MATERIALIZE_JOB,
203
+ domain_id=layout.domain_id,
204
+ payload={"branch": name},
205
+ unique=True,
206
+ )
207
+ return False
208
+
209
+
210
+ async def refork(
211
+ session: AsyncSession,
212
+ domain_id: str,
213
+ branch: str,
214
+ *,
215
+ actor: str,
216
+ base: str | None = None,
217
+ runtime: StoreRuntime | None = None,
218
+ clock: Callable[[], datetime] | None = None,
219
+ ) -> RefRow:
220
+ """Move `branch` to the head of its base (default: its current base) as an empty overlay.
221
+
222
+ The branch head must be an ancestor of the base head (merged into it), so the move is a
223
+ fast-forward and loses nothing. Rows of the branch are dropped; the base is locked first,
224
+ then the branch.
225
+ """
226
+ runtime = runtime or default_runtime()
227
+ now = clock or (lambda: datetime.now(UTC))
228
+ timeout = runtime.settings.commit.lock_timeout_ms
229
+ await scope_to_domain(session, domain_id)
230
+ refs = RefRepository(session, domain_id)
231
+ ref = await refs.require(branch)
232
+ base_name = base or ref.base_branch
233
+ if base_name is None:
234
+ raise OverlayError(f"branch {branch!r} has no base; name one")
235
+ await refs.lock(base_name, timeout_ms=timeout)
236
+ await refs.lock(branch, timeout_ms=timeout)
237
+ await copy_fence(session, domain_id, branch)
238
+ ref = await refs.require(branch)
239
+ base_ref = await refs.require(base_name)
240
+ if base_ref.materialization is not Materialization.FULL:
241
+ raise OverlayError(f"base branch {base_name!r} is not fully materialized")
242
+ if await refs.overlays_of(branch):
243
+ raise OverlayError(f"branch {branch!r} is the base of other overlays")
244
+ revisions = RevisionRepository(session, domain_id)
245
+ ahead, _ = await revisions.ahead_behind(ref.head, base_ref.head)
246
+ if ahead:
247
+ raise OverlayError(
248
+ f"branch {branch!r} has {ahead} Revisions that {base_name!r} does not contain; "
249
+ "merge it first"
250
+ )
251
+ head = await revisions.require(base_ref.head)
252
+ writes = runtime.settings.writes
253
+ for table in (MAT_RECORDS, MAT_LINKS, MAT_HISTORY):
254
+ await session.execute(
255
+ delete(table).where(table.c.domain_id == domain_id, table.c.branch == branch)
256
+ )
257
+ schema = await MaterializedRepository(session, domain_id, base_name, writes=writes).schema()
258
+ if schema is not None:
259
+ await MaterializedRepository(session, domain_id, branch, writes=writes).set_schema(schema)
260
+ if ref.head != head.id:
261
+ try:
262
+ await refs.compare_and_swap(
263
+ branch,
264
+ expected_head=ref.head,
265
+ expected_version=ref.version,
266
+ new_head=head.id,
267
+ actor=actor,
268
+ at=now(),
269
+ reason=REFORK_REASON,
270
+ )
271
+ except RefMovedError as error:
272
+ raise OverlayError(f"branch {branch!r} moved meanwhile") from error
273
+ await refs.set_materialization(
274
+ branch,
275
+ Materialization.OVERLAY,
276
+ base_branch=base_name,
277
+ base_revision=head.id,
278
+ base_generation=head.generation,
279
+ )
280
+ if schema is not None:
281
+ # Now an overlay: the indexes of the base's schema, none unique, queued.
282
+ from lorebase_store.commit.schema import load_compiled
283
+
284
+ await BranchIndexes(
285
+ session,
286
+ domain_id,
287
+ branch,
288
+ settings=runtime.settings.indexes,
289
+ constraints_only=constraints_only(runtime.settings),
290
+ maintenance=runtime.index_maintenance,
291
+ ).sync(load_compiled(schema.compiled))
292
+ return await refs.require(branch)