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,315 @@
1
+ """Materialized path Derivations (design 5.8): the Links of a virtual Relation in `mat_links`.
2
+
3
+ A path Derivation marked `materialized` keeps its Links in `mat_links` under the Derivation
4
+ id as `relation_id` (`DERIVED_LINK_PREFIX`), with no values and a digest of the pair as
5
+ `object_hash`; they are not tree objects, so they take no part in hashing, diff, merge or the
6
+ tree comparison of verification. Readers then use them like stored Links (reverse index
7
+ included); paths that are not materialized are composed at read time
8
+ (`lorebase_keeper.reading.compiler`). Both follow `lorebase_core.model.paths`: every Link of
9
+ the first step, at most `fanout` Links (smallest far keys) per intermediate Record later.
10
+
11
+ `PathLinks` keeps them current in the commit transaction, after derived values:
12
+
13
+ - `recompute_all` rewrites every materialized path (rebuilds, schema changes); the fan-out
14
+ used is recorded in `mat_schema.path_fanout` (`record_fanout`);
15
+ - `fanout_stale` tells whether the recorded fan-out differs from `store.traverse.path_fanout`
16
+ while materialized paths or derived values reading a path depend on it: commits check it
17
+ before their incremental update and then recompute both whole, and `refresh_stale_paths`
18
+ does the same for every branch (the job workers run it in the background when they start,
19
+ `run_path_fanout_sweep`; branches with nothing depending on the fan-out only record it);
20
+ - `recompute` rewrites the pairs of the start Records a changeset can affect. A Link of step
21
+ `i` that changed (for a foreign-key step: a `from` Record written or removed) marks its
22
+ near Record at level `i`; the start Records reaching a marked Record, found by walking the
23
+ steps backwards without bound, are recomputed from scratch. Above
24
+ `store.materialize.derivation_max_incremental_records` start Records the path is rewritten
25
+ whole.
26
+
27
+ Writes go through `MaterializedRepository`, so overlays copy and tombstone path Links like
28
+ any other row. With `write=False` nothing is written and `verify` compares the pairs it
29
+ computes (`expected`) with the stored ones.
30
+ """
31
+
32
+ from __future__ import annotations
33
+
34
+ import asyncio
35
+ import hashlib
36
+ import logging
37
+ from collections.abc import AsyncIterator, Sequence
38
+
39
+ from sqlalchemy import text
40
+ from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
41
+
42
+ from lorebase_core.expr.sensitivity import with_tags
43
+ from lorebase_core.model import PathRelation, Schema
44
+ from lorebase_store.auth.scope import rls_scope
45
+ from lorebase_store.bulk import chunks
46
+ from lorebase_store.materialize.derived import AppliedChanges, DerivedValues
47
+ from lorebase_store.materialize.links import path_affected, path_pairs_from
48
+ from lorebase_store.materialize.rows import search_weights
49
+ from lorebase_store.materialize.sensitivity import reference_tags
50
+ from lorebase_store.repositories.materializations import BranchMaterializationRepository
51
+ from lorebase_store.repositories.materialized import LinkRow, MaterializedRepository
52
+ from lorebase_store.repositories.refs import RefRepository
53
+ from lorebase_store.repositories.revisions import RevisionRepository
54
+ from lorebase_store.repositories.scope import scope_to_domain
55
+ from lorebase_store.runtime import StoreRuntime
56
+
57
+ logger = logging.getLogger("lorebase.store.paths")
58
+
59
+ Pair = tuple[str, str]
60
+
61
+ # Branches with a path Derivation whose recorded fan-out differs from the setting.
62
+ _STALE = text(
63
+ "SELECT domain_id, branch FROM mat_schema "
64
+ "WHERE path_fanout IS DISTINCT FROM :fanout "
65
+ "AND jsonb_path_exists(compiled, '$.derivations.*.definition ? (@.variant == \"path\")') "
66
+ "ORDER BY domain_id, branch"
67
+ )
68
+
69
+
70
+ def path_link_hash(path_id: str, from_key: str, to_key: str) -> bytes:
71
+ """`object_hash` of a materialized path Link: a digest of the pair, never an object."""
72
+ return hashlib.sha256(f"lb1 path\0{path_id}\0{from_key}\0{to_key}".encode()).digest()
73
+
74
+
75
+ class PathLinks:
76
+ """Keeps the materialized path Derivations of one schema on one branch current."""
77
+
78
+ def __init__(
79
+ self,
80
+ repository: MaterializedRepository,
81
+ schema: Schema,
82
+ *,
83
+ fanout: int,
84
+ batch: int,
85
+ max_incremental: int,
86
+ write: bool = True,
87
+ ) -> None:
88
+ self.repository = repository
89
+ self.fanout = fanout
90
+ self.batch = batch
91
+ self.max_incremental = max_incremental
92
+ self.write = write
93
+ self.paths = sorted(
94
+ (p for p in schema.path_relations().values() if p.materialized), key=lambda p: p.id
95
+ )
96
+ self.expected: dict[str, set[Pair]] = {}
97
+
98
+ # Entry points -----------------------------------------------------------------------------
99
+
100
+ async def prune(self) -> None:
101
+ """Drop the Links of path Derivations that are gone or no longer materialized."""
102
+ keep = {path.id for path in self.paths}
103
+ for path_id in sorted(await self.repository.link_counts(derived=True)):
104
+ if path_id not in keep:
105
+ pairs = [p async for p in self._stored_pairs(path_id)]
106
+ await self.repository.delete_links(path_id, pairs)
107
+
108
+ async def recompute_all(self) -> None:
109
+ for path in self.paths:
110
+ await self._rewrite(path)
111
+
112
+ async def fanout_stale(self, derived: DerivedValues | None) -> bool:
113
+ """Whether materialized paths, or derived values reading a path, were computed with
114
+ another fan-out than `store.traverse.path_fanout` (or none was recorded yet)."""
115
+ dependent = bool(self.paths) or (derived is not None and derived.reads_paths)
116
+ return dependent and await self.repository.path_fanout() != self.fanout
117
+
118
+ async def record_fanout(self) -> None:
119
+ """Record the fan-out the branch's path Links and derived values now follow."""
120
+ if self.write:
121
+ await self.repository.set_path_fanout(self.fanout)
122
+
123
+ async def refresh(self, path: PathRelation, starts: Sequence[str]) -> None:
124
+ """Recompute the pairs of the start keys `starts` (a piece of `recompute_all` that a
125
+ background job commits on its own)."""
126
+ await self._refresh(path, starts)
127
+
128
+ async def recompute(self, applied: AppliedChanges) -> None:
129
+ for path in self.paths:
130
+ starts = await self.affected(path, applied)
131
+ if not starts:
132
+ continue
133
+ if len(starts) > self.max_incremental:
134
+ await self._rewrite(path)
135
+ continue
136
+ for chunk in chunks(sorted(starts), self.batch):
137
+ await self._refresh(path, chunk)
138
+
139
+ # Affected start Records -------------------------------------------------------------------
140
+
141
+ async def affected(self, path: PathRelation, applied: AppliedChanges) -> set[str]:
142
+ """Start Records whose pairs a changeset can change (a superset)."""
143
+ return await path_affected(self.repository, path.steps, applied)
144
+
145
+ # Evaluation -------------------------------------------------------------------------------
146
+
147
+ async def pairs_from(self, path: PathRelation, starts: Sequence[str]) -> dict[str, set[str]]:
148
+ """Far keys reached from each start Record (`lorebase_core.model.paths`)."""
149
+ return await path_pairs_from(self.repository, path, starts, self.fanout)
150
+
151
+ async def _refresh(self, path: PathRelation, starts: Sequence[str]) -> None:
152
+ """Recompute the pairs of `starts` and write the difference."""
153
+ reached = await self.pairs_from(path, starts)
154
+ wanted = {(start, far) for start, fars in reached.items() for far in fars}
155
+ if not self.write:
156
+ self.expected.setdefault(path.id, set()).update(wanted)
157
+ return
158
+ stored = {
159
+ (near, far)
160
+ for near, far, _ in await self.repository.linked_keys(
161
+ path.id, near="from_key", keys=starts
162
+ )
163
+ }
164
+ gone = sorted(stored - wanted)
165
+ if gone:
166
+ await self.repository.delete_links(path.id, gone)
167
+ await self.repository.upsert_links(
168
+ [
169
+ LinkRow(path.id, f, t, {}, path_link_hash(path.id, f, t), {})
170
+ for f, t in sorted(wanted - stored)
171
+ ]
172
+ )
173
+
174
+ async def _rewrite(self, path: PathRelation) -> None:
175
+ """Recompute every pair: from every Record of the start Kind and every start key
176
+ stored (a start Kind changed by a schema change leaves old keys behind)."""
177
+ starts: set[str] = {f async for f, _ in self._stored_pairs(path.id)}
178
+ async for rows in self.repository.stream_record_hashes(
179
+ path.relation.from_kind, batch=self.batch
180
+ ):
181
+ starts.update(key for key, _ in rows)
182
+ for chunk in chunks(sorted(starts), self.batch):
183
+ await self._refresh(path, chunk)
184
+
185
+ async def _stored_pairs(self, path_id: str) -> AsyncIterator[Pair]:
186
+ async for rows in self.repository.stream_links(path_id, batch=self.batch):
187
+ for row in rows:
188
+ yield row.from_key, row.to_key
189
+
190
+ async def stored(self, path: PathRelation) -> set[Pair]:
191
+ """The pairs stored for a path."""
192
+ return {pair async for pair in self._stored_pairs(path.id)}
193
+
194
+ async def compute_all(self, path: PathRelation) -> None:
195
+ """Compute every pair of a path into `expected` (with `write=False`)."""
196
+ await self._rewrite(path)
197
+
198
+
199
+ async def refresh_path_fanout(
200
+ session: AsyncSession, domain_id: str, branch: str, *, runtime: StoreRuntime
201
+ ) -> bool:
202
+ """Rewrite the materialized paths of `branch`, and recompute its derived values when one
203
+ reads a path, when they were computed with another fan-out; in the caller's transaction
204
+ and under the branch lock. Returns whether it did. A branch whose background copy is
205
+ queued or running is left alone (rewriting it would hold its lock for long and turn its
206
+ path Links and derived values into own rows mid-copy): its next commit, or the next
207
+ sweep, rewrites it."""
208
+ settings = runtime.settings
209
+ if not settings.materialize.derivations_enabled:
210
+ return False
211
+ await scope_to_domain(session, domain_id)
212
+ refs = RefRepository(session, domain_id)
213
+ await refs.lock(branch, timeout_ms=settings.commit.lock_timeout_ms)
214
+ ref = await refs.require(branch)
215
+ if await BranchMaterializationRepository(session, domain_id).active(branch):
216
+ return False
217
+ head = await RevisionRepository(session, domain_id).require(ref.head)
218
+ repository = MaterializedRepository(
219
+ session,
220
+ domain_id,
221
+ branch,
222
+ writes=settings.writes,
223
+ layout=ref.layout,
224
+ generation=head.generation,
225
+ lock_timeout_ms=settings.commit.lock_timeout_ms,
226
+ )
227
+ row = await repository.schema()
228
+ if row is None:
229
+ return False
230
+ # Imported here: `lorebase_store.commit` imports this package (the commit pipeline
231
+ # applies changesets), so a module-level import is circular when this package loads first.
232
+ from lorebase_store.commit.schema import load_compiled
233
+
234
+ schema = load_compiled(row.compiled)
235
+ batch = settings.materialize.read_batch_rows
236
+ max_incremental = settings.materialize.derivation_max_incremental_records
237
+ fanout = settings.traverse.path_fanout
238
+ derived = DerivedValues(
239
+ repository,
240
+ schema,
241
+ limits=runtime.schema.expression_limits,
242
+ now=head.created_at,
243
+ batch=batch,
244
+ max_incremental=max_incremental,
245
+ fanout=fanout,
246
+ )
247
+ paths = PathLinks(
248
+ repository, schema, fanout=fanout, batch=batch, max_incremental=max_incremental
249
+ )
250
+ stale = await paths.fanout_stale(derived)
251
+ if stale:
252
+ changed = await derived.recompute_all() if derived.reads_paths else {}
253
+ await paths.recompute_all()
254
+ tags = await reference_tags(session, domain_id, branch, runtime=runtime)
255
+ tagged = with_tags(schema, tags)
256
+ for kind_id, keys in sorted(changed.items()):
257
+ if keys and derived.searchable(kind_id):
258
+ await repository.refresh_search(
259
+ kind_id,
260
+ search_weights(tagged.kinds.get(kind_id)),
261
+ config=settings.materialize.text_search_config,
262
+ keys=sorted(keys),
263
+ )
264
+ await paths.record_fanout() # also when nothing depends on it: not checked again
265
+ return stale
266
+
267
+
268
+ async def refresh_stale_paths(
269
+ sessions: async_sessionmaker[AsyncSession],
270
+ *,
271
+ runtime: StoreRuntime,
272
+ stop: asyncio.Event | None = None,
273
+ ) -> list[tuple[str, str]]:
274
+ """Rewrite the materialized paths of every branch computed with another fan-out than
275
+ `store.traverse.path_fanout`, one branch per transaction; returns the branches
276
+ rewritten. A branch that fails (deleted or locked past `commit.lock_timeout_ms`
277
+ meanwhile, or any other error) is logged and skipped: the next commit on it rewrites
278
+ them. Stops between branches once `stop` is set. Job workers run it next to their job
279
+ loop (`run_path_fanout_sweep`), not before it."""
280
+ if not runtime.settings.materialize.derivations_enabled:
281
+ return []
282
+ async with sessions() as session, session.begin(), rls_scope(session, bypass=True):
283
+ fanout = runtime.settings.traverse.path_fanout
284
+ stale = [(d, b) for d, b in await session.execute(_STALE, {"fanout": fanout})]
285
+ rewritten: list[tuple[str, str]] = []
286
+ for domain_id, branch in stale:
287
+ if stop is not None and stop.is_set():
288
+ break
289
+ try:
290
+ async with sessions() as session, session.begin():
291
+ if await refresh_path_fanout(session, domain_id, branch, runtime=runtime):
292
+ rewritten.append((domain_id, branch))
293
+ except Exception as error: # one branch must not stop the others
294
+ logger.warning(
295
+ "materialized paths not rewritten",
296
+ extra={"domain": domain_id, "branch": branch, "error": type(error).__name__},
297
+ )
298
+ return rewritten
299
+
300
+
301
+ async def run_path_fanout_sweep(
302
+ sessions: async_sessionmaker[AsyncSession],
303
+ *,
304
+ runtime: StoreRuntime,
305
+ stop: asyncio.Event | None = None,
306
+ ) -> None:
307
+ """`refresh_stale_paths` as a background task of a job worker: never raises (an error
308
+ listing the branches is logged), logs how many branches it rewrote."""
309
+ try:
310
+ rewritten = await refresh_stale_paths(sessions, runtime=runtime, stop=stop)
311
+ except Exception as error:
312
+ logger.warning("path fan-out sweep failed", extra={"error": type(error).__name__})
313
+ return
314
+ if rewritten:
315
+ logger.info("materialized paths rewritten", extra={"branches": len(rewritten)})
@@ -0,0 +1,326 @@
1
+ """Full materialization of a branch head from its tree (design 6.11).
2
+
3
+ Content comes from the tree and the objects it references; history is the changesets of the
4
+ head's first-parent chain; lineage and `updated_revision` are folded from that history with
5
+ the same rules as the incremental path (`rows.next_lineage`), so both produce equal rows.
6
+ Derived values are then computed for every Record, and the Links of materialized path
7
+ Derivations, with the evaluation the incremental path uses (`materialize.derived`,
8
+ `materialize.paths`).
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ from collections.abc import Mapping, Sequence
14
+ from dataclasses import dataclass
15
+ from datetime import datetime
16
+ from typing import Any
17
+
18
+ from sqlalchemy.ext.asyncio import AsyncSession
19
+
20
+ from lorebase_core.model import Schema
21
+ from lorebase_core.tree import PARTITION_SCHEMA, Tree
22
+ from lorebase_store.bulk import chunks
23
+ from lorebase_store.commit.schema import (
24
+ compile_schema,
25
+ element_from_body,
26
+ schema_from_elements,
27
+ )
28
+ from lorebase_store.indexes import BranchIndexes
29
+ from lorebase_store.materialize.derived import DerivedValues
30
+ from lorebase_store.materialize.overlay import promote_overlays_of
31
+ from lorebase_store.materialize.paths import PathLinks
32
+ from lorebase_store.materialize.rows import (
33
+ CREATED_KEY,
34
+ PartitionType,
35
+ changed_provenance,
36
+ next_lineage,
37
+ parse_partition,
38
+ record_title,
39
+ search_weights,
40
+ split_link_key,
41
+ )
42
+ from lorebase_store.materialize.sensitivity import branch_tagged_schema, tagged_schema
43
+ from lorebase_store.models.enums import Materialization
44
+ from lorebase_store.nodestore import NodeRepository
45
+ from lorebase_store.objectstore import PgObjectStore, StoredBody
46
+ from lorebase_store.repositories.materializations import copy_fence
47
+ from lorebase_store.repositories.materialized import (
48
+ LinkRow,
49
+ MaterializedRepository,
50
+ RecordRow,
51
+ SchemaRow,
52
+ )
53
+ from lorebase_store.repositories.refs import RefRepository
54
+ from lorebase_store.repositories.revisions import RevisionRepository, RevisionRow
55
+ from lorebase_store.repositories.scope import scope_to_domain
56
+ from lorebase_store.runtime import StoreRuntime
57
+ from lorebase_store.storage import constraints_only
58
+
59
+
60
+ @dataclass(frozen=True, slots=True)
61
+ class RebuildReport:
62
+ """What a rebuild wrote."""
63
+
64
+ branch: str
65
+ head: bytes
66
+ records: int
67
+ links: int
68
+
69
+
70
+ @dataclass(frozen=True, slots=True)
71
+ class Folded:
72
+ """Lineage inputs of one element folded from its history."""
73
+
74
+ provenance: dict[str, str]
75
+ revisions: dict[str, str]
76
+ revision_id: bytes
77
+ at: datetime
78
+
79
+
80
+ async def rebuild(
81
+ session: AsyncSession, domain_id: str, branch: str, *, runtime: StoreRuntime
82
+ ) -> RebuildReport:
83
+ """Replace the materialized head of `branch` with one computed from its tree.
84
+
85
+ Overlays based on the branch are promoted to full copies first, and an overlay branch is
86
+ rebuilt as a full one.
87
+ """
88
+ await scope_to_domain(session, domain_id)
89
+ refs = RefRepository(session, domain_id)
90
+ await refs.lock(branch, timeout_ms=runtime.settings.commit.lock_timeout_ms)
91
+ ref = await refs.require(branch)
92
+ head = await RevisionRepository(session, domain_id).require(ref.head)
93
+ await promote_overlays_of(session, domain_id, branch, runtime=runtime)
94
+ if ref.materialization is Materialization.OVERLAY:
95
+ await copy_fence(session, domain_id, branch)
96
+ await refs.set_materialization(branch, Materialization.FULL)
97
+ repository = MaterializedRepository(session, domain_id, branch, writes=runtime.settings.writes)
98
+ await repository.clear()
99
+ return await materialize_revision(session, domain_id, branch, head, runtime=runtime)
100
+
101
+
102
+ async def load_schema(
103
+ session: AsyncSession, domain_id: str, tree: Tree, *, runtime: StoreRuntime
104
+ ) -> tuple[Schema, bytes]:
105
+ """Schema of a tree and its schema partition hash."""
106
+ settings = runtime.settings
107
+ nodes = NodeRepository(
108
+ session, domain_id, writes=settings.writes, batch=settings.nodes.prefetch_batch
109
+ )
110
+ objects = PgObjectStore(
111
+ session, domain_id, writes=settings.writes, batch=settings.materialize.read_batch_rows
112
+ )
113
+ ref = tree.partitions[PARTITION_SCHEMA]
114
+ hashes: list[bytes] = []
115
+ async for entries in nodes.scan(ref.root):
116
+ hashes.extend(digest for _, digest in entries)
117
+ bodies = await objects.get_many(hashes)
118
+ elements = [element_from_body(bodies[h].object_type, bodies[h].body) for h in hashes]
119
+ return schema_from_elements(elements), ref.root
120
+
121
+
122
+ async def materialize_revision(
123
+ session: AsyncSession,
124
+ domain_id: str,
125
+ branch: str,
126
+ head: RevisionRow,
127
+ *,
128
+ runtime: StoreRuntime,
129
+ indexes: bool = True,
130
+ default_branch_tags: bool = True,
131
+ ) -> RebuildReport:
132
+ """Write the materialized head of an empty branch from the tree of `head`; without
133
+ `indexes` the branch gets no per-Attribute indexes, without `default_branch_tags` titles
134
+ and search vectors follow the sensitivity tags of `head`'s schema alone (snapshots,
135
+ `materialize.snapshot`)."""
136
+ settings = runtime.settings
137
+ batch = settings.materialize.read_batch_rows
138
+ repository = MaterializedRepository(
139
+ session, domain_id, branch, writes=settings.writes, generation=head.generation
140
+ )
141
+ nodes = NodeRepository(
142
+ session, domain_id, writes=settings.writes, batch=settings.nodes.prefetch_batch
143
+ )
144
+ objects = PgObjectStore(session, domain_id, writes=settings.writes, batch=batch)
145
+ tree = await nodes.load_tree(head.root)
146
+ schema, schema_hash = await load_schema(session, domain_id, tree, runtime=runtime)
147
+ tagged = (
148
+ await branch_tagged_schema(session, domain_id, branch, schema, runtime=runtime)
149
+ if default_branch_tags
150
+ else tagged_schema(schema, {}, runtime.schema.expression_limits)
151
+ )
152
+ branch_indexes = BranchIndexes(
153
+ session,
154
+ domain_id,
155
+ branch,
156
+ settings=settings.indexes,
157
+ maintenance=runtime.index_maintenance,
158
+ constraints_only=constraints_only(settings),
159
+ )
160
+ if indexes:
161
+ await branch_indexes.retire(schema)
162
+ await repository.set_schema(SchemaRow(schema_hash, compile_schema(schema)))
163
+ await repository.rebuild_history(head.id)
164
+ records = links = 0
165
+ for partition, ref in sorted(tree.partitions.items()):
166
+ parsed = parse_partition(partition)
167
+ if parsed.type is PartitionType.SCHEMA:
168
+ continue
169
+ entries: dict[str, bytes] = {}
170
+ async for scanned in nodes.scan(ref.root):
171
+ entries.update(scanned)
172
+ folded = await fold_history(repository, partition, batch=batch, objects=objects)
173
+ for chunk in chunks(sorted(entries), batch):
174
+ bodies = await objects.get_many(entries[key] for key in chunk)
175
+ values = {key: dict(bodies[entries[key]].body.get("values", {})) for key in chunk}
176
+ if parsed.type is PartitionType.KIND:
177
+ await repository.upsert_records(
178
+ [
179
+ record_row(
180
+ parsed.element_id,
181
+ key,
182
+ values[key],
183
+ entries[key],
184
+ tagged,
185
+ folded.get(key),
186
+ head,
187
+ )
188
+ for key in chunk
189
+ ]
190
+ )
191
+ else:
192
+ await repository.upsert_links(
193
+ [
194
+ link_row(parsed.element_id, key, values[key], entries[key], folded.get(key))
195
+ for key in chunk
196
+ ]
197
+ )
198
+ if parsed.type is PartitionType.KIND:
199
+ records += len(entries)
200
+ await repository.refresh_search(
201
+ parsed.element_id,
202
+ search_weights(tagged.kinds.get(parsed.element_id)),
203
+ config=settings.materialize.text_search_config,
204
+ )
205
+ else:
206
+ links += len(entries)
207
+ if settings.materialize.derivations_enabled:
208
+ derived = DerivedValues(
209
+ repository,
210
+ schema,
211
+ limits=runtime.schema.expression_limits,
212
+ now=head.created_at,
213
+ batch=batch,
214
+ max_incremental=settings.materialize.derivation_max_incremental_records,
215
+ fanout=settings.traverse.path_fanout,
216
+ )
217
+ await derived.recompute_all()
218
+ paths = PathLinks(
219
+ repository,
220
+ schema,
221
+ fanout=settings.traverse.path_fanout,
222
+ batch=batch,
223
+ max_incremental=settings.materialize.derivation_max_incremental_records,
224
+ )
225
+ await paths.recompute_all()
226
+ await paths.record_fanout()
227
+ for kind_id in sorted(derived.kinds):
228
+ if derived.searchable(kind_id):
229
+ await repository.refresh_search(
230
+ kind_id,
231
+ search_weights(tagged.kinds.get(kind_id)),
232
+ config=settings.materialize.text_search_config,
233
+ )
234
+ if indexes:
235
+ await branch_indexes.ensure(schema)
236
+ return RebuildReport(branch=branch, head=head.id, records=records, links=links)
237
+
238
+
239
+ async def fold_history(
240
+ repository: MaterializedRepository,
241
+ partition: str,
242
+ *,
243
+ batch: int,
244
+ objects: PgObjectStore,
245
+ keys: Sequence[str] | None = None,
246
+ ) -> dict[str, Folded]:
247
+ """Per element still present (of those in `keys`, when given), the union of its
248
+ provenance maps since it was last created, and the Revision of each entry. A step that
249
+ creates the element (no old hash) records its Revision under `CREATED_KEY`; history
250
+ that starts after the creation names none. A merge Revision's step attributes only the
251
+ values it changes against the branch's previous value (`rows.changed_provenance`): merges
252
+ made before their changesets were restricted carry the source's whole Lineage."""
253
+ folded: dict[str, Folded] = {}
254
+ async for steps in repository.stream_history_steps(partition, batch=batch, keys=keys):
255
+ merges = [s for s in steps if s.merge and s.new_hash is not None and s.provenance]
256
+ bodies = await objects.get_many(
257
+ h for s in merges for h in (s.old_hash, s.new_hash) if h is not None
258
+ )
259
+
260
+ for step in steps:
261
+ if step.new_hash is None:
262
+ folded.pop(step.element_key, None)
263
+ continue
264
+ taken = dict(step.provenance or {})
265
+ if step.merge and taken and (new := _values(bodies, step.new_hash)) is not None:
266
+ taken = changed_provenance(taken, _values(bodies, step.old_hash), new)
267
+ previous = folded.get(step.element_key)
268
+ provenance = {} if previous is None else previous.provenance
269
+ provenance.update(taken)
270
+ revisions = {} if previous is None else previous.revisions
271
+ if step.old_hash is None:
272
+ revisions = {CREATED_KEY: step.revision_id.hex()}
273
+ revisions.update(dict.fromkeys(taken, step.revision_id.hex()))
274
+ folded[step.element_key] = Folded(provenance, revisions, step.revision_id, step.at)
275
+ return folded
276
+
277
+
278
+ def _values(bodies: Mapping[bytes, StoredBody], digest: bytes | None) -> Mapping[str, Any] | None:
279
+ found = None if digest is None else bodies.get(digest)
280
+ return None if found is None else found.body.get("values") or {}
281
+
282
+
283
+ def record_row(
284
+ kind_id: str,
285
+ key: str,
286
+ values: Mapping[str, Any],
287
+ digest: bytes,
288
+ schema: Schema,
289
+ folded: Folded | None,
290
+ head: RevisionRow,
291
+ ) -> RecordRow:
292
+ return RecordRow(
293
+ kind_id=kind_id,
294
+ key=key,
295
+ values=dict(values),
296
+ title=record_title(schema.kinds.get(kind_id), values),
297
+ object_hash=digest,
298
+ lineage=next_lineage(None, None if folded is None else folded.provenance, values),
299
+ updated_revision=head.id if folded is None else folded.revision_id,
300
+ updated_at=head.created_at if folded is None else folded.at,
301
+ lineage_revisions=None
302
+ if folded is None
303
+ else {
304
+ attribute: revision
305
+ for attribute, revision in sorted(folded.revisions.items())
306
+ if attribute in values or attribute == CREATED_KEY
307
+ },
308
+ )
309
+
310
+
311
+ def link_row(
312
+ relation_id: str,
313
+ key: str,
314
+ values: Mapping[str, Any],
315
+ digest: bytes,
316
+ folded: Folded | None,
317
+ ) -> LinkRow:
318
+ from_key, to_key = split_link_key(key)
319
+ return LinkRow(
320
+ relation_id=relation_id,
321
+ from_key=from_key,
322
+ to_key=to_key,
323
+ values=dict(values),
324
+ object_hash=digest,
325
+ lineage=next_lineage(None, None if folded is None else folded.provenance, values),
326
+ )