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,444 @@
1
+ """Titles and search vectors a branch materialized before the default branch tagged an
2
+ Attribute (design 10.2): their rewrite in batches.
3
+
4
+ A branch forked before the default branch tagged a title or searchable Attribute keeps the
5
+ titles and search vectors it computed without the tag: Keeper keeps them out of responses and
6
+ lexical search for callers without the clearance, and verification reports the titles as
7
+ `sensitivity_stale`. The materializer adds the default branch's tags whenever it writes a
8
+ Record (`materialize.sensitivity`), so only rows written before the tag are stale, in the
9
+ Kinds whose title or search weights the tags change (`stale_kinds`).
10
+
11
+ **Rewrite.** `refresh_stale_rows` recomputes the titles and search vectors of those Kinds as
12
+ the materializer computes them now, `materialize.job_batch_rows` Records per transaction in
13
+ `(Kind id, key)` order. Each batch holds the branch lock, like a commit, and writes through
14
+ `MaterializedRepository` (rows an overlay reads from its base are copied up, rows overlays
15
+ based on the branch read are copied into them first, stamped with the generation of the
16
+ branch head). Only titles that differ are written, so a batch run twice writes nothing new.
17
+ Nothing is committed: titles and search vectors are not part of the tree.
18
+
19
+ Tags are the effective ones (`materialize.sensitivity.tagged_schema`): a derived title or
20
+ searchable Attribute reading a tagged Attribute is stale too.
21
+
22
+ **Job.** `store.sensitivity_refresh` (payload `{"branch"}`) runs the rewrite for one branch.
23
+ A batch that waits longer than `commit.lock_timeout_ms` for the branch lock, three times, queues
24
+ the job again after `materialize.job_retry_seconds`, with `resume` set to the last key
25
+ written. When it is done, the job queues itself for the overlays based on the branch: the
26
+ copies it made into them hold the old rows, and their own rows are theirs to rewrite. A job
27
+ is queued unless an identical one is still queued; a running one does not count, since it
28
+ may already be past the Kinds a new tag makes stale.
29
+
30
+ **Automatic.** With `materialize.refresh_branches_on_tag`, a commit or merge on the default
31
+ branch that adds a tag (to any Attribute, new ones included) queues the job, in one statement
32
+ of its transaction, for every other branch that has stale Kinds by its schema, except
33
+ overlays whose base is queued (the base's job queues them). Creating a copy of a branch with
34
+ stale Kinds queues the copy (an overlay's source is queued instead), and so does the end of a
35
+ background copy (`queue_if_stale`). The branches' schemas are not changed:
36
+ `POST .../branches/{b}/rebase-tags` (Keeper) commits the tags to a branch's schema, and that
37
+ schema change recomputes the titles and search vectors of the Kinds it changes in its own
38
+ transaction, as every schema change does.
39
+ """
40
+
41
+ from __future__ import annotations
42
+
43
+ import asyncio
44
+ import json
45
+ import logging
46
+ import time
47
+ from collections.abc import Callable, Mapping, Sequence
48
+ from dataclasses import dataclass, field
49
+ from datetime import UTC, datetime, timedelta
50
+ from typing import Any
51
+
52
+ from sqlalchemy import text
53
+ from sqlalchemy.exc import DBAPIError
54
+ from sqlalchemy.ext.asyncio import AsyncEngine, AsyncSession, async_sessionmaker
55
+
56
+ from lorebase_core.expr.sensitivity import raised_tags, schema_tags
57
+ from lorebase_core.model import Kind, Schema
58
+ from lorebase_store.auth.scope import rls_scope
59
+ from lorebase_store.commit.schema import load_compiled
60
+ from lorebase_store.jobs import SENSITIVITY_REFRESH_JOB, JobRow, enqueue_job
61
+ from lorebase_store.materialize.rows import record_title, search_weights
62
+ from lorebase_store.materialize.sensitivity import branch_tagged_schema, tagged_schema
63
+ from lorebase_store.models.enums import Materialization, RefKind
64
+ from lorebase_store.repositories.domains import DomainRepository
65
+ from lorebase_store.repositories.materialized import MaterializedRepository
66
+ from lorebase_store.repositories.refs import LockTimeoutError, RefRepository, sqlstate
67
+ from lorebase_store.repositories.revisions import RevisionRepository
68
+ from lorebase_store.repositories.scope import scope_to_domain
69
+ from lorebase_store.runtime import StoreRuntime, default_runtime
70
+ from lorebase_store.telemetry import BRANCH_JOB_BATCH_DURATION, BRANCH_JOB_ROWS, BRANCH_JOB_RUNS
71
+
72
+ log = logging.getLogger(__name__)
73
+
74
+ JOB_NAME = "sensitivity_refresh"
75
+ """Value of the `job` attribute of the branch job metrics."""
76
+ _RETRYABLE = frozenset({"40P01", "40001"})
77
+ _ATTEMPTS = 3
78
+ """Runs of a batch that hits a retryable failure or a busy branch before the job queues
79
+ itself again."""
80
+
81
+ Cursor = tuple[str, str]
82
+ """`(Kind id, key)` of the last Record a batch read."""
83
+
84
+
85
+ def _now() -> datetime:
86
+ return datetime.now(UTC)
87
+
88
+
89
+ def _title_hidden(kind: Kind) -> bool:
90
+ attribute = kind.attributes.get(kind.title_attribute or "")
91
+ return attribute is not None and attribute.is_sensitive
92
+
93
+
94
+ def stale_kinds(own: Schema, tagged: Schema) -> list[str]:
95
+ """Kinds of `own` whose titles or search vectors `tagged` (`own` with the default branch's
96
+ tags added) computes differently: a title or searchable Attribute it tags."""
97
+ stale = []
98
+ for kind_id, kind in own.kinds.items():
99
+ other = tagged.kinds.get(kind_id)
100
+ if other is None:
101
+ continue
102
+ if _title_hidden(other) != _title_hidden(kind) or search_weights(other) != search_weights(
103
+ kind
104
+ ):
105
+ stale.append(kind_id)
106
+ return sorted(stale)
107
+
108
+
109
+ @dataclass
110
+ class StaleRefresh:
111
+ """What a rewrite did: the stale Kinds, Records read, titles rewritten, and where it
112
+ stopped when it was deferred (`resume`)."""
113
+
114
+ branch: str
115
+ status: str = "done"
116
+ kinds: list[str] = field(default_factory=list)
117
+ batches: int = 0
118
+ rows: int = 0
119
+ titles: int = 0
120
+ vectors: int = 0
121
+ resume: Cursor | None = None
122
+
123
+ @property
124
+ def rewrote(self) -> bool:
125
+ return bool(self.titles or self.vectors)
126
+
127
+ def as_dict(self) -> dict[str, Any]:
128
+ return {
129
+ "branch": self.branch,
130
+ "status": self.status,
131
+ "kinds": list(self.kinds),
132
+ "batches": self.batches,
133
+ "rows": self.rows,
134
+ "titles": self.titles,
135
+ "vectors": self.vectors,
136
+ "resume": None if self.resume is None else list(self.resume),
137
+ }
138
+
139
+
140
+ def refresh_payload(branch: str, resume: Cursor | None = None) -> dict[str, Any]:
141
+ """Payload of a `store.sensitivity_refresh` job."""
142
+ payload: dict[str, Any] = {"branch": branch}
143
+ if resume is not None:
144
+ payload["resume"] = list(resume)
145
+ return payload
146
+
147
+
148
+ async def queue_stale_refresh(
149
+ session: AsyncSession,
150
+ domain_id: str,
151
+ branch: str,
152
+ *,
153
+ resume: Cursor | None = None,
154
+ run_after: datetime | None = None,
155
+ ) -> int:
156
+ """Queue the rewrite of a branch's stale rows (an identical job that has not started yet
157
+ is reused)."""
158
+ return await enqueue_job(
159
+ session,
160
+ SENSITIVITY_REFRESH_JOB,
161
+ domain_id=domain_id,
162
+ payload=refresh_payload(branch, resume),
163
+ run_after=run_after,
164
+ unique_queued=True,
165
+ )
166
+
167
+
168
+ async def queue_if_stale(
169
+ session: AsyncSession, domain_id: str, branch: str, schema: Schema, *, runtime: StoreRuntime
170
+ ) -> int | None:
171
+ """With `materialize.refresh_branches_on_tag`, queue the rewrite of `branch` (whose
172
+ schema is `schema`) when the default branch's tags make some of its Kinds stale."""
173
+ if not runtime.settings.materialize.refresh_branches_on_tag:
174
+ return None
175
+ tagged = await branch_tagged_schema(session, domain_id, branch, schema, runtime=runtime)
176
+ if not stale_kinds(schema, tagged):
177
+ return None
178
+ return await queue_stale_refresh(session, domain_id, branch)
179
+
180
+
181
+ _BRANCH_SCHEMAS = text(
182
+ """
183
+ SELECT r.name, r.materialization::text, r.base_branch, s.schema_hash
184
+ FROM refs r JOIN mat_schema s ON s.domain_id = r.domain_id AND s.branch = r.name
185
+ WHERE r.domain_id = :domain AND r.kind = 'branch' AND r.deleted_at IS NULL
186
+ AND r.name <> :default
187
+ """
188
+ )
189
+ _COMPILED = text(
190
+ """
191
+ SELECT DISTINCT ON (schema_hash) schema_hash, compiled FROM mat_schema
192
+ WHERE domain_id = :domain AND schema_hash = ANY(CAST(:hashes AS bytea[]))
193
+ """
194
+ )
195
+ _ENQUEUE_MISSING = text(
196
+ """
197
+ INSERT INTO jobs (type, domain_id, payload, priority, run_after)
198
+ SELECT CAST(:type AS text), CAST(:domain AS text), e.payload, 0, CAST(:now AS timestamptz)
199
+ FROM jsonb_array_elements(CAST(:payloads AS jsonb)) AS e(payload)
200
+ WHERE NOT EXISTS (
201
+ SELECT 1 FROM jobs j
202
+ WHERE j.type = CAST(:type AS text) AND j.domain_id = CAST(:domain AS text)
203
+ AND j.status::text = 'queued' AND j.payload = e.payload
204
+ )
205
+ RETURNING id
206
+ """
207
+ )
208
+
209
+
210
+ async def queue_after_tagging(
211
+ session: AsyncSession,
212
+ domain_id: str,
213
+ branch: str,
214
+ old: Schema,
215
+ new: Schema,
216
+ *,
217
+ runtime: StoreRuntime,
218
+ ) -> list[int]:
219
+ """In the transaction of a commit or merge that changed the schema of `branch` from `old`
220
+ to `new`: when `branch` is the default branch, `new` adds tags and
221
+ `materialize.refresh_branches_on_tag` is on, queue the rewrite, in one statement, for
222
+ every other branch that has stale Kinds by its schema and `new`'s tags, except overlays
223
+ whose base is queued too. Returns the job ids."""
224
+ if not runtime.settings.materialize.refresh_branches_on_tag or not raised_tags(old, new):
225
+ return []
226
+ domain = await DomainRepository(session).get(domain_id)
227
+ if domain is None or branch != domain.default_branch:
228
+ return []
229
+ found = (await session.execute(_BRANCH_SCHEMAS, {"domain": domain_id, "default": branch})).all()
230
+ hashes = sorted({bytes(row[3]) for row in found})
231
+ compiled = {
232
+ bytes(digest): load_compiled(body)
233
+ for digest, body in (
234
+ await session.execute(_COMPILED, {"domain": domain_id, "hashes": hashes})
235
+ ).all()
236
+ }
237
+ reference = schema_tags(new)
238
+ limits = runtime.schema.expression_limits
239
+ stale_by_hash = {
240
+ digest: bool(stale_kinds(schema, tagged_schema(schema, reference, limits)))
241
+ for digest, schema in compiled.items()
242
+ }
243
+ stale = {name for name, _, _, digest in found if stale_by_hash.get(bytes(digest))}
244
+ queued = sorted(
245
+ name
246
+ for name, materialization, base, _ in found
247
+ if name in stale
248
+ and not (materialization == Materialization.OVERLAY.value and base in stale)
249
+ )
250
+ if not queued:
251
+ return []
252
+ payloads = json.dumps([refresh_payload(name) for name in queued])
253
+ async with rls_scope(session, bypass=True):
254
+ result = await session.execute(
255
+ _ENQUEUE_MISSING,
256
+ {
257
+ "type": SENSITIVITY_REFRESH_JOB,
258
+ "domain": domain_id,
259
+ "payloads": payloads,
260
+ "now": _now(),
261
+ },
262
+ )
263
+ return [int(job_id) for (job_id,) in result.all()]
264
+
265
+
266
+ async def refresh_stale_rows(
267
+ engine: AsyncEngine,
268
+ domain_id: str,
269
+ branch: str,
270
+ *,
271
+ runtime: StoreRuntime,
272
+ resume: Cursor | None = None,
273
+ ) -> StaleRefresh:
274
+ """Rewrite the stale titles and search vectors of a branch in batches, each in its own
275
+ transaction (module doc), from `resume` when given. The report's status is `done`,
276
+ `deferred` (the branch stayed locked; `resume` says where to go on) or `cancelled` (the
277
+ branch is gone or not materialized)."""
278
+ sessions = async_sessionmaker(engine, expire_on_commit=False)
279
+ settings = runtime.settings
280
+ report = StaleRefresh(branch)
281
+ cursor = resume
282
+ failures = 0
283
+ while True:
284
+ started = time.perf_counter()
285
+ try:
286
+ async with sessions() as session, session.begin():
287
+ batch = await _refresh_batch(session, domain_id, branch, cursor, runtime, report)
288
+ except LockTimeoutError:
289
+ failures += 1
290
+ if failures >= _ATTEMPTS:
291
+ report.status, report.resume = "deferred", cursor
292
+ return report
293
+ await asyncio.sleep(min(settings.materialize.job_retry_seconds, 1.0))
294
+ continue
295
+ except DBAPIError as error:
296
+ failures += 1
297
+ if sqlstate(error) not in _RETRYABLE or failures >= _ATTEMPTS:
298
+ raise
299
+ log.info("sensitivity refresh of branch %s: batch retried after %s", branch, error)
300
+ continue
301
+ BRANCH_JOB_BATCH_DURATION.record(time.perf_counter() - started, {"job": JOB_NAME})
302
+ failures = 0
303
+ if batch is None:
304
+ return report
305
+ cursor = batch
306
+
307
+
308
+ async def _refresh_batch(
309
+ session: AsyncSession,
310
+ domain_id: str,
311
+ branch: str,
312
+ cursor: Cursor | None,
313
+ runtime: StoreRuntime,
314
+ report: StaleRefresh,
315
+ ) -> Cursor | None:
316
+ """Rewrite the next batch after `cursor`; returns the new cursor, None when done."""
317
+ settings = runtime.settings
318
+ await scope_to_domain(session, domain_id)
319
+ refs = RefRepository(session, domain_id)
320
+ await refs.lock(branch, timeout_ms=settings.commit.lock_timeout_ms)
321
+ ref = await refs.get(branch)
322
+ if ref is None or ref.kind is not RefKind.BRANCH:
323
+ report.status = "cancelled"
324
+ return None
325
+ generation = (await RevisionRepository(session, domain_id).generations([ref.head]))[ref.head]
326
+ repository = MaterializedRepository(
327
+ session,
328
+ domain_id,
329
+ branch,
330
+ writes=settings.writes,
331
+ generation=generation,
332
+ lock_timeout_ms=settings.commit.lock_timeout_ms,
333
+ )
334
+ stored = await repository.schema()
335
+ if stored is None:
336
+ report.status = "cancelled"
337
+ return None
338
+ own = load_compiled(stored.compiled)
339
+ tagged = await branch_tagged_schema(session, domain_id, branch, own, runtime=runtime)
340
+ report.kinds = stale_kinds(own, tagged)
341
+ for kind_id in report.kinds:
342
+ if cursor is not None and kind_id < cursor[0]:
343
+ continue
344
+ after = cursor[1] if cursor is not None and cursor[0] == kind_id else None
345
+ page = await repository.record_page(
346
+ kind_id, after=after, limit=settings.materialize.job_batch_rows
347
+ )
348
+ if not page:
349
+ continue
350
+ kind = tagged.kinds[kind_id]
351
+ titles = [
352
+ (row.key, title)
353
+ for row in page
354
+ if (title := record_title(kind, row.values)) != row.title
355
+ ]
356
+ if titles:
357
+ await repository.update_titles(kind_id, titles)
358
+ weights = search_weights(kind)
359
+ config = settings.materialize.text_search_config
360
+ # Vectors hold the title at weight A: a changed title changes the vector too. Other
361
+ # vectors are rewritten only when they differ from what the weights compute now.
362
+ keys = {key for key, _ in titles}
363
+ if weights != search_weights(own.kinds[kind_id]):
364
+ others = [row.key for row in page if row.key not in keys]
365
+ keys.update(
366
+ await repository.stale_search_keys(kind_id, weights, config=config, keys=others)
367
+ )
368
+ if keys:
369
+ await repository.refresh_search(kind_id, weights, config=config, keys=sorted(keys))
370
+ report.batches += 1
371
+ report.rows += len(page)
372
+ report.titles += len(titles)
373
+ report.vectors += len(keys)
374
+ BRANCH_JOB_ROWS.add(len(page), {"job": JOB_NAME})
375
+ return kind_id, page[-1].key
376
+ return None
377
+
378
+
379
+ async def run_sensitivity_refresh_job(
380
+ engine: AsyncEngine,
381
+ job: JobRow,
382
+ *,
383
+ runtime: StoreRuntime | None = None,
384
+ clock: Callable[[], datetime] | None = None,
385
+ ) -> Mapping[str, Any]:
386
+ """Job handler of `store.sensitivity_refresh` (module doc)."""
387
+ runtime = runtime or default_runtime()
388
+ now = clock or _now
389
+ if job.domain_id is None:
390
+ raise ValueError(f"a {job.type} job needs a Domain")
391
+ branch = job.payload.get("branch")
392
+ if not isinstance(branch, str) or not branch:
393
+ raise ValueError(f"a {job.type} job needs a `branch`")
394
+ resume = _cursor(job.payload.get("resume"))
395
+ try:
396
+ report = await refresh_stale_rows(
397
+ engine, job.domain_id, branch, runtime=runtime, resume=resume
398
+ )
399
+ except Exception:
400
+ BRANCH_JOB_RUNS.add(1, {"job": JOB_NAME, "outcome": "failed"})
401
+ raise
402
+ BRANCH_JOB_RUNS.add(1, {"job": JOB_NAME, "outcome": report.status})
403
+ result = report.as_dict()
404
+ sessions = async_sessionmaker(engine, expire_on_commit=False)
405
+ if report.status == "deferred":
406
+ delay = timedelta(seconds=runtime.settings.materialize.job_retry_seconds)
407
+ async with sessions() as session, session.begin():
408
+ result["again"] = await queue_stale_refresh(
409
+ session, job.domain_id, branch, resume=report.resume, run_after=now() + delay
410
+ )
411
+ elif report.status == "done":
412
+ async with sessions() as session, session.begin():
413
+ await scope_to_domain(session, job.domain_id)
414
+ overlays = await RefRepository(session, job.domain_id).overlays_of(branch)
415
+ result["overlays"] = [
416
+ await queue_stale_refresh(session, job.domain_id, ref.name) for ref in overlays
417
+ ]
418
+ return result
419
+
420
+
421
+ def _cursor(value: Any) -> Cursor | None:
422
+ if value is None:
423
+ return None
424
+ if (
425
+ not isinstance(value, Sequence)
426
+ or isinstance(value, str)
427
+ or len(value) != 2
428
+ or not all(isinstance(part, str) for part in value)
429
+ ):
430
+ raise ValueError("`resume` must be [kind id, key]")
431
+ return value[0], value[1]
432
+
433
+
434
+ __all__ = [
435
+ "JOB_NAME",
436
+ "StaleRefresh",
437
+ "queue_after_tagging",
438
+ "queue_if_stale",
439
+ "queue_stale_refresh",
440
+ "refresh_payload",
441
+ "refresh_stale_rows",
442
+ "run_sensitivity_refresh_job",
443
+ "stale_kinds",
444
+ ]