lorebase-scribe 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_scribe/__init__.py +3 -0
- lorebase_scribe/__main__.py +156 -0
- lorebase_scribe/checkpoints.py +324 -0
- lorebase_scribe/cron.py +117 -0
- lorebase_scribe/embeddings.py +570 -0
- lorebase_scribe/extraction/__init__.py +17 -0
- lorebase_scribe/extraction/assemble.py +339 -0
- lorebase_scribe/extraction/candidates.py +136 -0
- lorebase_scribe/extraction/discovery.py +567 -0
- lorebase_scribe/extraction/llm_discovery.py +271 -0
- lorebase_scribe/extraction/matching.py +285 -0
- lorebase_scribe/extraction/ontology.py +432 -0
- lorebase_scribe/extraction/parsing.py +114 -0
- lorebase_scribe/extraction/pipeline.py +1091 -0
- lorebase_scribe/extraction/relations.py +194 -0
- lorebase_scribe/extraction/sandbox.py +269 -0
- lorebase_scribe/extraction/settings.py +293 -0
- lorebase_scribe/extraction/text.py +179 -0
- lorebase_scribe/extraction/tier2.py +573 -0
- lorebase_scribe/extraction/values.py +282 -0
- lorebase_scribe/jobs.py +418 -0
- lorebase_scribe/scheduler.py +318 -0
- lorebase_scribe/service.py +146 -0
- lorebase_scribe/settings.py +521 -0
- lorebase_scribe/sources/__init__.py +60 -0
- lorebase_scribe/sources/access.py +241 -0
- lorebase_scribe/sources/connectors.py +320 -0
- lorebase_scribe/sources/delta.py +175 -0
- lorebase_scribe/sources/http_json.py +283 -0
- lorebase_scribe/sources/mapping.py +429 -0
- lorebase_scribe/sources/profile.py +36 -0
- lorebase_scribe/sources/records.py +298 -0
- lorebase_scribe/sources/sql.py +332 -0
- lorebase_scribe/sources/sync.py +730 -0
- lorebase_scribe/telemetry.py +113 -0
- lorebase_scribe/webhooks.py +99 -0
- lorebase_scribe/worker.py +254 -0
- lorebase_scribe-0.2.3.dist-info/METADATA +23 -0
- lorebase_scribe-0.2.3.dist-info/RECORD +41 -0
- lorebase_scribe-0.2.3.dist-info/WHEEL +4 -0
- lorebase_scribe-0.2.3.dist-info/entry_points.txt +2 -0
|
@@ -0,0 +1,730 @@
|
|
|
1
|
+
"""One synchronization run of a structured Source (design 9.1).
|
|
2
|
+
|
|
3
|
+
1. Resolve the Source in the schema of the Domain's default branch (the target branch) and
|
|
4
|
+
record the run in `source_runs`.
|
|
5
|
+
2. Read the rows (incremental Sources from the watermark of the last successful run) and map
|
|
6
|
+
them; rows that do not fit are skipped and reported, up to `max_row_errors`.
|
|
7
|
+
3. Prepare the Source branch (`target_branch_policy.branch_template`, default
|
|
8
|
+
`source/{source}`): create it from the target branch, or, when no Proposal from it is open
|
|
9
|
+
and its head differs from the target's, recreate it at the target head, so every run
|
|
10
|
+
compares the Source with what the target holds now.
|
|
11
|
+
4. Compute the delta against the branch head and commit it in batches of `commit_batch`
|
|
12
|
+
changes with origin `source_sync` and one provenance entry per changed row (Source id, run
|
|
13
|
+
id, row locator).
|
|
14
|
+
5. Open the Source's Proposal into the target branch, or update the open one (title with the
|
|
15
|
+
run's counts, idempotent per run), then apply the merge policy: `proposal` computes the
|
|
16
|
+
merge preview only; `auto_when_clean` merges when no Conflict remains; and
|
|
17
|
+
`auto_with_authority` also resolves value Conflicts on Attributes whose `authority` is the
|
|
18
|
+
Source in the Source's favor.
|
|
19
|
+
|
|
20
|
+
A dry run reads, maps and computes the delta against the state step 3 would produce, then
|
|
21
|
+
records the counts without writing to any branch.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
from __future__ import annotations
|
|
25
|
+
|
|
26
|
+
import logging
|
|
27
|
+
import re
|
|
28
|
+
import time
|
|
29
|
+
from collections import defaultdict
|
|
30
|
+
from collections.abc import Callable, Mapping
|
|
31
|
+
from dataclasses import dataclass, field
|
|
32
|
+
from datetime import UTC, date, datetime
|
|
33
|
+
from decimal import Decimal
|
|
34
|
+
from typing import Any
|
|
35
|
+
|
|
36
|
+
from pydantic import JsonValue, ValidationError
|
|
37
|
+
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
|
|
38
|
+
|
|
39
|
+
from lorebase_core.merge import MergePolicy, Side
|
|
40
|
+
from lorebase_core.model import (
|
|
41
|
+
IdPrefix,
|
|
42
|
+
ProvenanceEntry,
|
|
43
|
+
Schema,
|
|
44
|
+
Source,
|
|
45
|
+
SourceSyncOrigin,
|
|
46
|
+
new_id,
|
|
47
|
+
new_ulid,
|
|
48
|
+
)
|
|
49
|
+
from lorebase_core.model.source import MergePolicy as SourcePolicy
|
|
50
|
+
from lorebase_core.model.source import SyncMode
|
|
51
|
+
from lorebase_core.revision import Author, Origin
|
|
52
|
+
from lorebase_core.types.encoding import format_timestamp
|
|
53
|
+
from lorebase_scribe.settings import SourceSettings
|
|
54
|
+
from lorebase_scribe.sources.connectors import ConnectorFactory, default_connector
|
|
55
|
+
from lorebase_scribe.sources.delta import (
|
|
56
|
+
CurrentState,
|
|
57
|
+
Delta,
|
|
58
|
+
StoredRecord,
|
|
59
|
+
batches,
|
|
60
|
+
compute_delta,
|
|
61
|
+
)
|
|
62
|
+
from lorebase_scribe.sources.mapping import (
|
|
63
|
+
MappedRow,
|
|
64
|
+
Row,
|
|
65
|
+
RowError,
|
|
66
|
+
RowMapper,
|
|
67
|
+
SourceDefinitionError,
|
|
68
|
+
)
|
|
69
|
+
from lorebase_scribe.telemetry import SOURCE_CHANGES, SOURCE_ROWS, tracer
|
|
70
|
+
from lorebase_store.commit import Change, ValidationFailedError, commit
|
|
71
|
+
from lorebase_store.commit.schema import load_compiled
|
|
72
|
+
from lorebase_store.materialize import create_branch
|
|
73
|
+
from lorebase_store.merging import AttemptResult, MergeRequest, MergeService
|
|
74
|
+
from lorebase_store.models.enums import ProposalOrigin, ProposalStatus, RunStatus
|
|
75
|
+
from lorebase_store.repositories import (
|
|
76
|
+
DomainRepository,
|
|
77
|
+
DomainRow,
|
|
78
|
+
MaterializedRepository,
|
|
79
|
+
ProposalFilter,
|
|
80
|
+
ProposalRepository,
|
|
81
|
+
ProposalRow,
|
|
82
|
+
RefRepository,
|
|
83
|
+
SourceRunRepository,
|
|
84
|
+
SourceRunRow,
|
|
85
|
+
scope_to_domain,
|
|
86
|
+
)
|
|
87
|
+
from lorebase_store.runtime import StoreRuntime
|
|
88
|
+
|
|
89
|
+
log = logging.getLogger("lorebase.scribe.sources")
|
|
90
|
+
|
|
91
|
+
RUN_PREFIX = "srn"
|
|
92
|
+
PROPOSAL_PREFIX = "prp"
|
|
93
|
+
POLICY_KEY = "merge_policy"
|
|
94
|
+
"""Key of the merge policy in a Domain's settings and in a Proposal's `policies`."""
|
|
95
|
+
SYNC_ORIGIN = "source_sync"
|
|
96
|
+
ISSUE_SAMPLES = 10
|
|
97
|
+
|
|
98
|
+
Clock = Callable[[], datetime]
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def utc_now() -> datetime:
|
|
102
|
+
return datetime.now(UTC)
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
class SourceNotFoundError(LookupError):
|
|
106
|
+
"""The Domain or the Source does not exist."""
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
class SourceSyncError(RuntimeError):
|
|
110
|
+
"""The run cannot succeed as is (bad rows, rejected changes); retrying will not help."""
|
|
111
|
+
|
|
112
|
+
def __init__(self, message: str, details: dict[str, Any] | None = None) -> None:
|
|
113
|
+
super().__init__(message)
|
|
114
|
+
self.details = details or {}
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
PERMANENT_ERRORS: tuple[type[Exception], ...] = (
|
|
118
|
+
SourceNotFoundError,
|
|
119
|
+
SourceDefinitionError,
|
|
120
|
+
SourceSyncError,
|
|
121
|
+
)
|
|
122
|
+
"""Failures a retry of the same job cannot fix."""
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
# Watermarks -----------------------------------------------------------------------------------
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
_LEGACY_BYTES = re.compile(r"b'.*'|b\".*\"", re.S)
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def encode_watermark(value: Any) -> dict[str, JsonValue]:
|
|
132
|
+
"""A watermark value as JSON with its type, so the next run binds the same native type."""
|
|
133
|
+
if isinstance(value, bytes | bytearray | memoryview):
|
|
134
|
+
# SQL Server rowversion is binary(8): hex keeps it JSON-safe and bound back as bytes.
|
|
135
|
+
return {"type": "bytes", "value": bytes(value).hex()}
|
|
136
|
+
if isinstance(value, datetime):
|
|
137
|
+
return {"type": "timestamp", "value": value.isoformat()}
|
|
138
|
+
if isinstance(value, date):
|
|
139
|
+
return {"type": "date", "value": value.isoformat()}
|
|
140
|
+
if isinstance(value, bool):
|
|
141
|
+
return {"type": "text", "value": str(value).lower()}
|
|
142
|
+
if isinstance(value, int):
|
|
143
|
+
return {"type": "integer", "value": str(value)}
|
|
144
|
+
if isinstance(value, Decimal):
|
|
145
|
+
return {"type": "decimal", "value": str(value)}
|
|
146
|
+
if isinstance(value, float):
|
|
147
|
+
return {"type": "float", "value": repr(value)}
|
|
148
|
+
return {"type": "text", "value": str(value)}
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def decode_watermark(stored: Any) -> Any:
|
|
152
|
+
if not isinstance(stored, dict) or "value" not in stored:
|
|
153
|
+
return None
|
|
154
|
+
kind, text = stored.get("type"), str(stored["value"])
|
|
155
|
+
if kind == "bytes":
|
|
156
|
+
return bytes.fromhex(text)
|
|
157
|
+
if kind == "text" and _LEGACY_BYTES.fullmatch(text):
|
|
158
|
+
return None # the repr of bytes stored by an older run: it cannot be bound, resync
|
|
159
|
+
if kind == "timestamp":
|
|
160
|
+
return datetime.fromisoformat(text)
|
|
161
|
+
if kind == "date":
|
|
162
|
+
return date.fromisoformat(text)
|
|
163
|
+
if kind == "integer":
|
|
164
|
+
return int(text)
|
|
165
|
+
if kind == "decimal":
|
|
166
|
+
return Decimal(text)
|
|
167
|
+
if kind == "float":
|
|
168
|
+
return float(text)
|
|
169
|
+
return text
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def _later(current: Any, candidate: Any) -> Any:
|
|
173
|
+
candidate = bytes(candidate) if isinstance(candidate, bytearray | memoryview) else candidate
|
|
174
|
+
if current is None:
|
|
175
|
+
return candidate
|
|
176
|
+
try:
|
|
177
|
+
return candidate if candidate > current else current
|
|
178
|
+
except TypeError:
|
|
179
|
+
return candidate if str(candidate) > str(current) else current
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
# Report ---------------------------------------------------------------------------------------
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
@dataclass
|
|
186
|
+
class SyncReport:
|
|
187
|
+
"""Outcome of a run, stored as the run's stats and returned as the job result."""
|
|
188
|
+
|
|
189
|
+
run_id: str
|
|
190
|
+
domain: str
|
|
191
|
+
source: str
|
|
192
|
+
source_name: str
|
|
193
|
+
branch: str
|
|
194
|
+
target: str
|
|
195
|
+
dry_run: bool
|
|
196
|
+
rows_read: int = 0
|
|
197
|
+
rows_mapped: int = 0
|
|
198
|
+
rows_failed: int = 0
|
|
199
|
+
counts: dict[str, int] = field(default_factory=dict)
|
|
200
|
+
revisions: list[str] = field(default_factory=list)
|
|
201
|
+
proposal_id: str | None = None
|
|
202
|
+
proposal_status: str | None = None
|
|
203
|
+
merge_status: str | None = None
|
|
204
|
+
conflicts: int = 0
|
|
205
|
+
errors: list[dict[str, JsonValue]] = field(default_factory=list)
|
|
206
|
+
watermark: dict[str, JsonValue] | None = None
|
|
207
|
+
duration_ms: int = 0
|
|
208
|
+
|
|
209
|
+
def as_dict(self) -> dict[str, Any]:
|
|
210
|
+
return {
|
|
211
|
+
"run": self.run_id,
|
|
212
|
+
"domain": self.domain,
|
|
213
|
+
"source": self.source,
|
|
214
|
+
"source_name": self.source_name,
|
|
215
|
+
"branch": self.branch,
|
|
216
|
+
"target": self.target,
|
|
217
|
+
"dry_run": self.dry_run,
|
|
218
|
+
"rows": {
|
|
219
|
+
"read": self.rows_read,
|
|
220
|
+
"mapped": self.rows_mapped,
|
|
221
|
+
"failed": self.rows_failed,
|
|
222
|
+
},
|
|
223
|
+
"counts": dict(self.counts),
|
|
224
|
+
"revisions": list(self.revisions),
|
|
225
|
+
"proposal": self.proposal_id,
|
|
226
|
+
"proposal_status": self.proposal_status,
|
|
227
|
+
"merge_status": self.merge_status,
|
|
228
|
+
"conflicts": self.conflicts,
|
|
229
|
+
"errors": list(self.errors),
|
|
230
|
+
"watermark": self.watermark,
|
|
231
|
+
"duration_ms": self.duration_ms,
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
@dataclass
|
|
236
|
+
class _Read:
|
|
237
|
+
rows: list[MappedRow] = field(default_factory=list)
|
|
238
|
+
watermark: Any = None
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
# Helpers --------------------------------------------------------------------------------------
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
def find_source(schema: Schema, reference: str) -> Source | None:
|
|
245
|
+
"""A Source by id or api name."""
|
|
246
|
+
return schema.sources.get(reference) or schema.source_by_api_name(reference)
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
def source_branch(source: Source) -> str:
|
|
250
|
+
template = source.target_branch_policy.branch_template
|
|
251
|
+
try:
|
|
252
|
+
name = template.format(source=source.api_name)
|
|
253
|
+
except (KeyError, IndexError, ValueError):
|
|
254
|
+
raise SourceDefinitionError(f"invalid branch template {template!r}") from None
|
|
255
|
+
if not name.strip():
|
|
256
|
+
raise SourceDefinitionError(f"branch template {template!r} gives an empty name")
|
|
257
|
+
return name
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
def domain_merge_policy(domain: DomainRow) -> MergePolicy:
|
|
261
|
+
"""The Domain's merge policy setting; the default policy when it is missing or invalid."""
|
|
262
|
+
raw = (domain.settings or {}).get(POLICY_KEY) or {}
|
|
263
|
+
try:
|
|
264
|
+
return MergePolicy.model_validate(raw)
|
|
265
|
+
except ValidationError:
|
|
266
|
+
log.warning("invalid merge policy setting", extra={"domain": domain.id})
|
|
267
|
+
return MergePolicy()
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
def merge_policy(domain: DomainRow, schema: Schema, source: Source) -> MergePolicy:
|
|
271
|
+
"""The policy of the Source's merges: the Domain's, plus, for `auto_with_authority`, the
|
|
272
|
+
Source's side on every Attribute it is the authority of."""
|
|
273
|
+
policy = domain_merge_policy(domain)
|
|
274
|
+
if source.target_branch_policy.merge_policy is not SourcePolicy.AUTO_WITH_AUTHORITY:
|
|
275
|
+
return policy
|
|
276
|
+
kind = schema.kinds.get(source.target_kind)
|
|
277
|
+
authority = {
|
|
278
|
+
attribute.id: Side.THEIRS
|
|
279
|
+
for attribute in (kind.attributes.values() if kind else ())
|
|
280
|
+
if attribute.authority == source.id
|
|
281
|
+
}
|
|
282
|
+
return policy.model_copy(
|
|
283
|
+
update={"prefer_attributes": {**policy.prefer_attributes, **authority}}
|
|
284
|
+
)
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
def _issues(error: ValidationFailedError) -> list[dict[str, Any]]:
|
|
288
|
+
return [
|
|
289
|
+
{"code": issue.code.value, "message": issue.message, "key": issue.key}
|
|
290
|
+
for issue in error.issues[:ISSUE_SAMPLES]
|
|
291
|
+
]
|
|
292
|
+
|
|
293
|
+
|
|
294
|
+
# The run ----------------------------------------------------------------------------------------
|
|
295
|
+
|
|
296
|
+
|
|
297
|
+
class SourceSync:
|
|
298
|
+
"""Runs Source synchronizations against one database."""
|
|
299
|
+
|
|
300
|
+
def __init__(
|
|
301
|
+
self,
|
|
302
|
+
sessions: async_sessionmaker[AsyncSession],
|
|
303
|
+
runtime: StoreRuntime,
|
|
304
|
+
settings: SourceSettings,
|
|
305
|
+
*,
|
|
306
|
+
principal: str,
|
|
307
|
+
connectors: ConnectorFactory = default_connector,
|
|
308
|
+
clock: Clock = utc_now,
|
|
309
|
+
) -> None:
|
|
310
|
+
self._sessions = sessions
|
|
311
|
+
self._runtime = runtime
|
|
312
|
+
self._settings = settings
|
|
313
|
+
self._principal = principal
|
|
314
|
+
self._connectors = connectors
|
|
315
|
+
self._clock = clock
|
|
316
|
+
|
|
317
|
+
async def run(self, domain_id: str, reference: str, *, dry_run: bool = False) -> SyncReport:
|
|
318
|
+
"""Synchronize the Source `reference` (id or api name) of a Domain."""
|
|
319
|
+
started = time.monotonic()
|
|
320
|
+
async with self._sessions() as session, session.begin():
|
|
321
|
+
await scope_to_domain(session, domain_id)
|
|
322
|
+
domain = await DomainRepository(session).get(domain_id)
|
|
323
|
+
if domain is None:
|
|
324
|
+
raise SourceNotFoundError(f"no Domain {domain_id!r}")
|
|
325
|
+
schema = await self._schema(session, domain_id, domain.default_branch)
|
|
326
|
+
source = find_source(schema, reference)
|
|
327
|
+
if source is None:
|
|
328
|
+
raise SourceNotFoundError(f"no Source {reference!r} in Domain {domain_id!r}")
|
|
329
|
+
report = SyncReport(
|
|
330
|
+
run_id=f"{RUN_PREFIX}_{new_ulid()}",
|
|
331
|
+
domain=domain_id,
|
|
332
|
+
source=source.id,
|
|
333
|
+
source_name=source.api_name,
|
|
334
|
+
branch=source_branch(source),
|
|
335
|
+
target=domain.default_branch,
|
|
336
|
+
dry_run=dry_run,
|
|
337
|
+
)
|
|
338
|
+
await SourceRunRepository(session, domain_id).insert(
|
|
339
|
+
SourceRunRow(
|
|
340
|
+
id=report.run_id,
|
|
341
|
+
source_id=source.id,
|
|
342
|
+
status=RunStatus.RUNNING,
|
|
343
|
+
started_at=self._clock(),
|
|
344
|
+
finished_at=None,
|
|
345
|
+
stats={"dry_run": dry_run},
|
|
346
|
+
)
|
|
347
|
+
)
|
|
348
|
+
with tracer.start_as_current_span(
|
|
349
|
+
"scribe.source_sync",
|
|
350
|
+
attributes={
|
|
351
|
+
"lorebase.domain": domain_id,
|
|
352
|
+
"lorebase.source": source.api_name,
|
|
353
|
+
"lorebase.run": report.run_id,
|
|
354
|
+
"lorebase.dry_run": dry_run,
|
|
355
|
+
},
|
|
356
|
+
) as span:
|
|
357
|
+
try:
|
|
358
|
+
await self._run(domain, schema, source, report)
|
|
359
|
+
except Exception as error:
|
|
360
|
+
report.duration_ms = int((time.monotonic() - started) * 1000)
|
|
361
|
+
details = error.details if isinstance(error, SourceSyncError) else {}
|
|
362
|
+
await self._finish(
|
|
363
|
+
report,
|
|
364
|
+
RunStatus.FAILED,
|
|
365
|
+
error={"type": type(error).__name__, "message": str(error), **details},
|
|
366
|
+
)
|
|
367
|
+
span.record_exception(error)
|
|
368
|
+
raise
|
|
369
|
+
report.duration_ms = int((time.monotonic() - started) * 1000)
|
|
370
|
+
await self._finish(report, RunStatus.SUCCEEDED)
|
|
371
|
+
span.set_attribute("lorebase.changes", sum(report.counts.values()))
|
|
372
|
+
log.info(
|
|
373
|
+
"source synchronized",
|
|
374
|
+
extra={
|
|
375
|
+
"domain": domain_id,
|
|
376
|
+
"source": source.api_name,
|
|
377
|
+
"run": report.run_id,
|
|
378
|
+
"dry_run": dry_run,
|
|
379
|
+
"rows": report.rows_read,
|
|
380
|
+
**report.counts,
|
|
381
|
+
"proposal": report.proposal_id,
|
|
382
|
+
"merge_status": report.merge_status,
|
|
383
|
+
"duration_ms": report.duration_ms,
|
|
384
|
+
},
|
|
385
|
+
)
|
|
386
|
+
return report
|
|
387
|
+
|
|
388
|
+
async def _schema(self, session: AsyncSession, domain_id: str, branch: str) -> Schema:
|
|
389
|
+
writes = self._runtime.settings.writes
|
|
390
|
+
compiled = await MaterializedRepository(session, domain_id, branch, writes=writes).schema()
|
|
391
|
+
if compiled is None:
|
|
392
|
+
raise SourceNotFoundError(f"branch {branch!r} of {domain_id!r} is not materialized")
|
|
393
|
+
return load_compiled(compiled.compiled)
|
|
394
|
+
|
|
395
|
+
async def _finish(
|
|
396
|
+
self, report: SyncReport, status: RunStatus, error: dict[str, Any] | None = None
|
|
397
|
+
) -> None:
|
|
398
|
+
stats = report.as_dict()
|
|
399
|
+
revision = bytes.fromhex(report.revisions[-1]) if report.revisions else None
|
|
400
|
+
async with self._sessions() as session, session.begin():
|
|
401
|
+
await scope_to_domain(session, report.domain)
|
|
402
|
+
await SourceRunRepository(session, report.domain).finish(
|
|
403
|
+
report.run_id,
|
|
404
|
+
status=status,
|
|
405
|
+
finished_at=self._clock(),
|
|
406
|
+
stats=stats,
|
|
407
|
+
revision_id=revision,
|
|
408
|
+
proposal_id=report.proposal_id,
|
|
409
|
+
error=error,
|
|
410
|
+
)
|
|
411
|
+
|
|
412
|
+
async def _run(
|
|
413
|
+
self, domain: DomainRow, schema: Schema, source: Source, report: SyncReport
|
|
414
|
+
) -> None:
|
|
415
|
+
mapper = RowMapper(schema, source, limits=self._runtime.schema.expression_limits)
|
|
416
|
+
since = await self._since(domain.id, source)
|
|
417
|
+
read = await self._read(mapper, source, since, report)
|
|
418
|
+
async with self._sessions() as session, session.begin():
|
|
419
|
+
await scope_to_domain(session, domain.id)
|
|
420
|
+
state_branch = await self._prepare_branch(session, domain, report)
|
|
421
|
+
current = await self._current(session, domain.id, state_branch, mapper, source)
|
|
422
|
+
delete_missing = source.sync_mode is SyncMode.SNAPSHOT and report.rows_failed == 0
|
|
423
|
+
delta = compute_delta(
|
|
424
|
+
mapper,
|
|
425
|
+
read.rows,
|
|
426
|
+
current,
|
|
427
|
+
delete_missing=delete_missing,
|
|
428
|
+
provenance=lambda row: self._provenance(source, report.run_id, row),
|
|
429
|
+
)
|
|
430
|
+
report.counts = delta.counts()
|
|
431
|
+
SOURCE_CHANGES.add(
|
|
432
|
+
delta.change_count, {"lorebase.source": source.api_name, "dry_run": report.dry_run}
|
|
433
|
+
)
|
|
434
|
+
if report.dry_run:
|
|
435
|
+
return
|
|
436
|
+
await self._commit(domain.id, source, delta, report)
|
|
437
|
+
if read.watermark is not None:
|
|
438
|
+
report.watermark = encode_watermark(read.watermark)
|
|
439
|
+
else:
|
|
440
|
+
previous = await self._previous_watermark(domain.id, source)
|
|
441
|
+
report.watermark = previous
|
|
442
|
+
await self._propose(domain, schema, source, report)
|
|
443
|
+
|
|
444
|
+
# Reading --------------------------------------------------------------------------------
|
|
445
|
+
|
|
446
|
+
async def _previous_watermark(
|
|
447
|
+
self, domain_id: str, source: Source
|
|
448
|
+
) -> dict[str, JsonValue] | None:
|
|
449
|
+
if source.sync_mode is not SyncMode.INCREMENTAL:
|
|
450
|
+
return None
|
|
451
|
+
async with self._sessions() as session, session.begin():
|
|
452
|
+
await scope_to_domain(session, domain_id)
|
|
453
|
+
runs = await SourceRunRepository(session, domain_id).page(source.id, limit=50)
|
|
454
|
+
for run in runs:
|
|
455
|
+
stats = run.stats or {}
|
|
456
|
+
if run.status is RunStatus.SUCCEEDED and not stats.get("dry_run"):
|
|
457
|
+
watermark = stats.get("watermark")
|
|
458
|
+
return watermark if isinstance(watermark, dict) else None
|
|
459
|
+
return None
|
|
460
|
+
|
|
461
|
+
async def _since(self, domain_id: str, source: Source) -> Any:
|
|
462
|
+
return decode_watermark(await self._previous_watermark(domain_id, source))
|
|
463
|
+
|
|
464
|
+
async def _read(
|
|
465
|
+
self, mapper: RowMapper, source: Source, since: Any, report: SyncReport
|
|
466
|
+
) -> _Read:
|
|
467
|
+
connector = self._connectors(source, self._settings)
|
|
468
|
+
read = _Read()
|
|
469
|
+
watermark_column = source.watermark_column
|
|
470
|
+
checked = False
|
|
471
|
+
# Keys seen in the rows of a Source without fixed columns (JSON records): the mapped
|
|
472
|
+
# columns are checked against all of them once the rows are read. Expressions are
|
|
473
|
+
# compiled over all of them too, so such rows are mapped once every row was read.
|
|
474
|
+
seen: dict[str, None] = {}
|
|
475
|
+
held: list[tuple[str, Row]] = []
|
|
476
|
+
async for locator, row in connector.rows(since):
|
|
477
|
+
if not connector.columns:
|
|
478
|
+
seen.update(dict.fromkeys(row))
|
|
479
|
+
elif not checked:
|
|
480
|
+
mapper.check_columns(connector.columns)
|
|
481
|
+
mapper.use_columns(connector.columns)
|
|
482
|
+
checked = True
|
|
483
|
+
report.rows_read += 1
|
|
484
|
+
if watermark_column is not None and row.get(watermark_column) not in (None, ""):
|
|
485
|
+
read.watermark = _later(read.watermark, row[watermark_column])
|
|
486
|
+
if not connector.columns and mapper.has_expressions:
|
|
487
|
+
held.append((locator, row))
|
|
488
|
+
continue
|
|
489
|
+
self._map_row(mapper, row, locator, read, report, seen)
|
|
490
|
+
if seen:
|
|
491
|
+
mapper.use_columns(tuple(seen))
|
|
492
|
+
for locator, row in held:
|
|
493
|
+
self._map_row(mapper, row, locator, read, report, seen)
|
|
494
|
+
if seen:
|
|
495
|
+
mapper.check_columns(tuple(seen))
|
|
496
|
+
report.rows_mapped = len(read.rows)
|
|
497
|
+
SOURCE_ROWS.add(report.rows_read, {"lorebase.source": source.api_name})
|
|
498
|
+
return read
|
|
499
|
+
|
|
500
|
+
def _map_row(
|
|
501
|
+
self,
|
|
502
|
+
mapper: RowMapper,
|
|
503
|
+
row: Row,
|
|
504
|
+
locator: str,
|
|
505
|
+
read: _Read,
|
|
506
|
+
report: SyncReport,
|
|
507
|
+
seen: Mapping[str, None],
|
|
508
|
+
) -> None:
|
|
509
|
+
try:
|
|
510
|
+
read.rows.append(mapper.map(row, locator))
|
|
511
|
+
except RowError as error:
|
|
512
|
+
report.rows_failed += 1
|
|
513
|
+
if len(report.errors) < self._settings.error_samples:
|
|
514
|
+
report.errors.append(error.as_dict())
|
|
515
|
+
if report.rows_failed > self._settings.max_row_errors:
|
|
516
|
+
if seen: # a mapped column no row had explains the failures better
|
|
517
|
+
mapper.check_columns(tuple(seen))
|
|
518
|
+
raise SourceSyncError(
|
|
519
|
+
f"more than {self._settings.max_row_errors} rows failed mapping; nothing "
|
|
520
|
+
"was committed",
|
|
521
|
+
{"rows_failed": report.rows_failed},
|
|
522
|
+
) from None
|
|
523
|
+
|
|
524
|
+
# Branch and state ---------------------------------------------------------------------------
|
|
525
|
+
|
|
526
|
+
async def _open_proposal(
|
|
527
|
+
self, session: AsyncSession, domain_id: str, branch: str, target: str
|
|
528
|
+
) -> ProposalRow | None:
|
|
529
|
+
found = await ProposalRepository(session, domain_id).page(
|
|
530
|
+
ProposalFilter(status=ProposalStatus.OPEN, source_ref=branch, target_ref=target),
|
|
531
|
+
limit=1,
|
|
532
|
+
)
|
|
533
|
+
return found[0] if found else None
|
|
534
|
+
|
|
535
|
+
async def _prepare_branch(
|
|
536
|
+
self, session: AsyncSession, domain: DomainRow, report: SyncReport
|
|
537
|
+
) -> str:
|
|
538
|
+
"""Create or refresh the Source branch; returns the branch the delta is computed on."""
|
|
539
|
+
refs = RefRepository(session, domain.id)
|
|
540
|
+
branch, target = report.branch, report.target
|
|
541
|
+
ref = await refs.get(branch)
|
|
542
|
+
target_ref = await refs.require(target)
|
|
543
|
+
stale = (
|
|
544
|
+
ref is not None
|
|
545
|
+
and ref.head != target_ref.head
|
|
546
|
+
and not ref.protected
|
|
547
|
+
and await self._open_proposal(session, domain.id, branch, target) is None
|
|
548
|
+
)
|
|
549
|
+
if report.dry_run:
|
|
550
|
+
return target if ref is None or stale else branch
|
|
551
|
+
if ref is not None and not stale:
|
|
552
|
+
return branch
|
|
553
|
+
if ref is not None:
|
|
554
|
+
await refs.lock(branch, timeout_ms=self._runtime.settings.commit.lock_timeout_ms)
|
|
555
|
+
await refs.delete(branch)
|
|
556
|
+
await create_branch(
|
|
557
|
+
session,
|
|
558
|
+
domain.id,
|
|
559
|
+
branch,
|
|
560
|
+
created_by=self._principal,
|
|
561
|
+
source=target,
|
|
562
|
+
runtime=self._runtime,
|
|
563
|
+
clock=self._clock,
|
|
564
|
+
)
|
|
565
|
+
return branch
|
|
566
|
+
|
|
567
|
+
async def _current(
|
|
568
|
+
self,
|
|
569
|
+
session: AsyncSession,
|
|
570
|
+
domain_id: str,
|
|
571
|
+
branch: str,
|
|
572
|
+
mapper: RowMapper,
|
|
573
|
+
source: Source,
|
|
574
|
+
) -> CurrentState:
|
|
575
|
+
settings = self._runtime.settings
|
|
576
|
+
rows = MaterializedRepository(session, domain_id, branch, writes=settings.writes)
|
|
577
|
+
batch = settings.materialize.read_batch_rows
|
|
578
|
+
records: dict[str, StoredRecord] = {}
|
|
579
|
+
async for page in rows.stream_records(mapper.kind.id, batch=batch):
|
|
580
|
+
for row in page:
|
|
581
|
+
records[row.key] = StoredRecord(row.object_hash, row.values)
|
|
582
|
+
owned = await rows.record_keys_with_origin(
|
|
583
|
+
mapper.kind.id,
|
|
584
|
+
origin_type=SYNC_ORIGIN,
|
|
585
|
+
origin_field="source_id",
|
|
586
|
+
origin_value=source.id,
|
|
587
|
+
)
|
|
588
|
+
links: dict[str, dict[str, frozenset[str]]] = {}
|
|
589
|
+
for relation_id in mapper.relations:
|
|
590
|
+
by_from: dict[str, set[str]] = defaultdict(set)
|
|
591
|
+
async for link_page in rows.stream_links(relation_id, batch=batch):
|
|
592
|
+
for link in link_page:
|
|
593
|
+
by_from[link.from_key].add(link.to_key)
|
|
594
|
+
links[relation_id] = {key: frozenset(to) for key, to in by_from.items()}
|
|
595
|
+
return CurrentState(records=records, owned=frozenset(owned), links=links)
|
|
596
|
+
|
|
597
|
+
def _provenance(self, source: Source, run_id: str, row: MappedRow) -> ProvenanceEntry:
|
|
598
|
+
return ProvenanceEntry(
|
|
599
|
+
id=new_id(IdPrefix.PROVENANCE),
|
|
600
|
+
origin=SourceSyncOrigin(source_id=source.id, run_id=run_id, row_locator=row.locator),
|
|
601
|
+
actor=self._principal,
|
|
602
|
+
at=format_timestamp(self._clock()),
|
|
603
|
+
)
|
|
604
|
+
|
|
605
|
+
# Writing ----------------------------------------------------------------------------------
|
|
606
|
+
|
|
607
|
+
async def _commit(
|
|
608
|
+
self, domain_id: str, source: Source, delta: Delta, report: SyncReport
|
|
609
|
+
) -> None:
|
|
610
|
+
planned = batches(delta.groups, self._settings.commit_batch)
|
|
611
|
+
counts = delta.counts()
|
|
612
|
+
summary = f"+{counts['added']} ~{counts['changed']} -{counts['removed']}"
|
|
613
|
+
for index, batch in enumerate(planned, start=1):
|
|
614
|
+
changes = [change for group in batch for change in group.changes]
|
|
615
|
+
entries = {c.provenance.id: c.provenance for c in changes if c.provenance is not None}
|
|
616
|
+
part = f" (batch {index} of {len(planned)})" if len(planned) > 1 else ""
|
|
617
|
+
try:
|
|
618
|
+
async with self._sessions() as session, session.begin():
|
|
619
|
+
result = await commit(
|
|
620
|
+
session,
|
|
621
|
+
domain_id,
|
|
622
|
+
report.branch,
|
|
623
|
+
[
|
|
624
|
+
Change(c.change, None if c.provenance is None else c.provenance.id)
|
|
625
|
+
for c in changes
|
|
626
|
+
],
|
|
627
|
+
author=Author(principal=self._principal),
|
|
628
|
+
message=f"Sync {source.api_name}: {summary}{part}",
|
|
629
|
+
origin=Origin(type=SYNC_ORIGIN, ref=report.run_id),
|
|
630
|
+
provenance=list(entries.values()),
|
|
631
|
+
runtime=self._runtime,
|
|
632
|
+
clock=self._clock,
|
|
633
|
+
)
|
|
634
|
+
except ValidationFailedError as error:
|
|
635
|
+
raise SourceSyncError(
|
|
636
|
+
f"the branch rejected batch {index}: {error}",
|
|
637
|
+
{"issues": _issues(error)},
|
|
638
|
+
) from None
|
|
639
|
+
if not result.no_op:
|
|
640
|
+
report.revisions.append(result.revision_id.hex())
|
|
641
|
+
|
|
642
|
+
async def _propose(
|
|
643
|
+
self, domain: DomainRow, schema: Schema, source: Source, report: SyncReport
|
|
644
|
+
) -> None:
|
|
645
|
+
policy = merge_policy(domain, schema, source)
|
|
646
|
+
mode = source.target_branch_policy.merge_policy
|
|
647
|
+
counts = report.counts
|
|
648
|
+
title = (
|
|
649
|
+
f"Sync {source.api_name}: {counts.get('added', 0)} added, "
|
|
650
|
+
f"{counts.get('changed', 0)} changed, {counts.get('removed', 0)} removed"
|
|
651
|
+
)
|
|
652
|
+
description = (
|
|
653
|
+
f"Source {source.api_name} ({source.id}), run {report.run_id}: "
|
|
654
|
+
f"{report.rows_read} rows read, {report.rows_failed} skipped; "
|
|
655
|
+
f"links +{counts.get('links_added', 0)} -{counts.get('links_removed', 0)}."
|
|
656
|
+
)
|
|
657
|
+
async with self._sessions() as session, session.begin():
|
|
658
|
+
await scope_to_domain(session, domain.id)
|
|
659
|
+
proposals = ProposalRepository(session, domain.id)
|
|
660
|
+
proposal = await proposals.by_key(self._principal, report.run_id)
|
|
661
|
+
if proposal is None:
|
|
662
|
+
proposal = await self._open_proposal(
|
|
663
|
+
session, domain.id, report.branch, report.target
|
|
664
|
+
)
|
|
665
|
+
automatic = mode is not SourcePolicy.PROPOSAL
|
|
666
|
+
if not report.revisions and (proposal is None or not automatic):
|
|
667
|
+
# Nothing new: an open Proposal is left as it is (a no-op run).
|
|
668
|
+
if proposal is not None:
|
|
669
|
+
report.proposal_id = proposal.id
|
|
670
|
+
report.proposal_status = proposal.status.value
|
|
671
|
+
return
|
|
672
|
+
now = self._clock()
|
|
673
|
+
stored = {POLICY_KEY: policy.model_dump(mode="json", exclude_defaults=True)}
|
|
674
|
+
if proposal is None:
|
|
675
|
+
proposal = ProposalRow(
|
|
676
|
+
id=f"{PROPOSAL_PREFIX}_{new_ulid()}",
|
|
677
|
+
title=title,
|
|
678
|
+
description=description,
|
|
679
|
+
source_ref=report.branch,
|
|
680
|
+
target_ref=report.target,
|
|
681
|
+
origin=ProposalOrigin.SOURCE_SYNC,
|
|
682
|
+
status=ProposalStatus.OPEN,
|
|
683
|
+
author=self._principal,
|
|
684
|
+
created_at=now,
|
|
685
|
+
checks={},
|
|
686
|
+
policies=stored,
|
|
687
|
+
idempotency_key=report.run_id,
|
|
688
|
+
updated_at=now,
|
|
689
|
+
)
|
|
690
|
+
await proposals.insert(proposal)
|
|
691
|
+
elif report.revisions and proposal.status is ProposalStatus.OPEN:
|
|
692
|
+
updated = await proposals.update(
|
|
693
|
+
proposal.id, at=now, title=title, description=description, policies=stored
|
|
694
|
+
)
|
|
695
|
+
assert updated is not None
|
|
696
|
+
proposal = updated
|
|
697
|
+
report.proposal_id = proposal.id
|
|
698
|
+
if proposal.status is not ProposalStatus.OPEN:
|
|
699
|
+
report.proposal_status = proposal.status.value
|
|
700
|
+
return
|
|
701
|
+
result = await self._merge(session, domain.id, proposal, policy, mode, report)
|
|
702
|
+
report.merge_status = result.attempt.status.value
|
|
703
|
+
if result.computation is not None:
|
|
704
|
+
report.conflicts = result.computation.open_count
|
|
705
|
+
final = await proposals.get(proposal.id)
|
|
706
|
+
report.proposal_status = None if final is None else final.status.value
|
|
707
|
+
|
|
708
|
+
async def _merge(
|
|
709
|
+
self,
|
|
710
|
+
session: AsyncSession,
|
|
711
|
+
domain_id: str,
|
|
712
|
+
proposal: ProposalRow,
|
|
713
|
+
policy: MergePolicy,
|
|
714
|
+
mode: SourcePolicy,
|
|
715
|
+
report: SyncReport,
|
|
716
|
+
) -> AttemptResult:
|
|
717
|
+
service = MergeService(session, domain_id, runtime=self._runtime, clock=self._clock)
|
|
718
|
+
request = MergeRequest(
|
|
719
|
+
proposal=proposal,
|
|
720
|
+
actor=self._principal,
|
|
721
|
+
policy=policy,
|
|
722
|
+
idempotency_key=report.run_id,
|
|
723
|
+
)
|
|
724
|
+
automatic = mode in (SourcePolicy.AUTO_WHEN_CLEAN, SourcePolicy.AUTO_WITH_AUTHORITY)
|
|
725
|
+
if automatic and not self._settings.auto_merge_protected:
|
|
726
|
+
target = await RefRepository(session, domain_id).require(report.target)
|
|
727
|
+
automatic = not target.protected
|
|
728
|
+
if automatic:
|
|
729
|
+
return await service.merge(request)
|
|
730
|
+
return await service.recompute(request)
|