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,502 @@
1
+ """Lineage revisions of rows materialized before migration `0032`: their backfill in batches.
2
+
3
+ `mat_records.lineage_revisions` names the Revision behind each value of a Record and the one
4
+ that created it (`materialize.rows.next_lineage_revisions`). Rows written before the column
5
+ existed hold null, and rows of those Records updated since hold only the entries of their
6
+ later changes (no `$created`); readers fall back to the branch history for what is missing,
7
+ within `read.history.lineage_scan_changes` changes (Keeper `reading.record_history`).
8
+
9
+ **Backfill.** `backfill_lineage_revisions` folds the branch history of those rows as a rebuild
10
+ does (`materialize.rebuild.fold_history`) and writes the result, `materialize.job_batch_rows`
11
+ rows per transaction in `(Kind id, key)` order. A row is written only while it still holds
12
+ what the batch read, so a commit that changed it meanwhile wins; a Record with no history row
13
+ gets an empty map, so a batch run twice writes nothing new. Rebuilds and branch copies fold
14
+ the history themselves and leave nothing to backfill.
15
+
16
+ **Jobs.** `store.lineage_backfill` with payload `{}` (no Domain) is the sweep: it finds the
17
+ branches holding such rows, in one scan of `mat_records`, and queues the job with payload
18
+ `{"branch"}` for each in its Domain, which runs the backfill of that branch. A starting job
19
+ worker that runs the type queues the sweep once (`queue_lineage_sweep`): not again once one
20
+ is queued, running or done.
21
+
22
+ **Recompute.** Merges made before their changesets were restricted to the values they change
23
+ recorded the source's whole Lineage, so their Revision was named for values they left as they
24
+ were. With `"recompute": true` (in the sweep's payload, which then covers every branch with
25
+ materialized Records, or in a branch's), the job also folds again the rows whose lineage
26
+ revisions name a merge Revision; the fold attributes a merge step only the values that differ
27
+ from the branch's previous value (`rebuild.fold_history`). `python -m lorebase_store.jobs
28
+ lineage-backfill [--domain D --branch B] [--recompute]` queues it.
29
+
30
+ Recompute also corrects what fast-forwards wrote before they named the source's Revisions: one
31
+ history row per element at the source head, and rows naming the source head for every value
32
+ they changed. Under the branch lock, each history row of the branch's own that no changeset
33
+ holds (same element, Revision and previous object) is such a row; the fast-forward's range
34
+ (from the ref log: the head it moved from) is written again one Revision at a time, as a
35
+ rebuild writes it, within
36
+ `materialize.fast_forward_history_max_revisions` (a longer range is left as it is). The
37
+ Records last updated by those fast-forwards are then folded again like the others, their
38
+ lineage, lineage revisions and update Revision included, and so is the lineage of the Links
39
+ those fast-forwards wrote. Finding those rows reads the
40
+ branch's whole history once.
41
+ """
42
+
43
+ from __future__ import annotations
44
+
45
+ import json
46
+ import logging
47
+ from collections import defaultdict
48
+ from collections.abc import Mapping
49
+ from dataclasses import dataclass
50
+ from typing import Any
51
+
52
+ from sqlalchemy import TextClause, text
53
+ from sqlalchemy.ext.asyncio import AsyncEngine, AsyncSession, async_sessionmaker
54
+
55
+ from lorebase_core.tree import LINK_KEY_SEPARATOR, kind_partition
56
+ from lorebase_store.auth.scope import rls_scope
57
+ from lorebase_store.jobs import LINEAGE_BACKFILL_JOB, JobRow, enqueue_job
58
+ from lorebase_store.materialize.rebuild import fold_history
59
+ from lorebase_store.materialize.rows import (
60
+ CREATED_KEY,
61
+ PartitionType,
62
+ next_lineage,
63
+ parse_partition,
64
+ split_link_key,
65
+ )
66
+ from lorebase_store.objectstore import PgObjectStore
67
+ from lorebase_store.repositories.materialized import MERGE_ORIGIN_TYPE, MaterializedRepository
68
+ from lorebase_store.repositories.refs import RefRepository
69
+ from lorebase_store.repositories.scope import scope_to_domain
70
+ from lorebase_store.runtime import StoreRuntime, default_runtime
71
+
72
+ log = logging.getLogger(__name__)
73
+
74
+ SWEEP_PAYLOAD: Mapping[str, Any] = {}
75
+ """Payload of the sweep job."""
76
+
77
+ _REVISIONS = "lb_lineage(m.domain_id, m.lineage_revisions)"
78
+ """The lineage revisions of row `m`, a shared map resolved (revision 0036)."""
79
+
80
+ _INCOMPLETE = f"(m.lineage_revisions IS NULL OR ({_REVISIONS} ->> '{CREATED_KEY}') IS NULL)"
81
+
82
+ _BRANCHES = text(
83
+ f"""
84
+ SELECT DISTINCT m.domain_id, m.branch FROM mat_records m
85
+ WHERE NOT m.deleted AND {_INCOMPLETE}
86
+ ORDER BY m.domain_id, m.branch
87
+ """
88
+ )
89
+
90
+ # Serializes workers starting together, so they queue one sweep between them.
91
+ _SWEEP_LOCK = text("SELECT pg_advisory_xact_lock(hashtext('lorebase.store.lineage_backfill'))")
92
+
93
+ _SWEEP_SEEN = text(
94
+ """
95
+ SELECT 1 FROM jobs
96
+ WHERE type = :type AND domain_id IS NULL AND payload = CAST(:payload AS jsonb)
97
+ AND status::text IN ('queued', 'running', 'succeeded')
98
+ LIMIT 1
99
+ """
100
+ )
101
+
102
+ _ALL_BRANCHES = text(
103
+ """
104
+ SELECT DISTINCT m.domain_id, m.branch FROM mat_records m
105
+ WHERE NOT m.deleted
106
+ ORDER BY m.domain_id, m.branch
107
+ """
108
+ )
109
+
110
+ # Rows whose lineage revisions name a merge Revision.
111
+ _NAMES_A_MERGE = f"""EXISTS (
112
+ SELECT 1
113
+ FROM jsonb_each_text(CASE WHEN jsonb_typeof(m.lineage_revisions) = 'object'
114
+ THEN {_REVISIONS} ELSE '{{}}'::jsonb END) AS e
115
+ JOIN revisions r ON r.domain_id = m.domain_id AND r.id = decode(e.value, 'hex')
116
+ WHERE r.origin ->> 'type' = '{MERGE_ORIGIN_TYPE}')"""
117
+
118
+
119
+ def _page(condition: str) -> TextClause:
120
+ return text(
121
+ f"""
122
+ SELECT m.kind_id, m.key, {_REVISIONS} AS lineage_revisions, m."values",
123
+ lb_lineage(m.domain_id, m.lineage) AS lineage, m.updated_revision
124
+ FROM mat_records m
125
+ WHERE m.domain_id = :domain AND m.branch = :branch AND NOT m.deleted AND {condition}
126
+ AND (m.kind_id, m.key) > (:kind, :key)
127
+ ORDER BY m.kind_id, m.key
128
+ LIMIT :limit
129
+ """
130
+ )
131
+
132
+
133
+ _PAGE = _page(_INCOMPLETE)
134
+ _RECOMPUTE_PAGE = _page(
135
+ f"({_INCOMPLETE} OR {_NAMES_A_MERGE} "
136
+ "OR m.updated_revision = ANY(CAST(:fast_forwards AS bytea[])))"
137
+ )
138
+
139
+ # Fast-forward history rows: the branch's own history rows that no changeset holds (a
140
+ # fast-forward's row starts from the branch's previous object, not the source's), by
141
+ # Revision, with the head the branch moved from to it (ref log).
142
+ _FAST_FORWARDS = text(
143
+ """
144
+ SELECT DISTINCT h.revision_id,
145
+ (SELECT l.old_head FROM ref_log l
146
+ WHERE l.domain_id = h.domain_id AND l.ref_name = h.branch
147
+ AND l.new_head = h.revision_id
148
+ ORDER BY l.seq DESC LIMIT 1) AS old_head
149
+ FROM mat_history h
150
+ WHERE h.domain_id = :domain AND h.branch = :branch
151
+ AND NOT EXISTS (
152
+ SELECT 1 FROM changesets c
153
+ WHERE c.domain_id = h.domain_id AND c.revision_id = h.revision_id
154
+ AND c.partition = h.partition AND c.element_key = h.element_key
155
+ AND c.old_hash IS NOT DISTINCT FROM h.old_hash)
156
+ """
157
+ )
158
+
159
+ _ELEMENTS_AT = text(
160
+ "SELECT partition, element_key FROM mat_history "
161
+ "WHERE domain_id = :domain AND branch = :branch AND revision_id = :revision"
162
+ )
163
+
164
+ _LINK_LINEAGE = text(
165
+ """
166
+ UPDATE mat_links SET lineage = CAST(:lineage AS jsonb)
167
+ WHERE domain_id = :domain AND branch = :branch AND relation_id = :relation
168
+ AND from_key = :from_key AND to_key = :to_key
169
+ AND lb_lineage(domain_id, lineage) IS DISTINCT FROM CAST(:lineage AS jsonb)
170
+ """
171
+ )
172
+
173
+ _DROP_HISTORY_AT = text(
174
+ "DELETE FROM mat_history WHERE domain_id = :domain AND branch = :branch "
175
+ "AND revision_id = :revision"
176
+ )
177
+
178
+ _REFOLD = text(
179
+ """
180
+ UPDATE mat_records m
181
+ SET lineage_revisions = v.revs, lineage = v.lineage,
182
+ updated_revision = decode(v.updated, 'hex'), updated_at = v.updated_at
183
+ FROM jsonb_to_recordset(CAST(:rows AS jsonb)) AS v(kind_id text, key text, revs jsonb,
184
+ before jsonb, lineage jsonb,
185
+ updated text, updated_at timestamptz)
186
+ WHERE m.domain_id = :domain AND m.branch = :branch
187
+ AND m.kind_id = v.kind_id AND m.key = v.key
188
+ AND lb_lineage(m.domain_id, m.lineage_revisions) IS NOT DISTINCT FROM v.before
189
+ """
190
+ )
191
+
192
+ _WRITE = text(
193
+ """
194
+ UPDATE mat_records m SET lineage_revisions = v.revs
195
+ FROM jsonb_to_recordset(CAST(:rows AS jsonb)) AS v(kind_id text, key text, revs jsonb,
196
+ before jsonb)
197
+ WHERE m.domain_id = :domain AND m.branch = :branch
198
+ AND m.kind_id = v.kind_id AND m.key = v.key
199
+ AND lb_lineage(m.domain_id, m.lineage_revisions) IS NOT DISTINCT FROM v.before
200
+ """
201
+ )
202
+
203
+
204
+ @dataclass
205
+ class LineageBackfill:
206
+ """What a backfill did: rows read and rows written, in how many batches."""
207
+
208
+ branch: str
209
+ batches: int = 0
210
+ rows: int = 0
211
+ written: int = 0
212
+ fast_forwards: int = 0
213
+ links: int = 0
214
+
215
+ def as_dict(self) -> dict[str, Any]:
216
+ return {
217
+ "branch": self.branch,
218
+ "fast_forwards": self.fast_forwards,
219
+ "links": self.links,
220
+ "batches": self.batches,
221
+ "rows": self.rows,
222
+ "written": self.written,
223
+ }
224
+
225
+
226
+ def backfill_payload(branch: str | None = None, *, recompute: bool = False) -> dict[str, Any]:
227
+ """Payload of a `store.lineage_backfill` job: the sweep without `branch`."""
228
+ payload: dict[str, Any] = {} if branch is None else {"branch": branch}
229
+ if recompute:
230
+ payload["recompute"] = True
231
+ return payload
232
+
233
+
234
+ async def queue_lineage_sweep(sessions: async_sessionmaker[AsyncSession]) -> int | None:
235
+ """Queue the sweep unless one is queued, running or done; returns its id when queued.
236
+ Workers starting together wait for one another (a transaction advisory lock)."""
237
+ payload = json.dumps(dict(SWEEP_PAYLOAD))
238
+ async with sessions() as session, session.begin():
239
+ await session.execute(_SWEEP_LOCK)
240
+ async with rls_scope(session, bypass=True):
241
+ seen = await session.execute(
242
+ _SWEEP_SEEN, {"type": LINEAGE_BACKFILL_JOB, "payload": payload}
243
+ )
244
+ if seen.first() is not None:
245
+ return None
246
+ return await enqueue_job(session, LINEAGE_BACKFILL_JOB, payload=SWEEP_PAYLOAD)
247
+
248
+
249
+ async def queue_branch_backfills(session: AsyncSession, *, recompute: bool = False) -> list[int]:
250
+ """Queue the backfill of every branch holding rows without full lineage revisions, or
251
+ with `recompute` of every branch with materialized Records (an identical job not started
252
+ yet is reused)."""
253
+ async with rls_scope(session, bypass=True):
254
+ found = (await session.execute(_ALL_BRANCHES if recompute else _BRANCHES)).all()
255
+ return [
256
+ await enqueue_job(
257
+ session,
258
+ LINEAGE_BACKFILL_JOB,
259
+ domain_id=domain_id,
260
+ payload=backfill_payload(branch, recompute=recompute),
261
+ unique_queued=True,
262
+ )
263
+ for domain_id, branch in found
264
+ ]
265
+
266
+
267
+ async def backfill_lineage_revisions(
268
+ engine: AsyncEngine,
269
+ domain_id: str,
270
+ branch: str,
271
+ *,
272
+ runtime: StoreRuntime,
273
+ recompute: bool = False,
274
+ ) -> LineageBackfill:
275
+ """Write the lineage revisions of the branch's rows that lack them (with `recompute`,
276
+ also of those naming a merge Revision), in batches, each in its own transaction (module
277
+ doc)."""
278
+ sessions = async_sessionmaker(engine, expire_on_commit=False)
279
+ report = LineageBackfill(branch)
280
+ fast_forwards: list[bytes] = []
281
+ if recompute:
282
+ async with sessions() as session, session.begin():
283
+ fast_forwards = await _repair_fast_forwards(session, domain_id, branch, runtime, report)
284
+ report.fast_forwards = len(fast_forwards)
285
+ cursor = ("", "")
286
+ while True:
287
+ async with sessions() as session, session.begin():
288
+ last = await _backfill_batch(
289
+ session,
290
+ domain_id,
291
+ branch,
292
+ cursor,
293
+ runtime,
294
+ report,
295
+ recompute=recompute,
296
+ fast_forwards=fast_forwards,
297
+ )
298
+ if last is None:
299
+ return report
300
+ cursor = last
301
+
302
+
303
+ async def _repair_fast_forwards(
304
+ session: AsyncSession,
305
+ domain_id: str,
306
+ branch: str,
307
+ runtime: StoreRuntime,
308
+ report: LineageBackfill,
309
+ ) -> list[bytes]:
310
+ """Write the history of the branch's earlier fast-forwards one Revision at a time and
311
+ fold again the lineage of the Links they wrote (module doc); returns the fast-forward
312
+ targets it rewrote, whose Records need folding again (a range it leaves as it is keeps
313
+ its rows)."""
314
+ settings = runtime.settings
315
+ await scope_to_domain(session, domain_id)
316
+ await RefRepository(session, domain_id).lock(branch, timeout_ms=settings.commit.lock_timeout_ms)
317
+ found = (await session.execute(_FAST_FORWARDS, {"domain": domain_id, "branch": branch})).all()
318
+ repository = MaterializedRepository(session, domain_id, branch, writes=settings.writes)
319
+ targets: list[bytes] = []
320
+ links: dict[str, set[str]] = {}
321
+ for revision, old_head in found:
322
+ target = bytes(revision)
323
+ if old_head is None:
324
+ continue
325
+ forwarded = await repository.first_parent_range(
326
+ target,
327
+ bytes(old_head),
328
+ limit=settings.materialize.fast_forward_history_max_revisions,
329
+ )
330
+ if forwarded is None:
331
+ continue
332
+ at = {"domain": domain_id, "branch": branch, "revision": target}
333
+ for partition, key in (await session.execute(_ELEMENTS_AT, at)).all():
334
+ if parse_partition(partition).type is PartitionType.RELATION:
335
+ links.setdefault(partition, set()).add(key)
336
+ await session.execute(_DROP_HISTORY_AT, at)
337
+ await repository.insert_chain_history(forwarded)
338
+ targets.append(target)
339
+ report.links += await _refold_links(session, repository, links, runtime)
340
+ return targets
341
+
342
+
343
+ async def _refold_links(
344
+ session: AsyncSession,
345
+ repository: MaterializedRepository,
346
+ links: Mapping[str, set[str]],
347
+ runtime: StoreRuntime,
348
+ ) -> int:
349
+ """Lineage of the branch's own Links among `links` (keys by partition) folded again
350
+ from the history, as a rebuild writes it (`rebuild.link_row`); returns how many changed."""
351
+ settings = runtime.settings
352
+ objects = PgObjectStore(
353
+ session,
354
+ repository.domain_id,
355
+ writes=settings.writes,
356
+ batch=settings.materialize.read_batch_rows,
357
+ )
358
+ written = 0
359
+ for partition, keys in sorted(links.items()):
360
+ relation_id = parse_partition(partition).element_id
361
+ folded = await fold_history(
362
+ repository,
363
+ partition,
364
+ batch=settings.materialize.read_batch_rows,
365
+ objects=objects,
366
+ keys=sorted(keys),
367
+ )
368
+ rows = await repository.links(relation_id, [split_link_key(key) for key in keys])
369
+ for (from_key, to_key), row in sorted(rows.items()):
370
+ found = folded.get(f"{from_key}{LINK_KEY_SEPARATOR}{to_key}")
371
+ lineage = next_lineage(None, None if found is None else found.provenance, row.values)
372
+ result = await session.execute(
373
+ _LINK_LINEAGE,
374
+ {
375
+ "domain": repository.domain_id,
376
+ "branch": repository.branch,
377
+ "relation": relation_id,
378
+ "from_key": from_key,
379
+ "to_key": to_key,
380
+ "lineage": json.dumps(lineage),
381
+ },
382
+ )
383
+ written += int(getattr(result, "rowcount", 0) or 0)
384
+ return written
385
+
386
+
387
+ async def _backfill_batch(
388
+ session: AsyncSession,
389
+ domain_id: str,
390
+ branch: str,
391
+ cursor: tuple[str, str],
392
+ runtime: StoreRuntime,
393
+ report: LineageBackfill,
394
+ *,
395
+ recompute: bool,
396
+ fast_forwards: list[bytes],
397
+ ) -> tuple[str, str] | None:
398
+ settings = runtime.settings
399
+ await scope_to_domain(session, domain_id)
400
+ params = {
401
+ "domain": domain_id,
402
+ "branch": branch,
403
+ "kind": cursor[0],
404
+ "key": cursor[1],
405
+ "limit": settings.materialize.job_batch_rows,
406
+ "fast_forwards": fast_forwards,
407
+ }
408
+ page = (await session.execute(_RECOMPUTE_PAGE if recompute else _PAGE, params)).all()
409
+ if not page:
410
+ return None
411
+ repository = MaterializedRepository(session, domain_id, branch, writes=settings.writes)
412
+ objects = PgObjectStore(
413
+ session, domain_id, writes=settings.writes, batch=settings.materialize.read_batch_rows
414
+ )
415
+ by_kind: dict[str, list[Any]] = defaultdict(list)
416
+ for row in page:
417
+ by_kind[row.kind_id].append(row)
418
+ rows: list[dict[str, Any]] = []
419
+ refolded: list[dict[str, Any]] = []
420
+ for kind_id, entries in by_kind.items():
421
+ folded = await fold_history(
422
+ repository,
423
+ kind_partition(kind_id),
424
+ batch=settings.materialize.read_batch_rows,
425
+ objects=objects,
426
+ keys=[entry.key for entry in entries],
427
+ )
428
+ for entry in entries:
429
+ key, before = entry.key, entry.lineage_revisions
430
+ found = folded.get(key)
431
+ revisions = {} if found is None else dict(sorted(found.revisions.items()))
432
+ if recompute and found is not None:
433
+ # As a rebuild writes the row (`rebuild.record_row`).
434
+ lineage = next_lineage(None, found.provenance, entry.values or {})
435
+ if (revisions, lineage, found.revision_id) != (
436
+ before,
437
+ entry.lineage,
438
+ bytes(entry.updated_revision),
439
+ ):
440
+ refolded.append(
441
+ {
442
+ "kind_id": kind_id,
443
+ "key": key,
444
+ "revs": revisions,
445
+ "before": before,
446
+ "lineage": lineage,
447
+ "updated": found.revision_id.hex(),
448
+ "updated_at": found.at.isoformat(),
449
+ }
450
+ )
451
+ continue
452
+ if before is not None and not revisions:
453
+ continue
454
+ if revisions != before:
455
+ rows.append({"kind_id": kind_id, "key": key, "revs": revisions, "before": before})
456
+ if refolded:
457
+ result = await session.execute(
458
+ _REFOLD, {"domain": domain_id, "branch": branch, "rows": json.dumps(refolded)}
459
+ )
460
+ report.written += int(getattr(result, "rowcount", 0) or 0)
461
+ if rows:
462
+ result = await session.execute(
463
+ _WRITE, {"domain": domain_id, "branch": branch, "rows": json.dumps(rows)}
464
+ )
465
+ report.written += int(getattr(result, "rowcount", 0) or 0)
466
+ report.batches += 1
467
+ report.rows += len(page)
468
+ return str(page[-1][0]), str(page[-1][1])
469
+
470
+
471
+ async def run_lineage_backfill_job(
472
+ engine: AsyncEngine, job: JobRow, *, runtime: StoreRuntime | None = None
473
+ ) -> Mapping[str, Any]:
474
+ """Job handler of `store.lineage_backfill` (module doc)."""
475
+ runtime = runtime or default_runtime()
476
+ branch = job.payload.get("branch")
477
+ recompute = job.payload.get("recompute", False)
478
+ if not isinstance(recompute, bool):
479
+ raise ValueError("`recompute` must be a boolean")
480
+ if branch is None and job.domain_id is None:
481
+ sessions = async_sessionmaker(engine, expire_on_commit=False)
482
+ async with sessions() as session, session.begin():
483
+ queued = await queue_branch_backfills(session, recompute=recompute)
484
+ return {"queued": queued, "recompute": recompute}
485
+ if job.domain_id is None or not isinstance(branch, str) or not branch:
486
+ raise ValueError(f"a {job.type} job needs a Domain and a `branch`, or neither")
487
+ report = await backfill_lineage_revisions(
488
+ engine, job.domain_id, branch, runtime=runtime, recompute=recompute
489
+ )
490
+ log.info("lineage revisions backfilled", extra=report.as_dict())
491
+ return {**report.as_dict(), "recompute": recompute}
492
+
493
+
494
+ __all__ = [
495
+ "SWEEP_PAYLOAD",
496
+ "LineageBackfill",
497
+ "backfill_lineage_revisions",
498
+ "backfill_payload",
499
+ "queue_branch_backfills",
500
+ "queue_lineage_sweep",
501
+ "run_lineage_backfill_job",
502
+ ]