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,321 @@
|
|
|
1
|
+
"""Verification of a materialized head against its tree (design 6.11, invariant I4).
|
|
2
|
+
|
|
3
|
+
Recomputes what the tree at the branch head says the materialized rows must be and compares:
|
|
4
|
+
the schema partition hash, the set of `(key, object_hash)` of every Kind and Relation
|
|
5
|
+
partition, and the per-partition counts. With `deep=True`, Record and Link values and Record
|
|
6
|
+
titles are also recomputed from the stored objects. With `derived=True`, derived values are
|
|
7
|
+
recomputed from the materialized Records and Links (`materialize.derived`, nothing written) and
|
|
8
|
+
compared with the stored ones, and so are the Links of materialized path Derivations
|
|
9
|
+
(`materialize.paths`), which the tree comparison leaves out.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
from collections.abc import Mapping
|
|
15
|
+
from dataclasses import dataclass, field
|
|
16
|
+
from enum import StrEnum
|
|
17
|
+
from typing import Any
|
|
18
|
+
|
|
19
|
+
from sqlalchemy.ext.asyncio import AsyncSession
|
|
20
|
+
|
|
21
|
+
from lorebase_core.model import Schema
|
|
22
|
+
from lorebase_core.tree import PARTITION_SCHEMA, kind_partition, link_key, relation_partition
|
|
23
|
+
from lorebase_store.commit.schema import load_compiled
|
|
24
|
+
from lorebase_store.materialize.derived import DerivedValues
|
|
25
|
+
from lorebase_store.materialize.paths import PathLinks
|
|
26
|
+
from lorebase_store.materialize.rebuild import load_schema
|
|
27
|
+
from lorebase_store.materialize.rows import (
|
|
28
|
+
PartitionType,
|
|
29
|
+
parse_partition,
|
|
30
|
+
record_title,
|
|
31
|
+
)
|
|
32
|
+
from lorebase_store.materialize.sensitivity import branch_tagged_schema
|
|
33
|
+
from lorebase_store.nodestore import NodeRepository
|
|
34
|
+
from lorebase_store.objectstore import PgObjectStore
|
|
35
|
+
from lorebase_store.repositories.materialized import MaterializedRepository
|
|
36
|
+
from lorebase_store.repositories.refs import RefRepository
|
|
37
|
+
from lorebase_store.repositories.revisions import RevisionRepository
|
|
38
|
+
from lorebase_store.repositories.scope import scope_to_domain
|
|
39
|
+
from lorebase_store.runtime import StoreRuntime
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class DivergenceReason(StrEnum):
|
|
43
|
+
SCHEMA_MISSING = "schema_missing"
|
|
44
|
+
SCHEMA_MISMATCH = "schema_mismatch"
|
|
45
|
+
MISSING_ROW = "missing_row"
|
|
46
|
+
EXTRA_ROW = "extra_row"
|
|
47
|
+
HASH_MISMATCH = "hash_mismatch"
|
|
48
|
+
VALUES_MISMATCH = "values_mismatch"
|
|
49
|
+
COUNT_MISMATCH = "count_mismatch"
|
|
50
|
+
DERIVED_MISMATCH = "derived_mismatch"
|
|
51
|
+
SENSITIVITY_STALE = "sensitivity_stale"
|
|
52
|
+
"""A title materialized before the default branch tagged the title Attribute: stale,
|
|
53
|
+
not corrupt; the next change of the Kind on the branch, or a rebuild, recomputes it."""
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
@dataclass(frozen=True, slots=True)
|
|
57
|
+
class Divergence:
|
|
58
|
+
"""One difference between the tree-derived view and the materialized rows.
|
|
59
|
+
|
|
60
|
+
`expected` comes from the tree, `actual` from the materialized rows.
|
|
61
|
+
"""
|
|
62
|
+
|
|
63
|
+
reason: DivergenceReason
|
|
64
|
+
partition: str
|
|
65
|
+
key: str | None = None
|
|
66
|
+
expected: Any = None
|
|
67
|
+
actual: Any = None
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
@dataclass
|
|
71
|
+
class VerifyReport:
|
|
72
|
+
domain_id: str
|
|
73
|
+
branch: str
|
|
74
|
+
head: bytes
|
|
75
|
+
partitions: int = 0
|
|
76
|
+
rows: int = 0
|
|
77
|
+
divergences: list[Divergence] = field(default_factory=list)
|
|
78
|
+
truncated: bool = False
|
|
79
|
+
_limit: int = 1000
|
|
80
|
+
|
|
81
|
+
@property
|
|
82
|
+
def ok(self) -> bool:
|
|
83
|
+
return not self.divergences
|
|
84
|
+
|
|
85
|
+
def add(self, divergence: Divergence) -> None:
|
|
86
|
+
if len(self.divergences) >= self._limit:
|
|
87
|
+
self.truncated = True
|
|
88
|
+
return
|
|
89
|
+
self.divergences.append(divergence)
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
async def verify(
|
|
93
|
+
session: AsyncSession,
|
|
94
|
+
domain_id: str,
|
|
95
|
+
branch: str,
|
|
96
|
+
*,
|
|
97
|
+
runtime: StoreRuntime,
|
|
98
|
+
deep: bool = False,
|
|
99
|
+
derived: bool = False,
|
|
100
|
+
) -> VerifyReport:
|
|
101
|
+
"""Compare the materialized head of `branch` with the tree at its head (and, with
|
|
102
|
+
`derived`, its derived values with recomputed ones)."""
|
|
103
|
+
settings = runtime.settings
|
|
104
|
+
batch = settings.materialize.read_batch_rows
|
|
105
|
+
await scope_to_domain(session, domain_id)
|
|
106
|
+
ref = await RefRepository(session, domain_id).require(branch)
|
|
107
|
+
head = await RevisionRepository(session, domain_id).require(ref.head)
|
|
108
|
+
report = VerifyReport(
|
|
109
|
+
domain_id, branch, head.id, _limit=settings.materialize.verify_max_divergences
|
|
110
|
+
)
|
|
111
|
+
repository = MaterializedRepository(session, domain_id, branch, writes=settings.writes)
|
|
112
|
+
nodes = NodeRepository(
|
|
113
|
+
session, domain_id, writes=settings.writes, batch=settings.nodes.prefetch_batch
|
|
114
|
+
)
|
|
115
|
+
objects = PgObjectStore(session, domain_id, writes=settings.writes, batch=batch)
|
|
116
|
+
tree = await nodes.load_tree(head.root)
|
|
117
|
+
|
|
118
|
+
schema, schema_hash = await load_schema(session, domain_id, tree, runtime=runtime)
|
|
119
|
+
tagged = await branch_tagged_schema(session, domain_id, branch, schema, runtime=runtime)
|
|
120
|
+
stored_schema = await repository.schema()
|
|
121
|
+
if stored_schema is None:
|
|
122
|
+
report.add(Divergence(DivergenceReason.SCHEMA_MISSING, PARTITION_SCHEMA))
|
|
123
|
+
elif stored_schema.schema_hash != schema_hash or (
|
|
124
|
+
deep and load_compiled(stored_schema.compiled) != schema
|
|
125
|
+
):
|
|
126
|
+
report.add(
|
|
127
|
+
Divergence(
|
|
128
|
+
DivergenceReason.SCHEMA_MISMATCH,
|
|
129
|
+
PARTITION_SCHEMA,
|
|
130
|
+
expected=schema_hash.hex(),
|
|
131
|
+
actual=stored_schema.schema_hash.hex(),
|
|
132
|
+
)
|
|
133
|
+
)
|
|
134
|
+
|
|
135
|
+
counts = {kind_partition(k): n for k, n in (await repository.record_counts()).items()}
|
|
136
|
+
counts.update({relation_partition(r): n for r, n in (await repository.link_counts()).items()})
|
|
137
|
+
partitions = {p: ref.count for p, ref in tree.partitions.items() if p != PARTITION_SCHEMA}
|
|
138
|
+
for partition in sorted(partitions.keys() | counts.keys()):
|
|
139
|
+
report.partitions += 1
|
|
140
|
+
expected_count = partitions.get(partition, 0)
|
|
141
|
+
actual_count = counts.get(partition, 0)
|
|
142
|
+
if expected_count != actual_count:
|
|
143
|
+
report.add(
|
|
144
|
+
Divergence(
|
|
145
|
+
DivergenceReason.COUNT_MISMATCH,
|
|
146
|
+
partition,
|
|
147
|
+
expected=expected_count,
|
|
148
|
+
actual=actual_count,
|
|
149
|
+
)
|
|
150
|
+
)
|
|
151
|
+
expected: dict[str, bytes] = {}
|
|
152
|
+
if partition in tree.partitions:
|
|
153
|
+
async for entries in nodes.scan(tree.partitions[partition].root):
|
|
154
|
+
expected.update(entries)
|
|
155
|
+
actual = await _materialized(repository, partition, batch=batch)
|
|
156
|
+
report.rows += len(actual)
|
|
157
|
+
_compare_hashes(report, partition, expected, {k: v[0] for k, v in actual.items()})
|
|
158
|
+
if deep:
|
|
159
|
+
await _compare_values(report, partition, expected, actual, objects, tagged, schema)
|
|
160
|
+
if derived:
|
|
161
|
+
await _compare_derived(report, repository, schema, runtime, head.created_at)
|
|
162
|
+
await _compare_paths(report, repository, schema, runtime)
|
|
163
|
+
return report
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
async def _compare_derived(
|
|
167
|
+
report: VerifyReport,
|
|
168
|
+
repository: MaterializedRepository,
|
|
169
|
+
schema: Schema,
|
|
170
|
+
runtime: StoreRuntime,
|
|
171
|
+
at: Any,
|
|
172
|
+
) -> None:
|
|
173
|
+
"""Recompute every derived value without writing and compare with the stored ones."""
|
|
174
|
+
settings = runtime.settings.materialize
|
|
175
|
+
recomputed = DerivedValues(
|
|
176
|
+
repository,
|
|
177
|
+
schema,
|
|
178
|
+
limits=runtime.schema.expression_limits,
|
|
179
|
+
now=at,
|
|
180
|
+
batch=settings.read_batch_rows,
|
|
181
|
+
max_incremental=settings.derivation_max_incremental_records,
|
|
182
|
+
fanout=runtime.settings.traverse.path_fanout,
|
|
183
|
+
write=False,
|
|
184
|
+
)
|
|
185
|
+
await recomputed.recompute_all()
|
|
186
|
+
for kind_id in sorted(await repository.record_counts()):
|
|
187
|
+
async for rows in repository.stream_records(kind_id, batch=settings.read_batch_rows):
|
|
188
|
+
for row in rows:
|
|
189
|
+
want = recomputed.expected.get((kind_id, row.key), {})
|
|
190
|
+
if row.derived != want:
|
|
191
|
+
report.add(
|
|
192
|
+
Divergence(
|
|
193
|
+
DivergenceReason.DERIVED_MISMATCH,
|
|
194
|
+
kind_partition(kind_id),
|
|
195
|
+
row.key,
|
|
196
|
+
expected=want,
|
|
197
|
+
actual=row.derived,
|
|
198
|
+
)
|
|
199
|
+
)
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
async def _compare_paths(
|
|
203
|
+
report: VerifyReport, repository: MaterializedRepository, schema: Schema, runtime: StoreRuntime
|
|
204
|
+
) -> None:
|
|
205
|
+
"""Recompute the Links of every materialized path Derivation without writing and compare
|
|
206
|
+
them with the stored ones (and report Links of paths no longer materialized)."""
|
|
207
|
+
settings = runtime.settings
|
|
208
|
+
paths = PathLinks(
|
|
209
|
+
repository,
|
|
210
|
+
schema,
|
|
211
|
+
fanout=settings.traverse.path_fanout,
|
|
212
|
+
batch=settings.materialize.read_batch_rows,
|
|
213
|
+
max_incremental=settings.materialize.derivation_max_incremental_records,
|
|
214
|
+
write=False,
|
|
215
|
+
)
|
|
216
|
+
known = {path.id for path in paths.paths}
|
|
217
|
+
for path_id, count in sorted((await repository.link_counts(derived=True)).items()):
|
|
218
|
+
if path_id not in known:
|
|
219
|
+
report.add(
|
|
220
|
+
Divergence(
|
|
221
|
+
DivergenceReason.DERIVED_MISMATCH,
|
|
222
|
+
relation_partition(path_id),
|
|
223
|
+
expected=0,
|
|
224
|
+
actual=count,
|
|
225
|
+
)
|
|
226
|
+
)
|
|
227
|
+
for path in paths.paths:
|
|
228
|
+
await paths.compute_all(path)
|
|
229
|
+
want = paths.expected.get(path.id, set())
|
|
230
|
+
got = await paths.stored(path)
|
|
231
|
+
for from_key, to_key in sorted(want ^ got):
|
|
232
|
+
report.add(
|
|
233
|
+
Divergence(
|
|
234
|
+
DivergenceReason.DERIVED_MISMATCH,
|
|
235
|
+
relation_partition(path.id),
|
|
236
|
+
link_key(from_key, to_key),
|
|
237
|
+
expected=(from_key, to_key) in want,
|
|
238
|
+
actual=(from_key, to_key) in got,
|
|
239
|
+
)
|
|
240
|
+
)
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
async def _materialized(
|
|
244
|
+
repository: MaterializedRepository, partition: str, *, batch: int
|
|
245
|
+
) -> dict[str, tuple[bytes, dict[str, Any] | None, str | None]]:
|
|
246
|
+
"""`key -> (object_hash, values, title)` of a partition's materialized rows."""
|
|
247
|
+
parsed = parse_partition(partition)
|
|
248
|
+
rows: dict[str, tuple[bytes, dict[str, Any] | None, str | None]] = {}
|
|
249
|
+
if parsed.type is PartitionType.KIND:
|
|
250
|
+
async for records in repository.stream_records(parsed.element_id, batch=batch):
|
|
251
|
+
rows.update((r.key, (r.object_hash, r.values, r.title)) for r in records)
|
|
252
|
+
else:
|
|
253
|
+
async for links in repository.stream_links(parsed.element_id, batch=batch):
|
|
254
|
+
rows.update(
|
|
255
|
+
(link_key(r.from_key, r.to_key), (r.object_hash, r.values, None)) for r in links
|
|
256
|
+
)
|
|
257
|
+
return rows
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
def _compare_hashes(
|
|
261
|
+
report: VerifyReport, partition: str, expected: Mapping[str, bytes], actual: Mapping[str, bytes]
|
|
262
|
+
) -> None:
|
|
263
|
+
for key in sorted(expected.keys() | actual.keys()):
|
|
264
|
+
want, got = expected.get(key), actual.get(key)
|
|
265
|
+
if want == got:
|
|
266
|
+
continue
|
|
267
|
+
if got is None:
|
|
268
|
+
reason = DivergenceReason.MISSING_ROW
|
|
269
|
+
elif want is None:
|
|
270
|
+
reason = DivergenceReason.EXTRA_ROW
|
|
271
|
+
else:
|
|
272
|
+
reason = DivergenceReason.HASH_MISMATCH
|
|
273
|
+
report.add(
|
|
274
|
+
Divergence(
|
|
275
|
+
reason,
|
|
276
|
+
partition,
|
|
277
|
+
key,
|
|
278
|
+
expected=None if want is None else want.hex(),
|
|
279
|
+
actual=None if got is None else got.hex(),
|
|
280
|
+
)
|
|
281
|
+
)
|
|
282
|
+
|
|
283
|
+
|
|
284
|
+
async def _compare_values(
|
|
285
|
+
report: VerifyReport,
|
|
286
|
+
partition: str,
|
|
287
|
+
expected: Mapping[str, bytes],
|
|
288
|
+
actual: Mapping[str, tuple[bytes, dict[str, Any] | None, str | None]],
|
|
289
|
+
objects: PgObjectStore,
|
|
290
|
+
schema: Schema,
|
|
291
|
+
own: Schema | None = None,
|
|
292
|
+
) -> None:
|
|
293
|
+
"""`schema` has the default branch's tags added; `own` is the branch's own schema, which
|
|
294
|
+
tells a stale title (`SENSITIVITY_STALE`) from a wrong one."""
|
|
295
|
+
common = sorted(expected.keys() & actual.keys())
|
|
296
|
+
bodies = await objects.get_many(expected[key] for key in common)
|
|
297
|
+
parsed = parse_partition(partition)
|
|
298
|
+
kind = schema.kinds.get(parsed.element_id) if parsed.type is PartitionType.KIND else None
|
|
299
|
+
for key in common:
|
|
300
|
+
body = bodies.get(expected[key])
|
|
301
|
+
want_values = {} if body is None else dict(body.body.get("values", {}))
|
|
302
|
+
_, got_values, got_title = actual[key]
|
|
303
|
+
want_title = record_title(kind, want_values) if kind is not None else None
|
|
304
|
+
if got_values != want_values or got_title != want_title:
|
|
305
|
+
own_kind = None if own is None else own.kinds.get(parsed.element_id)
|
|
306
|
+
stale = (
|
|
307
|
+
got_values == want_values
|
|
308
|
+
and own_kind is not None
|
|
309
|
+
and got_title == record_title(own_kind, want_values)
|
|
310
|
+
)
|
|
311
|
+
report.add(
|
|
312
|
+
Divergence(
|
|
313
|
+
DivergenceReason.SENSITIVITY_STALE
|
|
314
|
+
if stale
|
|
315
|
+
else DivergenceReason.VALUES_MISMATCH,
|
|
316
|
+
partition,
|
|
317
|
+
key,
|
|
318
|
+
expected={"values": want_values, "title": want_title},
|
|
319
|
+
actual={"values": got_values, "title": got_title},
|
|
320
|
+
)
|
|
321
|
+
)
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
"""Merging Proposals over the store: merge attempts, Conflicts, resolutions, merge commits."""
|
|
2
|
+
|
|
3
|
+
from lorebase_store.merging.attempts import (
|
|
4
|
+
AttemptComputation,
|
|
5
|
+
ConflictState,
|
|
6
|
+
Decision,
|
|
7
|
+
check_resolution,
|
|
8
|
+
complete_provenance,
|
|
9
|
+
compute_attempt,
|
|
10
|
+
decisions_of,
|
|
11
|
+
taken_provenance,
|
|
12
|
+
)
|
|
13
|
+
from lorebase_store.merging.lineage import BranchLineage
|
|
14
|
+
from lorebase_store.merging.service import (
|
|
15
|
+
MERGE_ORIGIN,
|
|
16
|
+
MERGE_REASON,
|
|
17
|
+
AttemptResult,
|
|
18
|
+
MergeRequest,
|
|
19
|
+
MergeService,
|
|
20
|
+
ProposalBranchMissingError,
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
__all__ = [
|
|
24
|
+
"MERGE_ORIGIN",
|
|
25
|
+
"MERGE_REASON",
|
|
26
|
+
"AttemptComputation",
|
|
27
|
+
"AttemptResult",
|
|
28
|
+
"BranchLineage",
|
|
29
|
+
"ConflictState",
|
|
30
|
+
"Decision",
|
|
31
|
+
"MergeRequest",
|
|
32
|
+
"MergeService",
|
|
33
|
+
"ProposalBranchMissingError",
|
|
34
|
+
"check_resolution",
|
|
35
|
+
"complete_provenance",
|
|
36
|
+
"compute_attempt",
|
|
37
|
+
"decisions_of",
|
|
38
|
+
"taken_provenance",
|
|
39
|
+
]
|
|
@@ -0,0 +1,334 @@
|
|
|
1
|
+
"""Merge attempts computed from stored resolutions (design 6.10), independent of storage.
|
|
2
|
+
|
|
3
|
+
`compute_attempt` runs the core merge with the resolutions of a Proposal. Resolutions are
|
|
4
|
+
stored by fingerprint and may outnumber the Conflicts of the current run: a Conflict can
|
|
5
|
+
appear only once another one is resolved (a custom value that breaks uniqueness), and a
|
|
6
|
+
resolution can lose its Conflict when a head moves. The merge is therefore re-run with the
|
|
7
|
+
resolutions that matched so far until no new one matches; every Conflict seen on the way is
|
|
8
|
+
reported as open (in the final run) or resolved (its resolution was applied), and resolutions
|
|
9
|
+
that never matched are returned as unmatched.
|
|
10
|
+
|
|
11
|
+
`complete_provenance` attributes every change of a merge Revision: values taken from the
|
|
12
|
+
source keep their provenance, values decided by a resolution get the provenance entry of that
|
|
13
|
+
resolution (`merge_resolution`, design 6.10) and the rest the merge's own entry. Only the
|
|
14
|
+
Attributes a change sets are attributed (`taken_provenance`): an element taken whole from the
|
|
15
|
+
source brings the source's whole Lineage, and its unchanged values keep the Revision that last
|
|
16
|
+
changed them on the target.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
from collections.abc import Iterable, Mapping, Sequence
|
|
22
|
+
from dataclasses import dataclass, replace
|
|
23
|
+
from typing import Any
|
|
24
|
+
|
|
25
|
+
from lorebase_core.merge import (
|
|
26
|
+
CHOICES,
|
|
27
|
+
AutoResolution,
|
|
28
|
+
Conflict,
|
|
29
|
+
ConflictKind,
|
|
30
|
+
MergeAttempt,
|
|
31
|
+
MergedTree,
|
|
32
|
+
MergeInput,
|
|
33
|
+
MergeOutcome,
|
|
34
|
+
ObjectStore,
|
|
35
|
+
Resolution,
|
|
36
|
+
ResolutionChoice,
|
|
37
|
+
ResolutionError,
|
|
38
|
+
merge,
|
|
39
|
+
)
|
|
40
|
+
from lorebase_core.model import looks_like_reference
|
|
41
|
+
from lorebase_core.revision import ChangesetEntry
|
|
42
|
+
from lorebase_core.tree import PARTITION_SCHEMA
|
|
43
|
+
from lorebase_store.materialize.rows import ELEMENT_PROVENANCE_KEY, changed_attributes
|
|
44
|
+
|
|
45
|
+
__all__ = [
|
|
46
|
+
"AttemptComputation",
|
|
47
|
+
"ConflictState",
|
|
48
|
+
"Decision",
|
|
49
|
+
"check_resolution",
|
|
50
|
+
"complete_provenance",
|
|
51
|
+
"compute_attempt",
|
|
52
|
+
"decisions_of",
|
|
53
|
+
"taken_provenance",
|
|
54
|
+
]
|
|
55
|
+
|
|
56
|
+
#: Grouped kinds whose custom resolution may give values for some members only.
|
|
57
|
+
_PARTIAL_CUSTOM = frozenset({ConflictKind.UNIQUENESS, ConflictKind.REQUIRED_RELATION})
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
@dataclass(frozen=True)
|
|
61
|
+
class ConflictState:
|
|
62
|
+
"""A Conflict of an attempt with its full member keys and its state.
|
|
63
|
+
|
|
64
|
+
`resolution` is the stored resolution that applies to it. An open Conflict with a
|
|
65
|
+
resolution was `rejected`: the merge reported it again after applying the resolution.
|
|
66
|
+
"""
|
|
67
|
+
|
|
68
|
+
conflict: Conflict
|
|
69
|
+
members: tuple[str, ...]
|
|
70
|
+
open: bool
|
|
71
|
+
resolution: Resolution | None = None
|
|
72
|
+
|
|
73
|
+
@property
|
|
74
|
+
def rejected(self) -> bool:
|
|
75
|
+
return self.open and self.resolution is not None
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
@dataclass(frozen=True)
|
|
79
|
+
class AttemptComputation:
|
|
80
|
+
"""Outcome of a merge with stored resolutions applied."""
|
|
81
|
+
|
|
82
|
+
outcome: MergeOutcome
|
|
83
|
+
conflicts: tuple[ConflictState, ...]
|
|
84
|
+
applied: tuple[Resolution, ...]
|
|
85
|
+
unmatched: tuple[str, ...]
|
|
86
|
+
|
|
87
|
+
@property
|
|
88
|
+
def clean(self) -> bool:
|
|
89
|
+
return isinstance(self.outcome, MergedTree)
|
|
90
|
+
|
|
91
|
+
@property
|
|
92
|
+
def open_count(self) -> int:
|
|
93
|
+
return sum(1 for state in self.conflicts if state.open)
|
|
94
|
+
|
|
95
|
+
@property
|
|
96
|
+
def resolved_count(self) -> int:
|
|
97
|
+
return sum(1 for state in self.conflicts if not state.open)
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def compute_attempt(
|
|
101
|
+
merge_input: MergeInput, resolutions: Iterable[Resolution]
|
|
102
|
+
) -> AttemptComputation:
|
|
103
|
+
"""Merge `merge_input` with the stored `resolutions` (see the module docstring).
|
|
104
|
+
|
|
105
|
+
Raises `ResolutionError` when a stored resolution does not fit its Conflict.
|
|
106
|
+
"""
|
|
107
|
+
stored = {r.fingerprint: r for r in resolutions}
|
|
108
|
+
applied: dict[str, Resolution] = {}
|
|
109
|
+
seen: dict[str, tuple[Conflict, tuple[str, ...]]] = {}
|
|
110
|
+
outcome: MergeOutcome | None = None
|
|
111
|
+
for _ in range(len(stored) + 1):
|
|
112
|
+
outcome = merge(replace(merge_input, resolutions=tuple(applied.values())))
|
|
113
|
+
if isinstance(outcome, MergeAttempt):
|
|
114
|
+
for conflict in outcome.conflicts:
|
|
115
|
+
if conflict.fingerprint not in seen:
|
|
116
|
+
members = tuple(outcome.members.get(conflict.fingerprint, ()))
|
|
117
|
+
seen[conflict.fingerprint] = (conflict, members)
|
|
118
|
+
matched = {fp: r for fp, r in stored.items() if fp in seen and fp not in applied}
|
|
119
|
+
if not matched:
|
|
120
|
+
break
|
|
121
|
+
applied.update(matched)
|
|
122
|
+
assert outcome is not None
|
|
123
|
+
open_now = (
|
|
124
|
+
{c.fingerprint: c for c in outcome.conflicts} if isinstance(outcome, MergeAttempt) else {}
|
|
125
|
+
)
|
|
126
|
+
states: list[ConflictState] = []
|
|
127
|
+
for fingerprint, (conflict, members) in seen.items():
|
|
128
|
+
if fingerprint in open_now:
|
|
129
|
+
states.append(
|
|
130
|
+
ConflictState(
|
|
131
|
+
open_now[fingerprint], members, open=True, resolution=applied.get(fingerprint)
|
|
132
|
+
)
|
|
133
|
+
)
|
|
134
|
+
elif fingerprint in applied:
|
|
135
|
+
states.append(
|
|
136
|
+
ConflictState(conflict, members, open=False, resolution=applied[fingerprint])
|
|
137
|
+
)
|
|
138
|
+
return AttemptComputation(
|
|
139
|
+
outcome=outcome,
|
|
140
|
+
conflicts=tuple(states),
|
|
141
|
+
applied=tuple(applied.values()),
|
|
142
|
+
unmatched=tuple(fp for fp in stored if fp not in applied),
|
|
143
|
+
)
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def check_resolution(conflict: Conflict, members: Sequence[str], resolution: Resolution) -> None:
|
|
147
|
+
"""Raise `ResolutionError` unless `resolution` is acceptable for `conflict`.
|
|
148
|
+
|
|
149
|
+
The choice must be one of the Conflict kind's choices; a custom resolution of a grouped
|
|
150
|
+
Conflict given per member must cover every member unless it also has a default `value`
|
|
151
|
+
(uniqueness and required relation Conflicts accept values for some members only).
|
|
152
|
+
"""
|
|
153
|
+
if resolution.fingerprint != conflict.fingerprint:
|
|
154
|
+
raise ResolutionError("the resolution names another Conflict")
|
|
155
|
+
if resolution.choice not in CHOICES[conflict.kind]:
|
|
156
|
+
allowed = ", ".join(c.value for c in CHOICES[conflict.kind])
|
|
157
|
+
raise ResolutionError(
|
|
158
|
+
f"{resolution.choice.value} does not resolve a {conflict.kind.value} Conflict "
|
|
159
|
+
f"(accepted: {allowed})"
|
|
160
|
+
)
|
|
161
|
+
if resolution.choice is ResolutionChoice.CUSTOM and (
|
|
162
|
+
_holds_reference((conflict.base, conflict.ours, conflict.theirs))
|
|
163
|
+
or _holds_reference((resolution.value, resolution.values))
|
|
164
|
+
):
|
|
165
|
+
# A custom value would bypass the vault (design 13.5), and a reference would copy
|
|
166
|
+
# another Record's value: vault values are resolved by picking a side.
|
|
167
|
+
raise ResolutionError(
|
|
168
|
+
"a Conflict over vault values takes one of its sides, not a custom value"
|
|
169
|
+
)
|
|
170
|
+
if (
|
|
171
|
+
resolution.choice is ResolutionChoice.CUSTOM
|
|
172
|
+
and resolution.values is not None
|
|
173
|
+
and resolution.value is None
|
|
174
|
+
and conflict.kind not in _PARTIAL_CUSTOM
|
|
175
|
+
):
|
|
176
|
+
missing = [m for m in members if m not in resolution.values]
|
|
177
|
+
if missing:
|
|
178
|
+
raise ResolutionError(
|
|
179
|
+
f"custom values miss {len(missing)} member(s), for example {missing[0]!r}"
|
|
180
|
+
)
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def _holds_reference(value: Any) -> bool:
|
|
184
|
+
"""Whether a vault reference appears anywhere in `value`."""
|
|
185
|
+
if looks_like_reference(value):
|
|
186
|
+
return True
|
|
187
|
+
if isinstance(value, Mapping):
|
|
188
|
+
return any(_holds_reference(item) for item in value.values())
|
|
189
|
+
if isinstance(value, list | tuple):
|
|
190
|
+
return any(_holds_reference(item) for item in value)
|
|
191
|
+
return False
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
# Provenance -------------------------------------------------------------------------------
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
@dataclass(frozen=True)
|
|
198
|
+
class Decision:
|
|
199
|
+
"""A resolution or automatic resolution, located for attribution."""
|
|
200
|
+
|
|
201
|
+
fingerprint: str
|
|
202
|
+
resolver: str
|
|
203
|
+
choice: ResolutionChoice
|
|
204
|
+
partition: str
|
|
205
|
+
key: str | None
|
|
206
|
+
attribute: str | None
|
|
207
|
+
members: tuple[str, ...] = ()
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def decisions_of(
|
|
211
|
+
computation: AttemptComputation, auto: Sequence[AutoResolution] = ()
|
|
212
|
+
) -> list[Decision]:
|
|
213
|
+
"""Decisions of a clean merge: applied resolutions, then automatic resolutions."""
|
|
214
|
+
found: list[Decision] = []
|
|
215
|
+
for state in computation.conflicts:
|
|
216
|
+
if state.resolution is None or state.open:
|
|
217
|
+
continue
|
|
218
|
+
conflict = state.conflict
|
|
219
|
+
found.append(
|
|
220
|
+
Decision(
|
|
221
|
+
fingerprint=conflict.fingerprint,
|
|
222
|
+
resolver=state.resolution.resolver,
|
|
223
|
+
choice=state.resolution.choice,
|
|
224
|
+
partition=conflict.partition,
|
|
225
|
+
key=conflict.key,
|
|
226
|
+
attribute=conflict.attribute,
|
|
227
|
+
members=state.members or conflict.sample,
|
|
228
|
+
)
|
|
229
|
+
)
|
|
230
|
+
for item in auto:
|
|
231
|
+
found.append(
|
|
232
|
+
Decision(
|
|
233
|
+
fingerprint=item.fingerprint,
|
|
234
|
+
resolver=item.resolver,
|
|
235
|
+
choice=item.choice,
|
|
236
|
+
partition=item.partition,
|
|
237
|
+
key=item.key,
|
|
238
|
+
attribute=item.attribute,
|
|
239
|
+
members=item.sample,
|
|
240
|
+
)
|
|
241
|
+
)
|
|
242
|
+
return found
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
def _index(decisions: Sequence[Decision]) -> dict[tuple[str, str, str | None], Decision]:
|
|
246
|
+
"""Decisions by `(partition, key, attribute)`; schema decisions by element only."""
|
|
247
|
+
index: dict[tuple[str, str, str | None], Decision] = {}
|
|
248
|
+
for decision in decisions:
|
|
249
|
+
attribute = None if decision.partition == PARTITION_SCHEMA else decision.attribute
|
|
250
|
+
keys = [decision.key] if decision.key is not None else []
|
|
251
|
+
keys.extend(decision.members)
|
|
252
|
+
for key in keys:
|
|
253
|
+
index.setdefault((decision.partition, key, attribute), decision)
|
|
254
|
+
return index
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
def _values(objects: ObjectStore, digest: bytes | None) -> Mapping[str, Any] | None:
|
|
258
|
+
if digest is None:
|
|
259
|
+
return None
|
|
260
|
+
body = objects.get(digest)
|
|
261
|
+
if not isinstance(body, dict):
|
|
262
|
+
return None
|
|
263
|
+
values = body.get("values")
|
|
264
|
+
return values if isinstance(values, dict) else {}
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
def _changed(entry: ChangesetEntry, objects: ObjectStore) -> list[str] | None:
|
|
268
|
+
"""Attributes whose value the change sets; None for schema elements and deletions."""
|
|
269
|
+
if entry.partition == PARTITION_SCHEMA or entry.new_hash is None:
|
|
270
|
+
return None
|
|
271
|
+
return changed_attributes(
|
|
272
|
+
_values(objects, entry.old_hash), _values(objects, entry.new_hash) or {}
|
|
273
|
+
)
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
def _restricted(entry: ChangesetEntry, changed: list[str] | None) -> dict[str, str]:
|
|
277
|
+
if changed is None:
|
|
278
|
+
return dict(entry.provenance)
|
|
279
|
+
kept = {*changed, ELEMENT_PROVENANCE_KEY}
|
|
280
|
+
return {a: p for a, p in entry.provenance.items() if a in kept}
|
|
281
|
+
|
|
282
|
+
|
|
283
|
+
def taken_provenance(
|
|
284
|
+
entries: Sequence[ChangesetEntry], objects: ObjectStore
|
|
285
|
+
) -> list[ChangesetEntry]:
|
|
286
|
+
"""Entries whose provenance names only the Attributes they change (and the element key);
|
|
287
|
+
schema elements and deletions are kept as they are."""
|
|
288
|
+
return [
|
|
289
|
+
replace(entry, provenance=_restricted(entry, _changed(entry, objects))) for entry in entries
|
|
290
|
+
]
|
|
291
|
+
|
|
292
|
+
|
|
293
|
+
def complete_provenance(
|
|
294
|
+
entries: Sequence[ChangesetEntry],
|
|
295
|
+
objects: ObjectStore,
|
|
296
|
+
decisions: Sequence[Decision],
|
|
297
|
+
decision_ids: Mapping[str, str],
|
|
298
|
+
fallback: str,
|
|
299
|
+
) -> tuple[list[ChangesetEntry], set[str]]:
|
|
300
|
+
"""Entries whose provenance covers every changed Attribute; returns the decisions used.
|
|
301
|
+
|
|
302
|
+
`decision_ids` maps decision fingerprints to provenance entry ids; `fallback` is the id
|
|
303
|
+
used for changes that neither the source nor a decision accounts for. Provenance of
|
|
304
|
+
Attributes a change leaves as they are is dropped (`taken_provenance`).
|
|
305
|
+
"""
|
|
306
|
+
index = _index(decisions)
|
|
307
|
+
used: set[str] = set()
|
|
308
|
+
|
|
309
|
+
def decided(partition: str, key: str, attribute: str | None) -> str | None:
|
|
310
|
+
decision = index.get((partition, key, attribute))
|
|
311
|
+
if decision is None and attribute is not None:
|
|
312
|
+
decision = index.get((partition, key, None))
|
|
313
|
+
if decision is None or decision.fingerprint not in decision_ids:
|
|
314
|
+
return None
|
|
315
|
+
used.add(decision.fingerprint)
|
|
316
|
+
return decision_ids[decision.fingerprint]
|
|
317
|
+
|
|
318
|
+
completed: list[ChangesetEntry] = []
|
|
319
|
+
for entry in entries:
|
|
320
|
+
found = _changed(entry, objects)
|
|
321
|
+
provenance = _restricted(entry, found)
|
|
322
|
+
changed = found or []
|
|
323
|
+
for attribute in changed:
|
|
324
|
+
chosen = decided(entry.partition, entry.key, attribute)
|
|
325
|
+
if chosen is not None:
|
|
326
|
+
provenance[attribute] = chosen
|
|
327
|
+
elif attribute not in provenance:
|
|
328
|
+
provenance[attribute] = fallback
|
|
329
|
+
if not changed:
|
|
330
|
+
chosen = decided(entry.partition, entry.key, None)
|
|
331
|
+
if chosen is not None or not provenance:
|
|
332
|
+
provenance = {ELEMENT_PROVENANCE_KEY: chosen or fallback}
|
|
333
|
+
completed.append(replace(entry, provenance=provenance))
|
|
334
|
+
return completed, used
|