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,1033 @@
1
+ """Per-Attribute partial indexes on `mat_records` (design 7.2, store schema notes).
2
+
3
+ Attributes flagged `indexed` or `unique`, and `ref` Attributes that back a foreign-key
4
+ Relation, get one expression index per Domain, branch, Kind and Attribute. The index lives on
5
+ the hash partition of `mat_records` that holds the Domain, so building it scans one partition
6
+ and never locks the others:
7
+
8
+ CREATE [UNIQUE] INDEX mat_records_attr_<digest> ON mat_records_p<n>
9
+ (<expression>[, key]) WHERE domain_id = '<d>' AND branch = '<b>' AND kind_id = '<k>'
10
+
11
+ The predicate pins Domain, branch and Kind, so branches with different schemas never share an
12
+ index and a unique index is unique per branch. A query uses the index only when it carries the
13
+ same predicate literals and the same expression; `IndexSpec.expression` and `predicate_sql`
14
+ are the single source of both (see `lorebase_store.reads.query_by_attribute`).
15
+
16
+ Each index carries a JSON comment naming its Domain, branch, Kind and Attribute, which is how
17
+ existing indexes are found again; names start with `INDEX_PREFIX`, which schema comparison
18
+ ignores.
19
+
20
+ When a schema change lands, `BranchIndexes.retire` runs before the materialized rows are
21
+ written and drops, inside the transaction, the indexes that would reject the new rows (unique
22
+ ones, and casts of an Attribute whose type changed); other stale indexes are dropped later by
23
+ a queued job. `BranchIndexes.ensure` runs after the rows are written and creates the missing
24
+ indexes inside the transaction when the Kind is small (`store.indexes.inline_max_rows`),
25
+ otherwise it queues a job that `run_index_jobs` builds with `CREATE INDEX CONCURRENTLY`.
26
+
27
+ An overlay branch (`lorebase_store.layout`) gets the same indexes on its own rows, none unique
28
+ (`plan_indexes(..., overlay=True)`), when it is created or re-forked; they are always queued,
29
+ since even an index on few rows is built by scanning the whole partition. Until they are
30
+ built, the overlay half of a read scans the overlay's rows of the Kind by primary key.
31
+
32
+ Index DDL needs the role that owns `mat_records`. For a session role that does not own it (the
33
+ least-privileged application role subject to row-level security):
34
+
35
+ - with a maintenance role configured (`database.maintenance_dsn`, `StoreRuntime.
36
+ index_maintenance`), every change is queued as a job, and the job worker
37
+ (`python -m lorebase_store.jobs work`) runs `run_index_job` with the maintenance engine.
38
+ Indexes that would reject the new rows cannot be dropped before they are written: their
39
+ drops are queued, forced, in a transaction of their own (a warning names them), so a commit
40
+ that fails on them succeeds when retried after the worker has run (if it is never retried,
41
+ the next schema change or `pending_index_plan` brings the index back);
42
+ - otherwise index management is skipped, as with `store.indexes.enabled: false`, with a
43
+ warning once per process; `pending_index_plan` (`python -m lorebase_store.indexes plan
44
+ --domain <id>`) prints the DDL an administrator applies.
45
+ """
46
+
47
+ from __future__ import annotations
48
+
49
+ import argparse
50
+ import asyncio
51
+ import hashlib
52
+ import json
53
+ import logging
54
+ import re
55
+ import sys
56
+ from collections.abc import Mapping, Sequence
57
+ from dataclasses import dataclass, field, replace
58
+ from enum import StrEnum
59
+ from typing import Any
60
+
61
+ from sqlalchemy import text
62
+ from sqlalchemy.ext.asyncio import AsyncEngine, AsyncSession, async_sessionmaker
63
+
64
+ from lorebase_core.model import Attribute, Kind, RelationBacking, Schema
65
+ from lorebase_core.types import AttributeType
66
+ from lorebase_store.jobs import INDEX_JOB, JobRow, heartbeat
67
+ from lorebase_store.layout import load_layout
68
+ from lorebase_store.models.enums import JobStatus
69
+ from lorebase_store.repositories.scope import scope_to_domain
70
+ from lorebase_store.settings import IndexSettings, JobSettings
71
+
72
+ log = logging.getLogger(__name__)
73
+
74
+ INDEX_PREFIX = "mat_records_attr_"
75
+ """Prefix of runtime-managed per-Attribute indexes (store schema notes)."""
76
+
77
+ INDEX_JOB_TYPE = INDEX_JOB
78
+ """`jobs.type` of queued index builds and drops."""
79
+
80
+ PARENT_TABLE = "mat_records"
81
+ _NAME_DIGEST_CHARS = 24
82
+ _COMMENT_VERSION = 1
83
+ _NAME_SCOPE = b"lb1\x00mat-index\x00"
84
+ _LOCK_SCOPE = b"lb1\x00mat-index-ddl\x00"
85
+ _IDENTIFIER = re.compile(r"^[a-z][a-z0-9_]{0,62}$")
86
+
87
+ _CASTS: Mapping[AttributeType, str] = {
88
+ AttributeType.INTEGER: "bigint",
89
+ AttributeType.DECIMAL: "numeric",
90
+ AttributeType.FLOAT: "double precision",
91
+ AttributeType.BOOLEAN: "boolean",
92
+ }
93
+ """Types compared as SQL values; canonical text of the others already orders correctly or
94
+ is compared for equality only."""
95
+
96
+ _TEXT_TYPES = frozenset(
97
+ {
98
+ AttributeType.STRING,
99
+ AttributeType.URI,
100
+ AttributeType.ENUM,
101
+ AttributeType.REF,
102
+ AttributeType.DATE,
103
+ AttributeType.TIMESTAMP,
104
+ AttributeType.DURATION,
105
+ }
106
+ )
107
+ """Types indexed as their canonical text. Dates and timestamps are fixed-width ISO text, so
108
+ text order is time order; durations support equality only."""
109
+
110
+
111
+ class IndexMethod(StrEnum):
112
+ BTREE = "btree"
113
+ HASH = "hash"
114
+
115
+
116
+ class IndexReason(StrEnum):
117
+ """Why an Attribute is indexed."""
118
+
119
+ INDEXED = "indexed"
120
+ UNIQUE = "unique"
121
+ FOREIGN_KEY = "foreign_key"
122
+
123
+
124
+ @dataclass(frozen=True, slots=True)
125
+ class IndexSpec:
126
+ """The index one Attribute of one Kind needs, independent of Domain and branch.
127
+
128
+ `expression` is SQL over the `values` column; `cast` is the SQL type a compared value
129
+ must be cast to (`None` for text). Non-unique btree indexes end with `key`, so equality
130
+ lookups come back in key order and range scans page by `(value, key)`.
131
+ """
132
+
133
+ kind_id: str
134
+ attribute_id: str
135
+ attribute_type: AttributeType
136
+ method: IndexMethod
137
+ unique: bool
138
+ expression: str
139
+ cast: str | None
140
+ reasons: tuple[IndexReason, ...]
141
+
142
+ @property
143
+ def columns(self) -> str:
144
+ if self.method is IndexMethod.BTREE and not self.unique:
145
+ return f"{self.expression}, key"
146
+ return self.expression
147
+
148
+ @property
149
+ def ordered(self) -> bool:
150
+ """Whether range comparisons and ordering can use the index."""
151
+ return self.method is IndexMethod.BTREE and self.attribute_type is not AttributeType.TEXT
152
+
153
+ @property
154
+ def digest(self) -> bool:
155
+ """Whether the index holds a digest of the value (unique long text)."""
156
+ return self.expression.startswith("md5(")
157
+
158
+ @property
159
+ def value_type(self) -> str:
160
+ """How values compare: `md5` (digest), the cast type, or `text`; the `p_type`
161
+ argument of `lb_query_by_attribute`."""
162
+ if self.digest:
163
+ return "md5"
164
+ return self.cast or "text"
165
+
166
+ def value_sql(self, placeholder: str) -> str:
167
+ """SQL of a value compared with `expression`; `placeholder` binds its canonical text."""
168
+ value = f"CAST({placeholder} AS text)"
169
+ if self.digest:
170
+ return f"md5({value})"
171
+ if self.cast is not None:
172
+ return f"CAST({value} AS {self.cast})"
173
+ return value
174
+
175
+ def predicate_sql(self, domain_id: str, branch: str) -> str:
176
+ """The index predicate for one Domain and branch (see `predicate_sql`)."""
177
+ return predicate_sql(domain_id, branch, self.kind_id)
178
+
179
+ def name(self, domain_id: str, branch: str) -> str:
180
+ digest = hashlib.sha256(
181
+ _NAME_SCOPE
182
+ + "\x00".join(
183
+ (
184
+ domain_id,
185
+ branch,
186
+ self.kind_id,
187
+ self.attribute_id,
188
+ self.method.value,
189
+ "unique" if self.unique else "plain",
190
+ self.expression,
191
+ )
192
+ ).encode("utf-8")
193
+ ).hexdigest()
194
+ return INDEX_PREFIX + digest[:_NAME_DIGEST_CHARS]
195
+
196
+
197
+ @dataclass(frozen=True, slots=True)
198
+ class SkippedAttribute:
199
+ """An Attribute that asks for an index but whose type has no useful expression index."""
200
+
201
+ kind_id: str
202
+ attribute_id: str
203
+ attribute_type: AttributeType
204
+
205
+
206
+ @dataclass(frozen=True, slots=True)
207
+ class IndexPlan:
208
+ indexes: tuple[IndexSpec, ...]
209
+ skipped: tuple[SkippedAttribute, ...] = ()
210
+
211
+ def by_attribute(self) -> dict[tuple[str, str], IndexSpec]:
212
+ return {(spec.kind_id, spec.attribute_id): spec for spec in self.indexes}
213
+
214
+ def built(self, *, constraints_only: bool) -> tuple[IndexSpec, ...]:
215
+ """The indexes to build: all, or with `constraints_only` (storage profile `compact`,
216
+ `store.storage.attribute_indexes: constraints`) those of unique and foreign key
217
+ Attributes. Reads render the same expressions either way; without the index they
218
+ scan the Kind."""
219
+ if not constraints_only:
220
+ return self.indexes
221
+ kept = {IndexReason.UNIQUE, IndexReason.FOREIGN_KEY}
222
+ return tuple(spec for spec in self.indexes if kept.intersection(spec.reasons))
223
+
224
+
225
+ @dataclass(frozen=True, slots=True)
226
+ class ExistingIndex:
227
+ """A per-Attribute index found in the catalog."""
228
+
229
+ name: str
230
+ partition: str
231
+ branch: str
232
+ kind_id: str
233
+ attribute_id: str
234
+ unique: bool
235
+ cast: str | None
236
+ valid: bool
237
+
238
+
239
+ @dataclass
240
+ class IndexSync:
241
+ """What one synchronization did: index names created, dropped and queued."""
242
+
243
+ created: list[str] = field(default_factory=list)
244
+ dropped: list[str] = field(default_factory=list)
245
+ queued: list[str] = field(default_factory=list)
246
+
247
+
248
+ # Planning (pure) --------------------------------------------------------------------------------
249
+
250
+
251
+ def values_text(attribute_id: str) -> str:
252
+ """SQL text of one Attribute value: `("values" ->> '<attribute id>')`."""
253
+ return f'("values" ->> {sql_literal(_checked_id(attribute_id))})'
254
+
255
+
256
+ def attribute_expression(attribute: Attribute, *, unique: bool) -> tuple[str, str | None] | None:
257
+ """`(expression, cast)` indexing `attribute`, or `None` when its type is not indexable."""
258
+ name = attribute.type.name
259
+ raw = values_text(attribute.id)
260
+ if name in _CASTS:
261
+ cast = _CASTS[name]
262
+ return f"({raw}::{cast})", cast
263
+ if name in _TEXT_TYPES:
264
+ return raw, None
265
+ if name is AttributeType.TEXT:
266
+ # Long text exceeds btree tuple limits: equality by hash, uniqueness by digest.
267
+ return (f"md5{raw}", None) if unique else (raw, None)
268
+ return None
269
+
270
+
271
+ def plan_indexes(schema: Schema, *, overlay: bool = False) -> IndexPlan:
272
+ """Indexes the materialized head of a branch with `schema` needs; pure and deterministic.
273
+
274
+ With `overlay`, those of an overlay branch's own rows: the same expressions and
275
+ predicates, so the overlay half of its reads uses them as the base half uses the base's,
276
+ but none unique. Uniqueness on an overlay holds for its resolved rows, which the commit
277
+ checks; its own rows alone include tombstones, which keep their values.
278
+ """
279
+ foreign_keys = {
280
+ (relation.from_kind, relation.foreign_key_attribute)
281
+ for relation in schema.relations.values()
282
+ if relation.backing is RelationBacking.FOREIGN_KEY
283
+ and relation.foreign_key_attribute is not None
284
+ }
285
+ indexes: list[IndexSpec] = []
286
+ skipped: list[SkippedAttribute] = []
287
+ for kind in sorted(schema.kinds.values(), key=lambda k: k.id):
288
+ for attribute in sorted(kind.attributes.values(), key=lambda a: a.id):
289
+ reasons = _reasons(kind, attribute, foreign_keys)
290
+ if not reasons:
291
+ continue
292
+ spec = _spec(kind, attribute, reasons)
293
+ if spec is None:
294
+ skipped.append(SkippedAttribute(kind.id, attribute.id, attribute.type.name))
295
+ else:
296
+ indexes.append(replace(spec, unique=False) if overlay else spec)
297
+ return IndexPlan(tuple(indexes), tuple(skipped))
298
+
299
+
300
+ def _reasons(
301
+ kind: Kind, attribute: Attribute, foreign_keys: set[tuple[str, str]]
302
+ ) -> tuple[IndexReason, ...]:
303
+ reasons: list[IndexReason] = []
304
+ if attribute.indexed:
305
+ reasons.append(IndexReason.INDEXED)
306
+ if attribute.unique:
307
+ reasons.append(IndexReason.UNIQUE)
308
+ if (kind.id, attribute.id) in foreign_keys:
309
+ reasons.append(IndexReason.FOREIGN_KEY)
310
+ return tuple(reasons)
311
+
312
+
313
+ def _spec(kind: Kind, attribute: Attribute, reasons: tuple[IndexReason, ...]) -> IndexSpec | None:
314
+ unique = IndexReason.UNIQUE in reasons
315
+ found = attribute_expression(attribute, unique=unique)
316
+ if found is None:
317
+ return None
318
+ expression, cast = found
319
+ hashed = attribute.type.name is AttributeType.TEXT and not unique
320
+ return IndexSpec(
321
+ kind_id=kind.id,
322
+ attribute_id=attribute.id,
323
+ attribute_type=attribute.type.name,
324
+ method=IndexMethod.HASH if hashed else IndexMethod.BTREE,
325
+ unique=unique,
326
+ expression=expression,
327
+ cast=cast,
328
+ reasons=reasons,
329
+ )
330
+
331
+
332
+ # SQL text ---------------------------------------------------------------------------------------
333
+
334
+
335
+ def sql_literal(value: str) -> str:
336
+ """A standard SQL string literal (quotes doubled; NUL rejected).
337
+
338
+ Backslashes are left as they are, which is safe only with `standard_conforming_strings`
339
+ on: the Postgres default, and pinned by `db.create_engine` on every connection.
340
+ """
341
+ if "\x00" in value:
342
+ raise ValueError("SQL literals cannot contain NUL")
343
+ return "'" + value.replace("'", "''") + "'"
344
+
345
+
346
+ def _checked_id(element_id: str) -> str:
347
+ if not re.fullmatch(r"[a-z]{3}_[0-9A-Z]{26}", element_id):
348
+ raise ValueError(f"not a schema element id: {element_id!r}")
349
+ return element_id
350
+
351
+
352
+ def _checked_identifier(name: str) -> str:
353
+ if not _IDENTIFIER.match(name):
354
+ raise ValueError(f"unexpected SQL identifier {name!r}")
355
+ return name
356
+
357
+
358
+ def predicate_sql(domain_id: str, branch: str, kind_id: str) -> str:
359
+ """Partial index predicate; queries repeat it literally to match the index."""
360
+ return (
361
+ f"domain_id = {sql_literal(domain_id)} AND branch = {sql_literal(branch)} "
362
+ f"AND kind_id = {sql_literal(_checked_id(kind_id))}"
363
+ )
364
+
365
+
366
+ def create_index_sql(
367
+ spec: IndexSpec, domain_id: str, branch: str, partition: str, *, concurrently: bool
368
+ ) -> str:
369
+ return (
370
+ f"CREATE {'UNIQUE ' if spec.unique else ''}INDEX "
371
+ f"{'CONCURRENTLY ' if concurrently else ''}IF NOT EXISTS {spec.name(domain_id, branch)} "
372
+ f"ON {_checked_identifier(partition)} USING {spec.method.value} ({spec.columns}) "
373
+ f"WHERE {predicate_sql(domain_id, branch, spec.kind_id)}"
374
+ )
375
+
376
+
377
+ def comment_sql(spec: IndexSpec, domain_id: str, branch: str) -> str:
378
+ meta = {
379
+ "v": _COMMENT_VERSION,
380
+ "domain": domain_id,
381
+ "branch": branch,
382
+ "kind": spec.kind_id,
383
+ "attribute": spec.attribute_id,
384
+ "unique": spec.unique,
385
+ "cast": spec.cast,
386
+ }
387
+ payload = json.dumps(meta, sort_keys=True, separators=(",", ":"))
388
+ return f"COMMENT ON INDEX {spec.name(domain_id, branch)} IS {sql_literal(payload)}"
389
+
390
+
391
+ def drop_index_sql(name: str, *, concurrently: bool) -> str:
392
+ mode = "CONCURRENTLY " if concurrently else ""
393
+ return f"DROP INDEX {mode}IF EXISTS {_checked_identifier(name)}"
394
+
395
+
396
+ # Catalog ----------------------------------------------------------------------------------------
397
+
398
+ _PARTITION_OF = text(
399
+ rf"""
400
+ SELECT c.relname
401
+ FROM pg_inherits i
402
+ JOIN pg_class c ON c.oid = i.inhrelid
403
+ WHERE i.inhparent = CAST('{PARENT_TABLE}' AS regclass)
404
+ AND satisfies_hash_partition(
405
+ CAST('{PARENT_TABLE}' AS regclass),
406
+ CAST((regexp_match(pg_get_expr(c.relpartbound, c.oid), 'modulus (\d+)', 'i'))[1]
407
+ AS int),
408
+ CAST((regexp_match(pg_get_expr(c.relpartbound, c.oid), 'remainder (\d+)', 'i'))[1]
409
+ AS int),
410
+ CAST(:domain AS text))
411
+ """
412
+ )
413
+
414
+ _EXISTING = text(
415
+ rf"""
416
+ SELECT c.relname AS name, t.relname AS partition, x.indisvalid AS valid,
417
+ d.description AS meta
418
+ FROM pg_class c
419
+ JOIN pg_index x ON x.indexrelid = c.oid
420
+ JOIN pg_class t ON t.oid = x.indrelid
421
+ JOIN pg_description d
422
+ ON d.objoid = c.oid AND d.classoid = CAST('pg_class' AS regclass) AND d.objsubid = 0
423
+ WHERE c.relkind = 'i'
424
+ AND c.relname LIKE '{INDEX_PREFIX.replace("_", r"\_")}%'
425
+ AND c.relnamespace = (SELECT relnamespace FROM pg_class
426
+ WHERE oid = CAST('{PARENT_TABLE}' AS regclass))
427
+ AND d.description LIKE '{{%'
428
+ AND CAST(d.description AS jsonb) ->> 'domain' = :domain
429
+ ORDER BY c.relname
430
+ """
431
+ )
432
+
433
+ _partitions: dict[str, str] = {}
434
+ """Domain id -> partition of `mat_records`; partitions are fixed once the schema exists."""
435
+
436
+
437
+ async def partition_of(session: AsyncSession, domain_id: str) -> str:
438
+ """Name of the `mat_records` hash partition that holds `domain_id`."""
439
+ found = _partitions.get(domain_id)
440
+ if found is None:
441
+ found = (await session.execute(_PARTITION_OF, {"domain": domain_id})).scalar_one()
442
+ _partitions[domain_id] = found
443
+ return found
444
+
445
+
446
+ async def existing_indexes(session: AsyncSession, domain_id: str) -> list[ExistingIndex]:
447
+ """Per-Attribute indexes of a Domain, every branch, valid or not."""
448
+ rows = (await session.execute(_EXISTING, {"domain": domain_id})).mappings()
449
+ found: list[ExistingIndex] = []
450
+ for row in rows:
451
+ meta: dict[str, Any] = json.loads(row["meta"])
452
+ found.append(
453
+ ExistingIndex(
454
+ name=row["name"],
455
+ partition=row["partition"],
456
+ branch=meta["branch"],
457
+ kind_id=meta["kind"],
458
+ attribute_id=meta["attribute"],
459
+ unique=bool(meta["unique"]),
460
+ cast=meta.get("cast"),
461
+ valid=bool(row["valid"]),
462
+ )
463
+ )
464
+ return found
465
+
466
+
467
+ def _lock_key(partition: str) -> int:
468
+ digest = hashlib.sha256(_LOCK_SCOPE + partition.encode("utf-8")).digest()
469
+ return int.from_bytes(digest[:8], "big", signed=True)
470
+
471
+
472
+ # Synchronization --------------------------------------------------------------------------------
473
+
474
+ _BRANCHES = text("SELECT name FROM refs WHERE domain_id = :domain")
475
+ _OWNS_TABLE = text(
476
+ "SELECT pg_has_role(current_user, relowner, 'USAGE') FROM pg_class "
477
+ "WHERE oid = CAST(:table AS regclass)"
478
+ )
479
+ _warned_not_owner = False
480
+ _KIND_ROWS = text(
481
+ "SELECT count(*) FROM mat_records "
482
+ "WHERE domain_id = :domain AND branch = :branch AND kind_id = :kind"
483
+ )
484
+ _PENDING_JOB = text(
485
+ """
486
+ SELECT 1 FROM jobs
487
+ WHERE domain_id = :domain AND type = :type AND status IN ('queued', 'running')
488
+ AND payload ->> 'name' = :name AND payload ->> 'action' = :action
489
+ LIMIT 1
490
+ """
491
+ )
492
+ _QUEUE_JOB = text(
493
+ "INSERT INTO jobs (type, domain_id, payload) VALUES (:type, :domain, CAST(:payload AS jsonb))"
494
+ )
495
+
496
+
497
+ class IndexAction(StrEnum):
498
+ CREATE = "create"
499
+ DROP = "drop"
500
+
501
+
502
+ class IndexMode(StrEnum):
503
+ """How a session manages the indexes of a branch."""
504
+
505
+ INLINE = "inline"
506
+ """The role owns `mat_records`: DDL in the transaction, large Kinds queued."""
507
+ QUEUE = "queue"
508
+ """A maintenance role is configured: every change is queued for the job worker."""
509
+ OFF = "off"
510
+ """Disabled, or nobody may run the DDL: see `pending_index_plan`."""
511
+
512
+
513
+ async def _ddl(session: AsyncSession, statement: str) -> None:
514
+ """Run DDL text as is: literals in it may hold colons that bind parsing would misread."""
515
+ connection = await session.connection()
516
+ await connection.exec_driver_sql(statement)
517
+
518
+
519
+ class BranchIndexes:
520
+ """Keeps the per-Attribute indexes of one branch in line with its schema.
521
+
522
+ Runs in the caller's transaction, after `scope_to_domain`. `retire` goes before the
523
+ materialized rows of a schema change are written, `ensure` after them; `sync` does both
524
+ for a branch whose rows are already in place.
525
+
526
+ With `maintenance` (a maintenance role is configured, `database.maintenance_dsn`), a
527
+ session role that does not own `mat_records` queues every change instead of skipping it;
528
+ the job worker runs the jobs as the maintenance role. With `defer`, every index to create
529
+ is queued, whatever the size of its Kind (a transaction that must stay short). With
530
+ `constraints_only`, only the indexes of unique and foreign key Attributes are kept
531
+ (`IndexPlan.built`).
532
+ """
533
+
534
+ def __init__(
535
+ self,
536
+ session: AsyncSession,
537
+ domain_id: str,
538
+ branch: str,
539
+ *,
540
+ settings: IndexSettings,
541
+ maintenance: bool = False,
542
+ defer: bool = False,
543
+ constraints_only: bool = False,
544
+ ) -> None:
545
+ self._session = session
546
+ self._defer = defer
547
+ self._constraints_only = constraints_only
548
+ self._domain = domain_id
549
+ self._branch = branch
550
+ self._settings = settings
551
+ self._maintenance = maintenance
552
+ self._existing: list[ExistingIndex] | None = None
553
+ self._locked = False
554
+ self._mode: IndexMode | None = None
555
+ self._overlay: bool | None = None
556
+
557
+ async def sync(self, schema: Schema) -> IndexSync:
558
+ result = await self.retire(schema)
559
+ ensured = await self.ensure(schema)
560
+ result.created.extend(ensured.created)
561
+ result.queued.extend(ensured.queued)
562
+ return result
563
+
564
+ async def retire(self, schema: Schema) -> IndexSync:
565
+ """Drop or queue the drop of indexes the schema no longer plans.
566
+
567
+ Inline (in this transaction) for indexes that would reject rows the new schema allows:
568
+ unique indexes and cast expressions of an Attribute whose type changed. Indexes of
569
+ deleted branches of the Domain are queued too.
570
+ """
571
+ result = IndexSync()
572
+ mode = await self.mode()
573
+ if mode is IndexMode.OFF:
574
+ return result
575
+ planned = (await self._plan(schema)).indexes
576
+ wanted = {spec.name(self._domain, self._branch) for spec in planned}
577
+ branches = set((await self._session.execute(_BRANCHES, {"domain": self._domain})).scalars())
578
+ for index in await self._indexes():
579
+ if index.branch == self._branch:
580
+ if index.name in wanted and index.valid:
581
+ continue
582
+ inline = index.name in wanted or self._blocks_writes(index, schema)
583
+ # An invalid leftover of a failed concurrent build is rebuilt; an index that
584
+ # would reject the new rows goes before they are written.
585
+ if inline and mode is IndexMode.INLINE:
586
+ await self._drop_inline(index, result)
587
+ continue
588
+ if inline and index.name not in wanted:
589
+ log.warning(
590
+ "index %s may reject the rows of this change until the job worker "
591
+ "drops it (queued in its own transaction)",
592
+ index.name,
593
+ )
594
+ await self._queue(
595
+ IndexAction.DROP, index.name, index.kind_id, result, separately=True
596
+ )
597
+ elif index.name not in wanted:
598
+ await self._queue(IndexAction.DROP, index.name, index.kind_id, result)
599
+ elif index.branch not in branches:
600
+ await self._queue(IndexAction.DROP, index.name, index.kind_id, result)
601
+ return result
602
+
603
+ async def ensure(self, schema: Schema) -> IndexSync:
604
+ """Create the planned indexes that do not exist yet: inline for small Kinds when the
605
+ role owns `mat_records`, otherwise queued. An overlay's are always queued: building
606
+ an index scans the whole partition, which an overlay's creation must not pay."""
607
+ result = IndexSync()
608
+ mode = await self.mode()
609
+ if mode is IndexMode.OFF:
610
+ return result
611
+ present = {index.name for index in await self._indexes() if index.valid}
612
+ partition = await partition_of(self._session, self._domain)
613
+ queue_all = mode is IndexMode.QUEUE or self._defer or await self.overlay()
614
+ rows: dict[str, int] = {}
615
+ for spec in (await self._plan(schema)).indexes:
616
+ name = spec.name(self._domain, self._branch)
617
+ if name in present:
618
+ continue
619
+ if queue_all:
620
+ await self._queue(IndexAction.CREATE, name, spec.kind_id, result)
621
+ continue
622
+ if spec.kind_id not in rows:
623
+ rows[spec.kind_id] = await self._kind_rows(spec.kind_id)
624
+ if rows[spec.kind_id] > self._settings.inline_max_rows:
625
+ await self._queue(IndexAction.CREATE, name, spec.kind_id, result)
626
+ continue
627
+ await self._lock(partition)
628
+ await _ddl(
629
+ self._session,
630
+ create_index_sql(spec, self._domain, self._branch, partition, concurrently=False),
631
+ )
632
+ await _ddl(self._session, comment_sql(spec, self._domain, self._branch))
633
+ result.created.append(name)
634
+ self._existing = None
635
+ return result
636
+
637
+ async def drop_all(self) -> IndexSync:
638
+ """Queue the drop of every index of the branch (for example after deleting it)."""
639
+ result = IndexSync()
640
+ for index in await self._indexes():
641
+ if index.branch == self._branch:
642
+ await self._queue(IndexAction.DROP, index.name, index.kind_id, result)
643
+ return result
644
+
645
+ # Internals -----------------------------------------------------------------------------
646
+
647
+ async def mode(self) -> IndexMode:
648
+ """Inline when the session role owns `mat_records`, queued when it does not but a
649
+ maintenance role is configured, off otherwise (or when disabled)."""
650
+ global _warned_not_owner
651
+ if self._mode is None:
652
+ mode = IndexMode.OFF
653
+ if self._settings.enabled:
654
+ found = await self._session.execute(_OWNS_TABLE, {"table": PARENT_TABLE})
655
+ if found.scalar_one_or_none():
656
+ mode = IndexMode.INLINE
657
+ elif self._maintenance:
658
+ mode = IndexMode.QUEUE
659
+ elif not _warned_not_owner:
660
+ _warned_not_owner = True
661
+ log.warning(
662
+ "per-Attribute indexes are not managed: the database role does not own "
663
+ "%s and no database.maintenance_dsn is set; an administrator applies "
664
+ "`python -m lorebase_store.indexes plan --domain <id>`",
665
+ PARENT_TABLE,
666
+ )
667
+ self._mode = mode
668
+ return self._mode
669
+
670
+ async def overlay(self) -> bool:
671
+ """Whether the branch is an overlay (read from its ref once)."""
672
+ if self._overlay is None:
673
+ self._overlay = (await load_layout(self._session, self._domain, self._branch)).overlay
674
+ return self._overlay
675
+
676
+ async def _plan(self, schema: Schema) -> IndexPlan:
677
+ plan = plan_indexes(schema, overlay=await self.overlay())
678
+ return replace(plan, indexes=plan.built(constraints_only=self._constraints_only))
679
+
680
+ async def _indexes(self) -> list[ExistingIndex]:
681
+ if self._existing is None:
682
+ self._existing = await existing_indexes(self._session, self._domain)
683
+ return self._existing
684
+
685
+ def _blocks_writes(self, index: ExistingIndex, schema: Schema) -> bool:
686
+ if index.unique or not index.valid:
687
+ return True
688
+ if index.cast is None:
689
+ return False
690
+ kind = schema.kinds.get(index.kind_id)
691
+ attribute = None if kind is None else kind.attributes.get(index.attribute_id)
692
+ return attribute is not None and _CASTS.get(attribute.type.name) != index.cast
693
+
694
+ async def _drop_inline(self, index: ExistingIndex, result: IndexSync) -> None:
695
+ await self._lock(index.partition)
696
+ await _ddl(self._session, drop_index_sql(index.name, concurrently=False))
697
+ result.dropped.append(index.name)
698
+ self._existing = None
699
+
700
+ async def _lock(self, partition: str) -> None:
701
+ """Serialize index DDL per partition: two commits that both wrote rows and then wait
702
+ for each other's lock to build an index would deadlock."""
703
+ if not self._locked:
704
+ await self._session.execute(
705
+ text("SELECT pg_advisory_xact_lock(:key)"), {"key": _lock_key(partition)}
706
+ )
707
+ self._locked = True
708
+
709
+ async def _kind_rows(self, kind_id: str) -> int:
710
+ params = {"domain": self._domain, "branch": self._branch, "kind": kind_id}
711
+ return int((await self._session.execute(_KIND_ROWS, params)).scalar_one())
712
+
713
+ async def _queue(
714
+ self,
715
+ action: IndexAction,
716
+ name: str,
717
+ kind_id: str,
718
+ result: IndexSync,
719
+ *,
720
+ separately: bool = False,
721
+ ) -> None:
722
+ """Queue a job in the caller's transaction, or with `separately` in a transaction of
723
+ its own that commits now (it must outlive a commit that fails)."""
724
+ if separately:
725
+ engine = (await self._session.connection()).engine
726
+ async with AsyncSession(bind=engine) as other, other.begin():
727
+ await scope_to_domain(other, self._domain)
728
+ await self._insert_job(other, action, name, kind_id, force=True)
729
+ else:
730
+ await self._insert_job(self._session, action, name, kind_id)
731
+ result.queued.append(name)
732
+
733
+ async def _insert_job(
734
+ self,
735
+ session: AsyncSession,
736
+ action: IndexAction,
737
+ name: str,
738
+ kind_id: str,
739
+ *,
740
+ force: bool = False,
741
+ ) -> None:
742
+ params = {"domain": self._domain, "type": INDEX_JOB_TYPE, "name": name, "action": action}
743
+ if (await session.execute(_PENDING_JOB, params)).first() is None:
744
+ payload: dict[str, Any] = {
745
+ "action": action.value,
746
+ "name": name,
747
+ "branch": self._branch,
748
+ "kind": kind_id,
749
+ }
750
+ if force:
751
+ payload["force"] = True
752
+ await session.execute(
753
+ _QUEUE_JOB,
754
+ {"type": INDEX_JOB_TYPE, "domain": self._domain, "payload": json.dumps(payload)},
755
+ )
756
+
757
+
758
+ # Queued jobs ------------------------------------------------------------------------------------
759
+
760
+ _CLAIM = text(
761
+ """
762
+ UPDATE jobs SET status = 'running', locked_by = :worker, locked_at = now(),
763
+ attempts = attempts + 1
764
+ WHERE id = (
765
+ SELECT id FROM jobs
766
+ WHERE status = 'queued' AND type = :type AND run_after <= now()
767
+ ORDER BY priority DESC, run_after
768
+ LIMIT 1
769
+ FOR UPDATE SKIP LOCKED
770
+ )
771
+ RETURNING id, domain_id, payload
772
+ """
773
+ )
774
+ _FINISH = text("UPDATE jobs SET status = :status, result = CAST(:result AS jsonb) WHERE id = :id")
775
+ _BRANCH_SCHEMA = text(
776
+ "SELECT compiled FROM mat_schema WHERE domain_id = :domain AND branch = :branch"
777
+ )
778
+ _BRANCH_EXISTS = text(
779
+ "SELECT 1 FROM refs WHERE domain_id = :domain AND name = :branch AND deleted_at IS NULL"
780
+ )
781
+ _BYPASS = text("SELECT set_config('lorebase.bypass_rls', 'on', true)")
782
+
783
+
784
+ @dataclass(frozen=True, slots=True)
785
+ class IndexJobOutcome:
786
+ job_id: int
787
+ action: IndexAction
788
+ name: str
789
+ status: JobStatus
790
+ detail: str
791
+
792
+
793
+ async def run_index_jobs(
794
+ engine: AsyncEngine,
795
+ settings: IndexSettings,
796
+ *,
797
+ worker: str = "store",
798
+ jobs: JobSettings | None = None,
799
+ constraints_only: bool = False,
800
+ ) -> list[IndexJobOutcome]:
801
+ """Claim and run up to `settings.job_batch` queued index jobs.
802
+
803
+ Each job sends heartbeats (every `jobs.stale_after_seconds / 3`) while it runs, so a long
804
+ build is not reclaimed as lost.
805
+
806
+ Builds and drops run with `CONCURRENTLY`, outside any transaction, after checking that
807
+ the branch still wants (or no longer wants) the index.
808
+ """
809
+ outcomes: list[IndexJobOutcome] = []
810
+ sessions = async_sessionmaker(engine, expire_on_commit=False)
811
+ for _ in range(settings.job_batch):
812
+ async with engine.begin() as connection:
813
+ await connection.execute(_BYPASS)
814
+ claimed = (
815
+ await connection.execute(_CLAIM, {"worker": worker, "type": INDEX_JOB_TYPE})
816
+ ).first()
817
+ if claimed is None:
818
+ break
819
+ job_id, domain_id, payload = claimed
820
+ action = IndexAction(payload["action"])
821
+ beat = asyncio.create_task(heartbeat(sessions, worker, job_id, jobs or JobSettings()))
822
+ try:
823
+ status, detail = await _run_job(
824
+ engine, domain_id, action, payload, constraints_only=constraints_only
825
+ )
826
+ except Exception as error: # the job records the failure; the runner goes on
827
+ status, detail = JobStatus.FAILED, f"{type(error).__name__}: {error}"
828
+ finally:
829
+ beat.cancel()
830
+ await asyncio.gather(beat, return_exceptions=True)
831
+ async with engine.begin() as connection:
832
+ await connection.execute(_BYPASS)
833
+ await connection.execute(
834
+ _FINISH,
835
+ {"id": job_id, "status": status.value, "result": json.dumps({"detail": detail})},
836
+ )
837
+ outcomes.append(IndexJobOutcome(job_id, action, payload["name"], status, detail))
838
+ return outcomes
839
+
840
+
841
+ async def _run_job(
842
+ engine: AsyncEngine,
843
+ domain_id: str,
844
+ action: IndexAction,
845
+ payload: Mapping[str, Any],
846
+ *,
847
+ constraints_only: bool = False,
848
+ ) -> tuple[JobStatus, str]:
849
+ name: str = payload["name"]
850
+ branch: str = payload["branch"]
851
+ async with engine.begin() as connection, AsyncSession(bind=connection) as session:
852
+ await scope_to_domain(session, domain_id)
853
+ found = {"domain": domain_id, "branch": branch}
854
+ if action is IndexAction.CREATE and not await _branch_exists(session, found):
855
+ # Merged and deleted since the job was queued (the branch cleanup cancels the
856
+ # queued ones; this covers a job already claimed or a deletion that raced it).
857
+ log.debug(
858
+ "index job %s: branch %r of Domain %s is gone; nothing to build",
859
+ name,
860
+ branch,
861
+ domain_id,
862
+ )
863
+ return JobStatus.CANCELLED, "the branch no longer exists"
864
+ compiled = (await session.execute(_BRANCH_SCHEMA, found)).scalar_one_or_none()
865
+ partition = await partition_of(session, domain_id)
866
+ existing = {i.name: i for i in await existing_indexes(session, domain_id)}
867
+ overlay = (await load_layout(session, domain_id, branch)).overlay
868
+ spec = None
869
+ if compiled is not None:
870
+ planned = plan_indexes(Schema.model_validate(compiled), overlay=overlay).built(
871
+ constraints_only=constraints_only
872
+ )
873
+ spec = next((s for s in planned if s.name(domain_id, branch) == name), None)
874
+ statements: list[str] = []
875
+ if action is IndexAction.DROP:
876
+ # Jobs are queued by the application role and run by the maintenance role: only a
877
+ # per-Attribute index of this Domain may be dropped, whatever name the job carries.
878
+ if name not in existing:
879
+ return JobStatus.CANCELLED, "not a per-Attribute index of this Domain"
880
+ # Forced: an index that would reject the rows of a pending schema change, whose
881
+ # commit cannot land (and change the plan) while the index exists.
882
+ forced = bool(payload.get("force"))
883
+ if not forced and spec is not None and name in existing and existing[name].valid:
884
+ return JobStatus.CANCELLED, "the branch still plans this index"
885
+ statements.append(drop_index_sql(name, concurrently=True))
886
+ else:
887
+ if spec is None:
888
+ return JobStatus.CANCELLED, "the branch no longer plans this index"
889
+ if name in existing and existing[name].valid:
890
+ return JobStatus.SUCCEEDED, "already built"
891
+ if name in existing:
892
+ statements.append(drop_index_sql(name, concurrently=True))
893
+ statements.append(create_index_sql(spec, domain_id, branch, partition, concurrently=True))
894
+ statements.append(comment_sql(spec, domain_id, branch))
895
+ async with engine.connect() as connection:
896
+ auto = await connection.execution_options(isolation_level="AUTOCOMMIT")
897
+ for statement in statements:
898
+ await auto.exec_driver_sql(statement)
899
+ if action is IndexAction.CREATE and not await _branch_still_exists(
900
+ engine, domain_id, branch
901
+ ):
902
+ # Deleted while the (long) concurrent build ran: do not leave the index behind.
903
+ await auto.exec_driver_sql(drop_index_sql(name, concurrently=True))
904
+ log.debug(
905
+ "index job %s: branch %r of Domain %s was deleted during the build; dropped",
906
+ name,
907
+ branch,
908
+ domain_id,
909
+ )
910
+ return JobStatus.CANCELLED, "the branch was deleted during the build"
911
+ return JobStatus.SUCCEEDED, action.value
912
+
913
+
914
+ async def _branch_exists(session: AsyncSession, params: Mapping[str, str]) -> bool:
915
+ return (await session.execute(_BRANCH_EXISTS, params)).first() is not None
916
+
917
+
918
+ async def _branch_still_exists(engine: AsyncEngine, domain_id: str, branch: str) -> bool:
919
+ async with engine.begin() as connection, AsyncSession(bind=connection) as session:
920
+ await scope_to_domain(session, domain_id)
921
+ return await _branch_exists(session, {"domain": domain_id, "branch": branch})
922
+
923
+
924
+ async def run_index_job(
925
+ engine: AsyncEngine, job: JobRow, *, constraints_only: bool = False
926
+ ) -> dict[str, Any]:
927
+ """Handler of `store.index` jobs for `lorebase_store.jobs.run_jobs`.
928
+
929
+ `engine` runs the DDL: the maintenance engine (`database.maintenance_dsn`) where the
930
+ application role does not own `mat_records`. A failure raises, so the queue retries it.
931
+ """
932
+ if job.domain_id is None:
933
+ raise ValueError(f"index job {job.id} has no Domain")
934
+ action = IndexAction(job.payload["action"])
935
+ status, detail = await _run_job(
936
+ engine, job.domain_id, action, job.payload, constraints_only=constraints_only
937
+ )
938
+ return {"status": status.value, "detail": detail, "name": job.payload["name"]}
939
+
940
+
941
+ # Pending plan -----------------------------------------------------------------------------------
942
+
943
+ _SCHEMAS = text(
944
+ "SELECT r.name, s.compiled, r.materialization = 'overlay' FROM refs r "
945
+ "JOIN mat_schema s ON s.domain_id = r.domain_id AND s.branch = r.name "
946
+ "WHERE r.domain_id = :domain AND r.deleted_at IS NULL AND r.kind <> 'snapshot' "
947
+ "ORDER BY r.name"
948
+ )
949
+
950
+
951
+ async def pending_index_plan(
952
+ session: AsyncSession,
953
+ domain_id: str,
954
+ *,
955
+ branch: str | None = None,
956
+ constraints_only: bool = False,
957
+ ) -> list[str]:
958
+ """DDL that brings the per-Attribute indexes of a Domain (or of one branch) in line with
959
+ the materialized schemas: concurrent builds of missing or invalid indexes with their
960
+ comments, and concurrent drops of indexes that no branch plans.
961
+
962
+ For an administrator where no role that may run the DDL manages the indexes; each
963
+ statement runs outside a transaction. Runs in the caller's transaction, after
964
+ `scope_to_domain`, and only reads.
965
+ """
966
+ found = (await session.execute(_SCHEMAS, {"domain": domain_id})).all()
967
+ schemas = {
968
+ name: (Schema.model_validate(compiled), bool(overlay))
969
+ for name, compiled, overlay in found
970
+ if branch is None or name == branch
971
+ }
972
+ existing = {index.name: index for index in await existing_indexes(session, domain_id)}
973
+ branches = set((await session.execute(_BRANCHES, {"domain": domain_id})).scalars())
974
+ partition = await partition_of(session, domain_id)
975
+ statements: list[str] = []
976
+ wanted: set[str] = set()
977
+ for name, (schema, overlay) in schemas.items():
978
+ for spec in plan_indexes(schema, overlay=overlay).built(constraints_only=constraints_only):
979
+ index_name = spec.name(domain_id, name)
980
+ wanted.add(index_name)
981
+ current = existing.get(index_name)
982
+ if current is not None and current.valid:
983
+ continue
984
+ if current is not None:
985
+ statements.append(drop_index_sql(index_name, concurrently=True))
986
+ statements.append(create_index_sql(spec, domain_id, name, partition, concurrently=True))
987
+ statements.append(comment_sql(spec, domain_id, name))
988
+ for index in existing.values():
989
+ if index.name in wanted:
990
+ continue
991
+ if index.branch in schemas or (branch is None and index.branch not in branches):
992
+ statements.append(drop_index_sql(index.name, concurrently=True))
993
+ return statements
994
+
995
+
996
+ def _parser() -> argparse.ArgumentParser:
997
+ parser = argparse.ArgumentParser(
998
+ prog="python -m lorebase_store.indexes",
999
+ description="Per-Attribute indexes of mat_records.",
1000
+ )
1001
+ commands = parser.add_subparsers(dest="command", required=True)
1002
+ plan = commands.add_parser(
1003
+ "plan", help="Print the DDL that brings a Domain's indexes in line with its schemas."
1004
+ )
1005
+ plan.add_argument("--domain", required=True, help="Domain id.")
1006
+ plan.add_argument("--branch", default=None, help="Only this branch (default: every one).")
1007
+ return parser
1008
+
1009
+
1010
+ async def _plan(domain_id: str, branch: str | None) -> list[str]:
1011
+ from lorebase_platform.settings import load_settings
1012
+ from lorebase_store.db import create_engine
1013
+ from lorebase_store.settings import database_settings
1014
+
1015
+ engine = create_engine(database_settings(load_settings()), pooled=False)
1016
+ try:
1017
+ async with engine.begin() as connection, AsyncSession(bind=connection) as session:
1018
+ await scope_to_domain(session, domain_id)
1019
+ return await pending_index_plan(session, domain_id, branch=branch)
1020
+ finally:
1021
+ await engine.dispose()
1022
+
1023
+
1024
+ def main(argv: Sequence[str] | None = None) -> int:
1025
+ """`plan`: print the pending DDL, one statement per line, for psql."""
1026
+ args = _parser().parse_args(argv)
1027
+ for statement in asyncio.run(_plan(args.domain, args.branch)):
1028
+ sys.stdout.write(statement + ";\n")
1029
+ return 0
1030
+
1031
+
1032
+ if __name__ == "__main__":
1033
+ raise SystemExit(main())