sched: entry-level history cursor so tied occurrence entries paginate exactly once (B4)
This commit is contained in:
@@ -222,3 +222,92 @@ def test_deployment_revision_increments_on_save(tmp_path: Path) -> None:
|
||||
)
|
||||
)
|
||||
assert artifacts.get_deployment("dep-1").revision == 2
|
||||
|
||||
|
||||
def _tied_entry(
|
||||
kind: str,
|
||||
checkpoint_id: str | None,
|
||||
created_minute: int,
|
||||
) -> OccurrenceRecord:
|
||||
"""One history entry for the shared 12:00 occurrence of schedule a."""
|
||||
return OccurrenceRecord(
|
||||
schedule_id="a",
|
||||
occurrence_id="a|2026-09-08T12:00:00+00:00",
|
||||
kind=kind, # type: ignore[arg-type]
|
||||
resolved_at=datetime(2026, 9, 8, 12, 0, tzinfo=UTC),
|
||||
run_id="run-1",
|
||||
revision=1,
|
||||
checkpoint_id=checkpoint_id,
|
||||
created_at=datetime(2026, 9, 8, 12, created_minute, tzinfo=UTC),
|
||||
)
|
||||
|
||||
|
||||
def _traverse(store: FileScheduleStore, limit: int) -> list[dict]:
|
||||
"""Walk every page to the end, returning all rows in visit order."""
|
||||
rows: list[dict] = []
|
||||
cursor: str | None = None
|
||||
while True:
|
||||
page = store.list_occurrences("a", cursor=cursor, limit=limit)
|
||||
rows.extend(page["occurrences"]) # type: ignore[arg-type]
|
||||
cursor = page["next_cursor"] # type: ignore[assignment]
|
||||
if cursor is None:
|
||||
assert page["total"] == len(rows)
|
||||
return rows
|
||||
|
||||
|
||||
def test_history_pagination_visits_every_tied_entry_once(tmp_path: Path) -> None:
|
||||
"""Admission + repeated interruptions + completion share one cursor tie.
|
||||
|
||||
Every stored entry must appear exactly once across small-page
|
||||
traversal (B4): the cursor carries the entry ordinal, not just the
|
||||
shared ``(resolved_at, occurrence_id)`` tie.
|
||||
"""
|
||||
store = FileScheduleStore(tmp_path)
|
||||
store.create_schedule(_schedule("a"))
|
||||
store.append_history(_tied_entry("admitted", None, 0))
|
||||
store.append_history(_tied_entry("interrupted", "run-1.000001", 5))
|
||||
store.append_history(_tied_entry("interrupted", "run-1.000002", 9))
|
||||
store.append_history(_tied_entry("completed", "run-1.000003", 14))
|
||||
|
||||
rows = _traverse(store, limit=1)
|
||||
assert [(row["kind"], row["checkpoint_id"]) for row in rows] == [
|
||||
("admitted", None),
|
||||
("interrupted", "run-1.000001"),
|
||||
("interrupted", "run-1.000002"),
|
||||
("completed", "run-1.000003"),
|
||||
]
|
||||
first = store.list_occurrences("a", limit=1)
|
||||
assert first["next_cursor"] is not None
|
||||
assert len(str(first["next_cursor"]).split("|")) >= 3
|
||||
|
||||
|
||||
def test_history_pagination_legacy_rows_keep_file_order(tmp_path: Path) -> None:
|
||||
"""Rows persisted before the entry ordinal order by file position.
|
||||
|
||||
Real persisted data without ``seq`` must still traverse exactly once;
|
||||
pre-ordinal two-part cursors stay accepted and resume after the tied
|
||||
group exactly as they did before (no duplicates, no crash).
|
||||
"""
|
||||
import json
|
||||
|
||||
store = FileScheduleStore(tmp_path)
|
||||
store.create_schedule(_schedule("a"))
|
||||
history_path = tmp_path / "schedules" / "a" / "history.json"
|
||||
history_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
legacy = [
|
||||
_tied_entry("admitted", None, 0).model_dump(mode="json"),
|
||||
_tied_entry("interrupted", "run-1.000001", 5).model_dump(mode="json"),
|
||||
]
|
||||
for item in legacy:
|
||||
del item["seq"]
|
||||
history_path.write_text(json.dumps(legacy), encoding="utf-8")
|
||||
|
||||
rows = _traverse(store, limit=1)
|
||||
assert [row["kind"] for row in rows] == ["admitted", "interrupted"]
|
||||
|
||||
legacy_cursor = "2026-09-08T12:00:00+00:00|a|2026-09-08T12:00:00+00:00"
|
||||
resumed = store.list_occurrences("a", cursor=legacy_cursor, limit=1)
|
||||
assert resumed["occurrences"] == []
|
||||
assert resumed["next_cursor"] is None
|
||||
with pytest.raises(ValueError):
|
||||
store.list_occurrences("a", cursor="a|b|c|d", limit=1)
|
||||
|
||||
@@ -767,6 +767,74 @@ async def test_occurrences_pending_synthesis_first_page_only(tmp_path: Path) ->
|
||||
assert all(row["kind"] != "pending" for row in plain["occurrences"])
|
||||
|
||||
|
||||
async def test_occurrences_pagination_visits_tied_entries_once(tmp_path: Path) -> None:
|
||||
"""One occurrence's admission + stops each appear exactly once (B4).
|
||||
|
||||
Small-page traversal through the public API must not lose the tied
|
||||
stopped entries sharing the occurrence's ``(resolved_at,
|
||||
occurrence_id)``.
|
||||
"""
|
||||
api, sched_store, _, _, _ = _harness(tmp_path / "tied")
|
||||
await api.create_schedule(
|
||||
schedule_id="s", deployment_id="dep.personal", trigger=_cron()
|
||||
)
|
||||
base = ts(2026, 9, 8, 12, 0)
|
||||
recorder = FileScheduleHistoryRecorder(sched_store)
|
||||
recorder.record(
|
||||
HistoryEntry(
|
||||
schedule_id="s",
|
||||
kind="admitted",
|
||||
resolved_at=base,
|
||||
run_id="run-1",
|
||||
revision=1,
|
||||
reason="rev=1",
|
||||
created_at=base,
|
||||
)
|
||||
)
|
||||
recorder.record(
|
||||
HistoryEntry(
|
||||
schedule_id="s",
|
||||
kind="interrupted",
|
||||
resolved_at=base,
|
||||
run_id="run-1",
|
||||
revision=1,
|
||||
reason="fresh-result",
|
||||
checkpoint_id="run-1.000001",
|
||||
created_at=base + timedelta(minutes=5),
|
||||
)
|
||||
)
|
||||
recorder.record(
|
||||
HistoryEntry(
|
||||
schedule_id="s",
|
||||
kind="completed",
|
||||
resolved_at=base,
|
||||
run_id="run-1",
|
||||
revision=1,
|
||||
reason="reconciled-on-recovery",
|
||||
checkpoint_id="run-1.000002",
|
||||
created_at=base + timedelta(minutes=9),
|
||||
)
|
||||
)
|
||||
|
||||
seen: list[tuple[str, str | None]] = []
|
||||
cursor: str | None = None
|
||||
while True:
|
||||
page = await api.list_schedule_occurrences(
|
||||
schedule_id="s", cursor=cursor, limit=1
|
||||
)
|
||||
assert page["total"] == 3
|
||||
for row in page["occurrences"]:
|
||||
seen.append((row["kind"], row["checkpoint_id"]))
|
||||
cursor = page["next_cursor"]
|
||||
if cursor is None:
|
||||
break
|
||||
assert seen == [
|
||||
("admitted", None),
|
||||
("interrupted", "run-1.000001"),
|
||||
("completed", "run-1.000002"),
|
||||
]
|
||||
|
||||
|
||||
async def test_inspect_admitted_run_without_fabrication(tmp_path: Path) -> None:
|
||||
api, sched_store, run_store, artifact_store, context = _harness(
|
||||
tmp_path / "admitted"
|
||||
|
||||
@@ -265,6 +265,58 @@ async def test_rpc_schedule_occurrences_pagination_and_pending(tmp_path) -> None
|
||||
assert all(row["kind"] != "pending" for row in plain["occurrences"])
|
||||
|
||||
|
||||
async def test_rpc_schedule_occurrences_tied_entries_traverse_once(tmp_path) -> None:
|
||||
"""Tied admission/stopped entries each arrive exactly once over RPC (B4)."""
|
||||
server = await _seed_server(tmp_path)
|
||||
client, http_client = _client_for(server)
|
||||
store = FileScheduleStore(tmp_path / "store")
|
||||
base = datetime(2026, 9, 8, 12, 0, tzinfo=UTC)
|
||||
async with http_client:
|
||||
await client.create_schedule(
|
||||
schedule_id="s",
|
||||
deployment_id="dep.personal",
|
||||
trigger=_cron(),
|
||||
)
|
||||
recorder = FileScheduleHistoryRecorder(store)
|
||||
recorder.record(
|
||||
HistoryEntry(
|
||||
schedule_id="s",
|
||||
kind="admitted",
|
||||
resolved_at=base,
|
||||
run_id="run-1",
|
||||
revision=1,
|
||||
reason="rev=1",
|
||||
created_at=base,
|
||||
)
|
||||
)
|
||||
recorder.record(
|
||||
HistoryEntry(
|
||||
schedule_id="s",
|
||||
kind="interrupted",
|
||||
resolved_at=base,
|
||||
run_id="run-1",
|
||||
revision=1,
|
||||
reason="fresh-result",
|
||||
checkpoint_id="run-1.000001",
|
||||
created_at=base + timedelta(minutes=5),
|
||||
)
|
||||
)
|
||||
|
||||
seen: list[tuple[str, object]] = []
|
||||
cursor: object = None
|
||||
while True:
|
||||
page = await client.list_schedule_occurrences(
|
||||
schedule_id="s", cursor=cursor, limit=1 # type: ignore[arg-type]
|
||||
)
|
||||
assert page["total"] == 2
|
||||
for row in page["occurrences"]:
|
||||
seen.append((row["kind"], row["checkpoint_id"]))
|
||||
cursor = page["next_cursor"]
|
||||
if cursor is None:
|
||||
break
|
||||
assert seen == [("admitted", None), ("interrupted", "run-1.000001")]
|
||||
|
||||
|
||||
async def test_rpc_schedule_error_mapping(tmp_path) -> None:
|
||||
server = await _seed_server(tmp_path)
|
||||
client, http_client = _client_for(server)
|
||||
|
||||
Reference in New Issue
Block a user