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,652 @@
|
|
|
1
|
+
"""Merge service: the core merge over the store, merge attempts and merge commits (design 6.8).
|
|
2
|
+
|
|
3
|
+
`MergeService` runs `lorebase_core.merge` for a Proposal over the Domain's tree nodes, objects
|
|
4
|
+
and Revision graph, with the materialized head of the target branch as index view and the
|
|
5
|
+
source branch's materialized Lineage as provenance. Every run is stored as a merge attempt
|
|
6
|
+
with its Conflicts (the previous open attempt of the Proposal becomes `stale`); resolutions
|
|
7
|
+
are read from `conflict_resolutions` and matched by fingerprint, so an attempt recomputed
|
|
8
|
+
against moved heads keeps the resolutions whose Conflicts did not change.
|
|
9
|
+
|
|
10
|
+
`merge` runs under the target branch lock and, when no Conflict remains, writes the outcome
|
|
11
|
+
like the commit pipeline (design 6.6): objects, nodes, the merge Revision with parents
|
|
12
|
+
`[target head, source head]`, changeset and provenance, materialization of the changeset on the
|
|
13
|
+
target branch, compare-and-swap of the ref, audit entry and outbox events (`revision.committed`
|
|
14
|
+
and the Operation events deferred on the source branch, `repositories.deferred_events`). A
|
|
15
|
+
fast-forward moves the ref to the source head and materializes the difference; an up-to-date
|
|
16
|
+
merge writes no Revision. Everything runs in the caller's transaction.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
from collections.abc import Awaitable, Callable, Mapping
|
|
22
|
+
from dataclasses import dataclass, field
|
|
23
|
+
from datetime import datetime
|
|
24
|
+
from typing import Any
|
|
25
|
+
|
|
26
|
+
from sqlalchemy.ext.asyncio import AsyncSession
|
|
27
|
+
from sqlalchemy.orm import Session
|
|
28
|
+
|
|
29
|
+
from lorebase_core.canonical import hex
|
|
30
|
+
from lorebase_core.expr.sensitivity import lowered_tags
|
|
31
|
+
from lorebase_core.merge import (
|
|
32
|
+
MergedTree,
|
|
33
|
+
MergeInput,
|
|
34
|
+
MergePolicy,
|
|
35
|
+
MergeResult,
|
|
36
|
+
Resolution,
|
|
37
|
+
tree_schema_loader,
|
|
38
|
+
)
|
|
39
|
+
from lorebase_core.merge.objects import load_object
|
|
40
|
+
from lorebase_core.model import (
|
|
41
|
+
IdPrefix,
|
|
42
|
+
MergeResolutionOrigin,
|
|
43
|
+
ProvenanceEntry,
|
|
44
|
+
Schema,
|
|
45
|
+
new_id,
|
|
46
|
+
new_ulid,
|
|
47
|
+
)
|
|
48
|
+
from lorebase_core.revision import Author, ChangesetEntry, Origin, Revision
|
|
49
|
+
from lorebase_core.tree import PARTITION_SCHEMA, Tree, TreeParams
|
|
50
|
+
from lorebase_store.commit.errors import (
|
|
51
|
+
BranchLockTimeoutError,
|
|
52
|
+
BranchNotFoundError,
|
|
53
|
+
SensitivityLoweredError,
|
|
54
|
+
StaleHeadError,
|
|
55
|
+
)
|
|
56
|
+
from lorebase_store.commit.pipeline import (
|
|
57
|
+
COMMITTED_EVENT,
|
|
58
|
+
_Prepared,
|
|
59
|
+
_record_events,
|
|
60
|
+
_store_revision,
|
|
61
|
+
utc_now,
|
|
62
|
+
)
|
|
63
|
+
from lorebase_store.commit.schema import load_compiled
|
|
64
|
+
from lorebase_store.materialize.apply import MaterializedChange, apply_changeset
|
|
65
|
+
from lorebase_store.materialize.background import delete_branch
|
|
66
|
+
from lorebase_store.materialize.stale import queue_after_tagging
|
|
67
|
+
from lorebase_store.merging.attempts import (
|
|
68
|
+
AttemptComputation,
|
|
69
|
+
ConflictState,
|
|
70
|
+
complete_provenance,
|
|
71
|
+
compute_attempt,
|
|
72
|
+
decisions_of,
|
|
73
|
+
taken_provenance,
|
|
74
|
+
)
|
|
75
|
+
from lorebase_store.merging.lineage import BranchLineage, MergeLineage
|
|
76
|
+
from lorebase_store.models.enums import MergeAttemptStatus, ProposalStatus, RefKind
|
|
77
|
+
from lorebase_store.nodestore import PgNodeStore
|
|
78
|
+
from lorebase_store.objectstore import EncodedObject, SessionObjectStore
|
|
79
|
+
from lorebase_store.repositories.deferred_events import DeferredEventRepository
|
|
80
|
+
from lorebase_store.repositories.domains import DomainRepository
|
|
81
|
+
from lorebase_store.repositories.events import OutboxRepository
|
|
82
|
+
from lorebase_store.repositories.index_view import MaterializedIndexView
|
|
83
|
+
from lorebase_store.repositories.materialized import MERGE_ORIGIN_TYPE, MaterializedRepository
|
|
84
|
+
from lorebase_store.repositories.merges import (
|
|
85
|
+
ConflictRepository,
|
|
86
|
+
ConflictRow,
|
|
87
|
+
MergeAttemptRepository,
|
|
88
|
+
MergeAttemptRow,
|
|
89
|
+
ResolutionRepository,
|
|
90
|
+
ResolutionRow,
|
|
91
|
+
)
|
|
92
|
+
from lorebase_store.repositories.proposals import ProposalRepository, ProposalRow
|
|
93
|
+
from lorebase_store.repositories.provenance import ProvenanceRow
|
|
94
|
+
from lorebase_store.repositories.refs import (
|
|
95
|
+
LockTimeoutError,
|
|
96
|
+
RefMovedError,
|
|
97
|
+
RefRepository,
|
|
98
|
+
RefRow,
|
|
99
|
+
)
|
|
100
|
+
from lorebase_store.repositories.revisions import PgRevisionGraph, RevisionRow
|
|
101
|
+
from lorebase_store.repositories.scope import scope_to_domain
|
|
102
|
+
from lorebase_store.runtime import StoreRuntime
|
|
103
|
+
from lorebase_store.telemetry import MERGE_ATTEMPTS
|
|
104
|
+
|
|
105
|
+
MERGE_ORIGIN = MERGE_ORIGIN_TYPE
|
|
106
|
+
"""Origin type of merge Revisions and of the merge's own provenance entry."""
|
|
107
|
+
MERGE_REASON = "proposal.merge"
|
|
108
|
+
"""Ref log reason of a merge."""
|
|
109
|
+
ATTEMPT_PREFIX = "mga"
|
|
110
|
+
CONFLICTS_RESULT = "conflicts"
|
|
111
|
+
REJECTED_DETAIL = (
|
|
112
|
+
"The merge reported this Conflict again after applying its resolution; choose another."
|
|
113
|
+
)
|
|
114
|
+
|
|
115
|
+
Clock = Callable[[], datetime]
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
class ProposalBranchMissingError(BranchNotFoundError):
|
|
119
|
+
"""The source or target branch of a Proposal no longer exists."""
|
|
120
|
+
|
|
121
|
+
def __init__(self, side: str, name: str) -> None:
|
|
122
|
+
super().__init__(f"the {side} branch {name!r} of the Proposal does not exist")
|
|
123
|
+
self.side = side
|
|
124
|
+
self.name = name
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
@dataclass(frozen=True)
|
|
128
|
+
class MergeRequest:
|
|
129
|
+
"""What to merge and how: the Proposal, who runs it and the merge policy."""
|
|
130
|
+
|
|
131
|
+
proposal: ProposalRow
|
|
132
|
+
actor: str
|
|
133
|
+
policy: MergePolicy = field(default_factory=MergePolicy)
|
|
134
|
+
message: str | None = None
|
|
135
|
+
idempotency_key: str | None = None
|
|
136
|
+
#: Delete the source branch after a successful merge (auto-close).
|
|
137
|
+
delete_source: bool = False
|
|
138
|
+
#: Called with the target's schema and the merged schema before a clean merge commits;
|
|
139
|
+
#: raises to refuse it (the caller's checks: removed sensitivity tags, Sources feeding
|
|
140
|
+
#: vault Attributes). Without it merges that remove sensitivity tags are refused
|
|
141
|
+
#: (`SensitivityLoweredError`).
|
|
142
|
+
schema_guard: Callable[[Schema, Schema], Awaitable[None]] | None = None
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
@dataclass(frozen=True)
|
|
146
|
+
class AttemptResult:
|
|
147
|
+
"""The current attempt of a Proposal.
|
|
148
|
+
|
|
149
|
+
`computation` is set when this call ran the merge (otherwise the stored attempt was still
|
|
150
|
+
current); `previous` is the attempt it superseded. After a successful `merge`, `head` is
|
|
151
|
+
the new head of the target branch.
|
|
152
|
+
"""
|
|
153
|
+
|
|
154
|
+
attempt: MergeAttemptRow
|
|
155
|
+
computation: AttemptComputation | None = None
|
|
156
|
+
previous: MergeAttemptRow | None = None
|
|
157
|
+
head: bytes | None = None
|
|
158
|
+
source_deleted: bool = False
|
|
159
|
+
events: tuple[int, ...] = ()
|
|
160
|
+
"""Outbox ids of the deferred Operation events this merge wrote (`release`)."""
|
|
161
|
+
|
|
162
|
+
@property
|
|
163
|
+
def merged(self) -> bool:
|
|
164
|
+
return self.attempt.status is MergeAttemptStatus.MERGED
|
|
165
|
+
|
|
166
|
+
@property
|
|
167
|
+
def stale_previous(self) -> bool:
|
|
168
|
+
"""Whether a head moved since the previous attempt."""
|
|
169
|
+
previous = self.previous
|
|
170
|
+
return previous is not None and (
|
|
171
|
+
previous.ours != self.attempt.ours or previous.theirs != self.attempt.theirs
|
|
172
|
+
)
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
@dataclass
|
|
176
|
+
class _Commit:
|
|
177
|
+
merged: MergedTree
|
|
178
|
+
changeset: list[ChangesetEntry]
|
|
179
|
+
bodies: dict[tuple[str, str], Mapping[str, Any] | None]
|
|
180
|
+
schema: Schema
|
|
181
|
+
schema_hash: bytes | None
|
|
182
|
+
refresh_kinds: list[str]
|
|
183
|
+
provenance: list[ProvenanceRow]
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
@dataclass
|
|
187
|
+
class _Computed:
|
|
188
|
+
computation: AttemptComputation
|
|
189
|
+
nodes: dict[bytes, bytes]
|
|
190
|
+
objects: list[EncodedObject]
|
|
191
|
+
commit: _Commit | None
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
class MergeService:
|
|
195
|
+
"""Merge attempts and merge commits of one Domain, in the caller's transaction."""
|
|
196
|
+
|
|
197
|
+
def __init__(
|
|
198
|
+
self,
|
|
199
|
+
session: AsyncSession,
|
|
200
|
+
domain_id: str,
|
|
201
|
+
*,
|
|
202
|
+
runtime: StoreRuntime,
|
|
203
|
+
clock: Clock = utc_now,
|
|
204
|
+
retention: int = 5,
|
|
205
|
+
) -> None:
|
|
206
|
+
self._session = session
|
|
207
|
+
self._domain = domain_id
|
|
208
|
+
self._runtime = runtime
|
|
209
|
+
self._clock = clock
|
|
210
|
+
self._retention = retention
|
|
211
|
+
|
|
212
|
+
async def current(self, request: MergeRequest) -> AttemptResult:
|
|
213
|
+
"""The latest attempt, recomputed when there is none or a head moved since."""
|
|
214
|
+
await scope_to_domain(self._session, self._domain)
|
|
215
|
+
refs = RefRepository(self._session, self._domain)
|
|
216
|
+
target = await _branch(refs, request.proposal.target_ref, "target")
|
|
217
|
+
source = await _branch(refs, request.proposal.source_ref, "source")
|
|
218
|
+
latest = await MergeAttemptRepository(self._session, self._domain).latest(
|
|
219
|
+
request.proposal.id
|
|
220
|
+
)
|
|
221
|
+
if (
|
|
222
|
+
latest is not None
|
|
223
|
+
and latest.status in (MergeAttemptStatus.CONFLICTED, MergeAttemptStatus.RESOLVED)
|
|
224
|
+
and latest.ours == target.head
|
|
225
|
+
and latest.theirs == source.head
|
|
226
|
+
):
|
|
227
|
+
return AttemptResult(attempt=latest)
|
|
228
|
+
return await self._run(request, target, source, commit=False)
|
|
229
|
+
|
|
230
|
+
async def recompute(self, request: MergeRequest) -> AttemptResult:
|
|
231
|
+
"""Run the merge with the stored resolutions and store the attempt (no commit)."""
|
|
232
|
+
await scope_to_domain(self._session, self._domain)
|
|
233
|
+
refs = RefRepository(self._session, self._domain)
|
|
234
|
+
target = await _branch(refs, request.proposal.target_ref, "target")
|
|
235
|
+
source = await _branch(refs, request.proposal.source_ref, "source")
|
|
236
|
+
return await self._run(request, target, source, commit=False)
|
|
237
|
+
|
|
238
|
+
async def merge(self, request: MergeRequest) -> AttemptResult:
|
|
239
|
+
"""Merge under the target branch lock: commit when clean, else store the attempt.
|
|
240
|
+
|
|
241
|
+
Raises `BranchLockTimeoutError`, `ProposalBranchMissingError` or `StaleHeadError`.
|
|
242
|
+
"""
|
|
243
|
+
await scope_to_domain(self._session, self._domain)
|
|
244
|
+
refs = RefRepository(self._session, self._domain)
|
|
245
|
+
timeout = self._runtime.settings.commit.lock_timeout_ms
|
|
246
|
+
try:
|
|
247
|
+
await refs.lock(request.proposal.target_ref, timeout_ms=timeout)
|
|
248
|
+
except LockTimeoutError as error:
|
|
249
|
+
raise BranchLockTimeoutError(str(error)) from error
|
|
250
|
+
target = await _branch(refs, request.proposal.target_ref, "target")
|
|
251
|
+
source = await _branch(refs, request.proposal.source_ref, "source")
|
|
252
|
+
result = await self._run(request, target, source, commit=True)
|
|
253
|
+
attempt = result.attempt
|
|
254
|
+
clean = attempt.status is not MergeAttemptStatus.CONFLICTED
|
|
255
|
+
MERGE_ATTEMPTS.add(
|
|
256
|
+
1, {"result": attempt.result if clean and attempt.result else CONFLICTS_RESULT}
|
|
257
|
+
)
|
|
258
|
+
return result
|
|
259
|
+
|
|
260
|
+
async def abandon(self, proposal_id: str) -> int:
|
|
261
|
+
"""Mark the open attempts of a closed Proposal `abandoned`."""
|
|
262
|
+
await scope_to_domain(self._session, self._domain)
|
|
263
|
+
return await MergeAttemptRepository(self._session, self._domain).supersede(
|
|
264
|
+
proposal_id, status=MergeAttemptStatus.ABANDONED
|
|
265
|
+
)
|
|
266
|
+
|
|
267
|
+
# Run ------------------------------------------------------------------------------
|
|
268
|
+
|
|
269
|
+
async def _run(
|
|
270
|
+
self, request: MergeRequest, target: RefRow, source: RefRow, *, commit: bool
|
|
271
|
+
) -> AttemptResult:
|
|
272
|
+
session, domain_id = self._session, self._domain
|
|
273
|
+
settings = self._runtime.settings
|
|
274
|
+
domain = await DomainRepository(session).require(domain_id)
|
|
275
|
+
stored = await ResolutionRepository(session, domain_id).of(request.proposal.id)
|
|
276
|
+
resolutions = [_resolution(row) for row in stored]
|
|
277
|
+
created_at = self._clock()
|
|
278
|
+
attempt_id = f"{ATTEMPT_PREFIX}_{new_ulid()}"
|
|
279
|
+
runtime = self._runtime
|
|
280
|
+
|
|
281
|
+
def compute(sync: Session) -> _Computed:
|
|
282
|
+
store = PgNodeStore(
|
|
283
|
+
sync,
|
|
284
|
+
domain_id,
|
|
285
|
+
cache=runtime.node_cache,
|
|
286
|
+
prefetch_batch=settings.nodes.prefetch_batch,
|
|
287
|
+
)
|
|
288
|
+
objects = SessionObjectStore(sync, domain_id, batch=settings.nodes.prefetch_batch)
|
|
289
|
+
graph = PgRevisionGraph(sync, domain_id)
|
|
290
|
+
index = MaterializedIndexView(
|
|
291
|
+
sync, domain_id, target.name, batch=settings.commit.prefetch_batch
|
|
292
|
+
)
|
|
293
|
+
merge_input = MergeInput(
|
|
294
|
+
store=store,
|
|
295
|
+
objects=objects,
|
|
296
|
+
graph=graph,
|
|
297
|
+
revision_root=graph.root,
|
|
298
|
+
ours=target.head,
|
|
299
|
+
theirs=source.head,
|
|
300
|
+
index_view=lambda _head: index,
|
|
301
|
+
provenance=BranchLineage(sync, domain_id, source.name),
|
|
302
|
+
lineage=MergeLineage(
|
|
303
|
+
sync, domain_id, {target.head: target.name, source.head: source.name}
|
|
304
|
+
),
|
|
305
|
+
policy=request.policy,
|
|
306
|
+
tree_params=TreeParams(domain.leaf_size),
|
|
307
|
+
schema_rules=runtime.schema.rules(),
|
|
308
|
+
)
|
|
309
|
+
computation = compute_attempt(merge_input, resolutions)
|
|
310
|
+
prepared = None
|
|
311
|
+
outcome = computation.outcome
|
|
312
|
+
if (
|
|
313
|
+
commit
|
|
314
|
+
and isinstance(outcome, MergedTree)
|
|
315
|
+
and outcome.result is not MergeResult.UP_TO_DATE
|
|
316
|
+
):
|
|
317
|
+
prepared = _prepare_commit(
|
|
318
|
+
store, objects, computation, outcome, request, created_at
|
|
319
|
+
)
|
|
320
|
+
return _Computed(computation, store.take_pending(), objects.take_pending(), prepared)
|
|
321
|
+
|
|
322
|
+
computed = await session.run_sync(compute)
|
|
323
|
+
computation = computed.computation
|
|
324
|
+
head: bytes | None = None
|
|
325
|
+
source_deleted = False
|
|
326
|
+
events: list[int] = []
|
|
327
|
+
if commit and computation.clean:
|
|
328
|
+
head = await self._commit(request, target, source, computed, created_at)
|
|
329
|
+
events = await DeferredEventRepository(session, domain_id).release(
|
|
330
|
+
source.name,
|
|
331
|
+
source.head,
|
|
332
|
+
{
|
|
333
|
+
"branch": target.name,
|
|
334
|
+
"head": hex(head),
|
|
335
|
+
"source_branch": source.name,
|
|
336
|
+
"proposal": request.proposal.id,
|
|
337
|
+
},
|
|
338
|
+
)
|
|
339
|
+
if request.delete_source and source.name != target.name:
|
|
340
|
+
# A large source (promoted to a full copy) is hidden now and its rows are
|
|
341
|
+
# deleted by a background job.
|
|
342
|
+
try:
|
|
343
|
+
await delete_branch(
|
|
344
|
+
session, domain_id, source.name, runtime=self._runtime, clock=self._clock
|
|
345
|
+
)
|
|
346
|
+
except LockTimeoutError as error:
|
|
347
|
+
raise BranchLockTimeoutError(str(error)) from error
|
|
348
|
+
source_deleted = True
|
|
349
|
+
status = (
|
|
350
|
+
MergeAttemptStatus.MERGED
|
|
351
|
+
if head is not None
|
|
352
|
+
else MergeAttemptStatus.RESOLVED
|
|
353
|
+
if computation.clean
|
|
354
|
+
else MergeAttemptStatus.CONFLICTED
|
|
355
|
+
)
|
|
356
|
+
attempts = MergeAttemptRepository(session, domain_id)
|
|
357
|
+
previous = await attempts.latest(request.proposal.id)
|
|
358
|
+
await attempts.supersede(request.proposal.id, status=MergeAttemptStatus.STALE)
|
|
359
|
+
outcome = computation.outcome
|
|
360
|
+
attempt = MergeAttemptRow(
|
|
361
|
+
id=attempt_id,
|
|
362
|
+
proposal_id=request.proposal.id,
|
|
363
|
+
base=None if outcome.virtual_base else outcome.base,
|
|
364
|
+
ours=target.head,
|
|
365
|
+
theirs=source.head,
|
|
366
|
+
status=status,
|
|
367
|
+
created_at=created_at,
|
|
368
|
+
virtual_base=outcome.virtual_base,
|
|
369
|
+
result=outcome.result.value if isinstance(outcome, MergedTree) else CONFLICTS_RESULT,
|
|
370
|
+
policy=request.policy.model_dump(mode="json", exclude_defaults=True),
|
|
371
|
+
created_by=request.actor,
|
|
372
|
+
outcome=_summary(computation),
|
|
373
|
+
revision=head,
|
|
374
|
+
idempotency_key=request.idempotency_key if commit else None,
|
|
375
|
+
)
|
|
376
|
+
await attempts.insert(attempt)
|
|
377
|
+
by_fingerprint = {row.fingerprint: row for row in stored}
|
|
378
|
+
await ConflictRepository(session, domain_id).insert_many(
|
|
379
|
+
attempt_id, [_conflict_row(state, by_fingerprint) for state in computation.conflicts]
|
|
380
|
+
)
|
|
381
|
+
await attempts.prune(request.proposal.id, keep=self._retention)
|
|
382
|
+
changes: dict[str, Any] = {"checks": _checks(request.proposal, attempt, computation)}
|
|
383
|
+
if head is not None:
|
|
384
|
+
changes.update(status=ProposalStatus.MERGED, merged_revision=head)
|
|
385
|
+
await ProposalRepository(session, domain_id).update(
|
|
386
|
+
request.proposal.id, at=created_at, **changes
|
|
387
|
+
)
|
|
388
|
+
return AttemptResult(
|
|
389
|
+
attempt=attempt,
|
|
390
|
+
computation=computation,
|
|
391
|
+
previous=previous,
|
|
392
|
+
head=head,
|
|
393
|
+
source_deleted=source_deleted,
|
|
394
|
+
events=tuple(events),
|
|
395
|
+
)
|
|
396
|
+
|
|
397
|
+
async def _commit(
|
|
398
|
+
self,
|
|
399
|
+
request: MergeRequest,
|
|
400
|
+
target: RefRow,
|
|
401
|
+
source: RefRow,
|
|
402
|
+
computed: _Computed,
|
|
403
|
+
created_at: datetime,
|
|
404
|
+
) -> bytes:
|
|
405
|
+
"""Write a clean outcome to the target branch; returns its new head."""
|
|
406
|
+
prepared = computed.commit
|
|
407
|
+
if prepared is None: # up to date: the target already contains the source
|
|
408
|
+
return target.head
|
|
409
|
+
session, domain_id = self._session, self._domain
|
|
410
|
+
settings = self._runtime.settings
|
|
411
|
+
compiled = await MaterializedRepository(
|
|
412
|
+
session, domain_id, target.name, writes=settings.writes
|
|
413
|
+
).schema()
|
|
414
|
+
target_schema: Schema | None = None
|
|
415
|
+
if compiled is not None:
|
|
416
|
+
target_schema = load_compiled(compiled.compiled)
|
|
417
|
+
if request.schema_guard is not None:
|
|
418
|
+
await request.schema_guard(target_schema, prepared.schema)
|
|
419
|
+
else:
|
|
420
|
+
# Fails closed: only a caller-supplied guard (which checks clearance) may
|
|
421
|
+
# let a merge remove sensitivity tags.
|
|
422
|
+
lowered = lowered_tags(target_schema, prepared.schema)
|
|
423
|
+
if lowered:
|
|
424
|
+
raise SensitivityLoweredError(t.value for t in lowered)
|
|
425
|
+
merged = prepared.merged
|
|
426
|
+
changes = [
|
|
427
|
+
MaterializedChange(entry, prepared.bodies[(entry.partition, entry.key)])
|
|
428
|
+
for entry in prepared.changeset
|
|
429
|
+
]
|
|
430
|
+
message = request.message or _message(request.proposal)
|
|
431
|
+
stored: RevisionRow | None = None
|
|
432
|
+
if merged.result is MergeResult.FAST_FORWARD:
|
|
433
|
+
new_head, at = source.head, created_at
|
|
434
|
+
else:
|
|
435
|
+
revision = Revision(
|
|
436
|
+
parents=(target.head, source.head),
|
|
437
|
+
root=merged.root,
|
|
438
|
+
domain=domain_id,
|
|
439
|
+
author=Author(principal=request.actor),
|
|
440
|
+
message=message,
|
|
441
|
+
origin=Origin(type=MERGE_ORIGIN, ref=request.proposal.id),
|
|
442
|
+
created_at=created_at,
|
|
443
|
+
stats=merged.stats,
|
|
444
|
+
)
|
|
445
|
+
stored = await _store_revision(
|
|
446
|
+
session,
|
|
447
|
+
self._runtime,
|
|
448
|
+
revision,
|
|
449
|
+
_Prepared(
|
|
450
|
+
root=merged.root,
|
|
451
|
+
changeset=prepared.changeset,
|
|
452
|
+
bodies=prepared.bodies,
|
|
453
|
+
objects=computed.objects,
|
|
454
|
+
nodes=computed.nodes,
|
|
455
|
+
schema=prepared.schema,
|
|
456
|
+
schema_hash=prepared.schema_hash,
|
|
457
|
+
),
|
|
458
|
+
prepared.provenance,
|
|
459
|
+
)
|
|
460
|
+
new_head, at = stored.id, stored.created_at
|
|
461
|
+
await apply_changeset(
|
|
462
|
+
session,
|
|
463
|
+
domain_id,
|
|
464
|
+
target.name,
|
|
465
|
+
revision_id=new_head,
|
|
466
|
+
at=at,
|
|
467
|
+
changes=changes,
|
|
468
|
+
schema=prepared.schema,
|
|
469
|
+
schema_hash=prepared.schema_hash,
|
|
470
|
+
refresh_kinds=prepared.refresh_kinds,
|
|
471
|
+
settings=settings,
|
|
472
|
+
expression_limits=self._runtime.schema.expression_limits,
|
|
473
|
+
runtime=self._runtime,
|
|
474
|
+
lineage_source=source.name if merged.result is MergeResult.FAST_FORWARD else None,
|
|
475
|
+
fast_forward_from=target.head if merged.result is MergeResult.FAST_FORWARD else None,
|
|
476
|
+
)
|
|
477
|
+
if target_schema is not None and prepared.schema_hash is not None:
|
|
478
|
+
await queue_after_tagging(
|
|
479
|
+
session,
|
|
480
|
+
domain_id,
|
|
481
|
+
target.name,
|
|
482
|
+
target_schema,
|
|
483
|
+
prepared.schema,
|
|
484
|
+
runtime=self._runtime,
|
|
485
|
+
)
|
|
486
|
+
refs = RefRepository(session, domain_id)
|
|
487
|
+
try:
|
|
488
|
+
await refs.compare_and_swap(
|
|
489
|
+
target.name,
|
|
490
|
+
expected_head=target.head,
|
|
491
|
+
expected_version=target.version,
|
|
492
|
+
new_head=new_head,
|
|
493
|
+
actor=request.actor,
|
|
494
|
+
at=created_at,
|
|
495
|
+
reason=MERGE_REASON,
|
|
496
|
+
)
|
|
497
|
+
except RefMovedError as error:
|
|
498
|
+
current = await refs.get(target.name)
|
|
499
|
+
raise StaleHeadError(
|
|
500
|
+
target.name, target.head, None if current is None else current.head
|
|
501
|
+
) from error
|
|
502
|
+
if stored is None:
|
|
503
|
+
await OutboxRepository(session).enqueue(
|
|
504
|
+
domain_id,
|
|
505
|
+
COMMITTED_EVENT,
|
|
506
|
+
{
|
|
507
|
+
"domain": domain_id,
|
|
508
|
+
"branch": target.name,
|
|
509
|
+
"revision": hex(new_head),
|
|
510
|
+
"fast_forward": True,
|
|
511
|
+
"changes": merged.stats.changes,
|
|
512
|
+
"message": message,
|
|
513
|
+
},
|
|
514
|
+
)
|
|
515
|
+
else:
|
|
516
|
+
await _record_events(
|
|
517
|
+
session, domain_id, target.name, stored, prepared.changeset, message
|
|
518
|
+
)
|
|
519
|
+
return new_head
|
|
520
|
+
|
|
521
|
+
|
|
522
|
+
async def _branch(refs: RefRepository, name: str, side: str) -> RefRow:
|
|
523
|
+
ref = await refs.get(name)
|
|
524
|
+
if ref is None or ref.kind is not RefKind.BRANCH:
|
|
525
|
+
raise ProposalBranchMissingError(side, name)
|
|
526
|
+
return ref
|
|
527
|
+
|
|
528
|
+
|
|
529
|
+
def _resolution(row: ResolutionRow) -> Resolution:
|
|
530
|
+
return Resolution.model_validate(
|
|
531
|
+
{**row.resolution, "fingerprint": row.fingerprint, "resolver": row.resolved_by}
|
|
532
|
+
)
|
|
533
|
+
|
|
534
|
+
|
|
535
|
+
def _message(proposal: ProposalRow) -> str:
|
|
536
|
+
return f"Merge {proposal.source_ref} into {proposal.target_ref}: {proposal.title}"
|
|
537
|
+
|
|
538
|
+
|
|
539
|
+
def _prepare_commit(
|
|
540
|
+
store: PgNodeStore,
|
|
541
|
+
objects: SessionObjectStore,
|
|
542
|
+
computation: AttemptComputation,
|
|
543
|
+
merged: MergedTree,
|
|
544
|
+
request: MergeRequest,
|
|
545
|
+
created_at: datetime,
|
|
546
|
+
) -> _Commit:
|
|
547
|
+
"""Changeset with provenance, object bodies and schema of a clean outcome."""
|
|
548
|
+
provenance: list[ProvenanceRow] = []
|
|
549
|
+
changeset = list(merged.changes)
|
|
550
|
+
if merged.result is not MergeResult.MERGED:
|
|
551
|
+
changeset = taken_provenance(changeset, objects)
|
|
552
|
+
else:
|
|
553
|
+
own = ProvenanceRow(
|
|
554
|
+
id=new_id(IdPrefix.PROVENANCE),
|
|
555
|
+
origin_type=MERGE_ORIGIN,
|
|
556
|
+
origin_ref=Origin(type=MERGE_ORIGIN, ref=request.proposal.id).model_dump(),
|
|
557
|
+
actor=request.actor,
|
|
558
|
+
at=created_at,
|
|
559
|
+
)
|
|
560
|
+
decisions = decisions_of(computation, merged.auto_resolutions)
|
|
561
|
+
entries = {
|
|
562
|
+
d.fingerprint: ProvenanceEntry(
|
|
563
|
+
id=new_id(IdPrefix.PROVENANCE),
|
|
564
|
+
origin=MergeResolutionOrigin(
|
|
565
|
+
proposal_id=request.proposal.id,
|
|
566
|
+
conflict_id=d.fingerprint,
|
|
567
|
+
resolver=d.resolver or request.actor,
|
|
568
|
+
),
|
|
569
|
+
actor=d.resolver if d.resolver and ":" not in d.resolver else request.actor,
|
|
570
|
+
at=created_at,
|
|
571
|
+
)
|
|
572
|
+
for d in decisions
|
|
573
|
+
}
|
|
574
|
+
changeset, used = complete_provenance(
|
|
575
|
+
changeset, objects, decisions, {fp: e.id for fp, e in entries.items()}, own.id
|
|
576
|
+
)
|
|
577
|
+
provenance = [own, *(ProvenanceRow.from_entry(entries[fp]) for fp in sorted(used))]
|
|
578
|
+
bodies: dict[tuple[str, str], Mapping[str, Any] | None] = {
|
|
579
|
+
(entry.partition, entry.key): None
|
|
580
|
+
if entry.new_hash is None
|
|
581
|
+
else load_object(objects, entry.new_hash)
|
|
582
|
+
for entry in changeset
|
|
583
|
+
}
|
|
584
|
+
schema = tree_schema_loader(store, objects)(merged.root)
|
|
585
|
+
schema_keys = [entry.key for entry in changeset if entry.partition == PARTITION_SCHEMA]
|
|
586
|
+
schema_hash = (
|
|
587
|
+
Tree.load(store, merged.root).partitions[PARTITION_SCHEMA].root if schema_keys else None
|
|
588
|
+
)
|
|
589
|
+
return _Commit(
|
|
590
|
+
merged=merged,
|
|
591
|
+
changeset=changeset,
|
|
592
|
+
bodies=bodies,
|
|
593
|
+
schema=schema,
|
|
594
|
+
schema_hash=schema_hash,
|
|
595
|
+
refresh_kinds=sorted(key for key in schema_keys if key in schema.kinds),
|
|
596
|
+
provenance=provenance,
|
|
597
|
+
)
|
|
598
|
+
|
|
599
|
+
|
|
600
|
+
def _summary(computation: AttemptComputation) -> dict[str, Any]:
|
|
601
|
+
outcome = computation.outcome
|
|
602
|
+
summary: dict[str, Any] = {
|
|
603
|
+
"open": computation.open_count,
|
|
604
|
+
"resolved": computation.resolved_count,
|
|
605
|
+
"auto_resolutions": [a.model_dump(mode="json") for a in outcome.auto_resolutions],
|
|
606
|
+
"unmatched_resolutions": list(computation.unmatched),
|
|
607
|
+
}
|
|
608
|
+
if isinstance(outcome, MergedTree):
|
|
609
|
+
summary["stats"] = outcome.stats.model_dump()
|
|
610
|
+
return summary
|
|
611
|
+
|
|
612
|
+
|
|
613
|
+
def _conflict_row(state: ConflictState, stored: Mapping[str, ResolutionRow]) -> ConflictRow:
|
|
614
|
+
conflict = state.conflict
|
|
615
|
+
payload = conflict.model_dump(mode="json")
|
|
616
|
+
payload["members"] = list(state.members)
|
|
617
|
+
if state.rejected:
|
|
618
|
+
payload["details"] = {**payload.get("details", {}), "rejected": REJECTED_DETAIL}
|
|
619
|
+
row = stored.get(conflict.fingerprint)
|
|
620
|
+
resolved = not state.open and state.resolution is not None
|
|
621
|
+
return ConflictRow(
|
|
622
|
+
fingerprint=conflict.fingerprint,
|
|
623
|
+
code=conflict.kind.value,
|
|
624
|
+
partition=conflict.partition,
|
|
625
|
+
element_key=conflict.key,
|
|
626
|
+
attribute_id=conflict.attribute,
|
|
627
|
+
payload=payload,
|
|
628
|
+
member_count=conflict.count,
|
|
629
|
+
resolution=state.resolution.model_dump(mode="json")
|
|
630
|
+
if resolved and state.resolution
|
|
631
|
+
else None,
|
|
632
|
+
resolved_by=row.resolved_by if resolved and row else None,
|
|
633
|
+
resolved_at=row.at if resolved and row else None,
|
|
634
|
+
)
|
|
635
|
+
|
|
636
|
+
|
|
637
|
+
def _checks(
|
|
638
|
+
proposal: ProposalRow, attempt: MergeAttemptRow, computation: AttemptComputation
|
|
639
|
+
) -> dict[str, Any]:
|
|
640
|
+
"""Proposal checks with the merge preview of the latest attempt (design 10.3)."""
|
|
641
|
+
checks = dict(proposal.checks or {})
|
|
642
|
+
checks["merge"] = {
|
|
643
|
+
"attempt": attempt.id,
|
|
644
|
+
"result": attempt.result,
|
|
645
|
+
"status": attempt.status.value,
|
|
646
|
+
"open_conflicts": computation.open_count,
|
|
647
|
+
"resolved_conflicts": computation.resolved_count,
|
|
648
|
+
"ours": hex(attempt.ours),
|
|
649
|
+
"theirs": hex(attempt.theirs),
|
|
650
|
+
"at": attempt.created_at.isoformat(),
|
|
651
|
+
}
|
|
652
|
+
return checks
|