158 lines
5.6 KiB
Python
158 lines
5.6 KiB
Python
"""Operational diagnostics: what the application is using, and what is about to
|
|
run out (US07-05, concept §17).
|
|
|
|
Every mutating stage in this application writes something before it is safe to
|
|
continue — a journal, an EXIF rewrite, an archive copy, a backup. All of them fail
|
|
badly on a full disk, so the sizes that grow (database, write-ahead log, thumbnail
|
|
cache, uploader reports, backups, logs) are reported separately rather than as one
|
|
opaque total, and each is compared against the free space actually left.
|
|
|
|
This is a read-only report. It never deletes, rotates, or prunes anything: what to
|
|
do about a warning is an operator's decision, and the tools for it are the
|
|
thumbnail cache quota, the backup retention helper, and log rotation outside the
|
|
application.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import shutil
|
|
from pathlib import Path
|
|
|
|
from photo_pipeline.config import Config
|
|
from photo_pipeline.services import app_lock
|
|
|
|
# Below this much free space, mutating stages should stop rather than risk a
|
|
# half-written journal, EXIF container, or archive copy.
|
|
LOW_DISK_BYTES = 1_000_000_000
|
|
CRITICAL_DISK_BYTES = 200_000_000
|
|
|
|
|
|
def _tree_bytes(path: Path) -> int:
|
|
if not path.exists():
|
|
return 0
|
|
if path.is_file():
|
|
return path.stat().st_size
|
|
total = 0
|
|
for child in path.rglob("*"):
|
|
try:
|
|
if child.is_file() and not child.is_symlink():
|
|
total += child.stat().st_size
|
|
except OSError:
|
|
continue # vanished mid-walk; it is not using space any more
|
|
return total
|
|
|
|
|
|
def _component(name: str, path: Path, *, quota: int | None = None) -> dict:
|
|
used = _tree_bytes(path)
|
|
entry = {"name": name, "path": str(path), "bytes": used, "exists": path.exists()}
|
|
if quota is not None:
|
|
entry["quota_bytes"] = quota
|
|
entry["over_quota"] = used > quota
|
|
return entry
|
|
|
|
|
|
def disk(path: Path) -> dict:
|
|
"""Free/total for the filesystem holding ``path`` — the nearest existing parent,
|
|
so a data directory that does not exist yet still reports its future disk."""
|
|
probe = path
|
|
while not probe.exists() and probe != probe.parent:
|
|
probe = probe.parent
|
|
try:
|
|
usage = shutil.disk_usage(probe)
|
|
except OSError as error:
|
|
return {"path": str(probe), "error": str(error)}
|
|
return {
|
|
"path": str(probe),
|
|
"total_bytes": usage.total,
|
|
"free_bytes": usage.free,
|
|
"used_bytes": usage.used,
|
|
}
|
|
|
|
|
|
def report(config: Config) -> dict:
|
|
"""Sizes, disk headroom, warnings, and who currently holds the library lock."""
|
|
database = config.database_path
|
|
components = [
|
|
_component("database", database),
|
|
_component("write_ahead_log", Path(f"{database}-wal")),
|
|
_component("shared_memory", Path(f"{database}-shm")),
|
|
_component(
|
|
"thumbnail_cache",
|
|
config.thumbnail_cache_dir,
|
|
quota=config.thumbnail_cache_quota_bytes,
|
|
),
|
|
_component("upload_reports", config.data_dir / "uploads"),
|
|
_component("backups", config.data_dir / "backups"),
|
|
_component("logs", config.data_dir / "logs"),
|
|
]
|
|
space = disk(config.data_dir)
|
|
free = space.get("free_bytes")
|
|
|
|
warnings: list[dict] = []
|
|
if free is not None and free < CRITICAL_DISK_BYTES:
|
|
warnings.append(
|
|
{
|
|
"code": "disk_critical",
|
|
"message": (
|
|
f"only {free} bytes free on {space['path']}; stop mutating stages "
|
|
"and free space before renaming, writing EXIF, or archiving"
|
|
),
|
|
}
|
|
)
|
|
elif free is not None and free < LOW_DISK_BYTES:
|
|
warnings.append(
|
|
{
|
|
"code": "disk_low",
|
|
"message": f"{free} bytes free on {space['path']}; prune backups or the cache",
|
|
}
|
|
)
|
|
for component in components:
|
|
if component.get("over_quota"):
|
|
warnings.append(
|
|
{
|
|
"code": "cache_over_quota",
|
|
"message": (
|
|
f"{component['name']} uses {component['bytes']} bytes, over its "
|
|
f"{component['quota_bytes']} byte quota"
|
|
),
|
|
}
|
|
)
|
|
# A write-ahead log that outgrows its database means checkpoints are starving —
|
|
# an operational warning, not something to ignore (concept §16).
|
|
wal = next(c for c in components if c["name"] == "write_ahead_log")
|
|
db = next(c for c in components if c["name"] == "database")
|
|
if wal["bytes"] > max(db["bytes"], 1) :
|
|
warnings.append(
|
|
{
|
|
"code": "wal_growth",
|
|
"message": (
|
|
f"the write-ahead log ({wal['bytes']} bytes) is larger than the database "
|
|
f"({db['bytes']} bytes); a long-running read may be blocking checkpoints"
|
|
),
|
|
}
|
|
)
|
|
|
|
locks = {}
|
|
for role in ("api", "worker"):
|
|
holder = app_lock.LibraryLock(config, role).holder()
|
|
locks[role] = holder.as_dict() if holder else None
|
|
legacy = app_lock.legacy_activity(config)
|
|
if legacy["active"]:
|
|
warnings.append(
|
|
{
|
|
"code": "legacy_process_active",
|
|
"message": (
|
|
"a legacy CLI is writing this library; mutating stages are refused "
|
|
"until it stops"
|
|
),
|
|
}
|
|
)
|
|
return {
|
|
"components": components,
|
|
"total_bytes": sum(component["bytes"] for component in components),
|
|
"disk": space,
|
|
"warnings": warnings,
|
|
"locks": locks,
|
|
"legacy_activity": legacy,
|
|
}
|