116 lines
3.9 KiB
Python
116 lines
3.9 KiB
Python
"""Typed occurrence-history interface with idempotent reconciliation (R4/F4).
|
|
|
|
Both the poll loop and startup recovery record through :class:`HistoryRecorder`,
|
|
so completed/failed/interrupted entries share one shape and one idempotency
|
|
identity: ``(run_id, kind, checkpoint_id)``. A resumed run that interrupts
|
|
again produces a new checkpoint id and therefore a new entry; repeating
|
|
recovery never duplicates an entry. The file-backed recorder derives entry
|
|
identity the same way the scheduler always has (occurrence instants hash to
|
|
``occurrence_id``; interval summaries use their span key).
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from dataclasses import dataclass
|
|
from datetime import datetime, timezone
|
|
from typing import Any, Protocol
|
|
|
|
from wf_scheduling.models import OccurrenceKind, OccurrenceRecord
|
|
from wf_scheduling.occurrences import occurrence_id
|
|
|
|
UTC = timezone.utc
|
|
|
|
TERMINAL_KINDS: tuple[str, str, str] = ("completed", "interrupted", "failed")
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class HistoryEntry:
|
|
"""One occurrence-history entry with a stable idempotency identity."""
|
|
|
|
schedule_id: str
|
|
kind: OccurrenceKind
|
|
resolved_at: datetime | None = None
|
|
run_id: str | None = None
|
|
revision: int | None = None
|
|
reason: str = ""
|
|
admitted_at: datetime | None = None
|
|
started_at: datetime | None = None
|
|
checkpoint_id: str | None = None
|
|
interval_start: datetime | None = None
|
|
interval_end: datetime | None = None
|
|
interval_count: int = 0
|
|
created_at: datetime | None = None
|
|
|
|
|
|
class HistoryRecorder(Protocol):
|
|
"""Occurrence-history sink shared by polling and recovery."""
|
|
|
|
def record(self, entry: HistoryEntry) -> None:
|
|
"""Append one entry (callers dedup stopped results first)."""
|
|
...
|
|
|
|
def has_terminal(
|
|
self,
|
|
schedule_id: str,
|
|
run_id: str,
|
|
kind: str,
|
|
checkpoint_id: str | None = None,
|
|
) -> bool:
|
|
"""Whether this exact stopped result was already reconciled."""
|
|
...
|
|
|
|
|
|
def entry_occurrence_id(entry: HistoryEntry, created: datetime) -> str:
|
|
"""Derive the history occurrence id with the scheduler's standing rules."""
|
|
if entry.resolved_at is not None:
|
|
return occurrence_id(entry.schedule_id, entry.resolved_at)
|
|
if entry.interval_start is not None and entry.interval_end is not None:
|
|
start = entry.interval_start.isoformat()
|
|
end = entry.interval_end.isoformat()
|
|
return f"{entry.schedule_id}|summary|{start}|{end}"
|
|
return f"{entry.schedule_id}|summary|{created.isoformat()}"
|
|
|
|
|
|
class FileScheduleHistoryRecorder:
|
|
"""History recorder backed by a file schedule store."""
|
|
|
|
def __init__(self, schedule_store: Any) -> None:
|
|
self.schedule_store = schedule_store
|
|
|
|
def record(self, entry: HistoryEntry) -> None:
|
|
created = (
|
|
entry.created_at if entry.created_at is not None else datetime.now(UTC)
|
|
)
|
|
self.schedule_store.append_history(
|
|
OccurrenceRecord(
|
|
schedule_id=entry.schedule_id,
|
|
occurrence_id=entry_occurrence_id(entry, created),
|
|
kind=entry.kind,
|
|
resolved_at=entry.resolved_at,
|
|
run_id=entry.run_id,
|
|
revision=entry.revision,
|
|
reason=entry.reason,
|
|
admitted_at=entry.admitted_at,
|
|
started_at=entry.started_at,
|
|
checkpoint_id=entry.checkpoint_id,
|
|
interval_start=entry.interval_start,
|
|
interval_end=entry.interval_end,
|
|
interval_count=entry.interval_count,
|
|
created_at=created,
|
|
)
|
|
)
|
|
|
|
def has_terminal(
|
|
self,
|
|
schedule_id: str,
|
|
run_id: str,
|
|
kind: str,
|
|
checkpoint_id: str | None = None,
|
|
) -> bool:
|
|
return self.schedule_store.has_history_entry(
|
|
schedule_id,
|
|
run_id=run_id,
|
|
kind=kind,
|
|
checkpoint_id=checkpoint_id,
|
|
)
|