"""File-backed schedule store for local development and tests. Layout per schedule ``/schedules//``: - ``schedule.json`` — the schedule definition (deleted schedules stay with ``deleted=True``; ids are never reused to annex old history). - ``candidate.json`` — at most one pending latest candidate. - ``consumed.json`` — ``{"consumed_through": }`` watermark. - ``history.json`` — occurrence-history list for cursor pagination over ``(resolved_at, occurrence_id)`` (keyset; integer offsets accepted as a legacy fallback). - ``_poll_cursor.json`` — round-robin fairness cursor (persisted). Per-file writes are atomic (tmp + rename); the lock is process-local only. Scheduler ownership across processes arrives in T11. """ from __future__ import annotations import json from datetime import datetime, timezone from pathlib import Path from threading import RLock from .models import OccurrenceRecord, PendingCandidate, Schedule, ensure_schedule_id UTC = timezone.utc class ScheduleExistsError(ValueError): """A schedule id is already taken (deleted ids are never reused).""" class ScheduleNotFoundError(KeyError): """No schedule exists for the requested id.""" class StaleScheduleRevisionError(ValueError): """A schedule edit carried an outdated revision.""" class FileScheduleStore: """JSON file-backed schedule definitions, candidates, and history.""" def __init__(self, root: Path) -> None: self.root = root self._lock = RLock() self.schedules_dir.mkdir(parents=True, exist_ok=True) @property def schedules_dir(self) -> Path: return self.root / "schedules" # -- schedules ------------------------------------------------------ def create_schedule(self, schedule: Schedule) -> Schedule: """Persist a new schedule; deleted ids are never reusable.""" with self._lock: path = self._schedule_path(schedule.id) if path.exists(): raise ScheduleExistsError( f"schedule id already exists: {schedule.id!r}" ) self._write_json(path, schedule.model_dump(mode="json")) return schedule def get_schedule(self, schedule_id: str) -> Schedule: path = self._schedule_path(schedule_id) if not path.exists(): raise ScheduleNotFoundError(f"unknown schedule {schedule_id!r}") return Schedule.model_validate_json(path.read_text(encoding="utf-8")) def list_schedules(self, *, include_deleted: bool = False) -> list[Schedule]: schedules = [ Schedule.model_validate_json(path.read_text(encoding="utf-8")) for path in sorted(self.schedules_dir.glob("*/schedule.json")) ] if not include_deleted: schedules = [item for item in schedules if not item.deleted] return sorted(schedules, key=lambda item: item.id) def update_schedule( self, schedule: Schedule, *, expected_revision: int ) -> Schedule: """Replace a schedule after a revision check (stale edits rejected).""" with self._lock: current = self.get_schedule(schedule.id) if current.revision != expected_revision: raise StaleScheduleRevisionError( f"stale schedule revision for {schedule.id!r}: " f"expected {expected_revision}, found {current.revision}" ) self._write_json( self._schedule_path(schedule.id), schedule.model_dump(mode="json") ) return schedule def save_schedule(self, schedule: Schedule) -> None: """Persist a schedule without a revision check (ownership internals).""" with self._lock: self._write_json( self._schedule_path(schedule.id), schedule.model_dump(mode="json") ) # -- candidates (at most one) ---------------------------------------- def get_candidate(self, schedule_id: str) -> PendingCandidate | None: path = self._candidate_path(schedule_id) if not path.exists(): return None return PendingCandidate.model_validate_json(path.read_text(encoding="utf-8")) def save_candidate( self, candidate: PendingCandidate | None, *, schedule_id: str ) -> None: """Persist or clear the single pending candidate for a schedule.""" with self._lock: path = self._candidate_path(schedule_id) if candidate is None: if path.exists(): path.unlink() return self._write_json(path, candidate.model_dump(mode="json")) # -- consumed watermark ---------------------------------------------- def get_consumed(self, schedule_id: str) -> datetime | None: path = self._consumed_path(schedule_id) if not path.exists(): return None raw = json.loads(path.read_text(encoding="utf-8")) value = raw.get("consumed_through") if value is None: return None return datetime.fromisoformat(value) def save_consumed(self, schedule_id: str, consumed_through: datetime) -> None: if consumed_through.tzinfo is None or consumed_through.utcoffset() is None: raise ValueError("consumed_through requires an aware datetime") with self._lock: self._write_json( self._consumed_path(schedule_id), {"consumed_through": consumed_through.astimezone(UTC).isoformat()}, ) def get_poll_cursor(self) -> int: """Return the persisted round-robin fairness cursor.""" path = self.schedules_dir / "_poll_cursor.json" if not path.exists(): return 0 try: raw = json.loads(path.read_text(encoding="utf-8")) value = raw.get("cursor", 0) if isinstance(raw, dict) else 0 return value if isinstance(value, int) and value >= 0 else 0 except ValueError, AttributeError: return 0 def save_poll_cursor(self, cursor: int) -> None: """Persist the round-robin fairness cursor.""" with self._lock: self._write_json( self.schedules_dir / "_poll_cursor.json", {"cursor": cursor} ) # -- history with cursor pagination ----------------------------------- def append_history(self, record: OccurrenceRecord) -> None: """Append one occurrence-history entry for a schedule.""" with self._lock: path = self._history_path(record.schedule_id) entries = self._read_history_locked(record.schedule_id) entries.append(record.model_dump(mode="json")) self._write_json(path, entries) def has_history_entry( self, schedule_id: str, *, run_id: str, kind: str, checkpoint_id: str | None = None, ) -> bool: """Whether this exact stopped result already has a history entry. Idempotency identity is ``(run_id, kind, checkpoint_id)``: repeats of one recovery pass dedup, while a resumed run that stops again (new checkpoint id) records a new entry. """ for item in self._read_history_locked(schedule_id): if ( item.get("run_id") == run_id and item.get("kind") == kind and item.get("checkpoint_id") == checkpoint_id ): return True return False @staticmethod def _sort_key( item: OccurrenceRecord, ) -> tuple[bool, datetime, str]: return ( item.resolved_at is None, item.resolved_at or datetime.max.replace(tzinfo=UTC), item.occurrence_id, ) def list_occurrences( self, schedule_id: str, *, cursor: str | None = None, limit: int = 50, ) -> dict[str, object]: """Return one history page ordered by ``(resolved_at, occurrence_id)``. Keyset cursors are ``"|"`` (empty iso for rows without a resolved instant); plain integer offsets remain accepted as a legacy fallback. """ if limit < 1 or limit > 100: raise ValueError("limit must be between 1 and 100") records = [ OccurrenceRecord.model_validate(item) for item in self._read_history_locked(schedule_id) ] records.sort(key=self._sort_key) total = len(records) start_index = 0 if cursor is not None: if "|" in cursor: iso_part, _, oid_part = cursor.partition("|") key = ( True, datetime.max.replace(tzinfo=UTC), oid_part, ) if iso_part: try: key = (False, datetime.fromisoformat(iso_part), oid_part) except ValueError as exc: raise ValueError("invalid keyset cursor") from exc start_index = 0 for index, item in enumerate(records): if self._sort_key(item) > key: start_index = index break else: start_index = total else: try: start_index = int(cursor) except ValueError as exc: raise ValueError( "cursor must be a keyset '|' or integer offset" ) from exc if start_index < 0: raise ValueError("cursor must be a non-negative integer offset") page = records[start_index : start_index + limit] if start_index + limit < total and page: last = page[-1] iso = "" if last.resolved_at is None else last.resolved_at.isoformat() next_cursor: str | None = f"{iso}|{last.occurrence_id}" else: next_cursor = None return { "occurrences": [item.model_dump(mode="json") for item in page], "total": total, "cursor": cursor, "next_cursor": next_cursor, "limit": limit, } def _read_history_locked(self, schedule_id: str) -> list[dict]: path = self._history_path(schedule_id) if not path.exists(): return [] raw = json.loads(path.read_text(encoding="utf-8")) return raw if isinstance(raw, list) else [] # -- paths ------------------------------------------------------------ def _schedule_dir(self, schedule_id: str) -> Path: safe_id = ensure_schedule_id(schedule_id) root = self.schedules_dir.resolve() path = (self.schedules_dir / safe_id).resolve() if path.parent != root: raise ValueError(f"schedule id escapes schedule store: {schedule_id!r}") return path def _schedule_path(self, schedule_id: str) -> Path: return self._schedule_dir(schedule_id) / "schedule.json" def _candidate_path(self, schedule_id: str) -> Path: return self._schedule_dir(schedule_id) / "candidate.json" def _consumed_path(self, schedule_id: str) -> Path: return self._schedule_dir(schedule_id) / "consumed.json" def _history_path(self, schedule_id: str) -> Path: return self._schedule_dir(schedule_id) / "history.json" def _write_json(self, path: Path, payload: object) -> None: path.parent.mkdir(parents=True, exist_ok=True) temp_path = path.with_name(path.name + ".tmp") temp_path.write_text(json.dumps(payload, indent=2), encoding="utf-8") temp_path.replace(path)