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.
- lorebase_store/__init__.py +1 -0
- lorebase_store/_migrations/env.py +106 -0
- lorebase_store/_migrations/script.py.mako +27 -0
- lorebase_store/_migrations/versions/0001_initial_schema.py +1071 -0
- lorebase_store/_migrations/versions/0002_merge_attempt_details.py +74 -0
- lorebase_store/_migrations/versions/0003_webhooks.py +127 -0
- lorebase_store/_migrations/versions/0004_defer_revision_references.py +38 -0
- lorebase_store/_migrations/versions/0005_security_definer_reads.py +396 -0
- lorebase_store/_migrations/versions/0006_api_key_usage.py +48 -0
- lorebase_store/_migrations/versions/0007_document_extraction.py +154 -0
- lorebase_store/_migrations/versions/0008_definer_argument_checks.py +178 -0
- lorebase_store/_migrations/versions/0009_api_key_networks.py +33 -0
- lorebase_store/_migrations/versions/0010_overlay_branches.py +582 -0
- lorebase_store/_migrations/versions/0011_branch_scoped_search_index.py +229 -0
- lorebase_store/_migrations/versions/0012_overlay_neighborhood_lookups.py +185 -0
- lorebase_store/_migrations/versions/0013_web_sessions.py +72 -0
- lorebase_store/_migrations/versions/0014_embedding_jobs.py +70 -0
- lorebase_store/_migrations/versions/0015_append_only_audit_log.py +83 -0
- lorebase_store/_migrations/versions/0016_sealed_webhook_keys.py +55 -0
- lorebase_store/_migrations/versions/0017_background_branch_copies.py +77 -0
- lorebase_store/_migrations/versions/0018_search_lexicon.py +477 -0
- lorebase_store/_migrations/versions/0019_vault_bindings.py +56 -0
- lorebase_store/_migrations/versions/0020_outbox_consumers.py +150 -0
- lorebase_store/_migrations/versions/0021_schema_suggestions.py +114 -0
- lorebase_store/_migrations/versions/0022_read_audit_and_shared_rate_limits.py +133 -0
- lorebase_store/_migrations/versions/0023_agent_trace.py +96 -0
- lorebase_store/_migrations/versions/0024_embedding_template_keys.py +205 -0
- lorebase_store/_migrations/versions/0025_snapshot_refs.py +35 -0
- lorebase_store/_migrations/versions/0026_plain_reverse_link_index.py +112 -0
- lorebase_store/_migrations/versions/0027_vault_rotation_and_tombstones.py +145 -0
- lorebase_store/_migrations/versions/0028_operation_deferred_events.py +71 -0
- lorebase_store/_migrations/versions/0029_agent_trace_sessions.py +108 -0
- lorebase_store/_migrations/versions/0030_path_fanout.py +31 -0
- lorebase_store/_migrations/versions/0031_branch_builds.py +91 -0
- lorebase_store/_migrations/versions/0032_lineage_revisions.py +35 -0
- lorebase_store/_migrations/versions/0033_vault_moved_tokens.py +30 -0
- lorebase_store/_migrations/versions/0034_inference_endpoint_modes.py +73 -0
- lorebase_store/_migrations/versions/0035_search_truncation.py +104 -0
- lorebase_store/_migrations/versions/0036_lineage_maps.py +325 -0
- lorebase_store/_migrations/versions/0037_bm25_search.py +346 -0
- lorebase_store/auth/__init__.py +52 -0
- lorebase_store/auth/api_keys.py +161 -0
- lorebase_store/auth/audit.py +382 -0
- lorebase_store/auth/checkpoints.py +185 -0
- lorebase_store/auth/grants.py +161 -0
- lorebase_store/auth/principals.py +158 -0
- lorebase_store/auth/scope.py +70 -0
- lorebase_store/auth/sessions.py +153 -0
- lorebase_store/auth/unit.py +53 -0
- lorebase_store/auth/usage.py +70 -0
- lorebase_store/blobs.py +73 -0
- lorebase_store/bm25.py +346 -0
- lorebase_store/bulk.py +147 -0
- lorebase_store/bundle.py +1830 -0
- lorebase_store/changefeed.py +261 -0
- lorebase_store/commit/__init__.py +42 -0
- lorebase_store/commit/changes.py +67 -0
- lorebase_store/commit/errors.py +60 -0
- lorebase_store/commit/migrations.py +335 -0
- lorebase_store/commit/pipeline.py +754 -0
- lorebase_store/commit/schema.py +123 -0
- lorebase_store/db.py +58 -0
- lorebase_store/definer.py +50 -0
- lorebase_store/digests.py +99 -0
- lorebase_store/embeddings.py +444 -0
- lorebase_store/endpoint_modes.py +59 -0
- lorebase_store/extraction/__init__.py +61 -0
- lorebase_store/extraction/items.py +187 -0
- lorebase_store/extraction/materialize.py +364 -0
- lorebase_store/extraction/proposal.py +65 -0
- lorebase_store/extraction/review.py +168 -0
- lorebase_store/gc.py +970 -0
- lorebase_store/indexes.py +1033 -0
- lorebase_store/jobs.py +651 -0
- lorebase_store/keyring.py +72 -0
- lorebase_store/layout.py +335 -0
- lorebase_store/materialize/__init__.py +103 -0
- lorebase_store/materialize/apply.py +436 -0
- lorebase_store/materialize/background.py +1184 -0
- lorebase_store/materialize/branch.py +268 -0
- lorebase_store/materialize/build.py +536 -0
- lorebase_store/materialize/derived.py +540 -0
- lorebase_store/materialize/lineage_backfill.py +502 -0
- lorebase_store/materialize/links.py +266 -0
- lorebase_store/materialize/overlay.py +292 -0
- lorebase_store/materialize/paths.py +315 -0
- lorebase_store/materialize/rebuild.py +326 -0
- lorebase_store/materialize/rows.py +153 -0
- lorebase_store/materialize/sensitivity.py +66 -0
- lorebase_store/materialize/snapshot.py +205 -0
- lorebase_store/materialize/stale.py +444 -0
- lorebase_store/materialize/verify.py +321 -0
- lorebase_store/merging/__init__.py +39 -0
- lorebase_store/merging/attempts.py +334 -0
- lorebase_store/merging/lineage.py +123 -0
- lorebase_store/merging/service.py +652 -0
- lorebase_store/migrations.py +133 -0
- lorebase_store/models/__init__.py +136 -0
- lorebase_store/models/base.py +64 -0
- lorebase_store/models/enums.py +132 -0
- lorebase_store/models/governance.py +268 -0
- lorebase_store/models/ingestion.py +288 -0
- lorebase_store/models/materialized.py +277 -0
- lorebase_store/models/proposals.py +195 -0
- lorebase_store/models/queues.py +87 -0
- lorebase_store/models/tracing.py +116 -0
- lorebase_store/models/versioning.py +222 -0
- lorebase_store/models/webhooks.py +96 -0
- lorebase_store/nodestore.py +384 -0
- lorebase_store/objectstore.py +162 -0
- lorebase_store/outbox_events.py +166 -0
- lorebase_store/profiling/__init__.py +58 -0
- lorebase_store/profiling/delimited.py +109 -0
- lorebase_store/profiling/postgres.py +211 -0
- lorebase_store/profiling/record_files.py +127 -0
- lorebase_store/profiling/records.py +92 -0
- lorebase_store/profiling/sql_databases.py +298 -0
- lorebase_store/ratelimit.py +129 -0
- lorebase_store/reads.py +1491 -0
- lorebase_store/replicas.py +434 -0
- lorebase_store/repositories/__init__.py +152 -0
- lorebase_store/repositories/changesets.py +59 -0
- lorebase_store/repositories/deferred_events.py +142 -0
- lorebase_store/repositories/documents.py +305 -0
- lorebase_store/repositories/domains.py +97 -0
- lorebase_store/repositories/events.py +205 -0
- lorebase_store/repositories/index_view.py +489 -0
- lorebase_store/repositories/invocations.py +68 -0
- lorebase_store/repositories/materializations.py +276 -0
- lorebase_store/repositories/materialized.py +1129 -0
- lorebase_store/repositories/merges.py +417 -0
- lorebase_store/repositories/proposal_items.py +199 -0
- lorebase_store/repositories/proposals.py +184 -0
- lorebase_store/repositories/provenance.py +77 -0
- lorebase_store/repositories/refs.py +452 -0
- lorebase_store/repositories/revisions.py +273 -0
- lorebase_store/repositories/scope.py +33 -0
- lorebase_store/repositories/source_runs.py +123 -0
- lorebase_store/repositories/suggestions.py +638 -0
- lorebase_store/runtime.py +87 -0
- lorebase_store/schema_cache.py +50 -0
- lorebase_store/settings.py +879 -0
- lorebase_store/storage.py +1478 -0
- lorebase_store/telemetry.py +142 -0
- lorebase_store/testing.py +108 -0
- lorebase_store/tracing.py +505 -0
- lorebase_store/traverse.py +578 -0
- lorebase_store/vault/__init__.py +50 -0
- lorebase_store/vault/keys.py +216 -0
- lorebase_store/vault/quotes.py +133 -0
- lorebase_store/vault/rotation.py +209 -0
- lorebase_store/vault/settings.py +152 -0
- lorebase_store/vault/store.py +987 -0
- lorebase_store/vault/transfer.py +558 -0
- lorebase_store/webhooks/__init__.py +24 -0
- lorebase_store/webhooks/delivery.py +637 -0
- lorebase_store/webhooks/keys.py +126 -0
- lorebase_store/webhooks/repository.py +142 -0
- lorebase_store/webhooks/settings.py +138 -0
- lorebase_store/webhooks/signing.py +50 -0
- lorebase_store-0.2.3.dist-info/METADATA +13 -0
- lorebase_store-0.2.3.dist-info/RECORD +163 -0
- 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())
|