Files
hermes-agent/gateway/run_delivery_queue_watch.py
Christopher eb2781dd2f fix(gateway): drain a restart-safe worker's cron delivery when it is queued, not on the next tick
A restart-safe cron worker runs outside the gateway, queues its final send in
cron/deliveries.db and polls that row once a second. The gateway drained the
queue only as a housekeeping chore at the 60 s tick, so a reminder whose turn
ended in 8 s reached the user a minute later (the issue's 60.86 s silence).

The housekeeping thread now watches the queue file (and its WAL) between
ticks and drains as soon as a stamp moved; the tick's drain stays as the
fallback. The watch never resets itself after draining: the drain's own status
write costs one empty pass, a worker that enqueued during the drain still
wakes it, and an empty pass writes nothing, so the stamp settles.

Fixes #117307
2026-09-20 15:30:45 -07:00

79 lines
3.0 KiB
Python

"""Wake the restart-safe cron delivery drain when a worker writes the queue (#117307).
A restart-safe worker runs its job outside the gateway, queues the final send in
``cron/deliveries.db`` and polls that row once a second; the gateway drained the
queue only on its housekeeping tick, so a turn that ended in 8 s reached the user
a minute later. Between ticks the housekeeping thread now stats the queue file
(and its WAL) and drains as soon as either changed. The tick's own drain stays
as the fallback for anything the stat cannot see.
"""
from __future__ import annotations
import os
import time
from pathlib import Path
from typing import Callable, Iterable, Optional
# How often the housekeeping thread looks at the queue file between its ticks.
DELIVERY_QUEUE_WATCH_SECONDS = 1.0
DRAIN_LABEL = "Cron durable delivery queue drain"
class DeliveryQueueWatch:
"""Stamp-compare every served profile's queue file; ``drain`` when a stamp moved.
The watch never resets itself after draining: the drain's own status writes
move the stamp once more and cost one empty pass, after which the stamp is
stable, while a worker that enqueued during the drain still moves it again.
"""
def __init__(self, homes: Callable[[], Iterable[Optional[Path]]], drain: Callable[[], None]) -> None:
self._homes = homes
self.drain = drain
self._served: list = list(homes())
self._seen = self._stamp()
def refresh_homes(self) -> None:
"""Re-resolve the served profiles once per tick (a directory read under multiplex),
not on every poll; a profile that appears mid-interval is drained by the tick itself."""
self._served = list(self._homes())
def _stamp(self) -> tuple:
from cron.delivery_queue import queue_path
stamps = []
for home in self._served:
path = queue_path(home)
for candidate in (path, path.with_name(path.name + "-wal")):
try:
info = os.stat(candidate)
except OSError:
continue
stamps.append((str(candidate), info.st_mtime_ns, info.st_size))
return tuple(stamps)
def changed(self) -> bool:
current = self._stamp()
if current == self._seen:
return False
self._seen = current
return True
def wait_for_next_tick(stop_event, interval: float, watch: Optional[DeliveryQueueWatch], chore) -> None:
"""Sleep one housekeeping interval, draining the worker queue the moment it changes."""
if watch is None:
stop_event.wait(timeout=interval)
return
watch.refresh_homes()
deadline = time.monotonic() + interval
while True:
remaining = max(0.0, deadline - time.monotonic())
if stop_event.wait(timeout=min(remaining, DELIVERY_QUEUE_WATCH_SECONDS)):
return
if watch.changed():
chore(DRAIN_LABEL, watch.drain)
if time.monotonic() >= deadline:
return