Files
beaver-gateway/tests/test_scheduler.py
T

291 lines
10 KiB
Python

import asyncio
import inspect
import os
import tempfile
from datetime import UTC, datetime, timedelta
from pathlib import Path
from typing import Any
from zoneinfo import ZoneInfo
import psycopg
import pytest
from httpx import ASGITransport, AsyncClient
from pgqueuer import PsycopgDriver, Queries
from test_conversations import World
from beaver_gateway.conversations.service import parse_at
from beaver_gateway.jobs.scheduler import Budget, Job, JobRun, Scheduler, next_run
from beaver_gateway.storage.models import RateLimit
DATABASE_URL = os.environ.get("TEST_DATABASE_URL")
WARSAW = ZoneInfo("Europe/Warsaw")
class Pg:
def __init__(self) -> None:
self.connections: list[psycopg.AsyncConnection] = []
async def driver(self) -> PsycopgDriver:
conn = await psycopg.AsyncConnection.connect(str(DATABASE_URL), autocommit=True)
self.connections.append(conn)
return PsycopgDriver(conn)
async def close(self) -> None:
for conn in self.connections:
await conn.close()
@pytest.fixture
async def pg() -> Pg:
if not DATABASE_URL:
pytest.skip("TEST_DATABASE_URL (postgres) is not set")
handle = Pg()
queries = Queries(await handle.driver())
if await queries.has_table("pgqueuer"):
await queries.uninstall()
await queries.install()
yield handle
await queries.uninstall()
await handle.close()
@pytest.fixture
async def world() -> World:
root = Path(tempfile.mkdtemp(prefix="beaver-sched-"))
w = await World(root).setup()
yield w
await w.conversations.stop()
await w.pool.close_all()
await w.db.dispose()
async def until(pred, timeout: float = 5.0) -> Any:
deadline = asyncio.get_running_loop().time() + timeout
while asyncio.get_running_loop().time() < deadline:
value = pred()
if inspect.isawaitable(value):
value = await value
if value:
return value
await asyncio.sleep(0.05)
msg = "condition never happened"
raise AssertionError(msg)
def test_cron_is_read_in_the_gateway_tz() -> None:
summer = datetime(2026, 7, 1, 12, tzinfo=UTC)
assert next_run("0 4 * * *", WARSAW, summer) == datetime(2026, 7, 2, 2, tzinfo=UTC)
assert next_run("0 4 * * *", UTC, summer) == datetime(2026, 7, 2, 4, tzinfo=UTC)
before_fallback = datetime(2026, 10, 24, 12, tzinfo=UTC)
assert next_run("0 4 * * *", WARSAW, before_fallback) == datetime(
2026, 10, 25, 3, tzinfo=UTC
)
during_fallback = datetime(2026, 10, 25, 1, 30, tzinfo=UTC)
assert next_run("0 4 * * *", WARSAW, during_fallback) == datetime(
2026, 10, 25, 3, tzinfo=UTC
)
assert next_run("*/15 * * * *", WARSAW, during_fallback) == datetime(
2026, 10, 25, 1, 45, tzinfo=UTC
)
soon = next_run("* * * * *", WARSAW)
assert soon.tzinfo is UTC
assert timedelta(0) < soon - datetime.now(UTC) <= timedelta(minutes=1)
def test_parse_at_reads_naive_iso_in_tz() -> None:
assert parse_at("2026-09-01T10:00", WARSAW) == datetime(
2026, 9, 1, 8, 0, tzinfo=UTC
)
assert parse_at("2026-12-01T10:00", WARSAW) == datetime(
2026, 12, 1, 9, 0, tzinfo=UTC
)
assert parse_at("2026-09-01T10:00:00+02:00", WARSAW) == datetime(
2026, 9, 1, 8, 0, tzinfo=UTC
)
assert parse_at("2026-09-01T10:00") == datetime(2026, 9, 1, 10, 0, tzinfo=UTC)
async def test_runs_are_recorded_without_postgres(world: World) -> None:
async def ok(run: JobRun) -> None:
run.payload["seen"] = True
async def boom(_run: JobRun) -> None:
msg = "no network"
raise ConnectionError(msg)
scheduler = Scheduler(
conversations=world.conversations,
jobs=[Job("ok", ok, webhook=True), Job("boom", boom)],
tz="Europe/Warsaw",
)
await scheduler.start()
await scheduler.hook("ok", {"stack": "x"})
await scheduler.trigger(scheduler.job("boom"))
await until(lambda: len(scheduler._runs) == 2) # noqa: SLF001
runs = await scheduler.runs("boom")
assert [r["status"] for r in runs] == ["failed"]
assert runs[0]["error"].startswith("ConnectionError: no network\n")
assert runs[0]["trigger"] == "manual"
assert runs[0]["started_at"] <= runs[0]["finished_at"]
assert runs[0]["started_at"].endswith("+02:00")
ok_runs = await scheduler.runs("ok")
assert ok_runs[0]["payload"] == {"stack": "x", "seen": True}
assert ok_runs[0]["trigger"] == "webhook"
await scheduler.hook("ok", {"stack": "y"})
await until(lambda: scheduler._runs["ok"]["payload"].get("stack") == "y") # noqa: SLF001
assert len(await scheduler.runs("ok")) == 2
assert len(await scheduler.runs("ok", limit=1)) == 1
snapshot = await scheduler.snapshot()
assert snapshot["tz"] == "Europe/Warsaw"
runs_by_name = {j["name"]: j["run"] for j in snapshot["jobs"]}
assert runs_by_name["ok"]["payload"]["stack"] == "y"
assert runs_by_name["boom"]["status"] == "failed"
await scheduler.stop()
async def test_schedule_survives_a_restart(world: World, pg: Pg) -> None:
conv = await world.conversations.create(kind="master", agent="a", origin="test")
first = Scheduler(
conversations=world.conversations, driver=await pg.driver(), tz="Europe/Warsaw"
)
world.conversations.scheduler = first
await first.start()
job_id, when = await world.conversations.schedule(conv, "+1s", "push X")
assert job_id is not None
assert 0 < (when - datetime.now(UTC)).total_seconds() <= 1
assert when.utcoffset() == datetime.now(WARSAW).utcoffset()
queued = await world.conversations.schedules(conv)
assert [q["payload"]["text"] for q in queued] == ["push X"]
assert queued[0]["payload"]["at"] == when.isoformat(timespec="minutes")
await first.stop()
second = Scheduler(conversations=world.conversations, driver=await pg.driver())
world.conversations.scheduler = second
await second.start()
world.conversations._normal_window = 0.05 # noqa: SLF001
await until(lambda: len(ScriptedClient_prompts(world)) == 1)
prompt = ScriptedClient_prompts(world)[0]
assert prompt.startswith("[inject: schedule")
assert prompt.endswith("push X")
assert await world.statuses(conv) == [("wake", "done")]
assert await world.conversations.schedules(conv) == []
await second.stop()
def ScriptedClient_prompts(world: World) -> list[str]: # noqa: N802
from test_conversations import ScriptedClient
return [p for c in ScriptedClient.instances for p in c.prompts]
async def test_cancel_removes_a_pending_inject(world: World, pg: Pg) -> None:
conv = await world.conversations.create(kind="master", agent="a", origin="test")
scheduler = Scheduler(conversations=world.conversations, driver=await pg.driver())
world.conversations.scheduler = scheduler
await scheduler.start()
job_id, _ = await scheduler.schedule(conv, "+1h", "never")
assert job_id is not None
assert await scheduler.cancel(job_id)
assert not await scheduler.cancel(job_id)
await until(lambda: True)
assert await scheduler.scheduled(conv) == []
await scheduler.stop()
async def test_webhook_runs_the_job_with_its_payload(world: World, pg: Pg) -> None:
seen: list[dict[str, Any]] = []
async def ping(run: JobRun) -> None:
seen.append({"trigger": run.trigger, **run.payload})
scheduler = Scheduler(
conversations=world.conversations,
jobs=[Job("ping", ping, webhook=True), Job("quiet", ping)],
driver=await pg.driver(),
)
await scheduler.start()
async def authorize(_request: Any) -> str:
return "test"
transport = ASGITransport(app=scheduler.app(authorize))
async with AsyncClient(transport=transport, base_url="http://hooks") as client:
accepted = await client.post("/ping", json={"stack": "x"})
assert accepted.status_code == 202
assert accepted.json()["name"] == "ping"
missing = await client.post("/quiet", json={})
assert missing.status_code == 404
await until(lambda: seen)
assert seen == [{"trigger": "webhook", "stack": "x"}]
await until(lambda: scheduler._runs.get("ping")) # noqa: SLF001
snapshot = await scheduler.snapshot()
assert [j["name"] for j in snapshot["jobs"]] == ["ping", "quiet"]
assert snapshot["enabled"] and snapshot["jobs"][0]["webhook"]
assert snapshot["jobs"][0]["run"]["status"] == "done"
assert snapshot["jobs"][0]["run"]["trigger"] == "webhook"
runs = await scheduler.runs("ping")
assert [r["payload"] for r in runs] == [{"stack": "x"}]
await scheduler.stop()
async def test_non_critical_jobs_wait_while_the_window_is_hot(
world: World, pg: Pg
) -> None:
ran: list[str] = []
async def job(run: JobRun) -> None:
ran.append(run.job.name)
async with world.db.session() as session:
session.add(
RateLimit(
window="five_hour",
status="allowed_warning",
utilization=0.9,
resets_at=datetime.now(UTC) + timedelta(hours=2),
)
)
await session.commit()
scheduler = Scheduler(
conversations=world.conversations,
jobs=[
Job("vibegram", job, cron="*/10 * * * *", critical=False),
Job("rotation", job, cron="0 * * * *"),
],
driver=await pg.driver(),
budget=Budget(threshold=0.7),
tz="Europe/Warsaw",
)
await scheduler.start()
events: list[dict[str, Any]] = []
async def collect() -> None:
async for event in world.bus.stream():
events.append(event)
task = asyncio.create_task(collect())
await scheduler._dispatch( # noqa: SLF001
scheduler.job("vibegram"), trigger="cron", payload={}
)
await scheduler._dispatch( # noqa: SLF001
scheduler.job("rotation"), trigger="cron", payload={}
)
await asyncio.sleep(0.05)
task.cancel()
assert ran == ["rotation"]
assert [e["job"] for e in events if e["type"] == "job.deferred"] == ["vibegram"]
snapshot = await scheduler.snapshot()
assert snapshot["throttled"] and snapshot["utilization"] == 0.9
crons = {j["name"]: j["cron"] for j in snapshot["jobs"]}
assert crons == {"vibegram": "*/10 * * * *", "rotation": "0 * * * *"}
async def crons_ready() -> dict[str, Any] | None:
snap = await scheduler.snapshot()
return snap if all(j["next_run"] for j in snap["jobs"]) else None
snapshot = await until(crons_ready)
expected = datetime.now(WARSAW).utcoffset()
for job_info in snapshot["jobs"]:
assert datetime.fromisoformat(job_info["next_run"]).utcoffset() == expected
await scheduler.stop()