forked from Rockachopa/Timmy-time-dashboard
179 lines
5.8 KiB
Python
179 lines
5.8 KiB
Python
"""World-state backup strategy — timestamped files with rotation.
|
|
|
|
``WorldStateBackup`` writes each backup as a standalone JSON file and
|
|
maintains a ``MANIFEST.jsonl`` index for fast listing. Old backups
|
|
beyond the retention limit are rotated out automatically.
|
|
|
|
Usage::
|
|
|
|
backup = WorldStateBackup("var/backups/", max_backups=10)
|
|
record = backup.create(adapter, notes="pre-phase-8 checkpoint")
|
|
backup.restore(adapter, record.backup_id)
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import logging
|
|
from dataclasses import asdict, dataclass
|
|
from datetime import UTC, datetime
|
|
from pathlib import Path
|
|
|
|
from infrastructure.world.adapters.mock import MockWorldAdapter
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
@dataclass
|
|
class BackupRecord:
|
|
"""Metadata entry written to the backup manifest."""
|
|
|
|
backup_id: str
|
|
timestamp: str
|
|
location: str
|
|
entity_count: int
|
|
event_count: int
|
|
size_bytes: int = 0
|
|
notes: str = ""
|
|
|
|
|
|
class WorldStateBackup:
|
|
"""Timestamped, rotating world-state backups.
|
|
|
|
Each backup is a JSON file named ``backup_<timestamp>.json`` inside
|
|
*backup_dir*. A ``MANIFEST.jsonl`` index tracks all backups for fast
|
|
listing and rotation.
|
|
|
|
Parameters
|
|
----------
|
|
backup_dir:
|
|
Directory where backup files and the manifest are stored.
|
|
max_backups:
|
|
Maximum number of backup files to retain.
|
|
"""
|
|
|
|
MANIFEST_NAME = "MANIFEST.jsonl"
|
|
|
|
def __init__(
|
|
self,
|
|
backup_dir: Path | str,
|
|
*,
|
|
max_backups: int = 10,
|
|
) -> None:
|
|
self._dir = Path(backup_dir)
|
|
self._dir.mkdir(parents=True, exist_ok=True)
|
|
self._max = max_backups
|
|
|
|
# -- create ------------------------------------------------------------
|
|
|
|
def create(
|
|
self,
|
|
adapter: MockWorldAdapter,
|
|
*,
|
|
notes: str = "",
|
|
) -> BackupRecord:
|
|
"""Snapshot *adapter* and write a new backup file.
|
|
|
|
Returns the ``BackupRecord`` describing the backup.
|
|
"""
|
|
perception = adapter.observe()
|
|
ts = datetime.now(UTC).strftime("%Y%m%dT%H%M%S%f")
|
|
backup_id = f"backup_{ts}"
|
|
payload = {
|
|
"backup_id": backup_id,
|
|
"timestamp": datetime.now(UTC).isoformat(),
|
|
"location": perception.location,
|
|
"entities": list(perception.entities),
|
|
"events": list(perception.events),
|
|
"raw": dict(perception.raw),
|
|
"notes": notes,
|
|
}
|
|
backup_path = self._dir / f"{backup_id}.json"
|
|
backup_path.write_text(json.dumps(payload, indent=2))
|
|
size = backup_path.stat().st_size
|
|
|
|
record = BackupRecord(
|
|
backup_id=backup_id,
|
|
timestamp=payload["timestamp"],
|
|
location=perception.location,
|
|
entity_count=len(perception.entities),
|
|
event_count=len(perception.events),
|
|
size_bytes=size,
|
|
notes=notes,
|
|
)
|
|
self._update_manifest(record)
|
|
self._rotate()
|
|
logger.info(
|
|
"WorldStateBackup: created %s (%d bytes)", backup_id, size
|
|
)
|
|
return record
|
|
|
|
# -- restore -----------------------------------------------------------
|
|
|
|
def restore(self, adapter: MockWorldAdapter, backup_id: str) -> bool:
|
|
"""Restore *adapter* state from backup *backup_id*.
|
|
|
|
Returns ``True`` on success, ``False`` if the backup file is missing.
|
|
"""
|
|
backup_path = self._dir / f"{backup_id}.json"
|
|
if not backup_path.exists():
|
|
logger.warning("WorldStateBackup: backup %s not found", backup_id)
|
|
return False
|
|
|
|
payload = json.loads(backup_path.read_text())
|
|
adapter._location = payload.get("location", "")
|
|
adapter._entities = list(payload.get("entities", []))
|
|
adapter._events = list(payload.get("events", []))
|
|
logger.info("WorldStateBackup: restored from %s", backup_id)
|
|
return True
|
|
|
|
# -- listing -----------------------------------------------------------
|
|
|
|
def list_backups(self) -> list[BackupRecord]:
|
|
"""Return all backup records, most recent first."""
|
|
manifest = self._dir / self.MANIFEST_NAME
|
|
if not manifest.exists():
|
|
return []
|
|
records: list[BackupRecord] = []
|
|
for line in manifest.read_text().strip().splitlines():
|
|
try:
|
|
data = json.loads(line)
|
|
records.append(BackupRecord(**data))
|
|
except (json.JSONDecodeError, TypeError):
|
|
continue
|
|
return list(reversed(records))
|
|
|
|
def latest(self) -> BackupRecord | None:
|
|
"""Return the most recent backup record, or ``None``."""
|
|
backups = self.list_backups()
|
|
return backups[0] if backups else None
|
|
|
|
# -- internal ----------------------------------------------------------
|
|
|
|
def _update_manifest(self, record: BackupRecord) -> None:
|
|
manifest = self._dir / self.MANIFEST_NAME
|
|
with manifest.open("a") as f:
|
|
f.write(json.dumps(asdict(record)) + "\n")
|
|
|
|
def _rotate(self) -> None:
|
|
"""Remove oldest backups when over the retention limit."""
|
|
backups = self.list_backups() # most recent first
|
|
if len(backups) <= self._max:
|
|
return
|
|
to_remove = backups[self._max :]
|
|
for rec in to_remove:
|
|
path = self._dir / f"{rec.backup_id}.json"
|
|
try:
|
|
path.unlink(missing_ok=True)
|
|
logger.debug("WorldStateBackup: rotated out %s", rec.backup_id)
|
|
except OSError as exc:
|
|
logger.warning(
|
|
"WorldStateBackup: could not remove %s: %s", path, exc
|
|
)
|
|
# Rewrite manifest with only the retained backups
|
|
keep = backups[: self._max]
|
|
manifest = self._dir / self.MANIFEST_NAME
|
|
manifest.write_text(
|
|
"\n".join(json.dumps(asdict(r)) for r in reversed(keep)) + "\n"
|
|
)
|