syncmill 0.2.0__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.
- syncmill/__init__.py +11 -0
- syncmill/agents.py +2064 -0
- syncmill/artifact_io.py +201 -0
- syncmill/artifacts.py +1946 -0
- syncmill/board.py +2060 -0
- syncmill/breaker.py +327 -0
- syncmill/cli.py +4986 -0
- syncmill/command_errors.py +32 -0
- syncmill/config.py +1118 -0
- syncmill/dashboard.py +935 -0
- syncmill/gates.py +179 -0
- syncmill/gateway.py +234 -0
- syncmill/git_env.py +99 -0
- syncmill/isolated_session.py +431 -0
- syncmill/matcher.py +127 -0
- syncmill/memory.py +1067 -0
- syncmill/memory_backend.py +1907 -0
- syncmill/memory_migration.py +962 -0
- syncmill/model_routing.py +482 -0
- syncmill/models.py +244 -0
- syncmill/monitor.py +775 -0
- syncmill/phoenix_exporter.py +339 -0
- syncmill/planning.py +512 -0
- syncmill/platform_support.py +14 -0
- syncmill/preflight.py +1129 -0
- syncmill/project.py +1067 -0
- syncmill/report.py +502 -0
- syncmill/review_candidates.py +107 -0
- syncmill/room_compare.py +2380 -0
- syncmill/room_raw.py +484 -0
- syncmill/room_viewer.py +604 -0
- syncmill/rooms.py +4044 -0
- syncmill/session.py +444 -0
- syncmill/strategies/__init__.py +82 -0
- syncmill/strategies/base.py +910 -0
- syncmill/strategies/compete.py +233 -0
- syncmill/strategies/council.py +429 -0
- syncmill/strategies/decompose.py +570 -0
- syncmill/strategies/pipeline.py +221 -0
- syncmill/strategies/route.py +130 -0
- syncmill/supervisor.py +1423 -0
- syncmill/teamfile.py +571 -0
- syncmill/telemetry_hook.py +231 -0
- syncmill/terminal.py +245 -0
- syncmill/tracer.py +548 -0
- syncmill/tui.py +3537 -0
- syncmill/tui_collaboration.py +499 -0
- syncmill/tui_jobs.py +1407 -0
- syncmill/tui_render.py +156 -0
- syncmill/workflow.py +3746 -0
- syncmill/worktree.py +1045 -0
- syncmill-0.2.0.dist-info/METADATA +853 -0
- syncmill-0.2.0.dist-info/RECORD +56 -0
- syncmill-0.2.0.dist-info/WHEEL +4 -0
- syncmill-0.2.0.dist-info/entry_points.txt +2 -0
- syncmill-0.2.0.dist-info/licenses/LICENSE +191 -0
|
@@ -0,0 +1,962 @@
|
|
|
1
|
+
"""Staged, source-retaining migration between syncmill memory backends."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import errno
|
|
7
|
+
import hashlib
|
|
8
|
+
import json
|
|
9
|
+
import os
|
|
10
|
+
import stat
|
|
11
|
+
import sys
|
|
12
|
+
import tempfile
|
|
13
|
+
from collections.abc import Iterator
|
|
14
|
+
from contextlib import AsyncExitStack, contextmanager
|
|
15
|
+
from dataclasses import asdict, dataclass
|
|
16
|
+
from datetime import datetime, timezone
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
from typing import Any, Literal, NoReturn
|
|
19
|
+
from urllib.parse import urlsplit
|
|
20
|
+
from uuid import NAMESPACE_URL, UUID, uuid4, uuid5
|
|
21
|
+
|
|
22
|
+
from syncmill.artifacts import (
|
|
23
|
+
ApplyLockBusy,
|
|
24
|
+
ArtifactError,
|
|
25
|
+
_close_apply_lock_descriptor,
|
|
26
|
+
_lock_file_descriptor,
|
|
27
|
+
_open_apply_lock_descriptor,
|
|
28
|
+
_unlock_file_descriptor,
|
|
29
|
+
)
|
|
30
|
+
from syncmill.config import MemoryConfig, SyncmillConfig
|
|
31
|
+
|
|
32
|
+
BackendName = Literal["memtomem", "mem0", "langgraph"]
|
|
33
|
+
_PERSISTENT_PREFIX = "agent-runtime:"
|
|
34
|
+
_SCRATCH_PREFIX = "board-run:"
|
|
35
|
+
_SCHEMA_VERSION = 2
|
|
36
|
+
_STORAGE_BINDING_VERSION = 1
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@dataclass(frozen=True)
|
|
40
|
+
class MigrationRecord:
|
|
41
|
+
source_id: str
|
|
42
|
+
namespace: str
|
|
43
|
+
content: str
|
|
44
|
+
tags: list[str]
|
|
45
|
+
created_at: str | None
|
|
46
|
+
lineage_key: str
|
|
47
|
+
digest: str
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class MigrationError(RuntimeError):
|
|
51
|
+
"""A load-bearing migration failure."""
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _canonical(value: Any) -> bytes:
|
|
55
|
+
return json.dumps(
|
|
56
|
+
value, ensure_ascii=False, sort_keys=True, separators=(",", ":")
|
|
57
|
+
).encode("utf-8")
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _digest(namespace: str, content: str, tags: list[str]) -> str:
|
|
61
|
+
return hashlib.sha256(
|
|
62
|
+
_canonical({"namespace": namespace, "content": content, "tags": tags})
|
|
63
|
+
).hexdigest()
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _record(source: BackendName, source_id: str, namespace: str, content: str,
|
|
67
|
+
tags: list[str], created_at: str | None) -> MigrationRecord:
|
|
68
|
+
digest = _digest(namespace, content, tags)
|
|
69
|
+
lineage = hashlib.sha256(
|
|
70
|
+
_canonical({"source": source, "source_id": source_id, "digest": digest})
|
|
71
|
+
).hexdigest()
|
|
72
|
+
return MigrationRecord(source_id, namespace, content, tags, created_at, lineage, digest)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _aggregate(records: list[MigrationRecord]) -> str:
|
|
76
|
+
return hashlib.sha256(
|
|
77
|
+
_canonical([(r.lineage_key, r.digest) for r in records])
|
|
78
|
+
).hexdigest()
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _storage_path(value: str) -> str:
|
|
82
|
+
if value == ":memory:":
|
|
83
|
+
raise MigrationError("in-memory stores cannot be migration destinations")
|
|
84
|
+
return str(Path(value).expanduser().resolve(strict=False))
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _mem0_locator_unchecked(config: MemoryConfig) -> dict[str, Any]:
|
|
88
|
+
from syncmill.memory_backend import _resolve_mem0_config
|
|
89
|
+
|
|
90
|
+
try:
|
|
91
|
+
_, resolved = _resolve_mem0_config(config)
|
|
92
|
+
except (TypeError, ValueError) as exc:
|
|
93
|
+
raise MigrationError(f"invalid Mem0 destination configuration: {exc}") from exc
|
|
94
|
+
vector_cfg = resolved["vector_store"]["config"]
|
|
95
|
+
if vector_cfg.get("client") is not None:
|
|
96
|
+
raise MigrationError(
|
|
97
|
+
"programmatic Mem0 clients cannot be bound to a migration destination"
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
collection = vector_cfg.get("collection_name", "syncmill")
|
|
101
|
+
url = vector_cfg.get("url")
|
|
102
|
+
host = vector_cfg.get("host")
|
|
103
|
+
port = vector_cfg.get("port")
|
|
104
|
+
https = vector_cfg.get("https")
|
|
105
|
+
if url and host:
|
|
106
|
+
raise MigrationError("Mem0 destination must use either url or host, not both")
|
|
107
|
+
if url:
|
|
108
|
+
parsed = urlsplit(str(url))
|
|
109
|
+
if (
|
|
110
|
+
parsed.scheme not in {"http", "https"}
|
|
111
|
+
or not parsed.hostname
|
|
112
|
+
or parsed.username is not None
|
|
113
|
+
or parsed.password is not None
|
|
114
|
+
or parsed.query
|
|
115
|
+
or parsed.fragment
|
|
116
|
+
):
|
|
117
|
+
raise MigrationError(
|
|
118
|
+
"Mem0 destination url must be an uncredentialed http(s) endpoint"
|
|
119
|
+
)
|
|
120
|
+
prefix = parsed.path.rstrip("/")
|
|
121
|
+
vector = {
|
|
122
|
+
"mode": "remote",
|
|
123
|
+
"scheme": parsed.scheme,
|
|
124
|
+
"host": parsed.hostname.lower(),
|
|
125
|
+
"port": parsed.port or 6333,
|
|
126
|
+
"prefix": prefix,
|
|
127
|
+
"collection": collection,
|
|
128
|
+
}
|
|
129
|
+
elif host is not None:
|
|
130
|
+
if port is None:
|
|
131
|
+
raise MigrationError("Mem0 destination host requires an explicit port")
|
|
132
|
+
secure = https is True or (
|
|
133
|
+
https is None and vector_cfg.get("api_key") is not None
|
|
134
|
+
)
|
|
135
|
+
vector = {
|
|
136
|
+
"mode": "remote",
|
|
137
|
+
"scheme": "https" if secure else "http",
|
|
138
|
+
"host": str(host).lower(),
|
|
139
|
+
"port": int(port),
|
|
140
|
+
"prefix": "",
|
|
141
|
+
"collection": collection,
|
|
142
|
+
}
|
|
143
|
+
else:
|
|
144
|
+
if vector_cfg.get("api_key") is not None or https is not None:
|
|
145
|
+
raise MigrationError(
|
|
146
|
+
"Mem0 destination api_key/https requires an explicit url or host"
|
|
147
|
+
)
|
|
148
|
+
path = vector_cfg.get("path")
|
|
149
|
+
if not isinstance(path, str):
|
|
150
|
+
raise MigrationError("Mem0 local destination requires a persistent path")
|
|
151
|
+
vector = {
|
|
152
|
+
"mode": "local",
|
|
153
|
+
"path": _storage_path(path),
|
|
154
|
+
"collection": collection,
|
|
155
|
+
}
|
|
156
|
+
return {
|
|
157
|
+
"vector": vector,
|
|
158
|
+
# Mem0 delete writes a DELETE event here after removing the Qdrant row.
|
|
159
|
+
# It is therefore part of the rollback destination, not auxiliary cache.
|
|
160
|
+
"history_db_path": _storage_path(resolved["history_db_path"]),
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def _mem0_locator(config: MemoryConfig) -> dict[str, Any]:
|
|
165
|
+
try:
|
|
166
|
+
return _mem0_locator_unchecked(config)
|
|
167
|
+
except MigrationError:
|
|
168
|
+
raise
|
|
169
|
+
except (TypeError, ValueError) as exc:
|
|
170
|
+
raise MigrationError("invalid Mem0 destination configuration") from exc
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def _target_storage_digest(config: SyncmillConfig, backend: BackendName) -> str:
|
|
174
|
+
memory = _config_for(config, backend)
|
|
175
|
+
if backend == "memtomem":
|
|
176
|
+
locator = {"db_path": _storage_path(memory.db_path)}
|
|
177
|
+
elif backend == "langgraph":
|
|
178
|
+
locator = {"db_path": _storage_path(memory.langgraph.db_path)}
|
|
179
|
+
else:
|
|
180
|
+
locator = _mem0_locator(memory)
|
|
181
|
+
payload = {
|
|
182
|
+
"binding_version": _STORAGE_BINDING_VERSION,
|
|
183
|
+
"backend": backend,
|
|
184
|
+
"locator": locator,
|
|
185
|
+
}
|
|
186
|
+
return "sha256:" + hashlib.sha256(_canonical(payload)).hexdigest()
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def _reject_legacy_artifact(state: dict[str, Any]) -> NoReturn:
|
|
190
|
+
"""Refuse a pre-destination-binding (schema v1) artifact.
|
|
191
|
+
|
|
192
|
+
Called from ``_load``, so no consumer ever holds one. A v1 artifact that got
|
|
193
|
+
as far as writing targets cannot be recovered automatically — the plan does
|
|
194
|
+
not record which destination those ids belong to.
|
|
195
|
+
"""
|
|
196
|
+
if state.get("targets"):
|
|
197
|
+
raise MigrationError(
|
|
198
|
+
"legacy migration artifact has no destination binding; "
|
|
199
|
+
"automatic recovery is disabled; use state.json targets with "
|
|
200
|
+
"the original destination settings for manual recovery"
|
|
201
|
+
)
|
|
202
|
+
raise MigrationError(
|
|
203
|
+
"legacy migration artifact has no destination binding; create a new plan"
|
|
204
|
+
)
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def _validate_target_storage(config: SyncmillConfig, manifest: dict[str, Any]) -> None:
|
|
208
|
+
if manifest.get("storage_binding_version") != _STORAGE_BINDING_VERSION:
|
|
209
|
+
raise MigrationError("unsupported migration storage binding version")
|
|
210
|
+
expected = manifest.get("target_storage_digest")
|
|
211
|
+
actual = _target_storage_digest(config, manifest["target"])
|
|
212
|
+
if not isinstance(expected, str) or actual != expected:
|
|
213
|
+
raise MigrationError(
|
|
214
|
+
"migration destination storage configuration does not match the plan"
|
|
215
|
+
)
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def migration_root() -> Path:
|
|
219
|
+
return Path(os.environ.get("SYNCMILL_MIGRATIONS_DIR", "~/.syncmill/migrations")).expanduser()
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def _tighten_migration_root(*, create: bool) -> Path:
|
|
223
|
+
"""Return the migrations root, private (0700).
|
|
224
|
+
|
|
225
|
+
Every artifact under it holds verbatim memory content, and ``mkdir``'s ``mode``
|
|
226
|
+
applies to the LEAF only — so creating an artifact with ``parents=True`` left the
|
|
227
|
+
root itself at the default umask. Called on the READ paths too (via ``_artifact``,
|
|
228
|
+
best-effort): an existing installation that only ever applies or verifies would
|
|
229
|
+
otherwise keep the old permissive root forever.
|
|
230
|
+
"""
|
|
231
|
+
root = migration_root()
|
|
232
|
+
if create:
|
|
233
|
+
try:
|
|
234
|
+
root.mkdir(parents=True, exist_ok=True)
|
|
235
|
+
os.chmod(root, 0o700)
|
|
236
|
+
except OSError as exc:
|
|
237
|
+
# Same vocabulary as the read branch: the CLI catches only
|
|
238
|
+
# MigrationError, so a raw OSError here would traceback.
|
|
239
|
+
raise MigrationError(
|
|
240
|
+
f"cannot create the migrations directory ({root}): {exc}"
|
|
241
|
+
) from exc
|
|
242
|
+
return root
|
|
243
|
+
try:
|
|
244
|
+
os.chmod(root, 0o700)
|
|
245
|
+
except FileNotFoundError:
|
|
246
|
+
# Nothing to tighten; the caller reports the missing migration itself.
|
|
247
|
+
pass
|
|
248
|
+
except OSError as exc:
|
|
249
|
+
# A root we cannot tighten (owned by someone else, or a filesystem that
|
|
250
|
+
# refuses chmod) stays readable while holding verbatim memory content —
|
|
251
|
+
# refuse rather than proceed under a privacy guarantee we are not keeping.
|
|
252
|
+
raise MigrationError(
|
|
253
|
+
f"cannot make the migrations directory private ({root}): {exc}"
|
|
254
|
+
) from exc
|
|
255
|
+
return root
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
@contextmanager
|
|
259
|
+
def _migration_lock(path: Path) -> Iterator[None]:
|
|
260
|
+
"""Serialize a whole load → operate → write sequence for one migration.
|
|
261
|
+
|
|
262
|
+
Unique temp names stop a torn ``state.json``; they do NOT stop a stale one.
|
|
263
|
+
``apply``/``verify``/``rollback`` each read the full state document and rewrite
|
|
264
|
+
it, so two overlapping commands make the slower one publish a ``targets`` map
|
|
265
|
+
that predates the other's work — orphaning imported rows from any later retry or
|
|
266
|
+
rollback, which is exactly the data the rollback path needs.
|
|
267
|
+
|
|
268
|
+
NOT re-entrant, deliberately. ``apply`` needs a verify at the end, but it calls
|
|
269
|
+
``_verify_locked`` directly inside the lock it already holds rather than
|
|
270
|
+
re-entering here — a re-entrancy shortcut keyed on the migration id would let a
|
|
271
|
+
SECOND task or thread using that same id walk straight past the lock while the
|
|
272
|
+
first awaits backend I/O, which is precisely the concurrency the lock exists to
|
|
273
|
+
stop. Uses the artifact store's cross-platform helpers so the guarantee is not
|
|
274
|
+
quietly absent on a platform without ``fcntl``.
|
|
275
|
+
|
|
276
|
+
Requires a LOCAL filesystem — a correctness requirement, not a nicety.
|
|
277
|
+
``flock`` on a network mount is emulated at best (Linux maps it onto POSIX
|
|
278
|
+
locks over NFS) and silently local-only at worst, so a migrations root on a
|
|
279
|
+
shared mount can have two hosts both believe they hold this lock. The digest
|
|
280
|
+
checks do NOT contain the damage: each host passes the empty-destination scan,
|
|
281
|
+
each writes its own target ids, and last-writer-wins on ``state.json`` leaves
|
|
282
|
+
the other host's imported rows untracked — invisible to verify and unreachable
|
|
283
|
+
by rollback. Documented for operators in the README and both user guides.
|
|
284
|
+
|
|
285
|
+
Acquisition FAILS FAST rather than waiting. Orchestration bridges async through
|
|
286
|
+
one shared module-level event loop, so a blocking acquire taken on that loop's
|
|
287
|
+
thread wedges it: the waiter cannot yield, and the holder needs the same loop to
|
|
288
|
+
finish and release. Failing fast also suits the CLI, where the alternative is a
|
|
289
|
+
second invocation hanging with no output. Every failure becomes a
|
|
290
|
+
``MigrationError`` — the only exception type the migration commands' callers
|
|
291
|
+
catch, so neither ``ApplyLockBusy`` nor a raw ``OSError`` escapes.
|
|
292
|
+
"""
|
|
293
|
+
lock_path = path / ".lock"
|
|
294
|
+
try:
|
|
295
|
+
fd, owner_pid = _open_lock(lock_path)
|
|
296
|
+
except OSError as exc:
|
|
297
|
+
raise MigrationError(f"cannot open the migration lock: {exc}") from exc
|
|
298
|
+
try:
|
|
299
|
+
# Undo the umask masking of the 0600 the open asked for. Left at, say,
|
|
300
|
+
# 0400 under `umask 0200`, the file can never be reopened O_RDWR — the
|
|
301
|
+
# FIRST command would lock this migration out permanently.
|
|
302
|
+
_chmod_lock(fd, lock_path)
|
|
303
|
+
try:
|
|
304
|
+
_lock_file_descriptor(fd, blocking=False)
|
|
305
|
+
except ApplyLockBusy as exc:
|
|
306
|
+
raise MigrationError(
|
|
307
|
+
"another migration command is already running for this migration — "
|
|
308
|
+
"wait for it to finish, then retry"
|
|
309
|
+
) from exc
|
|
310
|
+
except (OSError, ArtifactError) as exc:
|
|
311
|
+
raise MigrationError(f"cannot lock the migration: {exc}") from exc
|
|
312
|
+
try:
|
|
313
|
+
yield
|
|
314
|
+
finally:
|
|
315
|
+
# Only the opener unlocks: a forked child inherits the open file
|
|
316
|
+
# description, and unlocking there would release the PARENT's hold.
|
|
317
|
+
if os.getpid() == owner_pid:
|
|
318
|
+
try:
|
|
319
|
+
_unlock_file_descriptor(fd)
|
|
320
|
+
except OSError: # closing below releases it regardless
|
|
321
|
+
pass
|
|
322
|
+
finally:
|
|
323
|
+
# Same owner-pid guard as the unlock, and for the same reason: the
|
|
324
|
+
# artifact at-fork handler already closes inherited descriptors, so a
|
|
325
|
+
# child unwinding this context would either raise on a closed fd or close
|
|
326
|
+
# an unrelated descriptor that has since reused the number.
|
|
327
|
+
if os.getpid() == owner_pid:
|
|
328
|
+
try:
|
|
329
|
+
_close_apply_lock_descriptor(fd)
|
|
330
|
+
except OSError as exc:
|
|
331
|
+
# Never mask the exception that is already unwinding.
|
|
332
|
+
if sys.exc_info()[0] is None:
|
|
333
|
+
raise MigrationError(
|
|
334
|
+
f"cannot release the migration lock: {exc}"
|
|
335
|
+
) from exc
|
|
336
|
+
|
|
337
|
+
|
|
338
|
+
def _open_lock(lock_path: Path) -> tuple[int, int]:
|
|
339
|
+
"""Open the lock descriptor, registered for fork safety.
|
|
340
|
+
|
|
341
|
+
Registered because a fork-only child inherits the locked open file
|
|
342
|
+
description: if the parent is killed before it unlocks, that child keeps every
|
|
343
|
+
later migration in the fail-fast "already running" state. ``artifacts`` already
|
|
344
|
+
owns that bookkeeping (and clears inherited fds in its at-fork hook), so this
|
|
345
|
+
reuses the descriptor lifecycle — but NOT its ``_cross_process_lock`` wrapper,
|
|
346
|
+
whose thread-local re-entrancy would let a second coroutine on our one shared
|
|
347
|
+
event loop re-enter a held lock, which is the bypass round 2 removed.
|
|
348
|
+
"""
|
|
349
|
+
try:
|
|
350
|
+
fd, owner_pid = _open_apply_lock_descriptor(lock_path)
|
|
351
|
+
except PermissionError:
|
|
352
|
+
# ONLY a permission failure means "left under-permissioned by a
|
|
353
|
+
# restrictive umask". Repairing on any OSError would chmod on EISDIR
|
|
354
|
+
# (turning a `.lock` DIRECTORY into mode 0600), on ENOSPC, on EMFILE —
|
|
355
|
+
# mutations unrelated to the failure, which also bury its real cause.
|
|
356
|
+
_require_regular_lock_path(lock_path)
|
|
357
|
+
os.chmod(lock_path, 0o600)
|
|
358
|
+
fd, owner_pid = _open_apply_lock_descriptor(lock_path)
|
|
359
|
+
except OSError as exc:
|
|
360
|
+
if exc.errno == errno.ELOOP:
|
|
361
|
+
# O_NOFOLLOW rejects symlinks before descriptor validation runs.
|
|
362
|
+
# Preserve the migration API's diagnostic without reopening them.
|
|
363
|
+
raise MigrationError(
|
|
364
|
+
f"the migration lock path is not a regular file: {lock_path}"
|
|
365
|
+
) from exc
|
|
366
|
+
raise
|
|
367
|
+
_verify_lock_target(fd, lock_path)
|
|
368
|
+
return fd, owner_pid
|
|
369
|
+
|
|
370
|
+
|
|
371
|
+
def _require_regular_lock_path(lock_path: Path) -> None:
|
|
372
|
+
"""lstat, so a repair can never follow a symlink into someone else's file."""
|
|
373
|
+
try:
|
|
374
|
+
info = os.lstat(lock_path)
|
|
375
|
+
except OSError as exc:
|
|
376
|
+
raise MigrationError(f"cannot inspect the migration lock: {exc}") from exc
|
|
377
|
+
if not stat.S_ISREG(info.st_mode):
|
|
378
|
+
raise MigrationError(
|
|
379
|
+
f"the migration lock path is not a regular file: {lock_path}"
|
|
380
|
+
)
|
|
381
|
+
|
|
382
|
+
|
|
383
|
+
def _verify_lock_target(fd: int, lock_path: Path) -> None:
|
|
384
|
+
"""Check what we actually opened, on the SUCCESS path too.
|
|
385
|
+
|
|
386
|
+
A readable symlink at ``.lock`` opens fine, so validating only after a
|
|
387
|
+
permission failure left the fast path unguarded: we would then lock, and
|
|
388
|
+
``_chmod_lock`` would change the LINK TARGET's mode — someone else's file set
|
|
389
|
+
to 0600. Comparing ``lstat`` (which does not follow) with ``fstat`` of the
|
|
390
|
+
descriptor we hold rejects a symlink and confirms the two are the same file,
|
|
391
|
+
so nothing swapped underneath between the open and the check."""
|
|
392
|
+
try:
|
|
393
|
+
link_info = os.lstat(lock_path)
|
|
394
|
+
open_info = os.fstat(fd)
|
|
395
|
+
except OSError as exc:
|
|
396
|
+
_release_lock_descriptor(fd)
|
|
397
|
+
raise MigrationError(f"cannot inspect the migration lock: {exc}") from exc
|
|
398
|
+
if (
|
|
399
|
+
not stat.S_ISREG(link_info.st_mode)
|
|
400
|
+
or (link_info.st_dev, link_info.st_ino) != (open_info.st_dev, open_info.st_ino)
|
|
401
|
+
):
|
|
402
|
+
_release_lock_descriptor(fd)
|
|
403
|
+
raise MigrationError(
|
|
404
|
+
f"the migration lock path is not a regular file: {lock_path}"
|
|
405
|
+
)
|
|
406
|
+
|
|
407
|
+
|
|
408
|
+
def _release_lock_descriptor(fd: int) -> None:
|
|
409
|
+
"""Close and unregister a descriptor we are abandoning before locking it."""
|
|
410
|
+
try:
|
|
411
|
+
_close_apply_lock_descriptor(fd)
|
|
412
|
+
except OSError:
|
|
413
|
+
pass
|
|
414
|
+
|
|
415
|
+
|
|
416
|
+
def _chmod_lock(fd: int, lock_path: Path) -> None:
|
|
417
|
+
fchmod = getattr(os, "fchmod", None)
|
|
418
|
+
try:
|
|
419
|
+
if fchmod is not None:
|
|
420
|
+
fchmod(fd, 0o600)
|
|
421
|
+
else: # pragma: no cover - exercised on Windows
|
|
422
|
+
os.chmod(lock_path, 0o600)
|
|
423
|
+
except OSError: # pragma: no cover - permission model is platform-specific
|
|
424
|
+
pass
|
|
425
|
+
|
|
426
|
+
|
|
427
|
+
def _atomic_json(path: Path, value: Any, *, mode: int = 0o600) -> None:
|
|
428
|
+
# A UNIQUE temp in the same dir (the report mkstemp recipe), not a fixed `.tmp`
|
|
429
|
+
# sibling: two writers to one `state.json` — apply and verify can overlap — both
|
|
430
|
+
# opened the same temp path and clobbered each other's content, with the loser's
|
|
431
|
+
# `os.replace` publishing a half-written mix.
|
|
432
|
+
fd, tmp_name = tempfile.mkstemp(dir=str(path.parent), prefix=f".{path.name}.", suffix=".tmp")
|
|
433
|
+
tmp = Path(tmp_name)
|
|
434
|
+
try:
|
|
435
|
+
with os.fdopen(fd, "w", encoding="utf-8") as fh:
|
|
436
|
+
fh.write(json.dumps(value, ensure_ascii=False, indent=2) + "\n")
|
|
437
|
+
os.chmod(tmp, mode)
|
|
438
|
+
os.replace(tmp, path)
|
|
439
|
+
except BaseException:
|
|
440
|
+
tmp.unlink(missing_ok=True)
|
|
441
|
+
raise
|
|
442
|
+
|
|
443
|
+
|
|
444
|
+
def _artifact(migration_id: str) -> Path:
|
|
445
|
+
if not migration_id or any(c not in "abcdefghijklmnopqrstuvwxyz0123456789-" for c in migration_id):
|
|
446
|
+
raise MigrationError("invalid migration id")
|
|
447
|
+
path = _tighten_migration_root(create=False) / migration_id
|
|
448
|
+
if not path.is_dir():
|
|
449
|
+
raise MigrationError(f"migration not found: {migration_id}")
|
|
450
|
+
return path
|
|
451
|
+
|
|
452
|
+
|
|
453
|
+
def _materialized_source(migration_id: str, record: MigrationRecord) -> Path:
|
|
454
|
+
"""Write a migrated record's content beside its migration artifacts and
|
|
455
|
+
return that absolute path, for use as the imported chunk's ``source_file``.
|
|
456
|
+
|
|
457
|
+
A synthetic path is not free: memtomem's orphan detection collects every
|
|
458
|
+
indexed ``source_file`` that no longer exists on disk and deletes its
|
|
459
|
+
chunks — run by the scheduled compaction job, the health watchdog, and
|
|
460
|
+
``mem_cleanup_orphans``. A path that never existed is orphaned by
|
|
461
|
+
definition, so the migrated chunks would be swept the first time any of
|
|
462
|
+
those runs (and, in bulk, could instead trip the mass-delete brake and
|
|
463
|
+
suppress the user's real orphan cleanup).
|
|
464
|
+
|
|
465
|
+
The file lives under the retained migration artifact directory rather than
|
|
466
|
+
the memory dirs: it is real enough for the existence check, outside every
|
|
467
|
+
index root so no watcher re-chunks it under a namespace of its own, and it
|
|
468
|
+
is genuinely the provenance record for what was imported.
|
|
469
|
+
"""
|
|
470
|
+
sources = migration_root() / migration_id / "sources"
|
|
471
|
+
sources.mkdir(parents=True, exist_ok=True)
|
|
472
|
+
# These files hold verbatim memory content; mkdir's mode is umask-masked, so
|
|
473
|
+
# tighten explicitly (the snapshot files beside them are written 0600 too).
|
|
474
|
+
os.chmod(sources, 0o700)
|
|
475
|
+
# The lineage key is caller-derived, so hash it: it may contain separators
|
|
476
|
+
# or exceed the filesystem's name limit.
|
|
477
|
+
name = hashlib.sha256(record.lineage_key.encode("utf-8")).hexdigest()[:32]
|
|
478
|
+
path = sources / f"{name}.md"
|
|
479
|
+
if not path.exists():
|
|
480
|
+
path.write_text(record.content, encoding="utf-8")
|
|
481
|
+
os.chmod(path, 0o600)
|
|
482
|
+
return path.resolve()
|
|
483
|
+
|
|
484
|
+
|
|
485
|
+
def _config_for(config: SyncmillConfig, backend: BackendName) -> MemoryConfig:
|
|
486
|
+
return config.memory.model_copy(update={"backend": backend})
|
|
487
|
+
|
|
488
|
+
|
|
489
|
+
class _MemtomemMaintenance:
|
|
490
|
+
"""Migration-only adapter; sanctioned owner of memtomem maintenance internals.
|
|
491
|
+
|
|
492
|
+
Runtime access remains isolated in ``MemtomemBackend``. Migration needs
|
|
493
|
+
enumerate/import/delete primitives that the public backend contract does not
|
|
494
|
+
expose, so this adapter is the single explicit maintenance exception.
|
|
495
|
+
"""
|
|
496
|
+
name: BackendName = "memtomem"
|
|
497
|
+
|
|
498
|
+
def __init__(self, config: MemoryConfig):
|
|
499
|
+
from syncmill.memory_backend import MemtomemBackend
|
|
500
|
+
self.backend = MemtomemBackend(config)
|
|
501
|
+
|
|
502
|
+
async def records(self) -> list[MigrationRecord]:
|
|
503
|
+
storage = await self.backend._storage()
|
|
504
|
+
rows: list[MigrationRecord] = []
|
|
505
|
+
for source in sorted(await storage.get_all_source_files()):
|
|
506
|
+
for chunk in await storage.list_chunks_by_source(source, limit=100_000):
|
|
507
|
+
rows.append(_record(
|
|
508
|
+
self.name, str(chunk.id), chunk.metadata.namespace, chunk.content,
|
|
509
|
+
list(chunk.metadata.tags), chunk.created_at.isoformat(),
|
|
510
|
+
))
|
|
511
|
+
return rows
|
|
512
|
+
|
|
513
|
+
async def put(self, record: MigrationRecord, migration_id: str) -> str:
|
|
514
|
+
from memtomem.models import Chunk, ChunkMetadata, ChunkType
|
|
515
|
+
|
|
516
|
+
comp = await self.backend._store._ensure_init()
|
|
517
|
+
chunk_id = uuid5(NAMESPACE_URL, f"syncmill:{record.lineage_key}")
|
|
518
|
+
existing = await comp.storage.get_chunk(chunk_id)
|
|
519
|
+
if existing is not None:
|
|
520
|
+
return str(chunk_id)
|
|
521
|
+
created = (
|
|
522
|
+
datetime.fromisoformat(record.created_at)
|
|
523
|
+
if record.created_at else datetime.now(timezone.utc)
|
|
524
|
+
)
|
|
525
|
+
chunk = Chunk(
|
|
526
|
+
id=chunk_id,
|
|
527
|
+
content=record.content,
|
|
528
|
+
created_at=created,
|
|
529
|
+
metadata=ChunkMetadata(
|
|
530
|
+
source_file=_materialized_source(migration_id, record),
|
|
531
|
+
chunk_type=ChunkType.RAW_TEXT,
|
|
532
|
+
tags=tuple(record.tags),
|
|
533
|
+
namespace=record.namespace,
|
|
534
|
+
),
|
|
535
|
+
)
|
|
536
|
+
chunk.embedding = (await comp.embedder.embed_texts([record.content]))[0]
|
|
537
|
+
await comp.storage.upsert_chunks([chunk])
|
|
538
|
+
return str(chunk_id)
|
|
539
|
+
|
|
540
|
+
async def get(self, target_id: str) -> MigrationRecord | None:
|
|
541
|
+
try:
|
|
542
|
+
chunk = await (await self.backend._storage()).get_chunk(UUID(target_id))
|
|
543
|
+
except (ValueError, TypeError):
|
|
544
|
+
return None
|
|
545
|
+
if chunk is None:
|
|
546
|
+
return None
|
|
547
|
+
return _record(
|
|
548
|
+
self.name, str(chunk.id), chunk.metadata.namespace, chunk.content,
|
|
549
|
+
list(chunk.metadata.tags), chunk.created_at.isoformat(),
|
|
550
|
+
)
|
|
551
|
+
|
|
552
|
+
async def delete(self, target_id: str) -> bool:
|
|
553
|
+
try:
|
|
554
|
+
return bool(await (await self.backend._storage()).delete_chunks([UUID(target_id)]))
|
|
555
|
+
except (ValueError, TypeError):
|
|
556
|
+
return False
|
|
557
|
+
|
|
558
|
+
async def close(self) -> None:
|
|
559
|
+
await self.backend.close()
|
|
560
|
+
|
|
561
|
+
|
|
562
|
+
class _Mem0Maintenance:
|
|
563
|
+
name: BackendName = "mem0"
|
|
564
|
+
|
|
565
|
+
def __init__(self, config: MemoryConfig):
|
|
566
|
+
from syncmill.memory_backend import Mem0Backend
|
|
567
|
+
self.backend = Mem0Backend(config)
|
|
568
|
+
self._lineage_ids: dict[str, str] | None = None
|
|
569
|
+
|
|
570
|
+
async def _scan(self) -> tuple[list[MigrationRecord], dict[str, str]]:
|
|
571
|
+
store = self.backend._qdrant_store()
|
|
572
|
+
offset = None
|
|
573
|
+
rows: list[MigrationRecord] = []
|
|
574
|
+
lineage_ids: dict[str, str] = {}
|
|
575
|
+
while True:
|
|
576
|
+
points, offset = await asyncio.to_thread(
|
|
577
|
+
store.client.scroll,
|
|
578
|
+
collection_name=store.collection_name,
|
|
579
|
+
offset=offset,
|
|
580
|
+
limit=100,
|
|
581
|
+
with_payload=True,
|
|
582
|
+
with_vectors=False,
|
|
583
|
+
)
|
|
584
|
+
for point in points:
|
|
585
|
+
payload = point.payload or {}
|
|
586
|
+
if "syncmill_tags" not in payload:
|
|
587
|
+
continue
|
|
588
|
+
namespace = payload.get("user_id")
|
|
589
|
+
content = payload.get("data")
|
|
590
|
+
if not isinstance(namespace, str) or not isinstance(content, str):
|
|
591
|
+
continue
|
|
592
|
+
tags = payload.get("syncmill_tags")
|
|
593
|
+
if not isinstance(tags, list) or not all(isinstance(t, str) for t in tags):
|
|
594
|
+
continue
|
|
595
|
+
lineage = payload.get("syncmill_migration_lineage")
|
|
596
|
+
if isinstance(lineage, str):
|
|
597
|
+
lineage_ids[lineage] = str(point.id)
|
|
598
|
+
rows.append(_record(
|
|
599
|
+
self.name, str(point.id), namespace, content, list(tags),
|
|
600
|
+
payload.get("created_at") if isinstance(payload.get("created_at"), str) else None,
|
|
601
|
+
))
|
|
602
|
+
if offset is None:
|
|
603
|
+
break
|
|
604
|
+
return rows, lineage_ids
|
|
605
|
+
|
|
606
|
+
async def records(self) -> list[MigrationRecord]:
|
|
607
|
+
rows, self._lineage_ids = await self._scan()
|
|
608
|
+
return rows
|
|
609
|
+
|
|
610
|
+
async def put(self, record: MigrationRecord, migration_id: str) -> str:
|
|
611
|
+
# One destination scan reconciles any add/state crash window for the
|
|
612
|
+
# whole apply. Cache it so N migrated records remain O(N), not O(N²).
|
|
613
|
+
if self._lineage_ids is None:
|
|
614
|
+
_, self._lineage_ids = await self._scan()
|
|
615
|
+
existing = self._lineage_ids.get(record.lineage_key)
|
|
616
|
+
if existing is not None:
|
|
617
|
+
return existing
|
|
618
|
+
response = await self.backend._store.add(
|
|
619
|
+
[{"role": "user", "content": record.content}],
|
|
620
|
+
user_id=record.namespace,
|
|
621
|
+
metadata={
|
|
622
|
+
"syncmill_tags": list(record.tags),
|
|
623
|
+
"syncmill_migration_id": migration_id,
|
|
624
|
+
"syncmill_migration_lineage": record.lineage_key,
|
|
625
|
+
"syncmill_migration_digest": record.digest,
|
|
626
|
+
**({"created_at": record.created_at} if record.created_at else {}),
|
|
627
|
+
},
|
|
628
|
+
infer=False,
|
|
629
|
+
)
|
|
630
|
+
results = response.get("results", []) if isinstance(response, dict) else []
|
|
631
|
+
if len(results) != 1 or results[0].get("memory") != record.content:
|
|
632
|
+
raise MigrationError("Mem0 did not confirm the migrated verbatim write")
|
|
633
|
+
target_id = str(results[0]["id"])
|
|
634
|
+
self._lineage_ids[record.lineage_key] = target_id
|
|
635
|
+
return target_id
|
|
636
|
+
|
|
637
|
+
async def get(self, target_id: str) -> MigrationRecord | None:
|
|
638
|
+
raw = await self.backend._store.get(target_id)
|
|
639
|
+
if not isinstance(raw, dict):
|
|
640
|
+
return None
|
|
641
|
+
namespace = raw.get("user_id")
|
|
642
|
+
content = raw.get("memory")
|
|
643
|
+
metadata = raw.get("metadata") or {}
|
|
644
|
+
tags = metadata.get("syncmill_tags")
|
|
645
|
+
if not isinstance(namespace, str) or not isinstance(content, str) or not isinstance(tags, list):
|
|
646
|
+
return None
|
|
647
|
+
return _record(self.name, target_id, namespace, content, list(tags), raw.get("created_at"))
|
|
648
|
+
|
|
649
|
+
async def delete(self, target_id: str) -> bool:
|
|
650
|
+
try:
|
|
651
|
+
await self.backend._store.delete(target_id)
|
|
652
|
+
return True
|
|
653
|
+
except Exception:
|
|
654
|
+
return False
|
|
655
|
+
|
|
656
|
+
async def close(self) -> None:
|
|
657
|
+
await self.backend.close()
|
|
658
|
+
|
|
659
|
+
|
|
660
|
+
class _LangGraphMaintenance:
|
|
661
|
+
"""Migration maintenance adapter over LangGraph's persistent BaseStore."""
|
|
662
|
+
|
|
663
|
+
name: BackendName = "langgraph"
|
|
664
|
+
|
|
665
|
+
def __init__(self, config: MemoryConfig):
|
|
666
|
+
from syncmill.memory_backend import LangGraphBackend
|
|
667
|
+
|
|
668
|
+
self.backend = LangGraphBackend(config)
|
|
669
|
+
|
|
670
|
+
async def records(self) -> list[MigrationRecord]:
|
|
671
|
+
from syncmill.memory_backend import _LANGGRAPH_NS
|
|
672
|
+
|
|
673
|
+
store = await self.backend._ensure_store()
|
|
674
|
+
rows: list[MigrationRecord] = []
|
|
675
|
+
offset = 0
|
|
676
|
+
while True:
|
|
677
|
+
items = await store.asearch(
|
|
678
|
+
_LANGGRAPH_NS, query=None, limit=100, offset=offset
|
|
679
|
+
)
|
|
680
|
+
for item in items:
|
|
681
|
+
row = self.backend._row(item)
|
|
682
|
+
if row is None:
|
|
683
|
+
continue
|
|
684
|
+
created = getattr(item, "created_at", None)
|
|
685
|
+
rows.append(_record(
|
|
686
|
+
self.name,
|
|
687
|
+
row["id"],
|
|
688
|
+
row["namespace"],
|
|
689
|
+
row["content"],
|
|
690
|
+
list(row.get("tags", [])),
|
|
691
|
+
created.isoformat() if hasattr(created, "isoformat") else None,
|
|
692
|
+
))
|
|
693
|
+
offset += len(items)
|
|
694
|
+
if len(items) < 100:
|
|
695
|
+
return rows
|
|
696
|
+
|
|
697
|
+
async def put(self, record: MigrationRecord, migration_id: str) -> str:
|
|
698
|
+
from syncmill.memory_backend import _LANGGRAPH_NS
|
|
699
|
+
|
|
700
|
+
target_id = str(uuid5(NAMESPACE_URL, f"syncmill:{record.lineage_key}"))
|
|
701
|
+
store = await self.backend._ensure_store()
|
|
702
|
+
existing = await store.aget(_LANGGRAPH_NS, target_id)
|
|
703
|
+
if existing is not None:
|
|
704
|
+
return target_id
|
|
705
|
+
value = self.backend._value(
|
|
706
|
+
record.content, list(record.tags), record.namespace
|
|
707
|
+
)
|
|
708
|
+
value.update({
|
|
709
|
+
"syncmill_migration_id": migration_id,
|
|
710
|
+
"syncmill_migration_lineage": record.lineage_key,
|
|
711
|
+
"syncmill_migration_digest": record.digest,
|
|
712
|
+
})
|
|
713
|
+
await store.aput(_LANGGRAPH_NS, target_id, value, index=["content"])
|
|
714
|
+
return target_id
|
|
715
|
+
|
|
716
|
+
async def get(self, target_id: str) -> MigrationRecord | None:
|
|
717
|
+
row = await self.backend.get(target_id)
|
|
718
|
+
if row is None:
|
|
719
|
+
return None
|
|
720
|
+
return _record(
|
|
721
|
+
self.name,
|
|
722
|
+
row["id"],
|
|
723
|
+
row["namespace"],
|
|
724
|
+
row["content"],
|
|
725
|
+
list(row.get("tags", [])),
|
|
726
|
+
None,
|
|
727
|
+
)
|
|
728
|
+
|
|
729
|
+
async def delete(self, target_id: str) -> bool:
|
|
730
|
+
from syncmill.memory_backend import _LANGGRAPH_NS
|
|
731
|
+
|
|
732
|
+
store = await self.backend._ensure_store()
|
|
733
|
+
if await store.aget(_LANGGRAPH_NS, target_id) is None:
|
|
734
|
+
return False
|
|
735
|
+
await store.adelete(_LANGGRAPH_NS, target_id)
|
|
736
|
+
return True
|
|
737
|
+
|
|
738
|
+
async def close(self) -> None:
|
|
739
|
+
await self.backend.close()
|
|
740
|
+
|
|
741
|
+
|
|
742
|
+
def _maintenance(config: SyncmillConfig, backend: BackendName):
|
|
743
|
+
cfg = _config_for(config, backend)
|
|
744
|
+
if backend == "memtomem":
|
|
745
|
+
return _MemtomemMaintenance(cfg)
|
|
746
|
+
if backend == "mem0":
|
|
747
|
+
return _Mem0Maintenance(cfg)
|
|
748
|
+
return _LangGraphMaintenance(cfg)
|
|
749
|
+
|
|
750
|
+
|
|
751
|
+
def _selected(record: MigrationRecord, namespaces: tuple[str, ...]) -> bool:
|
|
752
|
+
if record.namespace.startswith(_SCRATCH_PREFIX):
|
|
753
|
+
return False
|
|
754
|
+
if namespaces:
|
|
755
|
+
return record.namespace in namespaces
|
|
756
|
+
return record.namespace == "shared" or record.namespace.startswith(_PERSISTENT_PREFIX)
|
|
757
|
+
|
|
758
|
+
|
|
759
|
+
async def plan_migration(
|
|
760
|
+
config: SyncmillConfig,
|
|
761
|
+
source: BackendName,
|
|
762
|
+
target: BackendName,
|
|
763
|
+
namespaces: tuple[str, ...] = (),
|
|
764
|
+
) -> tuple[str, dict[str, Any]]:
|
|
765
|
+
if source == target:
|
|
766
|
+
raise MigrationError("source and target backends must differ")
|
|
767
|
+
if any(ns.startswith(_SCRATCH_PREFIX) for ns in namespaces):
|
|
768
|
+
raise MigrationError("board-run:* namespaces are never migratable")
|
|
769
|
+
target_storage_digest = _target_storage_digest(config, target)
|
|
770
|
+
# Registration-as-created, like _apply_locked: `dst`'s construction can raise
|
|
771
|
+
# after `src` exists, and a failing `src.close()` in a plain `finally` skips
|
|
772
|
+
# `dst.close()` — either way an embedded store keeps a handle and its lock.
|
|
773
|
+
async with AsyncExitStack() as stack:
|
|
774
|
+
src = _maintenance(config, source)
|
|
775
|
+
stack.push_async_callback(src.close)
|
|
776
|
+
dst = _maintenance(config, target)
|
|
777
|
+
stack.push_async_callback(dst.close)
|
|
778
|
+
records = sorted(
|
|
779
|
+
(r for r in await src.records() if _selected(r, namespaces)),
|
|
780
|
+
key=lambda r: (r.namespace, r.source_id),
|
|
781
|
+
)
|
|
782
|
+
target_rows = [r for r in await dst.records() if _selected(r, namespaces)]
|
|
783
|
+
if target_rows:
|
|
784
|
+
raise MigrationError("selected destination namespaces must be empty")
|
|
785
|
+
|
|
786
|
+
migration_id = datetime.now(timezone.utc).strftime("%Y%m%d%H%M%S") + "-" + uuid4().hex[:8]
|
|
787
|
+
path = _tighten_migration_root(create=True) / migration_id
|
|
788
|
+
path.mkdir(mode=0o700)
|
|
789
|
+
os.chmod(path, 0o700)
|
|
790
|
+
snapshot = {"schema_version": _SCHEMA_VERSION, "records": [asdict(r) for r in records]}
|
|
791
|
+
manifest = {
|
|
792
|
+
"schema_version": _SCHEMA_VERSION,
|
|
793
|
+
"migration_id": migration_id,
|
|
794
|
+
"source": source,
|
|
795
|
+
"target": target,
|
|
796
|
+
"storage_binding_version": _STORAGE_BINDING_VERSION,
|
|
797
|
+
"target_storage_digest": target_storage_digest,
|
|
798
|
+
"namespaces": list(namespaces),
|
|
799
|
+
"record_count": len(records),
|
|
800
|
+
"source_digest": _aggregate(records),
|
|
801
|
+
"created_at": datetime.now(timezone.utc).isoformat(),
|
|
802
|
+
}
|
|
803
|
+
_atomic_json(path / "snapshot.json", snapshot)
|
|
804
|
+
_atomic_json(path / "manifest.json", manifest)
|
|
805
|
+
_atomic_json(path / "state.json", {"status": "planned", "targets": {}, "errors": []})
|
|
806
|
+
return migration_id, manifest
|
|
807
|
+
|
|
808
|
+
|
|
809
|
+
def _load(path: Path) -> tuple[dict[str, Any], list[MigrationRecord], dict[str, Any]]:
|
|
810
|
+
manifest = json.loads((path / "manifest.json").read_text(encoding="utf-8"))
|
|
811
|
+
snapshot = json.loads((path / "snapshot.json").read_text(encoding="utf-8"))
|
|
812
|
+
state = json.loads((path / "state.json").read_text(encoding="utf-8"))
|
|
813
|
+
manifest_version = manifest.get("schema_version")
|
|
814
|
+
snapshot_version = snapshot.get("schema_version")
|
|
815
|
+
# A v1 artifact is rejected HERE, at the boundary, rather than by a consumer:
|
|
816
|
+
# no path can proceed with one, and the specific recovery guidance needs only
|
|
817
|
+
# `state`, which this function has already read.
|
|
818
|
+
if manifest_version == 1:
|
|
819
|
+
_reject_legacy_artifact(state)
|
|
820
|
+
if manifest_version != _SCHEMA_VERSION:
|
|
821
|
+
raise MigrationError("unsupported migration artifact schema version")
|
|
822
|
+
if snapshot_version != manifest_version:
|
|
823
|
+
raise MigrationError("migration artifact schema versions do not match")
|
|
824
|
+
records = [MigrationRecord(**row) for row in snapshot["records"]]
|
|
825
|
+
if _aggregate(records) != manifest["source_digest"]:
|
|
826
|
+
raise MigrationError("migration snapshot digest mismatch")
|
|
827
|
+
return manifest, records, state
|
|
828
|
+
|
|
829
|
+
|
|
830
|
+
async def apply_migration(config: SyncmillConfig, migration_id: str) -> dict[str, Any]:
|
|
831
|
+
path = _artifact(migration_id)
|
|
832
|
+
with _migration_lock(path):
|
|
833
|
+
return await _apply_locked(config, migration_id, path)
|
|
834
|
+
|
|
835
|
+
|
|
836
|
+
async def _apply_locked(
|
|
837
|
+
config: SyncmillConfig, migration_id: str, path: Path
|
|
838
|
+
) -> dict[str, Any]:
|
|
839
|
+
manifest, records, state = _load(path)
|
|
840
|
+
_validate_target_storage(config, manifest)
|
|
841
|
+
# AsyncExitStack, not two bare names: `dst`'s construction can raise after
|
|
842
|
+
# `src` exists (leaking an embedded store's handle and its lock, so the retry
|
|
843
|
+
# this failure invites cannot open it), and a failing `src.close()` in a plain
|
|
844
|
+
# `finally` skipped `dst.close()` entirely. Each adapter is registered the
|
|
845
|
+
# moment it exists, so every close runs.
|
|
846
|
+
async with AsyncExitStack() as stack:
|
|
847
|
+
src = _maintenance(config, manifest["source"])
|
|
848
|
+
stack.push_async_callback(src.close)
|
|
849
|
+
dst = _maintenance(config, manifest["target"])
|
|
850
|
+
stack.push_async_callback(dst.close)
|
|
851
|
+
live = sorted(
|
|
852
|
+
(r for r in await src.records() if _selected(r, tuple(manifest["namespaces"]))),
|
|
853
|
+
key=lambda r: (r.namespace, r.source_id),
|
|
854
|
+
)
|
|
855
|
+
if _aggregate(live) != manifest["source_digest"]:
|
|
856
|
+
raise MigrationError("source changed since migration plan")
|
|
857
|
+
if not state["targets"]:
|
|
858
|
+
existing = [r for r in await dst.records() if _selected(r, tuple(manifest["namespaces"]))]
|
|
859
|
+
if existing:
|
|
860
|
+
raise MigrationError("selected destination namespaces are no longer empty")
|
|
861
|
+
state["status"] = "applying"
|
|
862
|
+
_atomic_json(path / "state.json", state)
|
|
863
|
+
for record in records:
|
|
864
|
+
if record.lineage_key in state["targets"]:
|
|
865
|
+
continue
|
|
866
|
+
try:
|
|
867
|
+
state["targets"][record.lineage_key] = await dst.put(record, migration_id)
|
|
868
|
+
_atomic_json(path / "state.json", state)
|
|
869
|
+
except Exception as exc:
|
|
870
|
+
state["errors"].append({"lineage": record.lineage_key, "error": f"{type(exc).__name__}: {exc}"[:500]})
|
|
871
|
+
state["status"] = "partial"
|
|
872
|
+
_atomic_json(path / "state.json", state)
|
|
873
|
+
raise MigrationError("migration apply was partial") from exc
|
|
874
|
+
# Direct, NOT verify_migration: we already hold the lock (see _migration_lock).
|
|
875
|
+
return await _verify_locked(config, migration_id, path)
|
|
876
|
+
|
|
877
|
+
|
|
878
|
+
async def verify_migration(config: SyncmillConfig, migration_id: str) -> dict[str, Any]:
|
|
879
|
+
path = _artifact(migration_id)
|
|
880
|
+
with _migration_lock(path):
|
|
881
|
+
return await _verify_locked(config, migration_id, path)
|
|
882
|
+
|
|
883
|
+
|
|
884
|
+
async def _verify_locked(
|
|
885
|
+
config: SyncmillConfig, migration_id: str, path: Path
|
|
886
|
+
) -> dict[str, Any]:
|
|
887
|
+
manifest, records, state = _load(path)
|
|
888
|
+
_validate_target_storage(config, manifest)
|
|
889
|
+
dst = _maintenance(config, manifest["target"])
|
|
890
|
+
failures: list[str] = []
|
|
891
|
+
by_lineage = {r.lineage_key: r for r in records}
|
|
892
|
+
try:
|
|
893
|
+
for lineage, target_id in state["targets"].items():
|
|
894
|
+
expected = by_lineage.get(lineage)
|
|
895
|
+
actual = await dst.get(target_id)
|
|
896
|
+
if expected is None or actual is None or actual.digest != expected.digest:
|
|
897
|
+
failures.append(lineage)
|
|
898
|
+
finally:
|
|
899
|
+
await dst.close()
|
|
900
|
+
missing_target_count = len(records) - len(state["targets"])
|
|
901
|
+
state["status"] = (
|
|
902
|
+
"verified" if not failures and missing_target_count == 0
|
|
903
|
+
else "verification_failed"
|
|
904
|
+
)
|
|
905
|
+
_atomic_json(path / "state.json", state)
|
|
906
|
+
receipt = {
|
|
907
|
+
"schema_version": _SCHEMA_VERSION,
|
|
908
|
+
"migration_id": migration_id,
|
|
909
|
+
"status": state["status"],
|
|
910
|
+
"source": manifest["source"],
|
|
911
|
+
"target": manifest["target"],
|
|
912
|
+
"record_count": len(records),
|
|
913
|
+
"verified_count": len(records) - len(failures) - missing_target_count,
|
|
914
|
+
"missing_target_count": missing_target_count,
|
|
915
|
+
"source_digest": manifest["source_digest"],
|
|
916
|
+
"failure_lineages": failures[:20],
|
|
917
|
+
"verified_at": datetime.now(timezone.utc).isoformat(),
|
|
918
|
+
}
|
|
919
|
+
_atomic_json(path / "receipt.json", receipt)
|
|
920
|
+
if failures or missing_target_count:
|
|
921
|
+
raise MigrationError("migration verification failed")
|
|
922
|
+
return receipt
|
|
923
|
+
|
|
924
|
+
|
|
925
|
+
async def rollback_migration(config: SyncmillConfig, migration_id: str) -> dict[str, Any]:
|
|
926
|
+
path = _artifact(migration_id)
|
|
927
|
+
with _migration_lock(path):
|
|
928
|
+
return await _rollback_locked(config, migration_id, path)
|
|
929
|
+
|
|
930
|
+
|
|
931
|
+
async def _rollback_locked(
|
|
932
|
+
config: SyncmillConfig, migration_id: str, path: Path
|
|
933
|
+
) -> dict[str, Any]:
|
|
934
|
+
manifest, records, state = _load(path)
|
|
935
|
+
_validate_target_storage(config, manifest)
|
|
936
|
+
dst = _maintenance(config, manifest["target"])
|
|
937
|
+
by_lineage = {r.lineage_key: r for r in records}
|
|
938
|
+
deleted = 0
|
|
939
|
+
refused: list[str] = []
|
|
940
|
+
try:
|
|
941
|
+
for lineage, target_id in list(state["targets"].items()):
|
|
942
|
+
actual = await dst.get(target_id)
|
|
943
|
+
expected = by_lineage.get(lineage)
|
|
944
|
+
if actual is None:
|
|
945
|
+
state["targets"].pop(lineage, None)
|
|
946
|
+
continue
|
|
947
|
+
if expected is None or actual.digest != expected.digest:
|
|
948
|
+
refused.append(lineage)
|
|
949
|
+
continue
|
|
950
|
+
if await dst.delete(target_id):
|
|
951
|
+
deleted += 1
|
|
952
|
+
state["targets"].pop(lineage, None)
|
|
953
|
+
_atomic_json(path / "state.json", state)
|
|
954
|
+
finally:
|
|
955
|
+
await dst.close()
|
|
956
|
+
state["status"] = "rolled_back" if not refused and not state["targets"] else "rollback_partial"
|
|
957
|
+
_atomic_json(path / "state.json", state)
|
|
958
|
+
result = {"migration_id": migration_id, "status": state["status"], "deleted": deleted, "refused": refused[:20]}
|
|
959
|
+
_atomic_json(path / "rollback.json", result)
|
|
960
|
+
if state["status"] != "rolled_back":
|
|
961
|
+
raise MigrationError("migration rollback refused changed target records")
|
|
962
|
+
return result
|