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,315 @@
|
|
|
1
|
+
"""Materialized path Derivations (design 5.8): the Links of a virtual Relation in `mat_links`.
|
|
2
|
+
|
|
3
|
+
A path Derivation marked `materialized` keeps its Links in `mat_links` under the Derivation
|
|
4
|
+
id as `relation_id` (`DERIVED_LINK_PREFIX`), with no values and a digest of the pair as
|
|
5
|
+
`object_hash`; they are not tree objects, so they take no part in hashing, diff, merge or the
|
|
6
|
+
tree comparison of verification. Readers then use them like stored Links (reverse index
|
|
7
|
+
included); paths that are not materialized are composed at read time
|
|
8
|
+
(`lorebase_keeper.reading.compiler`). Both follow `lorebase_core.model.paths`: every Link of
|
|
9
|
+
the first step, at most `fanout` Links (smallest far keys) per intermediate Record later.
|
|
10
|
+
|
|
11
|
+
`PathLinks` keeps them current in the commit transaction, after derived values:
|
|
12
|
+
|
|
13
|
+
- `recompute_all` rewrites every materialized path (rebuilds, schema changes); the fan-out
|
|
14
|
+
used is recorded in `mat_schema.path_fanout` (`record_fanout`);
|
|
15
|
+
- `fanout_stale` tells whether the recorded fan-out differs from `store.traverse.path_fanout`
|
|
16
|
+
while materialized paths or derived values reading a path depend on it: commits check it
|
|
17
|
+
before their incremental update and then recompute both whole, and `refresh_stale_paths`
|
|
18
|
+
does the same for every branch (the job workers run it in the background when they start,
|
|
19
|
+
`run_path_fanout_sweep`; branches with nothing depending on the fan-out only record it);
|
|
20
|
+
- `recompute` rewrites the pairs of the start Records a changeset can affect. A Link of step
|
|
21
|
+
`i` that changed (for a foreign-key step: a `from` Record written or removed) marks its
|
|
22
|
+
near Record at level `i`; the start Records reaching a marked Record, found by walking the
|
|
23
|
+
steps backwards without bound, are recomputed from scratch. Above
|
|
24
|
+
`store.materialize.derivation_max_incremental_records` start Records the path is rewritten
|
|
25
|
+
whole.
|
|
26
|
+
|
|
27
|
+
Writes go through `MaterializedRepository`, so overlays copy and tombstone path Links like
|
|
28
|
+
any other row. With `write=False` nothing is written and `verify` compares the pairs it
|
|
29
|
+
computes (`expected`) with the stored ones.
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
from __future__ import annotations
|
|
33
|
+
|
|
34
|
+
import asyncio
|
|
35
|
+
import hashlib
|
|
36
|
+
import logging
|
|
37
|
+
from collections.abc import AsyncIterator, Sequence
|
|
38
|
+
|
|
39
|
+
from sqlalchemy import text
|
|
40
|
+
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
|
|
41
|
+
|
|
42
|
+
from lorebase_core.expr.sensitivity import with_tags
|
|
43
|
+
from lorebase_core.model import PathRelation, Schema
|
|
44
|
+
from lorebase_store.auth.scope import rls_scope
|
|
45
|
+
from lorebase_store.bulk import chunks
|
|
46
|
+
from lorebase_store.materialize.derived import AppliedChanges, DerivedValues
|
|
47
|
+
from lorebase_store.materialize.links import path_affected, path_pairs_from
|
|
48
|
+
from lorebase_store.materialize.rows import search_weights
|
|
49
|
+
from lorebase_store.materialize.sensitivity import reference_tags
|
|
50
|
+
from lorebase_store.repositories.materializations import BranchMaterializationRepository
|
|
51
|
+
from lorebase_store.repositories.materialized import LinkRow, MaterializedRepository
|
|
52
|
+
from lorebase_store.repositories.refs import RefRepository
|
|
53
|
+
from lorebase_store.repositories.revisions import RevisionRepository
|
|
54
|
+
from lorebase_store.repositories.scope import scope_to_domain
|
|
55
|
+
from lorebase_store.runtime import StoreRuntime
|
|
56
|
+
|
|
57
|
+
logger = logging.getLogger("lorebase.store.paths")
|
|
58
|
+
|
|
59
|
+
Pair = tuple[str, str]
|
|
60
|
+
|
|
61
|
+
# Branches with a path Derivation whose recorded fan-out differs from the setting.
|
|
62
|
+
_STALE = text(
|
|
63
|
+
"SELECT domain_id, branch FROM mat_schema "
|
|
64
|
+
"WHERE path_fanout IS DISTINCT FROM :fanout "
|
|
65
|
+
"AND jsonb_path_exists(compiled, '$.derivations.*.definition ? (@.variant == \"path\")') "
|
|
66
|
+
"ORDER BY domain_id, branch"
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def path_link_hash(path_id: str, from_key: str, to_key: str) -> bytes:
|
|
71
|
+
"""`object_hash` of a materialized path Link: a digest of the pair, never an object."""
|
|
72
|
+
return hashlib.sha256(f"lb1 path\0{path_id}\0{from_key}\0{to_key}".encode()).digest()
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
class PathLinks:
|
|
76
|
+
"""Keeps the materialized path Derivations of one schema on one branch current."""
|
|
77
|
+
|
|
78
|
+
def __init__(
|
|
79
|
+
self,
|
|
80
|
+
repository: MaterializedRepository,
|
|
81
|
+
schema: Schema,
|
|
82
|
+
*,
|
|
83
|
+
fanout: int,
|
|
84
|
+
batch: int,
|
|
85
|
+
max_incremental: int,
|
|
86
|
+
write: bool = True,
|
|
87
|
+
) -> None:
|
|
88
|
+
self.repository = repository
|
|
89
|
+
self.fanout = fanout
|
|
90
|
+
self.batch = batch
|
|
91
|
+
self.max_incremental = max_incremental
|
|
92
|
+
self.write = write
|
|
93
|
+
self.paths = sorted(
|
|
94
|
+
(p for p in schema.path_relations().values() if p.materialized), key=lambda p: p.id
|
|
95
|
+
)
|
|
96
|
+
self.expected: dict[str, set[Pair]] = {}
|
|
97
|
+
|
|
98
|
+
# Entry points -----------------------------------------------------------------------------
|
|
99
|
+
|
|
100
|
+
async def prune(self) -> None:
|
|
101
|
+
"""Drop the Links of path Derivations that are gone or no longer materialized."""
|
|
102
|
+
keep = {path.id for path in self.paths}
|
|
103
|
+
for path_id in sorted(await self.repository.link_counts(derived=True)):
|
|
104
|
+
if path_id not in keep:
|
|
105
|
+
pairs = [p async for p in self._stored_pairs(path_id)]
|
|
106
|
+
await self.repository.delete_links(path_id, pairs)
|
|
107
|
+
|
|
108
|
+
async def recompute_all(self) -> None:
|
|
109
|
+
for path in self.paths:
|
|
110
|
+
await self._rewrite(path)
|
|
111
|
+
|
|
112
|
+
async def fanout_stale(self, derived: DerivedValues | None) -> bool:
|
|
113
|
+
"""Whether materialized paths, or derived values reading a path, were computed with
|
|
114
|
+
another fan-out than `store.traverse.path_fanout` (or none was recorded yet)."""
|
|
115
|
+
dependent = bool(self.paths) or (derived is not None and derived.reads_paths)
|
|
116
|
+
return dependent and await self.repository.path_fanout() != self.fanout
|
|
117
|
+
|
|
118
|
+
async def record_fanout(self) -> None:
|
|
119
|
+
"""Record the fan-out the branch's path Links and derived values now follow."""
|
|
120
|
+
if self.write:
|
|
121
|
+
await self.repository.set_path_fanout(self.fanout)
|
|
122
|
+
|
|
123
|
+
async def refresh(self, path: PathRelation, starts: Sequence[str]) -> None:
|
|
124
|
+
"""Recompute the pairs of the start keys `starts` (a piece of `recompute_all` that a
|
|
125
|
+
background job commits on its own)."""
|
|
126
|
+
await self._refresh(path, starts)
|
|
127
|
+
|
|
128
|
+
async def recompute(self, applied: AppliedChanges) -> None:
|
|
129
|
+
for path in self.paths:
|
|
130
|
+
starts = await self.affected(path, applied)
|
|
131
|
+
if not starts:
|
|
132
|
+
continue
|
|
133
|
+
if len(starts) > self.max_incremental:
|
|
134
|
+
await self._rewrite(path)
|
|
135
|
+
continue
|
|
136
|
+
for chunk in chunks(sorted(starts), self.batch):
|
|
137
|
+
await self._refresh(path, chunk)
|
|
138
|
+
|
|
139
|
+
# Affected start Records -------------------------------------------------------------------
|
|
140
|
+
|
|
141
|
+
async def affected(self, path: PathRelation, applied: AppliedChanges) -> set[str]:
|
|
142
|
+
"""Start Records whose pairs a changeset can change (a superset)."""
|
|
143
|
+
return await path_affected(self.repository, path.steps, applied)
|
|
144
|
+
|
|
145
|
+
# Evaluation -------------------------------------------------------------------------------
|
|
146
|
+
|
|
147
|
+
async def pairs_from(self, path: PathRelation, starts: Sequence[str]) -> dict[str, set[str]]:
|
|
148
|
+
"""Far keys reached from each start Record (`lorebase_core.model.paths`)."""
|
|
149
|
+
return await path_pairs_from(self.repository, path, starts, self.fanout)
|
|
150
|
+
|
|
151
|
+
async def _refresh(self, path: PathRelation, starts: Sequence[str]) -> None:
|
|
152
|
+
"""Recompute the pairs of `starts` and write the difference."""
|
|
153
|
+
reached = await self.pairs_from(path, starts)
|
|
154
|
+
wanted = {(start, far) for start, fars in reached.items() for far in fars}
|
|
155
|
+
if not self.write:
|
|
156
|
+
self.expected.setdefault(path.id, set()).update(wanted)
|
|
157
|
+
return
|
|
158
|
+
stored = {
|
|
159
|
+
(near, far)
|
|
160
|
+
for near, far, _ in await self.repository.linked_keys(
|
|
161
|
+
path.id, near="from_key", keys=starts
|
|
162
|
+
)
|
|
163
|
+
}
|
|
164
|
+
gone = sorted(stored - wanted)
|
|
165
|
+
if gone:
|
|
166
|
+
await self.repository.delete_links(path.id, gone)
|
|
167
|
+
await self.repository.upsert_links(
|
|
168
|
+
[
|
|
169
|
+
LinkRow(path.id, f, t, {}, path_link_hash(path.id, f, t), {})
|
|
170
|
+
for f, t in sorted(wanted - stored)
|
|
171
|
+
]
|
|
172
|
+
)
|
|
173
|
+
|
|
174
|
+
async def _rewrite(self, path: PathRelation) -> None:
|
|
175
|
+
"""Recompute every pair: from every Record of the start Kind and every start key
|
|
176
|
+
stored (a start Kind changed by a schema change leaves old keys behind)."""
|
|
177
|
+
starts: set[str] = {f async for f, _ in self._stored_pairs(path.id)}
|
|
178
|
+
async for rows in self.repository.stream_record_hashes(
|
|
179
|
+
path.relation.from_kind, batch=self.batch
|
|
180
|
+
):
|
|
181
|
+
starts.update(key for key, _ in rows)
|
|
182
|
+
for chunk in chunks(sorted(starts), self.batch):
|
|
183
|
+
await self._refresh(path, chunk)
|
|
184
|
+
|
|
185
|
+
async def _stored_pairs(self, path_id: str) -> AsyncIterator[Pair]:
|
|
186
|
+
async for rows in self.repository.stream_links(path_id, batch=self.batch):
|
|
187
|
+
for row in rows:
|
|
188
|
+
yield row.from_key, row.to_key
|
|
189
|
+
|
|
190
|
+
async def stored(self, path: PathRelation) -> set[Pair]:
|
|
191
|
+
"""The pairs stored for a path."""
|
|
192
|
+
return {pair async for pair in self._stored_pairs(path.id)}
|
|
193
|
+
|
|
194
|
+
async def compute_all(self, path: PathRelation) -> None:
|
|
195
|
+
"""Compute every pair of a path into `expected` (with `write=False`)."""
|
|
196
|
+
await self._rewrite(path)
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
async def refresh_path_fanout(
|
|
200
|
+
session: AsyncSession, domain_id: str, branch: str, *, runtime: StoreRuntime
|
|
201
|
+
) -> bool:
|
|
202
|
+
"""Rewrite the materialized paths of `branch`, and recompute its derived values when one
|
|
203
|
+
reads a path, when they were computed with another fan-out; in the caller's transaction
|
|
204
|
+
and under the branch lock. Returns whether it did. A branch whose background copy is
|
|
205
|
+
queued or running is left alone (rewriting it would hold its lock for long and turn its
|
|
206
|
+
path Links and derived values into own rows mid-copy): its next commit, or the next
|
|
207
|
+
sweep, rewrites it."""
|
|
208
|
+
settings = runtime.settings
|
|
209
|
+
if not settings.materialize.derivations_enabled:
|
|
210
|
+
return False
|
|
211
|
+
await scope_to_domain(session, domain_id)
|
|
212
|
+
refs = RefRepository(session, domain_id)
|
|
213
|
+
await refs.lock(branch, timeout_ms=settings.commit.lock_timeout_ms)
|
|
214
|
+
ref = await refs.require(branch)
|
|
215
|
+
if await BranchMaterializationRepository(session, domain_id).active(branch):
|
|
216
|
+
return False
|
|
217
|
+
head = await RevisionRepository(session, domain_id).require(ref.head)
|
|
218
|
+
repository = MaterializedRepository(
|
|
219
|
+
session,
|
|
220
|
+
domain_id,
|
|
221
|
+
branch,
|
|
222
|
+
writes=settings.writes,
|
|
223
|
+
layout=ref.layout,
|
|
224
|
+
generation=head.generation,
|
|
225
|
+
lock_timeout_ms=settings.commit.lock_timeout_ms,
|
|
226
|
+
)
|
|
227
|
+
row = await repository.schema()
|
|
228
|
+
if row is None:
|
|
229
|
+
return False
|
|
230
|
+
# Imported here: `lorebase_store.commit` imports this package (the commit pipeline
|
|
231
|
+
# applies changesets), so a module-level import is circular when this package loads first.
|
|
232
|
+
from lorebase_store.commit.schema import load_compiled
|
|
233
|
+
|
|
234
|
+
schema = load_compiled(row.compiled)
|
|
235
|
+
batch = settings.materialize.read_batch_rows
|
|
236
|
+
max_incremental = settings.materialize.derivation_max_incremental_records
|
|
237
|
+
fanout = settings.traverse.path_fanout
|
|
238
|
+
derived = DerivedValues(
|
|
239
|
+
repository,
|
|
240
|
+
schema,
|
|
241
|
+
limits=runtime.schema.expression_limits,
|
|
242
|
+
now=head.created_at,
|
|
243
|
+
batch=batch,
|
|
244
|
+
max_incremental=max_incremental,
|
|
245
|
+
fanout=fanout,
|
|
246
|
+
)
|
|
247
|
+
paths = PathLinks(
|
|
248
|
+
repository, schema, fanout=fanout, batch=batch, max_incremental=max_incremental
|
|
249
|
+
)
|
|
250
|
+
stale = await paths.fanout_stale(derived)
|
|
251
|
+
if stale:
|
|
252
|
+
changed = await derived.recompute_all() if derived.reads_paths else {}
|
|
253
|
+
await paths.recompute_all()
|
|
254
|
+
tags = await reference_tags(session, domain_id, branch, runtime=runtime)
|
|
255
|
+
tagged = with_tags(schema, tags)
|
|
256
|
+
for kind_id, keys in sorted(changed.items()):
|
|
257
|
+
if keys and derived.searchable(kind_id):
|
|
258
|
+
await repository.refresh_search(
|
|
259
|
+
kind_id,
|
|
260
|
+
search_weights(tagged.kinds.get(kind_id)),
|
|
261
|
+
config=settings.materialize.text_search_config,
|
|
262
|
+
keys=sorted(keys),
|
|
263
|
+
)
|
|
264
|
+
await paths.record_fanout() # also when nothing depends on it: not checked again
|
|
265
|
+
return stale
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
async def refresh_stale_paths(
|
|
269
|
+
sessions: async_sessionmaker[AsyncSession],
|
|
270
|
+
*,
|
|
271
|
+
runtime: StoreRuntime,
|
|
272
|
+
stop: asyncio.Event | None = None,
|
|
273
|
+
) -> list[tuple[str, str]]:
|
|
274
|
+
"""Rewrite the materialized paths of every branch computed with another fan-out than
|
|
275
|
+
`store.traverse.path_fanout`, one branch per transaction; returns the branches
|
|
276
|
+
rewritten. A branch that fails (deleted or locked past `commit.lock_timeout_ms`
|
|
277
|
+
meanwhile, or any other error) is logged and skipped: the next commit on it rewrites
|
|
278
|
+
them. Stops between branches once `stop` is set. Job workers run it next to their job
|
|
279
|
+
loop (`run_path_fanout_sweep`), not before it."""
|
|
280
|
+
if not runtime.settings.materialize.derivations_enabled:
|
|
281
|
+
return []
|
|
282
|
+
async with sessions() as session, session.begin(), rls_scope(session, bypass=True):
|
|
283
|
+
fanout = runtime.settings.traverse.path_fanout
|
|
284
|
+
stale = [(d, b) for d, b in await session.execute(_STALE, {"fanout": fanout})]
|
|
285
|
+
rewritten: list[tuple[str, str]] = []
|
|
286
|
+
for domain_id, branch in stale:
|
|
287
|
+
if stop is not None and stop.is_set():
|
|
288
|
+
break
|
|
289
|
+
try:
|
|
290
|
+
async with sessions() as session, session.begin():
|
|
291
|
+
if await refresh_path_fanout(session, domain_id, branch, runtime=runtime):
|
|
292
|
+
rewritten.append((domain_id, branch))
|
|
293
|
+
except Exception as error: # one branch must not stop the others
|
|
294
|
+
logger.warning(
|
|
295
|
+
"materialized paths not rewritten",
|
|
296
|
+
extra={"domain": domain_id, "branch": branch, "error": type(error).__name__},
|
|
297
|
+
)
|
|
298
|
+
return rewritten
|
|
299
|
+
|
|
300
|
+
|
|
301
|
+
async def run_path_fanout_sweep(
|
|
302
|
+
sessions: async_sessionmaker[AsyncSession],
|
|
303
|
+
*,
|
|
304
|
+
runtime: StoreRuntime,
|
|
305
|
+
stop: asyncio.Event | None = None,
|
|
306
|
+
) -> None:
|
|
307
|
+
"""`refresh_stale_paths` as a background task of a job worker: never raises (an error
|
|
308
|
+
listing the branches is logged), logs how many branches it rewrote."""
|
|
309
|
+
try:
|
|
310
|
+
rewritten = await refresh_stale_paths(sessions, runtime=runtime, stop=stop)
|
|
311
|
+
except Exception as error:
|
|
312
|
+
logger.warning("path fan-out sweep failed", extra={"error": type(error).__name__})
|
|
313
|
+
return
|
|
314
|
+
if rewritten:
|
|
315
|
+
logger.info("materialized paths rewritten", extra={"branches": len(rewritten)})
|
|
@@ -0,0 +1,326 @@
|
|
|
1
|
+
"""Full materialization of a branch head from its tree (design 6.11).
|
|
2
|
+
|
|
3
|
+
Content comes from the tree and the objects it references; history is the changesets of the
|
|
4
|
+
head's first-parent chain; lineage and `updated_revision` are folded from that history with
|
|
5
|
+
the same rules as the incremental path (`rows.next_lineage`), so both produce equal rows.
|
|
6
|
+
Derived values are then computed for every Record, and the Links of materialized path
|
|
7
|
+
Derivations, with the evaluation the incremental path uses (`materialize.derived`,
|
|
8
|
+
`materialize.paths`).
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
from collections.abc import Mapping, Sequence
|
|
14
|
+
from dataclasses import dataclass
|
|
15
|
+
from datetime import datetime
|
|
16
|
+
from typing import Any
|
|
17
|
+
|
|
18
|
+
from sqlalchemy.ext.asyncio import AsyncSession
|
|
19
|
+
|
|
20
|
+
from lorebase_core.model import Schema
|
|
21
|
+
from lorebase_core.tree import PARTITION_SCHEMA, Tree
|
|
22
|
+
from lorebase_store.bulk import chunks
|
|
23
|
+
from lorebase_store.commit.schema import (
|
|
24
|
+
compile_schema,
|
|
25
|
+
element_from_body,
|
|
26
|
+
schema_from_elements,
|
|
27
|
+
)
|
|
28
|
+
from lorebase_store.indexes import BranchIndexes
|
|
29
|
+
from lorebase_store.materialize.derived import DerivedValues
|
|
30
|
+
from lorebase_store.materialize.overlay import promote_overlays_of
|
|
31
|
+
from lorebase_store.materialize.paths import PathLinks
|
|
32
|
+
from lorebase_store.materialize.rows import (
|
|
33
|
+
CREATED_KEY,
|
|
34
|
+
PartitionType,
|
|
35
|
+
changed_provenance,
|
|
36
|
+
next_lineage,
|
|
37
|
+
parse_partition,
|
|
38
|
+
record_title,
|
|
39
|
+
search_weights,
|
|
40
|
+
split_link_key,
|
|
41
|
+
)
|
|
42
|
+
from lorebase_store.materialize.sensitivity import branch_tagged_schema, tagged_schema
|
|
43
|
+
from lorebase_store.models.enums import Materialization
|
|
44
|
+
from lorebase_store.nodestore import NodeRepository
|
|
45
|
+
from lorebase_store.objectstore import PgObjectStore, StoredBody
|
|
46
|
+
from lorebase_store.repositories.materializations import copy_fence
|
|
47
|
+
from lorebase_store.repositories.materialized import (
|
|
48
|
+
LinkRow,
|
|
49
|
+
MaterializedRepository,
|
|
50
|
+
RecordRow,
|
|
51
|
+
SchemaRow,
|
|
52
|
+
)
|
|
53
|
+
from lorebase_store.repositories.refs import RefRepository
|
|
54
|
+
from lorebase_store.repositories.revisions import RevisionRepository, RevisionRow
|
|
55
|
+
from lorebase_store.repositories.scope import scope_to_domain
|
|
56
|
+
from lorebase_store.runtime import StoreRuntime
|
|
57
|
+
from lorebase_store.storage import constraints_only
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
@dataclass(frozen=True, slots=True)
|
|
61
|
+
class RebuildReport:
|
|
62
|
+
"""What a rebuild wrote."""
|
|
63
|
+
|
|
64
|
+
branch: str
|
|
65
|
+
head: bytes
|
|
66
|
+
records: int
|
|
67
|
+
links: int
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
@dataclass(frozen=True, slots=True)
|
|
71
|
+
class Folded:
|
|
72
|
+
"""Lineage inputs of one element folded from its history."""
|
|
73
|
+
|
|
74
|
+
provenance: dict[str, str]
|
|
75
|
+
revisions: dict[str, str]
|
|
76
|
+
revision_id: bytes
|
|
77
|
+
at: datetime
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
async def rebuild(
|
|
81
|
+
session: AsyncSession, domain_id: str, branch: str, *, runtime: StoreRuntime
|
|
82
|
+
) -> RebuildReport:
|
|
83
|
+
"""Replace the materialized head of `branch` with one computed from its tree.
|
|
84
|
+
|
|
85
|
+
Overlays based on the branch are promoted to full copies first, and an overlay branch is
|
|
86
|
+
rebuilt as a full one.
|
|
87
|
+
"""
|
|
88
|
+
await scope_to_domain(session, domain_id)
|
|
89
|
+
refs = RefRepository(session, domain_id)
|
|
90
|
+
await refs.lock(branch, timeout_ms=runtime.settings.commit.lock_timeout_ms)
|
|
91
|
+
ref = await refs.require(branch)
|
|
92
|
+
head = await RevisionRepository(session, domain_id).require(ref.head)
|
|
93
|
+
await promote_overlays_of(session, domain_id, branch, runtime=runtime)
|
|
94
|
+
if ref.materialization is Materialization.OVERLAY:
|
|
95
|
+
await copy_fence(session, domain_id, branch)
|
|
96
|
+
await refs.set_materialization(branch, Materialization.FULL)
|
|
97
|
+
repository = MaterializedRepository(session, domain_id, branch, writes=runtime.settings.writes)
|
|
98
|
+
await repository.clear()
|
|
99
|
+
return await materialize_revision(session, domain_id, branch, head, runtime=runtime)
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
async def load_schema(
|
|
103
|
+
session: AsyncSession, domain_id: str, tree: Tree, *, runtime: StoreRuntime
|
|
104
|
+
) -> tuple[Schema, bytes]:
|
|
105
|
+
"""Schema of a tree and its schema partition hash."""
|
|
106
|
+
settings = runtime.settings
|
|
107
|
+
nodes = NodeRepository(
|
|
108
|
+
session, domain_id, writes=settings.writes, batch=settings.nodes.prefetch_batch
|
|
109
|
+
)
|
|
110
|
+
objects = PgObjectStore(
|
|
111
|
+
session, domain_id, writes=settings.writes, batch=settings.materialize.read_batch_rows
|
|
112
|
+
)
|
|
113
|
+
ref = tree.partitions[PARTITION_SCHEMA]
|
|
114
|
+
hashes: list[bytes] = []
|
|
115
|
+
async for entries in nodes.scan(ref.root):
|
|
116
|
+
hashes.extend(digest for _, digest in entries)
|
|
117
|
+
bodies = await objects.get_many(hashes)
|
|
118
|
+
elements = [element_from_body(bodies[h].object_type, bodies[h].body) for h in hashes]
|
|
119
|
+
return schema_from_elements(elements), ref.root
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
async def materialize_revision(
|
|
123
|
+
session: AsyncSession,
|
|
124
|
+
domain_id: str,
|
|
125
|
+
branch: str,
|
|
126
|
+
head: RevisionRow,
|
|
127
|
+
*,
|
|
128
|
+
runtime: StoreRuntime,
|
|
129
|
+
indexes: bool = True,
|
|
130
|
+
default_branch_tags: bool = True,
|
|
131
|
+
) -> RebuildReport:
|
|
132
|
+
"""Write the materialized head of an empty branch from the tree of `head`; without
|
|
133
|
+
`indexes` the branch gets no per-Attribute indexes, without `default_branch_tags` titles
|
|
134
|
+
and search vectors follow the sensitivity tags of `head`'s schema alone (snapshots,
|
|
135
|
+
`materialize.snapshot`)."""
|
|
136
|
+
settings = runtime.settings
|
|
137
|
+
batch = settings.materialize.read_batch_rows
|
|
138
|
+
repository = MaterializedRepository(
|
|
139
|
+
session, domain_id, branch, writes=settings.writes, generation=head.generation
|
|
140
|
+
)
|
|
141
|
+
nodes = NodeRepository(
|
|
142
|
+
session, domain_id, writes=settings.writes, batch=settings.nodes.prefetch_batch
|
|
143
|
+
)
|
|
144
|
+
objects = PgObjectStore(session, domain_id, writes=settings.writes, batch=batch)
|
|
145
|
+
tree = await nodes.load_tree(head.root)
|
|
146
|
+
schema, schema_hash = await load_schema(session, domain_id, tree, runtime=runtime)
|
|
147
|
+
tagged = (
|
|
148
|
+
await branch_tagged_schema(session, domain_id, branch, schema, runtime=runtime)
|
|
149
|
+
if default_branch_tags
|
|
150
|
+
else tagged_schema(schema, {}, runtime.schema.expression_limits)
|
|
151
|
+
)
|
|
152
|
+
branch_indexes = BranchIndexes(
|
|
153
|
+
session,
|
|
154
|
+
domain_id,
|
|
155
|
+
branch,
|
|
156
|
+
settings=settings.indexes,
|
|
157
|
+
maintenance=runtime.index_maintenance,
|
|
158
|
+
constraints_only=constraints_only(settings),
|
|
159
|
+
)
|
|
160
|
+
if indexes:
|
|
161
|
+
await branch_indexes.retire(schema)
|
|
162
|
+
await repository.set_schema(SchemaRow(schema_hash, compile_schema(schema)))
|
|
163
|
+
await repository.rebuild_history(head.id)
|
|
164
|
+
records = links = 0
|
|
165
|
+
for partition, ref in sorted(tree.partitions.items()):
|
|
166
|
+
parsed = parse_partition(partition)
|
|
167
|
+
if parsed.type is PartitionType.SCHEMA:
|
|
168
|
+
continue
|
|
169
|
+
entries: dict[str, bytes] = {}
|
|
170
|
+
async for scanned in nodes.scan(ref.root):
|
|
171
|
+
entries.update(scanned)
|
|
172
|
+
folded = await fold_history(repository, partition, batch=batch, objects=objects)
|
|
173
|
+
for chunk in chunks(sorted(entries), batch):
|
|
174
|
+
bodies = await objects.get_many(entries[key] for key in chunk)
|
|
175
|
+
values = {key: dict(bodies[entries[key]].body.get("values", {})) for key in chunk}
|
|
176
|
+
if parsed.type is PartitionType.KIND:
|
|
177
|
+
await repository.upsert_records(
|
|
178
|
+
[
|
|
179
|
+
record_row(
|
|
180
|
+
parsed.element_id,
|
|
181
|
+
key,
|
|
182
|
+
values[key],
|
|
183
|
+
entries[key],
|
|
184
|
+
tagged,
|
|
185
|
+
folded.get(key),
|
|
186
|
+
head,
|
|
187
|
+
)
|
|
188
|
+
for key in chunk
|
|
189
|
+
]
|
|
190
|
+
)
|
|
191
|
+
else:
|
|
192
|
+
await repository.upsert_links(
|
|
193
|
+
[
|
|
194
|
+
link_row(parsed.element_id, key, values[key], entries[key], folded.get(key))
|
|
195
|
+
for key in chunk
|
|
196
|
+
]
|
|
197
|
+
)
|
|
198
|
+
if parsed.type is PartitionType.KIND:
|
|
199
|
+
records += len(entries)
|
|
200
|
+
await repository.refresh_search(
|
|
201
|
+
parsed.element_id,
|
|
202
|
+
search_weights(tagged.kinds.get(parsed.element_id)),
|
|
203
|
+
config=settings.materialize.text_search_config,
|
|
204
|
+
)
|
|
205
|
+
else:
|
|
206
|
+
links += len(entries)
|
|
207
|
+
if settings.materialize.derivations_enabled:
|
|
208
|
+
derived = DerivedValues(
|
|
209
|
+
repository,
|
|
210
|
+
schema,
|
|
211
|
+
limits=runtime.schema.expression_limits,
|
|
212
|
+
now=head.created_at,
|
|
213
|
+
batch=batch,
|
|
214
|
+
max_incremental=settings.materialize.derivation_max_incremental_records,
|
|
215
|
+
fanout=settings.traverse.path_fanout,
|
|
216
|
+
)
|
|
217
|
+
await derived.recompute_all()
|
|
218
|
+
paths = PathLinks(
|
|
219
|
+
repository,
|
|
220
|
+
schema,
|
|
221
|
+
fanout=settings.traverse.path_fanout,
|
|
222
|
+
batch=batch,
|
|
223
|
+
max_incremental=settings.materialize.derivation_max_incremental_records,
|
|
224
|
+
)
|
|
225
|
+
await paths.recompute_all()
|
|
226
|
+
await paths.record_fanout()
|
|
227
|
+
for kind_id in sorted(derived.kinds):
|
|
228
|
+
if derived.searchable(kind_id):
|
|
229
|
+
await repository.refresh_search(
|
|
230
|
+
kind_id,
|
|
231
|
+
search_weights(tagged.kinds.get(kind_id)),
|
|
232
|
+
config=settings.materialize.text_search_config,
|
|
233
|
+
)
|
|
234
|
+
if indexes:
|
|
235
|
+
await branch_indexes.ensure(schema)
|
|
236
|
+
return RebuildReport(branch=branch, head=head.id, records=records, links=links)
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
async def fold_history(
|
|
240
|
+
repository: MaterializedRepository,
|
|
241
|
+
partition: str,
|
|
242
|
+
*,
|
|
243
|
+
batch: int,
|
|
244
|
+
objects: PgObjectStore,
|
|
245
|
+
keys: Sequence[str] | None = None,
|
|
246
|
+
) -> dict[str, Folded]:
|
|
247
|
+
"""Per element still present (of those in `keys`, when given), the union of its
|
|
248
|
+
provenance maps since it was last created, and the Revision of each entry. A step that
|
|
249
|
+
creates the element (no old hash) records its Revision under `CREATED_KEY`; history
|
|
250
|
+
that starts after the creation names none. A merge Revision's step attributes only the
|
|
251
|
+
values it changes against the branch's previous value (`rows.changed_provenance`): merges
|
|
252
|
+
made before their changesets were restricted carry the source's whole Lineage."""
|
|
253
|
+
folded: dict[str, Folded] = {}
|
|
254
|
+
async for steps in repository.stream_history_steps(partition, batch=batch, keys=keys):
|
|
255
|
+
merges = [s for s in steps if s.merge and s.new_hash is not None and s.provenance]
|
|
256
|
+
bodies = await objects.get_many(
|
|
257
|
+
h for s in merges for h in (s.old_hash, s.new_hash) if h is not None
|
|
258
|
+
)
|
|
259
|
+
|
|
260
|
+
for step in steps:
|
|
261
|
+
if step.new_hash is None:
|
|
262
|
+
folded.pop(step.element_key, None)
|
|
263
|
+
continue
|
|
264
|
+
taken = dict(step.provenance or {})
|
|
265
|
+
if step.merge and taken and (new := _values(bodies, step.new_hash)) is not None:
|
|
266
|
+
taken = changed_provenance(taken, _values(bodies, step.old_hash), new)
|
|
267
|
+
previous = folded.get(step.element_key)
|
|
268
|
+
provenance = {} if previous is None else previous.provenance
|
|
269
|
+
provenance.update(taken)
|
|
270
|
+
revisions = {} if previous is None else previous.revisions
|
|
271
|
+
if step.old_hash is None:
|
|
272
|
+
revisions = {CREATED_KEY: step.revision_id.hex()}
|
|
273
|
+
revisions.update(dict.fromkeys(taken, step.revision_id.hex()))
|
|
274
|
+
folded[step.element_key] = Folded(provenance, revisions, step.revision_id, step.at)
|
|
275
|
+
return folded
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
def _values(bodies: Mapping[bytes, StoredBody], digest: bytes | None) -> Mapping[str, Any] | None:
|
|
279
|
+
found = None if digest is None else bodies.get(digest)
|
|
280
|
+
return None if found is None else found.body.get("values") or {}
|
|
281
|
+
|
|
282
|
+
|
|
283
|
+
def record_row(
|
|
284
|
+
kind_id: str,
|
|
285
|
+
key: str,
|
|
286
|
+
values: Mapping[str, Any],
|
|
287
|
+
digest: bytes,
|
|
288
|
+
schema: Schema,
|
|
289
|
+
folded: Folded | None,
|
|
290
|
+
head: RevisionRow,
|
|
291
|
+
) -> RecordRow:
|
|
292
|
+
return RecordRow(
|
|
293
|
+
kind_id=kind_id,
|
|
294
|
+
key=key,
|
|
295
|
+
values=dict(values),
|
|
296
|
+
title=record_title(schema.kinds.get(kind_id), values),
|
|
297
|
+
object_hash=digest,
|
|
298
|
+
lineage=next_lineage(None, None if folded is None else folded.provenance, values),
|
|
299
|
+
updated_revision=head.id if folded is None else folded.revision_id,
|
|
300
|
+
updated_at=head.created_at if folded is None else folded.at,
|
|
301
|
+
lineage_revisions=None
|
|
302
|
+
if folded is None
|
|
303
|
+
else {
|
|
304
|
+
attribute: revision
|
|
305
|
+
for attribute, revision in sorted(folded.revisions.items())
|
|
306
|
+
if attribute in values or attribute == CREATED_KEY
|
|
307
|
+
},
|
|
308
|
+
)
|
|
309
|
+
|
|
310
|
+
|
|
311
|
+
def link_row(
|
|
312
|
+
relation_id: str,
|
|
313
|
+
key: str,
|
|
314
|
+
values: Mapping[str, Any],
|
|
315
|
+
digest: bytes,
|
|
316
|
+
folded: Folded | None,
|
|
317
|
+
) -> LinkRow:
|
|
318
|
+
from_key, to_key = split_link_key(key)
|
|
319
|
+
return LinkRow(
|
|
320
|
+
relation_id=relation_id,
|
|
321
|
+
from_key=from_key,
|
|
322
|
+
to_key=to_key,
|
|
323
|
+
values=dict(values),
|
|
324
|
+
object_hash=digest,
|
|
325
|
+
lineage=next_lineage(None, None if folded is None else folded.provenance, values),
|
|
326
|
+
)
|