Files
ely-eye/backend/src/ely_eye/maintenance.py
T
ZacharyZhang-NY 6df713d135 Keep the self-report shapes in a module the line budget can hold
9f91ff7 pushed schemas.py from 498 to 534 lines and broke the 500-line rule
the repo enforces on every file. The status family (CacheLayerStatus,
MemoryStatus, RuntimeStatus, SenseStatus, SystemStatus) moves to
status_schemas.py, where it is self-contained: it needs only
TrainingAdapterKind from schemas.py, and schemas.py no longer looks back.

The same commit added three multi-line docstrings (browse.picture,
senses.sense_facts, SenseStatus). Each is now one line; the absent-vs-missing
distinction they carried survives in SenseStatus's own line, and the producer
senses.py still states why the two failures need different sentences.

Measured, not asserted: 263 tests pass; ruff clean on the eight touched
files; repo-wide mypy still 24 errors in 6 files (runtime.py's two are in
that baseline and reproduce on the HEAD copy of the file); pyright reports
the same two pre-existing runtime.py tuple findings on both trees;
multi-line docstrings repo-wide are back to 114, the parent's count.
2026-10-08 06:44:36 -04:00

254 lines
10 KiB
Python

"""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 .migrations import SCHEMA_VERSION
from .schemas import utc_now
from .senses import sense_facts
from .status_schemas import SenseStatus
BACKUP_FILE = "ely_eye.sqlite"
MANIFEST_FILE = "backup_manifest.json"
_REQUIRED_TABLES = ("atoms", "sources", "cartridges")
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 _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(facts: SenseStatus, warnings: list[str]) -> None:
"""Render the missing senses as sentences; the counting lives in senses.py, not here."""
encodable = facts.picture_atoms - facts.picture_bytes_missing
if facts.clip_path is not None and facts.visual == "absent":
warnings.append(f"the CLIP snapshot at {facts.clip_path} is missing, so wordless pictures cannot be found")
elif encodable and facts.visual == "absent":
warnings.append(
f"the store holds {encodable} picture {_atoms_word(encodable)} but no CLIP snapshot is set "
"(ELY_EYE_CLIP_MODEL_DIR), so they are findable only by the text already on them"
)
elif facts.visual == "stale":
warnings.append("the visual index is behind the store; run `ely-eye index-visual`")
if facts.picture_bytes_missing:
missing = facts.picture_bytes_missing
warnings.append(
f"{missing} picture {_atoms_word(missing)} with no bytes behind them; re-ingest the source they came from"
)
if facts.asr_path is not None and facts.speech == "missing":
warnings.append(f"the speech snapshot at {facts.asr_path} is missing, so audio cannot be transcribed")
elif facts.audio_atoms and facts.speech == "absent":
warnings.append(
f"the store holds {facts.audio_atoms} audio {_atoms_word(facts.audio_atoms)} but no speech snapshot is set "
"(ELY_EYE_ASR_MODEL_DIR), so new audio will not be transcribed"
)
if facts.vlm_path is not None and facts.captioning == "missing":
warnings.append(f"the captioning model at {facts.vlm_path} is missing, so images stay uncaptioned")
def _check_indexes(facts: SenseStatus, warnings: list[str]) -> None:
if facts.dense == "missing":
warnings.append("dense embeddings are on but no index exists; run `ely-eye build-dense-index`")
elif facts.dense == "behind":
warnings.append(
f"the dense index covers {facts.dense_covered} atoms while the store holds {facts.store_atoms}; "
"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)
facts = sense_facts(settings, database)
_check_senses(facts, warnings)
_check_indexes(facts, 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,
)