release: bump version to 0.61.0
This commit is contained in:
@@ -1,6 +1,6 @@
|
|||||||
from typing import Any, Optional
|
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 sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from app.api.v1.visualization import (
|
from app.api.v1.visualization import (
|
||||||
@@ -110,6 +110,7 @@ async def get_vessel_layer_snapshot(
|
|||||||
vessel_type: Optional[str] = Query(None, alias="type"),
|
vessel_type: Optional[str] = Query(None, alias="type"),
|
||||||
since_minutes: int = Query(60, ge=1, le=1440),
|
since_minutes: int = Query(60, ge=1, le=1440),
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
|
response: Response = None,
|
||||||
):
|
):
|
||||||
parsed_bbox = _parse_layer_bbox(bbox)
|
parsed_bbox = _parse_layer_bbox(bbox)
|
||||||
return await build_vessel_snapshot_response(
|
return await build_vessel_snapshot_response(
|
||||||
@@ -119,6 +120,7 @@ async def get_vessel_layer_snapshot(
|
|||||||
limit=limit,
|
limit=limit,
|
||||||
type_filter=vessel_type,
|
type_filter=vessel_type,
|
||||||
since_minutes=since_minutes,
|
since_minutes=since_minutes,
|
||||||
|
response=response,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ from app.services.system_logs import (
|
|||||||
normalize_log_level,
|
normalize_log_level,
|
||||||
read_log_snapshot,
|
read_log_snapshot,
|
||||||
)
|
)
|
||||||
|
from app.services.earth_layer_cache import earth_layer_cache
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|
||||||
@@ -112,6 +113,17 @@ class EarthClientLogEventResponse(BaseModel):
|
|||||||
level: str
|
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:
|
def ensure_super_admin(current_user: User) -> None:
|
||||||
if not require_super_admin(current_user.role):
|
if not require_super_admin(current_user.role):
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
@@ -132,6 +144,34 @@ def validate_log_date(raw_value: str | None, field_name: str) -> str | None:
|
|||||||
) from exc
|
) 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)
|
@router.post("/restart-tasks", response_model=RestartTaskResponse)
|
||||||
async def create_restart_task(
|
async def create_restart_task(
|
||||||
payload: RestartTaskCreate,
|
payload: RestartTaskCreate,
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
from typing import Optional
|
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 sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from app.api.v1.visualization import _parse_bbox, build_vessel_snapshot_response
|
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),
|
limit: int = Query(1000, ge=1, le=MAX_SNAPSHOT_LIMIT),
|
||||||
since_minutes: int = Query(60, ge=1, le=1440),
|
since_minutes: int = Query(60, ge=1, le=1440),
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
|
response: Response = None,
|
||||||
):
|
):
|
||||||
if not bbox:
|
if not bbox:
|
||||||
raise HTTPException(status_code=400, detail="bbox is required")
|
raise HTTPException(status_code=400, detail="bbox is required")
|
||||||
@@ -36,4 +37,5 @@ async def get_vessel_snapshot(
|
|||||||
type_filter=type,
|
type_filter=type,
|
||||||
limit=limit,
|
limit=limit,
|
||||||
since_minutes=since_minutes,
|
since_minutes=since_minutes,
|
||||||
|
response=response,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -56,6 +56,13 @@ from app.services.vessel_ais_aggregation import (
|
|||||||
get_vessel_raw_observations,
|
get_vessel_raw_observations,
|
||||||
MAX_SNAPSHOT_LIMIT,
|
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
|
from app.core.logging import get_logger
|
||||||
|
|
||||||
router = APIRouter()
|
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()
|
_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_NAME_FALLBACK_PATTERN = re.compile(r"^mmsi\s*\d+$", re.IGNORECASE)
|
||||||
VESSEL_SNAPSHOT_LEGACY_FALLBACK_ENABLED = True
|
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):
|
class TerrariumTileRequest(BaseModel):
|
||||||
@@ -1010,7 +1079,40 @@ async def build_vessel_snapshot_response(
|
|||||||
type_filter: str | None,
|
type_filter: str | None,
|
||||||
limit: int | None,
|
limit: int | None,
|
||||||
since_minutes: int = 60,
|
since_minutes: int = 60,
|
||||||
|
response: Response | None = None,
|
||||||
|
use_cache: bool = True,
|
||||||
) -> dict[str, Any]:
|
) -> 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)
|
requested_types = _requested_vessel_types(type_filter)
|
||||||
safe_limit = _safe_vessel_limit(limit)
|
safe_limit = _safe_vessel_limit(limit)
|
||||||
safe_since_minutes = min(max(int(since_minutes or 60), 1), 1440)
|
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")
|
@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)"""
|
"""获取海底电缆 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:
|
try:
|
||||||
records = await _load_current_collected_data(db, "arcgis_cables")
|
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")
|
@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:
|
try:
|
||||||
records_by_source = await _load_current_collected_data_by_sources(
|
records_by_source = await _load_current_collected_data_by_sources(
|
||||||
db,
|
db,
|
||||||
@@ -1731,8 +1857,25 @@ async def get_satellites_geojson(
|
|||||||
description="Maximum number of satellites to return. Omit for no limit.",
|
description="Maximum number of satellites to return. Omit for no limit.",
|
||||||
),
|
),
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
|
response: Response = None,
|
||||||
):
|
):
|
||||||
"""获取卫星 TLE GeoJSON 数据"""
|
"""获取卫星 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(
|
records = await _load_current_or_latest_task_data(
|
||||||
db,
|
db,
|
||||||
"celestrak_tle",
|
"celestrak_tle",
|
||||||
@@ -1800,8 +1943,25 @@ async def get_gpu_clusters_geojson(
|
|||||||
async def get_compute_centers_geojson(
|
async def get_compute_centers_geojson(
|
||||||
limit: int = Query(200, ge=1, le=1000),
|
limit: int = Query(200, ge=1, le=1000),
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
|
response: Response = None,
|
||||||
):
|
):
|
||||||
"""获取统一算力中心 GeoJSON 数据"""
|
"""获取统一算力中心 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(
|
records_by_source = await _load_current_collected_data_by_sources(
|
||||||
db,
|
db,
|
||||||
["top500", "epoch_ai_gpu"],
|
["top500", "epoch_ai_gpu"],
|
||||||
@@ -2397,7 +2557,31 @@ async def get_bgp_anomalies_geojson(
|
|||||||
status: Optional[str] = Query("active"),
|
status: Optional[str] = Query("active"),
|
||||||
limit: int = Query(200, ge=1, le=1000),
|
limit: int = Query(200, ge=1, le=1000),
|
||||||
db: AsyncSession = Depends(get_db),
|
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)
|
stmt = select(BGPAnomaly).order_by(BGPAnomaly.created_at.desc()).limit(limit)
|
||||||
if severity:
|
if severity:
|
||||||
stmt = stmt.where(BGPAnomaly.severity == severity)
|
stmt = stmt.where(BGPAnomaly.severity == severity)
|
||||||
@@ -2417,7 +2601,31 @@ async def get_bgp_incidents_geojson(
|
|||||||
status: Optional[str] = Query("active"),
|
status: Optional[str] = Query("active"),
|
||||||
limit: int = Query(100, ge=1, le=500),
|
limit: int = Query(100, ge=1, le=500),
|
||||||
db: AsyncSession = Depends(get_db),
|
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)
|
stmt = select(BGPIncident).order_by(BGPIncident.created_at.desc()).limit(limit)
|
||||||
if severity:
|
if severity:
|
||||||
stmt = stmt.where(BGPIncident.severity == severity)
|
stmt = stmt.where(BGPIncident.severity == severity)
|
||||||
@@ -2432,7 +2640,19 @@ async def get_bgp_incidents_geojson(
|
|||||||
|
|
||||||
|
|
||||||
@router.get("/geo/bgp-collectors")
|
@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(
|
coverage = await build_bgp_collector_coverage(
|
||||||
db,
|
db,
|
||||||
source_filter=("ris_live_bgp", "bgpstream_bgp"),
|
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")
|
@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."""
|
"""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")
|
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")
|
landing_point_count = await _count_current_or_latest_task_data(db, "arcgis_landing_points")
|
||||||
satellite_count = await _count_current_or_latest_task_data(
|
satellite_count = await _count_current_or_latest_task_data(
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ from app.core.config import settings
|
|||||||
from app.core.countries import normalize_country
|
from app.core.countries import normalize_country
|
||||||
from app.core.time import to_iso8601_utc
|
from app.core.time import to_iso8601_utc
|
||||||
from app.core.websocket.broadcaster import broadcaster
|
from app.core.websocket.broadcaster import broadcaster
|
||||||
|
from app.services.earth_layer_cache import invalidate_earth_layer_cache_for_source
|
||||||
|
|
||||||
|
|
||||||
class BaseCollector(ABC):
|
class BaseCollector(ABC):
|
||||||
@@ -531,6 +532,7 @@ class BaseCollector(ABC):
|
|||||||
}
|
}
|
||||||
|
|
||||||
await db.commit()
|
await db.commit()
|
||||||
|
invalidate_earth_layer_cache_for_source(self.name)
|
||||||
await self.update_progress(len(data), force=True)
|
await self.update_progress(len(data), force=True)
|
||||||
return records_added
|
return records_added
|
||||||
|
|
||||||
|
|||||||
@@ -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_event_locations import resolve_bgp_event_geo_dict
|
||||||
from app.services.bgp_incidents import create_bgp_incidents_for_anomalies
|
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 (
|
from app.services.bgp_detectors import (
|
||||||
detect_mass_withdrawal_anomalies,
|
detect_mass_withdrawal_anomalies,
|
||||||
detect_more_specific_burst_anomalies,
|
detect_more_specific_burst_anomalies,
|
||||||
@@ -223,6 +224,8 @@ async def save_bgp_observations_for_batch(
|
|||||||
|
|
||||||
if created:
|
if created:
|
||||||
await db.commit()
|
await db.commit()
|
||||||
|
for source in {"ris_live_bgp", "bgpstream_bgp"}:
|
||||||
|
invalidate_earth_layer_cache_for_source(source)
|
||||||
|
|
||||||
return created
|
return created
|
||||||
|
|
||||||
|
|||||||
392
backend/app/services/earth_layer_cache.py
Normal file
392
backend/app/services/earth_layer_cache.py
Normal file
@@ -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),
|
||||||
|
},
|
||||||
|
)
|
||||||
@@ -672,6 +672,77 @@ async def test_ingest_earth_client_log_accepts_public_events():
|
|||||||
app.dependency_overrides.clear()
|
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
|
@pytest.mark.asyncio
|
||||||
async def test_request_id_header_is_echoed_when_provided():
|
async def test_request_id_header_is_echoed_when_provided():
|
||||||
transport = ASGITransport(app=app)
|
transport = ASGITransport(app=app)
|
||||||
|
|||||||
254
backend/tests/test_earth_layer_cache.py
Normal file
254
backend/tests/test_earth_layer_cache.py
Normal file
@@ -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"
|
||||||
@@ -8,6 +8,22 @@ This project follows the repository versioning rule:
|
|||||||
- `improvement` -> `+0.0.1`(bugfix + 小功能混合)
|
- `improvement` -> `+0.0.1`(bugfix + 小功能混合)
|
||||||
- `bugfix` -> `+0.0.1`
|
- `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
|
## [0.60.0] — 2026-05-17
|
||||||
|
|
||||||
Released: 2026-05-17
|
Released: 2026-05-17
|
||||||
|
|||||||
71
docs/plans/earth-layer-redis-cache-oom-guard-plan.md
Normal file
71
docs/plans/earth-layer-redis-cache-oom-guard-plan.md
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
# Earth 图层 Redis 缓存与 OOM 防护完整计划
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
目标不是单纯“加缓存”,而是把 Earth 图层读路径改成可控、可观测、可降级的缓存架构,避免演示前高并发、重图层、船只数据膨胀再次把后端打到 OOM 无限重启。
|
||||||
|
|
||||||
|
前端继续请求原 API,response body 保持兼容。后端新增 Redis 读穿缓存、防击穿锁、stale 兜底、payload budget、主动失效、观测 header 和日志。
|
||||||
|
|
||||||
|
更新架构图:
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
flowchart TD
|
||||||
|
Earth["Earth 前端<br/>原 API 不变"] --> API["FastAPI Visualization / Layers APIs"]
|
||||||
|
|
||||||
|
API --> Guard["Request Guard<br/>limit clamp / bbox required / payload budget"]
|
||||||
|
Guard --> Cache["Layer Cache Adapter<br/>key / TTL / lock / stale"]
|
||||||
|
Cache -->|fresh hit| Redis["Redis<br/>earth:layer:v1:*<br/>fresh + stale payloads"]
|
||||||
|
Cache -->|miss or refresh| Builder["Layer Builders<br/>DB query + GeoJSON conversion"]
|
||||||
|
Builder --> DB["PostgreSQL / Timescale<br/>authoritative data"]
|
||||||
|
Builder --> Budget["Response Budget Check<br/>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<br/>hit miss stale bypass refresh<br/>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`.
|
||||||
@@ -16,12 +16,13 @@
|
|||||||
## Current Version
|
## Current Version
|
||||||
|
|
||||||
- `main` 当前主线历史推导到:`0.16.5`
|
- `main` 当前主线历史推导到:`0.16.5`
|
||||||
- `dev` 当前开发分支历史推导到:`0.60.0`
|
- `dev` 当前开发分支历史推导到:`0.61.0`
|
||||||
|
|
||||||
## Timeline
|
## Timeline
|
||||||
|
|
||||||
| Version | Type | Branch | Commit | Summary |
|
| 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.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.59.0` | feature | `dev` | `pending` | Earth 国界迁移为静态资产并恢复低精 fallback,新增工具栏高精构建进度、后台 Earth 内容/采集管理拆分、AI task prompt 管理和新闻锚点队列补丁链路 |
|
||||||
| `0.58.0` | feature | `dev` | `pending` | Earth 高精度国界切换到 PMTiles/MVT 和标准源采集器,移除旧低精度兜底,修复远距地表 z-fighting 雪花/黑块,并补齐新闻目标地点队列与文档 |
|
| `0.58.0` | feature | `dev` | `pending` | Earth 高精度国界切换到 PMTiles/MVT 和标准源采集器,移除旧低精度兜底,修复远距地表 z-fighting 雪花/黑块,并补齐新闻目标地点队列与文档 |
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "planet-frontend",
|
"name": "planet-frontend",
|
||||||
"version": "0.60.0",
|
"version": "0.61.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"packageManager": "bun@1",
|
"packageManager": "bun@1",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[project]
|
[project]
|
||||||
name = "planet"
|
name = "planet"
|
||||||
version = "0.60.0"
|
version = "0.61.0"
|
||||||
description = "智能星球计划 - 态势感知系统"
|
description = "智能星球计划 - 态势感知系统"
|
||||||
requires-python = ">=3.14"
|
requires-python = ">=3.14"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
|||||||
Reference in New Issue
Block a user