fix: close late scheduler poll during drain
This commit is contained in:
@@ -429,19 +429,25 @@ class SchedulerService:
|
|||||||
# unwind through the gate fence (in-memory accounting dropped,
|
# unwind through the gate fence (in-memory accounting dropped,
|
||||||
# durable marks kept), so recovery sees the exact crash shape.
|
# durable marks kept), so recovery sees the exact crash shape.
|
||||||
current = asyncio.current_task()
|
current = asyncio.current_task()
|
||||||
with self._lock:
|
await self._acquire_lock_async()
|
||||||
|
try:
|
||||||
resume_pending = [
|
resume_pending = [
|
||||||
task
|
task
|
||||||
for task in self._resume_tasks.values()
|
for task in self._resume_tasks.values()
|
||||||
if task is not current and not task.done()
|
if task is not current and not task.done()
|
||||||
]
|
]
|
||||||
|
finally:
|
||||||
|
self._lock.release()
|
||||||
for task in resume_pending:
|
for task in resume_pending:
|
||||||
task.cancel()
|
task.cancel()
|
||||||
if resume_pending:
|
if resume_pending:
|
||||||
await asyncio.gather(*resume_pending, return_exceptions=True)
|
await asyncio.gather(*resume_pending, return_exceptions=True)
|
||||||
with self._lock:
|
await self._acquire_lock_async()
|
||||||
|
try:
|
||||||
self._resume_tasks.clear()
|
self._resume_tasks.clear()
|
||||||
self._live_resumes.clear()
|
self._live_resumes.clear()
|
||||||
|
finally:
|
||||||
|
self._lock.release()
|
||||||
resume_cancelled = len([t for t in resume_pending if t.cancelled()])
|
resume_cancelled = len([t for t in resume_pending if t.cancelled()])
|
||||||
pending = list(self._executions)
|
pending = list(self._executions)
|
||||||
if pending:
|
if pending:
|
||||||
@@ -457,12 +463,15 @@ class SchedulerService:
|
|||||||
for task in pending:
|
for task in pending:
|
||||||
self._executions.discard(task)
|
self._executions.discard(task)
|
||||||
cancelled = len([t for t in pending if t.cancelled()]) + resume_cancelled
|
cancelled = len([t for t in pending if t.cancelled()]) + resume_cancelled
|
||||||
with self._lock:
|
await self._acquire_lock_async()
|
||||||
|
try:
|
||||||
report = DrainReport(
|
report = DrainReport(
|
||||||
settled=self._settled,
|
settled=self._settled,
|
||||||
abandoned=self._abandoned,
|
abandoned=self._abandoned,
|
||||||
cancelled=cancelled,
|
cancelled=cancelled,
|
||||||
)
|
)
|
||||||
|
finally:
|
||||||
|
self._lock.release()
|
||||||
self.ownership.release()
|
self.ownership.release()
|
||||||
self._started = False
|
self._started = False
|
||||||
return report
|
return report
|
||||||
@@ -475,6 +484,8 @@ class SchedulerService:
|
|||||||
"""
|
"""
|
||||||
if not self._started:
|
if not self._started:
|
||||||
raise SchedulerStartupError("scheduler service is not started")
|
raise SchedulerStartupError("scheduler service is not started")
|
||||||
|
if self._stopping:
|
||||||
|
raise SchedulerStartupError("scheduler service is stopping")
|
||||||
instant = now if now is not None else self.clock()
|
instant = now if now is not None else self.clock()
|
||||||
try:
|
try:
|
||||||
return await asyncio.to_thread(self._tick, instant)
|
return await asyncio.to_thread(self._tick, instant)
|
||||||
@@ -588,6 +599,11 @@ class SchedulerService:
|
|||||||
|
|
||||||
def _tick(self, now: datetime) -> dict[str, str]:
|
def _tick(self, now: datetime) -> dict[str, str]:
|
||||||
with self._lock:
|
with self._lock:
|
||||||
|
# poll_once can be submitted just as stop publishes the drain
|
||||||
|
# intent. Check again under the scheduler lock so a late worker
|
||||||
|
# cannot dispatch while stop joins the poll boundary.
|
||||||
|
if self._stopping:
|
||||||
|
raise SchedulerStartupError("scheduler service is stopping")
|
||||||
self._refresh_sources()
|
self._refresh_sources()
|
||||||
return self._scheduler.poll(now)
|
return self._scheduler.poll(now)
|
||||||
|
|
||||||
|
|||||||
@@ -457,6 +457,23 @@ async def test_cancelled_resume_release_waits_for_lock_cleanup(
|
|||||||
await service.stop()
|
await service.stop()
|
||||||
|
|
||||||
|
|
||||||
|
async def test_poll_once_rejects_after_stop_begins(tmp_path: Path) -> None:
|
||||||
|
"""A late manual tick cannot enter the scheduler during drain."""
|
||||||
|
intended = ts(2026, 9, 8, 12, 0)
|
||||||
|
service = _service(tmp_path, ScriptedRuntime("complete"))
|
||||||
|
try:
|
||||||
|
await service.start()
|
||||||
|
service.schedule_store.create_schedule(_sched_model("a", intended))
|
||||||
|
service._stopping = True
|
||||||
|
|
||||||
|
with pytest.raises(SchedulerStartupError, match="stopping"):
|
||||||
|
await service.poll_once(intended + timedelta(seconds=1))
|
||||||
|
assert service.run_store.list_runs() == []
|
||||||
|
finally:
|
||||||
|
service._stopping = False
|
||||||
|
await service.stop()
|
||||||
|
|
||||||
|
|
||||||
async def test_scheduled_interrupt_stays_resumable(tmp_path: Path) -> None:
|
async def test_scheduled_interrupt_stays_resumable(tmp_path: Path) -> None:
|
||||||
intended = ts(2026, 9, 8, 12, 0)
|
intended = ts(2026, 9, 8, 12, 0)
|
||||||
service = _service(tmp_path, ScriptedRuntime("interrupt"))
|
service = _service(tmp_path, ScriptedRuntime("interrupt"))
|
||||||
|
|||||||
Reference in New Issue
Block a user