From 9b9ff4053ff18d78e9d10e4da5d28f9b59bb30a8 Mon Sep 17 00:00:00 2001 From: Rod Boev Date: Sat, 27 Jun 2026 09:24:44 -0400 Subject: [PATCH] fix(#5047): honor bounded refresh follower latency --- api/config.py | 32 ++++++++++--- ...t_issue4756_session_visit_model_refresh.py | 48 +++++++++++++++++++ 2 files changed, 74 insertions(+), 6 deletions(-) diff --git a/api/config.py b/api/config.py index 24f24df7..0d6b4d2a 100644 --- a/api/config.py +++ b/api/config.py @@ -6891,18 +6891,38 @@ def get_available_models(*, prefer_cache: bool = False, force_refresh: bool = Fa # of re-entering the cold path (avoids duplicate 10s zai load_pool calls). if should_wait: wait_timeout = 60.0 - if force_refresh and _LIVE_REBUILD_BUDGET_SECONDS <= 0: - # The legacy synchronous path is explicitly unbounded. A forced - # refresh follower should keep coalescing behind that live - # rebuild instead of giving up after 60s and duplicating it. - wait_timeout = None + if force_refresh and force_refresh_started_at is not None: + if _LIVE_REBUILD_BUDGET_SECONDS <= 0: + # The legacy synchronous path is explicitly unbounded. A + # forced refresh follower should keep coalescing behind + # that live rebuild instead of giving up after 60s and + # duplicating it. + wait_timeout = None + else: + wait_timeout = max( + 0.0, + _LIVE_REBUILD_BUDGET_SECONDS - (time.monotonic() - force_refresh_started_at), + ) _cache_build_cv.wait_for( lambda: not _cache_build_in_progress, timeout=wait_timeout ) cached = _get_fresh_memory_models_cache(time.monotonic()) - if cached is not None: + if ( + cached is not None + and ( + not force_refresh + or ( + force_refresh_started_at is not None + and _available_models_live_rebuild_ts >= force_refresh_started_at + ) + ) + ): return cached + if force_refresh and _LIVE_REBUILD_BUDGET_SECONDS > 0 and _cache_build_in_progress: + if stale_disk_groups is not None: + return copy.deepcopy(stale_disk_groups) + return copy.deepcopy(_static_models_catalog_without_live_probes()) # Reload config if changed if _cfg_changed: diff --git a/tests/test_issue4756_session_visit_model_refresh.py b/tests/test_issue4756_session_visit_model_refresh.py index d738a28f..88c131b5 100644 --- a/tests/test_issue4756_session_visit_model_refresh.py +++ b/tests/test_issue4756_session_visit_model_refresh.py @@ -288,6 +288,54 @@ def test_force_refresh_sync_followers_retry_after_failed_active_rebuild(tmp_path assert cfg._cache_build_in_progress is False +def test_force_refresh_bounded_followers_wait_only_remaining_budget(tmp_path, monkeypatch): + import api.config as cfg + import threading + + _reset_models_memory_cache(monkeypatch) + stale_catalog = _catalog("stale-model") + config_path = tmp_path / "config.yaml" + config_path.write_text("{}", encoding="utf-8") + cache_path = tmp_path / "models_cache.profile.json" + cache_path.write_text("{}", encoding="utf-8") + old = time.time() - 600.0 + os.utime(cache_path, (old, old)) + fingerprint = {"profile": "demo"} + timeout_waits = [] + original_wait_for = threading.Condition.wait_for + + monkeypatch.setattr(cfg, "_LIVE_REBUILD_BUDGET_SECONDS", 0.05, raising=False) + monkeypatch.setattr(cfg, "_get_config_path", lambda: config_path) + monkeypatch.setattr(cfg, "_cfg_path", config_path, raising=False) + monkeypatch.setattr(cfg, "_cfg_mtime", config_path.stat().st_mtime, raising=False) + monkeypatch.setattr(cfg, "_get_models_cache_path", lambda: cache_path) + monkeypatch.setattr(cfg, "_load_models_cache_from_disk", lambda: None) + monkeypatch.setattr(cfg, "_load_stale_models_cache_from_disk", lambda: stale_catalog) + monkeypatch.setattr(cfg, "_models_cache_source_fingerprint", lambda: fingerprint) + monkeypatch.setattr(cfg, "_save_models_cache_to_disk", lambda _cache: None) + + def _wait_for(self, predicate, timeout=None): + if self is not cfg._cache_build_cv: + return original_wait_for(self, predicate, timeout) + timeout_waits.append(timeout) + time.sleep(min(timeout or 0.0, 0.01)) + return False + + monkeypatch.setattr(cfg, "_cache_build_in_progress", True, raising=False) + monkeypatch.setattr(threading.Condition, "wait_for", _wait_for) + + started_at = time.monotonic() + result = cfg.get_available_models(force_refresh=True) + elapsed = time.monotonic() - started_at + + assert result == stale_catalog + assert len(timeout_waits) == 1 + assert timeout_waits[0] is not None + assert 0.0 <= timeout_waits[0] <= 0.05 + assert 60.0 not in timeout_waits + assert elapsed < 0.1 + + def test_session_visit_overlapping_stale_calls_do_not_duplicate_over_budget_rebuild(tmp_path, monkeypatch): import api.config as cfg import threading