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,325 @@
|
|
|
1
|
+
"""Shared lineage maps and storage profile state (store schema notes, docs/performance.md).
|
|
2
|
+
|
|
3
|
+
A Record row of `mat_records` held two maps per Attribute, `lineage` (Attribute -> provenance
|
|
4
|
+
id) and `lineage_revisions` (Attribute -> Revision), and a Link row of `mat_links` its
|
|
5
|
+
`lineage`: about half of a Record row, and the same few hundred distinct maps repeated over
|
|
6
|
+
millions of rows. With shared maps a row holds `{"$map": "<hex id>"}` instead, and the map is
|
|
7
|
+
stored once per Domain in `mat_lineage_maps`, keyed by the first 16 bytes of the SHA-256 of
|
|
8
|
+
its canonical text.
|
|
9
|
+
|
|
10
|
+
- `mat_lineage_maps(domain_id, id, map)`: Domain-scoped with row-level security; rows go with
|
|
11
|
+
their Domain. Maps are immutable and never updated; a map no row names any more stays.
|
|
12
|
+
- `lb_lineage(domain, value)`: the map a stored value stands for (the value itself when it
|
|
13
|
+
is not a reference). Every reader of the columns goes through it; a reference whose map is
|
|
14
|
+
missing raises an error instead of reading as empty.
|
|
15
|
+
- `lb_lineage_share(domain, map)`: stores a map and returns its reference; maps smaller than
|
|
16
|
+
a reference, references and non-objects come back unchanged.
|
|
17
|
+
- Row triggers `lb_lineage_maps` (BEFORE INSERT or UPDATE of the map columns) on `mat_records`
|
|
18
|
+
and `mat_links` replace maps by references for every writer (commit pipeline, copies,
|
|
19
|
+
copy-on-write, rebuilds, backfills); a value that already is a reference passes through.
|
|
20
|
+
The storage profile (`store.storage.lineage_maps`) enables them or not; `python -m
|
|
21
|
+
lorebase_store.storage apply` switches them and rewrites the existing rows in batches, in
|
|
22
|
+
either direction.
|
|
23
|
+
- `storage_tasks(name, state)`: progress of the resumable steps of `storage apply`.
|
|
24
|
+
|
|
25
|
+
The triggers start enabled only on a database without materialized Records (a new install):
|
|
26
|
+
on an existing one, processes of the previous version may still run during a rolling upgrade
|
|
27
|
+
and read the columns as maps, so they stay disabled until `storage apply` runs once every
|
|
28
|
+
process has the code of this revision. Existing rows keep their maps until then; readers of
|
|
29
|
+
this revision accept both forms. The
|
|
30
|
+
downgrade writes every map back into its rows (one statement per table) before dropping the
|
|
31
|
+
objects.
|
|
32
|
+
|
|
33
|
+
Revision ID: 0036
|
|
34
|
+
Revises: 0035
|
|
35
|
+
Create Date: 2026-10-23 10:00:00.000000
|
|
36
|
+
"""
|
|
37
|
+
|
|
38
|
+
from __future__ import annotations
|
|
39
|
+
|
|
40
|
+
import time
|
|
41
|
+
from collections.abc import Sequence
|
|
42
|
+
|
|
43
|
+
import sqlalchemy as sa
|
|
44
|
+
from alembic import op
|
|
45
|
+
from sqlalchemy.dialects import postgresql
|
|
46
|
+
from sqlalchemy.exc import DBAPIError
|
|
47
|
+
|
|
48
|
+
revision: str = "0036"
|
|
49
|
+
down_revision: str | Sequence[str] | None = "0035"
|
|
50
|
+
branch_labels: str | Sequence[str] | None = None
|
|
51
|
+
depends_on: str | Sequence[str] | None = None
|
|
52
|
+
|
|
53
|
+
MAPS = "mat_lineage_maps"
|
|
54
|
+
TASKS = "storage_tasks"
|
|
55
|
+
TRIGGER = "lb_lineage_maps"
|
|
56
|
+
GRANTED = "SELECT, INSERT, UPDATE, DELETE"
|
|
57
|
+
REVOKED = "TRUNCATE, REFERENCES, TRIGGER"
|
|
58
|
+
LOCK_NOT_AVAILABLE = "55P03"
|
|
59
|
+
LOCK_RETRY_S = 0.2
|
|
60
|
+
"""Pause before the downgrade asks again for the locks of both tables."""
|
|
61
|
+
|
|
62
|
+
DOMAIN_VISIBLE = (
|
|
63
|
+
"current_setting('lorebase.bypass_rls', true) = 'on' "
|
|
64
|
+
"OR domain_id = ANY (string_to_array(current_setting('lorebase.domain_ids', true), ','))"
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
MAP_KEY = "$map"
|
|
68
|
+
"""Key of a reference; Attribute ids and `$created` never take this form."""
|
|
69
|
+
|
|
70
|
+
MIN_SHARED_BYTES = 64
|
|
71
|
+
"""Maps of at most this many bytes stay in their row: a reference is about as large."""
|
|
72
|
+
|
|
73
|
+
LINEAGE_MISSING = """
|
|
74
|
+
CREATE FUNCTION lb_lineage_missing(p_domain text, p_id text) RETURNS jsonb
|
|
75
|
+
LANGUAGE plpgsql STABLE PARALLEL SAFE AS $fn$
|
|
76
|
+
BEGIN
|
|
77
|
+
RAISE EXCEPTION 'lineage map % of Domain % is missing', p_id, p_domain
|
|
78
|
+
USING ERRCODE = 'XX001';
|
|
79
|
+
END
|
|
80
|
+
$fn$
|
|
81
|
+
"""
|
|
82
|
+
|
|
83
|
+
LINEAGE = f"""
|
|
84
|
+
CREATE FUNCTION lb_lineage(p_domain text, p_value jsonb) RETURNS jsonb
|
|
85
|
+
LANGUAGE sql STABLE PARALLEL SAFE AS $fn$
|
|
86
|
+
SELECT CASE
|
|
87
|
+
WHEN jsonb_typeof(p_value) = 'object' AND p_value ? '{MAP_KEY}' THEN coalesce(
|
|
88
|
+
(SELECT m.map FROM __MAPS__ m
|
|
89
|
+
WHERE m.domain_id = p_domain AND m.id = decode(p_value ->> '{MAP_KEY}', 'hex')),
|
|
90
|
+
__SCHEMA__.lb_lineage_missing(p_domain, p_value ->> '{MAP_KEY}'))
|
|
91
|
+
ELSE p_value
|
|
92
|
+
END
|
|
93
|
+
$fn$
|
|
94
|
+
"""
|
|
95
|
+
|
|
96
|
+
LINEAGE_SHARE = f"""
|
|
97
|
+
CREATE FUNCTION lb_lineage_share(p_domain text, p_map jsonb) RETURNS jsonb
|
|
98
|
+
LANGUAGE plpgsql VOLATILE AS $fn$
|
|
99
|
+
DECLARE
|
|
100
|
+
v_id bytea;
|
|
101
|
+
BEGIN
|
|
102
|
+
IF p_map IS NULL OR jsonb_typeof(p_map) <> 'object' OR p_map ? '{MAP_KEY}'
|
|
103
|
+
OR pg_column_size(p_map) <= {MIN_SHARED_BYTES} THEN
|
|
104
|
+
RETURN p_map;
|
|
105
|
+
END IF;
|
|
106
|
+
v_id := substr(sha256(convert_to(p_map::text, 'UTF8')), 1, 16);
|
|
107
|
+
PERFORM 1 FROM __MAPS__ WHERE domain_id = p_domain AND id = v_id;
|
|
108
|
+
IF NOT FOUND THEN
|
|
109
|
+
INSERT INTO __MAPS__ (domain_id, id, map) VALUES (p_domain, v_id, p_map)
|
|
110
|
+
ON CONFLICT DO NOTHING;
|
|
111
|
+
END IF;
|
|
112
|
+
RETURN jsonb_build_object('{MAP_KEY}', encode(v_id, 'hex'));
|
|
113
|
+
END
|
|
114
|
+
$fn$
|
|
115
|
+
"""
|
|
116
|
+
|
|
117
|
+
RECORD_TRIGGER = """
|
|
118
|
+
CREATE FUNCTION lb_lineage_maps_records() RETURNS trigger
|
|
119
|
+
LANGUAGE plpgsql AS $fn$
|
|
120
|
+
BEGIN
|
|
121
|
+
NEW.lineage := __SCHEMA__.lb_lineage_share(NEW.domain_id, NEW.lineage);
|
|
122
|
+
NEW.lineage_revisions := __SCHEMA__.lb_lineage_share(NEW.domain_id, NEW.lineage_revisions);
|
|
123
|
+
RETURN NEW;
|
|
124
|
+
END
|
|
125
|
+
$fn$
|
|
126
|
+
"""
|
|
127
|
+
|
|
128
|
+
LINK_TRIGGER = """
|
|
129
|
+
CREATE FUNCTION lb_lineage_maps_links() RETURNS trigger
|
|
130
|
+
LANGUAGE plpgsql AS $fn$
|
|
131
|
+
BEGIN
|
|
132
|
+
NEW.lineage := __SCHEMA__.lb_lineage_share(NEW.domain_id, NEW.lineage);
|
|
133
|
+
RETURN NEW;
|
|
134
|
+
END
|
|
135
|
+
$fn$
|
|
136
|
+
"""
|
|
137
|
+
|
|
138
|
+
TABLES = (
|
|
139
|
+
("mat_records", "lb_lineage_maps_records", ("lineage", "lineage_revisions")),
|
|
140
|
+
("mat_links", "lb_lineage_maps_links", ("lineage",)),
|
|
141
|
+
)
|
|
142
|
+
"""(table, trigger function, map columns)."""
|
|
143
|
+
|
|
144
|
+
TRIGGER_FUNCTIONS = ("lb_lineage_maps_records()", "lb_lineage_maps_links()")
|
|
145
|
+
FUNCTIONS = (
|
|
146
|
+
*TRIGGER_FUNCTIONS,
|
|
147
|
+
"lb_lineage_share(text, jsonb)",
|
|
148
|
+
"lb_lineage(text, jsonb)",
|
|
149
|
+
"lb_lineage_missing(text, text)",
|
|
150
|
+
)
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def _attribute(name: str) -> object:
|
|
154
|
+
config = op.get_context().config
|
|
155
|
+
if config is None or name not in config.attributes:
|
|
156
|
+
raise RuntimeError("run this migration through the Lorebase Alembic environment")
|
|
157
|
+
return config.attributes[name]
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def _application_role() -> str | None:
|
|
161
|
+
role: str | None = _attribute("database_settings").application_role # type: ignore[attr-defined]
|
|
162
|
+
return role
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def _shared() -> bool:
|
|
166
|
+
"""Whether the triggers start enabled: on a database without materialized Records (a new
|
|
167
|
+
install, where no process of an earlier version can read the references) when the profile
|
|
168
|
+
shares lineage maps (`store.storage.lineage_maps`, else every profile but `fast`, as
|
|
169
|
+
`lorebase_store.storage.PROFILES` when this was written). An existing install keeps its
|
|
170
|
+
maps inline until `storage apply` runs, once every process reads references."""
|
|
171
|
+
storage = _attribute("store_settings").storage # type: ignore[attr-defined]
|
|
172
|
+
if storage.lineage_maps is not None:
|
|
173
|
+
wanted = bool(storage.lineage_maps == "shared")
|
|
174
|
+
else:
|
|
175
|
+
wanted = bool(storage.profile != "fast")
|
|
176
|
+
if not wanted:
|
|
177
|
+
return False
|
|
178
|
+
op.execute("SELECT set_config('lorebase.bypass_rls', 'on', true)")
|
|
179
|
+
found = op.get_bind().execute(sa.text("SELECT 1 FROM mat_records LIMIT 1")).first()
|
|
180
|
+
op.execute("SELECT set_config('lorebase.bypass_rls', '', true)")
|
|
181
|
+
return found is None
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def _maps_name() -> str:
|
|
185
|
+
"""`mat_lineage_maps` qualified with the schema the migration runs in: the functions run
|
|
186
|
+
with the caller's search_path (`lb_lineage` must stay inlinable, so it has no SET)."""
|
|
187
|
+
name: str = (
|
|
188
|
+
op.get_bind()
|
|
189
|
+
.execute(
|
|
190
|
+
sa.text("SELECT quote_ident(current_schema()) || '.' || quote_ident(:t)"), {"t": MAPS}
|
|
191
|
+
)
|
|
192
|
+
.scalar_one()
|
|
193
|
+
)
|
|
194
|
+
return name
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def _quoted(role: str) -> str:
|
|
198
|
+
quoted: str = op.get_bind().execute(sa.text("SELECT quote_ident(:r)"), {"r": role}).scalar_one()
|
|
199
|
+
return quoted
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def _grant(table: str) -> None:
|
|
203
|
+
op.execute(f"REVOKE ALL ON TABLE {table} FROM PUBLIC")
|
|
204
|
+
role = _application_role()
|
|
205
|
+
if role is not None:
|
|
206
|
+
quoted = _quoted(role)
|
|
207
|
+
op.execute(f"REVOKE {REVOKED} ON TABLE {table} FROM {quoted}")
|
|
208
|
+
op.execute(f"GRANT {GRANTED} ON TABLE {table} TO {quoted}")
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def upgrade() -> None:
|
|
212
|
+
op.create_table(
|
|
213
|
+
MAPS,
|
|
214
|
+
sa.Column("domain_id", sa.Text(), nullable=False),
|
|
215
|
+
sa.Column("id", sa.LargeBinary(), nullable=False),
|
|
216
|
+
sa.Column("map", postgresql.JSONB(astext_type=sa.Text()), nullable=False),
|
|
217
|
+
sa.CheckConstraint("octet_length(id) = 16", name=op.f("ck_mat_lineage_maps_id_h16")),
|
|
218
|
+
sa.ForeignKeyConstraint(
|
|
219
|
+
["domain_id"],
|
|
220
|
+
["domains.id"],
|
|
221
|
+
name=op.f("fk_mat_lineage_maps_domain_id_domains"),
|
|
222
|
+
ondelete="CASCADE",
|
|
223
|
+
),
|
|
224
|
+
sa.PrimaryKeyConstraint("domain_id", "id", name=op.f("pk_mat_lineage_maps")),
|
|
225
|
+
)
|
|
226
|
+
op.execute(f"ALTER TABLE {MAPS} ENABLE ROW LEVEL SECURITY")
|
|
227
|
+
op.execute(f"ALTER TABLE {MAPS} FORCE ROW LEVEL SECURITY")
|
|
228
|
+
op.execute(
|
|
229
|
+
f"CREATE POLICY domain_isolation ON {MAPS} "
|
|
230
|
+
f"USING ({DOMAIN_VISIBLE}) WITH CHECK ({DOMAIN_VISIBLE})"
|
|
231
|
+
)
|
|
232
|
+
_grant(MAPS)
|
|
233
|
+
op.create_table(
|
|
234
|
+
TASKS,
|
|
235
|
+
sa.Column("name", sa.Text(), nullable=False),
|
|
236
|
+
sa.Column(
|
|
237
|
+
"state",
|
|
238
|
+
postgresql.JSONB(astext_type=sa.Text()),
|
|
239
|
+
server_default=sa.text("'{}'::jsonb"),
|
|
240
|
+
nullable=False,
|
|
241
|
+
),
|
|
242
|
+
sa.Column(
|
|
243
|
+
"updated_at",
|
|
244
|
+
sa.DateTime(timezone=True),
|
|
245
|
+
server_default=sa.text("now()"),
|
|
246
|
+
nullable=False,
|
|
247
|
+
),
|
|
248
|
+
sa.PrimaryKeyConstraint("name", name=op.f("pk_storage_tasks")),
|
|
249
|
+
)
|
|
250
|
+
_grant(TASKS)
|
|
251
|
+
maps = _maps_name()
|
|
252
|
+
schema = maps.rsplit(".", 1)[0]
|
|
253
|
+
for statement in (LINEAGE_MISSING, LINEAGE, LINEAGE_SHARE, RECORD_TRIGGER, LINK_TRIGGER):
|
|
254
|
+
op.execute(statement.replace("__MAPS__", maps).replace("__SCHEMA__", schema))
|
|
255
|
+
# Trigger functions are not called directly. The others run as their caller (no SECURITY
|
|
256
|
+
# DEFINER), under its table privileges and row-level security, so every role may call them.
|
|
257
|
+
for signature in TRIGGER_FUNCTIONS:
|
|
258
|
+
op.execute(f"REVOKE ALL ON FUNCTION {signature} FROM PUBLIC")
|
|
259
|
+
shared = _shared()
|
|
260
|
+
for table, function, columns in TABLES:
|
|
261
|
+
op.execute(
|
|
262
|
+
f"CREATE TRIGGER {TRIGGER} BEFORE INSERT OR UPDATE OF {', '.join(columns)} "
|
|
263
|
+
f"ON {table} FOR EACH ROW EXECUTE FUNCTION {function}()"
|
|
264
|
+
)
|
|
265
|
+
if not shared:
|
|
266
|
+
op.execute(f"ALTER TABLE {table} DISABLE TRIGGER {TRIGGER}")
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
def _lock_tables(bind: sa.Connection, tables: Sequence[str]) -> None:
|
|
270
|
+
"""Take the exclusive lock of every table up front (dropping the triggers needs it), in
|
|
271
|
+
the order commits write them (Records, then Links), each wait shorter than
|
|
272
|
+
`deadlock_timeout`: when a commit holding a later table waits for an earlier one, the
|
|
273
|
+
downgrade gives up what it holds before the deadlock check runs and asks again, so the
|
|
274
|
+
commit is never the victim, and neither is the downgrade."""
|
|
275
|
+
deadlock_ms = bind.execute(
|
|
276
|
+
sa.text("SELECT extract(epoch FROM current_setting('deadlock_timeout')::interval) * 1000")
|
|
277
|
+
).scalar_one()
|
|
278
|
+
wait_ms = max(1, int(float(deadlock_ms) / 2))
|
|
279
|
+
previous = bind.execute(sa.text("SELECT current_setting('lock_timeout')")).scalar_one()
|
|
280
|
+
while True:
|
|
281
|
+
try:
|
|
282
|
+
with bind.begin_nested():
|
|
283
|
+
bind.execute(sa.text(f"SET LOCAL lock_timeout = '{wait_ms}ms'"))
|
|
284
|
+
for table in tables:
|
|
285
|
+
bind.execute(sa.text(f"LOCK TABLE {table} IN ACCESS EXCLUSIVE MODE"))
|
|
286
|
+
break
|
|
287
|
+
except DBAPIError as error:
|
|
288
|
+
cause: BaseException | None = error
|
|
289
|
+
while cause is not None and not isinstance(getattr(cause, "sqlstate", None), str):
|
|
290
|
+
cause = getattr(cause, "orig", None) or cause.__cause__
|
|
291
|
+
if cause is None or cause.sqlstate != LOCK_NOT_AVAILABLE: # type: ignore[attr-defined]
|
|
292
|
+
raise
|
|
293
|
+
time.sleep(LOCK_RETRY_S)
|
|
294
|
+
bind.execute(sa.text("SELECT set_config('lock_timeout', :v, true)"), {"v": previous})
|
|
295
|
+
|
|
296
|
+
|
|
297
|
+
def downgrade() -> None:
|
|
298
|
+
op.execute("SELECT set_config('lorebase.bypass_rls', 'on', true)")
|
|
299
|
+
bind = op.get_bind()
|
|
300
|
+
_lock_tables(bind, [table for table, _, _ in TABLES])
|
|
301
|
+
for table, _, columns in TABLES:
|
|
302
|
+
op.execute(f"DROP TRIGGER IF EXISTS {TRIGGER} ON {table}")
|
|
303
|
+
assignments = ", ".join(f"{c} = lb_lineage(domain_id, {c})" for c in columns)
|
|
304
|
+
references = " OR ".join(
|
|
305
|
+
f"(jsonb_typeof({c}) = 'object' AND {c} ? '{MAP_KEY}')" for c in columns
|
|
306
|
+
)
|
|
307
|
+
# Partition by partition: the statement-level triggers of the parent (lexicon) do not
|
|
308
|
+
# run for these rows, whose words do not change.
|
|
309
|
+
partitions = bind.execute(
|
|
310
|
+
sa.text(
|
|
311
|
+
"SELECT c.relname FROM pg_inherits i JOIN pg_class c ON c.oid = i.inhrelid "
|
|
312
|
+
"WHERE i.inhparent = CAST(:t AS regclass) ORDER BY c.relname"
|
|
313
|
+
),
|
|
314
|
+
{"t": table},
|
|
315
|
+
).scalars().all() or [table]
|
|
316
|
+
for partition in partitions:
|
|
317
|
+
op.execute(f"UPDATE {partition} SET {assignments} WHERE {references}")
|
|
318
|
+
left = bind.execute(sa.text(f"SELECT 1 FROM {table} WHERE {references} LIMIT 1")).first()
|
|
319
|
+
if left is not None:
|
|
320
|
+
raise RuntimeError(f"{table} still holds lineage map references; not dropping maps")
|
|
321
|
+
op.execute("SELECT set_config('lorebase.bypass_rls', '', true)")
|
|
322
|
+
for signature in FUNCTIONS:
|
|
323
|
+
op.execute(f"DROP FUNCTION IF EXISTS {signature}")
|
|
324
|
+
op.drop_table(TASKS)
|
|
325
|
+
op.drop_table(MAPS)
|
|
@@ -0,0 +1,346 @@
|
|
|
1
|
+
"""Optional BM25 ranking of lexical search through ParadeDB `pg_search` (docs/search.md#bm25-
|
|
2
|
+
ranking; store schema notes).
|
|
3
|
+
|
|
4
|
+
Nothing here needs the extension: the functions below only name its types and operator inside
|
|
5
|
+
statements they build at run time, so this revision runs, and they exist, on any Postgres. The
|
|
6
|
+
BM25 index itself is built by `python -m lorebase_store.storage apply` with
|
|
7
|
+
`store.storage.bm25_index: true` (`lorebase_store.bm25`), and Keeper ranks with it only with
|
|
8
|
+
`search.backend: bm25` once the index is valid.
|
|
9
|
+
|
|
10
|
+
- `lb_search_terms(search)`: the lexemes of a search vector as whitespace-separated text, each
|
|
11
|
+
repeated at each of its positions and in position order (once when the vector has no
|
|
12
|
+
positions). The BM25 index tokenizes it on whitespace (`pdb.whitespace`, field `terms`), so
|
|
13
|
+
it holds exactly the lexemes full-text search matches (the text search configuration's
|
|
14
|
+
stemming, the searchable Attributes only, sensitive values left out as in the vector), with
|
|
15
|
+
their frequencies and order for phrases.
|
|
16
|
+
- `lb_bm25_query_valid(query)`: whether a JSON search query uses only what the store sends:
|
|
17
|
+
`term` and `phrase` on the field `terms`, `boolean` (`must`, `should`, `must_not`, none
|
|
18
|
+
empty) and `all`, at most 32 levels deep.
|
|
19
|
+
- `lb_search_bm25` and `lb_search_bm25_overlay`: security definer search, with the options,
|
|
20
|
+
checks and grants of revisions 0008 and 0018 (count ceilings, a Domain in the transaction
|
|
21
|
+
scope, a fixed search_path). The caller translates the query as written (`p_match`) and the
|
|
22
|
+
hits that match only through typo corrections (`p_fixed`) into BM25 queries; a query that is
|
|
23
|
+
not translated exactly (`p_*_exact` false: phrases, a negated phrase) is a superset, ranked
|
|
24
|
+
for its first `p_candidates` matches and rechecked against the search vector. A query that
|
|
25
|
+
matches everything but some words (`all`) leaves out rows without a search vector, as `@@`
|
|
26
|
+
does. Each scan is a top-k scan of the index ordered by BM25 score, ties by Kind and key
|
|
27
|
+
(bytewise); `score` is relative to the best row returned (1), so that it does not carry the
|
|
28
|
+
index-wide term statistics the raw BM25 score is built from. On an overlay
|
|
29
|
+
the overlay's own rows and the base's rows as of the fork point are scanned apart; the base's
|
|
30
|
+
scan reads as many more rows as the overlay holds of the searched Kinds (at most
|
|
31
|
+
`p_candidates` more), so the rows it shadows cannot push a match out. `truncated` is true on
|
|
32
|
+
every row when a scan that filled its limit may have left out a row that would rank among
|
|
33
|
+
those returned. When the index cannot serve the call (dropped meanwhile, the library not
|
|
34
|
+
loaded), the functions return one row without a Kind that holds the error (SQLSTATE in
|
|
35
|
+
`key`, message in `title`) instead of failing the caller's transaction; the store then ranks
|
|
36
|
+
the default way.
|
|
37
|
+
|
|
38
|
+
Revision ID: 0037
|
|
39
|
+
Revises: 0036
|
|
40
|
+
Create Date: 2026-10-30 09:00:00.000000
|
|
41
|
+
"""
|
|
42
|
+
|
|
43
|
+
from __future__ import annotations
|
|
44
|
+
|
|
45
|
+
import importlib.util
|
|
46
|
+
from collections.abc import Sequence
|
|
47
|
+
from pathlib import Path
|
|
48
|
+
from types import ModuleType
|
|
49
|
+
|
|
50
|
+
from alembic import op
|
|
51
|
+
|
|
52
|
+
revision: str = "0037"
|
|
53
|
+
down_revision: str | Sequence[str] | None = "0036"
|
|
54
|
+
branch_labels: str | Sequence[str] | None = None
|
|
55
|
+
depends_on: str | Sequence[str] | None = None
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _revision_module(filename: str) -> ModuleType:
|
|
59
|
+
"""An earlier revision file of this directory, loaded for its SQL templates and checks."""
|
|
60
|
+
path = Path(__file__).with_name(filename)
|
|
61
|
+
spec = importlib.util.spec_from_file_location(f"_lorebase_revision_{path.stem}", path)
|
|
62
|
+
if spec is None or spec.loader is None:
|
|
63
|
+
raise RuntimeError(f"cannot load migration {path}")
|
|
64
|
+
module = importlib.util.module_from_spec(spec)
|
|
65
|
+
spec.loader.exec_module(module)
|
|
66
|
+
return module
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
_lexicon = _revision_module("0018_search_lexicon.py")
|
|
70
|
+
_checks = _lexicon._checks
|
|
71
|
+
_reads = _lexicon._reads
|
|
72
|
+
|
|
73
|
+
INDEX = "ix_mat_records_bm25"
|
|
74
|
+
"""Built by `storage apply` (`lorebase_store.bm25.INDEX`); dropped with its functions."""
|
|
75
|
+
|
|
76
|
+
SEARCH_TERMS = """
|
|
77
|
+
CREATE FUNCTION lb_search_terms(p_search tsvector) RETURNS text
|
|
78
|
+
LANGUAGE sql IMMUTABLE PARALLEL SAFE
|
|
79
|
+
BEGIN ATOMIC
|
|
80
|
+
SELECT pg_catalog.string_agg(u.lexeme, ' ' ORDER BY p.position, u.lexeme COLLATE "C")
|
|
81
|
+
FROM pg_catalog.unnest(p_search) AS u(lexeme, positions, weights)
|
|
82
|
+
CROSS JOIN LATERAL pg_catalog.unnest(
|
|
83
|
+
CASE WHEN coalesce(pg_catalog.cardinality(u.positions), 0)
|
|
84
|
+
OPERATOR(pg_catalog.=) 0
|
|
85
|
+
THEN CAST(ARRAY[0] AS pg_catalog.int2[]) ELSE u.positions END) AS p(position);
|
|
86
|
+
END
|
|
87
|
+
"""
|
|
88
|
+
"""The indexed text of a search vector. A standard SQL body: names resolve when it is created,
|
|
89
|
+
never through a caller's path."""
|
|
90
|
+
|
|
91
|
+
QUERY_VALID = """
|
|
92
|
+
CREATE FUNCTION lb_bm25_query_valid(p_query jsonb, p_depth integer DEFAULT 0) RETURNS boolean
|
|
93
|
+
LANGUAGE plpgsql IMMUTABLE PARALLEL SAFE
|
|
94
|
+
SET search_path = {search_path}
|
|
95
|
+
AS $fn$
|
|
96
|
+
DECLARE
|
|
97
|
+
v_key text;
|
|
98
|
+
v_value jsonb;
|
|
99
|
+
v_clause text;
|
|
100
|
+
v_items jsonb;
|
|
101
|
+
BEGIN
|
|
102
|
+
IF p_query IS NULL OR p_depth > 32 OR jsonb_typeof(p_query) <> 'object'
|
|
103
|
+
OR (SELECT count(*) FROM jsonb_object_keys(p_query)) <> 1 THEN
|
|
104
|
+
RETURN false;
|
|
105
|
+
END IF;
|
|
106
|
+
SELECT e.key, e.value INTO v_key, v_value FROM jsonb_each(p_query) AS e;
|
|
107
|
+
IF v_key = 'all' THEN
|
|
108
|
+
RETURN v_value = 'null'::jsonb;
|
|
109
|
+
ELSIF v_key = 'term' THEN
|
|
110
|
+
RETURN jsonb_typeof(v_value) = 'object'
|
|
111
|
+
AND ARRAY(SELECT k FROM jsonb_object_keys(v_value) AS k ORDER BY 1)
|
|
112
|
+
= ARRAY['field', 'value']
|
|
113
|
+
AND v_value -> 'field' = '"terms"'::jsonb
|
|
114
|
+
AND jsonb_typeof(v_value -> 'value') = 'string';
|
|
115
|
+
ELSIF v_key = 'phrase' THEN
|
|
116
|
+
RETURN jsonb_typeof(v_value) = 'object'
|
|
117
|
+
AND ARRAY(SELECT k FROM jsonb_object_keys(v_value) AS k ORDER BY 1)
|
|
118
|
+
= ARRAY['field', 'phrases', 'slop']
|
|
119
|
+
AND v_value -> 'field' = '"terms"'::jsonb
|
|
120
|
+
AND jsonb_typeof(v_value -> 'phrases') = 'array'
|
|
121
|
+
AND jsonb_array_length(v_value -> 'phrases') BETWEEN 1 AND 64
|
|
122
|
+
AND NOT EXISTS (SELECT 1 FROM jsonb_array_elements(v_value -> 'phrases') AS p(x)
|
|
123
|
+
WHERE jsonb_typeof(p.x) <> 'string')
|
|
124
|
+
AND v_value -> 'slop' IN ('0'::jsonb, '1'::jsonb);
|
|
125
|
+
ELSIF v_key <> 'boolean' OR jsonb_typeof(v_value) <> 'object'
|
|
126
|
+
OR NOT EXISTS (SELECT 1 FROM jsonb_object_keys(v_value)) THEN
|
|
127
|
+
RETURN false;
|
|
128
|
+
END IF;
|
|
129
|
+
FOR v_clause, v_items IN SELECT e.key, e.value FROM jsonb_each(v_value) AS e LOOP
|
|
130
|
+
IF v_clause NOT IN ('must', 'should', 'must_not') OR jsonb_typeof(v_items) <> 'array'
|
|
131
|
+
OR jsonb_array_length(v_items) NOT BETWEEN 1 AND 256 THEN
|
|
132
|
+
RETURN false;
|
|
133
|
+
END IF;
|
|
134
|
+
IF EXISTS (SELECT 1 FROM jsonb_array_elements(v_items) AS i(x)
|
|
135
|
+
WHERE NOT lb_bm25_query_valid(i.x, p_depth + 1)) THEN
|
|
136
|
+
RETURN false;
|
|
137
|
+
END IF;
|
|
138
|
+
END LOOP;
|
|
139
|
+
RETURN true;
|
|
140
|
+
END
|
|
141
|
+
$fn$
|
|
142
|
+
"""
|
|
143
|
+
"""The queries `lorebase_store.bm25` builds and nothing else: the definer functions refuse any
|
|
144
|
+
other (another field, another query type of the extension), whoever calls them."""
|
|
145
|
+
|
|
146
|
+
LB_SEARCH_BM25 = """
|
|
147
|
+
CREATE FUNCTION {name}(
|
|
148
|
+
p_domain text, p_branch text, p_kinds text[], p_config regconfig, p_query text,
|
|
149
|
+
p_candidates integer, p_limit integer, p_match jsonb, p_match_exact boolean,
|
|
150
|
+
p_words text[] DEFAULT NULL, p_replacements text[] DEFAULT NULL, p_weight real DEFAULT 1,
|
|
151
|
+
p_fixed jsonb DEFAULT NULL, p_fixed_exact boolean DEFAULT true)
|
|
152
|
+
RETURNS TABLE (kind_id text, key text, title text, score real, exact boolean,
|
|
153
|
+
truncated boolean)
|
|
154
|
+
{{options}}
|
|
155
|
+
AS $fn$
|
|
156
|
+
DECLARE
|
|
157
|
+
v_kinds text := '';
|
|
158
|
+
v_ts tsquery;
|
|
159
|
+
v_fix tsquery;
|
|
160
|
+
v_word text;
|
|
161
|
+
v_replacements text[];
|
|
162
|
+
v_base text;
|
|
163
|
+
v_generation bigint;
|
|
164
|
+
v_shadow bigint := 0;
|
|
165
|
+
v_halves text[] := CAST(ARRAY[] AS text[]);
|
|
166
|
+
v_rows text[] := CAST(ARRAY[] AS text[]);
|
|
167
|
+
v_stats text[] := CAST(ARRAY[] AS text[]);
|
|
168
|
+
v_arm integer;
|
|
169
|
+
v_half integer;
|
|
170
|
+
v_n integer := 0;
|
|
171
|
+
v_exact boolean;
|
|
172
|
+
v_label text;
|
|
173
|
+
v_param text;
|
|
174
|
+
v_recheck text;
|
|
175
|
+
v_branch text;
|
|
176
|
+
v_filter text;
|
|
177
|
+
v_limit bigint;
|
|
178
|
+
v_present text;
|
|
179
|
+
v_state text;
|
|
180
|
+
v_message text;
|
|
181
|
+
BEGIN
|
|
182
|
+
IF cardinality(p_words) IS DISTINCT FROM cardinality(p_replacements) THEN
|
|
183
|
+
RAISE EXCEPTION 'p_words and p_replacements must have the same length'
|
|
184
|
+
USING ERRCODE = '22023';
|
|
185
|
+
END IF;
|
|
186
|
+
IF p_weight IS NULL OR p_weight < 0 THEN
|
|
187
|
+
RAISE EXCEPTION 'p_weight must not be negative' USING ERRCODE = '22023';
|
|
188
|
+
END IF;
|
|
189
|
+
IF p_match_exact IS NULL OR p_fixed_exact IS NULL OR NOT lb_bm25_query_valid(p_match)
|
|
190
|
+
OR (p_fixed IS NOT NULL AND NOT lb_bm25_query_valid(p_fixed)) THEN
|
|
191
|
+
RAISE EXCEPTION 'p_match and p_fixed must be search term queries'
|
|
192
|
+
USING ERRCODE = '22023';
|
|
193
|
+
END IF;
|
|
194
|
+
IF p_kinds IS NOT NULL THEN
|
|
195
|
+
v_kinds := ' AND m.kind_id = ANY ($3)';
|
|
196
|
+
END IF;
|
|
197
|
+
v_ts := websearch_to_tsquery(p_config, p_query);
|
|
198
|
+
v_fix := v_ts;
|
|
199
|
+
-- As lb_search_corrected: each corrected lexeme becomes (lexeme | replacement | ...).
|
|
200
|
+
FOR v_word, v_replacements IN
|
|
201
|
+
SELECT t.w, array_agg(t.r ORDER BY t.n)
|
|
202
|
+
FROM unnest(p_words, p_replacements) WITH ORDINALITY AS t(w, r, n)
|
|
203
|
+
GROUP BY t.w ORDER BY t.w
|
|
204
|
+
LOOP
|
|
205
|
+
v_fix := ts_rewrite(v_fix, lb_lexeme_query(v_word),
|
|
206
|
+
lb_lexeme_query(v_word, v_replacements));
|
|
207
|
+
END LOOP;
|
|
208
|
+
{{resolve}}
|
|
209
|
+
-- Arm 1: the query as written; arm 2: matches only through a correction. Each arm scans
|
|
210
|
+
-- the branch (half 1), or on an overlay its own rows (half 1) and the base's (half 2).
|
|
211
|
+
FOR v_arm IN 1..2 LOOP
|
|
212
|
+
CONTINUE WHEN v_arm = 2 AND (p_fixed IS NULL OR v_fix = v_ts);
|
|
213
|
+
v_exact := CASE WHEN v_arm = 1 THEN p_match_exact ELSE p_fixed_exact END;
|
|
214
|
+
v_param := CASE WHEN v_arm = 1 THEN '$6' ELSE '$7' END;
|
|
215
|
+
v_label := CASE WHEN v_arm = 1 THEN 'true' ELSE 'false' END;
|
|
216
|
+
-- `all` matches rows without a search vector too; `search @@` never does.
|
|
217
|
+
v_present := CASE WHEN jsonb_path_exists(CASE WHEN v_arm = 1 THEN p_match
|
|
218
|
+
ELSE p_fixed END, '$.**."all"')
|
|
219
|
+
THEN ' AND m.search IS NOT NULL' ELSE '' END;
|
|
220
|
+
v_recheck := CASE WHEN v_exact THEN 'true'
|
|
221
|
+
WHEN v_arm = 1 THEN 'h.search @@ $4'
|
|
222
|
+
ELSE 'h.search @@ $5 AND NOT h.search @@ $4' END;
|
|
223
|
+
FOR v_half IN 1..CASE WHEN v_base IS NULL THEN 1 ELSE 2 END LOOP
|
|
224
|
+
v_n := v_n + 1;
|
|
225
|
+
v_limit := CASE WHEN v_exact THEN p_limit ELSE p_candidates END;
|
|
226
|
+
v_filter := v_recheck;
|
|
227
|
+
IF v_base IS NULL THEN
|
|
228
|
+
v_branch := format('m.branch = %L', p_branch);
|
|
229
|
+
ELSIF v_half = 1 THEN
|
|
230
|
+
v_branch := format('m.branch = %L AND NOT m.deleted', p_branch);
|
|
231
|
+
ELSE
|
|
232
|
+
-- Base rows written at or before the fork, while the ref is still that
|
|
233
|
+
-- overlay, that the overlay does not shadow (as lb_overlay_rows): the scan
|
|
234
|
+
-- reads one more row for each row the overlay holds of these Kinds.
|
|
235
|
+
v_branch := format('m.branch = %L AND m.revision_generation <= %s',
|
|
236
|
+
v_base, v_generation);
|
|
237
|
+
v_limit := v_limit + least(v_shadow, p_candidates);
|
|
238
|
+
v_filter := v_filter || format(
|
|
239
|
+
' AND EXISTS (SELECT 1 FROM refs f WHERE f.domain_id = %1$L '
|
|
240
|
+
'AND f.name = %2$L AND f.base_branch = %3$L AND f.base_generation = %4$s '
|
|
241
|
+
'OFFSET 0) AND NOT EXISTS (SELECT 1 FROM mat_records x '
|
|
242
|
+
'WHERE x.domain_id = %1$L AND x.branch = %2$L AND x.kind_id = h.kind_id '
|
|
243
|
+
'AND x.key = h.key OFFSET 0)',
|
|
244
|
+
p_domain, p_branch, v_base, v_generation);
|
|
245
|
+
END IF;
|
|
246
|
+
v_halves := v_halves || format(
|
|
247
|
+
'h%1$s AS MATERIALIZED (SELECT m.kind_id, m.key, m.title, %2$s AS search, '
|
|
248
|
+
'CAST(pdb.score(m.key) AS real) AS raw FROM mat_records m '
|
|
249
|
+
'WHERE m.domain_id = %3$L AND %4$s%5$s%8$s '
|
|
250
|
+
'AND m.key OPERATOR(pg_catalog.@@@) CAST(%6$s AS paradedb.searchqueryinput) '
|
|
251
|
+
'ORDER BY pdb.score(m.key) DESC, m.kind_id COLLATE "C", m.key COLLATE "C" '
|
|
252
|
+
'LIMIT %7$s)',
|
|
253
|
+
v_n, CASE WHEN v_exact THEN 'CAST(NULL AS tsvector)' ELSE 'm.search' END,
|
|
254
|
+
p_domain, v_branch, v_kinds, v_param, v_limit, v_present);
|
|
255
|
+
v_rows := v_rows || format(
|
|
256
|
+
'SELECT h.kind_id, h.key, h.title, h.raw, %1$s AS exact FROM h%2$s h '
|
|
257
|
+
'WHERE %3$s', v_label, v_n, v_filter);
|
|
258
|
+
v_stats := v_stats || format(
|
|
259
|
+
'SELECT %1$s AS exact, count(*) >= %2$s AS filled, min(h.raw) AS floor '
|
|
260
|
+
'FROM h%3$s h', v_label, v_limit, v_n);
|
|
261
|
+
END LOOP;
|
|
262
|
+
END LOOP;
|
|
263
|
+
-- A scan that filled its limit may have left out rows scoring at most its last one: the
|
|
264
|
+
-- result is cut when such a row would rank among those returned (fewer rows than asked
|
|
265
|
+
-- at or above its arm, or a returned row of its arm scoring below that last one).
|
|
266
|
+
BEGIN
|
|
267
|
+
RETURN QUERY EXECUTE
|
|
268
|
+
'WITH ' || array_to_string(v_halves, ', ')
|
|
269
|
+
|| ', r AS (' || array_to_string(v_rows, ' UNION ALL ') || ')'
|
|
270
|
+
|| ', s AS (' || array_to_string(v_stats, ' UNION ALL ') || ')'
|
|
271
|
+
|| ', t AS (SELECT r.kind_id, r.key, r.title, r.raw, r.exact, '
|
|
272
|
+
|| 'CAST(CASE WHEN r.exact THEN r.raw ELSE $8 * r.raw END AS real) AS score FROM r '
|
|
273
|
+
|| 'ORDER BY r.exact DESC, score DESC, r.raw DESC, r.kind_id COLLATE "C", '
|
|
274
|
+
|| 'r.key COLLATE "C" LIMIT $9) '
|
|
275
|
+
|| 'SELECT t.kind_id, t.key, t.title, '
|
|
276
|
+
|| 'CAST(coalesce(t.score / nullif(max(t.score) OVER (), 0), 0) AS real), t.exact, '
|
|
277
|
+
|| 'EXISTS (SELECT 1 FROM s WHERE s.filled AND ((SELECT count(*) FROM t x '
|
|
278
|
+
|| 'WHERE x.exact OR NOT s.exact) < $9 '
|
|
279
|
+
|| 'OR (SELECT min(x.raw) FROM t x WHERE x.exact = s.exact) < s.floor)) '
|
|
280
|
+
|| 'FROM t ORDER BY t.exact DESC, t.score DESC, t.raw DESC, t.kind_id COLLATE "C", '
|
|
281
|
+
|| 't.key COLLATE "C"'
|
|
282
|
+
USING p_domain, p_branch, p_kinds, v_ts, v_fix, p_match, p_fixed, p_weight, p_limit;
|
|
283
|
+
EXCEPTION WHEN OTHERS THEN
|
|
284
|
+
-- The index cannot serve the call (dropped since the caller looked, the library not
|
|
285
|
+
-- loaded, a query the extension refuses): a row without a Kind, holding the error,
|
|
286
|
+
-- tells the caller to rank the default way. Cancellation is not caught (OTHERS).
|
|
287
|
+
GET STACKED DIAGNOSTICS v_state = RETURNED_SQLSTATE, v_message = MESSAGE_TEXT;
|
|
288
|
+
RETURN QUERY SELECT CAST(NULL AS text), v_state, v_message, CAST(NULL AS real),
|
|
289
|
+
CAST(NULL AS boolean), CAST(NULL AS boolean);
|
|
290
|
+
END;
|
|
291
|
+
END
|
|
292
|
+
$fn$
|
|
293
|
+
"""
|
|
294
|
+
|
|
295
|
+
_RESOLVE_OVERLAY = """
|
|
296
|
+
SELECT r.base_branch, r.base_generation INTO v_base, v_generation FROM refs r
|
|
297
|
+
WHERE r.domain_id = p_domain AND r.name = p_branch AND r.materialization = 'overlay';
|
|
298
|
+
IF v_base IS NOT NULL THEN
|
|
299
|
+
EXECUTE 'SELECT count(*) FROM (SELECT 1 FROM mat_records x WHERE x.domain_id = $1 '
|
|
300
|
+
'AND x.branch = $2 AND ($3 IS NULL OR x.kind_id = ANY ($3)) LIMIT $4) AS s'
|
|
301
|
+
INTO v_shadow USING p_domain, p_branch, p_kinds, p_candidates + 1;
|
|
302
|
+
END IF;"""
|
|
303
|
+
"""The overlay variant finds the base and the generation it forked at (a branch that is no
|
|
304
|
+
longer an overlay is read as a full one) and counts the overlay's rows of the searched Kinds,
|
|
305
|
+
tombstones included: each can shadow one base row."""
|
|
306
|
+
|
|
307
|
+
PARALLEL = "\nSET max_parallel_workers_per_gather = 0"
|
|
308
|
+
"""A top-k scan of a few rows costs less than starting workers: with them the planner picked a
|
|
309
|
+
parallel scan of the overlay's rows that took 19 ms instead of 1 (1M Records)."""
|
|
310
|
+
|
|
311
|
+
SEARCH = (
|
|
312
|
+
("lb_search_bm25", ""),
|
|
313
|
+
("lb_search_bm25_overlay", _RESOLVE_OVERLAY),
|
|
314
|
+
)
|
|
315
|
+
ARGUMENTS = (
|
|
316
|
+
"(text, text, text[], regconfig, text, integer, integer, jsonb, boolean, text[], text[], "
|
|
317
|
+
"real, jsonb, boolean)"
|
|
318
|
+
)
|
|
319
|
+
SIGNATURES = tuple(f"{name}{ARGUMENTS}" for name, _ in SEARCH)
|
|
320
|
+
HELPERS = ("lb_bm25_query_valid(jsonb, integer)", "lb_search_terms(tsvector)")
|
|
321
|
+
|
|
322
|
+
|
|
323
|
+
def upgrade() -> None:
|
|
324
|
+
role = _reads._application_role()
|
|
325
|
+
if role is not None:
|
|
326
|
+
_checks._refuse_writable_schemas(role)
|
|
327
|
+
search_path = _reads._search_path()
|
|
328
|
+
op.execute(SEARCH_TERMS)
|
|
329
|
+
op.execute(QUERY_VALID.format(search_path=search_path))
|
|
330
|
+
options = _reads._OPTIONS.format(search_path=search_path).strip() + PARALLEL
|
|
331
|
+
for name, resolve in SEARCH:
|
|
332
|
+
guards = _checks._guards(search_path, ("p_candidates", "p_limit"))
|
|
333
|
+
template = LB_SEARCH_BM25.format(name=name)
|
|
334
|
+
statement = _checks._create_or_replace(template, guards)
|
|
335
|
+
op.execute(statement.format(options=options, resolve=resolve.strip()))
|
|
336
|
+
_lexicon._grant(SIGNATURES)
|
|
337
|
+
|
|
338
|
+
|
|
339
|
+
def downgrade() -> None:
|
|
340
|
+
op.execute(f"DROP INDEX IF EXISTS {INDEX}")
|
|
341
|
+
for signature in reversed(SIGNATURES):
|
|
342
|
+
op.execute(f"DROP FUNCTION IF EXISTS {signature}")
|
|
343
|
+
op.execute(f"DROP FUNCTION IF EXISTS {HELPERS[0]}")
|
|
344
|
+
# CASCADE: partition indexes of an interrupted build, never attached to INDEX, use it too.
|
|
345
|
+
# The extension stays; an administrator drops it.
|
|
346
|
+
op.execute(f"DROP FUNCTION IF EXISTS {HELPERS[1]} CASCADE")
|