test: harden scheduling review boundaries
This commit is contained in:
@@ -9,10 +9,12 @@ anew.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from datetime import UTC, datetime, timedelta, timezone
|
||||
from pathlib import Path
|
||||
from typing import Any, cast
|
||||
|
||||
import pytest
|
||||
|
||||
from tests.artifacts.test_run_store import artifact as _artifact
|
||||
from tests.artifacts.test_run_store import deployment as _deployment
|
||||
from tests.scheduling.controlled import (
|
||||
@@ -30,7 +32,11 @@ from wf_artifacts.runs.models import ResumeAttempt
|
||||
from wf_artifacts.runs.store import FileRunStore
|
||||
from wf_core import RunState, RunStatus
|
||||
from wf_scheduling import recovery as sched_recovery
|
||||
from wf_scheduling.history import FileScheduleHistoryRecorder
|
||||
from wf_scheduling.history import (
|
||||
FileScheduleHistoryRecorder,
|
||||
HistoryEntry,
|
||||
entry_occurrence_id,
|
||||
)
|
||||
from wf_scheduling.models import Schedule
|
||||
from wf_scheduling.ownership import SchedulerOwnership
|
||||
from wf_scheduling.poll import Scheduler
|
||||
@@ -136,6 +142,80 @@ def _recover(
|
||||
ownership.release()
|
||||
|
||||
|
||||
def test_interval_identity_normalizes_offsets_and_deduplicates(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
store = FileScheduleStore(tmp_path / "sched")
|
||||
recorder = FileScheduleHistoryRecorder(store)
|
||||
utc_start = datetime(2026, 9, 8, 12, 0, tzinfo=UTC)
|
||||
utc_end = datetime(2026, 9, 8, 13, 0, tzinfo=UTC)
|
||||
offset = timezone(timedelta(hours=2))
|
||||
|
||||
recorder.record(
|
||||
HistoryEntry(
|
||||
schedule_id="a",
|
||||
kind="interval-summary",
|
||||
interval_start=utc_start,
|
||||
interval_end=utc_end,
|
||||
interval_count=2,
|
||||
created_at=utc_start,
|
||||
)
|
||||
)
|
||||
recorder.record(
|
||||
HistoryEntry(
|
||||
schedule_id="a",
|
||||
kind="interval-summary",
|
||||
interval_start=utc_start.astimezone(offset),
|
||||
interval_end=utc_end.astimezone(offset),
|
||||
interval_count=2,
|
||||
created_at=utc_end,
|
||||
)
|
||||
)
|
||||
|
||||
rows = cast(
|
||||
list[dict[str, Any]], store.list_occurrences("a", limit=10)["occurrences"]
|
||||
)
|
||||
assert len(rows) == 1
|
||||
row = rows[0]
|
||||
assert row["occurrence_id"] == (
|
||||
"a|summary|2026-09-08T12:00:00+00:00|2026-09-08T13:00:00+00:00"
|
||||
)
|
||||
assert row["interval_start"] == "2026-09-08T12:00:00Z"
|
||||
assert row["interval_end"] == "2026-09-08T13:00:00Z"
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("interval_start", "interval_end"),
|
||||
[
|
||||
(datetime(2026, 9, 8, 12, 0), datetime(2026, 9, 8, 13, 0, tzinfo=UTC)),
|
||||
(datetime(2026, 9, 8, 12, 0, tzinfo=UTC), datetime(2026, 9, 8, 13, 0)),
|
||||
(None, datetime(2026, 9, 8, 13, 0, tzinfo=UTC)),
|
||||
(datetime(2026, 9, 8, 12, 0, tzinfo=UTC), None),
|
||||
],
|
||||
)
|
||||
def test_interval_identity_rejects_naive_or_half_specified_bounds(
|
||||
interval_start: datetime | None,
|
||||
interval_end: datetime | None,
|
||||
) -> None:
|
||||
with pytest.raises(ValueError):
|
||||
entry_occurrence_id(
|
||||
HistoryEntry(
|
||||
schedule_id="a",
|
||||
kind="interval-summary",
|
||||
interval_start=interval_start,
|
||||
interval_end=interval_end,
|
||||
),
|
||||
datetime(2026, 9, 8, 14, 0, tzinfo=UTC),
|
||||
)
|
||||
|
||||
|
||||
def test_history_identity_without_interval_still_uses_created_time() -> None:
|
||||
created = datetime(2026, 9, 8, 14, 0, tzinfo=UTC)
|
||||
assert entry_occurrence_id(
|
||||
HistoryEntry(schedule_id="a", kind="interval-summary"), created
|
||||
) == "a|summary|2026-09-08T14:00:00+00:00"
|
||||
|
||||
|
||||
def test_failed_run_persists_reason_without_callback(tmp_path: Path) -> None:
|
||||
sched_store = FileScheduleStore(tmp_path / "sched")
|
||||
run_store = FileRunStore(tmp_path / "runs")
|
||||
@@ -466,7 +546,7 @@ def test_consumed_write_failure_recovers_without_dup(tmp_path: Path) -> None:
|
||||
sched_store, run_store, ownership, script={"*": "hang"}
|
||||
)
|
||||
sched.sources["a"] = OneShotSource(intended)
|
||||
with __import__("pytest").raises(OSError, match="injected consumed"):
|
||||
with pytest.raises(OSError, match="injected consumed"):
|
||||
sched.poll(intended)
|
||||
assert [a.id for a in run_store.list_admissions()] != []
|
||||
first = run_store.list_admissions()[0].id
|
||||
|
||||
@@ -261,8 +261,6 @@ async def test_settlement_does_not_block_the_event_loop(
|
||||
settlement_finished.set()
|
||||
|
||||
monkeypatch.setattr(Scheduler, "record_stopped_execution", _blocking_settle)
|
||||
release_timer = threading.Timer(1.0, release.set)
|
||||
release_timer.start()
|
||||
try:
|
||||
await service.start()
|
||||
service.schedule_store.create_schedule(_sched_model("a", intended))
|
||||
@@ -275,7 +273,6 @@ async def test_settlement_does_not_block_the_event_loop(
|
||||
await _wait_for(lambda: service.live_executions == 0)
|
||||
finally:
|
||||
release.set()
|
||||
release_timer.cancel()
|
||||
await service.stop()
|
||||
|
||||
|
||||
@@ -303,8 +300,6 @@ async def test_shutdown_joins_cancelled_settlement_before_releasing_ownership(
|
||||
original(self, run_id, state, now)
|
||||
|
||||
monkeypatch.setattr(Scheduler, "record_stopped_execution", _blocking_settle)
|
||||
release_timer = threading.Timer(1.0, release.set)
|
||||
release_timer.start()
|
||||
try:
|
||||
await service.start()
|
||||
service.schedule_store.create_schedule(_sched_model("a", intended))
|
||||
@@ -328,7 +323,6 @@ async def test_shutdown_joins_cancelled_settlement_before_releasing_ownership(
|
||||
).status.value == ("completed")
|
||||
finally:
|
||||
release.set()
|
||||
release_timer.cancel()
|
||||
await service.stop()
|
||||
|
||||
|
||||
|
||||
@@ -123,6 +123,7 @@ def test_competing_processes_share_the_canonical_lock(tmp_path: Path) -> None:
|
||||
)
|
||||
proc = subprocess.run(
|
||||
[sys.executable, str(script_path)],
|
||||
cwd=Path(__file__).resolve().parents[2],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=60,
|
||||
|
||||
@@ -112,8 +112,8 @@ def test_production_scheduler_has_no_fixture_seams() -> None:
|
||||
|
||||
|
||||
def test_preparer_contract_is_a_protocol() -> None:
|
||||
assert issubclass(InvocationPreparer, object)
|
||||
assert issubclass(RunDispatcher, object)
|
||||
assert typing.is_protocol(InvocationPreparer)
|
||||
assert typing.is_protocol(RunDispatcher)
|
||||
|
||||
|
||||
def test_occurrence_bindings_resolve_into_admission(tmp_path: Path) -> None:
|
||||
|
||||
Reference in New Issue
Block a user