feat(observe): detect missed notifications via sweep comparison
This commit is contained in:
@@ -44,6 +44,7 @@ class ObserveManager:
|
||||
self._settle_lock = threading.Lock()
|
||||
self.subscribed_hrefs: set[str] = set()
|
||||
self._notified: set[str] = set()
|
||||
self.fallback_hrefs: set[str] = set()
|
||||
|
||||
def mark_write_pending(self, href: str, settle_s: float = DEFAULT_SETTLE_S) -> None:
|
||||
with self._settle_lock:
|
||||
@@ -119,3 +120,28 @@ class ObserveManager:
|
||||
self.mode = mode
|
||||
self.last_mode_change_ts = time.monotonic()
|
||||
self.last_mode_change_wall = time.time()
|
||||
|
||||
def check_sweep_for_misses(self, sweep_resources: dict[str, dict]) -> bool:
|
||||
"""Compare a safety-net sweep result against the cache for every
|
||||
subscribed href. A mismatch outside its settle window means
|
||||
OBSERVE silently missed a change (it only notifies on change, so
|
||||
any disagreement is a real miss, not a case of "nothing changed
|
||||
yet"). Returns True if a miss was detected."""
|
||||
if self.mode != MODE_OBSERVE:
|
||||
return False
|
||||
for href in self.subscribed_hrefs:
|
||||
if href not in sweep_resources or self._is_settling(href):
|
||||
continue
|
||||
cached = self.cache.get(href)
|
||||
if cached is not None and cached != sweep_resources[href]:
|
||||
self.log.warning(
|
||||
"observe missed a change on %s (sweep disagrees with cache)",
|
||||
href,
|
||||
)
|
||||
return True
|
||||
return False
|
||||
|
||||
def downgrade_to_poll(self) -> None:
|
||||
self.fallback_hrefs = set(self.subscribed_hrefs)
|
||||
self.subscribed_hrefs = set()
|
||||
self._set_mode(MODE_POLL)
|
||||
|
||||
@@ -189,3 +189,65 @@ def test_try_enter_observe_mode_clears_stale_notifications_on_retry():
|
||||
assert entered is False
|
||||
assert mgr.mode == 'poll'
|
||||
assert mgr.subscribed_hrefs == set()
|
||||
|
||||
|
||||
def test_check_sweep_for_misses_false_when_not_in_observe_mode():
|
||||
mgr = _manager()
|
||||
assert mgr.check_sweep_for_misses({'/oven/vs/0': {'a': 2}}) is False
|
||||
|
||||
|
||||
def test_check_sweep_for_misses_detects_mismatch():
|
||||
mgr = _manager()
|
||||
session = _FakeSession()
|
||||
href = '/oven/vs/0'
|
||||
|
||||
def _notify_during_grace_period():
|
||||
time.sleep(0.005)
|
||||
mgr.on_notification(href, cbor2.dumps({'a': 1}))
|
||||
|
||||
notifier = threading.Thread(target=_notify_during_grace_period, daemon=True)
|
||||
notifier.start()
|
||||
|
||||
mgr.try_enter_observe_mode(session, [href], grace_period_s=0.02)
|
||||
notifier.join()
|
||||
assert mgr.mode == 'observe'
|
||||
|
||||
# Sweep sees a value the cache never got via notify -> observe missed it.
|
||||
missed = mgr.check_sweep_for_misses({href: {'a': 2}})
|
||||
|
||||
assert missed is True
|
||||
|
||||
|
||||
def test_check_sweep_for_misses_ignores_href_during_settle_window():
|
||||
mgr = _manager()
|
||||
session = _FakeSession()
|
||||
href = '/oven/vs/0'
|
||||
mgr.on_notification(href, cbor2.dumps({'a': 1}))
|
||||
mgr.try_enter_observe_mode(session, [href], grace_period_s=0.01)
|
||||
mgr.mark_write_pending(href, settle_s=5.0)
|
||||
|
||||
missed = mgr.check_sweep_for_misses({href: {'a': 2}})
|
||||
|
||||
assert missed is False
|
||||
|
||||
|
||||
def test_downgrade_to_poll_moves_subscribed_to_fallback():
|
||||
mgr = _manager()
|
||||
session = _FakeSession()
|
||||
href = '/oven/vs/0'
|
||||
|
||||
def _notify_during_grace_period():
|
||||
time.sleep(0.005)
|
||||
mgr.on_notification(href, cbor2.dumps({'a': 1}))
|
||||
|
||||
notifier = threading.Thread(target=_notify_during_grace_period, daemon=True)
|
||||
notifier.start()
|
||||
|
||||
mgr.try_enter_observe_mode(session, [href], grace_period_s=0.02)
|
||||
notifier.join()
|
||||
|
||||
mgr.downgrade_to_poll()
|
||||
|
||||
assert mgr.mode == 'poll'
|
||||
assert mgr.subscribed_hrefs == set()
|
||||
assert mgr.fallback_hrefs == {href}
|
||||
|
||||
Reference in New Issue
Block a user