303 lines
12 KiB
Python
303 lines
12 KiB
Python
"""File-backed schedule store for local development and tests.
|
|
|
|
Layout per schedule ``<root>/schedules/<id>/``:
|
|
|
|
- ``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": <utc iso>}`` 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 ``"<resolved_at_iso>|<occurrence_id>"`` (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 '<iso>|<id>' 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)
|