Reduce observation update latency

This commit is contained in:
2569718930@qq.com
2026-06-09 22:36:23 +08:00
parent 45397f1780
commit bd88f717fb
4 changed files with 124 additions and 3 deletions
+1 -1
View File
@@ -112,7 +112,7 @@ services:
POLYWEATHER_OBSERVATION_COLLECTOR_INITIAL_DELAY_SEC: ${POLYWEATHER_OBSERVATION_COLLECTOR_INITIAL_DELAY_SEC:-5} POLYWEATHER_OBSERVATION_COLLECTOR_INITIAL_DELAY_SEC: ${POLYWEATHER_OBSERVATION_COLLECTOR_INITIAL_DELAY_SEC:-5}
POLYWEATHER_OBSERVATION_COLLECTOR_MADIS_SEC: ${POLYWEATHER_OBSERVATION_COLLECTOR_MADIS_SEC:-300} POLYWEATHER_OBSERVATION_COLLECTOR_MADIS_SEC: ${POLYWEATHER_OBSERVATION_COLLECTOR_MADIS_SEC:-300}
POLYWEATHER_OBSERVATION_COLLECTOR_TICK_SEC: ${POLYWEATHER_OBSERVATION_COLLECTOR_TICK_SEC:-30} POLYWEATHER_OBSERVATION_COLLECTOR_TICK_SEC: ${POLYWEATHER_OBSERVATION_COLLECTOR_TICK_SEC:-30}
POLYWEATHER_SCAN_TERMINAL_PREWARM_ENABLED: 'false' POLYWEATHER_SCAN_TERMINAL_PREWARM_ENABLED: ${POLYWEATHER_SCAN_TERMINAL_PREWARM_ENABLED:-true}
POLYWEATHER_SERVICE_ROLE: web POLYWEATHER_SERVICE_ROLE: web
UVICORN_WORKERS: ${UVICORN_WORKERS:-2} UVICORN_WORKERS: ${UVICORN_WORKERS:-2}
healthcheck: healthcheck:
+5 -1
View File
@@ -91,7 +91,11 @@ def test_docker_compose_isolates_collector_from_web_and_bot_services():
assert "POLYWEATHER_SERVICE_ROLE: bot" in compose assert "POLYWEATHER_SERVICE_ROLE: bot" in compose
assert "POLYWEATHER_SERVICE_ROLE: collector" in collector_block assert "POLYWEATHER_SERVICE_ROLE: collector" in collector_block
assert "POLYWEATHER_SCAN_TERMINAL_PREWARM_ENABLED: 'false'" in bot_block assert "POLYWEATHER_SCAN_TERMINAL_PREWARM_ENABLED: 'false'" in bot_block
assert "POLYWEATHER_SCAN_TERMINAL_PREWARM_ENABLED: 'false'" in web_block assert (
"POLYWEATHER_SCAN_TERMINAL_PREWARM_ENABLED: "
"${POLYWEATHER_SCAN_TERMINAL_PREWARM_ENABLED:-true}"
in web_block
)
assert "POLYWEATHER_SCAN_TERMINAL_PREWARM_ENABLED: 'false'" in collector_block assert "POLYWEATHER_SCAN_TERMINAL_PREWARM_ENABLED: 'false'" in collector_block
assert "POLYWEATHER_OBSERVATION_COLLECTOR_ENABLED: 'false'" in bot_block assert "POLYWEATHER_OBSERVATION_COLLECTOR_ENABLED: 'false'" in bot_block
assert "POLYWEATHER_OBSERVATION_COLLECTOR_ENABLED: 'false'" in web_block assert "POLYWEATHER_OBSERVATION_COLLECTOR_ENABLED: 'false'" in web_block
+59 -1
View File
@@ -1,5 +1,6 @@
from concurrent.futures import ThreadPoolExecutor from concurrent.futures import ThreadPoolExecutor, TimeoutError
import sqlite3 import sqlite3
import threading
import time import time
@@ -119,6 +120,7 @@ def test_observation_collector_run_due_once_refreshes_panel_cache():
) )
], ],
cache_refresher=lambda city: refreshed.append(city), cache_refresher=lambda city: refreshed.append(city),
async_cache_refresh=False,
) )
assert collector.run_due_once(now_ts=1000.0) == 1 assert collector.run_due_once(now_ts=1000.0) == 1
@@ -133,6 +135,62 @@ def test_observation_collector_run_due_once_refreshes_panel_cache():
assert refreshed == ["qingdao", "qingdao"] assert refreshed == ["qingdao", "qingdao"]
def test_observation_collector_cache_refresh_does_not_block_source_polling():
from web.observation_collector_service import (
ObservationCollector,
ObservationSourceProfile,
)
calls = []
refresh_started = threading.Event()
release_refresh = threading.Event()
refreshed = []
class FakeWeather:
def _uses_fahrenheit(self, city):
return False
def _attach_china_amsc_awos_data(self, results, city, use_fahrenheit):
calls.append(city)
results["amsc_awos"] = {"source": "amsc_awos", "temp_c": 24.0}
def slow_cache_refresher(city):
refresh_started.set()
release_refresh.wait(timeout=2)
refreshed.append(city)
collector = ObservationCollector(
weather=FakeWeather(),
profiles=[
ObservationSourceProfile(
source="amsc_awos",
cities=("qingdao", "beijing"),
interval_sec=180,
)
],
cache_refresher=slow_cache_refresher,
)
with ThreadPoolExecutor(max_workers=1) as executor:
future = executor.submit(collector.run_due_once, now_ts=1000.0)
assert refresh_started.wait(timeout=1)
try:
try:
result = future.result(timeout=0.1)
except TimeoutError:
result = None
finally:
release_refresh.set()
assert result == 2
assert calls == ["qingdao", "beijing"]
deadline = time.time() + 2
while set(refreshed) != {"qingdao", "beijing"} and time.time() < deadline:
time.sleep(0.01)
assert set(refreshed) == {"qingdao", "beijing"}
collector.close()
def test_observation_collector_records_source_status_to_runtime_state(tmp_path): def test_observation_collector_records_source_status_to_runtime_state(tmp_path):
from src.database.runtime_state import ObservationCollectorStatusRepository, RuntimeStateDB from src.database.runtime_state import ObservationCollectorStatusRepository, RuntimeStateDB
from web.observation_collector_service import ( from web.observation_collector_service import (
+59
View File
@@ -5,6 +5,7 @@ from __future__ import annotations
import os import os
import threading import threading
import time import time
from concurrent.futures import ThreadPoolExecutor
from dataclasses import dataclass from dataclasses import dataclass
from typing import Any, Callable, Iterable, List, Optional, Sequence, Tuple from typing import Any, Callable, Iterable, List, Optional, Sequence, Tuple
@@ -53,6 +54,8 @@ class ObservationCollector:
profiles: Sequence[ObservationSourceProfile], profiles: Sequence[ObservationSourceProfile],
cache_refresher: Optional[Callable[[str], Any]] = None, cache_refresher: Optional[Callable[[str], Any]] = None,
status_recorder: Optional[ObservationCollectorStatusRepository] = None, status_recorder: Optional[ObservationCollectorStatusRepository] = None,
async_cache_refresh: Optional[bool] = None,
cache_refresh_workers: Optional[int] = None,
) -> None: ) -> None:
self.weather = weather self.weather = weather
self.profiles = list(profiles) self.profiles = list(profiles)
@@ -60,6 +63,29 @@ class ObservationCollector:
self.status_recorder = status_recorder self.status_recorder = status_recorder
self._last_run_ts: dict[tuple[str, str], float] = {} self._last_run_ts: dict[tuple[str, str], float] = {}
self._lock = threading.Lock() self._lock = threading.Lock()
self._cache_refresh_lock = threading.Lock()
self._cache_refresh_inflight: set[str] = set()
self._cache_refresh_async = (
_env_bool("POLYWEATHER_OBSERVATION_COLLECTOR_CACHE_REFRESH_ASYNC", True)
if async_cache_refresh is None
else bool(async_cache_refresh)
)
worker_count = max(
1,
min(
4,
int(
cache_refresh_workers
if cache_refresh_workers is not None
else _env_int("POLYWEATHER_OBSERVATION_COLLECTOR_CACHE_REFRESH_WORKERS", 1)
),
),
)
self._cache_refresh_executor: Optional[ThreadPoolExecutor] = (
ThreadPoolExecutor(max_workers=worker_count)
if callable(cache_refresher) and self._cache_refresh_async
else None
)
def run_due_once(self, *, now_ts: Optional[float] = None) -> int: def run_due_once(self, *, now_ts: Optional[float] = None) -> int:
now = float(time.time() if now_ts is None else now_ts) now = float(time.time() if now_ts is None else now_ts)
@@ -166,11 +192,44 @@ class ObservationCollector:
def _refresh_city_cache(self, city: str) -> None: def _refresh_city_cache(self, city: str) -> None:
if not callable(self.cache_refresher): if not callable(self.cache_refresher):
return return
normalized_city = str(city or "").strip().lower()
if not normalized_city:
return
if self._cache_refresh_executor is None:
self._refresh_city_cache_inline(normalized_city)
return
with self._cache_refresh_lock:
if normalized_city in self._cache_refresh_inflight:
return
self._cache_refresh_inflight.add(normalized_city)
try:
self._cache_refresh_executor.submit(self._refresh_city_cache_task, normalized_city)
except Exception as exc:
with self._cache_refresh_lock:
self._cache_refresh_inflight.discard(normalized_city)
logger.warning(
"observation collector cache refresh queue failed city={}: {}",
normalized_city,
exc,
)
def _refresh_city_cache_task(self, city: str) -> None:
try:
self._refresh_city_cache_inline(city)
finally:
with self._cache_refresh_lock:
self._cache_refresh_inflight.discard(city)
def _refresh_city_cache_inline(self, city: str) -> None:
try: try:
self.cache_refresher(city) self.cache_refresher(city)
except Exception as exc: except Exception as exc:
logger.warning("observation collector cache refresh failed city={}: {}", city, exc) logger.warning("observation collector cache refresh failed city={}: {}", city, exc)
def close(self) -> None:
if self._cache_refresh_executor is not None:
self._cache_refresh_executor.shutdown(wait=False)
def build_observation_source_profiles() -> List[ObservationSourceProfile]: def build_observation_source_profiles() -> List[ObservationSourceProfile]:
us_madis_cities = [ us_madis_cities = [