fix: collection first-run pending and module tab cross-nav

Defer collection scheduling to BackgroundTasks after commit; claim pending runs atomically with retries. Only handle focus-module broadcasts for the matching moduleId so opening one module does not rewrite other tabs.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
oliver 2026-05-28 10:22:11 +08:00
parent 3413d5bf32
commit fed7f56ad0
5 changed files with 169 additions and 43 deletions

View file

@ -1,6 +1,6 @@
from __future__ import annotations from __future__ import annotations
from fastapi import APIRouter, Depends, Query from fastapi import APIRouter, BackgroundTasks, Depends, Query
from fastapi.responses import FileResponse, Response from fastapi.responses import FileResponse, Response
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
@ -20,6 +20,7 @@ from .collection_service import (
from .collection_schemas import CollectionJobCreate from .collection_schemas import CollectionJobCreate
from .db import get_db from .db import get_db
from .models import NeCollectionRun from .models import NeCollectionRun
from .ne_collect_runner import dispatch_collection_runs
router = APIRouter(prefix="/v1/ne-collections", tags=["ne-collections"]) router = APIRouter(prefix="/v1/ne-collections", tags=["ne-collections"])
@ -34,8 +35,19 @@ def api_eligible_ne(
@router.post("") @router.post("")
def api_create_collection(body: CollectionJobCreate, db: Session = Depends(get_db)): def api_create_collection(
return create_and_start_collection(db, body).model_dump() body: CollectionJobCreate,
background_tasks: BackgroundTasks,
db: Session = Depends(get_db),
):
out, payload = create_and_start_collection(db, body)
background_tasks.add_task(
dispatch_collection_runs,
payload["job_id"],
payload["run_ids"],
payload["commands"],
)
return out.model_dump()
@router.get("") @router.get("")
@ -92,13 +104,35 @@ def api_pause_collection(job_id: str, db: Session = Depends(get_db)):
@router.post("/{job_id}/restart") @router.post("/{job_id}/restart")
def api_restart_collection(job_id: str, db: Session = Depends(get_db)): def api_restart_collection(
return restart_collection_job(db, job_id).model_dump() job_id: str,
background_tasks: BackgroundTasks,
db: Session = Depends(get_db),
):
out, payload = restart_collection_job(db, job_id)
background_tasks.add_task(
dispatch_collection_runs,
payload["job_id"],
payload["run_ids"],
payload["commands"],
)
return out.model_dump()
@router.post("/{job_id}/retry-failed") @router.post("/{job_id}/retry-failed")
def api_retry_failed_collection(job_id: str, db: Session = Depends(get_db)): def api_retry_failed_collection(
return retry_failed_collection_job(db, job_id).model_dump() job_id: str,
background_tasks: BackgroundTasks,
db: Session = Depends(get_db),
):
out, payload = retry_failed_collection_job(db, job_id)
background_tasks.add_task(
dispatch_collection_runs,
payload["job_id"],
payload["run_ids"],
payload["commands"],
)
return out.model_dump()
@router.delete("/{job_id}") @router.delete("/{job_id}")

View file

@ -7,7 +7,7 @@ import shutil
import zipfile import zipfile
from datetime import datetime from datetime import datetime
from pathlib import Path from pathlib import Path
from typing import Any from typing import Any, TypedDict
from fastapi import HTTPException from fastapi import HTTPException
from sqlalchemy import func, or_ from sqlalchemy import func, or_
@ -21,12 +21,17 @@ from .collection_job_state import (
_sync_job_counts, _sync_job_counts,
) )
from .collection_schemas import CollectionJobCreate, CollectionJobOut, CollectionRunOut from .collection_schemas import CollectionJobCreate, CollectionJobOut, CollectionRunOut
from .ne_collect_runner import schedule_collection_runs
from .ne_collection_paths import clear_run_output_files, collection_data_root from .ne_collection_paths import clear_run_output_files, collection_data_root
_log = logging.getLogger("netx.collection") _log = logging.getLogger("netx.collection")
class CollectionSchedulePayload(TypedDict):
job_id: str
run_ids: list[str]
commands: list[str]
def _now() -> datetime: def _now() -> datetime:
return datetime.now() return datetime.now()
@ -130,7 +135,9 @@ def list_eligible_ne(db: Session, *, page: int = 1, page_size: int = 200) -> dic
return {"total": total, "page": page, "page_size": page_size, "items": items} return {"total": total, "page": page, "page_size": page_size, "items": items}
def create_and_start_collection(db: Session, body: CollectionJobCreate) -> CollectionJobOut: def create_and_start_collection(
db: Session, body: CollectionJobCreate
) -> tuple[CollectionJobOut, CollectionSchedulePayload]:
commands = _parse_commands(body.commands) commands = _parse_commands(body.commands)
if not commands: if not commands:
raise HTTPException(status_code=400, detail="commands_empty") raise HTTPException(status_code=400, detail="commands_empty")
@ -184,8 +191,12 @@ def create_and_start_collection(db: Session, body: CollectionJobCreate) -> Colle
db.commit() db.commit()
db.refresh(job) db.refresh(job)
schedule_collection_runs(str(job.id), run_ids, commands) payload: CollectionSchedulePayload = {
return job_to_out(job, output_count=0) "job_id": str(job.id),
"run_ids": run_ids,
"commands": commands,
}
return job_to_out(job, output_count=0), payload
def list_collection_jobs(db: Session, *, page: int = 1, page_size: int = 20) -> dict[str, Any]: def list_collection_jobs(db: Session, *, page: int = 1, page_size: int = 20) -> dict[str, Any]:
@ -319,7 +330,7 @@ def _start_job_retry(
commands: list[str], commands: list[str],
*, *,
reset_all_counts: bool, reset_all_counts: bool,
) -> CollectionJobOut: ) -> tuple[CollectionJobOut, CollectionSchedulePayload]:
if not retry_ids: if not retry_ids:
raise HTTPException(status_code=400, detail="collection_nothing_to_retry") raise HTTPException(status_code=400, detail="collection_nothing_to_retry")
now = _now() now = _now()
@ -336,11 +347,15 @@ def _start_job_retry(
job.last_run_at = now job.last_run_at = now
db.commit() db.commit()
db.refresh(job) db.refresh(job)
schedule_collection_runs(job_id, retry_ids, commands) payload: CollectionSchedulePayload = {
return job_to_out(job, output_count=_output_count_for_job(db, job_id)) "job_id": job_id,
"run_ids": retry_ids,
"commands": commands,
}
return job_to_out(job, output_count=_output_count_for_job(db, job_id)), payload
def restart_collection_job(db: Session, job_id: str) -> CollectionJobOut: def restart_collection_job(db: Session, job_id: str) -> tuple[CollectionJobOut, CollectionSchedulePayload]:
job = db.get(NeCollectionJob, job_id) job = db.get(NeCollectionJob, job_id)
if not job: if not job:
raise HTTPException(status_code=404, detail="collection_job_not_found") raise HTTPException(status_code=404, detail="collection_job_not_found")
@ -361,7 +376,9 @@ def restart_collection_job(db: Session, job_id: str) -> CollectionJobOut:
return _start_job_retry(db, job, job_id, retry_ids, commands, reset_all_counts=True) return _start_job_retry(db, job, job_id, retry_ids, commands, reset_all_counts=True)
def retry_failed_collection_job(db: Session, job_id: str) -> CollectionJobOut: def retry_failed_collection_job(
db: Session, job_id: str
) -> tuple[CollectionJobOut, CollectionSchedulePayload]:
job = db.get(NeCollectionJob, job_id) job = db.get(NeCollectionJob, job_id)
if not job: if not job:
raise HTTPException(status_code=404, detail="collection_job_not_found") raise HTTPException(status_code=404, detail="collection_job_not_found")

View file

@ -2,6 +2,7 @@ from __future__ import annotations
import logging import logging
import re import re
import time
import traceback import traceback
from concurrent.futures import ThreadPoolExecutor, TimeoutError as FuturesTimeout from concurrent.futures import ThreadPoolExecutor, TimeoutError as FuturesTimeout
from datetime import datetime from datetime import datetime
@ -97,31 +98,70 @@ def _update_run(run_id: str, **fields: Any) -> None:
db.close() db.close()
def _collection_aborted(job_id: str, run_id: str) -> bool: def _job_is_paused(job_id: str) -> bool:
db = SessionLocal() db = SessionLocal()
try: try:
job = db.get(NeCollectionJob, job_id) job = db.get(NeCollectionJob, job_id)
run = db.get(NeCollectionRun, run_id) return not job or str(job.status or "") == "paused"
if not job or not run:
return True
if str(job.status or "") == "paused":
if str(run.status or "") == "pending":
run.status = "cancelled"
run.message = "paused"
run.ended_at = datetime.now()
db.commit()
return True
if str(run.status or "") in ("cancelled", "success", "fail"):
return True
return False
finally: finally:
db.close() db.close()
def _run_single(job_id: str, run_id: str, commands: list[str]) -> None: def _claim_run(job_id: str, run_id: str) -> bool:
if _collection_aborted(job_id, run_id): """Atomically move a pending run to running; retry while DB rows become visible."""
for attempt in range(10):
db = SessionLocal() db = SessionLocal()
try: try:
run = db.get(NeCollectionRun, run_id)
job = db.get(NeCollectionJob, job_id)
if not run or not job:
time.sleep(0.05 * (attempt + 1))
continue
job_status = str(job.status or "")
run_status = str(run.status or "")
if job_status == "paused":
if run_status == "pending":
run.status = "cancelled"
run.message = "paused"
run.ended_at = datetime.now()
db.commit()
return False
if job_status != "running":
return False
if run_status == "running":
return True
if run_status != "pending":
return False
run.status = "running"
run.message = "collecting"
run.started_at = datetime.now()
db.commit()
return True
finally:
db.close()
time.sleep(0.05 * (attempt + 1))
return False
def _run_single(job_id: str, run_id: str, commands: list[str]) -> None:
if not _claim_run(job_id, run_id):
db = SessionLocal()
try:
run = db.get(NeCollectionRun, run_id)
st = str(run.status or "") if run else ""
if st in ("cancelled", "success", "fail"):
sync_job_progress(db, job_id)
finalize_collection_job(db, job_id)
return
if st == "pending":
_log.warning("collection claim failed job=%s run=%s", job_id, run_id)
_update_run(
run_id,
status="fail",
message="collection_claim_failed",
ended_at=datetime.now(),
)
sync_job_progress(db, job_id)
finalize_collection_job(db, job_id) finalize_collection_job(db, job_id)
finally: finally:
db.close() db.close()
@ -135,8 +175,9 @@ def _run_single(job_id: str, run_id: str, commands: list[str]) -> None:
if not ne: if not ne:
_update_run(run_id, status="fail", message="managed_ne_not_found", ended_at=datetime.now()) _update_run(run_id, status="fail", message="managed_ne_not_found", ended_at=datetime.now())
return return
started_at = datetime.now() if _job_is_paused(job_id):
_update_run(run_id, status="running", message="collecting", started_at=started_at) _update_run(run_id, status="cancelled", message="paused", ended_at=datetime.now())
return
try: try:
creds = get_device_credentials(ne) creds = get_device_credentials(ne)
output = _collect_with_timeout(creds, commands) output = _collect_with_timeout(creds, commands)
@ -174,10 +215,37 @@ def _run_single(job_id: str, run_id: str, commands: list[str]) -> None:
db2.close() db2.close()
def _run_collect_safe(job_id: str, run_id: str, commands: list[str]) -> None:
try:
_run_single(job_id, run_id, commands)
except Exception:
_log.exception("collection task crashed job=%s run=%s", job_id, run_id)
_update_run(
run_id,
status="fail",
message="collection_worker_crashed",
ended_at=datetime.now(),
)
db = SessionLocal()
try:
sync_job_progress(db, job_id)
finalize_collection_job(db, job_id)
finally:
db.close()
def schedule_collection_runs(job_id: str, run_ids: list[str], commands: list[str]) -> int: def schedule_collection_runs(job_id: str, run_ids: list[str], commands: list[str]) -> int:
pool = _executor_pool() pool = _executor_pool()
cmd_list = list(commands)
submitted = 0 submitted = 0
for run_id in run_ids: for run_id in run_ids:
pool.submit(_run_single, job_id, run_id, list(commands)) pool.submit(_run_collect_safe, job_id, str(run_id), cmd_list)
submitted += 1 submitted += 1
if submitted:
_log.info("scheduled collection job=%s runs=%s", job_id, submitted)
return submitted return submitted
def dispatch_collection_runs(job_id: str, run_ids: list[str], commands: list[str]) -> int:
"""Entry point for FastAPI BackgroundTasks after the request transaction commits."""
return schedule_collection_runs(job_id, run_ids, commands)

View file

@ -36,13 +36,18 @@ export function registerModuleWindow(moduleId: string): () => void {
/* ignore */ /* ignore */
} }
return registerBroadcastListener<FocusModuleMessage>(MODULE_CHANNEL, moduleAckKey(moduleId), (data) => { return registerBroadcastListener<FocusModuleMessage>(
const targetPath = String(data.path || "").trim(); MODULE_CHANNEL,
if (targetPath && window.location.pathname !== targetPath) { moduleAckKey(moduleId),
window.location.assign(targetPath); (data) => {
} const targetPath = String(data.path || "").trim();
window.focus(); if (targetPath && window.location.pathname !== targetPath) {
}); window.location.assign(targetPath);
}
window.focus();
},
(data) => data.moduleId === moduleId,
);
} }
export function openOrFocusModule({ moduleId, path }: ModuleWindowSpec): void { export function openOrFocusModule({ moduleId, path }: ModuleWindowSpec): void {

View file

@ -10,6 +10,7 @@ export function registerBroadcastListener<T extends { requestId: string }>(
channelName: string, channelName: string,
ackStorageKey: string, ackStorageKey: string,
onMessage: (data: T) => void, onMessage: (data: T) => void,
shouldHandle?: (data: T) => boolean,
): () => void { ): () => void {
if (typeof BroadcastChannel === "undefined") { if (typeof BroadcastChannel === "undefined") {
return () => undefined; return () => undefined;
@ -19,6 +20,7 @@ export function registerBroadcastListener<T extends { requestId: string }>(
channel.onmessage = (ev: MessageEvent<T>) => { channel.onmessage = (ev: MessageEvent<T>) => {
const data = ev.data; const data = ev.data;
if (!data?.requestId) return; if (!data?.requestId) return;
if (shouldHandle && !shouldHandle(data)) return;
try { try {
sessionStorage.setItem(ackStorageKey, data.requestId); sessionStorage.setItem(ackStorageKey, data.requestId);
} catch { } catch {