* refactor(thumbnails): one resolver for where previews live on disk (#320) Add py/api/preview_store.py: the store of a source, a preview's path, URL parts to disk, containment (owns), a write generation and the lock around every preview's final rename. Every preview reader, writer and remover now asks it instead of joining <source>/thumbnails by hand: serve, on-demand, capture (its path resolved when the write starts), the after-scan queue, the thumbnail plans and job payloads, clear, the temp sweep, History covers, the auto-tag listing and the owned-preview lookups. Source mode only: every path is the one built before, so nothing changes on disk or on the wire. A structural test keeps new thumbnails joins out of the backend. * refactor(thumbnails): keep the after-scan queue's store rule and pin the write lock (#320) A planned target keeps the plan's own containment rule (inside the store, as the thumbnail job writes it); captures keep the serve rule. The structural test also covers PREVIEW_FOLDER joins and the repo-root modules; the write-lock test asserts the lock instead of waiting on a timer; new tests cover nested roots in the after-scan queue and a legacy upper-case preview served through the resolver. * feat(thumbnails): keep previews in one folder, set from config.json (#320) thumbnails.location in config.json can now name one folder for every source's previews: <folder>/<source key>/<rel>_thumb<ext>. A source key is a readable slug plus a short hash of the source's identity, stored in the folder's marker (.promptmanager-thumbnails.json) with the install that made it. Serving, on-demand and capture writes, listings, clear, delete, duplicates, the temp sweep and History covers all follow it; a leftover in <source>/thumbnails is never served and other text under thumbnails/ is refused. A missing, unplugged or foreign folder is unavailable (a probe checks it every 5 s within 2 s; a stalled or failing store call flips it at once): originals are served, nothing is written anywhere, the folder is never re-created, and generate, convert and clear answer 409 location-unavailable. A bad config value is unavailable too, never a fall-back to the sources. No UI yet: POST /settings refuses thumbnail_location (409 location-needs-move); GET /settings reports it with its path, the thumbnail status without one. Sources inside or around the folder and a library move into it are refused. * fix(thumbnails): never follow a marker or a link out of the thumbnail folder (#320) Marker keys are data, not paths: only a single <slug>-<hex> folder name is ever joined onto the thumbnail folder; anything else is dropped and logged. Every preview write and delete passes one ownership check (below a key folder, lexically and resolved, no link, junction or reparse point on the way); make_parent refuses anything else and writes nothing while the folder lacks this install's marker (an unmounted mount point). Clear never descends into a junction. The configured location is in force before any request (unchecked folder mode is unavailable), and a startup check that fails leaves it invalid, never the sources. Older listings that run on the event loop no longer touch the store in folder mode. A leftover under another case of thumbnails/ on a case-insensitive disk is refused too. * test(memory): let the musl test fake os.confstr on Windows (#523) The portability run on main fails since #523: Windows has no os.confstr, so patching it without create=True raises before the test runs. * feat(thumbnails): move thumbnails between the sources and one folder (#320) POST /images/thumbnails/plan reviews a move (what is copied, removed and left, free space, too-long Windows paths, sources no longer listed) and hands out a token; POST /jobs/thumbnails {mode: move} runs it as a thumbnails job: hold automatic previews, copy and check every preview on the job pool, switch with one config commit that records the old copies, then remove them. Per-file errors still switch; a folder-wide error, a cancel before the switch or a crash leaves nothing changed (a journal is undone at the next start); old copies that could not go are removed by a finishing job at the next start. Leaving a folder removes the previews of this install's sources that are no longer listed (Q320-8). Recreate folder and Use this folder answer an unavailable folder; the Thumbnail maintenance scope prune removes the thumbnails of removed sources. * feat(system): choose where thumbnails are kept and move them (#320) System › Sources & scans gains the Thumbnail location row (D196): Inside each source or One folder, the folder path, the status line and Review move…, whose review (D197) lists what is copied, removed and left before Move thumbnails starts; a refused folder is explained under the field, a folder of your own asks for a confirmation first. Activity shows a move and a prune with their own tiles and step line (D198). An unavailable folder shows a notice on Overview and Sources & scans with Recreate folder or Use this folder (D199). Thumbnail maintenance offers Remove thumbnails of removed sources in folder mode (D200), and Move library's review says where the thumbnails stay (D201). Closes #320 * fix(thumbnails): keep a move's own previews and never walk out of a store (#320) A cleanup that waited from an earlier move never names a key folder the new move just filled, so moving back keeps the previews. Removing empty folders walks top-down and never into a link or junction. Any error while copying undoes the copies; once the switch is committed, a journal that cannot be removed no longer undoes it. Hand-edited cleanup entries and marker keys never name a folder outside the stores; removal stops at interpreter exit. The path field carries aria-invalid with its reason, and a review that went stale reviews again. * test(thumbnails): cover the prune scope, the Overview notice and the space refusal (#320) The frontend coverage gate counted the new branches of thumbnail maintenance, the Activity views, the notices' Change location and the api-client's space refusal as uncovered; page and unit tests now drive them. * fix(thumbnails): report removals that fail and explain the quiet excepts (#320) An old thumbnail the finishing move cannot remove (held open on Windows) is now an error row of the job with its path, and logged; a failed copy's temp that cannot go is logged with its path. Every except that only passes says why, and the thread runners say their caller re-raises. * fix(thumbnails): never plan or delete through a linked key folder (#320) Review finding P1 on #591: in folder mode a source key folder that is a link or junction to a folder outside was still handed to the planner, so Convert outdated could remove an older-format file outside the thumbnail folder. ensure_store now refuses a linked store before any planning, and each removal of an old preview passes may_write at the moment it runs. * fix(thumbnails): the temp sweep never walks into a link (#320) Review finding P2 on #591: Clear thumbnails and the startup sweep removed crashed-write temps with a plain os.walk, which on Windows walks into a junction even without followlinks. The sweeper now skips a folder that is a link, junction or reparse point (the top one too), and each temp passes may_write before it is removed. * fix(thumbnails): lock the folder marker across processes (#320) Review finding P2 on #591: two installs sharing one thumbnail folder could each read the marker, add a key and replace it, so the last writer dropped the other's key. write_marker now re-reads, changes and replaces the marker under a lock file made with O_CREAT | O_EXCL beside it (atomic on shares, unlike flock); a lock older than 30 s, left by a writer that died, is taken over, and a wait past 1.5 s fails as a TimeoutError like an unreachable folder. Tested with three processes adding keys at once. * test(thumbnails): cover the space refusal, Overview's Use this folder and the prune review (#320) The frontend coverage floors of activity-kinds, system-overview and thumbnail-review: a disk without the space shows the review but cannot move (a fixture route fakes the free space), Use this folder on Overview reloads the host values, the prune review's text and rows are pure functions with unit tests, and Overview takes the System drafts it is always mounted with. * fix(thumbnails): never copy through a linked folder; refuse a target inside the folder in use (#320) Moving back to Inside each source followed an existing linked folder in the destination and could replace a file outside the source (Codex P1). A target nested inside the thumbnail folder in use was accepted, and the old-copy cleanup then removed the previews just copied there (Codex P2). Every copy now checks the whole destination chain for links, also when the folders exist, and the move refuses a folder inside the one in use with location-inside-current. The cancelled branches no longer mix explicit and implicit returns (code-quality note). * fix(system): one full stop in the move-back review (#320) * fix(thumbnails): a folder check answers for the folder as it is now (#320) Probe.check() joined a check already running, or a finished one nobody had collected. A folder deleted right after configure() woke the probe therefore stayed available until the next probe, which failed the Overview notice spec in the merge queue. It now waits for a running check and starts a new one, still never two at once, and a stuck check still times out. The spec also asserts the fixture's removal took effect.
837 lines
31 KiB
Python
837 lines
31 KiB
Python
"""Move library (#249 S6b): copy the node-folder library, verify it, switch.
|
||
|
||
``plan_move`` checks a ``utils.library_location`` location can move and
|
||
sizes the copy; ``move_library`` does it, in this order:
|
||
|
||
1. **Disk check** on the target's volume (the copy, the backups and
|
||
config.json, plus a margin). The source is never renamed across folders,
|
||
so another drive is simply a copy.
|
||
2. **Copy** with SQLite's backup API from a private connection holding one
|
||
read transaction: the per-table counts, the workflow documents' digest
|
||
and ``PRAGMA data_version`` describe exactly the copied snapshot. The copy
|
||
is a temp file in the target folder, made self-contained
|
||
(``journal_mode=DELETE``), fsynced and owner-only.
|
||
3. **Verify**: ``verify_database_file`` (integrity check, schema, every
|
||
workflow document decompressed against its hash, #315), the same row
|
||
count in every table and the same documents.
|
||
4. **Copy files**: every pooled safety backup and pre-migration backup,
|
||
checked by size and SHA-256 after the copy (their times are kept, so the
|
||
pool prunes in the same order).
|
||
5. **Publish** under the old database's write lock (``BEGIN IMMEDIATE``) and
|
||
the config.json lock: if any other connection wrote since the snapshot
|
||
(``data_version``), steps 2–4 run again (``attempts``, then
|
||
``library-busy``). Else config.json is copied (owner-only, checked), a
|
||
``pending`` marker is written, the temp files take their final names
|
||
(``os.replace`` inside the target folder), the marker turns ``moved``
|
||
and ``switch`` points the process at the new library while the old file
|
||
is still locked, so no write can land in it.
|
||
6. **Retire**: the old file's WAL is checkpointed and the old ``prompts.db``
|
||
and ``config.json`` are renamed ``*.moved-to-<folder>`` (never deleted;
|
||
the old backups stay where they are). A file still open elsewhere is left
|
||
in place and reported (``old-in-use``): the marker already makes a restart
|
||
use the new library.
|
||
|
||
Any failure before the marker says ``moved`` removes everything the move
|
||
wrote in the target folder and leaves the old library in use, untouched.
|
||
A crash at any point leaves either the old library in use (the marker is
|
||
absent or ``pending``; the next move replaces its own leftovers) or the new
|
||
one (``moved``). ``MoveFailed`` carries a code and a message without paths.
|
||
"""
|
||
|
||
import contextlib
|
||
import hashlib
|
||
import json
|
||
import os
|
||
import shutil
|
||
import sqlite3
|
||
import time
|
||
import types
|
||
import uuid
|
||
from pathlib import Path
|
||
from dataclasses import dataclass, field
|
||
from typing import Callable, Dict, List, Optional, Tuple
|
||
|
||
from .models import BUSY_TIMEOUT_SECONDS, PromptModel
|
||
|
||
try:
|
||
from ..utils import library_location as ll
|
||
from ..utils.logging_config import get_logger
|
||
from ..utils.workspace_keys import KEY_FILE_NAME
|
||
except ImportError:
|
||
from utils import library_location as ll
|
||
from utils.logging_config import get_logger
|
||
from utils.workspace_keys import KEY_FILE_NAME
|
||
|
||
logger = get_logger("prompt_manager.database.library_move")
|
||
|
||
TEMP_PREFIX = ".pm-move-"
|
||
MOVED_INFIX = ".moved-to-"
|
||
DISK_MARGIN_BYTES = 16 * 2**20
|
||
DEFAULT_ATTEMPTS = 3
|
||
_COPY_CHUNK = 4 * 2**20
|
||
# Backups that live beside the database and move with it.
|
||
BACKUP_INFIXES = (".backup_", ".pre-workflow-storage_")
|
||
|
||
MESSAGES = {
|
||
"not-movable": (
|
||
"This library is not in the node folder, so there is nothing to move."
|
||
),
|
||
"same-folder": "The new folder is the node folder itself.",
|
||
"target-invalid": "The new folder cannot hold the library.",
|
||
"data-dir-served": (
|
||
"The new folder is inside Prompt Manager's thumbnail folder. Nothing was "
|
||
"moved."
|
||
),
|
||
"target-has-library": (
|
||
"The new folder already holds a Prompt Manager library. Nothing was moved."
|
||
),
|
||
"target-conflict": (
|
||
"The new folder already holds files with the same names. Nothing was moved."
|
||
),
|
||
"insufficient-space": (
|
||
"There is not enough free space for the copy. Nothing was moved."
|
||
),
|
||
"copy-failed": "The copy could not be written or did not match. Nothing was moved.",
|
||
"verify-failed": "The copy did not pass verification. Nothing was moved.",
|
||
"library-busy": (
|
||
"Prompt Manager kept writing to the library while it was copied. "
|
||
"Nothing was moved; try again when no workflow is running."
|
||
),
|
||
"switch-failed": (
|
||
"Prompt Manager could not switch to the verified copy, so the copy was "
|
||
"removed. Nothing was moved."
|
||
),
|
||
}
|
||
|
||
|
||
class MoveFailed(Exception):
|
||
"""A move that did not happen (or did not finish), with a coded reason."""
|
||
|
||
def __init__(self, code: str, message: Optional[str] = None):
|
||
self.code = code
|
||
self.message = message or MESSAGES.get(code, "The library was not moved.")
|
||
super().__init__(self.message)
|
||
|
||
|
||
@dataclass(frozen=True)
|
||
class MovePlan:
|
||
location: "ll.LibraryLocation"
|
||
db_path: str
|
||
source_dir: str
|
||
target_dir: str
|
||
config_path: Optional[str]
|
||
backups: Tuple[str, ...]
|
||
db_bytes: int
|
||
files_bytes: int
|
||
|
||
@property
|
||
def required_bytes(self) -> int:
|
||
return self.db_bytes + self.files_bytes + DISK_MARGIN_BYTES
|
||
|
||
@property
|
||
def target_location(self) -> "ll.LibraryLocation":
|
||
"""Where the library is once moved (an explicit config.json stays)."""
|
||
source = self.location.target_source or ll.SOURCE_DEFAULT
|
||
config = (
|
||
self.location.config_path
|
||
if self.location.config_explicit
|
||
else os.path.join(self.target_dir, ll.CONFIG_NAME)
|
||
)
|
||
return ll.LibraryLocation(
|
||
source,
|
||
os.path.join(self.target_dir, ll.DATABASE_NAME),
|
||
config,
|
||
self.target_dir,
|
||
self.target_dir,
|
||
None,
|
||
source,
|
||
self.location.config_explicit,
|
||
)
|
||
|
||
|
||
@dataclass
|
||
class MoveResult:
|
||
location: "ll.LibraryLocation"
|
||
counts: Dict[str, int]
|
||
files: List[str]
|
||
renamed: List[str] = field(default_factory=list)
|
||
retire_problem: Optional[str] = None
|
||
|
||
|
||
def _size(path: str) -> int:
|
||
try:
|
||
return os.path.getsize(path)
|
||
except OSError:
|
||
return 0
|
||
|
||
|
||
def _free_bytes(path: str) -> int:
|
||
return shutil.disk_usage(path).free
|
||
|
||
|
||
def _existing_ancestor(path: str) -> str:
|
||
current = os.path.abspath(path)
|
||
while not os.path.isdir(current):
|
||
parent = os.path.dirname(current)
|
||
if parent == current:
|
||
break
|
||
current = parent
|
||
return current
|
||
|
||
|
||
def backup_names(db_path: str) -> Tuple[str, ...]:
|
||
"""The pooled and pre-migration backups beside ``db_path`` (names)."""
|
||
folder = os.path.dirname(os.path.abspath(db_path))
|
||
base = os.path.basename(db_path)
|
||
prefixes = tuple(base + infix for infix in BACKUP_INFIXES)
|
||
try:
|
||
names = os.listdir(folder)
|
||
except OSError:
|
||
return ()
|
||
return tuple(
|
||
sorted(
|
||
name
|
||
for name in names
|
||
if name.startswith(prefixes) and os.path.isfile(os.path.join(folder, name))
|
||
)
|
||
)
|
||
|
||
|
||
def plan_move(location) -> MovePlan:
|
||
"""Check ``location`` can move to its target; raises MoveFailed."""
|
||
if not location.movable:
|
||
raise MoveFailed("not-movable")
|
||
target = os.path.abspath(location.target_dir)
|
||
source_dir = os.path.dirname(os.path.abspath(location.db_path))
|
||
if ll.same_folder(target, source_dir) or os.path.normcase(
|
||
os.path.realpath(target)
|
||
) == os.path.normcase(os.path.realpath(source_dir)):
|
||
raise MoveFailed("same-folder")
|
||
if os.path.exists(target) and not os.path.isdir(target):
|
||
raise MoveFailed("target-invalid")
|
||
backups = backup_names(location.db_path)
|
||
# config.json moves with a node-folder library; one named by
|
||
# PROMPT_MANAGER_CONFIG_PATH is the user's and stays where it is.
|
||
config = None if location.config_explicit else location.config_path
|
||
config = config if config and os.path.isfile(config) else None
|
||
if ll.read_marker(target).get("state") != ll.MARKER_PENDING:
|
||
if os.path.exists(os.path.join(target, ll.DATABASE_NAME)):
|
||
raise MoveFailed("target-has-library")
|
||
names = [ll.CONFIG_NAME] if config else []
|
||
if any(os.path.exists(os.path.join(target, n)) for n in names + list(backups)):
|
||
raise MoveFailed("target-conflict")
|
||
db_bytes = _size(location.db_path) + _size(location.db_path + "-wal")
|
||
files_bytes = sum(_size(os.path.join(source_dir, n)) for n in backups)
|
||
files_bytes += _size(config) if config else 0
|
||
return MovePlan(
|
||
location,
|
||
os.path.abspath(location.db_path),
|
||
source_dir,
|
||
target,
|
||
config,
|
||
backups,
|
||
db_bytes,
|
||
files_bytes,
|
||
)
|
||
|
||
|
||
def space_problem(plan: MovePlan) -> Optional[Tuple[int, int]]:
|
||
"""``(required, free)`` when the target's volume is short, else None."""
|
||
free = _free_bytes(_existing_ancestor(plan.target_dir))
|
||
if free < plan.required_bytes:
|
||
return plan.required_bytes, free
|
||
return None
|
||
|
||
|
||
# ── copy and verify ───────────────────────────────────────────────────
|
||
|
||
|
||
def _tables(conn) -> List[str]:
|
||
return [
|
||
row[0]
|
||
for row in conn.execute(
|
||
"SELECT name FROM sqlite_master WHERE type='table' "
|
||
"AND name NOT LIKE 'sqlite_%' ORDER BY name"
|
||
)
|
||
]
|
||
|
||
|
||
def _describe(conn) -> Tuple[Dict[str, int], str]:
|
||
"""Row counts per table and a digest of the workflow documents."""
|
||
counts = {}
|
||
for table in _tables(conn):
|
||
# A name read from sqlite_master, quoted as an identifier.
|
||
quoted = '"' + table.replace('"', '""') + '"'
|
||
sql = f"SELECT COUNT(*) FROM {quoted}" # nosec B608
|
||
counts[table] = conn.execute(sql).fetchone()[0]
|
||
digest = hashlib.sha256()
|
||
if "workflow_documents" in counts:
|
||
for row in conn.execute(
|
||
"SELECT id, sha256, byte_length FROM workflow_documents ORDER BY id"
|
||
):
|
||
digest.update(repr((row[0], bytes(row[1]), row[2])).encode())
|
||
return counts, digest.hexdigest()
|
||
|
||
|
||
def _fsync_file(path: str) -> None:
|
||
with open(path, "rb+") as handle:
|
||
os.fsync(handle.fileno())
|
||
|
||
|
||
def _fsync_dir(path: str) -> None:
|
||
if os.name != "posix":
|
||
return
|
||
fd = os.open(path, os.O_RDONLY)
|
||
try:
|
||
os.fsync(fd)
|
||
finally:
|
||
os.close(fd)
|
||
|
||
|
||
def _private(path: str) -> None:
|
||
if os.name == "posix":
|
||
os.chmod(path, 0o600)
|
||
|
||
|
||
def _copy_snapshot(source: sqlite3.Connection, temp: str):
|
||
"""Back up ``source``'s current snapshot to ``temp``; return what it holds."""
|
||
source.execute("BEGIN")
|
||
try:
|
||
version = source.execute("PRAGMA data_version").fetchone()[0]
|
||
counts, documents = _describe(source)
|
||
destination = sqlite3.connect(temp)
|
||
try:
|
||
source.backup(destination)
|
||
finally:
|
||
destination.close()
|
||
finally:
|
||
source.execute("COMMIT")
|
||
with contextlib.closing(sqlite3.connect(temp)) as conn:
|
||
conn.execute("PRAGMA journal_mode=DELETE").fetchone()
|
||
_fsync_file(temp)
|
||
_private(temp)
|
||
return version, counts, documents
|
||
|
||
|
||
def _verify_copy(temp: str, counts, documents) -> None:
|
||
ok, reason = PromptModel.verify_database_file(temp)
|
||
if not ok:
|
||
logger.error(f"Library move: the copy did not verify: {reason}")
|
||
detail = reason if reason.startswith("Workflow document") else None
|
||
raise MoveFailed(
|
||
"verify-failed",
|
||
f"{MESSAGES['verify-failed']} {detail}" if detail else None,
|
||
)
|
||
uri = Path(os.path.abspath(temp)).as_uri() + "?mode=ro"
|
||
try:
|
||
with contextlib.closing(sqlite3.connect(uri, uri=True)) as conn:
|
||
copied = _describe(conn)
|
||
except sqlite3.Error as error:
|
||
logger.error(f"Library move: the copy could not be read: {error}")
|
||
raise MoveFailed("verify-failed") from error
|
||
if copied != (counts, documents):
|
||
logger.error("Library move: the copy's rows or documents differ")
|
||
raise MoveFailed("verify-failed")
|
||
|
||
|
||
def _hash_copy(source: str, destination: str) -> Tuple[int, str]:
|
||
"""Copy ``source`` to ``destination`` (fsynced, owner-only, times kept)."""
|
||
digest = hashlib.sha256()
|
||
size = 0
|
||
with open(source, "rb") as reader, open(destination, "wb") as writer:
|
||
while True:
|
||
chunk = reader.read(_COPY_CHUNK)
|
||
if not chunk:
|
||
break
|
||
digest.update(chunk)
|
||
size += len(chunk)
|
||
writer.write(chunk)
|
||
writer.flush()
|
||
os.fsync(writer.fileno())
|
||
stat = os.stat(source)
|
||
os.utime(destination, ns=(stat.st_atime_ns, stat.st_mtime_ns))
|
||
_private(destination)
|
||
return size, digest.hexdigest()
|
||
|
||
|
||
def _file_digest(path: str) -> Tuple[int, str]:
|
||
digest = hashlib.sha256()
|
||
size = 0
|
||
with open(path, "rb") as reader:
|
||
while True:
|
||
chunk = reader.read(_COPY_CHUNK)
|
||
if not chunk:
|
||
return size, digest.hexdigest()
|
||
digest.update(chunk)
|
||
size += len(chunk)
|
||
|
||
|
||
def _check_copies(copies) -> None:
|
||
for source, temp, expected in copies:
|
||
if _file_digest(temp) != expected or _file_digest(source) != expected:
|
||
logger.error(
|
||
f"Library move: {os.path.basename(source)} did not copy unchanged"
|
||
)
|
||
raise MoveFailed("copy-failed")
|
||
|
||
|
||
def write_marker(
|
||
directory: str, state: str, source_db: str, source_config: Optional[str] = None
|
||
) -> str:
|
||
"""Atomically write the move marker; return its path.
|
||
|
||
``from`` and ``config_from`` name the old files, so a restart after a
|
||
crash between the switch and the rename can finish the rename.
|
||
"""
|
||
path = os.path.join(directory, ll.MARKER_NAME)
|
||
temp = os.path.join(directory, f"{TEMP_PREFIX}{uuid.uuid4().hex}.marker")
|
||
payload = json.dumps(
|
||
{
|
||
"version": 1,
|
||
"state": state,
|
||
"from": source_db,
|
||
"config_from": source_config,
|
||
"updated_at": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
|
||
},
|
||
indent=2,
|
||
).encode("utf-8")
|
||
fd = os.open(temp, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600)
|
||
try:
|
||
with os.fdopen(fd, "wb") as handle:
|
||
handle.write(payload)
|
||
handle.flush()
|
||
os.fsync(handle.fileno())
|
||
_private(temp)
|
||
os.replace(temp, path)
|
||
except BaseException:
|
||
_remove(temp)
|
||
raise
|
||
_fsync_dir(directory)
|
||
return path
|
||
|
||
|
||
def _remove(*paths) -> None:
|
||
for path in paths:
|
||
try:
|
||
if path and os.path.lexists(path):
|
||
os.remove(path)
|
||
except OSError:
|
||
logger.warning(f"Library move: could not remove {os.path.basename(path)}")
|
||
|
||
|
||
def sweep_move_temps(directory: str) -> List[str]:
|
||
"""Remove the temp files an interrupted move left in ``directory``."""
|
||
removed = []
|
||
try:
|
||
names = os.listdir(directory)
|
||
except OSError:
|
||
return removed
|
||
for name in names:
|
||
if name.startswith(TEMP_PREFIX):
|
||
_remove(os.path.join(directory, name))
|
||
removed.append(name)
|
||
return removed
|
||
|
||
|
||
# ── the move ─────────────────────────────────────────────────────────
|
||
|
||
|
||
def _noop(*_args, **_kwargs):
|
||
return None
|
||
|
||
|
||
def switch_process(old_db: str, location, detached: list) -> None:
|
||
"""Point this process at ``location``: resolver, every model, observers.
|
||
|
||
Adds the old file's connections, detached and still open, to
|
||
``detached`` as soon as they are detached, so the mover retires them
|
||
(``PromptModel.retire_detached``) whether or not the rest succeeds.
|
||
"""
|
||
from .execution_history import rekey_history_restore
|
||
from .review_changes import rekey_review_changes
|
||
|
||
ll.switch_to(location)
|
||
detached.extend(PromptModel.repoint_instances(old_db, location.db_path))
|
||
rekey_review_changes(old_db, location.db_path)
|
||
rekey_history_restore(old_db, location.db_path)
|
||
|
||
|
||
def unswitch_process(old_db: str, new_db: str) -> list:
|
||
"""Undo ``switch_process`` (any part of it); returns detached connections."""
|
||
from .execution_history import rekey_history_restore
|
||
from .review_changes import rekey_review_changes
|
||
|
||
ll.clear_override()
|
||
detached = PromptModel.repoint_instances(new_db, old_db)
|
||
rekey_review_changes(new_db, old_db)
|
||
rekey_history_restore(new_db, old_db)
|
||
return detached
|
||
|
||
|
||
class _Move:
|
||
def __init__(self, plan, *, switch, config_lock, progress, checkpoint):
|
||
self.plan = plan
|
||
self.switch = switch
|
||
self.config_lock = config_lock
|
||
self.progress = progress
|
||
self.checkpoint = checkpoint
|
||
self.created_dir = False
|
||
self.temps: List[str] = []
|
||
self.published: List[str] = []
|
||
self.marker: Optional[str] = None
|
||
self.moved = False
|
||
# Retired connections still in use; awaited once the locks are free.
|
||
self.retiring: list = []
|
||
|
||
def temp(self, suffix: str) -> str:
|
||
path = os.path.join(
|
||
self.plan.target_dir, f"{TEMP_PREFIX}{uuid.uuid4().hex}{suffix}"
|
||
)
|
||
self.temps.append(path)
|
||
return path
|
||
|
||
def prepare_target(self) -> None:
|
||
target = self.plan.target_dir
|
||
self.created_dir = not os.path.isdir(target)
|
||
ll.ensure_private_dir(target)
|
||
sweep_move_temps(target)
|
||
if ll.read_marker(target).get("state") == ll.MARKER_PENDING:
|
||
# An earlier move stopped before it switched: its files are ours.
|
||
names = (ll.DATABASE_NAME, ll.CONFIG_NAME, KEY_FILE_NAME)
|
||
for name in names + self.plan.backups:
|
||
_remove(os.path.join(target, name))
|
||
for suffix in ("-wal", "-shm", "-journal"):
|
||
_remove(os.path.join(target, ll.DATABASE_NAME + suffix))
|
||
|
||
def copy_round(self, source):
|
||
"""Steps 2–4 once: the verified temp copies and the snapshot version."""
|
||
self.progress("Copying the database")
|
||
temp_db = self.temp(".db")
|
||
version, counts, documents = _copy_snapshot(source, temp_db)
|
||
self.checkpoint("copied")
|
||
self.progress("Verifying the copy")
|
||
_verify_copy(temp_db, counts, documents)
|
||
self.checkpoint("verified")
|
||
copies = []
|
||
for name in self.plan.backups:
|
||
src = os.path.join(self.plan.source_dir, name)
|
||
temp = self.temp(".bak")
|
||
try:
|
||
expected = _hash_copy(src, temp)
|
||
except OSError as error:
|
||
logger.error(f"Library move: copying {name} failed: {error}")
|
||
raise MoveFailed("copy-failed") from error
|
||
copies.append((src, temp, expected, name))
|
||
if copies:
|
||
self.progress(f"Copied {len(copies)} backup file(s)")
|
||
self.checkpoint("files")
|
||
_check_copies([(s, t, e) for s, t, e, _ in copies])
|
||
return version, counts, temp_db, copies
|
||
|
||
def discard_round(self, temp_db, copies) -> None:
|
||
_remove(temp_db, *[temp for _s, temp, _e, _n in copies])
|
||
|
||
def publish(self, temp_db, copies) -> List[str]:
|
||
"""Under the locks: config.json, marker, final names, marker moved."""
|
||
target = self.plan.target_dir
|
||
files = []
|
||
if self.plan.config_path:
|
||
temp = self.temp(".config")
|
||
try:
|
||
expected = _hash_copy(self.plan.config_path, temp)
|
||
except OSError as error:
|
||
raise MoveFailed("copy-failed") from error
|
||
_check_copies([(self.plan.config_path, temp, expected)])
|
||
copies = copies + [(self.plan.config_path, temp, expected, ll.CONFIG_NAME)]
|
||
copies = copies + self._key_copy()
|
||
self.marker = write_marker(
|
||
target, ll.MARKER_PENDING, self.plan.db_path, self.plan.config_path
|
||
)
|
||
self.checkpoint("pending")
|
||
for _src, temp, _expected, name in copies:
|
||
final = os.path.join(target, name)
|
||
os.replace(temp, final)
|
||
self.published.append(final)
|
||
files.append(name)
|
||
final_db = os.path.join(target, ll.DATABASE_NAME)
|
||
os.replace(temp_db, final_db)
|
||
self.published.append(final_db)
|
||
files.append(ll.DATABASE_NAME)
|
||
_fsync_dir(target)
|
||
self.checkpoint("published")
|
||
write_marker(target, ll.MARKER_MOVED, self.plan.db_path, self.plan.config_path)
|
||
self.moved = True
|
||
self.checkpoint("marked")
|
||
return files
|
||
|
||
def _key_copy(self) -> list:
|
||
"""The workspace key's verified copy (#430), or none: it is re-creatable.
|
||
|
||
Without it the moved library starts with a new key, so pages left open
|
||
across the restart refresh once; that never fails the move.
|
||
"""
|
||
source = os.path.join(self.plan.source_dir, KEY_FILE_NAME)
|
||
if not os.path.isfile(source):
|
||
return []
|
||
temp = self.temp(".key")
|
||
try:
|
||
expected = _hash_copy(source, temp)
|
||
_check_copies([(source, temp, expected)])
|
||
except (OSError, MoveFailed) as error:
|
||
_remove(temp)
|
||
logger.warning(
|
||
f"Library move: the workspace key was not copied ({error}); "
|
||
"open workspace pages refresh once after the restart"
|
||
)
|
||
return []
|
||
return [(source, temp, expected, KEY_FILE_NAME)]
|
||
|
||
def cleanup(self) -> None:
|
||
if self.moved:
|
||
return
|
||
_remove(*self.temps, *self.published, self.marker)
|
||
if self.created_dir:
|
||
try:
|
||
os.rmdir(self.plan.target_dir)
|
||
except OSError:
|
||
# not empty or already gone: the undo leaves it alone
|
||
pass
|
||
|
||
|
||
def _unique(path: str) -> str:
|
||
candidate, number = path, 1
|
||
while os.path.lexists(candidate):
|
||
candidate = f"{path}_{number}"
|
||
number += 1
|
||
return candidate
|
||
|
||
|
||
def retire_old_files(plan: MovePlan) -> Tuple[List[str], Optional[str]]:
|
||
"""Rename the old database and config.json; ``(renamed, problem code)``.
|
||
|
||
Every connection to the old file must be closed. The WAL is checkpointed
|
||
first; when it cannot be emptied (another process still has the file
|
||
open) nothing is renamed (``old-in-use``).
|
||
"""
|
||
old = plan.db_path
|
||
folder = os.path.basename(os.path.normpath(plan.target_dir))
|
||
try:
|
||
with contextlib.closing(sqlite3.connect(old, timeout=1)) as conn:
|
||
busy = conn.execute("PRAGMA wal_checkpoint(TRUNCATE)").fetchone()[0]
|
||
except sqlite3.Error as error:
|
||
logger.warning(
|
||
f"Library move: the old database could not be checkpointed: {error}"
|
||
)
|
||
return [], "old-in-use"
|
||
if busy or _size(old + "-wal") > 0:
|
||
return [], "old-in-use"
|
||
renamed = []
|
||
try:
|
||
for path in (old, plan.config_path):
|
||
if not path or not os.path.isfile(path):
|
||
continue
|
||
new_name = _unique(path + MOVED_INFIX + folder)
|
||
os.rename(path, new_name)
|
||
renamed.append(os.path.basename(new_name))
|
||
for suffix in ("-wal", "-shm"):
|
||
if os.path.isfile(old + suffix) and _size(old + suffix) == 0:
|
||
os.remove(old + suffix)
|
||
except OSError as error:
|
||
logger.warning(f"Library move: the old files could not be renamed: {error}")
|
||
return renamed, "old-locked"
|
||
return renamed, None
|
||
|
||
|
||
def finish_interrupted_move(location) -> List[str]:
|
||
"""Rename old files a crash left between the switch and the rename.
|
||
|
||
The marker of the folder in use says ``moved`` and names the old files;
|
||
an old ``prompts.db`` (and config.json) still there under its own name is
|
||
renamed ``*.moved-to-<folder>`` now, never deleted. Returns the new
|
||
names (nothing when there is nothing to finish or the file is in use).
|
||
"""
|
||
if location.source not in (ll.SOURCE_DEFAULT, ll.SOURCE_ENVIRONMENT):
|
||
return []
|
||
marker = ll.read_marker(location.data_dir)
|
||
old = marker.get("from")
|
||
if marker.get("state") != ll.MARKER_MOVED or not isinstance(old, str):
|
||
return []
|
||
if not os.path.isfile(old) or os.path.basename(old) != ll.DATABASE_NAME:
|
||
return []
|
||
if ll.same_folder(os.path.dirname(old), location.data_dir):
|
||
return []
|
||
config = marker.get("config_from")
|
||
if not (
|
||
isinstance(config, str)
|
||
and os.path.basename(config) == ll.CONFIG_NAME
|
||
and os.path.isfile(config)
|
||
and os.path.normcase(os.path.abspath(config))
|
||
!= os.path.normcase(os.path.abspath(location.config_path))
|
||
):
|
||
config = None
|
||
pending = types.SimpleNamespace(
|
||
db_path=old, config_path=config, target_dir=location.data_dir
|
||
)
|
||
renamed, problem = retire_old_files(pending)
|
||
if problem:
|
||
logger.warning(f"Library move: the old files are still in use ({problem})")
|
||
elif renamed:
|
||
logger.info(f"Library move finished: renamed {', '.join(renamed)}")
|
||
return renamed
|
||
|
||
|
||
def move_library(
|
||
plan: MovePlan,
|
||
*,
|
||
switch: Callable = switch_process,
|
||
unswitch: Callable = unswitch_process,
|
||
after_switch: Callable = _noop,
|
||
config_lock=None,
|
||
progress: Callable[[str], None] = _noop,
|
||
checkpoint: Callable[[str], None] = _noop,
|
||
attempts: int = DEFAULT_ATTEMPTS,
|
||
) -> MoveResult:
|
||
"""Move the library as planned (blocking; see the module docstring).
|
||
|
||
``switch(old_db, location, detached)`` points the process at the copy
|
||
and adds the old connections it detached to ``detached``; if it raises,
|
||
``unswitch(old_db, new_db)`` undoes it while both files are still
|
||
locked, every detached connection is closed, the copy is removed and
|
||
the move fails (``switch-failed``). ``after_switch()`` runs
|
||
once the switch is final; it is best effort (logged, never fails the move).
|
||
"""
|
||
short = space_problem(plan)
|
||
if short is not None:
|
||
raise MoveFailed("insufficient-space")
|
||
lock = config_lock if config_lock is not None else contextlib.nullcontext()
|
||
move = _Move(
|
||
plan,
|
||
switch=switch,
|
||
config_lock=lock,
|
||
progress=progress,
|
||
checkpoint=checkpoint,
|
||
)
|
||
move.unswitch = unswitch
|
||
location = plan.target_location
|
||
try:
|
||
move.prepare_target()
|
||
files, counts = _copy_and_switch(move, location, attempts)
|
||
except Exception:
|
||
move.cleanup()
|
||
raise
|
||
try:
|
||
after_switch()
|
||
except Exception:
|
||
logger.exception("Library move: a follow-up after the switch failed")
|
||
from .execution_history import notify_history_restore
|
||
|
||
notify_history_restore(location.db_path)
|
||
progress("Renaming the old files")
|
||
renamed, problem = retire_old_files(plan)
|
||
checkpoint("retired")
|
||
return MoveResult(location, counts, files, renamed, problem)
|
||
|
||
|
||
def _switch_guarded(move: _Move, location) -> list:
|
||
"""Switch while the copy is locked too; undo it all if any part fails.
|
||
|
||
The copy's write lock keeps every repointed thread from writing to it
|
||
until the switch is whole, so undoing a partial switch loses nothing.
|
||
Returns the old file's detached connections (still open). On failure
|
||
every detached connection is retired; those still in use are added to
|
||
``move.retiring`` for the caller to await once the locks are released.
|
||
"""
|
||
plan = move.plan
|
||
guard = sqlite3.connect(
|
||
location.db_path, timeout=BUSY_TIMEOUT_SECONDS, isolation_level=None
|
||
)
|
||
try:
|
||
guard.execute("BEGIN IMMEDIATE")
|
||
detached: list = []
|
||
try:
|
||
move.switch(plan.db_path, location, detached)
|
||
except Exception as error:
|
||
logger.exception("Library move: switching failed; undoing it")
|
||
move.moved = False # the copy and its marker are removed
|
||
# Retire, never wait, here: a connection still in use may be
|
||
# waiting on the very locks this thread holds.
|
||
try:
|
||
undone = move.unswitch(plan.db_path, location.db_path)
|
||
except Exception:
|
||
logger.exception("Library move: undoing the switch failed")
|
||
else:
|
||
_retire(move, undone)
|
||
# The old file's connections a partial switch detached: the
|
||
# models reopen it on next use, so close them now, not at GC.
|
||
_retire(move, detached)
|
||
raise MoveFailed("switch-failed") from error
|
||
move.checkpoint("switched")
|
||
return detached
|
||
finally:
|
||
if guard.in_transaction:
|
||
guard.execute("ROLLBACK")
|
||
guard.close()
|
||
|
||
|
||
def _copy_and_switch(move: _Move, location, attempts: int):
|
||
"""Copy, then switch under both locks; retired connections are awaited.
|
||
|
||
A connection that was inside a statement when it was retired is closed
|
||
by its own thread once the statement ends, which may need the locks
|
||
released first; the old files are renamed (and a failed copy removed)
|
||
only after that, since Windows refuses to rename or delete an open file.
|
||
"""
|
||
try:
|
||
return _copy_and_switch_locked(move, location, attempts)
|
||
finally:
|
||
try:
|
||
PromptModel.await_detached(move.retiring, logger)
|
||
except Exception:
|
||
logger.exception("Library move: waiting for old connections failed")
|
||
|
||
|
||
def _retire(move: _Move, connections: list) -> None:
|
||
"""Retire ``connections``, adding those still in use to ``move.retiring``.
|
||
|
||
Never raises: a switch that completed must not be reported as failed
|
||
because one of the old connections could not be retired (logged; the
|
||
rename then reports the old files as still in use).
|
||
"""
|
||
try:
|
||
move.retiring.extend(PromptModel.retire_detached(connections, logger))
|
||
except Exception:
|
||
logger.exception("Library move: retiring the old connections failed")
|
||
|
||
|
||
def _copy_and_switch_locked(move: _Move, location, attempts: int):
|
||
plan = move.plan
|
||
for attempt in range(1, attempts + 1):
|
||
source = sqlite3.connect(
|
||
plan.db_path, timeout=BUSY_TIMEOUT_SECONDS, isolation_level=None
|
||
)
|
||
try:
|
||
source.execute(f"PRAGMA busy_timeout = {BUSY_TIMEOUT_SECONDS * 1000}")
|
||
version, counts, temp_db, copies = move.copy_round(source)
|
||
source.execute("BEGIN IMMEDIATE")
|
||
try:
|
||
if source.execute("PRAGMA data_version").fetchone()[0] != version:
|
||
move.progress("The library changed during the copy; copying again")
|
||
move.discard_round(temp_db, copies)
|
||
continue
|
||
move.checkpoint("locked")
|
||
with move.config_lock:
|
||
files = move.publish(temp_db, copies)
|
||
detached = _switch_guarded(move, location)
|
||
# Still holding the old file's lock: a write that was waiting
|
||
# on it is interrupted and cannot commit, so it fails (and
|
||
# runs again on the new library) instead of landing in the
|
||
# old file. Its own thread closes the connection.
|
||
_retire(move, detached)
|
||
finally:
|
||
if source.in_transaction:
|
||
source.execute("ROLLBACK")
|
||
return files, counts
|
||
finally:
|
||
source.close()
|
||
raise MoveFailed("library-busy")
|