fix: preserve pending occurrence pagination bounds

This commit is contained in:
lda
2026-09-09 21:23:27 +07:00 Verified
parent d8a6795193
commit 384f610301
3 changed files with 104 additions and 9 deletions
+43 -7
View File
@@ -41,6 +41,11 @@ from .saved_subgraphs import resolve_saved_subgraph_tree
_PROJECT_SCHEDULE = JsonProjector(ScheduleResult) _PROJECT_SCHEDULE = JsonProjector(ScheduleResult)
_PROJECT_LIST_SCHEDULES = JsonProjector(ListSchedulesResult) _PROJECT_LIST_SCHEDULES = JsonProjector(ListSchedulesResult)
_PROJECT_OCCURRENCE_PAGE = JsonProjector(OccurrencePage) _PROJECT_OCCURRENCE_PAGE = JsonProjector(OccurrencePage)
# The pending projection is not stored, so a limit-one page needs a cursor
# that means "start the stored history at its first row". A regular store
# cursor cannot express a position before that row without hiding it behind a
# cursor that points after it.
_PENDING_PAGE_CURSOR = "__pending__"
def _serialize_schedule_write(method: Any) -> Any: def _serialize_schedule_write(method: Any) -> Any:
@@ -457,19 +462,47 @@ class WorkflowScheduleApi:
is prepended: ``occurrence_id`` derives from is prepended: ``occurrence_id`` derives from
``(schedule_id, intended instant)``, ``resolved_at`` is the ``(schedule_id, intended instant)``, ``resolved_at`` is the
intended instant, ``revision`` is the candidate revision, and intended instant, ``revision`` is the candidate revision, and
``reason`` is empty; ``total`` grows by one. Later pages never ``reason`` is empty; ``total`` grows by one. The stored page is
fetched with one fewer row so the response still honors ``limit``.
For ``limit=1``, the synthetic ``next_cursor`` is an opaque marker
that resumes stored history from its first row. Later pages never
carry the row (a repeat fetch after the candidate admits therefore carry the row (a repeat fetch after the candidate admits therefore
shows the durable ``admitted`` entry instead of a duplicate shows the durable ``admitted`` entry instead of a duplicate pending
pending projection). projection).
""" """
store = self._schedule_store() store = self._schedule_store()
# KeyError first: an unknown schedule must not leak pagination. # KeyError first: an unknown schedule must not leak pagination.
store.get_schedule(schedule_id) store.get_schedule(schedule_id)
if limit < 1 or limit > 100: if limit < 1 or limit > 100:
raise ValueError("limit must be between 1 and 100") raise ValueError("limit must be between 1 and 100")
page = store.list_occurrences(schedule_id, cursor=cursor, limit=limit) first_page = cursor in (None, "0")
candidate = store.get_candidate(schedule_id) candidate = store.get_candidate(schedule_id)
if candidate is not None and cursor in (None, "0"): include_pending = candidate is not None and first_page
stored_cursor = None if cursor == _PENDING_PAGE_CURSOR else cursor
stored_limit = limit - 1 if include_pending else limit
if stored_limit:
page = store.list_occurrences(
schedule_id,
cursor=stored_cursor,
limit=stored_limit,
)
else:
# Fetch only enough metadata to know whether a stored row must be
# visited after the pending-only page; do not expose that row or
# use its after-row cursor, which would make it unreachable.
stored_page = store.list_occurrences(
schedule_id,
cursor=stored_cursor,
limit=1,
)
page = {
"occurrences": [],
"total": stored_page["total"],
"cursor": stored_cursor,
"next_cursor": (_PENDING_PAGE_CURSOR if stored_page["total"] else None),
"limit": limit,
}
if include_pending:
now = datetime.now(UTC) now = datetime.now(UTC)
intended = candidate.intended_at intended = candidate.intended_at
pending = OccurrenceRecord( pending = OccurrenceRecord(
@@ -492,12 +525,15 @@ class WorkflowScheduleApi:
"occurrences": [ "occurrences": [
pending.model_dump(mode="json"), pending.model_dump(mode="json"),
*page["occurrences"], *page["occurrences"],
], ][:limit],
"total": page["total"] + 1, "total": page["total"] + 1,
"cursor": page["cursor"], "cursor": page["cursor"],
"next_cursor": page["next_cursor"], "next_cursor": page["next_cursor"],
"limit": page["limit"], "limit": limit,
} }
elif cursor == _PENDING_PAGE_CURSOR:
page["cursor"] = cursor
page["limit"] = limit
return _PROJECT_OCCURRENCE_PAGE(page) return _PROJECT_OCCURRENCE_PAGE(page)
+59
View File
@@ -787,6 +787,65 @@ async def test_occurrences_pending_synthesis_first_page_only(tmp_path: Path) ->
assert all(row["kind"] != "pending" for row in plain["occurrences"]) assert all(row["kind"] != "pending" for row in plain["occurrences"])
async def test_occurrences_pending_limit_one_traverses_every_row(
tmp_path: Path,
) -> None:
"""A synthesized pending row must not make the first stored row unreachable."""
api, sched_store, _, _, _ = _harness(tmp_path / "pending_limit_one")
await api.create_schedule(
schedule_id="s", deployment_id="dep.personal", trigger=_cron()
)
base = ts(2026, 9, 8, 12, 0)
recorder = FileScheduleHistoryRecorder(sched_store)
for index in range(3):
instant = base + timedelta(minutes=index)
recorder.record(
HistoryEntry(
schedule_id="s",
kind="admitted",
resolved_at=instant,
run_id=f"run-{index}",
revision=1,
reason="rev=1",
created_at=instant,
)
)
sched_store.save_candidate(
PendingCandidate(
schedule_id="s",
intended_at=base + timedelta(hours=1),
revision=1,
),
schedule_id="s",
)
rows: list[dict[str, Any]] = []
cursor: str | None = None
for _ in range(5):
page = await api.list_schedule_occurrences(
schedule_id="s", cursor=cursor, limit=1
)
assert len(page["occurrences"]) <= 1
rows.extend(page["occurrences"])
cursor = page["next_cursor"]
if cursor is None:
break
else:
pytest.fail("occurrence pagination did not terminate")
assert [row["kind"] for row in rows] == [
"pending",
"admitted",
"admitted",
"admitted",
]
assert [row["run_id"] for row in rows[1:]] == [
"run-0",
"run-1",
"run-2",
]
async def test_occurrences_pagination_visits_tied_entries_once(tmp_path: Path) -> None: async def test_occurrences_pagination_visits_tied_entries_once(tmp_path: Path) -> None:
"""One occurrence's admission + stops each appear exactly once (B4). """One occurrence's admission + stops each appear exactly once (B4).
@@ -242,8 +242,8 @@ async def test_rpc_schedule_occurrences_pagination_and_pending(tmp_path) -> None
assert first["total"] == 4 assert first["total"] == 4
assert first["cursor"] is None assert first["cursor"] is None
assert first["limit"] == 2 assert first["limit"] == 2
# The pending synthesis prepends one row to the stored page. # The pending synthesis consumes one slot from the stored page.
assert len(first["occurrences"]) == 3 assert len(first["occurrences"]) == 2
pending = first["occurrences"][0] pending = first["occurrences"][0]
assert pending["kind"] == "pending" assert pending["kind"] == "pending"
assert pending["schedule_id"] == "s" assert pending["schedule_id"] == "s"