643 lines
26 KiB
Python
643 lines
26 KiB
Python
"""专业软件任务的校验、幂等持久化和 offer 状态机。"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import json
|
||
import re
|
||
from datetime import datetime, timedelta, timezone
|
||
from hashlib import sha256
|
||
from uuid import UUID, uuid4
|
||
|
||
from sqlalchemy import select
|
||
from sqlalchemy.exc import IntegrityError
|
||
|
||
from core.software_nodes import SUPPORTED_CAPABILITIES
|
||
from core.storage.engine import session_scope
|
||
from core.storage.models import Artifact, SoftwareJob, SoftwareNode, Task
|
||
|
||
OFFER_SECONDS = 60
|
||
ALLOWED_PLOT_TYPES = frozenset(
|
||
{"line", "scatter", "line_scatter"}
|
||
)
|
||
ALLOWED_OUTPUT_FORMATS = frozenset({"opju", "png", "svg", "pdf"})
|
||
ALLOWED_INPUT_SUFFIXES = frozenset({".csv", ".xlsx", ".json"})
|
||
MAX_INPUT_BYTES = 100 * 1024 * 1024
|
||
MAX_OUTPUT_ARTIFACT_BYTES = 256 * 1024 * 1024
|
||
class SoftwareJobError(Exception):
|
||
pass
|
||
|
||
|
||
MAX_OUTPUT_TOTAL_BYTES = 512 * 1024 * 1024
|
||
OUTPUT_ARTIFACTS = {
|
||
"project": ("project.opju", "application/x-origin-project", "opju"),
|
||
"figure_png": ("figure.png", "image/png", "png"),
|
||
"figure_svg": ("figure.svg", "image/svg+xml", "svg"),
|
||
"figure_pdf": ("figure.pdf", "application/pdf", "pdf"),
|
||
"plot_spec": ("plot-spec.json", "application/json", None),
|
||
"provenance": ("provenance.json", "application/json", None),
|
||
}
|
||
|
||
|
||
def _has_only(value: dict, fields: set[str]) -> bool:
|
||
return set(value).issubset(fields)
|
||
|
||
|
||
def _canonical_request(request: dict) -> tuple[dict, str]:
|
||
if not isinstance(request, dict) or set(request) != {"schema_version", "input", "plot", "output"}:
|
||
raise SoftwareJobError("invalid origin plot request fields")
|
||
if request.get("schema_version") != 1:
|
||
raise SoftwareJobError("unsupported origin plot schema version")
|
||
input_spec = request.get("input")
|
||
plot = request.get("plot")
|
||
output = request.get("output")
|
||
if not all(isinstance(item, dict) for item in (input_spec, plot, output)):
|
||
raise SoftwareJobError("origin plot request sections must be objects")
|
||
if not _has_only(input_spec, {"input_id", "sheet"}):
|
||
raise SoftwareJobError("unsupported origin input fields")
|
||
try:
|
||
UUID(str(input_spec.get("input_id") or ""))
|
||
except ValueError as exc:
|
||
raise SoftwareJobError("input.input_id must be an artifact UUID") from exc
|
||
if "sheet" in input_spec and (
|
||
not isinstance(input_spec["sheet"], str) or not 1 <= len(input_spec["sheet"]) <= 128
|
||
):
|
||
raise SoftwareJobError("input.sheet must be a string")
|
||
if not _has_only(
|
||
plot,
|
||
{"type", "x", "y", "template", "title", "x_axis", "y_axis", "legend", "error_bars"},
|
||
):
|
||
raise SoftwareJobError("unsupported origin plot fields")
|
||
if plot.get("type") not in ALLOWED_PLOT_TYPES:
|
||
raise SoftwareJobError("unsupported origin plot type")
|
||
if "title" in plot and (
|
||
not isinstance(plot["title"], str) or len(plot["title"]) > 500
|
||
):
|
||
raise SoftwareJobError("plot.title must be a string")
|
||
if plot.get("template", "publication_double_column") != "publication_double_column":
|
||
raise SoftwareJobError("unsupported origin plot template")
|
||
x_column = plot.get("x")
|
||
y_columns = plot.get("y")
|
||
if not isinstance(x_column, str) or not 1 <= len(x_column) <= 128:
|
||
raise SoftwareJobError("plot.x must be a column name")
|
||
if isinstance(y_columns, str):
|
||
y_columns = [y_columns]
|
||
if (
|
||
not isinstance(y_columns, list)
|
||
or not 1 <= len(y_columns) <= 16
|
||
or len(y_columns) != len(set(y_columns))
|
||
or any(not isinstance(item, str) or not 1 <= len(item) <= 128 for item in y_columns)
|
||
):
|
||
raise SoftwareJobError("plot.y must contain 1 to 16 unique column names")
|
||
for axis_name in ("x_axis", "y_axis"):
|
||
axis = plot.get(axis_name)
|
||
if axis is not None and (
|
||
not isinstance(axis, dict)
|
||
or not _has_only(axis, {"title", "unit", "scale"})
|
||
or axis.get("scale", "linear") != "linear"
|
||
or any(
|
||
name in axis and not isinstance(axis[name], str)
|
||
for name in ("title", "unit")
|
||
)
|
||
):
|
||
raise SoftwareJobError(f"invalid {axis_name}")
|
||
legend = plot.get("legend")
|
||
if legend is not None and (
|
||
not isinstance(legend, dict)
|
||
or not _has_only(legend, {"enabled", "position"})
|
||
or ("enabled" in legend and not isinstance(legend["enabled"], bool))
|
||
or legend.get("enabled", True) is not True
|
||
or legend.get("position", "top_right") != "top_right"
|
||
):
|
||
raise SoftwareJobError("invalid plot.legend")
|
||
if plot.get("error_bars") is not None:
|
||
raise SoftwareJobError("error bars are not supported in origin.plot@v1")
|
||
if not _has_only(output, {"formats", "dpi", "capture_screenshots", "record_video"}):
|
||
raise SoftwareJobError("unsupported origin output fields")
|
||
if any(
|
||
name in output and not isinstance(output[name], bool)
|
||
for name in ("capture_screenshots", "record_video")
|
||
):
|
||
raise SoftwareJobError("origin output capture flags must be boolean")
|
||
if output.get("record_video", False):
|
||
raise SoftwareJobError("origin video recording is not supported")
|
||
dpi = output.get("dpi", 300)
|
||
if not isinstance(dpi, int) or isinstance(dpi, bool) or not 72 <= dpi <= 1200:
|
||
raise SoftwareJobError("output.dpi must be between 72 and 1200")
|
||
formats = output.get("formats")
|
||
if (
|
||
not isinstance(formats, list)
|
||
or not formats
|
||
or len(formats) != len(set(formats))
|
||
or any(item not in ALLOWED_OUTPUT_FORMATS for item in formats)
|
||
):
|
||
raise SoftwareJobError("output.formats contains unsupported values")
|
||
encoded = json.dumps(request, ensure_ascii=False, sort_keys=True, separators=(",", ":"))
|
||
if len(encoded.encode("utf-8")) > 256 * 1024:
|
||
raise SoftwareJobError("origin plot request is too large")
|
||
normalized = json.loads(encoded)
|
||
return normalized, sha256(encoded.encode("utf-8")).hexdigest()
|
||
|
||
|
||
def _job_dict(row: SoftwareJob) -> dict:
|
||
return {
|
||
"job_id": str(row.job_id),
|
||
"task_id": str(row.task_id),
|
||
"capability": row.capability,
|
||
"request_digest": row.request_digest,
|
||
"node_id": str(row.node_id) if row.node_id else None,
|
||
"status": row.status,
|
||
"stage": row.stage,
|
||
"progress": row.progress,
|
||
"metrics": row.metrics,
|
||
"error": row.error,
|
||
"artifact_manifest": row.artifact_manifest,
|
||
"created_at": row.created_at.isoformat() if row.created_at else None,
|
||
"started_at": row.started_at.isoformat() if row.started_at else None,
|
||
"terminal_at": row.terminal_at.isoformat() if row.terminal_at else None,
|
||
}
|
||
|
||
|
||
def create_job(
|
||
user_id: UUID,
|
||
task_id: UUID,
|
||
*,
|
||
idempotency_key: str,
|
||
capability: str,
|
||
request: dict,
|
||
) -> tuple[dict, bool]:
|
||
key = idempotency_key.strip()
|
||
if not key or len(key) > 200:
|
||
raise SoftwareJobError("idempotency_key must contain 1 to 200 characters")
|
||
if capability not in SUPPORTED_CAPABILITIES:
|
||
raise SoftwareJobError("unsupported capability")
|
||
normalized, digest = _canonical_request(request)
|
||
with session_scope() as session:
|
||
task = session.execute(
|
||
select(Task.task_id).where(Task.task_id == task_id, Task.user_id == user_id)
|
||
).first()
|
||
if task is None:
|
||
raise SoftwareJobError("task not found")
|
||
artifact_id = UUID(normalized["input"]["input_id"])
|
||
artifact = session.execute(
|
||
select(Artifact).where(
|
||
Artifact.artifact_id == artifact_id,
|
||
Artifact.user_id == user_id,
|
||
Artifact.status == "active",
|
||
)
|
||
).scalar_one_or_none()
|
||
if artifact is None:
|
||
raise SoftwareJobError("input artifact not found")
|
||
suffix = "." + artifact.current_path.rsplit(".", 1)[-1].lower() if "." in artifact.current_path else ""
|
||
if suffix not in ALLOWED_INPUT_SUFFIXES:
|
||
raise SoftwareJobError("input artifact type is not supported")
|
||
if (
|
||
artifact.size_bytes is None
|
||
or artifact.size_bytes < 0
|
||
or artifact.size_bytes > MAX_INPUT_BYTES
|
||
or not artifact.content_sha256
|
||
or len(artifact.content_sha256) != 64
|
||
):
|
||
raise SoftwareJobError("input artifact metadata is incomplete or too large")
|
||
input_manifest = {
|
||
"artifact_id": str(artifact.artifact_id),
|
||
"filename": artifact.current_path.replace("\\", "/").rsplit("/", 1)[-1],
|
||
"size_bytes": artifact.size_bytes,
|
||
"sha256": artifact.content_sha256,
|
||
}
|
||
existing = session.execute(
|
||
select(SoftwareJob).where(
|
||
SoftwareJob.user_id == user_id,
|
||
SoftwareJob.idempotency_key == key,
|
||
)
|
||
).scalar_one_or_none()
|
||
if existing is not None:
|
||
if (
|
||
existing.task_id != task_id
|
||
or existing.capability != capability
|
||
or existing.request_digest != digest
|
||
):
|
||
raise SoftwareJobError("idempotency key was already used for a different request")
|
||
return _job_dict(existing), False
|
||
row = SoftwareJob(
|
||
job_id=uuid4(),
|
||
user_id=user_id,
|
||
task_id=task_id,
|
||
idempotency_key=key,
|
||
capability=capability,
|
||
request=normalized,
|
||
request_digest=digest,
|
||
input_manifest=input_manifest,
|
||
status="queued",
|
||
stage="",
|
||
metrics={},
|
||
error={},
|
||
artifact_manifest=[],
|
||
)
|
||
try:
|
||
with session.begin_nested():
|
||
session.add(row)
|
||
session.flush()
|
||
return _job_dict(row), True
|
||
except IntegrityError:
|
||
existing = session.execute(
|
||
select(SoftwareJob).where(
|
||
SoftwareJob.user_id == user_id,
|
||
SoftwareJob.idempotency_key == key,
|
||
)
|
||
).scalar_one()
|
||
if (
|
||
existing.task_id != task_id
|
||
or existing.capability != capability
|
||
or existing.request_digest != digest
|
||
):
|
||
raise SoftwareJobError(
|
||
"idempotency key was already used for a different request"
|
||
)
|
||
return _job_dict(existing), False
|
||
|
||
|
||
def get_job(user_id: UUID, job_id: UUID) -> dict | None:
|
||
with session_scope() as session:
|
||
row = session.execute(
|
||
select(SoftwareJob).where(SoftwareJob.job_id == job_id, SoftwareJob.user_id == user_id)
|
||
).scalar_one_or_none()
|
||
return _job_dict(row) if row else None
|
||
|
||
|
||
def offer_next_job(node_ids: set[UUID]) -> dict | None:
|
||
"""从当前进程实际在线的节点中选择一个,为最早 queued job 创建短租约。"""
|
||
if not node_ids:
|
||
return None
|
||
now = datetime.now(timezone.utc)
|
||
with session_scope() as session:
|
||
expired = session.execute(
|
||
select(SoftwareJob)
|
||
.where(
|
||
SoftwareJob.status == "offered",
|
||
SoftwareJob.lease_expires_at <= now,
|
||
)
|
||
.with_for_update(skip_locked=True)
|
||
).scalars()
|
||
for item in expired:
|
||
item.status = "queued"
|
||
item.node_id = None
|
||
item.lease_id = None
|
||
item.lease_expires_at = None
|
||
job = session.execute(
|
||
select(SoftwareJob)
|
||
.where(SoftwareJob.status == "queued")
|
||
.order_by(SoftwareJob.created_at, SoftwareJob.job_id)
|
||
.with_for_update(skip_locked=True)
|
||
.limit(1)
|
||
).scalar_one_or_none()
|
||
if job is None:
|
||
return None
|
||
busy_node_ids = set(
|
||
session.execute(
|
||
select(SoftwareJob.node_id).where(
|
||
SoftwareJob.node_id.is_not(None),
|
||
SoftwareJob.status.in_({"offered", "dispatched", "running"}),
|
||
)
|
||
).scalars()
|
||
)
|
||
nodes = session.execute(
|
||
select(SoftwareNode)
|
||
.where(SoftwareNode.node_id.in_(node_ids), SoftwareNode.status == "online")
|
||
.order_by(SoftwareNode.last_seen_at.desc())
|
||
).scalars()
|
||
node = next(
|
||
(
|
||
item
|
||
for item in nodes
|
||
if item.node_id not in busy_node_ids
|
||
and job.capability in item.capabilities
|
||
and int((item.runtime or {}).get("available_slots") or 0) > 0
|
||
),
|
||
None,
|
||
)
|
||
if node is None:
|
||
return None
|
||
lease_id = uuid4()
|
||
expires_at = now + timedelta(seconds=OFFER_SECONDS)
|
||
job.node_id = node.node_id
|
||
job.lease_id = lease_id
|
||
job.lease_expires_at = expires_at
|
||
job.status = "offered"
|
||
return {
|
||
"node_id": node.node_id,
|
||
"payload": {
|
||
"job_id": str(job.job_id),
|
||
"lease_id": str(lease_id),
|
||
"lease_expires_at": expires_at.isoformat(),
|
||
"capability": job.capability,
|
||
"request_digest": job.request_digest,
|
||
"request": job.request,
|
||
"input_transfer": {
|
||
**job.input_manifest,
|
||
"download_path": f"/v1/software-jobs/{job.job_id}/input",
|
||
},
|
||
},
|
||
}
|
||
|
||
|
||
def get_job_input(node_id: UUID, job_id: UUID) -> dict | None:
|
||
"""返回任务绑定的 artifact 定位信息;调用方仍需在 user_root 内安全解析。"""
|
||
with session_scope() as session:
|
||
job = session.execute(
|
||
select(SoftwareJob).where(
|
||
SoftwareJob.job_id == job_id,
|
||
SoftwareJob.node_id == node_id,
|
||
SoftwareJob.status.in_({"offered", "dispatched", "running", "disconnected"}),
|
||
)
|
||
).scalar_one_or_none()
|
||
if job is None:
|
||
return None
|
||
artifact_id = UUID(job.input_manifest["artifact_id"])
|
||
artifact = session.execute(
|
||
select(Artifact).where(
|
||
Artifact.artifact_id == artifact_id,
|
||
Artifact.user_id == job.user_id,
|
||
Artifact.status == "active",
|
||
)
|
||
).scalar_one_or_none()
|
||
if artifact is None:
|
||
return None
|
||
return {
|
||
"user_id": job.user_id,
|
||
"current_path": artifact.current_path,
|
||
**job.input_manifest,
|
||
}
|
||
|
||
|
||
def get_job_output_context(node_id: UUID, job_id: UUID, lease_id: UUID, digest: str) -> dict | None:
|
||
"""返回 Node 输出上传上下文,不向 Node 暴露任何云端文件路径。"""
|
||
with session_scope() as session:
|
||
row = session.execute(
|
||
select(SoftwareJob, Task.working_dir)
|
||
.join(Task, Task.task_id == SoftwareJob.task_id)
|
||
.where(SoftwareJob.job_id == job_id)
|
||
).one_or_none()
|
||
if row is None:
|
||
return None
|
||
job, working_dir = row
|
||
if (
|
||
job.node_id != node_id
|
||
or job.lease_id != lease_id
|
||
or job.request_digest != digest
|
||
or job.status not in {"dispatched", "running", "disconnected", "succeeded"}
|
||
):
|
||
return None
|
||
return {
|
||
"user_id": job.user_id,
|
||
"task_id": job.task_id,
|
||
"working_dir": working_dir,
|
||
"request": job.request,
|
||
"status": job.status,
|
||
"artifact_manifest": job.artifact_manifest,
|
||
}
|
||
|
||
|
||
def validate_output_manifest(request: dict, manifest: object) -> list[dict]:
|
||
if not isinstance(manifest, list):
|
||
raise SoftwareJobError("job artifact manifest must be a list")
|
||
requested_formats = set(request.get("output", {}).get("formats") or [])
|
||
expected_ids = {"plot_spec", "provenance"}
|
||
expected_ids.update(
|
||
artifact_id
|
||
for artifact_id, (_, _, output_format) in OUTPUT_ARTIFACTS.items()
|
||
if output_format in requested_formats
|
||
)
|
||
if len(manifest) != len(expected_ids):
|
||
raise SoftwareJobError("job artifact manifest is incomplete")
|
||
normalized: list[dict] = []
|
||
seen: set[str] = set()
|
||
total = 0
|
||
for raw in manifest:
|
||
if not isinstance(raw, dict) or set(raw) != {
|
||
"artifact_id", "filename", "media_type", "size_bytes", "sha256"
|
||
}:
|
||
raise SoftwareJobError("job artifact manifest entry is invalid")
|
||
local_id = raw.get("artifact_id")
|
||
if local_id not in expected_ids or local_id in seen:
|
||
raise SoftwareJobError("job artifact manifest identity is invalid")
|
||
filename, media_type, _ = OUTPUT_ARTIFACTS[local_id]
|
||
size = raw.get("size_bytes")
|
||
digest = raw.get("sha256")
|
||
if raw.get("filename") != filename or raw.get("media_type") != media_type:
|
||
raise SoftwareJobError("job artifact manifest metadata does not match its identity")
|
||
if not isinstance(size, int) or isinstance(size, bool) or not 1 <= size <= MAX_OUTPUT_ARTIFACT_BYTES:
|
||
raise SoftwareJobError("job output artifact size is invalid")
|
||
if not isinstance(digest, str) or not re.fullmatch(r"[0-9a-f]{64}", digest):
|
||
raise SoftwareJobError("job output artifact digest is invalid")
|
||
total += size
|
||
seen.add(local_id)
|
||
normalized.append(dict(raw))
|
||
if seen != expected_ids or total > MAX_OUTPUT_TOTAL_BYTES:
|
||
raise SoftwareJobError("job artifact manifest is incomplete or too large")
|
||
return normalized
|
||
|
||
|
||
def abandon_offer(node_id: UUID, payload: dict) -> None:
|
||
"""WebSocket 发送失败时只回滚仍属于该连接租约的 offer。"""
|
||
try:
|
||
job_id = UUID(str(payload.get("job_id", "")))
|
||
lease_id = UUID(str(payload.get("lease_id", "")))
|
||
except ValueError:
|
||
return
|
||
with session_scope() as session:
|
||
job = session.execute(
|
||
select(SoftwareJob).where(SoftwareJob.job_id == job_id).with_for_update()
|
||
).scalar_one_or_none()
|
||
if (
|
||
job is not None
|
||
and job.status == "offered"
|
||
and job.node_id == node_id
|
||
and job.lease_id == lease_id
|
||
):
|
||
job.status = "queued"
|
||
job.node_id = None
|
||
job.lease_id = None
|
||
job.lease_expires_at = None
|
||
|
||
|
||
def respond_to_offer(node_id: UUID, *, accepted: bool, payload: dict) -> None:
|
||
try:
|
||
job_id = UUID(str(payload.get("job_id", "")))
|
||
lease_id = UUID(str(payload.get("lease_id", "")))
|
||
except ValueError as exc:
|
||
raise SoftwareJobError("invalid job offer response identity") from exc
|
||
now = datetime.now(timezone.utc)
|
||
with session_scope() as session:
|
||
job = session.execute(
|
||
select(SoftwareJob).where(SoftwareJob.job_id == job_id).with_for_update()
|
||
).scalar_one_or_none()
|
||
if job is None or job.node_id != node_id or job.lease_id != lease_id:
|
||
raise SoftwareJobError("job offer is stale or does not belong to this node")
|
||
if (
|
||
accepted
|
||
and job.status in {"dispatched", "running", "succeeded", "failed", "cancelled"}
|
||
and payload.get("request_digest") == job.request_digest
|
||
):
|
||
return
|
||
if job.status != "offered":
|
||
raise SoftwareJobError("job offer is stale or does not belong to this node")
|
||
if job.lease_expires_at is None or job.lease_expires_at <= now:
|
||
job.status = "queued"
|
||
job.node_id = None
|
||
job.lease_id = None
|
||
job.lease_expires_at = None
|
||
raise SoftwareJobError("job offer has expired")
|
||
if accepted:
|
||
if payload.get("request_digest") != job.request_digest:
|
||
raise SoftwareJobError("job request digest mismatch")
|
||
job.status = "dispatched"
|
||
job.stage = "accepted"
|
||
job.error = {}
|
||
else:
|
||
job.status = "queued"
|
||
job.node_id = None
|
||
job.lease_id = None
|
||
job.lease_expires_at = None
|
||
job.error = {"code": "node_rejected", "detail": str(payload.get("reason") or "")[:500]}
|
||
|
||
|
||
def update_job_state(node_id: UUID, payload: dict) -> None:
|
||
job_id, lease_id, digest = _message_identity(payload)
|
||
stage = str(payload.get("stage") or "")
|
||
progress = payload.get("progress")
|
||
metrics = payload.get("metrics") or {}
|
||
if not stage or len(stage) > 100:
|
||
raise SoftwareJobError("job stage is required")
|
||
if not isinstance(progress, int) or isinstance(progress, bool) or not 0 <= progress <= 100:
|
||
raise SoftwareJobError("job progress must be between 0 and 100")
|
||
if not isinstance(metrics, dict) or len(json.dumps(metrics, ensure_ascii=False)) > 64 * 1024:
|
||
raise SoftwareJobError("job metrics are invalid")
|
||
now = datetime.now(timezone.utc)
|
||
with session_scope() as session:
|
||
job = session.execute(
|
||
select(SoftwareJob).where(SoftwareJob.job_id == job_id).with_for_update()
|
||
).scalar_one_or_none()
|
||
_assert_job_message(job, node_id, lease_id, digest)
|
||
if job.status in {"succeeded", "failed", "cancelled"}:
|
||
return
|
||
if not _can_accept_state(job.status):
|
||
raise SoftwareJobError("job state cannot advance from its current status")
|
||
job.status = (
|
||
"dispatched"
|
||
if stage in {"accepted", "waiting_input", "ready_to_run"}
|
||
else "running"
|
||
)
|
||
job.stage = stage
|
||
job.progress = progress
|
||
job.metrics = metrics
|
||
if job.status == "running" and job.started_at is None:
|
||
job.started_at = now
|
||
|
||
|
||
def record_job_terminal(node_id: UUID, payload: dict) -> None:
|
||
job_id, lease_id, digest = _message_identity(payload)
|
||
terminal_status = payload.get("status")
|
||
if terminal_status not in {"succeeded", "failed", "cancelled"}:
|
||
raise SoftwareJobError("invalid job terminal status")
|
||
error = payload.get("error") or {}
|
||
manifest = payload.get("artifact_manifest") or []
|
||
if not isinstance(error, dict) or len(json.dumps(error, ensure_ascii=False)) > 64 * 1024:
|
||
raise SoftwareJobError("job terminal error is invalid")
|
||
if not isinstance(manifest, list) or len(json.dumps(manifest, ensure_ascii=False)) > 256 * 1024:
|
||
raise SoftwareJobError("job artifact manifest is invalid")
|
||
now = datetime.now(timezone.utc)
|
||
with session_scope() as session:
|
||
job = session.execute(
|
||
select(SoftwareJob).where(SoftwareJob.job_id == job_id).with_for_update()
|
||
).scalar_one_or_none()
|
||
_assert_job_message(job, node_id, lease_id, digest)
|
||
if terminal_status == "succeeded":
|
||
expected = validate_output_manifest(
|
||
job.request,
|
||
[
|
||
{
|
||
"artifact_id": item.get("source_artifact_id"),
|
||
"filename": item.get("filename"),
|
||
"media_type": item.get("media_type"),
|
||
"size_bytes": item.get("size_bytes"),
|
||
"sha256": item.get("sha256"),
|
||
}
|
||
for item in manifest
|
||
if isinstance(item, dict)
|
||
],
|
||
)
|
||
if len(expected) != len(manifest) or any(
|
||
not isinstance(item.get("artifact_id"), str)
|
||
or not _is_uuid(item["artifact_id"])
|
||
or not isinstance(item.get("path"), str)
|
||
or not item["path"].startswith(f"origin/{job.job_id}/")
|
||
for item in manifest
|
||
):
|
||
raise SoftwareJobError("successful job artifacts have not been published")
|
||
if job.status in {"succeeded", "failed", "cancelled"}:
|
||
if job.status != terminal_status:
|
||
raise SoftwareJobError("job terminal status conflicts with existing terminal")
|
||
return
|
||
if job.status not in {"offered", "dispatched", "running", "disconnected"}:
|
||
raise SoftwareJobError("job terminal cannot advance from its current status")
|
||
job.status = terminal_status
|
||
job.stage = "terminal"
|
||
job.progress = 100 if terminal_status == "succeeded" else job.progress
|
||
job.error = error
|
||
job.artifact_manifest = manifest
|
||
job.terminal_at = now
|
||
|
||
|
||
def _is_uuid(value: str) -> bool:
|
||
try:
|
||
UUID(value)
|
||
except ValueError:
|
||
return False
|
||
return True
|
||
|
||
|
||
def mark_node_jobs_disconnected(node_id: UUID) -> None:
|
||
"""连接丢失后保留 Node 归属和 lease,禁止任务被自动重派。"""
|
||
with session_scope() as session:
|
||
jobs = session.execute(
|
||
select(SoftwareJob)
|
||
.where(
|
||
SoftwareJob.node_id == node_id,
|
||
SoftwareJob.status.in_({"dispatched", "running"}),
|
||
)
|
||
.with_for_update()
|
||
).scalars()
|
||
for job in jobs:
|
||
job.status = "disconnected"
|
||
|
||
|
||
def _can_accept_state(status: str) -> bool:
|
||
return status in {"offered", "dispatched", "running", "disconnected"}
|
||
|
||
|
||
def _message_identity(payload: dict) -> tuple[UUID, UUID, str]:
|
||
try:
|
||
job_id = UUID(str(payload.get("job_id", "")))
|
||
lease_id = UUID(str(payload.get("lease_id", "")))
|
||
except ValueError as exc:
|
||
raise SoftwareJobError("invalid job message identity") from exc
|
||
digest = str(payload.get("request_digest") or "")
|
||
if len(digest) != 64:
|
||
raise SoftwareJobError("invalid job request digest")
|
||
return job_id, lease_id, digest
|
||
|
||
|
||
def _assert_job_message(
|
||
job: SoftwareJob | None,
|
||
node_id: UUID,
|
||
lease_id: UUID,
|
||
digest: str,
|
||
) -> None:
|
||
if (
|
||
job is None
|
||
or job.node_id != node_id
|
||
or job.lease_id != lease_id
|
||
or job.request_digest != digest
|
||
):
|
||
raise SoftwareJobError("job message does not belong to this node or lease")
|