release: bump version to 0.66.0
Some checks failed
ci / backend (push) Has been cancelled
ci / frontend (push) Has been cancelled
ci / delivery (push) Has been cancelled
release / images (push) Has been cancelled

This commit is contained in:
rayd1o
2026-05-26 03:41:47 +08:00
parent e65267fe21
commit 5bf5c73ca0
173 changed files with 8669 additions and 13210 deletions

View File

@@ -1,4 +1,3 @@
import asyncio
from datetime import datetime, timedelta, timezone
from typing import Optional
@@ -7,7 +6,7 @@ from pydantic import BaseModel, Field
from sqlalchemy import func, or_, select, text
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.cache import cache
from app.core.logging import get_logger
from app.core.time import to_iso8601_utc
from app.core.security import get_current_user
from app.core.data_sources import get_data_sources_config
@@ -22,16 +21,26 @@ from app.models.user import User
from app.models.vessel import AISRawObservation
from app.services.vessel_ais_aggregation import VESSEL_AIS_SCHEMA
from app.services.scheduler import (
cancel_running_collector_now,
get_latest_task_id_for_datasource,
run_collector_now,
sync_datasource_job,
)
from app.services.earth_layer_cache import invalidate_earth_layer_cache_for_source
from app.services.data_jobs import (
JOB_STATUS_CANCELLING,
JOB_STATUS_QUEUED,
JOB_STATUS_RUNNING,
JOB_TYPE_CLEAR_CACHE,
JOB_TYPE_CLEAR_DATA,
JOB_TYPE_COLLECT,
enqueue_datasource_job,
get_active_datasource_job,
request_cancel_datasource_task,
)
from app.services.business_logs import emit_business_log
router = APIRouter()
logger = get_logger(__name__, service="api")
STALE_RUNNING_TASK_TIMEOUT_MINUTES = 90
PRODUCT_SOURCE_KEYWORDS: tuple[tuple[str, tuple[str, ...]], ...] = (
("vessels", ("vessel", "ais")),
("cables", ("cable", "landing", "telegeography", "arcgis", "fao")),
@@ -115,7 +124,7 @@ async def _load_latest_running_tasks(
_task_rank_column(CollectionTask.started_at),
)
.where(CollectionTask.datasource_id.in_(datasource_ids))
.where(CollectionTask.status == "running")
.where(CollectionTask.status.in_((JOB_STATUS_QUEUED, JOB_STATUS_RUNNING, JOB_STATUS_CANCELLING)))
.subquery()
)
result = await db.execute(
@@ -126,32 +135,6 @@ async def _load_latest_running_tasks(
return {task.datasource_id: task for task in result.scalars().all()}
async def _load_latest_task_ids(
db: AsyncSession,
datasource_ids: list[int],
) -> dict[int, int]:
if not datasource_ids:
return {}
ranked_tasks = (
select(
CollectionTask.id.label("task_id"),
CollectionTask.datasource_id.label("datasource_id"),
func.row_number().over(
partition_by=CollectionTask.datasource_id,
order_by=CollectionTask.id.desc(),
).label("row_num"),
)
.where(CollectionTask.datasource_id.in_(datasource_ids))
.subquery()
)
result = await db.execute(
select(ranked_tasks.c.datasource_id, ranked_tasks.c.task_id)
.where(ranked_tasks.c.row_num == 1)
)
return {datasource_id: task_id for datasource_id, task_id in result.all()}
async def _load_latest_tasks(
db: AsyncSession,
datasource_ids: list[int],
@@ -303,8 +286,11 @@ def serialize_datasource_row(
"last_run": to_iso8601_utc(last_run_at),
"last_run_at": to_iso8601_utc(last_run_at),
"last_status": last_status,
"is_running": running_task is not None,
"is_running": running_task is not None and running_task.task_type == JOB_TYPE_COLLECT,
"is_task_active": running_task is not None,
"task_status": running_task.status if running_task else None,
"task_id": display_task.id if display_task else None,
"task_type": display_task.task_type if display_task else None,
"progress": display_task.progress if display_task else None,
"phase": display_task.phase if display_task else None,
"phase_progress": display_task.phase_progress if display_task else None,
@@ -400,9 +386,25 @@ async def _trigger_datasource_batch(
datasources: list[DataSource],
*,
force: bool,
actor_id: int | None = None,
trigger_kind: str = "batch",
) -> dict:
await emit_business_log(
logger,
event=f"collector.trigger.{trigger_kind}.start",
message="Datasource batch trigger started",
category="collector",
service="api",
module=__name__,
user_id=actor_id,
context={
"trigger_kind": trigger_kind,
"force": force,
"requested_count": len(datasources),
},
)
if not datasources:
return {
result = {
"status": "noop",
"message": "No matching data sources to trigger",
"force": force,
@@ -410,8 +412,18 @@ async def _trigger_datasource_batch(
"skipped": [],
"failed": [],
}
await emit_business_log(
logger,
event=f"collector.trigger.{trigger_kind}.completed",
message="Datasource batch trigger completed with no matching sources",
category="collector",
service="api",
module=__name__,
user_id=actor_id,
context={"trigger_kind": trigger_kind, "force": force, "status": "noop", "triggered_count": 0},
)
return result
previous_task_ids: dict[int, Optional[int]] = {}
triggered_sources: list[dict] = []
skipped_sources: list[dict] = []
failed_sources: list[dict] = []
@@ -446,9 +458,11 @@ async def _trigger_datasource_batch(
}
)
continue
cancelled = await cancel_running_collector_now(datasource.source)
if not cancelled:
await rollback_orphaned_running_task(db, datasource, running_task)
await request_cancel_datasource_task(
db,
running_task,
reason="superseded_by_forced_collection",
)
if not force and not is_due_for_collection(datasource, now):
skipped_sources.append(
@@ -465,57 +479,51 @@ async def _trigger_datasource_batch(
)
continue
previous_task_ids[datasource.id] = None
success = run_collector_now(datasource.source)
if not success:
failed_sources.append(
{
"id": datasource.id,
"source": datasource.source,
"name": datasource.name,
"reason": "trigger_failed",
}
)
continue
task = await enqueue_datasource_job(
db,
datasource,
JOB_TYPE_COLLECT,
payload={"force": force, "trigger": "batch"},
)
triggered_sources.append(
{
"id": datasource.id,
"source": datasource.source,
"name": datasource.name,
"task_id": None,
"task_id": task.id,
}
)
latest_task_ids = await _load_latest_task_ids(
db,
[datasource.id for datasource in datasources],
)
for datasource_id in previous_task_ids:
previous_task_ids[datasource_id] = latest_task_ids.get(datasource_id)
for _ in range(20):
await asyncio.sleep(0.1)
pending = [item for item in triggered_sources if item["task_id"] is None]
if not pending:
break
latest_task_ids = await _load_latest_task_ids(
db,
[item["id"] for item in pending],
)
for item in pending:
task_id = latest_task_ids.get(item["id"])
if task_id is not None and task_id != previous_task_ids.get(item["id"]):
item["task_id"] = task_id
return {
"status": "triggered" if triggered_sources else "partial",
"message": f"Triggered {len(triggered_sources)} data sources",
result = {
"status": "queued" if triggered_sources else "partial",
"message": f"Queued {len(triggered_sources)} data source jobs",
"force": force,
"triggered": triggered_sources,
"skipped": skipped_sources,
"failed": failed_sources,
}
await emit_business_log(
logger,
event=f"collector.trigger.{trigger_kind}.completed",
message="Datasource batch trigger completed",
category="collector",
service="api",
module=__name__,
user_id=actor_id,
context={
"trigger_kind": trigger_kind,
"force": force,
"status": result["status"],
"requested_count": len(datasources),
"triggered_count": len(triggered_sources),
"skipped_count": len(skipped_sources),
"failed_count": len(failed_sources),
"triggered_sources": [item["source"] for item in triggered_sources],
"skipped_reasons": [item["reason"] for item in skipped_sources],
},
)
return result
async def get_datasource_record(db: AsyncSession, source_id: str) -> Optional[DataSource]:
@@ -780,7 +788,13 @@ async def trigger_all_datasources(
.order_by(DataSource.module, DataSource.id)
)
datasources = result.scalars().all()
return await _trigger_datasource_batch(db, datasources, force=force)
return await _trigger_datasource_batch(
db,
datasources,
force=force,
actor_id=current_user.id,
trigger_kind="all",
)
@router.post("/trigger-batch")
@@ -816,7 +830,13 @@ async def trigger_datasource_batch(
collected=None if payload.source_ids else payload.collected,
credential_status=None if payload.source_ids else payload.credential_status,
)
return await _trigger_datasource_batch(db, datasources, force=payload.force)
return await _trigger_datasource_batch(
db,
datasources,
force=payload.force,
actor_id=current_user.id,
trigger_kind="batch",
)
@router.get("/snapshots")
@@ -992,8 +1012,24 @@ async def trigger_datasource(
if not datasource.is_active:
raise HTTPException(status_code=400, detail="Data source is disabled")
running_task = await get_running_task(db, datasource.id)
running_task = await get_active_datasource_job(db, datasource.id, task_types=(JOB_TYPE_COLLECT,))
if running_task is not None and not force:
await emit_business_log(
logger,
event="collector.trigger.single.skipped_already_running",
message="Datasource trigger skipped because a task is already running",
category="collector",
level="warning",
service="api",
module=__name__,
user_id=current_user.id,
context={
"collector_name": datasource.source,
"datasource_id": datasource.id,
"task_id": running_task.id,
"status": "skipped",
},
)
raise HTTPException(
status_code=409,
detail={
@@ -1013,31 +1049,42 @@ async def trigger_datasource(
)
if running_task is not None and force:
cancelled = await cancel_running_collector_now(datasource.source)
if not cancelled:
await rollback_orphaned_running_task(db, datasource, running_task)
await request_cancel_datasource_task(
db,
running_task,
reason="superseded_by_forced_collection",
)
previous_task_id = await get_latest_task_id_for_datasource(datasource.id)
success = run_collector_now(datasource.source)
if not success:
raise HTTPException(status_code=500, detail=f"Failed to trigger collector '{datasource.source}'")
task_id = None
for _ in range(20):
await asyncio.sleep(0.1)
task_id = await get_latest_task_id_for_datasource(datasource.id)
if task_id is not None and task_id != previous_task_id:
break
if task_id == previous_task_id:
task_id = None
task = await enqueue_datasource_job(
db,
datasource,
JOB_TYPE_COLLECT,
payload={"force": force, "trigger": "single"},
)
await emit_business_log(
logger,
event="collector.trigger.single.completed",
message="Datasource trigger queued",
category="collector",
service="api",
module=__name__,
user_id=current_user.id,
context={
"collector_name": datasource.source,
"datasource_id": datasource.id,
"task_id": task.id,
"force": force,
"status": "queued",
},
)
return {
"status": "triggered",
"status": "queued",
"source_id": datasource.id,
"task_id": task_id,
"task_id": task.id,
"collector_name": datasource.source,
"force": force,
"message": f"Collector '{datasource.source}' has been triggered",
"message": f"Collector '{datasource.source}' has been queued",
}
@@ -1051,22 +1098,30 @@ async def clear_datasource_data(
if not datasource:
raise HTTPException(status_code=404, detail="Data source not found")
result = await db.execute(
select(func.count(CollectedData.id)).where(CollectedData.source == datasource.source)
active_task = await get_active_datasource_job(db, datasource.id)
if active_task is not None:
raise HTTPException(
status_code=409,
detail={
"reason": "datasource_job_in_progress",
"message": "当前数据源已有任务在执行,请等待完成或先取消任务。",
"task_id": active_task.id,
"task_type": active_task.task_type,
"status": active_task.status,
},
)
task = await enqueue_datasource_job(
db,
datasource,
JOB_TYPE_CLEAR_DATA,
payload={"source": datasource.source},
)
count = result.scalar() or 0
if count == 0:
return {"status": "success", "message": "No data to clear", "deleted_count": 0}
delete_query = CollectedData.__table__.delete().where(CollectedData.source == datasource.source)
await db.execute(delete_query)
await db.commit()
return {
"status": "success",
"message": f"Cleared {count} records for data source '{datasource.name}'",
"deleted_count": count,
"status": "queued",
"message": f"Queued data clearing for data source '{datasource.name}'",
"task_id": task.id,
"deleted_count": None,
}
@@ -1080,16 +1135,44 @@ async def clear_datasource_cache(
if not datasource:
raise HTTPException(status_code=404, detail="Data source not found")
earth_deleted_count = invalidate_earth_layer_cache_for_source(datasource.source)
dashboard_deleted_count = int(cache.delete("dashboard:stats")) + int(cache.delete("dashboard:summary"))
deleted_count = earth_deleted_count + dashboard_deleted_count
task = await enqueue_datasource_job(
db,
datasource,
JOB_TYPE_CLEAR_CACHE,
payload={"source": datasource.source},
dedupe_key=f"clear_cache:{datasource.source}",
)
return {
"status": "success",
"message": f"Cleared {deleted_count} cache keys for data source '{datasource.name}'",
"deleted_count": deleted_count,
"earth_layer_deleted_count": earth_deleted_count,
"dashboard_deleted_count": dashboard_deleted_count,
"status": "queued",
"message": f"Queued cache clearing for data source '{datasource.name}'",
"task_id": task.id,
"deleted_count": None,
}
@router.post("/{source_id}/tasks/{task_id}/cancel")
async def cancel_datasource_task(
source_id: str,
task_id: int,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
datasource = await get_datasource_record(db, source_id)
if not datasource:
raise HTTPException(status_code=404, detail="Data source not found")
task = await db.get(CollectionTask, task_id)
if not task or task.datasource_id != datasource.id:
raise HTTPException(status_code=404, detail="Task not found")
task = await request_cancel_datasource_task(db, task)
return {
"status": "cancelled" if task.completed_at else "cancelling",
"task_id": task.id,
"task_type": task.task_type,
"phase": task.phase,
"requested_cancel_at": to_iso8601_utc(task.requested_cancel_at),
}
@@ -1109,7 +1192,7 @@ async def get_task_status(
if not task or task.datasource_id != datasource.id:
raise HTTPException(status_code=404, detail="Task not found")
else:
task = await get_running_task(db, datasource.id)
task = await get_active_datasource_job(db, datasource.id)
if task is None:
result = await db.execute(
select(CollectionTask)
@@ -1134,8 +1217,12 @@ async def get_task_status(
}
return {
"is_running": task.status == "running",
"is_running": task.status in {JOB_STATUS_QUEUED, JOB_STATUS_RUNNING, JOB_STATUS_CANCELLING}
and task.task_type == JOB_TYPE_COLLECT,
"is_task_active": task.status in {JOB_STATUS_QUEUED, JOB_STATUS_RUNNING, JOB_STATUS_CANCELLING},
"task_id": task.id,
"task_type": task.task_type,
"task_status": task.status,
"progress": task.progress,
"phase": task.phase,
"phase_progress": task.phase_progress,
@@ -1146,5 +1233,6 @@ async def get_task_status(
"records_processed": task.records_processed,
"total_records": task.total_records,
"status": task.status,
"requested_cancel_at": to_iso8601_utc(task.requested_cancel_at),
"error_message": task.error_message,
}