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" },