Files
lda-wf/src/wf_scheduling/history.py
T

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,
)