From 5c65ee24d640777c91e0adaac6352521eac662e2 Mon Sep 17 00:00:00 2001 From: rayd1o Date: Mon, 18 May 2026 02:37:19 +0800 Subject: [PATCH] release: bump version to 0.61.0 --- VERSION | 2 +- backend/app/api/v1/layers.py | 4 +- backend/app/api/v1/system_control.py | 40 ++ backend/app/api/v1/vessels.py | 4 +- backend/app/api/v1/visualization.py | 240 ++++++++++- backend/app/services/collectors/base.py | 2 + backend/app/services/collectors/bgp_common.py | 3 + backend/app/services/earth_layer_cache.py | 392 ++++++++++++++++++ backend/tests/test_api.py | 71 ++++ backend/tests/test_earth_layer_cache.py | 254 ++++++++++++ docs/CHANGELOG.md | 16 + .../earth-layer-redis-cache-oom-guard-plan.md | 71 ++++ docs/version-history.md | 3 +- frontend/package.json | 2 +- pyproject.toml | 2 +- uv.lock | 2 +- 16 files changed, 1097 insertions(+), 11 deletions(-) create mode 100644 backend/app/services/earth_layer_cache.py create mode 100644 backend/tests/test_earth_layer_cache.py create mode 100644 docs/plans/earth-layer-redis-cache-oom-guard-plan.md diff --git a/VERSION b/VERSION index 7e750b4e..0b094550 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.60.0 +0.61.0 diff --git a/backend/app/api/v1/layers.py b/backend/app/api/v1/layers.py index 64bc518f..991ff5a2 100644 --- a/backend/app/api/v1/layers.py +++ b/backend/app/api/v1/layers.py @@ -1,6 +1,6 @@ from typing import Any, Optional -from fastapi import APIRouter, Depends, HTTPException, Query +from fastapi import APIRouter, Depends, HTTPException, Query, Response from sqlalchemy.ext.asyncio import AsyncSession from app.api.v1.visualization import ( @@ -110,6 +110,7 @@ async def get_vessel_layer_snapshot( vessel_type: Optional[str] = Query(None, alias="type"), since_minutes: int = Query(60, ge=1, le=1440), db: AsyncSession = Depends(get_db), + response: Response = None, ): parsed_bbox = _parse_layer_bbox(bbox) return await build_vessel_snapshot_response( @@ -119,6 +120,7 @@ async def get_vessel_layer_snapshot( limit=limit, type_filter=vessel_type, since_minutes=since_minutes, + response=response, ) diff --git a/backend/app/api/v1/system_control.py b/backend/app/api/v1/system_control.py index 254fe353..079b35ad 100644 --- a/backend/app/api/v1/system_control.py +++ b/backend/app/api/v1/system_control.py @@ -35,6 +35,7 @@ from app.services.system_logs import ( normalize_log_level, read_log_snapshot, ) +from app.services.earth_layer_cache import earth_layer_cache router = APIRouter() @@ -112,6 +113,17 @@ class EarthClientLogEventResponse(BaseModel): level: str +class EarthLayerCacheStatusResponse(BaseModel): + prefix: str + key_count: int + memory_bytes: int + layers: dict[str, dict[str, int]] + + +class EarthLayerCacheClearResponse(BaseModel): + deleted: int + + def ensure_super_admin(current_user: User) -> None: if not require_super_admin(current_user.role): raise HTTPException( @@ -132,6 +144,34 @@ def validate_log_date(raw_value: str | None, field_name: str) -> str | None: ) from exc +@router.get("/cache/earth-layers", response_model=EarthLayerCacheStatusResponse) +async def get_earth_layer_cache_status( + current_user: User = Depends(get_current_user), +): + ensure_super_admin(current_user) + try: + return earth_layer_cache.status() + except Exception as exc: + raise HTTPException( + status_code=status.HTTP_503_SERVICE_UNAVAILABLE, + detail=f"Unable to read Earth layer cache status: {exc}", + ) from exc + + +@router.delete("/cache/earth-layers", response_model=EarthLayerCacheClearResponse) +async def clear_earth_layer_cache( + current_user: User = Depends(get_current_user), +): + ensure_super_admin(current_user) + try: + return {"deleted": earth_layer_cache.delete_pattern()} + except Exception as exc: + raise HTTPException( + status_code=status.HTTP_503_SERVICE_UNAVAILABLE, + detail=f"Unable to clear Earth layer cache: {exc}", + ) from exc + + @router.post("/restart-tasks", response_model=RestartTaskResponse) async def create_restart_task( payload: RestartTaskCreate, diff --git a/backend/app/api/v1/vessels.py b/backend/app/api/v1/vessels.py index 091ca283..2779e440 100644 --- a/backend/app/api/v1/vessels.py +++ b/backend/app/api/v1/vessels.py @@ -2,7 +2,7 @@ from typing import Optional -from fastapi import APIRouter, Depends, HTTPException, Query +from fastapi import APIRouter, Depends, HTTPException, Query, Response from sqlalchemy.ext.asyncio import AsyncSession from app.api.v1.visualization import _parse_bbox, build_vessel_snapshot_response @@ -23,6 +23,7 @@ async def get_vessel_snapshot( limit: int = Query(1000, ge=1, le=MAX_SNAPSHOT_LIMIT), since_minutes: int = Query(60, ge=1, le=1440), db: AsyncSession = Depends(get_db), + response: Response = None, ): if not bbox: raise HTTPException(status_code=400, detail="bbox is required") @@ -36,4 +37,5 @@ async def get_vessel_snapshot( type_filter=type, limit=limit, since_minutes=since_minutes, + response=response, ) diff --git a/backend/app/api/v1/visualization.py b/backend/app/api/v1/visualization.py index c6e6abc9..8616ced0 100644 --- a/backend/app/api/v1/visualization.py +++ b/backend/app/api/v1/visualization.py @@ -56,6 +56,13 @@ from app.services.vessel_ais_aggregation import ( get_vessel_raw_observations, MAX_SNAPSHOT_LIMIT, ) +from app.services.earth_layer_cache import ( + EarthLayerCachePolicy, + earth_layer_cache, + format_bbox_key, + get_or_build_layer_payload, + quantize_bbox, +) from app.core.logging import get_logger router = APIRouter() @@ -69,6 +76,68 @@ TERRAIN_TILE_BATCH_CONCURRENCY = 16 _terrain_tile_cache: OrderedDict[tuple[int, int, int], tuple[bytes, str, dict[str, str]]] = OrderedDict() VESSEL_NAME_FALLBACK_PATTERN = re.compile(r"^mmsi\s*\d+$", re.IGNORECASE) VESSEL_SNAPSHOT_LEGACY_FALLBACK_ENABLED = True +SECONDS_PER_MINUTE = 60 +BYTES_PER_MIB = 1024 * 1024 +CABLE_CACHE_FRESH_SECONDS = 6 * 60 * SECONDS_PER_MINUTE +CABLE_CACHE_STALE_SECONDS = 24 * 60 * SECONDS_PER_MINUTE +SATELLITE_CACHE_FRESH_SECONDS = 15 * SECONDS_PER_MINUTE +SATELLITE_CACHE_STALE_SECONDS = 2 * 60 * SECONDS_PER_MINUTE +COMPUTE_CENTER_CACHE_FRESH_SECONDS = 10 * SECONDS_PER_MINUTE +COMPUTE_CENTER_CACHE_STALE_SECONDS = 60 * SECONDS_PER_MINUTE +BGP_CACHE_FRESH_SECONDS = 60 +BGP_EVENT_CACHE_FRESH_SECONDS = 30 +BGP_CACHE_STALE_SECONDS = 10 * SECONDS_PER_MINUTE +VESSEL_SNAPSHOT_CACHE_FRESH_SECONDS = 5 +VESSEL_SNAPSHOT_CACHE_STALE_SECONDS = 30 + +CABLE_CACHE_POLICY = EarthLayerCachePolicy( + CABLE_CACHE_FRESH_SECONDS, + CABLE_CACHE_STALE_SECONDS, + max_features=6000, + max_bytes=10 * BYTES_PER_MIB, +) +LANDING_POINT_CACHE_POLICY = EarthLayerCachePolicy( + CABLE_CACHE_FRESH_SECONDS, + CABLE_CACHE_STALE_SECONDS, + max_features=6000, + max_bytes=8 * BYTES_PER_MIB, +) +SATELLITE_CACHE_POLICY = EarthLayerCachePolicy( + SATELLITE_CACHE_FRESH_SECONDS, + SATELLITE_CACHE_STALE_SECONDS, + max_features=8000, + max_bytes=10 * BYTES_PER_MIB, +) +COMPUTE_CENTER_CACHE_POLICY = EarthLayerCachePolicy( + COMPUTE_CENTER_CACHE_FRESH_SECONDS, + COMPUTE_CENTER_CACHE_STALE_SECONDS, + max_features=1000, + max_bytes=4 * BYTES_PER_MIB, +) +BGP_CACHE_POLICY = EarthLayerCachePolicy( + BGP_CACHE_FRESH_SECONDS, + BGP_CACHE_STALE_SECONDS, + max_features=1000, + max_bytes=3 * BYTES_PER_MIB, +) +BGP_EVENT_CACHE_POLICY = EarthLayerCachePolicy( + BGP_EVENT_CACHE_FRESH_SECONDS, + BGP_CACHE_STALE_SECONDS, + max_features=1000, + max_bytes=3 * BYTES_PER_MIB, +) +SUMMARY_CACHE_POLICY = EarthLayerCachePolicy( + BGP_EVENT_CACHE_FRESH_SECONDS, + BGP_CACHE_STALE_SECONDS, + max_features=0, + max_bytes=512 * 1024, +) +VESSEL_SNAPSHOT_CACHE_POLICY = EarthLayerCachePolicy( + VESSEL_SNAPSHOT_CACHE_FRESH_SECONDS, + VESSEL_SNAPSHOT_CACHE_STALE_SECONDS, + max_features=1500, + max_bytes=3 * BYTES_PER_MIB, +) class TerrariumTileRequest(BaseModel): @@ -1010,7 +1079,40 @@ async def build_vessel_snapshot_response( type_filter: str | None, limit: int | None, since_minutes: int = 60, + response: Response | None = None, + use_cache: bool = True, ) -> dict[str, Any]: + if use_cache and bbox is not None: + safe_limit_for_key = _safe_vessel_limit(limit) + safe_since_for_key = min(max(int(since_minutes or 60), 1), 1440) + cache_key = earth_layer_cache.key( + "vessels-snapshot", + bbox=format_bbox_key(quantize_bbox(bbox)), + zoom=zoom or "none", + type=type_filter or "all", + limit=safe_limit_for_key, + since=safe_since_for_key, + ) + + async def build_uncached() -> dict[str, Any]: + return await build_vessel_snapshot_response( + db, + bbox=bbox, + zoom=zoom, + type_filter=type_filter, + limit=limit, + since_minutes=since_minutes, + response=None, + use_cache=False, + ) + + return await get_or_build_layer_payload( + key=cache_key, + policy=VESSEL_SNAPSHOT_CACHE_POLICY, + builder=build_uncached, + response=response, + ) + requested_types = _requested_vessel_types(type_filter) safe_limit = _safe_vessel_limit(limit) safe_since_minutes = min(max(int(since_minutes or 60), 1), 1440) @@ -1444,8 +1546,20 @@ def convert_bgp_incidents_to_geojson( @router.get("/geo/cables") -async def get_cables_geojson(db: AsyncSession = Depends(get_db)): +async def get_cables_geojson(db: AsyncSession = Depends(get_db), response: Response = None): """获取海底电缆 GeoJSON 数据 (LineString)""" + async def build_payload() -> dict[str, Any]: + return await _build_cables_geojson(db) + + return await get_or_build_layer_payload( + key=earth_layer_cache.key("cables"), + policy=CABLE_CACHE_POLICY, + builder=build_payload, + response=response, + ) + + +async def _build_cables_geojson(db: AsyncSession) -> dict[str, Any]: try: records = await _load_current_collected_data(db, "arcgis_cables") @@ -1478,7 +1592,19 @@ async def get_cables_geojson(db: AsyncSession = Depends(get_db)): @router.get("/geo/landing-points") -async def get_landing_points_geojson(db: AsyncSession = Depends(get_db)): +async def get_landing_points_geojson(db: AsyncSession = Depends(get_db), response: Response = None): + async def build_payload() -> dict[str, Any]: + return await _build_landing_points_geojson(db) + + return await get_or_build_layer_payload( + key=earth_layer_cache.key("landing-points"), + policy=LANDING_POINT_CACHE_POLICY, + builder=build_payload, + response=response, + ) + + +async def _build_landing_points_geojson(db: AsyncSession) -> dict[str, Any]: try: records_by_source = await _load_current_collected_data_by_sources( db, @@ -1731,8 +1857,25 @@ async def get_satellites_geojson( description="Maximum number of satellites to return. Omit for no limit.", ), db: AsyncSession = Depends(get_db), + response: Response = None, ): """获取卫星 TLE GeoJSON 数据""" + async def build_payload() -> dict[str, Any]: + return await _build_satellites_geojson(limit=limit, db=db) + + return await get_or_build_layer_payload( + key=earth_layer_cache.key("satellites", limit=limit or "all"), + policy=SATELLITE_CACHE_POLICY, + builder=build_payload, + response=response, + ) + + +async def _build_satellites_geojson( + *, + limit: int | None, + db: AsyncSession, +) -> dict[str, Any]: records = await _load_current_or_latest_task_data( db, "celestrak_tle", @@ -1800,8 +1943,25 @@ async def get_gpu_clusters_geojson( async def get_compute_centers_geojson( limit: int = Query(200, ge=1, le=1000), db: AsyncSession = Depends(get_db), + response: Response = None, ): """获取统一算力中心 GeoJSON 数据""" + async def build_payload() -> dict[str, Any]: + return await _build_compute_centers_geojson(limit=limit, db=db) + + return await get_or_build_layer_payload( + key=earth_layer_cache.key("compute-centers", limit=limit), + policy=COMPUTE_CENTER_CACHE_POLICY, + builder=build_payload, + response=response, + ) + + +async def _build_compute_centers_geojson( + *, + limit: int, + db: AsyncSession, +) -> dict[str, Any]: records_by_source = await _load_current_collected_data_by_sources( db, ["top500", "epoch_ai_gpu"], @@ -2397,7 +2557,31 @@ async def get_bgp_anomalies_geojson( status: Optional[str] = Query("active"), limit: int = Query(200, ge=1, le=1000), db: AsyncSession = Depends(get_db), + response: Response = None, ): + async def build_payload() -> dict[str, Any]: + return await _build_bgp_anomalies_geojson( + severity=severity, + status=status, + limit=limit, + db=db, + ) + + return await get_or_build_layer_payload( + key=earth_layer_cache.key("bgp-anomalies", severity=severity or "all", status=status or "all", limit=limit), + policy=BGP_EVENT_CACHE_POLICY, + builder=build_payload, + response=response, + ) + + +async def _build_bgp_anomalies_geojson( + *, + severity: str | None, + status: str | None, + limit: int, + db: AsyncSession, +) -> dict[str, Any]: stmt = select(BGPAnomaly).order_by(BGPAnomaly.created_at.desc()).limit(limit) if severity: stmt = stmt.where(BGPAnomaly.severity == severity) @@ -2417,7 +2601,31 @@ async def get_bgp_incidents_geojson( status: Optional[str] = Query("active"), limit: int = Query(100, ge=1, le=500), db: AsyncSession = Depends(get_db), + response: Response = None, ): + async def build_payload() -> dict[str, Any]: + return await _build_bgp_incidents_geojson( + severity=severity, + status=status, + limit=limit, + db=db, + ) + + return await get_or_build_layer_payload( + key=earth_layer_cache.key("bgp-incidents", severity=severity or "all", status=status or "all", limit=limit), + policy=BGP_EVENT_CACHE_POLICY, + builder=build_payload, + response=response, + ) + + +async def _build_bgp_incidents_geojson( + *, + severity: str | None, + status: str | None, + limit: int, + db: AsyncSession, +) -> dict[str, Any]: stmt = select(BGPIncident).order_by(BGPIncident.created_at.desc()).limit(limit) if severity: stmt = stmt.where(BGPIncident.severity == severity) @@ -2432,7 +2640,19 @@ async def get_bgp_incidents_geojson( @router.get("/geo/bgp-collectors") -async def get_bgp_collectors_geojson(db: AsyncSession = Depends(get_db)): +async def get_bgp_collectors_geojson(db: AsyncSession = Depends(get_db), response: Response = None): + async def build_payload() -> dict[str, Any]: + return await _build_bgp_collectors_geojson(db) + + return await get_or_build_layer_payload( + key=earth_layer_cache.key("bgp-collectors"), + policy=BGP_CACHE_POLICY, + builder=build_payload, + response=response, + ) + + +async def _build_bgp_collectors_geojson(db: AsyncSession) -> dict[str, Any]: coverage = await build_bgp_collector_coverage( db, source_filter=("ris_live_bgp", "bgpstream_bgp"), @@ -2447,8 +2667,20 @@ async def get_bgp_collectors_geojson(db: AsyncSession = Depends(get_db)): @router.get("/geo/summary") -async def get_visualization_geo_summary(db: AsyncSession = Depends(get_db)): +async def get_visualization_geo_summary(db: AsyncSession = Depends(get_db), response: Response = None): """Return lightweight Earth HUD counts without loading layer GeoJSON payloads.""" + async def build_payload() -> dict[str, Any]: + return await _build_visualization_geo_summary(db) + + return await get_or_build_layer_payload( + key=earth_layer_cache.key("summary"), + policy=SUMMARY_CACHE_POLICY, + builder=build_payload, + response=response, + ) + + +async def _build_visualization_geo_summary(db: AsyncSession) -> dict[str, Any]: cable_count = await _count_current_or_latest_task_data(db, "arcgis_cables") landing_point_count = await _count_current_or_latest_task_data(db, "arcgis_landing_points") satellite_count = await _count_current_or_latest_task_data( diff --git a/backend/app/services/collectors/base.py b/backend/app/services/collectors/base.py index f1d0285d..b9695d3b 100644 --- a/backend/app/services/collectors/base.py +++ b/backend/app/services/collectors/base.py @@ -13,6 +13,7 @@ from app.core.config import settings from app.core.countries import normalize_country from app.core.time import to_iso8601_utc from app.core.websocket.broadcaster import broadcaster +from app.services.earth_layer_cache import invalidate_earth_layer_cache_for_source class BaseCollector(ABC): @@ -531,6 +532,7 @@ class BaseCollector(ABC): } await db.commit() + invalidate_earth_layer_cache_for_source(self.name) await self.update_progress(len(data), force=True) return records_added diff --git a/backend/app/services/collectors/bgp_common.py b/backend/app/services/collectors/bgp_common.py index b4cbf413..9399cb63 100644 --- a/backend/app/services/collectors/bgp_common.py +++ b/backend/app/services/collectors/bgp_common.py @@ -19,6 +19,7 @@ from app.services.bgp_collector_locations import ( ) from app.services.bgp_event_locations import resolve_bgp_event_geo_dict from app.services.bgp_incidents import create_bgp_incidents_for_anomalies +from app.services.earth_layer_cache import invalidate_earth_layer_cache_for_source from app.services.bgp_detectors import ( detect_mass_withdrawal_anomalies, detect_more_specific_burst_anomalies, @@ -223,6 +224,8 @@ async def save_bgp_observations_for_batch( if created: await db.commit() + for source in {"ris_live_bgp", "bgpstream_bgp"}: + invalidate_earth_layer_cache_for_source(source) return created diff --git a/backend/app/services/earth_layer_cache.py b/backend/app/services/earth_layer_cache.py new file mode 100644 index 00000000..1d9dc612 --- /dev/null +++ b/backend/app/services/earth_layer_cache.py @@ -0,0 +1,392 @@ +from __future__ import annotations + +import asyncio +from collections.abc import Awaitable, Callable +from dataclasses import dataclass +import hashlib +import json +import time +from typing import Any + +from fastapi import Response + +from app.core.cache import _RedisClient +from app.core.config import settings +from app.core.logging import get_logger + + +logger = get_logger(__name__, service="earth_layer_cache") + +EARTH_LAYER_CACHE_PREFIX = "earth:layer:v1" +EARTH_LAYER_LOCK_PREFIX = "earth:layer:lock:v1" +DEFAULT_LOCK_TTL_SECONDS = 10 +DEFAULT_LOCK_WAIT_SECONDS = 0.2 +DEFAULT_MAX_FEATURES = 5000 +DEFAULT_MAX_BYTES = 5 * 1024 * 1024 +DEFAULT_BBOX_PRECISION_DEGREES = 0.1 +DEV_CACHE_KEY_HEADER = {"development", "dev", "test", "testing", "local"} + + +@dataclass(frozen=True) +class EarthLayerCachePolicy: + fresh_ttl_seconds: int + stale_ttl_seconds: int + max_features: int = DEFAULT_MAX_FEATURES + max_bytes: int = DEFAULT_MAX_BYTES + lock_ttl_seconds: int = DEFAULT_LOCK_TTL_SECONDS + lock_wait_seconds: float = DEFAULT_LOCK_WAIT_SECONDS + + +@dataclass(frozen=True) +class EarthLayerCacheResult: + payload: dict[str, Any] + state: str + key: str + features: int + bytes: int + + +class EarthLayerCache: + def __init__(self) -> None: + self._client = None + + @property + def client(self): + if self._client is None: + self._client = _RedisClient.get_client() + return self._client + + @staticmethod + def key(layer: str, **params: Any) -> str: + parts = [EARTH_LAYER_CACHE_PREFIX, _safe_key_part(layer)] + for name in sorted(params): + value = params[name] + if value is None: + value = "none" + parts.append(f"{_safe_key_part(name)}:{_safe_key_part(value)}") + return ":".join(parts) + + @staticmethod + def stale_key(key: str) -> str: + return f"{key}:stale" + + @staticmethod + def lock_key(key: str) -> str: + digest = hashlib.sha256(key.encode("utf-8")).hexdigest()[:32] + return f"{EARTH_LAYER_LOCK_PREFIX}:{digest}" + + def get_json(self, key: str) -> dict[str, Any] | None: + raw = self.client.get(key) + if not raw: + return None + value = json.loads(raw) + return value if isinstance(value, dict) else None + + def set_json(self, key: str, payload: dict[str, Any], ttl_seconds: int) -> None: + self.client.setex(key, ttl_seconds, json.dumps(payload, ensure_ascii=False, default=str)) + + def acquire_lock(self, key: str, ttl_seconds: int) -> bool: + return bool(self.client.set(self.lock_key(key), "1", nx=True, ex=ttl_seconds)) + + def release_lock(self, key: str) -> None: + try: + self.client.delete(self.lock_key(key)) + except Exception: + pass + + def delete_pattern(self, pattern: str = f"{EARTH_LAYER_CACHE_PREFIX}:*") -> int: + keys = list(self.client.scan_iter(match=pattern)) + if not keys: + return 0 + return int(self.client.delete(*keys)) + + def status(self) -> dict[str, Any]: + keys = list(self.client.scan_iter(match=f"{EARTH_LAYER_CACHE_PREFIX}:*")) + by_layer: dict[str, dict[str, Any]] = {} + total_memory = 0 + for key in keys: + key_str = key.decode("utf-8") if isinstance(key, bytes) else str(key) + layer = _layer_from_key(key_str) + entry = by_layer.setdefault(layer, {"keys": 0, "stale_keys": 0, "memory_bytes": 0}) + entry["keys"] += 1 + if key_str.endswith(":stale"): + entry["stale_keys"] += 1 + try: + memory = int(self.client.memory_usage(key) or 0) + except Exception: + memory = 0 + entry["memory_bytes"] += memory + total_memory += memory + return { + "prefix": EARTH_LAYER_CACHE_PREFIX, + "key_count": len(keys), + "memory_bytes": total_memory, + "layers": by_layer, + } + + +earth_layer_cache = EarthLayerCache() + + +def quantize_bbox( + bbox: tuple[float, float, float, float], + *, + precision: float = DEFAULT_BBOX_PRECISION_DEGREES, +) -> tuple[float, float, float, float]: + return tuple(round(value / precision) * precision for value in bbox) # type: ignore[return-value] + + +def format_bbox_key(bbox: tuple[float, float, float, float]) -> str: + return ",".join(f"{value:.1f}" for value in bbox) + + +def apply_cache_headers(response: Response | None, result: EarthLayerCacheResult) -> None: + if response is None: + return + response.headers["X-Planet-Cache"] = result.state + response.headers["X-Planet-Cache-Features"] = str(result.features) + response.headers["X-Planet-Cache-Bytes"] = str(result.bytes) + env_name = str(getattr(settings, "ENVIRONMENT", "") or "development").lower() + if env_name in DEV_CACHE_KEY_HEADER: + response.headers["X-Planet-Cache-Key"] = result.key + + +async def get_or_build_layer_payload( + *, + key: str, + policy: EarthLayerCachePolicy, + builder: Callable[[], Awaitable[dict[str, Any]]], + response: Response | None = None, +) -> dict[str, Any]: + result = await resolve_layer_payload(key=key, policy=policy, builder=builder) + apply_cache_headers(response, result) + return result.payload + + +async def resolve_layer_payload( + *, + key: str, + policy: EarthLayerCachePolicy, + builder: Callable[[], Awaitable[dict[str, Any]]], +) -> EarthLayerCacheResult: + started = time.perf_counter() + try: + cached = earth_layer_cache.get_json(key) + if cached is not None: + return _result(cached, state="hit", key=key) + + lock_acquired = earth_layer_cache.acquire_lock(key, policy.lock_ttl_seconds) + if lock_acquired: + try: + payload = await _build_budgeted_payload(builder, policy) + _write_fresh_and_stale(key, payload, policy) + _log_cache_event("refresh", key, payload, started) + return _result(payload, state="refresh", key=key) + except Exception as exc: + stale = _read_stale(key) + if stale is not None: + logger.warning_event( + "Earth layer cache builder failed; returning stale payload", + event="earth_layer_cache.stale_after_builder_error", + context={"key": key, "error": str(exc)}, + ) + return _result(stale, state="stale", key=key) + raise + finally: + earth_layer_cache.release_lock(key) + + stale = _read_stale(key) + if stale is not None: + return _result(stale, state="stale", key=key) + + await asyncio.sleep(policy.lock_wait_seconds) + cached_after_wait = earth_layer_cache.get_json(key) + if cached_after_wait is not None: + return _result(cached_after_wait, state="hit", key=key) + + payload = await _build_budgeted_payload(builder, policy) + _log_cache_event("miss", key, payload, started) + return _result(payload, state="miss", key=key) + except Exception as exc: + try: + payload = await _build_budgeted_payload(builder, policy) + except Exception: + raise exc + logger.warning_event( + "Earth layer cache bypassed", + event="earth_layer_cache.bypass", + context={"key": key, "error": str(exc)}, + ) + return _result(payload, state="bypass", key=key) + + +def apply_payload_budget(payload: dict[str, Any], policy: EarthLayerCachePolicy) -> dict[str, Any]: + budgeted = _truncate_features(payload, policy.max_features, "feature_budget") + size = _payload_size(budgeted) + if size <= policy.max_bytes: + return budgeted + + features = budgeted.get("features") + if not isinstance(features, list): + return _with_budget_diagnostics( + budgeted, + truncated=True, + reason="byte_budget", + bytes_before=size, + bytes_after=size, + ) + + low = 0 + high = len(features) + best = [] + best_size = _payload_size({**budgeted, "features": best}) + while low <= high: + mid = (low + high) // 2 + candidate_features = features[:mid] + candidate = _with_budget_diagnostics( + {**budgeted, "features": candidate_features}, + truncated=mid < len(features), + reason="byte_budget", + bytes_before=size, + bytes_after=0, + ) + candidate_size = _payload_size(candidate) + if candidate_size <= policy.max_bytes: + best = candidate_features + best_size = candidate_size + low = mid + 1 + else: + high = mid - 1 + + return _with_budget_diagnostics( + {**budgeted, "features": best}, + truncated=True, + reason="byte_budget", + bytes_before=size, + bytes_after=best_size, + ) + + +def invalidate_earth_layer_cache_for_source(source: str) -> int: + source_key = str(source or "").strip() + patterns = { + "arcgis_cables": ["cables*", "landing-points*", "summary*"], + "arcgis_landing_points": ["landing-points*", "summary*"], + "arcgis_cable_landing_relation": ["landing-points*", "summary*"], + "celestrak_tle": ["satellites*", "summary*"], + "spacetrack_tle": ["satellites*", "summary*"], + "top500": ["compute-centers*", "summary*"], + "epoch_ai_gpu": ["compute-centers*", "summary*"], + "ris_live_bgp": ["bgp*", "summary*"], + "bgpstream_bgp": ["bgp*", "summary*"], + }.get(source_key, []) + deleted = 0 + for layer_pattern in patterns: + deleted += earth_layer_cache.delete_pattern(f"{EARTH_LAYER_CACHE_PREFIX}:{layer_pattern}") + return deleted + + +async def _build_budgeted_payload( + builder: Callable[[], Awaitable[dict[str, Any]]], + policy: EarthLayerCachePolicy, +) -> dict[str, Any]: + payload = await builder() + return apply_payload_budget(payload, policy) + + +def _write_fresh_and_stale(key: str, payload: dict[str, Any], policy: EarthLayerCachePolicy) -> None: + earth_layer_cache.set_json(key, payload, policy.fresh_ttl_seconds) + earth_layer_cache.set_json(earth_layer_cache.stale_key(key), payload, policy.stale_ttl_seconds) + + +def _read_stale(key: str) -> dict[str, Any] | None: + try: + return earth_layer_cache.get_json(earth_layer_cache.stale_key(key)) + except Exception: + return None + + +def _truncate_features(payload: dict[str, Any], max_features: int, reason: str) -> dict[str, Any]: + features = payload.get("features") + if not isinstance(features, list) or len(features) <= max_features: + return payload + return _with_budget_diagnostics( + {**payload, "features": features[:max_features]}, + truncated=True, + reason=reason, + original_feature_count=len(features), + ) + + +def _with_budget_diagnostics( + payload: dict[str, Any], + *, + truncated: bool, + reason: str, + original_feature_count: int | None = None, + bytes_before: int | None = None, + bytes_after: int | None = None, +) -> dict[str, Any]: + diagnostics = dict(payload.get("diagnostics") or {}) + diagnostics.update( + { + "truncated": bool(truncated or diagnostics.get("truncated")), + "limit_reason": reason, + } + ) + if original_feature_count is not None: + diagnostics["original_feature_count"] = original_feature_count + if bytes_before is not None: + diagnostics["bytes_before_budget"] = bytes_before + if bytes_after is not None: + diagnostics["bytes_after_budget"] = bytes_after + return {**payload, "diagnostics": diagnostics} + + +def _result(payload: dict[str, Any], *, state: str, key: str) -> EarthLayerCacheResult: + return EarthLayerCacheResult( + payload=payload, + state=state, + key=key, + features=_feature_count(payload), + bytes=_payload_size(payload), + ) + + +def _feature_count(payload: dict[str, Any]) -> int: + features = payload.get("features") + if isinstance(features, list): + return len(features) + count = payload.get("count") + return int(count) if isinstance(count, int) else 0 + + +def _payload_size(payload: dict[str, Any]) -> int: + return len(json.dumps(payload, ensure_ascii=False, default=str).encode("utf-8")) + + +def _safe_key_part(value: Any) -> str: + raw = str(value).strip().lower() + return "".join(char if char.isalnum() or char in {"-", "_", ".", ","} else "_" for char in raw)[:160] + + +def _layer_from_key(key: str) -> str: + prefix = f"{EARTH_LAYER_CACHE_PREFIX}:" + if not key.startswith(prefix): + return "unknown" + remainder = key[len(prefix):] + return remainder.split(":", 1)[0] + + +def _log_cache_event(state: str, key: str, payload: dict[str, Any], started: float) -> None: + logger.info_event( + "Earth layer cache resolved", + event="earth_layer_cache.resolved", + context={ + "state": state, + "key": key, + "features": _feature_count(payload), + "bytes": _payload_size(payload), + "duration_ms": round((time.perf_counter() - started) * 1000, 2), + }, + ) diff --git a/backend/tests/test_api.py b/backend/tests/test_api.py index f39f950f..7f5963ac 100644 --- a/backend/tests/test_api.py +++ b/backend/tests/test_api.py @@ -672,6 +672,77 @@ async def test_ingest_earth_client_log_accepts_public_events(): app.dependency_overrides.clear() +@pytest.mark.asyncio +async def test_earth_layer_cache_status_requires_super_admin(auth_headers, monkeypatch): + def override_get_current_user(): + return User( + id=1, + username="root", + email="root@example.com", + password_hash="hashed", + role="super_admin", + is_active=True, + ) + + monkeypatch.setattr( + "app.api.v1.system_control.earth_layer_cache.status", + lambda: { + "prefix": "earth:layer:v1", + "key_count": 2, + "memory_bytes": 42, + "layers": {"cables": {"keys": 2, "stale_keys": 1, "memory_bytes": 42}}, + }, + ) + + app.dependency_overrides = { + __import__("app.core.security", fromlist=["get_current_user"]).get_current_user: override_get_current_user, + } + transport = ASGITransport(app=app) + try: + async with AsyncClient(transport=transport, base_url="http://test") as client: + response = await client.get("/api/v1/system/cache/earth-layers", headers=auth_headers) + assert response.status_code == 200 + data = response.json() + assert data["prefix"] == "earth:layer:v1" + assert data["layers"]["cables"]["stale_keys"] == 1 + finally: + app.dependency_overrides.clear() + + +@pytest.mark.asyncio +async def test_clear_earth_layer_cache_deletes_only_earth_layer_prefix(auth_headers, monkeypatch): + def override_get_current_user(): + return User( + id=1, + username="root", + email="root@example.com", + password_hash="hashed", + role="super_admin", + is_active=True, + ) + + captured = {} + + def fake_delete_pattern(pattern="earth:layer:v1:*"): + captured["pattern"] = pattern + return 3 + + monkeypatch.setattr("app.api.v1.system_control.earth_layer_cache.delete_pattern", fake_delete_pattern) + + app.dependency_overrides = { + __import__("app.core.security", fromlist=["get_current_user"]).get_current_user: override_get_current_user, + } + transport = ASGITransport(app=app) + try: + async with AsyncClient(transport=transport, base_url="http://test") as client: + response = await client.delete("/api/v1/system/cache/earth-layers", headers=auth_headers) + assert response.status_code == 200 + assert response.json()["deleted"] == 3 + assert captured["pattern"] == "earth:layer:v1:*" + finally: + app.dependency_overrides.clear() + + @pytest.mark.asyncio async def test_request_id_header_is_echoed_when_provided(): transport = ASGITransport(app=app) diff --git a/backend/tests/test_earth_layer_cache.py b/backend/tests/test_earth_layer_cache.py new file mode 100644 index 00000000..71528be9 --- /dev/null +++ b/backend/tests/test_earth_layer_cache.py @@ -0,0 +1,254 @@ +from __future__ import annotations + +import json + +import pytest +from fastapi import Response + +from app.api.v1 import visualization +from app.services.earth_layer_cache import ( + EarthLayerCachePolicy, + apply_payload_budget, + earth_layer_cache, + format_bbox_key, + quantize_bbox, + resolve_layer_payload, +) + + +class FakeRedis: + def __init__(self, *, fail: bool = False) -> None: + self.store: dict[str, str] = {} + self.fail = fail + self.lock_claimed = False + + def _maybe_fail(self) -> None: + if self.fail: + raise RuntimeError("redis unavailable") + + def get(self, key: str): + self._maybe_fail() + return self.store.get(key) + + def set(self, key: str, value: str, nx: bool = False, ex: int | None = None): + self._maybe_fail() + if nx and key in self.store: + return False + self.store[key] = value + return True + + def setex(self, key: str, _seconds: int, value: str): + self._maybe_fail() + self.store[key] = value + return True + + def delete(self, *keys: str): + self._maybe_fail() + deleted = 0 + for key in keys: + deleted += 1 if self.store.pop(key, None) is not None else 0 + return deleted + + def scan_iter(self, match: str): + self._maybe_fail() + prefix = match.rstrip("*") + for key in list(self.store): + if key.startswith(prefix): + yield key + + def memory_usage(self, key: str): + value = self.store.get(key, "") + return len(value.encode("utf-8")) + + +@pytest.fixture(autouse=True) +def fake_cache_client(): + previous = earth_layer_cache._client + fake = FakeRedis() + earth_layer_cache._client = fake + try: + yield fake + finally: + earth_layer_cache._client = previous + + +def test_quantized_bbox_key_is_stable_for_small_movements(): + first = format_bbox_key(quantize_bbox((10.01, 59.04, 10.96, 60.02))) + second = format_bbox_key(quantize_bbox((10.04, 59.01, 10.99, 60.04))) + + assert first == second + assert first == "10.0,59.0,11.0,60.0" + + +def test_payload_budget_truncates_features(): + payload = { + "type": "FeatureCollection", + "features": [{"id": index} for index in range(5)], + } + policy = EarthLayerCachePolicy(60, 120, max_features=2, max_bytes=1024) + + result = apply_payload_budget(payload, policy) + + assert len(result["features"]) == 2 + assert result["diagnostics"]["truncated"] is True + assert result["diagnostics"]["limit_reason"] == "feature_budget" + assert result["diagnostics"]["original_feature_count"] == 5 + + +@pytest.mark.asyncio +async def test_resolve_layer_payload_writes_fresh_and_stale(fake_cache_client): + calls = 0 + + async def builder(): + nonlocal calls + calls += 1 + return {"type": "FeatureCollection", "features": [{"id": "a"}]} + + key = earth_layer_cache.key("satellites", limit="all") + policy = EarthLayerCachePolicy(60, 120) + + first = await resolve_layer_payload(key=key, policy=policy, builder=builder) + second = await resolve_layer_payload(key=key, policy=policy, builder=builder) + + assert first.state == "refresh" + assert second.state == "hit" + assert calls == 1 + assert key in fake_cache_client.store + assert f"{key}:stale" in fake_cache_client.store + + +@pytest.mark.asyncio +async def test_resolve_layer_payload_returns_stale_when_builder_fails(fake_cache_client): + key = earth_layer_cache.key("bgp-incidents", status="active") + fake_cache_client.store[f"{key}:stale"] = json.dumps({"type": "FeatureCollection", "features": []}) + + async def builder(): + raise RuntimeError("db exploded") + + result = await resolve_layer_payload( + key=key, + policy=EarthLayerCachePolicy(60, 120), + builder=builder, + ) + + assert result.state == "stale" + assert result.payload["features"] == [] + + +@pytest.mark.asyncio +async def test_resolve_layer_payload_uses_stale_during_lock_contention(fake_cache_client): + key = earth_layer_cache.key("cables") + fake_cache_client.store[earth_layer_cache.lock_key(key)] = "1" + fake_cache_client.store[f"{key}:stale"] = json.dumps( + {"type": "FeatureCollection", "features": [{"id": "stale-cable"}]} + ) + calls = 0 + + async def builder(): + nonlocal calls + calls += 1 + return {"type": "FeatureCollection", "features": [{"id": "fresh-cable"}]} + + result = await resolve_layer_payload( + key=key, + policy=EarthLayerCachePolicy(60, 120), + builder=builder, + ) + + assert result.state == "stale" + assert result.payload["features"][0]["id"] == "stale-cable" + assert calls == 0 + + +@pytest.mark.asyncio +async def test_resolve_layer_payload_bypasses_redis_failure(): + previous = earth_layer_cache._client + earth_layer_cache._client = FakeRedis(fail=True) + try: + async def builder(): + return {"type": "FeatureCollection", "features": [{"id": "safe"}]} + + result = await resolve_layer_payload( + key=earth_layer_cache.key("cables"), + policy=EarthLayerCachePolicy(60, 120), + builder=builder, + ) + + assert result.state == "bypass" + assert result.payload["features"][0]["id"] == "safe" + finally: + earth_layer_cache._client = previous + + +@pytest.mark.asyncio +async def test_visualization_endpoint_sets_cache_headers(fake_cache_client, monkeypatch): + calls = 0 + + async def fake_build_satellites_geojson(*, limit, db): + nonlocal calls + calls += 1 + return {"type": "FeatureCollection", "features": [{"id": f"sat-{limit}"}], "count": 1} + + monkeypatch.setattr(visualization, "_build_satellites_geojson", fake_build_satellites_geojson) + + first_response = Response() + first = await visualization.get_satellites_geojson(limit=25, db=object(), response=first_response) + second_response = Response() + second = await visualization.get_satellites_geojson(limit=25, db=object(), response=second_response) + + assert first == second + assert calls == 1 + assert first_response.headers["X-Planet-Cache"] == "refresh" + assert second_response.headers["X-Planet-Cache"] == "hit" + + +@pytest.mark.asyncio +async def test_vessel_snapshot_uses_short_cache(fake_cache_client, monkeypatch): + calls = 0 + + async def fake_load_raw_vessel_snapshot_features(db, *, bbox, limit, observed_since): + nonlocal calls + calls += 1 + return ( + [ + { + "type": "Feature", + "geometry": {"type": "Point", "coordinates": [10.1, 59.1]}, + "properties": {"mmsi": 123, "vessel_type_name": "Cargo"}, + } + ], + {"raw_feature_count": 1}, + ) + + monkeypatch.setattr( + visualization, + "_load_raw_vessel_snapshot_features", + fake_load_raw_vessel_snapshot_features, + ) + + first_response = Response() + first = await visualization.build_vessel_snapshot_response( + object(), + bbox=(10.01, 59.04, 10.96, 60.02), + zoom=12, + type_filter=None, + limit=1000, + since_minutes=60, + response=first_response, + ) + second_response = Response() + second = await visualization.build_vessel_snapshot_response( + object(), + bbox=(10.04, 59.01, 10.99, 60.04), + zoom=12, + type_filter=None, + limit=1000, + since_minutes=60, + response=second_response, + ) + + assert first["count"] == 1 + assert second == first + assert calls == 1 + assert first_response.headers["X-Planet-Cache"] == "refresh" + assert second_response.headers["X-Planet-Cache"] == "hit" diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 3c93cb98..b952ef7b 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -8,6 +8,22 @@ This project follows the repository versioning rule: - `improvement` -> `+0.0.1`(bugfix + 小功能混合) - `bugfix` -> `+0.0.1` +## [0.61.0] — 2026-05-18 + +Released: 2026-05-18 + +### Highlights +- 新增 Earth 图层 Redis 读穿缓存、防击穿锁、stale 兜底和 payload budget,降低演示前重图层与船只 snapshot 对后端内存的冲击。 +- 保持前端原 API 不变,为海缆、登陆点、卫星、算力中心、BGP、summary 和船只 snapshot 增加透明缓存 header 可观测性。 +- 新增 super admin Earth 图层缓存状态与清理接口,并在采集写入后按 source 主动失效相关缓存。 + +### Added / Fixed / Improved +- 新增 `earth:layer:v1:*` 缓存命名空间、fresh/stale 双 key、Redis 故障 bypass 和 OOM 防护诊断。 +- 船只 snapshot 使用短 TTL、bbox 量化和响应预算,避免重复视窗请求和超大 payload 触发后端 OOM。 +- 补充 Earth layer cache 计划文档与后端测试,覆盖 hit、refresh、stale、bypass、锁竞争、cache header 和运维清理。 + +--- + ## [0.60.0] — 2026-05-17 Released: 2026-05-17 diff --git a/docs/plans/earth-layer-redis-cache-oom-guard-plan.md b/docs/plans/earth-layer-redis-cache-oom-guard-plan.md new file mode 100644 index 00000000..2b9a87ec --- /dev/null +++ b/docs/plans/earth-layer-redis-cache-oom-guard-plan.md @@ -0,0 +1,71 @@ +# Earth 图层 Redis 缓存与 OOM 防护完整计划 + +## Summary + +目标不是单纯“加缓存”,而是把 Earth 图层读路径改成可控、可观测、可降级的缓存架构,避免演示前高并发、重图层、船只数据膨胀再次把后端打到 OOM 无限重启。 + +前端继续请求原 API,response body 保持兼容。后端新增 Redis 读穿缓存、防击穿锁、stale 兜底、payload budget、主动失效、观测 header 和日志。 + +更新架构图: + +```mermaid +flowchart TD + Earth["Earth 前端
原 API 不变"] --> API["FastAPI Visualization / Layers APIs"] + + API --> Guard["Request Guard
limit clamp / bbox required / payload budget"] + Guard --> Cache["Layer Cache Adapter
key / TTL / lock / stale"] + Cache -->|fresh hit| Redis["Redis
earth:layer:v1:*
fresh + stale payloads"] + Cache -->|miss or refresh| Builder["Layer Builders
DB query + GeoJSON conversion"] + Builder --> DB["PostgreSQL / Timescale
authoritative data"] + Builder --> Budget["Response Budget Check
feature cap / byte cap / diagnostics"] + Budget --> Cache + Cache --> API + API --> Earth + + Collectors["Collectors / Data writes"] --> DB + Collectors --> Invalidate["Source-scoped invalidation"] + Invalidate --> Redis + + Cache --> Metrics["Structured logs / headers
hit miss stale bypass refresh
bytes features duration"] +``` + +## Implementation Changes + +- Add an Earth layer cache adapter that owns Redis keys, TTLs, stale fallback, single-flight locks, JSON serialization, response headers, and graceful Redis bypass. +- Use `earth:layer:v1:{layer}:{params}` for fresh cache, `earth:layer:v1:{layer}:{params}:stale` for stale fallback, and `earth:layer:lock:v1:{hash}` for rebuild locks. +- Cache policy: + - `cables`, `landing-points`: fresh `6h`, stale `24h` + - `satellites`: fresh `15m`, stale `2h` + - `compute-centers`: fresh `10m`, stale `1h` + - `bgp-collectors`, `bgp-anomalies`, `bgp-incidents`, `geo/summary`: fresh `30-60s`, stale `10m` + - `vessels snapshot`: fresh `5s`, stale `30s`, with bbox rounded to `0.1` degrees and key including `zoom/type/limit/since_minutes` +- Prevent cache stampedes with `SET NX EX` locks. The lock holder refreshes; other requests prefer stale, wait briefly, then fall back to the guarded DB path. +- Enforce payload budgets on every cached layer: maximum features, maximum serialized bytes, and diagnostics when truncation happens. +- Keep vessel snapshot viewport-first: require bbox, clamp low-zoom limits, never build an unbounded all-vessel GeoJSON for Earth startup. +- Add cache observability headers: `X-Planet-Cache`, `X-Planet-Cache-Features`, `X-Planet-Cache-Bytes`, and development-only `X-Planet-Cache-Key`. +- Add super-admin system endpoints for Earth layer cache status and clearing. + +## Public Interfaces + +- Frontend request URLs stay unchanged. +- Response bodies stay compatible. +- New optional response headers report cache state. +- New system endpoints: + - `GET /api/v1/system/cache/earth-layers` + - `DELETE /api/v1/system/cache/earth-layers` +- Redis key contract: `earth:layer:v1:*`. Existing news keys remain `earth_news:target_location:*`. + +## Test Plan + +- Unit-test key generation, bbox rounding, payload budget truncation, Redis miss/hit, stale fallback, Redis bypass, and single-flight lock behavior. +- API-test repeated requests for cache headers, super-admin cache status/clear endpoints, and vessel snapshot bbox/limit safeguards. +- Regression-test existing layer guard behavior and vessel type forwarding. +- Verify Redis outage does not break Earth API responses. +- Verify large vessel requests return bounded payload diagnostics instead of exhausting memory. + +## Assumptions + +- PostgreSQL remains the authoritative data source; Redis is disposable read-through cache. +- First phase does not change frontend rendering. If Three.js rendering becomes the bottleneck, that is a separate frontend performance task. +- When safety conflicts with completeness, vessel responses prefer bounded/truncated data plus diagnostics over risking backend OOM. +- GeoJSON schema changes should bump the Redis key version from `v1` to `v2`. diff --git a/docs/version-history.md b/docs/version-history.md index 108d4d76..8133b5f7 100644 --- a/docs/version-history.md +++ b/docs/version-history.md @@ -16,12 +16,13 @@ ## Current Version - `main` 当前主线历史推导到:`0.16.5` -- `dev` 当前开发分支历史推导到:`0.60.0` +- `dev` 当前开发分支历史推导到:`0.61.0` ## Timeline | Version | Type | Branch | Commit | Summary | | --- | --- | --- | --- | --- | +| `0.61.0` | feature | `dev` | `pending` | Earth 图层新增 Redis 读穿缓存、防击穿、stale 兜底、payload budget、缓存状态/清理接口和采集后主动失效,降低船只与重图层演示 OOM 风险 | | `0.60.0` | feature | `dev` | `pending` | Earth 内容/国界运行体验、新闻中文摘要展示、AI Provider 纯净边界和提示词运维配置落地,并补充 Agent Runtime 与 Earth LLM 指令计划 | | `0.59.0` | feature | `dev` | `pending` | Earth 国界迁移为静态资产并恢复低精 fallback,新增工具栏高精构建进度、后台 Earth 内容/采集管理拆分、AI task prompt 管理和新闻锚点队列补丁链路 | | `0.58.0` | feature | `dev` | `pending` | Earth 高精度国界切换到 PMTiles/MVT 和标准源采集器,移除旧低精度兜底,修复远距地表 z-fighting 雪花/黑块,并补齐新闻目标地点队列与文档 | diff --git a/frontend/package.json b/frontend/package.json index d341f608..36e18895 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,6 +1,6 @@ { "name": "planet-frontend", - "version": "0.60.0", + "version": "0.61.0", "private": true, "packageManager": "bun@1", "dependencies": { diff --git a/pyproject.toml b/pyproject.toml index b3929e28..c2336005 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "planet" -version = "0.60.0" +version = "0.61.0" description = "智能星球计划 - 态势感知系统" requires-python = ">=3.14" dependencies = [ diff --git a/uv.lock b/uv.lock index f271362c..dd5e3092 100644 --- a/uv.lock +++ b/uv.lock @@ -757,7 +757,7 @@ wheels = [ [[package]] name = "planet" -version = "0.60.0" +version = "0.61.0" source = { virtual = "." } dependencies = [ { name = "aiofiles" },