"""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, }