Make the store movable and the CI gate actually runnable
CI was a workflow that could not run its own tests: pytest is not a dependency of anything, so a fresh clone fails with "No module named pytest". Test tooling now lives in the backend's dev extra, so CI and a new contributor install the same tools, and ruff is pinned there instead of drifting as a bare version in a workflow step. backup, restore and doctor move the store. Backup uses SQLite's own online API so a running server is safe, and the manifest records the schema version, row counts, sha256 and size read back from the copy. Restore verifies the digest, integrity_check, required tables and schema age before it touches the live file, and keeps what it replaces as .before-restore. Doctor separates broken from degraded. On the 906 MB baseline store (165,973 atoms) it reports no problems and one true warning: 21,362 picture atoms with no CLIP snapshot configured, so those atoms are findable only by the text already on them. The first version called that store healthy because it only complained about paths that were set and missing, never about a sense the store needs and never had. AGPL-3.0-only, because PyMuPDF is dual-licensed AGPL-or-commercial and the repository is public. A permissive license needs the Artifex commercial license bought first; the README says so rather than hiding it. Gates: 253 tests pass; ruff clean repo-wide (17 errors at HEAD); mypy unchanged from HEAD on touched files; pyright 0 errors on the new files; backup 4.0s and restore 2.8s on real data with counts verified equal.
This commit is contained in:
1 parent
922d01202f
commit
07825913fe
10 files changed
+1234
-11
No files matched your search
@@ -18,6 +18,7 @@ from .benchmarks import (
|
||||
from .cartridge import CartridgeService
|
||||
from .cartridge_assets import CartridgeAssetService
|
||||
from .cli_common import json_dump
|
||||
from .cli_maintenance import register as register_maintenance_commands
|
||||
from .cli_senses import register as register_sense_commands
|
||||
from .cli_senses import senses_report
|
||||
from .cli_transactive import register as register_transactive_commands
|
||||
@@ -40,6 +41,7 @@ app = typer.Typer(no_args_is_help=True)
|
||||
|
||||
register_transactive_commands(app, lambda: get_settings())
|
||||
register_sense_commands(app, lambda: get_settings())
|
||||
register_maintenance_commands(app, lambda: get_settings())
|
||||
|
||||
|
||||
@app.command()
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
"""The commands that keep a local store keepable: backup, restore, doctor."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable
|
||||
from pathlib import Path
|
||||
|
||||
import typer
|
||||
|
||||
from .config import Settings
|
||||
from .maintenance import backup, doctor, restore
|
||||
|
||||
|
||||
def register(app: typer.Typer, settings: Callable[[], Settings]) -> None:
|
||||
"""Add the maintenance commands; the settings thunk lets one patch in a test redirect every path."""
|
||||
|
||||
@app.command("backup")
|
||||
def backup_command(destination: Path, force: bool = False) -> None:
|
||||
"""Copy the whole store into a folder while a running server keeps answering."""
|
||||
typer.echo(backup(settings(), destination, force).model_dump_json(indent=2))
|
||||
|
||||
@app.command("restore")
|
||||
def restore_command(source: Path, force: bool = False) -> None:
|
||||
"""Put a backed-up folder back; the store it replaces is kept beside the new one."""
|
||||
typer.echo(restore(settings(), source, force).model_dump_json(indent=2))
|
||||
|
||||
@app.command("doctor")
|
||||
def doctor_command() -> None:
|
||||
"""Report what is broken and what is only degraded; a broken store exits non-zero."""
|
||||
report = doctor(settings())
|
||||
typer.echo(report.model_dump_json(indent=2))
|
||||
if not report.ok:
|
||||
raise typer.Exit(code=1)
|
||||
@@ -0,0 +1,259 @@
|
||||
"""Move the store and ask whether it is well: backup, restore, doctor."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import shutil
|
||||
import sqlite3
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
from . import __version__
|
||||
from .cartridge_store import mounted_ids
|
||||
from .config import Settings
|
||||
from .db import Database
|
||||
from .embeddings import EmbeddingService
|
||||
from .migrations import SCHEMA_VERSION
|
||||
from .schemas import utc_now
|
||||
from .visual_index import VisualIndex
|
||||
|
||||
BACKUP_FILE = "ely_eye.sqlite"
|
||||
MANIFEST_FILE = "backup_manifest.json"
|
||||
_REQUIRED_TABLES = ("atoms", "sources", "cartridges")
|
||||
PICTURE_MODALITIES = ("image", "ui_screenshot")
|
||||
|
||||
|
||||
class BackupManifest(BaseModel):
|
||||
"""What a backup holds, so a folder of files can be checked before it is trusted."""
|
||||
|
||||
created_at: datetime
|
||||
ely_eye_version: str
|
||||
schema_version: int
|
||||
atoms: int
|
||||
sources: int
|
||||
cartridges: int
|
||||
sha256: str
|
||||
size_bytes: int
|
||||
path: str
|
||||
|
||||
|
||||
class DoctorReport(BaseModel):
|
||||
"""Broken versus degraded: problems stop the store, warnings only shrink what it can do."""
|
||||
|
||||
ok: bool
|
||||
store: str
|
||||
schema_version: int | None = None
|
||||
atoms: int = 0
|
||||
sources: int = 0
|
||||
cartridges: int = 0
|
||||
mounted: list[str] = []
|
||||
problems: list[str] = []
|
||||
warnings: list[str] = []
|
||||
|
||||
|
||||
def _atoms_word(count: int) -> str:
|
||||
return "atom" if count == 1 else "atoms"
|
||||
|
||||
|
||||
def _modality_counts(database: Database) -> dict[str, int]:
|
||||
with database.connect() as connection:
|
||||
rows = connection.execute("SELECT modality, COUNT(*) AS total FROM atoms GROUP BY modality").fetchall()
|
||||
return {str(row["modality"]): int(row["total"]) for row in rows}
|
||||
|
||||
|
||||
def _counts(connection: sqlite3.Connection) -> dict[str, int]:
|
||||
return {
|
||||
"atoms": int(connection.execute("SELECT COUNT(*) FROM atoms").fetchone()[0]),
|
||||
"sources": int(connection.execute("SELECT COUNT(*) FROM sources").fetchone()[0]),
|
||||
"cartridges": int(connection.execute("SELECT COUNT(*) FROM cartridges").fetchone()[0]),
|
||||
}
|
||||
|
||||
|
||||
def _schema_version(connection: sqlite3.Connection) -> int:
|
||||
return int(connection.execute("PRAGMA user_version").fetchone()[0])
|
||||
|
||||
|
||||
def _sha256(path: Path) -> str:
|
||||
digest = hashlib.sha256()
|
||||
with path.open("rb") as handle:
|
||||
for block in iter(lambda: handle.read(1024 * 1024), b""):
|
||||
digest.update(block)
|
||||
return digest.hexdigest()
|
||||
|
||||
|
||||
def backup(settings: Settings, destination: Path, force: bool = False) -> BackupManifest:
|
||||
"""Copy the live store with SQLite's own backup API, so a running server still yields a whole file."""
|
||||
origin = settings.db_path
|
||||
if not origin.exists():
|
||||
raise ValueError(f"no memory store at {origin}; nothing to back up")
|
||||
destination.mkdir(parents=True, exist_ok=True)
|
||||
target = destination / BACKUP_FILE
|
||||
if target.exists() and not force:
|
||||
raise ValueError(f"{target} already holds a backup; pass --force to replace it")
|
||||
with sqlite3.connect(origin) as source, sqlite3.connect(target) as copy:
|
||||
source.backup(copy)
|
||||
counted = _counts(copy)
|
||||
version = _schema_version(copy)
|
||||
manifest = BackupManifest(
|
||||
created_at=utc_now(),
|
||||
ely_eye_version=__version__,
|
||||
schema_version=version,
|
||||
atoms=counted["atoms"],
|
||||
sources=counted["sources"],
|
||||
cartridges=counted["cartridges"],
|
||||
sha256=_sha256(target),
|
||||
size_bytes=target.stat().st_size,
|
||||
path=str(target),
|
||||
)
|
||||
(destination / MANIFEST_FILE).write_text(manifest.model_dump_json(indent=2), encoding="utf-8")
|
||||
return manifest
|
||||
|
||||
|
||||
def _read_sidecar(source: Path) -> tuple[BackupManifest, int]:
|
||||
manifest_path = source / MANIFEST_FILE
|
||||
backup_file = source / BACKUP_FILE
|
||||
if not manifest_path.is_file() or not backup_file.is_file():
|
||||
raise ValueError(f"{source} is not a backup: it needs {BACKUP_FILE} and {MANIFEST_FILE} inside it")
|
||||
manifest = BackupManifest.model_validate_json(manifest_path.read_text(encoding="utf-8"))
|
||||
digest = _sha256(backup_file)
|
||||
if digest != manifest.sha256:
|
||||
raise ValueError(
|
||||
f"{backup_file} does not match its manifest: the file hashes {digest[:12]}, the manifest says {manifest.sha256[:12]}"
|
||||
)
|
||||
with sqlite3.connect(backup_file) as connection:
|
||||
if str(connection.execute("PRAGMA integrity_check").fetchone()[0]) != "ok":
|
||||
raise ValueError(f"{backup_file} fails integrity_check; do not restore it")
|
||||
tables = {
|
||||
str(row[0])
|
||||
for row in connection.execute("SELECT name FROM sqlite_master WHERE type = 'table'").fetchall()
|
||||
}
|
||||
missing = [table for table in _REQUIRED_TABLES if table not in tables]
|
||||
if missing:
|
||||
raise ValueError(f"{backup_file} is missing the tables this store needs: {', '.join(missing)}")
|
||||
version = _schema_version(connection)
|
||||
return manifest, version
|
||||
|
||||
|
||||
def restore(settings: Settings, source: Path, force: bool = False) -> BackupManifest:
|
||||
"""Put a backup back, and keep the store it replaces out of reach instead of under it."""
|
||||
manifest, version = _read_sidecar(source)
|
||||
if version > SCHEMA_VERSION:
|
||||
raise ValueError(
|
||||
f"{source / BACKUP_FILE} was written by a newer build: schema {version}, this build understands {SCHEMA_VERSION}"
|
||||
)
|
||||
live = settings.db_path
|
||||
if live.exists():
|
||||
if not force:
|
||||
raise ValueError(f"{live} already holds a store; pass --force to replace it")
|
||||
kept = live.with_suffix(".before-restore")
|
||||
try:
|
||||
shutil.move(str(live), kept)
|
||||
for suffix in ("-wal", "-shm"):
|
||||
sibling = Path(f"{live}{suffix}")
|
||||
if sibling.exists():
|
||||
shutil.move(str(sibling), Path(f"{kept}{suffix}"))
|
||||
except OSError as error:
|
||||
raise ValueError(f"cannot move {live} aside to {kept}: {error}") from error
|
||||
live.parent.mkdir(parents=True, exist_ok=True)
|
||||
try:
|
||||
shutil.copyfile(source / BACKUP_FILE, live)
|
||||
except OSError as error:
|
||||
raise ValueError(f"cannot write the store to {live}: {error}") from error
|
||||
Database(settings).init()
|
||||
return manifest.model_copy(update={"path": str(live)})
|
||||
|
||||
|
||||
def _check_cartridges(database: Database, mounted: list[str], problems: list[str]) -> None:
|
||||
roots = {str(row["cartridge_id"]): str(row["root_path"]) for row in database.list_cartridges()}
|
||||
for cartridge_id in mounted:
|
||||
root = roots.get(cartridge_id)
|
||||
if root is None:
|
||||
problems.append(f"{cartridge_id} is mounted but no cartridge row names it")
|
||||
elif not Path(root).exists():
|
||||
problems.append(f"{cartridge_id} is mounted but its files at {root} are gone")
|
||||
|
||||
|
||||
def _check_senses(settings: Settings, database: Database, visual: VisualIndex, warnings: list[str]) -> None:
|
||||
counts = _modality_counts(database)
|
||||
pictures = sum(counts.get(name, 0) for name in PICTURE_MODALITIES)
|
||||
if settings.clip_model_dir is not None and visual.model_dir() is None:
|
||||
warnings.append(f"the CLIP snapshot at {settings.clip_model_dir} is missing, so wordless pictures cannot be found")
|
||||
elif pictures and not visual.available():
|
||||
warnings.append(
|
||||
f"the store holds {pictures} picture {_atoms_word(pictures)} but no CLIP snapshot is set "
|
||||
"(ELY_EYE_CLIP_MODEL_DIR), so they are findable only by the text already on them"
|
||||
)
|
||||
elif visual.visual_atoms() and not visual.is_current():
|
||||
warnings.append("the visual index is behind the store; run `ely-eye index-visual`")
|
||||
audio = counts.get("audio", 0)
|
||||
if settings.asr_model_dir is None:
|
||||
if audio:
|
||||
warnings.append(
|
||||
f"the store holds {audio} audio {_atoms_word(audio)} but no speech snapshot is set "
|
||||
"(ELY_EYE_ASR_MODEL_DIR), so new audio will not be transcribed"
|
||||
)
|
||||
elif not settings.asr_model_dir.exists():
|
||||
warnings.append(f"the speech snapshot at {settings.asr_model_dir} is missing, so audio cannot be transcribed")
|
||||
if settings.vlm_model_dir is not None and not settings.vlm_model_dir.exists():
|
||||
warnings.append(f"the captioning model at {settings.vlm_model_dir} is missing, so images stay uncaptioned")
|
||||
|
||||
|
||||
def _check_indexes(settings: Settings, database: Database, warnings: list[str]) -> None:
|
||||
if not settings.dense_embeddings_enabled:
|
||||
return
|
||||
index = EmbeddingService(settings, database).load_index()
|
||||
stored = database.atom_count()
|
||||
if index is None:
|
||||
warnings.append("dense embeddings are on but no index exists; run `ely-eye build-dense-index`")
|
||||
elif len(index.atom_ids) != stored:
|
||||
warnings.append(
|
||||
f"the dense index covers {len(index.atom_ids)} atoms while the store holds {stored}; run `ely-eye build-dense-index`"
|
||||
)
|
||||
|
||||
|
||||
def doctor(settings: Settings) -> DoctorReport:
|
||||
"""Say what is broken and what is only degraded, and name the command that fixes each."""
|
||||
problems: list[str] = []
|
||||
warnings: list[str] = []
|
||||
live = settings.db_path
|
||||
if not live.exists():
|
||||
return DoctorReport(
|
||||
ok=False,
|
||||
store=str(live),
|
||||
schema_version=None,
|
||||
problems=[f"no memory store at {live}; run `ely-eye ingest` or `ely-eye remember` first"],
|
||||
)
|
||||
database: Database
|
||||
try:
|
||||
database = Database(settings)
|
||||
except SystemExit as error:
|
||||
return DoctorReport(
|
||||
ok=False,
|
||||
store=str(live),
|
||||
problems=[f"the store cannot be opened by this build: {error}"],
|
||||
)
|
||||
with database.connect() as connection:
|
||||
if str(connection.execute("PRAGMA integrity_check").fetchone()[0]) != "ok":
|
||||
problems.append(f"{live} fails integrity_check; restore a backup before writing to it")
|
||||
version = _schema_version(connection)
|
||||
if version != SCHEMA_VERSION:
|
||||
problems.append(f"the schema settled at version {version} while this build writes {SCHEMA_VERSION}")
|
||||
mounted = mounted_ids(database)
|
||||
_check_cartridges(database, mounted, problems)
|
||||
_check_senses(settings, database, VisualIndex(settings, database), warnings)
|
||||
_check_indexes(settings, database, warnings)
|
||||
if settings.ui_dir is not None and not settings.ui_dir.exists():
|
||||
problems.append(f"ui_dir {settings.ui_dir} is set but holds no built interface, so the API refuses to start")
|
||||
return DoctorReport(
|
||||
ok=not problems,
|
||||
store=str(live),
|
||||
schema_version=version,
|
||||
atoms=database.atom_count(),
|
||||
sources=database.source_count(),
|
||||
cartridges=database.cartridge_count(),
|
||||
mounted=mounted,
|
||||
problems=problems,
|
||||
warnings=warnings,
|
||||
)
|
||||
Reference in new issue
Block a user