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,1478 @@
1
+ """Storage profiles: what the database keeps, traded against memory, disk and speed.
2
+
3
+ `store.storage.profile` (`compact`, `balanced`, `fast`) picks each choice below; every other
4
+ key of `store.storage` overrides one (docs/performance.md#storage-profiles):
5
+
6
+ | choice | compact | balanced | fast |
7
+ |---|---|---|---|
8
+ | `lineage_maps`: lineage maps of Records and Links | shared | shared | inline |
9
+ | `values_index`: GIN index on Record values | off | on | on |
10
+ | `attribute_indexes`: per-Attribute indexes built | constraints | all | all |
11
+ | `lexicon_index`: trigram index of the word lexicon | off | on | on |
12
+ | `embedding_precision`: HNSW index of embeddings | half | full | full |
13
+ | `compression`: TOAST compression of large values | pglz | lz4 | lz4 |
14
+ | `records_fillfactor` | 100 | 100 | 90 |
15
+ | `prewarm`: load the hot relations after a restart | off | off | on |
16
+ | `bm25_index`: BM25 index of search (`pg_search`, optional) | off | off | off |
17
+
18
+ Every feature works with every profile; what a profile leaves out makes some reads slower:
19
+ equality filters on Attributes without an index scan the Kind, typo corrections compare
20
+ every word of the searched Kinds, an inline lineage map makes rows twice as large.
21
+
22
+ The database keeps what it has until `python -m lorebase_store.storage apply` runs: reads
23
+ accept both forms of a lineage map, use whichever HNSW index exists, and work without the
24
+ optional indexes. `apply` is batched and resumable (`storage_tasks`); reads go on during it:
25
+
26
+ - lineage maps: enables or disables the `lb_lineage_maps` triggers (revision 0036), then
27
+ rewrites the rows of `mat_records` and `mat_links` page range by page range;
28
+ - indexes: built with `CREATE INDEX CONCURRENTLY`, dropped `CONCURRENTLY` where Postgres
29
+ allows it. The values index is a partitioned index: its partitions are built concurrently
30
+ and attached; dropping it takes a short exclusive lock (waits at most
31
+ `store.storage.lock_timeout_ms`, then the step is retried by the next run);
32
+ - per-Attribute indexes: queued as index jobs (`python -m lorebase_store.jobs work`);
33
+ - BM25 index (`lorebase_store.bm25`): only on a server that offers `pg_search` and preloads
34
+ it (a ParadeDB image); creates the extension, then builds and drops the index as the values
35
+ index. Without the extension the step is skipped and says so;
36
+ - compression and fill factor: catalog changes that apply to rows written from then on;
37
+ - `--compact`: moves the rows of the last pages of `mat_records`, `mat_links` and
38
+ `mat_history` into the free space of earlier pages (one branch at a time, under the branch
39
+ lock that commits take: every writer of these tables holds it, see the store schema
40
+ notes), lets VACUUM give the emptied pages back, and with `--reindex`
41
+ rebuilds their indexes concurrently. This is what shrinks a table after a rewrite.
42
+
43
+ `prewarm` loads the hot relations of the agent read path, most valuable first, into shared
44
+ buffers with `pg_prewarm` (up to `store.storage.prewarm_fraction` of `shared_buffers`);
45
+ `autoprewarm` (`shared_preload_libraries = 'pg_prewarm'`) restores them after a restart.
46
+ `status` prints the choices, what the database has, and the size of the hot relations.
47
+
48
+ DDL needs the role that owns the tables: the maintenance role when configured
49
+ (`database.maintenance_dsn`), else the configured role.
50
+ """
51
+
52
+ from __future__ import annotations
53
+
54
+ import argparse
55
+ import asyncio
56
+ import json
57
+ import logging
58
+ import sys
59
+ from collections.abc import Mapping, Sequence
60
+ from dataclasses import asdict, dataclass, field, replace
61
+ from typing import Any, Literal
62
+
63
+ from sqlalchemy import text
64
+ from sqlalchemy.exc import DBAPIError
65
+ from sqlalchemy.ext.asyncio import AsyncConnection, AsyncEngine
66
+
67
+ from lorebase_store import bm25
68
+ from lorebase_store.repositories.refs import advisory_lock_key, sqlstate
69
+ from lorebase_store.settings import StorageSettings, StoreSettings
70
+
71
+ log = logging.getLogger(__name__)
72
+
73
+ LineageMaps = Literal["shared", "inline"]
74
+ AttributeIndexes = Literal["all", "constraints"]
75
+ Precision = Literal["half", "full"]
76
+ Compression = Literal["lz4", "pglz"]
77
+
78
+ LINEAGE_TRIGGER = "lb_lineage_maps"
79
+ """Row trigger of revision 0036 on `mat_records` and `mat_links`."""
80
+ MAP_KEY = "$map"
81
+ """Key of a reference to a shared lineage map."""
82
+ VALUES_INDEX = "ix_mat_records_values"
83
+ LEXICON_INDEX = "ix_mat_lexicon_branch_word"
84
+ PRECISION_TTL_S = 60.0
85
+ """How long a process trusts the HNSW precision it found (`embeddings.index_precision`)."""
86
+ HALFVEC_MIN_VERSION = (0, 7, 0)
87
+ """First pgvector release with `halfvec`."""
88
+ LOCK_NOT_AVAILABLE = "55P03"
89
+ DEADLOCK = "40P01"
90
+ RETRY_PASSES = 3
91
+ """Passes over the page ranges whose rows a commit held during the lineage rewrite."""
92
+ RETRY_WAIT_S = 1.0
93
+ """Pause between those passes."""
94
+
95
+ LINEAGE_TABLES: Mapping[str, tuple[str, ...]] = {
96
+ "mat_records": ("lineage", "lineage_revisions"),
97
+ "mat_links": ("lineage",),
98
+ }
99
+ """Tables with lineage map columns."""
100
+ COMPRESSED: Mapping[str, tuple[str, ...]] = {
101
+ "mat_records": ("values", "derived", "lineage", "lineage_revisions", "search"),
102
+ "mat_links": ("values", "lineage"),
103
+ "mat_history": ("provenance",),
104
+ }
105
+ """Columns whose large values are compressed (TOAST) with the profile's method."""
106
+ COMPACTED = ("mat_records", "mat_links", "mat_history")
107
+ """Tables `--compact` moves rows of; every row belongs to one branch."""
108
+
109
+
110
+ @dataclass(frozen=True, slots=True)
111
+ class StorageChoices:
112
+ """What a profile keeps (see the module documentation)."""
113
+
114
+ lineage_maps: LineageMaps
115
+ values_index: bool
116
+ attribute_indexes: AttributeIndexes
117
+ lexicon_index: bool
118
+ embedding_precision: Precision
119
+ compression: Compression
120
+ records_fillfactor: int
121
+ prewarm: bool
122
+ bm25_index: bool = False
123
+
124
+
125
+ PROFILES: Mapping[str, StorageChoices] = {
126
+ "compact": StorageChoices(
127
+ lineage_maps="shared",
128
+ values_index=False,
129
+ attribute_indexes="constraints",
130
+ lexicon_index=False,
131
+ embedding_precision="half",
132
+ compression="pglz",
133
+ records_fillfactor=100,
134
+ prewarm=False,
135
+ ),
136
+ "balanced": StorageChoices(
137
+ lineage_maps="shared",
138
+ values_index=True,
139
+ attribute_indexes="all",
140
+ lexicon_index=True,
141
+ embedding_precision="full",
142
+ compression="lz4",
143
+ records_fillfactor=100,
144
+ prewarm=False,
145
+ ),
146
+ "fast": StorageChoices(
147
+ lineage_maps="inline",
148
+ values_index=True,
149
+ attribute_indexes="all",
150
+ lexicon_index=True,
151
+ embedding_precision="full",
152
+ compression="lz4",
153
+ records_fillfactor=90,
154
+ prewarm=True,
155
+ ),
156
+ }
157
+
158
+
159
+ def storage_choices(settings: StorageSettings) -> StorageChoices:
160
+ """The profile's choices with the keys set in `settings` instead."""
161
+ choices = PROFILES[settings.profile]
162
+ overrides = {
163
+ name: getattr(settings, name)
164
+ for name in StorageChoices.__dataclass_fields__
165
+ if getattr(settings, name, None) is not None
166
+ }
167
+ return replace(choices, **overrides)
168
+
169
+
170
+ def constraints_only(settings: StoreSettings) -> bool:
171
+ """Whether per-Attribute indexes are built for unique and foreign key Attributes only."""
172
+ return storage_choices(settings.storage).attribute_indexes == "constraints"
173
+
174
+
175
+ # What the database has ------------------------------------------------------------------------
176
+
177
+
178
+ @dataclass(slots=True)
179
+ class StorageState:
180
+ """The physical state the choices are compared with."""
181
+
182
+ lineage_triggers: dict[str, bool] = field(default_factory=dict)
183
+ values_index: bool = False
184
+ values_index_partitions: list[str] = field(default_factory=list)
185
+ lexicon_index: bool = False
186
+ bm25_index: bool = False
187
+ bm25_index_partitions: list[str] = field(default_factory=list)
188
+ pg_search: str | None = None
189
+ """Installed version of the extension; `None` when it is not installed."""
190
+ pg_search_available: bool = False
191
+ pg_search_preloaded: bool = False
192
+ hnsw: dict[str, bool] = field(default_factory=dict)
193
+ invalid_indexes: list[str] = field(default_factory=list)
194
+ """Optional indexes that exist but are not valid (an interrupted concurrent build, a
195
+ partitioned index with partitions not attached yet): the flags above count only valid ones."""
196
+ compression: dict[str, str] = field(default_factory=dict)
197
+ records_fillfactor: int = 100
198
+ pgvector: tuple[int, ...] | None = None
199
+ prewarm_extension: bool = False
200
+ prewarm_available: bool = False
201
+ autoprewarm: bool = False
202
+ lineage_rows: dict[str, dict[str, int]] = field(default_factory=dict)
203
+ """Per table, rows of a 0.1% page sample holding a reference (`shared`) and holding
204
+ an inline map larger than a reference (`inline`)."""
205
+
206
+
207
+ def hnsw_index_name(dims: int, precision: Precision) -> str:
208
+ """Name of the HNSW index of `embeddings` for `dims` (`half`: on `halfvec`)."""
209
+ return f"embeddings_hnsw_{dims}" if precision == "full" else f"embeddings_hnsw_half_{dims}"
210
+
211
+
212
+ def hnsw_index_sql(dims: int, precision: Precision, *, concurrently: bool = True) -> str:
213
+ """The HNSW index of `embeddings` for `dims` (as revision 0001 creates the full one)."""
214
+ kind = "vector" if precision == "full" else "halfvec"
215
+ ops = f"{kind}_cosine_ops"
216
+ how = "CONCURRENTLY " if concurrently else ""
217
+ return (
218
+ f"CREATE INDEX {how}IF NOT EXISTS {hnsw_index_name(dims, precision)} ON embeddings "
219
+ f"USING hnsw ((embedding::{kind}({dims})) {ops}) WHERE dims = {dims}"
220
+ )
221
+
222
+
223
+ def _version(value: str | None) -> tuple[int, ...] | None:
224
+ if not value:
225
+ return None
226
+ parts: list[int] = []
227
+ for piece in value.split("."):
228
+ digits = "".join(ch for ch in piece if ch.isdigit())
229
+ parts.append(int(digits or 0))
230
+ return tuple(parts)
231
+
232
+
233
+ async def _partitions(connection: AsyncConnection, table: str) -> list[str]:
234
+ """`table` itself, or its partitions when it is partitioned."""
235
+ rows = await connection.execute(
236
+ text(
237
+ "SELECT c.relname FROM pg_inherits i JOIN pg_class c ON c.oid = i.inhrelid "
238
+ "WHERE i.inhparent = CAST(:t AS regclass) ORDER BY c.relname"
239
+ ),
240
+ {"t": table},
241
+ )
242
+ found = [str(name) for name in rows.scalars()]
243
+ return found or [table]
244
+
245
+
246
+ async def read_state(connection: AsyncConnection, dims: int) -> StorageState:
247
+ """What the database has now."""
248
+ state = StorageState()
249
+ for table in LINEAGE_TABLES:
250
+ enabled = await connection.scalar(
251
+ text(
252
+ "SELECT tgenabled <> 'D' FROM pg_trigger "
253
+ "WHERE tgrelid = CAST(:t AS regclass) AND tgname = :n"
254
+ ),
255
+ {"t": table, "n": LINEAGE_TRIGGER},
256
+ )
257
+ state.lineage_triggers[table] = bool(enabled)
258
+ found = await connection.execute(
259
+ text(
260
+ "SELECT c.relname, x.indisvalid FROM pg_index x "
261
+ "JOIN pg_class c ON c.oid = x.indexrelid "
262
+ "JOIN pg_namespace n ON n.oid = c.relnamespace WHERE n.nspname = current_schema()"
263
+ )
264
+ )
265
+ valid = {str(name): bool(ok) for name, ok in found}
266
+ names = set(valid)
267
+ optional = [
268
+ VALUES_INDEX,
269
+ LEXICON_INDEX,
270
+ bm25.INDEX,
271
+ *(hnsw_index_name(dims, p) for p in ("full", "half")),
272
+ ]
273
+ state.invalid_indexes = [name for name in optional if name in valid and not valid[name]]
274
+ state.values_index = valid.get(VALUES_INDEX, False)
275
+ state.lexicon_index = valid.get(LEXICON_INDEX, False)
276
+ state.bm25_index = valid.get(bm25.INDEX, False)
277
+ for precision in ("full", "half"):
278
+ state.hnsw[precision] = valid.get(hnsw_index_name(dims, precision), False)
279
+ state.values_index_partitions = [
280
+ p for p in await _partitions(connection, "mat_records") if f"{p}_values_idx" in names
281
+ ]
282
+ state.bm25_index_partitions = [
283
+ p for p in await _partitions(connection, "mat_records") if bm25.partition_index(p) in names
284
+ ]
285
+ rows = await connection.execute(
286
+ text(
287
+ "SELECT c.relname || '.' || a.attname, CAST(a.attcompression AS text) "
288
+ "FROM pg_attribute a "
289
+ "JOIN pg_class c ON c.oid = a.attrelid "
290
+ "WHERE c.relname ~ CAST(:tables AS text) AND a.attname = ANY(CAST(:cols AS text[])) "
291
+ "AND c.relkind IN ('r', 'p')"
292
+ ),
293
+ {
294
+ "tables": "^(" + "|".join(COMPRESSED) + ")(_p[0-9]+)?$",
295
+ "cols": sorted({c for cols in COMPRESSED.values() for c in cols}),
296
+ },
297
+ )
298
+ method = {"p": "pglz", "l": "lz4", "": "default"}
299
+ state.compression = {str(name): method.get(str(code), str(code)) for name, code in rows}
300
+ # Set on the partitions (a partitioned table has no storage parameters); mixed reads as 0.
301
+ factors = set()
302
+ for partition in await _partitions(connection, "mat_records"):
303
+ options = await connection.scalar(
304
+ text("SELECT reloptions FROM pg_class WHERE oid = CAST(:t AS regclass)"),
305
+ {"t": partition},
306
+ )
307
+ given = [str(o).split("=", 1)[1] for o in options or () if str(o).startswith("fillfactor=")]
308
+ factors.add(int(given[0]) if given else 100)
309
+ state.records_fillfactor = factors.pop() if len(factors) == 1 else 0
310
+ state.pgvector = _version(
311
+ await connection.scalar(
312
+ text("SELECT extversion FROM pg_extension WHERE extname = 'vector'")
313
+ )
314
+ )
315
+ state.prewarm_extension = bool(
316
+ await connection.scalar(text("SELECT 1 FROM pg_extension WHERE extname = 'pg_prewarm'"))
317
+ )
318
+ state.prewarm_available = bool(
319
+ await connection.scalar(
320
+ text("SELECT 1 FROM pg_available_extensions WHERE name = 'pg_prewarm'")
321
+ )
322
+ )
323
+ preload = str(await connection.scalar(text("SHOW shared_preload_libraries")) or "")
324
+ preloaded = {p.strip() for p in preload.split(",")}
325
+ state.autoprewarm = "pg_prewarm" in preloaded
326
+ state.pg_search_preloaded = bm25.EXTENSION in preloaded
327
+ state.pg_search = await connection.scalar(
328
+ text("SELECT extversion FROM pg_extension WHERE extname = :e"), {"e": bm25.EXTENSION}
329
+ )
330
+ state.pg_search_available = bool(
331
+ await connection.scalar(
332
+ text("SELECT 1 FROM pg_available_extensions WHERE name = :e"), {"e": bm25.EXTENSION}
333
+ )
334
+ )
335
+ for table, columns in LINEAGE_TABLES.items():
336
+ shared = _map_columns_condition(columns, shared=False)
337
+ inline = _map_columns_condition(columns, shared=True)
338
+ row = (
339
+ await connection.execute(
340
+ text(
341
+ f"SELECT count(*) FILTER (WHERE {shared}), "
342
+ f"count(*) FILTER (WHERE {inline}) FROM {table} TABLESAMPLE SYSTEM (0.1)"
343
+ )
344
+ )
345
+ ).one()
346
+ state.lineage_rows[table] = {"shared": int(row[0]), "inline": int(row[1])}
347
+ return state
348
+
349
+
350
+ # Reports ------------------------------------------------------------------------------------------
351
+
352
+
353
+ @dataclass(slots=True)
354
+ class StepResult:
355
+ step: str
356
+ status: Literal["done", "skipped", "deferred", "failed", "planned"]
357
+ detail: str = ""
358
+ rows: int = 0
359
+
360
+
361
+ @dataclass(slots=True)
362
+ class ApplyReport:
363
+ profile: str
364
+ choices: dict[str, Any]
365
+ steps: list[StepResult] = field(default_factory=list)
366
+
367
+ def add(self, step: str, status: Any, detail: str = "", rows: int = 0) -> None:
368
+ self.steps.append(StepResult(step, status, detail, rows))
369
+ log.info("storage %s: %s %s", step, status, detail)
370
+
371
+ @property
372
+ def complete(self) -> bool:
373
+ return all(s.status in ("done", "skipped") for s in self.steps)
374
+
375
+ def as_dict(self) -> dict[str, Any]:
376
+ return {
377
+ "profile": self.profile,
378
+ "choices": self.choices,
379
+ "complete": self.complete,
380
+ "steps": [asdict(s) for s in self.steps],
381
+ }
382
+
383
+
384
+ # Apply --------------------------------------------------------------------------------------------
385
+
386
+
387
+ class _LockedError(Exception):
388
+ """A lock was not granted within `lock_timeout_ms`."""
389
+
390
+
391
+ class _NoRoomError(Exception):
392
+ """Rows moved out of the last pages landed in them again (rolled back)."""
393
+
394
+
395
+ async def _autocommit(engine: AsyncEngine) -> AsyncConnection:
396
+ connection = await engine.connect()
397
+ connection = await connection.execution_options(isolation_level="AUTOCOMMIT")
398
+ await connection.execute(text("SELECT set_config('lorebase.bypass_rls', 'on', false)"))
399
+ return connection
400
+
401
+
402
+ async def _ddl(connection: AsyncConnection, statement: str, lock_timeout_ms: int) -> None:
403
+ """Run DDL waiting at most `lock_timeout_ms` for its locks."""
404
+ await connection.exec_driver_sql(f"SET lock_timeout = '{int(lock_timeout_ms)}ms'")
405
+ try:
406
+ await connection.exec_driver_sql(statement)
407
+ except DBAPIError as error:
408
+ if sqlstate(error) in (LOCK_NOT_AVAILABLE, DEADLOCK):
409
+ raise _LockedError(statement) from error
410
+ raise
411
+ finally:
412
+ await connection.exec_driver_sql("SET lock_timeout = 0")
413
+
414
+
415
+ async def _task(connection: AsyncConnection, name: str) -> dict[str, Any]:
416
+ state = await connection.scalar(
417
+ text("SELECT state FROM storage_tasks WHERE name = :n"), {"n": name}
418
+ )
419
+ if state is None:
420
+ return {}
421
+ return dict(json.loads(state) if isinstance(state, str) else state)
422
+
423
+
424
+ async def _save_task(connection: AsyncConnection, name: str, state: Mapping[str, Any]) -> None:
425
+ await connection.execute(
426
+ text(
427
+ "INSERT INTO storage_tasks (name, state, updated_at) "
428
+ "VALUES (:n, CAST(:s AS jsonb), now()) "
429
+ "ON CONFLICT (name) DO UPDATE SET state = excluded.state, updated_at = now()"
430
+ ),
431
+ {"n": name, "s": json.dumps(dict(state))},
432
+ )
433
+
434
+
435
+ async def _pages(connection: AsyncConnection, table: str) -> int:
436
+ size = await connection.scalar(
437
+ text("SELECT pg_relation_size(CAST(:t AS regclass))"), {"t": table}
438
+ )
439
+ return int(size or 0) // 8192
440
+
441
+
442
+ async def _rows_per_page(connection: AsyncConnection, table: str) -> float:
443
+ row = (
444
+ await connection.execute(
445
+ text("SELECT reltuples, relpages FROM pg_class WHERE oid = CAST(:t AS regclass)"),
446
+ {"t": table},
447
+ )
448
+ ).one()
449
+ tuples, pages = float(row[0] or 0), float(row[1] or 0)
450
+ return tuples / pages if tuples > 0 and pages > 0 else 4.0
451
+
452
+
453
+ def _map_columns_condition(columns: Sequence[str], *, shared: bool) -> str:
454
+ """Rows of the batch the lineage rewrite changes."""
455
+ if shared:
456
+ one = (
457
+ "(jsonb_typeof({c}) = 'object' AND NOT {c} ? '" + MAP_KEY + "' "
458
+ "AND pg_column_size({c}) > 64)"
459
+ )
460
+ else:
461
+ one = "(jsonb_typeof({c}) = 'object' AND {c} ? '" + MAP_KEY + "')"
462
+ return " OR ".join(one.format(c=c) for c in columns)
463
+
464
+
465
+ async def _watermark(connection: AsyncConnection) -> int:
466
+ """The oldest transaction id still running now: every row written from here on has a
467
+ transaction id at or after it (modulo wraparound, as `age` compares them)."""
468
+ value = await connection.scalar(
469
+ text("SELECT (pg_snapshot_xmin(pg_current_snapshot())::text::bigint % 4294967296)")
470
+ )
471
+ return int(value or 0)
472
+
473
+
474
+ def _since(condition: str, watermark: int | None) -> str:
475
+ """`condition` limited to rows written since `watermark`. The transaction id is tested
476
+ first, without reading the map columns, which are large and TOASTed."""
477
+ if watermark is None:
478
+ return condition
479
+ return (
480
+ f"CASE WHEN age(xmin) <= age(CAST('{int(watermark)}' AS xid)) "
481
+ f"THEN ({condition}) ELSE false END"
482
+ )
483
+
484
+
485
+ async def _rewrite_range(
486
+ engine: AsyncEngine,
487
+ settings: StorageSettings,
488
+ name: str,
489
+ statements: tuple[Any, Any],
490
+ state: Mapping[str, Any],
491
+ pages: tuple[int, int],
492
+ *,
493
+ page: int | None = None,
494
+ ) -> dict[str, Any]:
495
+ """Rewrite one page range and save the task: the range goes on the retry list while
496
+ rows of the other form are left in it, and off it once none are."""
497
+ rewrite, remaining = statements
498
+ start, stop = pages
499
+ async with engine.begin() as connection:
500
+ await connection.execute(text("SELECT set_config('lorebase.bypass_rls', 'on', true)"))
501
+ await connection.exec_driver_sql(
502
+ f"SET LOCAL lock_timeout = '{int(settings.lock_timeout_ms)}ms'"
503
+ )
504
+ bounds = {"lo": (start, 0), "hi": (stop, 0)}
505
+ result = await connection.execute(rewrite, bounds)
506
+ left = int((await connection.execute(remaining, bounds)).scalar_one())
507
+ retry = [r for r in state.get("retry", []) if r != [start, stop]]
508
+ if left:
509
+ retry.append([start, stop])
510
+ new = {**state, "rows": int(state["rows"]) + int(result.rowcount or 0), "retry": retry}
511
+ if page is not None:
512
+ new["page"] = page
513
+ await _save_task(connection, name, new)
514
+ return new
515
+
516
+
517
+ async def _rewrite_lineage(
518
+ engine: AsyncEngine,
519
+ settings: StorageSettings,
520
+ shared: bool,
521
+ report: ApplyReport,
522
+ *,
523
+ max_batches: int | None,
524
+ leftovers: Mapping[str, Mapping[str, int]] | None = None,
525
+ verify: bool = False,
526
+ ) -> None:
527
+ """Rewrite the map columns of every row, page range by page range, from the first page
528
+ to the last page the table had when the pass started (rows a concurrent write puts after
529
+ it are written by the trigger, or are already inline). Ranges with rows a commit held are
530
+ rewritten again after the pass, `RETRY_PASSES` times; ranges still held stay in the task
531
+ and the step is deferred, so a later run finishes them. Resumes where it stopped.
532
+
533
+ A pass that ends with no row of the other form left marks the table `complete` and keeps
534
+ the `watermark` it started at. While the triggers are in the state the profile wants,
535
+ every writer stores the wanted form (the triggers convert maps to references; with them
536
+ off, nothing writes a reference), so a later run with the same target only looks at the
537
+ rows written since the watermark, without reading their maps, and moves the watermark
538
+ on; a hit starts a pass limited to those rows. `verify` ignores all that and scans
539
+ every row."""
540
+ mode = "shared" if shared else "inline"
541
+ async with engine.connect() as probe:
542
+ work = [
543
+ (table, partition)
544
+ for table in LINEAGE_TABLES
545
+ for partition in await _partitions(probe, table)
546
+ ]
547
+ batches = 0
548
+ for table, partition in work:
549
+ columns = LINEAGE_TABLES[table]
550
+ name = f"lineage:{partition}"
551
+ # Rows of the other form found after a finished pass (copies of rows written before
552
+ # the triggers switched) start the pass again.
553
+ stale = bool((leftovers or {}).get(table, {}).get("shared" if not shared else "inline"))
554
+ condition = _map_columns_condition(columns, shared=shared)
555
+ async with engine.begin() as connection:
556
+ state = await _task(connection, name)
557
+ since: int | None = None
558
+ if state.get("mode") == mode and state.get("done") and not verify:
559
+ mark = state.get("watermark")
560
+ if mark is not None and state.get("complete") and not stale:
561
+ now = await _watermark(connection)
562
+ changed = int(
563
+ await connection.scalar(
564
+ text(
565
+ f"SELECT count(*) FROM {partition} "
566
+ f"WHERE {_since(condition, int(mark))}"
567
+ )
568
+ )
569
+ or 0
570
+ )
571
+ if not changed:
572
+ await _save_task(connection, name, {**state, "watermark": now})
573
+ report.add(
574
+ f"lineage {partition}", "skipped", "complete; no rows to rewrite"
575
+ )
576
+ continue
577
+ since = int(mark)
578
+ if state.get("mode") != mode or state.get("done"):
579
+ state = {
580
+ "mode": mode,
581
+ "page": 0,
582
+ "end": await _pages(connection, partition),
583
+ "rows": 0,
584
+ "retry": [],
585
+ "done": False,
586
+ "complete": False,
587
+ "watermark": await _watermark(connection),
588
+ "since": since,
589
+ }
590
+ await _save_task(connection, name, state)
591
+ per_page = await _rows_per_page(connection, partition)
592
+ step = max(1, int(settings.batch_rows / max(per_page, 1.0)))
593
+ if shared:
594
+ assignments = ", ".join(f"{c} = {c}" for c in columns)
595
+ else:
596
+ assignments = ", ".join(f"{c} = lb_lineage(domain_id, {c})" for c in columns)
597
+ condition = _since(condition, state.get("since"))
598
+ in_range = "ctid >= CAST(:lo AS tid) AND ctid < CAST(:hi AS tid)"
599
+ # Rows a commit holds are skipped, never waited for: the rewrite cannot deadlock with
600
+ # commits, which lock rows in key order. The rows of the range still in the other
601
+ # form afterwards (the skipped ones, or rows a commit wrote meanwhile) put the range
602
+ # on the retry list.
603
+ rewrite = text(
604
+ f"UPDATE {partition} SET {assignments} WHERE ctid = ANY(ARRAY("
605
+ f"SELECT ctid FROM {partition} WHERE {in_range} AND ({condition}) "
606
+ "FOR UPDATE SKIP LOCKED))"
607
+ )
608
+ remaining = text(f"SELECT count(*) FROM {partition} WHERE {in_range} AND ({condition})")
609
+
610
+ try:
611
+ while int(state["page"]) < int(state["end"]):
612
+ if max_batches is not None and batches >= max_batches:
613
+ report.add(
614
+ f"lineage {partition}", "deferred", f"stopped at page {state['page']}"
615
+ )
616
+ return
617
+ start = int(state["page"])
618
+ stop = min(start + step, int(state["end"]))
619
+ state = await _rewrite_range(
620
+ engine, settings, name, (rewrite, remaining), state, (start, stop), page=stop
621
+ )
622
+ batches += 1
623
+ for attempt in range(RETRY_PASSES):
624
+ if not state.get("retry"):
625
+ break
626
+ if attempt:
627
+ await asyncio.sleep(RETRY_WAIT_S)
628
+ for start, stop in list(state["retry"]):
629
+ state = await _rewrite_range(
630
+ engine, settings, name, (rewrite, remaining), state, (start, stop)
631
+ )
632
+ except DBAPIError as error:
633
+ if sqlstate(error) not in (LOCK_NOT_AVAILABLE, DEADLOCK):
634
+ raise
635
+ report.add(
636
+ f"lineage {partition}",
637
+ "deferred",
638
+ f"lock conflict at page {state['page']}; run again",
639
+ )
640
+ return
641
+ if state.get("retry"):
642
+ report.add(
643
+ f"lineage {partition}",
644
+ "deferred",
645
+ f"{len(state['retry'])} page ranges hold rows commits kept locked; run again",
646
+ )
647
+ continue
648
+ async with engine.begin() as connection:
649
+ await _save_task(
650
+ connection, name, {**state, "done": True, "complete": True, "since": None}
651
+ )
652
+ if int(state["rows"]) == 0:
653
+ continue
654
+ report.add(
655
+ f"lineage {partition}", "done", f"{mode}, {state['rows']} rows", int(state["rows"])
656
+ )
657
+
658
+
659
+ async def _switch_triggers(
660
+ ddl: AsyncConnection,
661
+ state: StorageState,
662
+ shared: bool,
663
+ settings: StorageSettings,
664
+ report: ApplyReport,
665
+ ) -> bool:
666
+ """Enable or disable the lineage map triggers; `False` when a lock was not granted."""
667
+ ok = True
668
+ for table in LINEAGE_TABLES:
669
+ if state.lineage_triggers.get(table) == shared:
670
+ continue
671
+ action = "ENABLE" if shared else "DISABLE"
672
+ try:
673
+ await _ddl(
674
+ ddl,
675
+ f"ALTER TABLE {table} {action} TRIGGER {LINEAGE_TRIGGER}",
676
+ settings.lock_timeout_ms,
677
+ )
678
+ report.add(f"lineage trigger {table}", "done", action.lower())
679
+ except _LockedError:
680
+ ok = False
681
+ report.add(f"lineage trigger {table}", "deferred", "lock not granted; run again")
682
+ return ok
683
+
684
+
685
+ async def _values_index(
686
+ ddl: AsyncConnection,
687
+ state: StorageState,
688
+ wanted: bool,
689
+ settings: StorageSettings,
690
+ report: ApplyReport,
691
+ ) -> None:
692
+ if wanted and state.values_index:
693
+ return
694
+ if not wanted:
695
+ if not state.values_index and VALUES_INDEX not in state.invalid_indexes:
696
+ # Leftovers of an interrupted build.
697
+ for partition in state.values_index_partitions:
698
+ await ddl.exec_driver_sql(
699
+ f"DROP INDEX CONCURRENTLY IF EXISTS {partition}_values_idx"
700
+ )
701
+ return
702
+ try:
703
+ await _ddl(ddl, f"DROP INDEX IF EXISTS {VALUES_INDEX}", settings.lock_timeout_ms)
704
+ report.add("values index", "done", "dropped")
705
+ except _LockedError:
706
+ report.add("values index", "deferred", "lock not granted; run again")
707
+ return
708
+ # Built partition by partition without blocking writes, then attached to the parent.
709
+ partitions = await _partitions(ddl, "mat_records")
710
+ for partition in partitions:
711
+ index = f"{partition}_values_idx"
712
+ valid = await ddl.scalar(
713
+ text("SELECT indisvalid FROM pg_index WHERE indexrelid = to_regclass(:i)"),
714
+ {"i": index},
715
+ )
716
+ if valid is False:
717
+ await ddl.exec_driver_sql(f"DROP INDEX CONCURRENTLY IF EXISTS {index}")
718
+ await ddl.exec_driver_sql(
719
+ f"CREATE INDEX CONCURRENTLY IF NOT EXISTS {index} ON {partition} "
720
+ f'USING gin ("values" jsonb_path_ops)'
721
+ )
722
+ try:
723
+ await _ddl(
724
+ ddl,
725
+ f"CREATE INDEX IF NOT EXISTS {VALUES_INDEX} ON ONLY mat_records "
726
+ f'USING gin ("values" jsonb_path_ops)',
727
+ settings.lock_timeout_ms,
728
+ )
729
+ for partition in partitions:
730
+ attached = await ddl.scalar(
731
+ text(
732
+ "SELECT 1 FROM pg_inherits WHERE inhrelid = to_regclass(:i) "
733
+ "AND inhparent = to_regclass(:p)"
734
+ ),
735
+ {"i": f"{partition}_values_idx", "p": VALUES_INDEX},
736
+ )
737
+ if not attached:
738
+ await _ddl(
739
+ ddl,
740
+ f"ALTER INDEX {VALUES_INDEX} ATTACH PARTITION {partition}_values_idx",
741
+ settings.lock_timeout_ms,
742
+ )
743
+ report.add("values index", "done", "built")
744
+ except _LockedError:
745
+ report.add("values index", "deferred", "lock not granted; run again")
746
+
747
+
748
+ async def _lexicon_index(
749
+ ddl: AsyncConnection, state: StorageState, wanted: bool, report: ApplyReport
750
+ ) -> None:
751
+ present = state.lexicon_index or LEXICON_INDEX in state.invalid_indexes
752
+ if wanted == state.lexicon_index and (wanted or not present):
753
+ return
754
+ if wanted:
755
+ if present: # an invalid leftover of an interrupted concurrent build
756
+ await ddl.exec_driver_sql(f"DROP INDEX CONCURRENTLY IF EXISTS {LEXICON_INDEX}")
757
+ await ddl.exec_driver_sql(
758
+ f"CREATE INDEX CONCURRENTLY IF NOT EXISTS {LEXICON_INDEX} ON mat_lexicon "
759
+ "USING gin (domain_id, branch, word gin_trgm_ops)"
760
+ )
761
+ report.add("lexicon index", "done", "built")
762
+ else:
763
+ await ddl.exec_driver_sql(f"DROP INDEX CONCURRENTLY IF EXISTS {LEXICON_INDEX}")
764
+ report.add("lexicon index", "done", "dropped")
765
+
766
+
767
+ _WRITABLE_EXTENSION_SCHEMAS = text(
768
+ "SELECT DISTINCT n.nspname FROM pg_namespace n "
769
+ "CROSS JOIN LATERAL aclexplode(coalesce(n.nspacl, acldefault('n', n.nspowner))) AS a "
770
+ "WHERE a.privilege_type = 'CREATE' AND a.grantee <> n.nspowner "
771
+ "AND n.oid IN (SELECT d.objid FROM pg_depend d JOIN pg_extension e ON e.oid = d.refobjid "
772
+ "WHERE e.extname = :e AND d.classid = 'pg_namespace'::regclass AND d.deptype = 'e' "
773
+ "UNION SELECT e.extnamespace FROM pg_extension e WHERE e.extname = :e "
774
+ "AND e.extnamespace <> 'public'::regnamespace) ORDER BY 1"
775
+ )
776
+ """Schemas of `pg_search` (its own, `pdb` and `paradedb`) where a role other than the owner,
777
+ or PUBLIC (grantee 0), may create objects."""
778
+
779
+
780
+ async def _bm25_index(
781
+ ddl: AsyncConnection,
782
+ state: StorageState,
783
+ wanted: bool,
784
+ settings: StorageSettings,
785
+ report: ApplyReport,
786
+ ) -> None:
787
+ """Build or drop the BM25 index (`lorebase_store.bm25`): partitions concurrently, then
788
+ attached to an index on the parent, as the values index."""
789
+ present = state.bm25_index or bool(state.bm25_index_partitions)
790
+ if not wanted:
791
+ if not present and bm25.INDEX not in state.invalid_indexes:
792
+ return
793
+ try:
794
+ await _ddl(ddl, f"DROP INDEX IF EXISTS {bm25.INDEX}", settings.lock_timeout_ms)
795
+ for partition in state.bm25_index_partitions: # leftovers never attached
796
+ await ddl.exec_driver_sql(
797
+ f"DROP INDEX CONCURRENTLY IF EXISTS {bm25.partition_index(partition)}"
798
+ )
799
+ report.add("bm25 index", "done", "dropped")
800
+ except _LockedError:
801
+ report.add("bm25 index", "deferred", "lock not granted; run again")
802
+ return
803
+ if state.bm25_index:
804
+ return
805
+ if state.pg_search is None:
806
+ if not state.pg_search_available:
807
+ report.add(
808
+ "bm25 index",
809
+ "skipped",
810
+ "pg_search is not available on this server (a ParadeDB image provides it); "
811
+ "search keeps the default ranking",
812
+ )
813
+ return
814
+ if not state.pg_search_preloaded:
815
+ report.add(
816
+ "bm25 index",
817
+ "failed",
818
+ "pg_search must be in shared_preload_libraries; add it and restart Postgres",
819
+ )
820
+ return
821
+ try:
822
+ # pg_search needs pgvector (`vector`), which revision 0001 installs.
823
+ await ddl.exec_driver_sql(f"CREATE EXTENSION IF NOT EXISTS {bm25.EXTENSION} CASCADE")
824
+ except DBAPIError as error:
825
+ report.add("bm25 index", "failed", f"cannot create the extension: {error.orig}")
826
+ return
827
+ # The definer functions call the extension's functions by schema: a role that may create
828
+ # objects there could put its own in front of them and run them as the function owner.
829
+ open_schemas = list(await ddl.scalars(_WRITABLE_EXTENSION_SCHEMAS, {"e": bm25.EXTENSION}))
830
+ if open_schemas:
831
+ report.add(
832
+ "bm25 index",
833
+ "failed",
834
+ f"roles other than the owner may create objects in schema(s) "
835
+ f"{', '.join(open_schemas)} of pg_search; revoke CREATE there and run again",
836
+ )
837
+ return
838
+ partitions = await _partitions(ddl, "mat_records")
839
+ try:
840
+ for partition in partitions:
841
+ index = bm25.partition_index(partition)
842
+ valid = await ddl.scalar(
843
+ text("SELECT indisvalid FROM pg_index WHERE indexrelid = to_regclass(:i)"),
844
+ {"i": index},
845
+ )
846
+ if valid is False:
847
+ await ddl.exec_driver_sql(f"DROP INDEX CONCURRENTLY IF EXISTS {index}")
848
+ await ddl.exec_driver_sql(bm25.index_sql(partition, index, concurrently=True))
849
+ except DBAPIError as error:
850
+ # An interrupted concurrent build leaves an invalid index the next run rebuilds.
851
+ report.add("bm25 index", "failed", f"build of {index} failed: {error.orig}")
852
+ return
853
+ try:
854
+ await _ddl(
855
+ ddl, bm25.index_sql("mat_records", bm25.INDEX, only=True), settings.lock_timeout_ms
856
+ )
857
+ for partition in partitions:
858
+ index = bm25.partition_index(partition)
859
+ attached = await ddl.scalar(
860
+ text(
861
+ "SELECT 1 FROM pg_inherits WHERE inhrelid = to_regclass(:i) "
862
+ "AND inhparent = to_regclass(:p)"
863
+ ),
864
+ {"i": index, "p": bm25.INDEX},
865
+ )
866
+ if not attached:
867
+ await _ddl(
868
+ ddl,
869
+ f"ALTER INDEX {bm25.INDEX} ATTACH PARTITION {index}",
870
+ settings.lock_timeout_ms,
871
+ )
872
+ bm25.forget()
873
+ report.add("bm25 index", "done", "built")
874
+ except _LockedError:
875
+ report.add("bm25 index", "deferred", "lock not granted; run again")
876
+
877
+
878
+ async def _hnsw(
879
+ ddl: AsyncConnection,
880
+ state: StorageState,
881
+ wanted: Precision,
882
+ dims: int,
883
+ report: ApplyReport,
884
+ *,
885
+ switch_wait_s: float,
886
+ ) -> None:
887
+ if wanted == "half" and (state.pgvector is None or state.pgvector < HALFVEC_MIN_VERSION):
888
+ report.add(
889
+ "embedding index", "skipped", f"pgvector {state.pgvector} has no halfvec; kept full"
890
+ )
891
+ wanted = "full"
892
+ other: Precision = "full" if wanted == "half" else "half"
893
+ if state.hnsw.get(wanted) and not state.hnsw.get(other):
894
+ return
895
+ if state.hnsw.get(wanted) and state.hnsw.get(other):
896
+ # Both valid (an interrupted switch): searches use the newest, so the wanted one is
897
+ # built again when it is the older.
898
+ newest = await ddl.scalar(
899
+ text(
900
+ "SELECT relname FROM pg_class WHERE relname IN (:a, :b) ORDER BY oid DESC LIMIT 1"
901
+ ),
902
+ {"a": hnsw_index_name(dims, wanted), "b": hnsw_index_name(dims, other)},
903
+ )
904
+ if newest != hnsw_index_name(dims, wanted):
905
+ await ddl.exec_driver_sql(
906
+ f"DROP INDEX CONCURRENTLY IF EXISTS {hnsw_index_name(dims, wanted)}"
907
+ )
908
+ state.hnsw[wanted] = False
909
+ if not state.hnsw.get(wanted):
910
+ name = hnsw_index_name(dims, wanted)
911
+ valid = await ddl.scalar(
912
+ text("SELECT indisvalid FROM pg_index WHERE indexrelid = to_regclass(:i)"),
913
+ {"i": name},
914
+ )
915
+ if valid is False:
916
+ await ddl.exec_driver_sql(f"DROP INDEX CONCURRENTLY IF EXISTS {name}")
917
+ await ddl.exec_driver_sql(hnsw_index_sql(dims, wanted))
918
+ valid = await ddl.scalar(
919
+ text("SELECT indisvalid FROM pg_index WHERE indexrelid = to_regclass(:i)"),
920
+ {"i": hnsw_index_name(dims, wanted)},
921
+ )
922
+ if not valid:
923
+ report.add("embedding index", "failed", f"{wanted} index not valid after the build")
924
+ return
925
+ if state.hnsw.get(other):
926
+ # Searches use the newest valid index (`embeddings.index_precision`, looked up again
927
+ # after PRECISION_TTL_S): wait until every process uses the new one.
928
+ await asyncio.sleep(switch_wait_s)
929
+ await ddl.exec_driver_sql(f"DROP INDEX CONCURRENTLY IF EXISTS {hnsw_index_name(dims, other)}")
930
+ report.add("embedding index", "done", wanted)
931
+
932
+
933
+ async def _compression(
934
+ ddl: AsyncConnection,
935
+ state: StorageState,
936
+ wanted: Compression,
937
+ settings: StorageSettings,
938
+ report: ApplyReport,
939
+ ) -> None:
940
+ changed = 0
941
+ for table, columns in COMPRESSED.items():
942
+ targets = await _partitions(ddl, table)
943
+ if targets != [table]:
944
+ targets = [table, *targets]
945
+ for target in targets:
946
+ for column in columns:
947
+ current = state.compression.get(f"{target}.{column}")
948
+ if current == wanted:
949
+ continue
950
+ try:
951
+ await _ddl(
952
+ ddl,
953
+ f'ALTER TABLE {target} ALTER COLUMN "{column}" SET COMPRESSION {wanted}',
954
+ settings.lock_timeout_ms,
955
+ )
956
+ changed += 1
957
+ except _LockedError:
958
+ report.add("compression", "deferred", "lock not granted; run again")
959
+ return
960
+ report.add("compression", "done" if changed else "skipped", f"{wanted}, {changed} columns")
961
+
962
+
963
+ async def _fillfactor(
964
+ ddl: AsyncConnection,
965
+ state: StorageState,
966
+ wanted: int,
967
+ settings: StorageSettings,
968
+ report: ApplyReport,
969
+ ) -> None:
970
+ if state.records_fillfactor == wanted:
971
+ return
972
+ try:
973
+ for target in await _partitions(ddl, "mat_records"):
974
+ await _ddl(
975
+ ddl,
976
+ f"ALTER TABLE {target} SET (fillfactor = {int(wanted)})",
977
+ settings.lock_timeout_ms,
978
+ )
979
+ report.add("records fillfactor", "done", f"{wanted} (pages written from now on)")
980
+ except _LockedError:
981
+ report.add("records fillfactor", "deferred", "lock not granted; run again")
982
+
983
+
984
+ async def _attribute_indexes(
985
+ engine: AsyncEngine, ddl: AsyncConnection, store: StoreSettings, report: ApplyReport
986
+ ) -> None:
987
+ """Build and drop per-Attribute indexes, concurrently, so every branch of every Domain
988
+ has those the profile keeps (`indexes.pending_index_plan`)."""
989
+ from sqlalchemy.ext.asyncio import AsyncSession
990
+
991
+ from lorebase_store.indexes import pending_index_plan
992
+ from lorebase_store.repositories.scope import scope_to_domain
993
+
994
+ async with engine.connect() as connection:
995
+ domains = [str(d) for d in (await connection.execute(text(_DOMAINS))).scalars()]
996
+ statements: list[str] = []
997
+ for domain_id in domains:
998
+ async with engine.begin() as connection, AsyncSession(bind=connection) as session:
999
+ await scope_to_domain(session, domain_id)
1000
+ statements.extend(
1001
+ await pending_index_plan(
1002
+ session, domain_id, constraints_only=constraints_only(store)
1003
+ )
1004
+ )
1005
+ for statement in statements:
1006
+ await ddl.exec_driver_sql(statement)
1007
+ created = sum(1 for s in statements if s.startswith("CREATE"))
1008
+ dropped = sum(1 for s in statements if s.startswith("DROP"))
1009
+ report.add(
1010
+ "attribute indexes",
1011
+ "done" if statements else "skipped",
1012
+ f"{created} built, {dropped} dropped",
1013
+ len(statements),
1014
+ )
1015
+
1016
+
1017
+ _DOMAINS = "SELECT id FROM domains ORDER BY id"
1018
+
1019
+
1020
+ # Compaction ---------------------------------------------------------------------------------------
1021
+
1022
+
1023
+ async def _vacuum(ddl: AsyncConnection, table: str) -> None:
1024
+ """Vacuum that always removes dead line pointers (it may skip index cleanup when few rows
1025
+ are dead, which would leave the emptied last pages in place) and truncates them."""
1026
+ await ddl.exec_driver_sql(f"VACUUM (ANALYZE, INDEX_CLEANUP ON, TRUNCATE ON) {table}")
1027
+
1028
+
1029
+ _MOVE = (
1030
+ "WITH moved AS (DELETE FROM {table} WHERE ctid >= CAST(:lo AS tid) "
1031
+ "AND ctid < CAST(:hi AS tid) AND domain_id = :d AND branch = :br RETURNING *) "
1032
+ "INSERT INTO {table} SELECT * FROM moved RETURNING (ctid::text::point)[0]"
1033
+ )
1034
+ """Moves the rows of one branch in the pages `[:lo, :hi)` to wherever the free space map
1035
+ points; the pages each row landed on come back."""
1036
+
1037
+ _BRANCHES_IN = (
1038
+ "SELECT DISTINCT domain_id, branch FROM {table} "
1039
+ "WHERE ctid >= CAST(:lo AS tid) AND ctid < CAST(:hi AS tid)"
1040
+ )
1041
+
1042
+ COMPACT_ROUNDS = 3
1043
+ """Passes over a table: each VACUUM gives back the emptied end and records the space freed in
1044
+ the middle, which the next pass can fill."""
1045
+
1046
+
1047
+ async def _move_window(
1048
+ engine: AsyncEngine,
1049
+ table: str,
1050
+ lo: int,
1051
+ hi: int,
1052
+ branches: Sequence[Any],
1053
+ settings: StorageSettings,
1054
+ ) -> int:
1055
+ """Move the rows of pages `[lo, hi)`, one branch at a time; raises `_NoRoomError` when a
1056
+ row would land at `lo` or later (the transaction is rolled back)."""
1057
+ moved = 0
1058
+ for domain_id, branch in branches:
1059
+ async with engine.begin() as connection:
1060
+ await connection.execute(text("SELECT set_config('lorebase.bypass_rls', 'on', true)"))
1061
+ await connection.exec_driver_sql(
1062
+ f"SET LOCAL lock_timeout = '{int(settings.lock_timeout_ms)}ms'"
1063
+ )
1064
+ # Commits of the branch take the same lock: none changes a row in flight.
1065
+ await connection.execute(
1066
+ text("SELECT pg_advisory_xact_lock(:k)"),
1067
+ {"k": advisory_lock_key(str(domain_id), str(branch))},
1068
+ )
1069
+ params = {"lo": (lo, 0), "hi": (hi, 0), "d": domain_id, "br": branch}
1070
+ found = await connection.execute(text(_MOVE.format(table=table)), params)
1071
+ landed = found.scalars().all()
1072
+ if landed and max(landed) >= lo:
1073
+ raise _NoRoomError
1074
+ moved += len(landed)
1075
+ return moved
1076
+
1077
+
1078
+ async def _compact_table(
1079
+ engine: AsyncEngine,
1080
+ ddl: AsyncConnection,
1081
+ table: str,
1082
+ settings: StorageSettings,
1083
+ report: ApplyReport,
1084
+ *,
1085
+ reindex: bool,
1086
+ max_batches: int | None,
1087
+ ) -> None:
1088
+ """Move the rows of the last pages into free space of earlier pages, window by window
1089
+ from the end, until no room is left below the window; then VACUUM truncates the end."""
1090
+ await _vacuum(ddl, table)
1091
+ before = await _pages(ddl, table)
1092
+ moved = batches = 0
1093
+ stalled = ""
1094
+ for _ in range(COMPACT_ROUNDS):
1095
+ pages = await _pages(ddl, table)
1096
+ per_page = await _rows_per_page(ddl, table)
1097
+ window = max(1, int(settings.batch_rows / max(per_page, 1.0)))
1098
+ hi, moved_now = pages, 0
1099
+ while hi > 0:
1100
+ if max_batches is not None and batches >= max_batches:
1101
+ stalled = "batch limit; run again"
1102
+ break
1103
+ lo = max(0, hi - window)
1104
+ found = await ddl.execute(
1105
+ text(_BRANCHES_IN.format(table=table)), {"lo": (lo, 0), "hi": (hi, 0)}
1106
+ )
1107
+ try:
1108
+ moved_now += await _move_window(engine, table, lo, hi, found.all(), settings)
1109
+ except _NoRoomError:
1110
+ break
1111
+ except DBAPIError as error:
1112
+ if sqlstate(error) in (LOCK_NOT_AVAILABLE, DEADLOCK):
1113
+ stalled = "branch lock not granted; run again"
1114
+ break
1115
+ raise
1116
+ batches += 1
1117
+ hi = lo
1118
+ await _vacuum(ddl, table)
1119
+ moved += moved_now
1120
+ if stalled or moved_now == 0 or await _pages(ddl, table) >= pages:
1121
+ break
1122
+ after = await _pages(ddl, table)
1123
+ if reindex:
1124
+ for target in await _partitions(ddl, table):
1125
+ await ddl.exec_driver_sql(f"REINDEX TABLE CONCURRENTLY {target}")
1126
+ detail = f"{before} -> {after} pages, {moved} rows moved" + (f" ({stalled})" if stalled else "")
1127
+ report.add(f"compact {table}", "deferred" if stalled else "done", detail, moved)
1128
+
1129
+
1130
+ async def compact(
1131
+ engine: AsyncEngine,
1132
+ settings: StorageSettings,
1133
+ report: ApplyReport,
1134
+ *,
1135
+ tables: Sequence[str] = COMPACTED,
1136
+ reindex: bool = False,
1137
+ max_batches: int | None = None,
1138
+ ) -> None:
1139
+ """`--compact`: shrink the materialized tables (see the module documentation)."""
1140
+ ddl = await _autocommit(engine)
1141
+ try:
1142
+ for table in tables:
1143
+ for partition in await _partitions(ddl, table):
1144
+ if await _pages(ddl, partition) == 0:
1145
+ continue
1146
+ await _compact_table(
1147
+ engine,
1148
+ ddl,
1149
+ partition,
1150
+ settings,
1151
+ report,
1152
+ reindex=reindex,
1153
+ max_batches=max_batches,
1154
+ )
1155
+ finally:
1156
+ await ddl.close()
1157
+
1158
+
1159
+ async def apply(
1160
+ engine: AsyncEngine,
1161
+ store: StoreSettings,
1162
+ *,
1163
+ compact_tables: bool = False,
1164
+ reindex: bool = False,
1165
+ max_batches: int | None = None,
1166
+ dry_run: bool = False,
1167
+ verify: bool = False,
1168
+ switch_wait_s: float = PRECISION_TTL_S + 1,
1169
+ ) -> ApplyReport:
1170
+ """Bring the database in line with the storage profile (see the module documentation)."""
1171
+ settings = store.storage
1172
+ choices = storage_choices(settings)
1173
+ report = ApplyReport(settings.profile, asdict(choices))
1174
+ dims = store.embeddings.dims
1175
+ ddl = await _autocommit(engine)
1176
+ try:
1177
+ state = await read_state(ddl, dims)
1178
+ if dry_run:
1179
+ for step, have, want in _differences(state, choices):
1180
+ report.add(step, "planned", f"{have} -> {want}")
1181
+ return report
1182
+ shared = choices.lineage_maps == "shared"
1183
+ triggers_ok = await _switch_triggers(ddl, state, shared, settings, report)
1184
+ await _values_index(ddl, state, choices.values_index, settings, report)
1185
+ await _lexicon_index(ddl, state, choices.lexicon_index, report)
1186
+ await _bm25_index(ddl, state, choices.bm25_index, settings, report)
1187
+ await _hnsw(
1188
+ ddl, state, choices.embedding_precision, dims, report, switch_wait_s=switch_wait_s
1189
+ )
1190
+ await _compression(ddl, state, choices.compression, settings, report)
1191
+ await _fillfactor(ddl, state, choices.records_fillfactor, settings, report)
1192
+ await _attribute_indexes(engine, ddl, store, report)
1193
+ finally:
1194
+ await ddl.close()
1195
+ if triggers_ok:
1196
+ await _rewrite_lineage(
1197
+ engine,
1198
+ settings,
1199
+ shared,
1200
+ report,
1201
+ max_batches=max_batches,
1202
+ leftovers=state.lineage_rows,
1203
+ verify=verify,
1204
+ )
1205
+ else:
1206
+ report.add("lineage rows", "deferred", "triggers not switched yet")
1207
+ if compact_tables:
1208
+ # Garbage collection deletes history rows without the branch lock unless it guards
1209
+ # every branch (`gc.guard_branch_locks`): a moved row could then escape its delete.
1210
+ tables = COMPACTED if store.gc.guard_branch_locks else ("mat_records", "mat_links")
1211
+ await compact(
1212
+ engine, settings, report, tables=tables, reindex=reindex, max_batches=max_batches
1213
+ )
1214
+ if choices.prewarm:
1215
+ warmed = await prewarm(engine, settings)
1216
+ report.add(
1217
+ "prewarm",
1218
+ "done" if "error" not in warmed else "skipped",
1219
+ warmed.get("error") or f"{warmed['loaded_mb']} MB into shared buffers",
1220
+ )
1221
+ return report
1222
+
1223
+
1224
+ def _differences(state: StorageState, choices: StorageChoices) -> list[tuple[str, Any, Any]]:
1225
+ shared = choices.lineage_maps == "shared"
1226
+ found: list[tuple[str, Any, Any]] = []
1227
+ for table, enabled in state.lineage_triggers.items():
1228
+ if enabled != shared:
1229
+ found.append((f"lineage trigger {table}", enabled, shared))
1230
+ for table, rows in state.lineage_rows.items():
1231
+ if rows["inline" if shared else "shared"]:
1232
+ found.append(
1233
+ (f"lineage rows {table}", f"{rows} in a 0.1% sample", choices.lineage_maps)
1234
+ )
1235
+ if state.values_index != choices.values_index:
1236
+ found.append(("values index", state.values_index, choices.values_index))
1237
+ if state.lexicon_index != choices.lexicon_index:
1238
+ found.append(("lexicon index", state.lexicon_index, choices.lexicon_index))
1239
+ if state.bm25_index != choices.bm25_index:
1240
+ found.append(("bm25 index", state.bm25_index, choices.bm25_index))
1241
+ have = "half" if state.hnsw.get("half") and not state.hnsw.get("full") else "full"
1242
+ if have != choices.embedding_precision:
1243
+ found.append(("embedding index", have, choices.embedding_precision))
1244
+ off = sorted(k for k, v in state.compression.items() if v != choices.compression)
1245
+ if off:
1246
+ found.append(("compression", f"{len(off)} columns", choices.compression))
1247
+ if state.records_fillfactor != choices.records_fillfactor:
1248
+ found.append(("records fillfactor", state.records_fillfactor, choices.records_fillfactor))
1249
+ return found
1250
+
1251
+
1252
+ # Prewarm ------------------------------------------------------------------------------------------
1253
+
1254
+ HOT_RELATIONS: tuple[tuple[str, str], ...] = (
1255
+ # (relation or pattern, why). Order is priority: small and index pages first, then heaps.
1256
+ ("refs", "branch heads"),
1257
+ ("revisions", "Revisions"),
1258
+ ("mat_schema", "compiled schemas"),
1259
+ ("provenance", "provenance"),
1260
+ ("mat_lineage_maps", "shared lineage maps"),
1261
+ ("mat_lexicon", "word lexicon"),
1262
+ ("index:mat_lexicon", "lexicon indexes"),
1263
+ ("index:mat_lineage_maps", "lineage map key"),
1264
+ ("pkey:mat_records", "Record keys"),
1265
+ ("pkey:mat_links", "Link keys"),
1266
+ ("reverse:mat_links", "incoming Links"),
1267
+ ("search:mat_records", "full-text search"),
1268
+ ("attr:mat_records", "per-Attribute indexes"),
1269
+ ("hnsw:embeddings", "vector search"),
1270
+ ("heap:mat_records", "Records"),
1271
+ ("heap:mat_links", "Links"),
1272
+ ("pkey:mat_history", "history keys"),
1273
+ ("heap:mat_history", "history"),
1274
+ ("heap:embeddings", "vectors"),
1275
+ )
1276
+
1277
+
1278
+ async def _hot_relations(connection: AsyncConnection) -> list[tuple[str, int, str]]:
1279
+ """(relation, bytes, why) in prewarm order; partitions expanded, empty ones skipped."""
1280
+ out: list[tuple[str, int, str]] = []
1281
+
1282
+ async def add(names: Sequence[str], why: str) -> None:
1283
+ for name in names:
1284
+ size = int(
1285
+ await connection.scalar(
1286
+ text("SELECT pg_relation_size(to_regclass(:r))"), {"r": name}
1287
+ )
1288
+ or 0
1289
+ )
1290
+ if size > 0:
1291
+ out.append((name, size, why))
1292
+
1293
+ async def indexes_of(table: str, where: str) -> list[str]:
1294
+ rows = await connection.execute(
1295
+ text(
1296
+ "SELECT ic.relname FROM pg_index x JOIN pg_class ic ON ic.oid = x.indexrelid "
1297
+ "JOIN pg_class t ON t.oid = x.indrelid "
1298
+ "WHERE (t.relname = :t OR t.oid IN (SELECT inhrelid FROM pg_inherits "
1299
+ "WHERE inhparent = to_regclass(:t))) AND ic.relkind = 'i' AND "
1300
+ + where
1301
+ + " ORDER BY ic.relname"
1302
+ ),
1303
+ {"t": table},
1304
+ )
1305
+ return [str(r) for r in rows.scalars()]
1306
+
1307
+ for spec, why in HOT_RELATIONS:
1308
+ kind, _, table = spec.partition(":")
1309
+ if not table:
1310
+ await add(await _partitions(connection, kind), why)
1311
+ elif kind == "heap":
1312
+ await add(await _partitions(connection, table), why)
1313
+ elif kind == "index":
1314
+ await add(await indexes_of(table, "true"), why)
1315
+ elif kind == "pkey":
1316
+ await add(await indexes_of(table, "x.indisprimary"), why)
1317
+ elif kind == "reverse":
1318
+ await add(await indexes_of(table, "ic.relname LIKE '%to_key%'"), why)
1319
+ elif kind == "search":
1320
+ await add(await indexes_of(table, "ic.relname LIKE '%search%'"), why)
1321
+ elif kind == "attr":
1322
+ await add(await indexes_of(table, "ic.relname LIKE 'mat_records_attr_%'"), why)
1323
+ elif kind == "hnsw":
1324
+ await add(await indexes_of(table, "ic.relname LIKE 'embeddings_hnsw_%'"), why)
1325
+ return out
1326
+
1327
+
1328
+ async def prewarm(
1329
+ engine: AsyncEngine, settings: StorageSettings, *, fraction: float | None = None
1330
+ ) -> dict[str, Any]:
1331
+ """Load the hot relations into shared buffers, most valuable first, within the budget."""
1332
+ connection = await _autocommit(engine)
1333
+ try:
1334
+ try:
1335
+ await connection.exec_driver_sql("CREATE EXTENSION IF NOT EXISTS pg_prewarm")
1336
+ except DBAPIError as error:
1337
+ return {"loaded": [], "error": f"pg_prewarm is not available: {error.orig}"}
1338
+ shared = int(
1339
+ await connection.scalar(
1340
+ text(
1341
+ "SELECT setting::bigint * pg_size_bytes(coalesce(unit, '8kB')) "
1342
+ "FROM pg_settings WHERE name = 'shared_buffers'"
1343
+ )
1344
+ )
1345
+ or 0
1346
+ )
1347
+ budget = int(shared * (fraction or settings.prewarm_fraction))
1348
+ loaded: list[dict[str, Any]] = []
1349
+ used = 0
1350
+ for relation, size, why in await _hot_relations(connection):
1351
+ if used >= budget:
1352
+ break
1353
+ blocks = min(size, budget - used) // 8192
1354
+ if blocks <= 0:
1355
+ break
1356
+ count = await connection.scalar(
1357
+ text("SELECT pg_prewarm(to_regclass(:r), 'buffer', 'main', 0, :last)"),
1358
+ {"r": relation, "last": blocks - 1},
1359
+ )
1360
+ used += int(count or 0) * 8192
1361
+ loaded.append(
1362
+ {
1363
+ "relation": relation,
1364
+ "why": why,
1365
+ "mb": round(int(count or 0) * 8192 / 2**20, 1),
1366
+ "complete": blocks * 8192 >= size,
1367
+ }
1368
+ )
1369
+ preload = str(await connection.scalar(text("SHOW shared_preload_libraries")) or "")
1370
+ return {
1371
+ "shared_buffers_mb": round(shared / 2**20),
1372
+ "budget_mb": round(budget / 2**20),
1373
+ "loaded_mb": round(used / 2**20, 1),
1374
+ "autoprewarm": "pg_prewarm" in preload,
1375
+ "loaded": loaded,
1376
+ }
1377
+ finally:
1378
+ await connection.close()
1379
+
1380
+
1381
+ # Status -------------------------------------------------------------------------------------------
1382
+
1383
+
1384
+ async def status(engine: AsyncEngine, store: StoreSettings) -> dict[str, Any]:
1385
+ """Choices, what the database has, open steps and the size of the hot relations."""
1386
+ choices = storage_choices(store.storage)
1387
+ async with engine.connect() as connection:
1388
+ await connection.execute(text("SELECT set_config('lorebase.bypass_rls', 'on', false)"))
1389
+ state = await read_state(connection, store.embeddings.dims)
1390
+ hot = await _hot_relations(connection)
1391
+ sizes = [{"relation": r, "mb": round(b / 2**20, 1), "why": w} for r, b, w in hot]
1392
+ tasks = {
1393
+ str(name): (json.loads(s) if isinstance(s, str) else s)
1394
+ for name, s in await connection.execute(text("SELECT name, state FROM storage_tasks"))
1395
+ }
1396
+ return {
1397
+ "profile": store.storage.profile,
1398
+ "choices": asdict(choices),
1399
+ "state": asdict(state),
1400
+ "pending": [{"step": s, "have": h, "want": w} for s, h, w in _differences(state, choices)],
1401
+ "tasks": tasks,
1402
+ "hot_relations": sizes,
1403
+ "hot_mb": round(sum(b for _, b, _ in hot) / 2**20, 1),
1404
+ }
1405
+
1406
+
1407
+ # Command line -------------------------------------------------------------------------------------
1408
+
1409
+
1410
+ def _parser() -> argparse.ArgumentParser:
1411
+ parser = argparse.ArgumentParser(
1412
+ prog="python -m lorebase_store.storage",
1413
+ description="Storage profile of the database (store.storage).",
1414
+ )
1415
+ commands = parser.add_subparsers(dest="command", required=True)
1416
+ commands.add_parser("status", help="Choices, what the database has, hot relation sizes.")
1417
+ run = commands.add_parser("apply", help="Bring the database in line with the profile.")
1418
+ run.add_argument("--dry-run", action="store_true", help="Only list what would change.")
1419
+ run.add_argument(
1420
+ "--compact", action="store_true", help="Also move rows into free space and truncate."
1421
+ )
1422
+ run.add_argument(
1423
+ "--reindex", action="store_true", help="With --compact: rebuild indexes concurrently."
1424
+ )
1425
+ run.add_argument(
1426
+ "--verify",
1427
+ action="store_true",
1428
+ help="Scan every row of the lineage maps even when a finished pass is recorded.",
1429
+ )
1430
+ run.add_argument(
1431
+ "--max-batches", type=int, default=None, help="Stop after this many batches (resumable)."
1432
+ )
1433
+ warm = commands.add_parser("prewarm", help="Load the hot relations into shared buffers.")
1434
+ warm.add_argument("--fraction", type=float, default=None, help="Share of shared_buffers.")
1435
+ return parser
1436
+
1437
+
1438
+ async def _run(args: argparse.Namespace) -> dict[str, Any]:
1439
+ from lorebase_platform.settings import load_settings
1440
+ from lorebase_store.db import create_engine
1441
+ from lorebase_store.settings import database_settings, store_settings
1442
+
1443
+ loaded = load_settings()
1444
+ database = database_settings(loaded)
1445
+ store = store_settings(loaded)
1446
+ url = None if database.maintenance_dsn is None else database.maintenance_dsn.get_secret_value()
1447
+ engine = create_engine(database, url=url, pooled=False, statement_timeout_ms=0)
1448
+ try:
1449
+ if args.command == "status":
1450
+ return await status(engine, store)
1451
+ if args.command == "prewarm":
1452
+ return await prewarm(engine, store.storage, fraction=args.fraction)
1453
+ report = await apply(
1454
+ engine,
1455
+ store,
1456
+ compact_tables=args.compact,
1457
+ reindex=args.reindex,
1458
+ max_batches=args.max_batches,
1459
+ dry_run=args.dry_run,
1460
+ verify=args.verify,
1461
+ )
1462
+ return report.as_dict()
1463
+ finally:
1464
+ await engine.dispose()
1465
+
1466
+
1467
+ def main(argv: Sequence[str] | None = None) -> int:
1468
+ logging.basicConfig(level=logging.INFO, format="%(message)s")
1469
+ args = _parser().parse_args(argv)
1470
+ result = asyncio.run(_run(args))
1471
+ sys.stdout.write(json.dumps(result, indent=2, default=str) + "\n")
1472
+ if args.command == "apply" and not result.get("complete", True):
1473
+ return 3
1474
+ return 0
1475
+
1476
+
1477
+ if __name__ == "__main__":
1478
+ raise SystemExit(main())