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