sched: address R3 blockers on pagination, intervals, and fail-closed polling
This commit is contained in:
+80
-24
@@ -5,10 +5,11 @@ 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 plus the
|
||||
schedule revision it belongs to.
|
||||
- ``consumed.json`` — ``{"consumed_through": <utc iso>}`` watermark.
|
||||
- ``history.json`` — occurrence-history list for cursor pagination over
|
||||
``(resolved_at, occurrence_id)``.
|
||||
``(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.
|
||||
@@ -131,12 +132,33 @@ class FileScheduleStore:
|
||||
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."""
|
||||
@@ -146,6 +168,16 @@ class FileScheduleStore:
|
||||
entries.append(record.model_dump(mode="json"))
|
||||
self._write_json(path, entries)
|
||||
|
||||
@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,
|
||||
@@ -153,38 +185,62 @@ class FileScheduleStore:
|
||||
cursor: str | None = None,
|
||||
limit: int = 50,
|
||||
) -> dict[str, object]:
|
||||
"""Return one history page ordered by ``(resolved_at, occurrence_id)``."""
|
||||
"""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=lambda item: (
|
||||
item.resolved_at is None,
|
||||
item.resolved_at or datetime.max.replace(tzinfo=UTC),
|
||||
item.occurrence_id,
|
||||
)
|
||||
)
|
||||
start = 0
|
||||
if cursor is not None:
|
||||
try:
|
||||
start = int(cursor)
|
||||
except ValueError as exc:
|
||||
raise ValueError(
|
||||
"cursor must be a non-negative integer offset"
|
||||
) from exc
|
||||
if start < 0:
|
||||
raise ValueError("cursor must be a non-negative integer offset")
|
||||
records.sort(key=self._sort_key)
|
||||
total = len(records)
|
||||
page = records[start : start + limit]
|
||||
end = start + limit
|
||||
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": str(end) if end < total else None,
|
||||
"next_cursor": next_cursor,
|
||||
"limit": limit,
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user