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,536 @@
|
|
|
1
|
+
"""Batches of a branch built from its tree, and of a schema change reconciled after a copy
|
|
2
|
+
(`materialize.background`).
|
|
3
|
+
|
|
4
|
+
Each function runs one batch in the caller's transaction and returns the rows it handled
|
|
5
|
+
with the cursor of the next batch (`None`: the phase is done). The caller holds the branch
|
|
6
|
+
lock. They write the branch's own rows (`full_layout`): a branch being built is not read
|
|
7
|
+
through its materialized rows meanwhile, and a reconciled branch is full.
|
|
8
|
+
|
|
9
|
+
A tree build does in pieces what `rebuild.materialize_revision` does in one transaction:
|
|
10
|
+
|
|
11
|
+
- `clear_batch`: deletes rows the branch held before (an overlay detached from its base);
|
|
12
|
+
- `history_batch`: the changesets of the head's first-parent chain, a few Revisions at a
|
|
13
|
+
time (`depth` from the head), then the schema row;
|
|
14
|
+
- `rows_batch`: Records and Links of the head's tree, whole leaves at a time
|
|
15
|
+
(`NodeRepository.scan_from`), with lineage folded from the history, titles and search
|
|
16
|
+
vectors;
|
|
17
|
+
- `derived_batch` and `paths_batch`: every derived value, Derivation by Derivation, and the
|
|
18
|
+
pairs of the materialized paths, start key by start key.
|
|
19
|
+
|
|
20
|
+
A schema change made while an overlay was copied (`apply.apply_changeset`) is reconciled
|
|
21
|
+
after the switch with what a schema change recomputes on a full branch: `prune_batch` drops
|
|
22
|
+
derived values and path Links the schema no longer has, then `derived_batch`, `paths_batch`
|
|
23
|
+
and `search_batch` (titles and search vectors of the Kinds whose settings changed).
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
from __future__ import annotations
|
|
27
|
+
|
|
28
|
+
from collections import defaultdict
|
|
29
|
+
from collections.abc import Sequence
|
|
30
|
+
from dataclasses import dataclass
|
|
31
|
+
from typing import Any
|
|
32
|
+
|
|
33
|
+
from sqlalchemy import bindparam, text
|
|
34
|
+
from sqlalchemy.dialects.postgresql import JSONB
|
|
35
|
+
from sqlalchemy.ext.asyncio import AsyncSession
|
|
36
|
+
|
|
37
|
+
from lorebase_core.expr import ExprLimits
|
|
38
|
+
from lorebase_core.expr.sensitivity import with_tags
|
|
39
|
+
from lorebase_core.model import Schema
|
|
40
|
+
from lorebase_store.bulk import chunks
|
|
41
|
+
from lorebase_store.layout import MAT_HISTORY, MAT_LINKS, MAT_RECORDS, full_layout
|
|
42
|
+
from lorebase_store.materialize.derived import DerivedValues, plan_derivations
|
|
43
|
+
from lorebase_store.materialize.paths import PathLinks
|
|
44
|
+
from lorebase_store.materialize.rebuild import fold_history, link_row, load_schema, record_row
|
|
45
|
+
from lorebase_store.materialize.rows import (
|
|
46
|
+
PartitionType,
|
|
47
|
+
parse_partition,
|
|
48
|
+
record_title,
|
|
49
|
+
search_weights,
|
|
50
|
+
)
|
|
51
|
+
from lorebase_store.materialize.sensitivity import reference_tags
|
|
52
|
+
from lorebase_store.nodestore import NodeRepository
|
|
53
|
+
from lorebase_store.objectstore import PgObjectStore
|
|
54
|
+
from lorebase_store.repositories.materialized import DERIVED_LINK_PREFIX, SchemaRow
|
|
55
|
+
from lorebase_store.repositories.materialized import MaterializedRepository as Rows
|
|
56
|
+
from lorebase_store.repositories.revisions import RevisionRow
|
|
57
|
+
from lorebase_store.runtime import StoreRuntime
|
|
58
|
+
|
|
59
|
+
Cursor = list[Any] | None
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
@dataclass(frozen=True, slots=True)
|
|
63
|
+
class Batch:
|
|
64
|
+
"""What one batch did: rows handled and where the next one starts (`None`: done)."""
|
|
65
|
+
|
|
66
|
+
rows: int
|
|
67
|
+
cursor: Cursor
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def own_rows(
|
|
71
|
+
session: AsyncSession,
|
|
72
|
+
domain_id: str,
|
|
73
|
+
branch: str,
|
|
74
|
+
runtime: StoreRuntime,
|
|
75
|
+
*,
|
|
76
|
+
generation: int | None = None,
|
|
77
|
+
) -> Rows:
|
|
78
|
+
"""The branch's own rows, whatever its ref says (`full_layout`)."""
|
|
79
|
+
return Rows(
|
|
80
|
+
session,
|
|
81
|
+
domain_id,
|
|
82
|
+
branch,
|
|
83
|
+
writes=runtime.settings.writes,
|
|
84
|
+
layout=full_layout(domain_id, branch),
|
|
85
|
+
generation=generation,
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
async def branch_schema(rows: Rows) -> Schema | None:
|
|
90
|
+
"""The schema stored for the branch, if any."""
|
|
91
|
+
stored = await rows.schema()
|
|
92
|
+
if stored is None:
|
|
93
|
+
return None
|
|
94
|
+
# Imported here: the commit package imports the materializer.
|
|
95
|
+
from lorebase_store.commit.schema import load_compiled
|
|
96
|
+
|
|
97
|
+
return load_compiled(stored.compiled)
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
# Tree build ---------------------------------------------------------------------------------
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
async def clear_batch(session: AsyncSession, domain_id: str, branch: str, *, limit: int) -> Batch:
|
|
104
|
+
"""Delete up to `limit` rows the branch holds (Records, Links, history), table by
|
|
105
|
+
table; done when none is left."""
|
|
106
|
+
for table in (MAT_RECORDS, MAT_LINKS, MAT_HISTORY):
|
|
107
|
+
key = ", ".join(c.name for c in table.primary_key.columns)
|
|
108
|
+
deleted = await session.execute(
|
|
109
|
+
text(
|
|
110
|
+
f"DELETE FROM {table.name} WHERE ({key}) IN (SELECT {key} FROM {table.name} "
|
|
111
|
+
"WHERE domain_id = :domain AND branch = :branch LIMIT :limit)"
|
|
112
|
+
),
|
|
113
|
+
{"domain": domain_id, "branch": branch, "limit": limit},
|
|
114
|
+
)
|
|
115
|
+
count = int(getattr(deleted, "rowcount", 0) or 0)
|
|
116
|
+
if count:
|
|
117
|
+
return Batch(count, [])
|
|
118
|
+
return Batch(0, None)
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
_CHAIN_HISTORY = text(
|
|
122
|
+
"""
|
|
123
|
+
WITH RECURSIVE chain(id, depth) AS (
|
|
124
|
+
SELECT CAST(:head AS bytea), 0
|
|
125
|
+
UNION ALL
|
|
126
|
+
SELECT p.parent_id, chain.depth + 1
|
|
127
|
+
FROM chain
|
|
128
|
+
JOIN revision_parents p
|
|
129
|
+
ON p.domain_id = :domain AND p.revision_id = chain.id AND p.position = 0
|
|
130
|
+
),
|
|
131
|
+
sized AS (
|
|
132
|
+
SELECT chain.id, chain.depth, counted.entries,
|
|
133
|
+
sum(counted.entries) OVER (ORDER BY chain.depth) AS upto
|
|
134
|
+
FROM chain
|
|
135
|
+
CROSS JOIN LATERAL (
|
|
136
|
+
SELECT count(*) AS entries FROM changesets c
|
|
137
|
+
WHERE c.domain_id = :domain AND c.revision_id = chain.id
|
|
138
|
+
) counted
|
|
139
|
+
WHERE chain.depth >= :from_depth
|
|
140
|
+
),
|
|
141
|
+
picked AS (
|
|
142
|
+
SELECT id, depth FROM sized WHERE upto - entries < :limit
|
|
143
|
+
),
|
|
144
|
+
inserted AS (
|
|
145
|
+
INSERT INTO mat_history
|
|
146
|
+
(domain_id, branch, partition, element_key, revision_id, at, old_hash, new_hash,
|
|
147
|
+
provenance)
|
|
148
|
+
SELECT :domain, :branch, c.partition, c.element_key, c.revision_id, r.created_at,
|
|
149
|
+
c.old_hash, c.new_hash, c.provenance
|
|
150
|
+
FROM picked
|
|
151
|
+
JOIN revisions r ON r.domain_id = :domain AND r.id = picked.id
|
|
152
|
+
JOIN changesets c ON c.domain_id = :domain AND c.revision_id = picked.id
|
|
153
|
+
ON CONFLICT DO NOTHING
|
|
154
|
+
RETURNING 1
|
|
155
|
+
)
|
|
156
|
+
SELECT (SELECT count(*) FROM inserted), (SELECT max(depth) FROM picked)
|
|
157
|
+
"""
|
|
158
|
+
)
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
async def history_batch(
|
|
162
|
+
session: AsyncSession,
|
|
163
|
+
domain_id: str,
|
|
164
|
+
branch: str,
|
|
165
|
+
head: RevisionRow,
|
|
166
|
+
cursor: Cursor,
|
|
167
|
+
*,
|
|
168
|
+
limit: int,
|
|
169
|
+
runtime: StoreRuntime,
|
|
170
|
+
) -> Batch:
|
|
171
|
+
"""Copy the changesets of the next Revisions of the head's first-parent chain (cursor:
|
|
172
|
+
`[depth]`, 0 being the head) into the branch history, about `limit` entries (at least one
|
|
173
|
+
Revision). When none is left, store the schema of the head's tree."""
|
|
174
|
+
depth = 0 if not cursor else int(cursor[0])
|
|
175
|
+
found = (
|
|
176
|
+
await session.execute(
|
|
177
|
+
_CHAIN_HISTORY,
|
|
178
|
+
{
|
|
179
|
+
"head": head.id,
|
|
180
|
+
"domain": domain_id,
|
|
181
|
+
"branch": branch,
|
|
182
|
+
"from_depth": depth,
|
|
183
|
+
"limit": limit,
|
|
184
|
+
},
|
|
185
|
+
)
|
|
186
|
+
).one()
|
|
187
|
+
inserted, last = int(found[0] or 0), found[1]
|
|
188
|
+
if last is not None:
|
|
189
|
+
return Batch(inserted, [int(last) + 1])
|
|
190
|
+
settings = runtime.settings
|
|
191
|
+
nodes = NodeRepository(
|
|
192
|
+
session, domain_id, writes=settings.writes, batch=settings.nodes.prefetch_batch
|
|
193
|
+
)
|
|
194
|
+
tree = await nodes.load_tree(head.root)
|
|
195
|
+
schema, schema_hash = await load_schema(session, domain_id, tree, runtime=runtime)
|
|
196
|
+
# Imported here: the commit package imports the materializer.
|
|
197
|
+
from lorebase_store.commit.schema import compile_schema
|
|
198
|
+
|
|
199
|
+
await own_rows(session, domain_id, branch, runtime).set_schema(
|
|
200
|
+
SchemaRow(schema_hash, compile_schema(schema))
|
|
201
|
+
)
|
|
202
|
+
return Batch(0, None)
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
async def rows_batch(
|
|
206
|
+
session: AsyncSession,
|
|
207
|
+
domain_id: str,
|
|
208
|
+
branch: str,
|
|
209
|
+
head: RevisionRow,
|
|
210
|
+
cursor: Cursor,
|
|
211
|
+
*,
|
|
212
|
+
limit: int,
|
|
213
|
+
runtime: StoreRuntime,
|
|
214
|
+
) -> Batch:
|
|
215
|
+
"""Write the next Records or Links of the head's tree, whole leaves of one partition at
|
|
216
|
+
a time (cursor: `[partition, leaf path]`)."""
|
|
217
|
+
settings = runtime.settings
|
|
218
|
+
read_batch = settings.materialize.read_batch_rows
|
|
219
|
+
nodes = NodeRepository(
|
|
220
|
+
session, domain_id, writes=settings.writes, batch=settings.nodes.prefetch_batch
|
|
221
|
+
)
|
|
222
|
+
tree = await nodes.load_tree(head.root)
|
|
223
|
+
partitions = [
|
|
224
|
+
p for p in sorted(tree.partitions) if parse_partition(p).type is not PartitionType.SCHEMA
|
|
225
|
+
]
|
|
226
|
+
if cursor:
|
|
227
|
+
partition, after = str(cursor[0]), cursor[1]
|
|
228
|
+
elif partitions:
|
|
229
|
+
partition, after = partitions[0], None
|
|
230
|
+
else:
|
|
231
|
+
return Batch(0, None)
|
|
232
|
+
rows = own_rows(session, domain_id, branch, runtime, generation=head.generation)
|
|
233
|
+
schema = await branch_schema(rows)
|
|
234
|
+
if schema is None:
|
|
235
|
+
raise RuntimeError(f"branch {branch!r} has no schema row: its history is not built")
|
|
236
|
+
tagged = with_tags(schema, await reference_tags(session, domain_id, branch, runtime=runtime))
|
|
237
|
+
objects = PgObjectStore(session, domain_id, writes=settings.writes, batch=read_batch)
|
|
238
|
+
entries, last = await nodes.scan_from(tree.partitions[partition].root, after=after, limit=limit)
|
|
239
|
+
parsed = parse_partition(partition)
|
|
240
|
+
hashes = dict(entries)
|
|
241
|
+
keys = sorted(hashes)
|
|
242
|
+
folded = (
|
|
243
|
+
await fold_history(rows, partition, batch=read_batch, objects=objects, keys=keys)
|
|
244
|
+
if keys
|
|
245
|
+
else {}
|
|
246
|
+
)
|
|
247
|
+
for chunk in chunks(keys, read_batch):
|
|
248
|
+
bodies = await objects.get_many(hashes[key] for key in chunk)
|
|
249
|
+
values = {key: dict(bodies[hashes[key]].body.get("values", {})) for key in chunk}
|
|
250
|
+
if parsed.type is PartitionType.KIND:
|
|
251
|
+
await rows.upsert_records(
|
|
252
|
+
[
|
|
253
|
+
record_row(
|
|
254
|
+
parsed.element_id,
|
|
255
|
+
key,
|
|
256
|
+
values[key],
|
|
257
|
+
hashes[key],
|
|
258
|
+
tagged,
|
|
259
|
+
folded.get(key),
|
|
260
|
+
head,
|
|
261
|
+
)
|
|
262
|
+
for key in chunk
|
|
263
|
+
]
|
|
264
|
+
)
|
|
265
|
+
await rows.refresh_search(
|
|
266
|
+
parsed.element_id,
|
|
267
|
+
search_weights(tagged.kinds.get(parsed.element_id)),
|
|
268
|
+
config=settings.materialize.text_search_config,
|
|
269
|
+
keys=chunk,
|
|
270
|
+
)
|
|
271
|
+
else:
|
|
272
|
+
await rows.upsert_links(
|
|
273
|
+
[
|
|
274
|
+
link_row(parsed.element_id, key, values[key], hashes[key], folded.get(key))
|
|
275
|
+
for key in chunk
|
|
276
|
+
]
|
|
277
|
+
)
|
|
278
|
+
if last is not None:
|
|
279
|
+
return Batch(len(keys), [partition, last])
|
|
280
|
+
following = [p for p in partitions if p > partition]
|
|
281
|
+
return Batch(len(keys), [following[0], None] if following else None)
|
|
282
|
+
|
|
283
|
+
|
|
284
|
+
# Derived values and paths (tree build and reconciliation) -----------------------------------
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
async def _next_keys(
|
|
288
|
+
session: AsyncSession,
|
|
289
|
+
domain_id: str,
|
|
290
|
+
branch: str,
|
|
291
|
+
kind_id: str,
|
|
292
|
+
after: str | None,
|
|
293
|
+
limit: int,
|
|
294
|
+
*,
|
|
295
|
+
path_id: str | None = None,
|
|
296
|
+
) -> list[str]:
|
|
297
|
+
"""The next `limit` keys of a Kind's Records after `after`, with `path_id` also the
|
|
298
|
+
start keys stored for that path (a start Kind changed by a schema change leaves them)."""
|
|
299
|
+
params: dict[str, Any] = {"domain": domain_id, "branch": branch, "kind": kind_id}
|
|
300
|
+
params["limit"] = limit
|
|
301
|
+
# Separate statements with and without a start: a range condition the index serves.
|
|
302
|
+
records = (
|
|
303
|
+
"SELECT key FROM mat_records WHERE domain_id = :domain AND branch = :branch "
|
|
304
|
+
"AND kind_id = :kind"
|
|
305
|
+
)
|
|
306
|
+
if after is not None:
|
|
307
|
+
params["after"] = after
|
|
308
|
+
records += " AND key > :after"
|
|
309
|
+
if path_id is None:
|
|
310
|
+
sql = f"{records} ORDER BY key LIMIT :limit"
|
|
311
|
+
else:
|
|
312
|
+
params["path"] = path_id
|
|
313
|
+
stored = (
|
|
314
|
+
"SELECT from_key FROM mat_links WHERE domain_id = :domain AND branch = :branch "
|
|
315
|
+
"AND relation_id = :path"
|
|
316
|
+
)
|
|
317
|
+
if after is not None:
|
|
318
|
+
stored += " AND from_key > :after"
|
|
319
|
+
sql = (
|
|
320
|
+
f"SELECT key FROM (({records} ORDER BY key LIMIT :limit) UNION "
|
|
321
|
+
f"({stored} ORDER BY from_key LIMIT :limit)) k ORDER BY key LIMIT :limit"
|
|
322
|
+
)
|
|
323
|
+
return [str(key) for (key,) in await session.execute(text(sql), params)]
|
|
324
|
+
|
|
325
|
+
|
|
326
|
+
async def derived_batch(
|
|
327
|
+
session: AsyncSession,
|
|
328
|
+
domain_id: str,
|
|
329
|
+
branch: str,
|
|
330
|
+
head: RevisionRow,
|
|
331
|
+
cursor: Cursor,
|
|
332
|
+
*,
|
|
333
|
+
limit: int,
|
|
334
|
+
runtime: StoreRuntime,
|
|
335
|
+
expression_limits: ExprLimits,
|
|
336
|
+
) -> Batch:
|
|
337
|
+
"""Recompute one Derivation for the next `limit` Records of its Kind (cursor: `[step,
|
|
338
|
+
last key]`, steps in evaluation order), and the search vectors whose values changed."""
|
|
339
|
+
settings = runtime.settings
|
|
340
|
+
if not settings.materialize.derivations_enabled:
|
|
341
|
+
return Batch(0, None)
|
|
342
|
+
rows = own_rows(session, domain_id, branch, runtime)
|
|
343
|
+
schema = await branch_schema(rows)
|
|
344
|
+
if schema is None:
|
|
345
|
+
return Batch(0, None)
|
|
346
|
+
derived = DerivedValues(
|
|
347
|
+
rows,
|
|
348
|
+
schema,
|
|
349
|
+
limits=expression_limits,
|
|
350
|
+
now=head.created_at,
|
|
351
|
+
batch=settings.materialize.read_batch_rows,
|
|
352
|
+
max_incremental=settings.materialize.derivation_max_incremental_records,
|
|
353
|
+
fanout=settings.traverse.path_fanout,
|
|
354
|
+
)
|
|
355
|
+
index, after = (0, None) if not cursor else (int(cursor[0]), cursor[1])
|
|
356
|
+
while index < len(derived.steps):
|
|
357
|
+
step = derived.steps[index]
|
|
358
|
+
keys = await _next_keys(session, domain_id, branch, step.kind.id, after, limit)
|
|
359
|
+
if not keys:
|
|
360
|
+
index, after = index + 1, None
|
|
361
|
+
continue
|
|
362
|
+
changed = await derived.recompute_step(step, keys)
|
|
363
|
+
if changed and step.attribute.searchable:
|
|
364
|
+
tagged = with_tags(
|
|
365
|
+
schema, await reference_tags(session, domain_id, branch, runtime=runtime)
|
|
366
|
+
)
|
|
367
|
+
await rows.refresh_search(
|
|
368
|
+
step.kind.id,
|
|
369
|
+
search_weights(tagged.kinds.get(step.kind.id)),
|
|
370
|
+
config=settings.materialize.text_search_config,
|
|
371
|
+
keys=sorted(changed),
|
|
372
|
+
)
|
|
373
|
+
return Batch(len(keys), [index, keys[-1]])
|
|
374
|
+
return Batch(0, None)
|
|
375
|
+
|
|
376
|
+
|
|
377
|
+
async def paths_batch(
|
|
378
|
+
session: AsyncSession,
|
|
379
|
+
domain_id: str,
|
|
380
|
+
branch: str,
|
|
381
|
+
cursor: Cursor,
|
|
382
|
+
*,
|
|
383
|
+
limit: int,
|
|
384
|
+
runtime: StoreRuntime,
|
|
385
|
+
) -> Batch:
|
|
386
|
+
"""Recompute the pairs of one materialized path for its next `limit` start keys
|
|
387
|
+
(cursor: `[path, last key]`)."""
|
|
388
|
+
settings = runtime.settings
|
|
389
|
+
if not settings.materialize.derivations_enabled:
|
|
390
|
+
return Batch(0, None)
|
|
391
|
+
rows = own_rows(session, domain_id, branch, runtime)
|
|
392
|
+
schema = await branch_schema(rows)
|
|
393
|
+
if schema is None:
|
|
394
|
+
return Batch(0, None)
|
|
395
|
+
paths = PathLinks(
|
|
396
|
+
rows,
|
|
397
|
+
schema,
|
|
398
|
+
fanout=settings.traverse.path_fanout,
|
|
399
|
+
batch=settings.materialize.read_batch_rows,
|
|
400
|
+
max_incremental=settings.materialize.derivation_max_incremental_records,
|
|
401
|
+
)
|
|
402
|
+
index, after = (0, None) if not cursor else (int(cursor[0]), cursor[1])
|
|
403
|
+
while index < len(paths.paths):
|
|
404
|
+
path = paths.paths[index]
|
|
405
|
+
starts = await _next_keys(
|
|
406
|
+
session, domain_id, branch, path.relation.from_kind, after, limit, path_id=path.id
|
|
407
|
+
)
|
|
408
|
+
if not starts:
|
|
409
|
+
index, after = index + 1, None
|
|
410
|
+
continue
|
|
411
|
+
await paths.refresh(path, starts)
|
|
412
|
+
return Batch(len(starts), [index, starts[-1]])
|
|
413
|
+
# Every path is computed with the current fan-out: commits need not recompute them.
|
|
414
|
+
await paths.record_fanout()
|
|
415
|
+
return Batch(0, None)
|
|
416
|
+
|
|
417
|
+
|
|
418
|
+
# Reconciliation -----------------------------------------------------------------------------
|
|
419
|
+
|
|
420
|
+
|
|
421
|
+
_DERIVED_ROWS = (
|
|
422
|
+
"SELECT kind_id, key, derived FROM mat_records "
|
|
423
|
+
"WHERE domain_id = :domain AND branch = :branch AND derived <> CAST('{{}}' AS jsonb){after} "
|
|
424
|
+
"ORDER BY kind_id, key LIMIT :limit"
|
|
425
|
+
)
|
|
426
|
+
_SET_DERIVED = text(
|
|
427
|
+
"UPDATE mat_records SET derived = :derived WHERE domain_id = :domain AND branch = :branch "
|
|
428
|
+
"AND kind_id = :kind AND key = :key"
|
|
429
|
+
).bindparams(bindparam("derived", type_=JSONB))
|
|
430
|
+
_STALE_PATH_LINKS = text(
|
|
431
|
+
"DELETE FROM mat_links WHERE (domain_id, branch, relation_id, from_key, to_key) IN ("
|
|
432
|
+
"SELECT domain_id, branch, relation_id, from_key, to_key FROM mat_links "
|
|
433
|
+
"WHERE domain_id = :domain AND branch = :branch AND starts_with(relation_id, :prefix) "
|
|
434
|
+
"AND NOT (relation_id = ANY(CAST(:keep AS text[]))) LIMIT :limit)"
|
|
435
|
+
)
|
|
436
|
+
|
|
437
|
+
|
|
438
|
+
async def prune_batch(
|
|
439
|
+
session: AsyncSession,
|
|
440
|
+
domain_id: str,
|
|
441
|
+
branch: str,
|
|
442
|
+
cursor: Cursor,
|
|
443
|
+
*,
|
|
444
|
+
limit: int,
|
|
445
|
+
runtime: StoreRuntime,
|
|
446
|
+
expression_limits: ExprLimits,
|
|
447
|
+
) -> Batch:
|
|
448
|
+
"""Drop derived values of Attributes that are no longer derived from the next `limit`
|
|
449
|
+
Records holding any (cursor: `["records", kind, key]`), then Links of path Derivations
|
|
450
|
+
that are gone or no longer materialized (`["links"]`)."""
|
|
451
|
+
rows = own_rows(session, domain_id, branch, runtime)
|
|
452
|
+
schema = await branch_schema(rows)
|
|
453
|
+
if schema is None:
|
|
454
|
+
return Batch(0, None)
|
|
455
|
+
if not cursor or cursor[0] == "records":
|
|
456
|
+
keep: dict[str, set[str]] = defaultdict(set)
|
|
457
|
+
if runtime.settings.materialize.derivations_enabled:
|
|
458
|
+
for step in plan_derivations(schema, expression_limits):
|
|
459
|
+
keep[step.kind.id].add(step.attribute.id)
|
|
460
|
+
params: dict[str, Any] = {"domain": domain_id, "branch": branch, "limit": limit}
|
|
461
|
+
after = ""
|
|
462
|
+
if cursor:
|
|
463
|
+
params.update(kind=cursor[1], key=cursor[2])
|
|
464
|
+
after = " AND (kind_id, key) > (:kind, :key)"
|
|
465
|
+
found = (await session.execute(text(_DERIVED_ROWS.format(after=after)), params)).all()
|
|
466
|
+
if found:
|
|
467
|
+
for kind_id, record_key, values in found:
|
|
468
|
+
kept = {k: v for k, v in dict(values).items() if k in keep.get(kind_id, ())}
|
|
469
|
+
if kept != dict(values):
|
|
470
|
+
await session.execute(
|
|
471
|
+
_SET_DERIVED,
|
|
472
|
+
{
|
|
473
|
+
"derived": kept,
|
|
474
|
+
"domain": domain_id,
|
|
475
|
+
"branch": branch,
|
|
476
|
+
"kind": kind_id,
|
|
477
|
+
"key": record_key,
|
|
478
|
+
},
|
|
479
|
+
)
|
|
480
|
+
last_kind, last_key, _ = found[-1]
|
|
481
|
+
return Batch(len(found), ["records", last_kind, last_key])
|
|
482
|
+
materialized = sorted(p.id for p in schema.path_relations().values() if p.materialized)
|
|
483
|
+
if not runtime.settings.materialize.derivations_enabled:
|
|
484
|
+
materialized = []
|
|
485
|
+
deleted = await session.execute(
|
|
486
|
+
_STALE_PATH_LINKS,
|
|
487
|
+
{
|
|
488
|
+
"domain": domain_id,
|
|
489
|
+
"branch": branch,
|
|
490
|
+
"prefix": DERIVED_LINK_PREFIX,
|
|
491
|
+
"keep": materialized,
|
|
492
|
+
"limit": limit,
|
|
493
|
+
},
|
|
494
|
+
)
|
|
495
|
+
count = int(getattr(deleted, "rowcount", 0) or 0)
|
|
496
|
+
return Batch(count, ["links"] if count else None)
|
|
497
|
+
|
|
498
|
+
|
|
499
|
+
async def search_batch(
|
|
500
|
+
session: AsyncSession,
|
|
501
|
+
domain_id: str,
|
|
502
|
+
branch: str,
|
|
503
|
+
refresh_kinds: Sequence[str],
|
|
504
|
+
cursor: Cursor,
|
|
505
|
+
*,
|
|
506
|
+
limit: int,
|
|
507
|
+
runtime: StoreRuntime,
|
|
508
|
+
) -> Batch:
|
|
509
|
+
"""Recompute titles and search vectors of the next `limit` Records of the Kinds in
|
|
510
|
+
`refresh_kinds` (cursor: `[kind index, last key]`)."""
|
|
511
|
+
rows = own_rows(session, domain_id, branch, runtime)
|
|
512
|
+
schema = await branch_schema(rows)
|
|
513
|
+
if schema is None:
|
|
514
|
+
return Batch(0, None)
|
|
515
|
+
tagged = with_tags(schema, await reference_tags(session, domain_id, branch, runtime=runtime))
|
|
516
|
+
kinds = sorted(refresh_kinds)
|
|
517
|
+
index, after = (0, None) if not cursor else (int(cursor[0]), cursor[1])
|
|
518
|
+
while index < len(kinds):
|
|
519
|
+
kind_id = kinds[index]
|
|
520
|
+
keys = await _next_keys(session, domain_id, branch, kind_id, after, limit)
|
|
521
|
+
if not keys:
|
|
522
|
+
index, after = index + 1, None
|
|
523
|
+
continue
|
|
524
|
+
kind = tagged.kinds.get(kind_id)
|
|
525
|
+
found = await rows.records(kind_id, keys)
|
|
526
|
+
await rows.update_titles(
|
|
527
|
+
kind_id, [(key, record_title(kind, row.values)) for key, row in sorted(found.items())]
|
|
528
|
+
)
|
|
529
|
+
await rows.refresh_search(
|
|
530
|
+
kind_id,
|
|
531
|
+
search_weights(kind),
|
|
532
|
+
config=runtime.settings.materialize.text_search_config,
|
|
533
|
+
keys=keys,
|
|
534
|
+
)
|
|
535
|
+
return Batch(len(keys), [index, keys[-1]])
|
|
536
|
+
return Batch(0, None)
|