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,1129 @@
|
|
|
1
|
+
"""Materialized head of one branch: Records, Links, history and compiled schema (design 7.2)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import AsyncIterator, Callable, Iterable, Sequence
|
|
6
|
+
from dataclasses import dataclass, field
|
|
7
|
+
from datetime import datetime
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
from sqlalchemy import (
|
|
11
|
+
ARRAY,
|
|
12
|
+
BigInteger,
|
|
13
|
+
BindParameter,
|
|
14
|
+
ColumnElement,
|
|
15
|
+
FromClause,
|
|
16
|
+
Select,
|
|
17
|
+
Table,
|
|
18
|
+
Text,
|
|
19
|
+
and_,
|
|
20
|
+
bindparam,
|
|
21
|
+
cast,
|
|
22
|
+
delete,
|
|
23
|
+
false,
|
|
24
|
+
func,
|
|
25
|
+
insert,
|
|
26
|
+
literal,
|
|
27
|
+
literal_column,
|
|
28
|
+
select,
|
|
29
|
+
text,
|
|
30
|
+
true,
|
|
31
|
+
tuple_,
|
|
32
|
+
update,
|
|
33
|
+
)
|
|
34
|
+
from sqlalchemy.dialects.postgresql import JSONB, REGCONFIG
|
|
35
|
+
from sqlalchemy.dialects.postgresql import insert as pg_insert
|
|
36
|
+
from sqlalchemy.ext.asyncio import AsyncSession
|
|
37
|
+
|
|
38
|
+
from lorebase_store.bulk import OnConflict, bulk_write, chunks, pairs_in
|
|
39
|
+
from lorebase_store.layout import BranchLayout, lineage_column, load_layout
|
|
40
|
+
from lorebase_store.models import metadata
|
|
41
|
+
from lorebase_store.repositories.refs import RefRepository
|
|
42
|
+
from lorebase_store.settings import WriteSettings
|
|
43
|
+
|
|
44
|
+
MAT_SCHEMA: Table = metadata.tables["mat_schema"]
|
|
45
|
+
MAT_RECORDS: Table = metadata.tables["mat_records"]
|
|
46
|
+
MAT_LINKS: Table = metadata.tables["mat_links"]
|
|
47
|
+
MAT_HISTORY: Table = metadata.tables["mat_history"]
|
|
48
|
+
REVISIONS: Table = metadata.tables["revisions"]
|
|
49
|
+
|
|
50
|
+
MERGE_ORIGIN_TYPE = "merge"
|
|
51
|
+
"""Origin type of merge Revisions (`merging.MERGE_ORIGIN`)."""
|
|
52
|
+
|
|
53
|
+
DERIVED_LINK_PREFIX = "drv_"
|
|
54
|
+
"""`relation_id` prefix of the rows of materialized path Derivations in `mat_links` (the
|
|
55
|
+
Derivation id): they are not tree objects, so tree comparisons leave them out."""
|
|
56
|
+
|
|
57
|
+
SEARCH_WEIGHTS = ("A", "B", "C", "D")
|
|
58
|
+
"""Valid full-text weights (Postgres `setweight`)."""
|
|
59
|
+
|
|
60
|
+
_RECORD_UPDATE_COLUMNS = (
|
|
61
|
+
"values",
|
|
62
|
+
"title",
|
|
63
|
+
"object_hash",
|
|
64
|
+
"lineage",
|
|
65
|
+
"updated_revision",
|
|
66
|
+
"updated_at",
|
|
67
|
+
"revision_generation",
|
|
68
|
+
"deleted",
|
|
69
|
+
"lineage_revisions",
|
|
70
|
+
)
|
|
71
|
+
_LINK_UPDATE_COLUMNS = ("values", "object_hash", "lineage", "revision_generation", "deleted")
|
|
72
|
+
|
|
73
|
+
Where = Callable[[FromClause], Sequence[ColumnElement[bool]]]
|
|
74
|
+
"""Conditions on the rows (of the alias given) a write is about to change."""
|
|
75
|
+
|
|
76
|
+
_REBUILD_HISTORY = text(
|
|
77
|
+
"""
|
|
78
|
+
WITH RECURSIVE chain(id) AS (
|
|
79
|
+
SELECT CAST(:head AS bytea)
|
|
80
|
+
UNION ALL
|
|
81
|
+
SELECT p.parent_id
|
|
82
|
+
FROM chain
|
|
83
|
+
JOIN revision_parents p
|
|
84
|
+
ON p.domain_id = :domain AND p.revision_id = chain.id AND p.position = 0
|
|
85
|
+
)
|
|
86
|
+
INSERT INTO mat_history
|
|
87
|
+
(domain_id, branch, partition, element_key, revision_id, at, old_hash, new_hash,
|
|
88
|
+
provenance)
|
|
89
|
+
SELECT :domain, :branch, c.partition, c.element_key, c.revision_id, r.created_at,
|
|
90
|
+
c.old_hash, c.new_hash, c.provenance
|
|
91
|
+
FROM chain
|
|
92
|
+
JOIN revisions r ON r.domain_id = :domain AND r.id = chain.id
|
|
93
|
+
JOIN changesets c ON c.domain_id = :domain AND c.revision_id = chain.id
|
|
94
|
+
"""
|
|
95
|
+
)
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
_CHAIN_TO = text(
|
|
99
|
+
"""
|
|
100
|
+
WITH RECURSIVE chain(id, depth) AS (
|
|
101
|
+
SELECT CAST(:head AS bytea), 0
|
|
102
|
+
UNION ALL
|
|
103
|
+
SELECT p.parent_id, chain.depth + 1
|
|
104
|
+
FROM chain
|
|
105
|
+
JOIN revision_parents p
|
|
106
|
+
ON p.domain_id = :domain AND p.revision_id = chain.id AND p.position = 0
|
|
107
|
+
WHERE chain.id <> :stop AND chain.depth < :limit
|
|
108
|
+
)
|
|
109
|
+
SELECT id FROM chain
|
|
110
|
+
"""
|
|
111
|
+
)
|
|
112
|
+
|
|
113
|
+
_INSERT_CHAIN_HISTORY = text(
|
|
114
|
+
"""
|
|
115
|
+
INSERT INTO mat_history
|
|
116
|
+
(domain_id, branch, partition, element_key, revision_id, at, old_hash, new_hash,
|
|
117
|
+
provenance)
|
|
118
|
+
SELECT :domain, :branch, c.partition, c.element_key, c.revision_id, r.created_at,
|
|
119
|
+
c.old_hash, c.new_hash, c.provenance
|
|
120
|
+
FROM revisions r
|
|
121
|
+
JOIN changesets c ON c.domain_id = :domain AND c.revision_id = r.id
|
|
122
|
+
WHERE r.domain_id = :domain AND r.id = ANY(CAST(:ids AS bytea[]))
|
|
123
|
+
"""
|
|
124
|
+
)
|
|
125
|
+
|
|
126
|
+
_KEYS_WITH_ORIGIN = """
|
|
127
|
+
SELECT r.key FROM {records}
|
|
128
|
+
WHERE r.domain_id = :domain AND r.branch = :branch AND r.kind_id = :kind
|
|
129
|
+
AND EXISTS (
|
|
130
|
+
SELECT 1 FROM jsonb_each_text(lb_lineage(r.domain_id, r.lineage))
|
|
131
|
+
AS l(attribute, provenance_id)
|
|
132
|
+
JOIN provenance p ON p.domain_id = r.domain_id AND p.id = l.provenance_id
|
|
133
|
+
WHERE p.origin_type = :origin_type AND p.origin_ref ->> :field = :value
|
|
134
|
+
)
|
|
135
|
+
ORDER BY r.key
|
|
136
|
+
"""
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
@dataclass(frozen=True, slots=True)
|
|
140
|
+
class RecordRow:
|
|
141
|
+
"""A materialized Record. `derived` holds derived values by Attribute id (AC-07): they are
|
|
142
|
+
written by the materializer only, never by `upsert_records`. `lineage_revisions` is None
|
|
143
|
+
when unknown (`materialize.rows.next_lineage_revisions`)."""
|
|
144
|
+
|
|
145
|
+
kind_id: str
|
|
146
|
+
key: str
|
|
147
|
+
values: dict[str, Any]
|
|
148
|
+
title: str | None
|
|
149
|
+
object_hash: bytes
|
|
150
|
+
lineage: dict[str, str]
|
|
151
|
+
updated_revision: bytes
|
|
152
|
+
updated_at: datetime
|
|
153
|
+
derived: dict[str, Any] = field(default_factory=dict)
|
|
154
|
+
lineage_revisions: dict[str, str] | None = None
|
|
155
|
+
|
|
156
|
+
@property
|
|
157
|
+
def inputs(self) -> dict[str, Any]:
|
|
158
|
+
"""Stored and derived values together, as expressions read them."""
|
|
159
|
+
return {**self.values, **self.derived}
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
@dataclass(frozen=True, slots=True)
|
|
163
|
+
class LinkRow:
|
|
164
|
+
relation_id: str
|
|
165
|
+
from_key: str
|
|
166
|
+
to_key: str
|
|
167
|
+
values: dict[str, Any]
|
|
168
|
+
object_hash: bytes
|
|
169
|
+
lineage: dict[str, str]
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
@dataclass(frozen=True, slots=True)
|
|
173
|
+
class HistoryRow:
|
|
174
|
+
partition: str
|
|
175
|
+
element_key: str
|
|
176
|
+
revision_id: bytes
|
|
177
|
+
at: datetime
|
|
178
|
+
old_hash: bytes | None
|
|
179
|
+
new_hash: bytes | None
|
|
180
|
+
provenance: dict[str, str] | None
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
@dataclass(frozen=True, slots=True)
|
|
184
|
+
class SchemaRow:
|
|
185
|
+
schema_hash: bytes
|
|
186
|
+
compiled: dict[str, Any]
|
|
187
|
+
path_fanout: int | None = None
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
@dataclass(frozen=True, slots=True)
|
|
191
|
+
class HistoryStep:
|
|
192
|
+
"""One change of an element along the branch, in generation order."""
|
|
193
|
+
|
|
194
|
+
element_key: str
|
|
195
|
+
revision_id: bytes
|
|
196
|
+
at: datetime
|
|
197
|
+
new_hash: bytes | None
|
|
198
|
+
provenance: dict[str, str] | None
|
|
199
|
+
old_hash: bytes | None
|
|
200
|
+
merge: bool = False
|
|
201
|
+
"""The change is a merge Revision's (`MERGE_ORIGIN_TYPE`)."""
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def search_vector(
|
|
205
|
+
config: str, weights: Sequence[tuple[str, str]], rows: FromClause = MAT_RECORDS
|
|
206
|
+
) -> ColumnElement[Any]:
|
|
207
|
+
"""Search vector of a Record row: its title at weight A plus searchable Attributes.
|
|
208
|
+
|
|
209
|
+
`weights` lists `(attribute_id, weight)`; values are read as text from `values`, or from
|
|
210
|
+
`derived` for derived Attributes, of `rows` (`mat_records` or the rows an overlay reads).
|
|
211
|
+
"""
|
|
212
|
+
regconfig = cast(literal(config), REGCONFIG)
|
|
213
|
+
vector: ColumnElement[Any] = func.setweight(
|
|
214
|
+
func.to_tsvector(regconfig, func.coalesce(rows.c.title, "")),
|
|
215
|
+
literal_column("'A'"),
|
|
216
|
+
)
|
|
217
|
+
for position, (attribute_id, weight) in enumerate(weights):
|
|
218
|
+
if weight not in SEARCH_WEIGHTS:
|
|
219
|
+
raise ValueError(f"invalid search weight {weight!r}")
|
|
220
|
+
name: BindParameter[str] = bindparam(f"search_attr_{position}", attribute_id)
|
|
221
|
+
value = func.coalesce(rows.c["values"].op("->>")(name), rows.c.derived.op("->>")(name))
|
|
222
|
+
part = func.setweight(
|
|
223
|
+
func.to_tsvector(regconfig, func.coalesce(value, "")), literal_column(f"'{weight}'")
|
|
224
|
+
)
|
|
225
|
+
vector = vector.op("||")(part)
|
|
226
|
+
return vector
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
class MaterializedRepository:
|
|
230
|
+
"""Rows of the materialized head of one branch.
|
|
231
|
+
|
|
232
|
+
Reads see the branch's rows: its own, or for an overlay the resolved rows of
|
|
233
|
+
`lorebase_store.layout`. Writes keep overlays correct on both sides:
|
|
234
|
+
|
|
235
|
+
- on an overlay, a row about to change is first copied up from the base (so it keeps its
|
|
236
|
+
derived values and search vector), and a delete leaves a tombstone;
|
|
237
|
+
- on a branch other overlays are based on, a row about to change or go is first copied
|
|
238
|
+
into each of them that does not hold it yet (copy on write). Those overlays are locked
|
|
239
|
+
first (their branch locks, after the caller's lock on this branch), so no commit or
|
|
240
|
+
promotion of theirs runs meanwhile.
|
|
241
|
+
|
|
242
|
+
`generation` is the generation of the Revision whose rows are written; it is stored in
|
|
243
|
+
`revision_generation`, which hides rows written after an overlay's fork point from it.
|
|
244
|
+
"""
|
|
245
|
+
|
|
246
|
+
def __init__(
|
|
247
|
+
self,
|
|
248
|
+
session: AsyncSession,
|
|
249
|
+
domain_id: str,
|
|
250
|
+
branch: str,
|
|
251
|
+
*,
|
|
252
|
+
writes: WriteSettings,
|
|
253
|
+
layout: BranchLayout | None = None,
|
|
254
|
+
generation: int | None = None,
|
|
255
|
+
lock_timeout_ms: int = 0,
|
|
256
|
+
) -> None:
|
|
257
|
+
self._session = session
|
|
258
|
+
self._domain = domain_id
|
|
259
|
+
self._branch = branch
|
|
260
|
+
self._writes = writes
|
|
261
|
+
self._layout = layout
|
|
262
|
+
self._generation = generation
|
|
263
|
+
self._lock_timeout_ms = lock_timeout_ms
|
|
264
|
+
self._dependents: list[tuple[str, int]] | None = None
|
|
265
|
+
|
|
266
|
+
@property
|
|
267
|
+
def branch(self) -> str:
|
|
268
|
+
return self._branch
|
|
269
|
+
|
|
270
|
+
@property
|
|
271
|
+
def domain_id(self) -> str:
|
|
272
|
+
return self._domain
|
|
273
|
+
|
|
274
|
+
@property
|
|
275
|
+
def session(self) -> AsyncSession:
|
|
276
|
+
return self._session
|
|
277
|
+
|
|
278
|
+
async def layout(self) -> BranchLayout:
|
|
279
|
+
"""Where the branch's rows live (read from its ref once)."""
|
|
280
|
+
if self._layout is None:
|
|
281
|
+
self._layout = await load_layout(self._session, self._domain, self._branch)
|
|
282
|
+
return self._layout
|
|
283
|
+
|
|
284
|
+
def forget_layout(self) -> None:
|
|
285
|
+
"""Read the layout again on next use (after a promotion or a re-fork)."""
|
|
286
|
+
self._layout = None
|
|
287
|
+
self._dependents = None
|
|
288
|
+
|
|
289
|
+
async def dependents(self) -> list[tuple[str, int]]:
|
|
290
|
+
"""`(name, base generation)` of the overlays based on this branch, locked."""
|
|
291
|
+
if self._dependents is not None:
|
|
292
|
+
return self._dependents
|
|
293
|
+
if (await self.layout()).overlay:
|
|
294
|
+
self._dependents = []
|
|
295
|
+
return self._dependents
|
|
296
|
+
refs = RefRepository(self._session, self._domain)
|
|
297
|
+
locked: set[str] = set()
|
|
298
|
+
while True:
|
|
299
|
+
found = await refs.overlays_of(self._branch)
|
|
300
|
+
pending = sorted(r.name for r in found if r.name not in locked)
|
|
301
|
+
if not pending:
|
|
302
|
+
break
|
|
303
|
+
for name in pending:
|
|
304
|
+
await refs.lock(name, timeout_ms=self._lock_timeout_ms)
|
|
305
|
+
locked.add(name)
|
|
306
|
+
self._dependents = [
|
|
307
|
+
(r.name, int(r.base_generation)) for r in found if r.base_generation is not None
|
|
308
|
+
]
|
|
309
|
+
return self._dependents
|
|
310
|
+
|
|
311
|
+
def _scope(self, table: FromClause) -> ColumnElement[bool]:
|
|
312
|
+
return and_(table.c.domain_id == self._domain, table.c.branch == self._branch)
|
|
313
|
+
|
|
314
|
+
async def _records(self) -> FromClause:
|
|
315
|
+
return (await self.layout()).records()
|
|
316
|
+
|
|
317
|
+
async def _links(self) -> FromClause:
|
|
318
|
+
return (await self.layout()).links()
|
|
319
|
+
|
|
320
|
+
# Schema -------------------------------------------------------------------------
|
|
321
|
+
|
|
322
|
+
async def schema(self) -> SchemaRow | None:
|
|
323
|
+
result = await self._session.execute(
|
|
324
|
+
select(MAT_SCHEMA.c.schema_hash, MAT_SCHEMA.c.compiled, MAT_SCHEMA.c.path_fanout).where(
|
|
325
|
+
self._scope(MAT_SCHEMA)
|
|
326
|
+
)
|
|
327
|
+
)
|
|
328
|
+
found = result.one_or_none()
|
|
329
|
+
return None if found is None else SchemaRow(found[0], found[1], found[2])
|
|
330
|
+
|
|
331
|
+
async def set_schema(self, row: SchemaRow) -> None:
|
|
332
|
+
"""Write the schema row; its `path_fanout` is kept unless `row` gives one."""
|
|
333
|
+
await bulk_write(
|
|
334
|
+
self._session,
|
|
335
|
+
MAT_SCHEMA,
|
|
336
|
+
[
|
|
337
|
+
{
|
|
338
|
+
"domain_id": self._domain,
|
|
339
|
+
"branch": self._branch,
|
|
340
|
+
"schema_hash": row.schema_hash,
|
|
341
|
+
"compiled": row.compiled,
|
|
342
|
+
"path_fanout": row.path_fanout,
|
|
343
|
+
}
|
|
344
|
+
],
|
|
345
|
+
writes=self._writes,
|
|
346
|
+
on_conflict=OnConflict.UPDATE,
|
|
347
|
+
update_columns=(
|
|
348
|
+
("schema_hash", "compiled")
|
|
349
|
+
if row.path_fanout is None
|
|
350
|
+
else ("schema_hash", "compiled", "path_fanout")
|
|
351
|
+
),
|
|
352
|
+
)
|
|
353
|
+
|
|
354
|
+
async def path_fanout(self) -> int | None:
|
|
355
|
+
"""The fan-out the branch's materialized paths were computed with."""
|
|
356
|
+
found = await self._session.scalar(
|
|
357
|
+
select(MAT_SCHEMA.c.path_fanout).where(self._scope(MAT_SCHEMA))
|
|
358
|
+
)
|
|
359
|
+
return None if found is None else int(found)
|
|
360
|
+
|
|
361
|
+
async def set_path_fanout(self, fanout: int) -> None:
|
|
362
|
+
await self._session.execute(
|
|
363
|
+
update(MAT_SCHEMA).where(self._scope(MAT_SCHEMA)).values(path_fanout=fanout)
|
|
364
|
+
)
|
|
365
|
+
|
|
366
|
+
# Records --------------------------------------------------------------------------
|
|
367
|
+
|
|
368
|
+
async def records(self, kind_id: str, keys: Iterable[str]) -> dict[str, RecordRow]:
|
|
369
|
+
rows = await self._records()
|
|
370
|
+
found: dict[str, RecordRow] = {}
|
|
371
|
+
for chunk in chunks(list(dict.fromkeys(keys)), self._writes.insert_batch_rows):
|
|
372
|
+
result = await self._session.execute(
|
|
373
|
+
_record_select(rows).where(
|
|
374
|
+
self._scope(rows), rows.c.kind_id == kind_id, rows.c.key.in_(chunk)
|
|
375
|
+
)
|
|
376
|
+
)
|
|
377
|
+
for row in result.mappings():
|
|
378
|
+
found[row["key"]] = RecordRow(**row)
|
|
379
|
+
return found
|
|
380
|
+
|
|
381
|
+
async def upsert_records(self, rows: Sequence[RecordRow]) -> None:
|
|
382
|
+
if not rows:
|
|
383
|
+
return
|
|
384
|
+
overlay = (await self.layout()).overlay
|
|
385
|
+
by_kind: dict[str, list[str]] = {}
|
|
386
|
+
for row in rows:
|
|
387
|
+
by_kind.setdefault(row.kind_id, []).append(row.key)
|
|
388
|
+
for kind_id, keys in by_kind.items():
|
|
389
|
+
await self._before_record_writes(kind_id, keys)
|
|
390
|
+
if overlay:
|
|
391
|
+
await self._revive_records(kind_id, keys)
|
|
392
|
+
await bulk_write(
|
|
393
|
+
self._session,
|
|
394
|
+
MAT_RECORDS,
|
|
395
|
+
[self._record_values(row) for row in rows],
|
|
396
|
+
writes=self._writes,
|
|
397
|
+
on_conflict=OnConflict.UPDATE,
|
|
398
|
+
update_columns=_RECORD_UPDATE_COLUMNS,
|
|
399
|
+
)
|
|
400
|
+
|
|
401
|
+
async def delete_records(self, kind_id: str, keys: Sequence[str]) -> None:
|
|
402
|
+
await self._before_record_writes(kind_id, keys)
|
|
403
|
+
overlay = (await self.layout()).overlay
|
|
404
|
+
for chunk in chunks(keys, self._writes.insert_batch_rows):
|
|
405
|
+
where = (
|
|
406
|
+
self._scope(MAT_RECORDS),
|
|
407
|
+
MAT_RECORDS.c.kind_id == kind_id,
|
|
408
|
+
MAT_RECORDS.c.key.in_(chunk),
|
|
409
|
+
)
|
|
410
|
+
if overlay:
|
|
411
|
+
await self._session.execute(
|
|
412
|
+
update(MAT_RECORDS)
|
|
413
|
+
.where(*where)
|
|
414
|
+
.values(deleted=true(), search=None, **self._stamp())
|
|
415
|
+
)
|
|
416
|
+
else:
|
|
417
|
+
await self._session.execute(delete(MAT_RECORDS).where(*where))
|
|
418
|
+
|
|
419
|
+
async def update_titles(self, kind_id: str, titles: Sequence[tuple[str, str | None]]) -> None:
|
|
420
|
+
await self._before_record_writes(kind_id, [key for key, _ in titles])
|
|
421
|
+
statement = (
|
|
422
|
+
update(MAT_RECORDS)
|
|
423
|
+
.where(
|
|
424
|
+
self._scope(MAT_RECORDS),
|
|
425
|
+
MAT_RECORDS.c.kind_id == kind_id,
|
|
426
|
+
MAT_RECORDS.c.key == bindparam("record_key"),
|
|
427
|
+
)
|
|
428
|
+
.values(title=bindparam("record_title"), **self._stamp())
|
|
429
|
+
)
|
|
430
|
+
for chunk in chunks(titles, self._writes.insert_batch_rows):
|
|
431
|
+
await self._session.execute(
|
|
432
|
+
statement, [{"record_key": key, "record_title": title} for key, title in chunk]
|
|
433
|
+
)
|
|
434
|
+
|
|
435
|
+
async def refresh_search(
|
|
436
|
+
self,
|
|
437
|
+
kind_id: str,
|
|
438
|
+
weights: Sequence[tuple[str, str]],
|
|
439
|
+
*,
|
|
440
|
+
config: str,
|
|
441
|
+
revision: bytes | None = None,
|
|
442
|
+
keys: Sequence[str] | None = None,
|
|
443
|
+
) -> None:
|
|
444
|
+
"""Recompute search vectors of a Kind's rows (only those of `revision`, or of `keys`,
|
|
445
|
+
when given). With both, `keys` are the keys `revision` wrote: they bound the update to
|
|
446
|
+
primary-key lookups, where `revision` alone filters every row of the Kind."""
|
|
447
|
+
if keys is not None and revision is None:
|
|
448
|
+
await self._before_record_writes(kind_id, keys)
|
|
449
|
+
elif keys is None and revision is None:
|
|
450
|
+
await self._before_writes(MAT_RECORDS, lambda s: [s.c.kind_id == kind_id])
|
|
451
|
+
# Rows of `revision` were written by it, after their previous state was preserved.
|
|
452
|
+
statement = (
|
|
453
|
+
update(MAT_RECORDS)
|
|
454
|
+
.where(self._scope(MAT_RECORDS), MAT_RECORDS.c.kind_id == kind_id)
|
|
455
|
+
.values(search=search_vector(config, weights), **self._stamp())
|
|
456
|
+
)
|
|
457
|
+
if revision is not None:
|
|
458
|
+
statement = statement.where(MAT_RECORDS.c.updated_revision == revision)
|
|
459
|
+
if keys is None:
|
|
460
|
+
await self._session.execute(statement)
|
|
461
|
+
return
|
|
462
|
+
for chunk in chunks(list(keys), self._writes.insert_batch_rows):
|
|
463
|
+
await self._session.execute(statement.where(MAT_RECORDS.c.key.in_(chunk)))
|
|
464
|
+
|
|
465
|
+
async def stale_search_keys(
|
|
466
|
+
self,
|
|
467
|
+
kind_id: str,
|
|
468
|
+
weights: Sequence[tuple[str, str]],
|
|
469
|
+
*,
|
|
470
|
+
config: str,
|
|
471
|
+
keys: Sequence[str],
|
|
472
|
+
) -> list[str]:
|
|
473
|
+
"""Keys among `keys` whose stored search vector differs from the one `weights`
|
|
474
|
+
compute now."""
|
|
475
|
+
rows = await self._records()
|
|
476
|
+
found: list[str] = []
|
|
477
|
+
for chunk in chunks(list(keys), self._writes.insert_batch_rows):
|
|
478
|
+
result = await self._session.execute(
|
|
479
|
+
select(rows.c.key).where(
|
|
480
|
+
self._scope(rows),
|
|
481
|
+
rows.c.kind_id == kind_id,
|
|
482
|
+
rows.c.key.in_(chunk),
|
|
483
|
+
rows.c.search.is_distinct_from(search_vector(config, weights, rows)),
|
|
484
|
+
)
|
|
485
|
+
)
|
|
486
|
+
found.extend(key for (key,) in result)
|
|
487
|
+
return sorted(found)
|
|
488
|
+
|
|
489
|
+
# Derived values -------------------------------------------------------------------
|
|
490
|
+
|
|
491
|
+
async def set_derived(
|
|
492
|
+
self, kind_id: str, attribute_id: str, values: Sequence[tuple[str, Any]]
|
|
493
|
+
) -> None:
|
|
494
|
+
"""Set one derived Attribute of Records by key; a `None` value removes it."""
|
|
495
|
+
await self._before_record_writes(kind_id, [key for key, _ in values])
|
|
496
|
+
statement = (
|
|
497
|
+
update(MAT_RECORDS)
|
|
498
|
+
.where(
|
|
499
|
+
self._scope(MAT_RECORDS),
|
|
500
|
+
MAT_RECORDS.c.kind_id == kind_id,
|
|
501
|
+
MAT_RECORDS.c.key == bindparam("record_key"),
|
|
502
|
+
)
|
|
503
|
+
.values(
|
|
504
|
+
derived=MAT_RECORDS.c.derived.op("-")(
|
|
505
|
+
bindparam("derived_attribute", type_=Text)
|
|
506
|
+
).op("||")(bindparam("derived_patch", type_=JSONB)),
|
|
507
|
+
**self._stamp(),
|
|
508
|
+
)
|
|
509
|
+
)
|
|
510
|
+
for chunk in chunks(list(values), self._writes.insert_batch_rows):
|
|
511
|
+
await self._session.execute(
|
|
512
|
+
statement,
|
|
513
|
+
[
|
|
514
|
+
{
|
|
515
|
+
"record_key": key,
|
|
516
|
+
"derived_attribute": attribute_id,
|
|
517
|
+
"derived_patch": {} if value is None else {attribute_id: value},
|
|
518
|
+
}
|
|
519
|
+
for key, value in chunk
|
|
520
|
+
],
|
|
521
|
+
)
|
|
522
|
+
|
|
523
|
+
async def prune_derived(self, kind_id: str, keep: Sequence[str]) -> None:
|
|
524
|
+
"""Remove derived values of a Kind's Records except those of the `keep` Attributes."""
|
|
525
|
+
await self._before_writes(
|
|
526
|
+
MAT_RECORDS, lambda s: [s.c.kind_id == kind_id, s.c.derived != literal({}, JSONB)]
|
|
527
|
+
)
|
|
528
|
+
kept = text(
|
|
529
|
+
"COALESCE((SELECT jsonb_object_agg(e.key, e.value) FROM jsonb_each(derived) e "
|
|
530
|
+
"WHERE e.key = ANY(:keep)), '{}'::jsonb)"
|
|
531
|
+
).bindparams(bindparam("keep", list(keep)))
|
|
532
|
+
await self._session.execute(
|
|
533
|
+
update(MAT_RECORDS)
|
|
534
|
+
.where(
|
|
535
|
+
self._scope(MAT_RECORDS),
|
|
536
|
+
MAT_RECORDS.c.kind_id == kind_id,
|
|
537
|
+
MAT_RECORDS.c.derived != literal({}, JSONB),
|
|
538
|
+
)
|
|
539
|
+
.values(derived=kept, **self._stamp())
|
|
540
|
+
)
|
|
541
|
+
|
|
542
|
+
async def clear_derived(self, keep_kinds: Sequence[str]) -> None:
|
|
543
|
+
"""Remove every derived value of Records of Kinds other than `keep_kinds`."""
|
|
544
|
+
await self._before_writes(
|
|
545
|
+
MAT_RECORDS,
|
|
546
|
+
lambda s: [
|
|
547
|
+
s.c.kind_id.not_in(list(keep_kinds)),
|
|
548
|
+
s.c.derived != literal({}, JSONB),
|
|
549
|
+
],
|
|
550
|
+
)
|
|
551
|
+
await self._session.execute(
|
|
552
|
+
update(MAT_RECORDS)
|
|
553
|
+
.where(
|
|
554
|
+
self._scope(MAT_RECORDS),
|
|
555
|
+
MAT_RECORDS.c.kind_id.not_in(list(keep_kinds)),
|
|
556
|
+
MAT_RECORDS.c.derived != literal({}, JSONB),
|
|
557
|
+
)
|
|
558
|
+
.values(derived=literal({}, JSONB), **self._stamp())
|
|
559
|
+
)
|
|
560
|
+
|
|
561
|
+
async def linked_keys(
|
|
562
|
+
self, relation_id: str, *, near: str, keys: Sequence[str], limit: int | None = None
|
|
563
|
+
) -> list[tuple[str, str, dict[str, Any]]]:
|
|
564
|
+
"""Stored Links of a Relation whose `near` end (`from_key` or `to_key`) is in `keys`:
|
|
565
|
+
`(near key, far key, link values)`; at most `limit` of them when set."""
|
|
566
|
+
links = await self._links()
|
|
567
|
+
near_column = links.c[near]
|
|
568
|
+
far_column = links.c["to_key" if near == "from_key" else "from_key"]
|
|
569
|
+
found: list[tuple[str, str, dict[str, Any]]] = []
|
|
570
|
+
for chunk in chunks(list(dict.fromkeys(keys)), self._writes.insert_batch_rows):
|
|
571
|
+
if limit is not None and len(found) >= limit:
|
|
572
|
+
break
|
|
573
|
+
query = (
|
|
574
|
+
select(near_column, far_column, links.c["values"])
|
|
575
|
+
.where(
|
|
576
|
+
self._scope(links),
|
|
577
|
+
links.c.relation_id == relation_id,
|
|
578
|
+
near_column.in_(chunk),
|
|
579
|
+
)
|
|
580
|
+
.order_by(near_column, far_column)
|
|
581
|
+
)
|
|
582
|
+
if limit is not None:
|
|
583
|
+
query = query.limit(limit - len(found))
|
|
584
|
+
result = await self._session.execute(query)
|
|
585
|
+
found.extend((a, b, dict(v or {})) for a, b, v in result)
|
|
586
|
+
return found
|
|
587
|
+
|
|
588
|
+
async def foreign_key_links(
|
|
589
|
+
self,
|
|
590
|
+
kind_id: str,
|
|
591
|
+
attribute_id: str,
|
|
592
|
+
*,
|
|
593
|
+
keys: Sequence[str],
|
|
594
|
+
by_target: bool,
|
|
595
|
+
limit: int | None = None,
|
|
596
|
+
) -> list[tuple[str, str]]:
|
|
597
|
+
"""`(from key, to key)` of a foreign-key Relation: Records of `kind_id` (the `from`
|
|
598
|
+
Kind) whose key is in `keys`, or whose `attribute_id` value is in `keys` when
|
|
599
|
+
`by_target`; at most `limit` of them when set."""
|
|
600
|
+
rows = await self._records()
|
|
601
|
+
target = rows.c["values"].op("->>")(literal(attribute_id))
|
|
602
|
+
found: list[tuple[str, str]] = []
|
|
603
|
+
for chunk in chunks(list(dict.fromkeys(keys)), self._writes.insert_batch_rows):
|
|
604
|
+
if limit is not None and len(found) >= limit:
|
|
605
|
+
break
|
|
606
|
+
condition = target.in_(chunk) if by_target else rows.c.key.in_(chunk)
|
|
607
|
+
query = (
|
|
608
|
+
select(rows.c.key, target)
|
|
609
|
+
.where(
|
|
610
|
+
self._scope(rows),
|
|
611
|
+
rows.c.kind_id == kind_id,
|
|
612
|
+
target.is_not(None),
|
|
613
|
+
condition,
|
|
614
|
+
)
|
|
615
|
+
.order_by(rows.c.key)
|
|
616
|
+
)
|
|
617
|
+
if limit is not None:
|
|
618
|
+
query = query.limit(limit - len(found))
|
|
619
|
+
result = await self._session.execute(query)
|
|
620
|
+
found.extend((key, value) for key, value in result)
|
|
621
|
+
return found
|
|
622
|
+
|
|
623
|
+
async def stream_records(self, kind_id: str, *, batch: int) -> AsyncIterator[list[RecordRow]]:
|
|
624
|
+
"""All Records of a Kind in key order, `batch` rows at a time."""
|
|
625
|
+
last: str | None = None
|
|
626
|
+
while True:
|
|
627
|
+
rows = await self.record_page(kind_id, after=last, limit=batch)
|
|
628
|
+
if not rows:
|
|
629
|
+
return
|
|
630
|
+
yield rows
|
|
631
|
+
last = rows[-1].key
|
|
632
|
+
|
|
633
|
+
async def record_page(self, kind_id: str, *, after: str | None, limit: int) -> list[RecordRow]:
|
|
634
|
+
"""Up to `limit` Records of a Kind in key order, after the key `after` when given."""
|
|
635
|
+
rows_from = await self._records()
|
|
636
|
+
query = (
|
|
637
|
+
_record_select(rows_from)
|
|
638
|
+
.where(self._scope(rows_from), rows_from.c.kind_id == kind_id)
|
|
639
|
+
.order_by(rows_from.c.key)
|
|
640
|
+
.limit(limit)
|
|
641
|
+
)
|
|
642
|
+
if after is not None:
|
|
643
|
+
query = query.where(rows_from.c.key > after)
|
|
644
|
+
return [RecordRow(**row) for row in (await self._session.execute(query)).mappings()]
|
|
645
|
+
|
|
646
|
+
async def stream_record_hashes(
|
|
647
|
+
self, kind_id: str, *, batch: int
|
|
648
|
+
) -> AsyncIterator[list[tuple[str, bytes]]]:
|
|
649
|
+
"""`(key, object_hash)` of a Kind's Records in key order."""
|
|
650
|
+
rows_from = await self._records()
|
|
651
|
+
last: str | None = None
|
|
652
|
+
while True:
|
|
653
|
+
query = (
|
|
654
|
+
select(rows_from.c.key, rows_from.c.object_hash)
|
|
655
|
+
.where(self._scope(rows_from), rows_from.c.kind_id == kind_id)
|
|
656
|
+
.order_by(rows_from.c.key)
|
|
657
|
+
.limit(batch)
|
|
658
|
+
)
|
|
659
|
+
if last is not None:
|
|
660
|
+
query = query.where(rows_from.c.key > last)
|
|
661
|
+
rows = [(key, digest) for key, digest in (await self._session.execute(query))]
|
|
662
|
+
if not rows:
|
|
663
|
+
return
|
|
664
|
+
yield rows
|
|
665
|
+
last = rows[-1][0]
|
|
666
|
+
|
|
667
|
+
async def record_keys_with_origin(
|
|
668
|
+
self, kind_id: str, *, origin_type: str, origin_field: str, origin_value: str
|
|
669
|
+
) -> list[str]:
|
|
670
|
+
"""Keys of a Kind's Records whose Lineage names a provenance entry of `origin_type`
|
|
671
|
+
with `origin_ref[origin_field] == origin_value` (for example the Records a Source
|
|
672
|
+
wrote: `source_sync`, `source_id`)."""
|
|
673
|
+
records = (await self.layout()).records_from("r")
|
|
674
|
+
result = await self._session.execute(
|
|
675
|
+
text(_KEYS_WITH_ORIGIN.format(records=records)),
|
|
676
|
+
{
|
|
677
|
+
"domain": self._domain,
|
|
678
|
+
"branch": self._branch,
|
|
679
|
+
"kind": kind_id,
|
|
680
|
+
"origin_type": origin_type,
|
|
681
|
+
"field": origin_field,
|
|
682
|
+
"value": origin_value,
|
|
683
|
+
},
|
|
684
|
+
)
|
|
685
|
+
return [str(key) for key in result.scalars()]
|
|
686
|
+
|
|
687
|
+
async def record_counts(self) -> dict[str, int]:
|
|
688
|
+
"""Number of Records per Kind id."""
|
|
689
|
+
rows = await self._records()
|
|
690
|
+
result = await self._session.execute(
|
|
691
|
+
select(rows.c.kind_id, func.count()).where(self._scope(rows)).group_by(rows.c.kind_id)
|
|
692
|
+
)
|
|
693
|
+
return {kind_id: int(count) for kind_id, count in result}
|
|
694
|
+
|
|
695
|
+
# Links --------------------------------------------------------------------------
|
|
696
|
+
|
|
697
|
+
async def links(
|
|
698
|
+
self, relation_id: str, pairs: Iterable[tuple[str, str]]
|
|
699
|
+
) -> dict[tuple[str, str], LinkRow]:
|
|
700
|
+
rows = await self._links()
|
|
701
|
+
found: dict[tuple[str, str], LinkRow] = {}
|
|
702
|
+
for chunk in chunks(list(dict.fromkeys(pairs)), self._writes.insert_batch_rows):
|
|
703
|
+
result = await self._session.execute(
|
|
704
|
+
_link_select(rows).where(
|
|
705
|
+
self._scope(rows),
|
|
706
|
+
rows.c.relation_id == relation_id,
|
|
707
|
+
pairs_in(rows.c.from_key, rows.c.to_key, chunk),
|
|
708
|
+
)
|
|
709
|
+
)
|
|
710
|
+
for row in result.mappings():
|
|
711
|
+
found[(row["from_key"], row["to_key"])] = LinkRow(**row)
|
|
712
|
+
return found
|
|
713
|
+
|
|
714
|
+
async def upsert_links(self, rows: Sequence[LinkRow]) -> None:
|
|
715
|
+
if not rows:
|
|
716
|
+
return
|
|
717
|
+
by_relation: dict[str, list[tuple[str, str]]] = {}
|
|
718
|
+
for row in rows:
|
|
719
|
+
by_relation.setdefault(row.relation_id, []).append((row.from_key, row.to_key))
|
|
720
|
+
for relation_id, pairs in by_relation.items():
|
|
721
|
+
await self._before_link_writes(relation_id, pairs)
|
|
722
|
+
await bulk_write(
|
|
723
|
+
self._session,
|
|
724
|
+
MAT_LINKS,
|
|
725
|
+
[self._link_values(row) for row in rows],
|
|
726
|
+
writes=self._writes,
|
|
727
|
+
on_conflict=OnConflict.UPDATE,
|
|
728
|
+
update_columns=_LINK_UPDATE_COLUMNS,
|
|
729
|
+
)
|
|
730
|
+
|
|
731
|
+
async def delete_links(self, relation_id: str, pairs: Sequence[tuple[str, str]]) -> None:
|
|
732
|
+
await self._before_link_writes(relation_id, pairs)
|
|
733
|
+
overlay = (await self.layout()).overlay
|
|
734
|
+
for chunk in chunks(pairs, self._writes.insert_batch_rows):
|
|
735
|
+
where = (
|
|
736
|
+
self._scope(MAT_LINKS),
|
|
737
|
+
MAT_LINKS.c.relation_id == relation_id,
|
|
738
|
+
pairs_in(MAT_LINKS.c.from_key, MAT_LINKS.c.to_key, chunk),
|
|
739
|
+
)
|
|
740
|
+
if overlay:
|
|
741
|
+
await self._session.execute(
|
|
742
|
+
update(MAT_LINKS).where(*where).values(deleted=true(), **self._stamp())
|
|
743
|
+
)
|
|
744
|
+
else:
|
|
745
|
+
await self._session.execute(delete(MAT_LINKS).where(*where))
|
|
746
|
+
|
|
747
|
+
async def stream_links(self, relation_id: str, *, batch: int) -> AsyncIterator[list[LinkRow]]:
|
|
748
|
+
"""All Links of a Relation in `(from_key, to_key)` order."""
|
|
749
|
+
links = await self._links()
|
|
750
|
+
last: tuple[str, str] | None = None
|
|
751
|
+
while True:
|
|
752
|
+
query = (
|
|
753
|
+
_link_select(links)
|
|
754
|
+
.where(self._scope(links), links.c.relation_id == relation_id)
|
|
755
|
+
.order_by(links.c.from_key, links.c.to_key)
|
|
756
|
+
.limit(batch)
|
|
757
|
+
)
|
|
758
|
+
if last is not None:
|
|
759
|
+
query = query.where(tuple_(links.c.from_key, links.c.to_key) > last)
|
|
760
|
+
rows = [LinkRow(**row) for row in (await self._session.execute(query)).mappings()]
|
|
761
|
+
if not rows:
|
|
762
|
+
return
|
|
763
|
+
yield rows
|
|
764
|
+
last = (rows[-1].from_key, rows[-1].to_key)
|
|
765
|
+
|
|
766
|
+
async def link_counts(self, *, derived: bool = False) -> dict[str, int]:
|
|
767
|
+
"""Number of Links per Relation id: stored Links of the Relations of the tree, or with
|
|
768
|
+
`derived` the Links of materialized path Derivations (keyed by Derivation id)."""
|
|
769
|
+
links = await self._links()
|
|
770
|
+
is_derived = links.c.relation_id.startswith(DERIVED_LINK_PREFIX, autoescape=True)
|
|
771
|
+
result = await self._session.execute(
|
|
772
|
+
select(links.c.relation_id, func.count())
|
|
773
|
+
.where(self._scope(links), is_derived if derived else ~is_derived)
|
|
774
|
+
.group_by(links.c.relation_id)
|
|
775
|
+
)
|
|
776
|
+
return {relation_id: int(count) for relation_id, count in result}
|
|
777
|
+
|
|
778
|
+
# History ------------------------------------------------------------------------
|
|
779
|
+
|
|
780
|
+
async def insert_history(self, rows: Sequence[HistoryRow]) -> None:
|
|
781
|
+
await bulk_write(
|
|
782
|
+
self._session,
|
|
783
|
+
MAT_HISTORY,
|
|
784
|
+
[
|
|
785
|
+
{
|
|
786
|
+
"domain_id": self._domain,
|
|
787
|
+
"branch": self._branch,
|
|
788
|
+
"partition": row.partition,
|
|
789
|
+
"element_key": row.element_key,
|
|
790
|
+
"revision_id": row.revision_id,
|
|
791
|
+
"at": row.at,
|
|
792
|
+
"old_hash": row.old_hash,
|
|
793
|
+
"new_hash": row.new_hash,
|
|
794
|
+
"provenance": row.provenance,
|
|
795
|
+
}
|
|
796
|
+
for row in rows
|
|
797
|
+
],
|
|
798
|
+
writes=self._writes,
|
|
799
|
+
on_conflict=OnConflict.ERROR,
|
|
800
|
+
)
|
|
801
|
+
|
|
802
|
+
async def first_parent_range(
|
|
803
|
+
self, head: bytes, stop: bytes, *, limit: int
|
|
804
|
+
) -> list[bytes] | None:
|
|
805
|
+
"""The Revisions from `head` down the first-parent chain to `stop` (excluded), when
|
|
806
|
+
`stop` is reached within `limit` of them; None otherwise."""
|
|
807
|
+
if limit <= 0 or head == stop:
|
|
808
|
+
return None
|
|
809
|
+
found = await self._session.execute(
|
|
810
|
+
_CHAIN_TO, {"head": head, "stop": stop, "domain": self._domain, "limit": limit}
|
|
811
|
+
)
|
|
812
|
+
ids = [bytes(row[0]) for row in found]
|
|
813
|
+
if ids[-1] != stop:
|
|
814
|
+
return None
|
|
815
|
+
return ids[:-1]
|
|
816
|
+
|
|
817
|
+
async def insert_chain_history(self, revisions: Sequence[bytes]) -> None:
|
|
818
|
+
"""History rows of the changesets of `revisions`, as `rebuild_history` writes them."""
|
|
819
|
+
await self._session.execute(
|
|
820
|
+
_INSERT_CHAIN_HISTORY,
|
|
821
|
+
{"domain": self._domain, "branch": self._branch, "ids": list(revisions)},
|
|
822
|
+
)
|
|
823
|
+
|
|
824
|
+
async def history(self, partition: str, element_key: str) -> list[HistoryRow]:
|
|
825
|
+
"""Changes of one element on this branch, oldest first."""
|
|
826
|
+
history = (await self.layout()).history()
|
|
827
|
+
result = await self._session.execute(
|
|
828
|
+
select(
|
|
829
|
+
history.c.partition,
|
|
830
|
+
history.c.element_key,
|
|
831
|
+
history.c.revision_id,
|
|
832
|
+
history.c.at,
|
|
833
|
+
history.c.old_hash,
|
|
834
|
+
history.c.new_hash,
|
|
835
|
+
history.c.provenance,
|
|
836
|
+
)
|
|
837
|
+
.join(
|
|
838
|
+
REVISIONS,
|
|
839
|
+
and_(
|
|
840
|
+
REVISIONS.c.domain_id == history.c.domain_id,
|
|
841
|
+
REVISIONS.c.id == history.c.revision_id,
|
|
842
|
+
),
|
|
843
|
+
)
|
|
844
|
+
.where(
|
|
845
|
+
self._scope(history),
|
|
846
|
+
history.c.partition == partition,
|
|
847
|
+
history.c.element_key == element_key,
|
|
848
|
+
)
|
|
849
|
+
.order_by(REVISIONS.c.generation)
|
|
850
|
+
)
|
|
851
|
+
return [HistoryRow(**row) for row in result.mappings()]
|
|
852
|
+
|
|
853
|
+
async def rebuild_history(self, head: bytes) -> None:
|
|
854
|
+
"""Replace this branch's history with the changesets of `head`'s first-parent chain."""
|
|
855
|
+
await self._session.execute(delete(MAT_HISTORY).where(self._scope(MAT_HISTORY)))
|
|
856
|
+
await self._session.execute(
|
|
857
|
+
_REBUILD_HISTORY, {"head": head, "domain": self._domain, "branch": self._branch}
|
|
858
|
+
)
|
|
859
|
+
|
|
860
|
+
async def stream_history_steps(
|
|
861
|
+
self, partition: str, *, batch: int, keys: Sequence[str] | None = None
|
|
862
|
+
) -> AsyncIterator[list[HistoryStep]]:
|
|
863
|
+
"""History of a partition (of its elements in `keys`, when given) ordered by element
|
|
864
|
+
key, then oldest change first."""
|
|
865
|
+
history = (await self.layout()).history()
|
|
866
|
+
query = (
|
|
867
|
+
select(
|
|
868
|
+
history.c.element_key,
|
|
869
|
+
history.c.revision_id,
|
|
870
|
+
history.c.at,
|
|
871
|
+
history.c.new_hash,
|
|
872
|
+
history.c.provenance,
|
|
873
|
+
history.c.old_hash,
|
|
874
|
+
(REVISIONS.c.origin["type"].astext == MERGE_ORIGIN_TYPE).label("merge"),
|
|
875
|
+
)
|
|
876
|
+
.join(
|
|
877
|
+
REVISIONS,
|
|
878
|
+
and_(
|
|
879
|
+
REVISIONS.c.domain_id == history.c.domain_id,
|
|
880
|
+
REVISIONS.c.id == history.c.revision_id,
|
|
881
|
+
),
|
|
882
|
+
)
|
|
883
|
+
.where(self._scope(history), history.c.partition == partition)
|
|
884
|
+
.order_by(history.c.element_key, REVISIONS.c.generation)
|
|
885
|
+
)
|
|
886
|
+
if keys is not None:
|
|
887
|
+
query = query.where(history.c.element_key.in_(list(keys)))
|
|
888
|
+
result = await self._session.stream(query)
|
|
889
|
+
async for part in result.partitions(batch):
|
|
890
|
+
yield [HistoryStep(*row) for row in part]
|
|
891
|
+
|
|
892
|
+
# Whole branch -------------------------------------------------------------------
|
|
893
|
+
|
|
894
|
+
async def clear(self) -> None:
|
|
895
|
+
"""Delete every materialized row of the branch (its own rows, for an overlay)."""
|
|
896
|
+
if await self.dependents():
|
|
897
|
+
raise RuntimeError(
|
|
898
|
+
f"branch {self._branch!r} has overlay branches; promote them before clearing it"
|
|
899
|
+
)
|
|
900
|
+
for target in (MAT_RECORDS, MAT_LINKS, MAT_HISTORY, MAT_SCHEMA):
|
|
901
|
+
await self._session.execute(delete(target).where(self._scope(target)))
|
|
902
|
+
|
|
903
|
+
async def copy_from(self, source: str | BranchLayout) -> None:
|
|
904
|
+
"""Copy every materialized row of `source` into this (empty) branch; an overlay source
|
|
905
|
+
gives its resolved rows."""
|
|
906
|
+
layout = (
|
|
907
|
+
source
|
|
908
|
+
if isinstance(source, BranchLayout)
|
|
909
|
+
else await load_layout(self._session, self._domain, source)
|
|
910
|
+
)
|
|
911
|
+
sources = (
|
|
912
|
+
(MAT_SCHEMA, MAT_SCHEMA),
|
|
913
|
+
(MAT_RECORDS, layout.records()),
|
|
914
|
+
(MAT_LINKS, layout.links()),
|
|
915
|
+
(MAT_HISTORY, layout.history()),
|
|
916
|
+
)
|
|
917
|
+
for target, rows in sources:
|
|
918
|
+
names = [c.name for c in target.columns]
|
|
919
|
+
selected = select(
|
|
920
|
+
*(
|
|
921
|
+
literal(self._branch).label("branch") if name == "branch" else rows.c[name]
|
|
922
|
+
for name in names
|
|
923
|
+
)
|
|
924
|
+
).where(rows.c.domain_id == self._domain, rows.c.branch == layout.branch)
|
|
925
|
+
await self._session.execute(insert(target).from_select(names, selected))
|
|
926
|
+
|
|
927
|
+
async def complete_overlay(self) -> None:
|
|
928
|
+
"""Turn this overlay's rows into a full head: copy the base rows it reads (as of its
|
|
929
|
+
fork point), drop its tombstones and copy the base history up to the fork."""
|
|
930
|
+
layout = await self.layout()
|
|
931
|
+
if not layout.overlay:
|
|
932
|
+
return
|
|
933
|
+
for table in (MAT_RECORDS, MAT_LINKS):
|
|
934
|
+
await self._copy(table, layout, lambda _: ())
|
|
935
|
+
await self._session.execute(
|
|
936
|
+
delete(table).where(self._scope(table), table.c.deleted.is_(true()))
|
|
937
|
+
)
|
|
938
|
+
names = [c.name for c in MAT_HISTORY.columns]
|
|
939
|
+
history = layout.history()
|
|
940
|
+
await self._session.execute(
|
|
941
|
+
pg_insert(MAT_HISTORY)
|
|
942
|
+
.from_select(
|
|
943
|
+
names,
|
|
944
|
+
select(*(history.c[name] for name in names)).where(
|
|
945
|
+
history.c.branch == self._branch,
|
|
946
|
+
history.c.domain_id == self._domain,
|
|
947
|
+
),
|
|
948
|
+
)
|
|
949
|
+
.on_conflict_do_nothing()
|
|
950
|
+
)
|
|
951
|
+
|
|
952
|
+
async def own_rows(self, *, limit: int) -> int:
|
|
953
|
+
"""Rows (Records, Links, tombstones) stored for this branch, counted up to `limit`."""
|
|
954
|
+
total = 0
|
|
955
|
+
for table in (MAT_RECORDS, MAT_LINKS):
|
|
956
|
+
bounded: Select[Any] = (
|
|
957
|
+
select(literal_column("1")).where(self._scope(table)).limit(limit + 1)
|
|
958
|
+
)
|
|
959
|
+
total += int(
|
|
960
|
+
await self._session.scalar(select(func.count()).select_from(bounded.subquery()))
|
|
961
|
+
or 0
|
|
962
|
+
)
|
|
963
|
+
return total
|
|
964
|
+
|
|
965
|
+
# Overlay bookkeeping ------------------------------------------------------------
|
|
966
|
+
|
|
967
|
+
def _stamp(self) -> dict[str, Any]:
|
|
968
|
+
"""`revision_generation` of a write, when the writing Revision is known."""
|
|
969
|
+
return {} if self._generation is None else {"revision_generation": self._generation}
|
|
970
|
+
|
|
971
|
+
async def _before_record_writes(self, kind_id: str, keys: Sequence[str]) -> None:
|
|
972
|
+
for chunk in chunks(list(dict.fromkeys(keys)), self._writes.insert_batch_rows):
|
|
973
|
+
await self._before_writes(MAT_RECORDS, _record_keys(kind_id, chunk))
|
|
974
|
+
|
|
975
|
+
async def _before_link_writes(self, relation_id: str, pairs: Sequence[tuple[str, str]]) -> None:
|
|
976
|
+
for chunk in chunks(list(dict.fromkeys(pairs)), self._writes.insert_batch_rows):
|
|
977
|
+
await self._before_writes(MAT_LINKS, _link_keys(relation_id, chunk))
|
|
978
|
+
|
|
979
|
+
async def _before_writes(self, table: Table, where: Where) -> None:
|
|
980
|
+
"""Preserve the rows matching `where` before they change (see the class doc)."""
|
|
981
|
+
layout = await self.layout()
|
|
982
|
+
if layout.overlay:
|
|
983
|
+
await self._copy(table, layout, where)
|
|
984
|
+
return
|
|
985
|
+
dependents = await self.dependents()
|
|
986
|
+
if not dependents:
|
|
987
|
+
return
|
|
988
|
+
if self._generation is None:
|
|
989
|
+
raise RuntimeError(
|
|
990
|
+
f"writing branch {self._branch!r}, which has overlay branches, needs the "
|
|
991
|
+
"generation of the writing Revision"
|
|
992
|
+
)
|
|
993
|
+
source = table.alias("s")
|
|
994
|
+
targets = (
|
|
995
|
+
func.unnest(
|
|
996
|
+
bindparam("cow_names", [name for name, _ in dependents], type_=ARRAY(Text())),
|
|
997
|
+
bindparam(
|
|
998
|
+
"cow_generations", [gen for _, gen in dependents], type_=ARRAY(BigInteger())
|
|
999
|
+
),
|
|
1000
|
+
)
|
|
1001
|
+
.table_valued("name", "generation")
|
|
1002
|
+
.render_derived(name="d")
|
|
1003
|
+
)
|
|
1004
|
+
names = [c.name for c in table.columns]
|
|
1005
|
+
selected = (
|
|
1006
|
+
select(*(_copied(source, name, targets.c.name) for name in names))
|
|
1007
|
+
.select_from(source.join(targets, source.c.revision_generation <= targets.c.generation))
|
|
1008
|
+
.where(
|
|
1009
|
+
source.c.domain_id == self._domain,
|
|
1010
|
+
source.c.branch == self._branch,
|
|
1011
|
+
*where(source),
|
|
1012
|
+
)
|
|
1013
|
+
)
|
|
1014
|
+
await self._session.execute(
|
|
1015
|
+
pg_insert(table).from_select(names, selected).on_conflict_do_nothing()
|
|
1016
|
+
)
|
|
1017
|
+
|
|
1018
|
+
async def _copy(self, table: Table, layout: BranchLayout, where: Where) -> None:
|
|
1019
|
+
"""Copy the base rows an overlay reads (those matching `where`) into it, keeping the
|
|
1020
|
+
rows it already holds."""
|
|
1021
|
+
assert layout.base is not None
|
|
1022
|
+
assert layout.base_generation is not None
|
|
1023
|
+
source = table.alias("s")
|
|
1024
|
+
names = [c.name for c in table.columns]
|
|
1025
|
+
selected = select(*(_copied(source, name, literal(self._branch)) for name in names)).where(
|
|
1026
|
+
source.c.domain_id == self._domain,
|
|
1027
|
+
source.c.branch == layout.base,
|
|
1028
|
+
source.c.revision_generation <= layout.base_generation,
|
|
1029
|
+
*where(source),
|
|
1030
|
+
)
|
|
1031
|
+
await self._session.execute(
|
|
1032
|
+
pg_insert(table).from_select(names, selected).on_conflict_do_nothing()
|
|
1033
|
+
)
|
|
1034
|
+
|
|
1035
|
+
async def _revive_records(self, kind_id: str, keys: Sequence[str]) -> None:
|
|
1036
|
+
"""Tombstones about to be written again start like new rows (no derived values)."""
|
|
1037
|
+
for chunk in chunks(list(dict.fromkeys(keys)), self._writes.insert_batch_rows):
|
|
1038
|
+
await self._session.execute(
|
|
1039
|
+
update(MAT_RECORDS)
|
|
1040
|
+
.where(
|
|
1041
|
+
self._scope(MAT_RECORDS),
|
|
1042
|
+
MAT_RECORDS.c.kind_id == kind_id,
|
|
1043
|
+
MAT_RECORDS.c.key.in_(chunk),
|
|
1044
|
+
MAT_RECORDS.c.deleted.is_(true()),
|
|
1045
|
+
)
|
|
1046
|
+
.values(derived=literal({}, JSONB), search=None)
|
|
1047
|
+
)
|
|
1048
|
+
|
|
1049
|
+
# Row mapping --------------------------------------------------------------------
|
|
1050
|
+
|
|
1051
|
+
def _record_values(self, row: RecordRow) -> dict[str, Any]:
|
|
1052
|
+
return {
|
|
1053
|
+
"domain_id": self._domain,
|
|
1054
|
+
"branch": self._branch,
|
|
1055
|
+
"kind_id": row.kind_id,
|
|
1056
|
+
"key": row.key,
|
|
1057
|
+
"values": row.values,
|
|
1058
|
+
"title": row.title,
|
|
1059
|
+
"object_hash": row.object_hash,
|
|
1060
|
+
"lineage": row.lineage,
|
|
1061
|
+
"updated_revision": row.updated_revision,
|
|
1062
|
+
"updated_at": row.updated_at,
|
|
1063
|
+
"revision_generation": self._generation or 0,
|
|
1064
|
+
"deleted": False,
|
|
1065
|
+
"lineage_revisions": row.lineage_revisions,
|
|
1066
|
+
}
|
|
1067
|
+
|
|
1068
|
+
def _link_values(self, row: LinkRow) -> dict[str, Any]:
|
|
1069
|
+
return {
|
|
1070
|
+
"domain_id": self._domain,
|
|
1071
|
+
"branch": self._branch,
|
|
1072
|
+
"relation_id": row.relation_id,
|
|
1073
|
+
"from_key": row.from_key,
|
|
1074
|
+
"to_key": row.to_key,
|
|
1075
|
+
"values": row.values,
|
|
1076
|
+
"object_hash": row.object_hash,
|
|
1077
|
+
"lineage": row.lineage,
|
|
1078
|
+
"revision_generation": self._generation or 0,
|
|
1079
|
+
"deleted": False,
|
|
1080
|
+
}
|
|
1081
|
+
|
|
1082
|
+
|
|
1083
|
+
def _record_select(rows: FromClause) -> Any:
|
|
1084
|
+
c = rows.c
|
|
1085
|
+
return select(
|
|
1086
|
+
c.kind_id,
|
|
1087
|
+
c.key,
|
|
1088
|
+
c["values"],
|
|
1089
|
+
c.title,
|
|
1090
|
+
c.object_hash,
|
|
1091
|
+
lineage_column(rows),
|
|
1092
|
+
c.updated_revision,
|
|
1093
|
+
c.updated_at,
|
|
1094
|
+
c.derived,
|
|
1095
|
+
lineage_column(rows, "lineage_revisions"),
|
|
1096
|
+
)
|
|
1097
|
+
|
|
1098
|
+
|
|
1099
|
+
def _link_select(rows: FromClause) -> Any:
|
|
1100
|
+
c = rows.c
|
|
1101
|
+
return select(
|
|
1102
|
+
c.relation_id, c.from_key, c.to_key, c["values"], c.object_hash, lineage_column(rows)
|
|
1103
|
+
)
|
|
1104
|
+
|
|
1105
|
+
|
|
1106
|
+
def _record_keys(kind_id: str, keys: Sequence[str]) -> Where:
|
|
1107
|
+
def where(rows: FromClause) -> list[ColumnElement[bool]]:
|
|
1108
|
+
return [rows.c.kind_id == kind_id, rows.c.key.in_(keys)]
|
|
1109
|
+
|
|
1110
|
+
return where
|
|
1111
|
+
|
|
1112
|
+
|
|
1113
|
+
def _link_keys(relation_id: str, pairs: Sequence[tuple[str, str]]) -> Where:
|
|
1114
|
+
def where(rows: FromClause) -> list[ColumnElement[bool]]:
|
|
1115
|
+
return [
|
|
1116
|
+
rows.c.relation_id == relation_id,
|
|
1117
|
+
pairs_in(rows.c.from_key, rows.c.to_key, pairs),
|
|
1118
|
+
]
|
|
1119
|
+
|
|
1120
|
+
return where
|
|
1121
|
+
|
|
1122
|
+
|
|
1123
|
+
def _copied(source: FromClause, name: str, branch: ColumnElement[Any]) -> ColumnElement[Any]:
|
|
1124
|
+
"""Column `name` of a row copied from `source` into another branch."""
|
|
1125
|
+
if name == "branch":
|
|
1126
|
+
return branch.label("branch")
|
|
1127
|
+
if name == "deleted":
|
|
1128
|
+
return false().label("deleted")
|
|
1129
|
+
return source.c[name]
|