Replace task scheduler utcnow calls (#1456)

Co-authored-by: ghreprimand <203024559+ghreprimand@users.noreply.github.com>
This commit is contained in:
ghreprimand
2026-06-03 00:14:30 -05:00
committed by GitHub
parent 4f03f5ccdd
commit 41d2767b30
2 changed files with 72 additions and 55 deletions

View File

@@ -6,12 +6,17 @@ import logging
import re import re
import time import time
import uuid import uuid
from datetime import datetime, timedelta from datetime import datetime, timedelta, timezone
from typing import Any, Awaitable, Callable, Dict, Tuple from typing import Any, Awaitable, Callable, Dict, Tuple
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
def _utcnow() -> datetime:
"""Return naive UTC for task DB fields without using deprecated APIs."""
return datetime.now(timezone.utc).replace(tzinfo=None)
# ── Shared TTL cache (singleflight) ──────────────────────────────────────── # ── Shared TTL cache (singleflight) ────────────────────────────────────────
# Multiple scheduled tasks firing in the same minute often need the same # Multiple scheduled tasks firing in the same minute often need the same
# external data (Miniflux unreads, MCP tool snapshots, etc.). This cache # external data (Miniflux unreads, MCP tool snapshots, etc.). This cache
@@ -73,7 +78,6 @@ def compute_next_run(schedule: str, scheduled_time: str,
the legacy behavior (`scheduled_time` interpreted as naive-UTC wall clock) the legacy behavior (`scheduled_time` interpreted as naive-UTC wall clock)
is preserved so existing tasks don't shift. is preserved so existing tasks don't shift.
""" """
from datetime import timezone
try: try:
from zoneinfo import ZoneInfo from zoneinfo import ZoneInfo
except ImportError: except ImportError:
@@ -89,12 +93,12 @@ def compute_next_run(schedule: str, scheduled_time: str,
# "now" used for comparisons. When tz is set we work entirely in local tz # "now" used for comparisons. When tz is set we work entirely in local tz
# and convert to UTC at the end. Otherwise we use naive UTC (legacy). # and convert to UTC at the end. Otherwise we use naive UTC (legacy).
if tz is not None: if tz is not None:
now_utc = after or datetime.utcnow() now_utc = after or _utcnow()
if now_utc.tzinfo is None: if now_utc.tzinfo is None:
now_utc = now_utc.replace(tzinfo=timezone.utc) now_utc = now_utc.replace(tzinfo=timezone.utc)
now = now_utc.astimezone(tz) now = now_utc.astimezone(tz)
else: else:
now = after or datetime.utcnow() now = after or _utcnow()
def _to_utc_naive(dt: datetime) -> datetime: def _to_utc_naive(dt: datetime) -> datetime:
"""Convert a tz-aware datetime to naive UTC for DB storage.""" """Convert a tz-aware datetime to naive UTC for DB storage."""
@@ -284,7 +288,7 @@ class TaskScheduler:
run.status = "aborted" run.status = "aborted"
run.error = message run.error = message
run.result = run.result or message run.result = run.result or message
run.finished_at = datetime.utcnow() run.finished_at = _utcnow()
db.commit() db.commit()
return True return True
finally: finally:
@@ -305,7 +309,7 @@ class TaskScheduler:
"task_id": task_id, "task_id": task_id,
"owner": owner, "owner": owner,
"body": (body[:500] + "") if body and len(body) > 500 else body, "body": (body[:500] + "") if body and len(body) > 500 else body,
"timestamp": datetime.utcnow().isoformat() + "Z", "timestamp": _utcnow().isoformat() + "Z",
}) })
# Cap at 50 to avoid unbounded growth # Cap at 50 to avoid unbounded growth
if len(self._pending_notifications) > 50: if len(self._pending_notifications) > 50:
@@ -351,7 +355,7 @@ class TaskScheduler:
TaskRun.status.in_(("running", "queued")) TaskRun.status.in_(("running", "queued"))
).all() ).all()
if stale: if stale:
now = datetime.utcnow() now = _utcnow()
for r in stale: for r in stale:
old_status = r.status or "running" old_status = r.status or "running"
r.status = "aborted" r.status = "aborted"
@@ -372,7 +376,7 @@ class TaskScheduler:
from core.database import SessionLocal as _SL, ScheduledTask as _ST from core.database import SessionLocal as _SL, ScheduledTask as _ST
db = _SL() db = _SL()
try: try:
now = datetime.utcnow() now = _utcnow()
overdue = db.query(_ST).filter( overdue = db.query(_ST).filter(
_ST.status == "active", _ST.status == "active",
_ST.next_run.isnot(None), _ST.next_run.isnot(None),
@@ -574,7 +578,7 @@ class TaskScheduler:
_ST.next_run.isnot(None), _ST.next_run.isnot(None),
).order_by(_ST.next_run.asc()).first() ).order_by(_ST.next_run.asc()).first()
if next_run and next_run[0]: if next_run and next_run[0]:
delta = (next_run[0] - datetime.utcnow()).total_seconds() delta = (next_run[0] - _utcnow()).total_seconds()
sleep_for = max(1.0, min(60.0, delta)) sleep_for = max(1.0, min(60.0, delta))
finally: finally:
_db.close() _db.close()
@@ -586,7 +590,7 @@ class TaskScheduler:
from core.database import SessionLocal, ScheduledTask from core.database import SessionLocal, ScheduledTask
db = SessionLocal() db = SessionLocal()
try: try:
now = datetime.utcnow() now = _utcnow()
async with self._executing_lock: async with self._executing_lock:
# Snapshot under the lock so we don't race with mid-iteration adds. # Snapshot under the lock so we don't race with mid-iteration adds.
executing_snapshot = set(self._executing) executing_snapshot = set(self._executing)
@@ -622,7 +626,7 @@ class TaskScheduler:
run = TaskRun( run = TaskRun(
id=run_id, id=run_id,
task_id=task_id, task_id=task_id,
started_at=datetime.utcnow(), started_at=_utcnow(),
status="queued", status="queued",
result="Queued — waiting for a free slot…", result="Queued — waiting for a free slot…",
) )
@@ -665,7 +669,7 @@ class TaskScheduler:
stale = db.query(TaskRun).filter(TaskRun.id == run_id).first() stale = db.query(TaskRun).filter(TaskRun.id == run_id).first()
if stale and stale.status == "queued": if stale and stale.status == "queued":
stale.status = "skipped" stale.status = "skipped"
stale.finished_at = datetime.utcnow() stale.finished_at = _utcnow()
stale.error = f"Task no longer active (status={task.status if task else 'deleted'})" stale.error = f"Task no longer active (status={task.status if task else 'deleted'})"
db.commit() db.commit()
return return
@@ -676,7 +680,7 @@ class TaskScheduler:
run = db.query(TaskRun).filter(TaskRun.id == run_id).first() run = db.query(TaskRun).filter(TaskRun.id == run_id).first()
if run: if run:
run.status = "running" run.status = "running"
run.started_at = datetime.utcnow() run.started_at = _utcnow()
run.result = "Starting…" run.result = "Starting…"
db.commit() db.commit()
else: else:
@@ -685,7 +689,7 @@ class TaskScheduler:
run = TaskRun( run = TaskRun(
id=run_id, id=run_id,
task_id=task.id, task_id=task.id,
started_at=datetime.utcnow(), started_at=_utcnow(),
status="running", status="running",
result="Starting…", result="Starting…",
) )
@@ -727,7 +731,7 @@ class TaskScheduler:
delay_seconds = int(getattr(defer, "delay_seconds", 20 * 60) or (20 * 60)) delay_seconds = int(getattr(defer, "delay_seconds", 20 * 60) or (20 * 60))
if count > 2: if count > 2:
delay_seconds = max(delay_seconds, 40 * 60) delay_seconds = max(delay_seconds, 40 * 60)
when = datetime.utcnow() + timedelta(seconds=delay_seconds) when = _utcnow() + timedelta(seconds=delay_seconds)
logger.info( logger.info(
"Task '%s' deferred for %ss after %s quiet-window hit(s): %s", "Task '%s' deferred for %ss after %s quiet-window hit(s): %s",
task.name, delay_seconds, count, defer, task.name, delay_seconds, count, defer,
@@ -745,13 +749,13 @@ class TaskScheduler:
run_obj.status = "aborted" run_obj.status = "aborted"
run_obj.error = "Stopped by user" run_obj.error = "Stopped by user"
run_obj.result = run_obj.result or "Stopped by user" run_obj.result = run_obj.result or "Stopped by user"
run_obj.finished_at = datetime.utcnow() run_obj.finished_at = _utcnow()
task.last_run = datetime.utcnow() task.last_run = _utcnow()
if (task.trigger_type or "schedule") == "schedule": if (task.trigger_type or "schedule") == "schedule":
task.next_run = compute_next_run( task.next_run = compute_next_run(
task.schedule, task.scheduled_time, task.schedule, task.scheduled_time,
task.scheduled_day, task.scheduled_date, task.scheduled_day, task.scheduled_date,
after=datetime.utcnow(), after=_utcnow(),
cron_expression=task.cron_expression, cron_expression=task.cron_expression,
tz_name=_resolve_task_timezone(db, task), tz_name=_resolve_task_timezone(db, task),
) )
@@ -768,13 +772,13 @@ class TaskScheduler:
logger.info(f"Task '{task.name}' no-op: {noop}") logger.info(f"Task '{task.name}' no-op: {noop}")
run.status = "skipped" run.status = "skipped"
run.result = str(noop) run.result = str(noop)
run.finished_at = datetime.utcnow() run.finished_at = _utcnow()
task.last_run = datetime.utcnow() task.last_run = _utcnow()
if (task.trigger_type or "schedule") == "schedule": if (task.trigger_type or "schedule") == "schedule":
task.next_run = compute_next_run( task.next_run = compute_next_run(
task.schedule, task.scheduled_time, task.schedule, task.scheduled_time,
task.scheduled_day, task.scheduled_date, task.scheduled_day, task.scheduled_date,
after=datetime.utcnow(), after=_utcnow(),
cron_expression=task.cron_expression, cron_expression=task.cron_expression,
tz_name=_resolve_task_timezone(db, task), tz_name=_resolve_task_timezone(db, task),
) )
@@ -783,10 +787,10 @@ class TaskScheduler:
db.commit() db.commit()
return return
run.finished_at = datetime.utcnow() run.finished_at = _utcnow()
# Update task # Update task
task.last_run = datetime.utcnow() task.last_run = _utcnow()
task.run_count = (task.run_count or 0) + 1 task.run_count = (task.run_count or 0) + 1
self._task_defer_counts.pop(task_id, None) self._task_defer_counts.pop(task_id, None)
@@ -795,7 +799,7 @@ class TaskScheduler:
task.next_run = compute_next_run( task.next_run = compute_next_run(
task.schedule, task.scheduled_time, task.schedule, task.scheduled_time,
task.scheduled_day, task.scheduled_date, task.scheduled_day, task.scheduled_date,
after=datetime.utcnow(), after=_utcnow(),
cron_expression=task.cron_expression, cron_expression=task.cron_expression,
tz_name=_resolve_task_timezone(db, task), tz_name=_resolve_task_timezone(db, task),
) )
@@ -869,17 +873,17 @@ class TaskScheduler:
if run_obj and run_obj.status in ("running", "success"): if run_obj and run_obj.status in ("running", "success"):
run_obj.status = "error" run_obj.status = "error"
run_obj.error = err_text[:2000] run_obj.error = err_text[:2000]
run_obj.finished_at = datetime.utcnow() run_obj.finished_at = _utcnow()
# Advance next_run even on failure so a broken task doesn't # Advance next_run even on failure so a broken task doesn't
# busy-loop the scheduler every tick with a stale past date. # busy-loop the scheduler every tick with a stale past date.
task_obj = db.query(ScheduledTask).filter(ScheduledTask.id == task_id).first() task_obj = db.query(ScheduledTask).filter(ScheduledTask.id == task_id).first()
if task_obj and (task_obj.trigger_type or "schedule") == "schedule": if task_obj and (task_obj.trigger_type or "schedule") == "schedule":
task_obj.last_run = datetime.utcnow() task_obj.last_run = _utcnow()
try: try:
task_obj.next_run = compute_next_run( task_obj.next_run = compute_next_run(
task_obj.schedule, task_obj.scheduled_time, task_obj.schedule, task_obj.scheduled_time,
task_obj.scheduled_day, task_obj.scheduled_date, task_obj.scheduled_day, task_obj.scheduled_date,
after=datetime.utcnow(), after=_utcnow(),
cron_expression=task_obj.cron_expression, cron_expression=task_obj.cron_expression,
tz_name=_resolve_task_timezone(db, task_obj), tz_name=_resolve_task_timezone(db, task_obj),
) )
@@ -904,13 +908,13 @@ class TaskScheduler:
if _r and _r.status in ("running", "queued"): if _r and _r.status in ("running", "queued"):
_r.status = "aborted" _r.status = "aborted"
_r.error = f"commit_failed: {type(commit_err).__name__}: {commit_err}"[:2000] _r.error = f"commit_failed: {type(commit_err).__name__}: {commit_err}"[:2000]
_r.finished_at = datetime.utcnow() _r.finished_at = _utcnow()
_t = _recover_db.query(ScheduledTask).filter(ScheduledTask.id == task_id).first() _t = _recover_db.query(ScheduledTask).filter(ScheduledTask.id == task_id).first()
if _t and (_t.trigger_type or "schedule") == "schedule": if _t and (_t.trigger_type or "schedule") == "schedule":
# Push next_run forward 5min as a safe stall so the # Push next_run forward 5min as a safe stall so the
# scheduler doesn't immediately re-dispatch. # scheduler doesn't immediately re-dispatch.
_t.next_run = datetime.utcnow() + _td(minutes=5) _t.next_run = _utcnow() + _td(minutes=5)
_t.last_run = datetime.utcnow() _t.last_run = _utcnow()
_recover_db.commit() _recover_db.commit()
except Exception as recover_err: except Exception as recover_err:
logger.error("Task %s recovery commit ALSO failed: %s", task_id, recover_err) logger.error("Task %s recovery commit ALSO failed: %s", task_id, recover_err)
@@ -1087,14 +1091,14 @@ class TaskScheduler:
if tz_name: if tz_name:
from zoneinfo import ZoneInfo from zoneinfo import ZoneInfo
from datetime import timezone, timedelta from datetime import timezone, timedelta
now = datetime.utcnow().replace(tzinfo=timezone.utc).astimezone(ZoneInfo(tz_name)) now = _utcnow().replace(tzinfo=timezone.utc).astimezone(ZoneInfo(tz_name))
else: else:
from datetime import timedelta from datetime import timedelta
now = datetime.utcnow() now = _utcnow()
time_str = now.strftime("%A, %B %d %Y, %H:%M") time_str = now.strftime("%A, %B %d %Y, %H:%M")
except Exception: except Exception:
from datetime import timedelta from datetime import timedelta
now = datetime.utcnow() now = _utcnow()
time_str = now.strftime("%H:%M UTC") time_str = now.strftime("%H:%M UTC")
raw = {} raw = {}
@@ -1297,8 +1301,8 @@ class TaskScheduler:
endpoint_url=endpoint_url, endpoint_url=endpoint_url,
model=model, model=model,
owner=task.owner, owner=task.owner,
created_at=datetime.utcnow(), created_at=_utcnow(),
updated_at=datetime.utcnow(), updated_at=_utcnow(),
) )
db.add(sess) db.add(sess)
task.session_id = session_id task.session_id = session_id
@@ -1327,12 +1331,12 @@ class TaskScheduler:
if tz_name: if tz_name:
from zoneinfo import ZoneInfo from zoneinfo import ZoneInfo
from datetime import timezone from datetime import timezone
now_local = datetime.utcnow().replace(tzinfo=timezone.utc).astimezone(ZoneInfo(tz_name)) now_local = _utcnow().replace(tzinfo=timezone.utc).astimezone(ZoneInfo(tz_name))
time_str = now_local.strftime("%A, %B %d %Y, %H:%M %Z") time_str = now_local.strftime("%A, %B %d %Y, %H:%M %Z")
else: else:
time_str = datetime.utcnow().strftime("%A, %B %d %Y, %H:%M UTC") time_str = _utcnow().strftime("%A, %B %d %Y, %H:%M UTC")
except Exception: except Exception:
time_str = datetime.utcnow().strftime("%A, %B %d %Y, %H:%M UTC") time_str = _utcnow().strftime("%A, %B %d %Y, %H:%M UTC")
system_prompt = f"Current time: {time_str}\n\n{system_prompt}" system_prompt = f"Current time: {time_str}\n\n{system_prompt}"
# Compute tool filter from CrewMember.enabled_tools if set # Compute tool filter from CrewMember.enabled_tools if set
@@ -1439,8 +1443,8 @@ class TaskScheduler:
endpoint_url=endpoint_url or "", endpoint_url=endpoint_url or "",
model=model_name or "", model=model_name or "",
owner=task.owner, owner=task.owner,
created_at=datetime.utcnow(), created_at=_utcnow(),
updated_at=datetime.utcnow(), updated_at=_utcnow(),
) )
db.add(sess) db.add(sess)
task.session_id = session_id task.session_id = session_id
@@ -1463,7 +1467,7 @@ class TaskScheduler:
session_id=session_id, session_id=session_id,
role="user", role="user",
content=user_content, content=user_content,
timestamp=datetime.utcnow(), timestamp=_utcnow(),
meta_data=msg_meta, meta_data=msg_meta,
) )
assistant_msg = ChatMessage( assistant_msg = ChatMessage(
@@ -1471,7 +1475,7 @@ class TaskScheduler:
session_id=session_id, session_id=session_id,
role="assistant", role="assistant",
content=result or "", content=result or "",
timestamp=datetime.utcnow(), timestamp=_utcnow(),
meta_data=msg_meta, meta_data=msg_meta,
) )
db.add(user_msg) db.add(user_msg)
@@ -1719,8 +1723,8 @@ class TaskScheduler:
endpoint_url=endpoint_url, endpoint_url=endpoint_url,
model=model, model=model,
owner=task.owner, owner=task.owner,
created_at=datetime.utcnow(), created_at=_utcnow(),
updated_at=datetime.utcnow(), updated_at=_utcnow(),
) )
db.add(sess) db.add(sess)
task.session_id = session_id task.session_id = session_id
@@ -2001,7 +2005,7 @@ class TaskScheduler:
task.cron_expression = defs["cron_expression"] task.cron_expression = defs["cron_expression"]
task.next_run = compute_next_run( task.next_run = compute_next_run(
defs["schedule"], defs["scheduled_time"], None, None, defs["schedule"], defs["scheduled_time"], None, None,
after=datetime.utcnow(), cron_expression=defs["cron_expression"], after=_utcnow(), cron_expression=defs["cron_expression"],
tz_name=_resolve_task_timezone(db, task), tz_name=_resolve_task_timezone(db, task),
) )
normalized = True normalized = True
@@ -2036,7 +2040,7 @@ class TaskScheduler:
task.next_run = compute_next_run( task.next_run = compute_next_run(
task.schedule, task.scheduled_time, task.schedule, task.scheduled_time,
task.scheduled_day, task.scheduled_date, task.scheduled_day, task.scheduled_date,
after=datetime.utcnow(), cron_expression=task.cron_expression, after=_utcnow(), cron_expression=task.cron_expression,
tz_name=_resolve_task_timezone(db, task), tz_name=_resolve_task_timezone(db, task),
) )
# Built-in housekeeping/action jobs should not create browser # Built-in housekeeping/action jobs should not create browser
@@ -2051,7 +2055,7 @@ class TaskScheduler:
if trigger_type == "schedule": if trigger_type == "schedule":
next_run = compute_next_run( next_run = compute_next_run(
defs["schedule"], defs["scheduled_time"], None, None, defs["schedule"], defs["scheduled_time"], None, None,
after=datetime.utcnow(), cron_expression=defs["cron_expression"], after=_utcnow(), cron_expression=defs["cron_expression"],
) )
ships_paused = bool(defs.get("ship_paused")) ships_paused = bool(defs.get("ship_paused"))
task = ScheduledTask( task = ScheduledTask(
@@ -2188,8 +2192,8 @@ class TaskScheduler:
is_important=True, is_important=True,
mode="agent", mode="agent",
folder="Assistant", folder="Assistant",
created_at=datetime.utcnow(), created_at=_utcnow(),
updated_at=datetime.utcnow(), updated_at=_utcnow(),
) )
db.add(sess) db.add(sess)
db.flush() db.flush()

View File

@@ -11,12 +11,16 @@ test asserts the opposite: the task fires at most once across two consecutive
polls. polls.
""" """
import sys, types, asyncio import sys, types, asyncio
from datetime import datetime, timedelta from datetime import datetime, timedelta, timezone
from unittest.mock import MagicMock from unittest.mock import MagicMock
from sqlalchemy import create_engine, Column, String, DateTime, Integer, Boolean, Text from sqlalchemy import create_engine, Column, String, DateTime, Integer, Boolean, Text
from sqlalchemy.orm import sessionmaker, declarative_base from sqlalchemy.orm import sessionmaker, declarative_base
def _test_utcnow():
return datetime.now(timezone.utc).replace(tzinfo=None)
def _stub_heavy(): def _stub_heavy():
for name in [ for name in [
"src.builtin_actions", "src.ai_interaction", "src.endpoint_resolver", "src.builtin_actions", "src.ai_interaction", "src.endpoint_resolver",
@@ -59,6 +63,15 @@ def _setup_isolated_db():
return cd, ScheduledTask, TaskRun return cd, ScheduledTask, TaskRun
def test_scheduler_utcnow_preserves_naive_utc_contract():
from src.task_scheduler import _utcnow
now = _utcnow()
assert now.tzinfo is None
assert abs((now - _test_utcnow()).total_seconds()) < 2
def _drive_scheduler(monkeypatch, pre_start_setup=None): def _drive_scheduler(monkeypatch, pre_start_setup=None):
"""Build a TaskScheduler bypassing __init__ and run start() + two polls.""" """Build a TaskScheduler bypassing __init__ and run start() + two polls."""
_stub_heavy() _stub_heavy()
@@ -115,7 +128,7 @@ def test_restart_does_not_re_dispatch_overdue_task(monkeypatch):
db.add(ScheduledTask( db.add(ScheduledTask(
id="t_due_1", owner="alice", name="overdue", id="t_due_1", owner="alice", name="overdue",
task_type="llm", task_type="llm",
next_run=datetime.utcnow() - timedelta(hours=1), next_run=_test_utcnow() - timedelta(hours=1),
status="active", status="active",
)) ))
db.commit() db.commit()
@@ -126,7 +139,7 @@ def test_restart_does_not_re_dispatch_overdue_task(monkeypatch):
db = cd.SessionLocal() db = cd.SessionLocal()
t = db.query(ScheduledTask).filter(ScheduledTask.id == "t_due_1").first() t = db.query(ScheduledTask).filter(ScheduledTask.id == "t_due_1").first()
db.close() db.close()
assert t.next_run >= datetime.utcnow() - timedelta(seconds=1), ( assert t.next_run >= _test_utcnow() - timedelta(seconds=1), (
f"After start(), next_run should have been pushed into the future; " f"After start(), next_run should have been pushed into the future; "
f"got {t.next_run}" f"got {t.next_run}"
) )
@@ -140,7 +153,7 @@ def test_restart_does_not_re_dispatch_overdue_task(monkeypatch):
def test_startup_does_not_advance_fresh_tasks(monkeypatch): def test_startup_does_not_advance_fresh_tasks(monkeypatch):
"""Tasks whose next_run is in the future must be untouched by the startup """Tasks whose next_run is in the future must be untouched by the startup
sweep — only overdue ones get pushed forward.""" sweep — only overdue ones get pushed forward."""
future = datetime.utcnow() + timedelta(hours=2) future = _test_utcnow() + timedelta(hours=2)
def _setup(cd, ScheduledTask, TaskRun): def _setup(cd, ScheduledTask, TaskRun):
db = cd.SessionLocal() db = cd.SessionLocal()
db.add(ScheduledTask( db.add(ScheduledTask(
@@ -169,7 +182,7 @@ def test_startup_does_not_advance_paused_tasks(monkeypatch):
db.add(ScheduledTask( db.add(ScheduledTask(
id="t_paused", owner="alice", name="paused", id="t_paused", owner="alice", name="paused",
task_type="llm", task_type="llm",
next_run=datetime.utcnow() - timedelta(hours=1), next_run=_test_utcnow() - timedelta(hours=1),
status="paused", status="paused",
)) ))
db.commit() db.commit()
@@ -183,7 +196,7 @@ def test_startup_does_not_advance_paused_tasks(monkeypatch):
# The stored next_run should still be ~1h in the past (the startup sweep # The stored next_run should still be ~1h in the past (the startup sweep
# only advances active overdue tasks; a paused task with an old next_run # only advances active overdue tasks; a paused task with an old next_run
# is left alone). Allow a small delta to absorb the time the sweep took. # is left alone). Allow a small delta to absorb the time the sweep took.
one_hour_ago = datetime.utcnow() - timedelta(hours=1) one_hour_ago = _test_utcnow() - timedelta(hours=1)
assert abs((t.next_run - one_hour_ago).total_seconds()) < 5, ( assert abs((t.next_run - one_hour_ago).total_seconds()) < 5, (
f"Paused task's next_run was modified: " f"Paused task's next_run was modified: "
f"expected ~{one_hour_ago}, got {t.next_run}" f"expected ~{one_hour_ago}, got {t.next_run}"