mirror of
https://github.com/hansjone/netx.git
synced 2026-10-09 03:10:46 +08:00
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:
parent
3413d5bf32
commit
fed7f56ad0
5 changed files with 169 additions and 43 deletions
|
|
@ -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}")
|
||||||
|
|
|
||||||
|
|
@ -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")
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue