Files
massive-cloud/server/algorithmJobRunner.ts

547 lines
19 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 通用算法插件子进程入口
* ------------------------------------------------------------
* fork 自 algorithmJob.ts,负责单次插件运行的全过程:
* 1. 校验 manifest 是否注册;
* 2. 准备 workDir = /data/tasks/<taskId>/analysis/<resultId>,写 input.json;
* 3. 按 runtime.kind 走 process / mock 两条路径:
* - process:spawn Python,按行解析 stdout 进度 JSON,等子进程退出后读 result.json;
* - mock:读 fixtures/<file>.json,假装跑 simulateMs ms 后写到 outDir/result.json;
* 4. 把 result.json 合并到 task_analysis_results.resultData,更新 status。
*
* 进程参数:
* node algorithmJobRunner.js <resultId> <taskId> <pluginId> <paramsJSON>
*/
import 'dotenv/config';
import * as fs from 'node:fs';
import * as path from 'node:path';
import { spawn } from 'node:child_process';
import { setTimeout as sleep } from 'node:timers/promises';
import * as db from './db.js';
import { getPlugin, adaptersDir, fixturesDir } from './plugins/registry.js';
import {
inspectionObjectContextFromName,
logCompatibilityWarnings,
normalizeMeasurements,
type InspectionObjectContext,
} from './measurementCompatibility.js';
import type {
PluginManifest,
PluginProgressEvent,
PluginResult,
PluginResultData,
} from './plugins/types.js';
const DATA_DIR = process.env.DATA_DIR || '/data';
const TASKS_DIR = path.join(DATA_DIR, 'tasks');
const PYTHON_BIN = process.env.PYTHON3_PATH || 'python3';
/** 算法源码根。生产镜像/开发宿主机上把 alg/ 挂到此处。 */
const ALG_ROOT = process.env.ALG_ROOT || '/home/lipan/massive-cloud/alg';
const DEFAULT_TIMEOUT_MS = 20 * 60 * 1000; // 20 分钟(验收要求)
function workDirFor(taskId: number, resultId: number): string {
return path.join(TASKS_DIR, String(taskId), 'analysis', String(resultId));
}
function ensureDir(dir: string) {
fs.mkdirSync(dir, { recursive: true });
}
function formatRegistrationTimestamp(date: Date): string {
const pad = (n: number) => String(n).padStart(2, '0');
return [
date.getFullYear(),
pad(date.getMonth() + 1),
pad(date.getDate()),
'-',
pad(date.getHours()),
pad(date.getMinutes()),
pad(date.getSeconds()),
].join('');
}
function registeredPointcloudDisplayName(sourceName: string | undefined, derivedFile: string, resultId: number): string {
const sourceParsed = path.parse(sourceName || 'pointcloud');
const stem = sourceParsed.name || 'pointcloud';
const ext = path.extname(derivedFile) || sourceParsed.ext || '.ply';
return `${stem}_已配准_${formatRegistrationTimestamp(new Date())}_R${resultId}${ext}`;
}
/** 把 process / mock cmd 模板里的占位符替换成实际路径。 */
function resolvePlaceholders(value: string, ctx: Record<string, string>): string {
return value.replace(/\{(\w+)\}/g, (_, key) => ctx[key] ?? `{${key}}`);
}
/** 收集任务相关上下文:点云路径、CAD 路径等。 */
async function gatherInputs(
taskId: number,
manifest: PluginManifest,
pointCloudFileIds?: number[],
cadFileIds?: number[],
) {
const task = await db.getInspectionTaskById(taskId);
if (!task) throw new Error(`task ${taskId} not found`);
let pointCloudRecords = await db.listTaskPointcloudFiles(taskId);
if (Array.isArray(pointCloudFileIds) && pointCloudFileIds.length > 0) {
const idSet = new Set(pointCloudFileIds);
pointCloudRecords = pointCloudRecords.filter((r) => idSet.has(r.id));
}
const pointCloudPaths = pointCloudRecords.map((r) => r.filePath);
if (manifest.requiresPointCloud !== false && pointCloudPaths.length === 0) {
throw new Error('当前任务下未上传点云,请先上传。');
}
let cadPath: string | null = null;
if (manifest.requiresCad || (Array.isArray(cadFileIds) && cadFileIds.length > 0)) {
// 优先使用任务级 CAD(前端选中的)
if (Array.isArray(cadFileIds) && cadFileIds.length > 0) {
const taskCads = await db.listTaskCadFiles(taskId);
const idSet = new Set(cadFileIds);
const selected = taskCads.filter((r) => idSet.has(r.id));
if (selected.length > 0) {
cadPath = selected[0].filePath;
}
}
// 回退到车型级 CAD
if (!cadPath) {
const modelCads = await db.listModelCadFiles(task.modelId);
if (modelCads.length > 0) {
cadPath = modelCads[0].filePath;
}
}
if (!cadPath && manifest.requiresCad) {
throw new Error('该插件需要 CAD 文件,但当前任务和车型下均无 CAD。');
}
}
const inspectionObject = task.inspectionObjectId
? await db.getInspectionObjectById(task.inspectionObjectId)
: undefined;
const inspectionContext = inspectionObjectContextFromName(inspectionObject?.name);
return { task, pointCloudRecords, pointCloudPaths, cadPath, inspectionContext };
}
async function writeInputJson(
workDir: string,
taskId: number,
resultId: number,
manifest: PluginManifest,
params: Record<string, unknown>,
pointCloudPaths: string[],
cadPath: string | null,
inputPointCloudFileIds: number[],
inputCadFileIds: number[],
inspectionContext: InspectionObjectContext,
) {
const inputJson = {
schemaVersion: 1,
pluginId: manifest.id,
pluginVersion: manifest.version,
resultId,
taskId,
workDir,
outDir: workDir,
context: inspectionContext,
inputs: {
pointCloudPaths,
pointCloudFileIds: inputPointCloudFileIds,
cadPath,
cadFileIds: inputCadFileIds,
config: params,
},
};
const target = path.join(workDir, 'input.json');
fs.writeFileSync(target, JSON.stringify(inputJson, null, 2), 'utf-8');
return target;
}
async function patchProgress(
resultId: number,
pluginId: string,
progress: number | undefined,
stage: string | undefined,
) {
const data: Record<string, unknown> = {
kind: 'plugin',
pluginId,
progress: progress != null ? Math.max(0, Math.min(1, progress)) : undefined,
stage,
};
await db.patchTaskAnalysisResultData(resultId, data);
}
function normalizeProgress(percent: number): number {
// 适配器允许 0~1 也允许 0~100;> 1 时按百分比处理
if (percent <= 1.0 && percent >= 0) return percent;
return Math.max(0, Math.min(100, percent)) / 100;
}
function readResultJson(workDir: string): PluginResult | null {
const p = path.join(workDir, 'result.json');
if (!fs.existsSync(p)) return null;
try {
// JSON 标准不支持 NaN/Infinity;Python json 模块有时会输出 NaN,用正则替换掉
const raw = fs.readFileSync(p, 'utf-8').replace(/:\s*NaN\b/g, ': null').replace(/:\s*Infinity\b/g, ': null').replace(/:\s*-Infinity\b/g, ': null');
return JSON.parse(raw) as PluginResult;
} catch (e) {
console.error('[AlgorithmJobRunner] result.json 解析失败:', e);
return null;
}
}
const DEMO_FLOW_RESULT_DATA_KEYS = [
'demoFlowRunId',
'demoFlowRole',
'demoFlowIndex',
'demoFlowTotal',
'demoFlowSourcePointCloudFileId',
'demoFlowCadFileId',
'demoFlowCancelledAt',
'demoFlowMode',
'demoFlowSelectedTaskNos',
'demoFlowSelectedTaskCount',
'demoFlowPluginTaskNos',
] as const;
async function readDemoFlowResultData(resultId: number): Promise<Record<string, unknown>> {
const row = await db.getTaskAnalysisResultById(resultId);
const existing = (row?.resultData as Record<string, unknown> | null) ?? {};
const picked: Record<string, unknown> = {};
for (const key of DEMO_FLOW_RESULT_DATA_KEYS) {
if (existing[key] !== undefined) {
picked[key] = existing[key];
}
}
return picked;
}
async function runProcessRuntime(
manifest: PluginManifest,
workDir: string,
inputJsonPath: string,
resultId: number,
): Promise<{ exitCode: number | null; lastStage?: string }> {
if (manifest.runtime.kind !== 'process') {
throw new Error('runtime.kind !== process');
}
const rt = manifest.runtime;
const ctx: Record<string, string> = {
pythonBin: PYTHON_BIN,
inputJson: inputJsonPath,
workDir,
adapterDir: adaptersDir(),
algRoot: ALG_ROOT,
};
const [cmd0, ...rawArgs] = rt.cmd;
if (!cmd0) throw new Error('runtime.cmd 为空');
const cmd = resolvePlaceholders(cmd0, ctx);
const args = rawArgs.map((a) => resolvePlaceholders(a, ctx));
const envExtra: Record<string, string> = {};
for (const [k, v] of Object.entries(rt.envExtra ?? {})) {
envExtra[k] = resolvePlaceholders(String(v), ctx);
}
console.log(`[AlgorithmJobRunner] spawn ${cmd} ${args.join(' ')}`);
const proc = spawn(cmd, args, {
cwd: workDir,
env: { ...process.env, ...envExtra },
stdio: ['ignore', 'pipe', 'inherit'],
});
const timeoutMs = rt.timeoutMs ?? DEFAULT_TIMEOUT_MS;
const timer = setTimeout(() => {
console.error(`[AlgorithmJobRunner] 超时 ${timeoutMs}ms,杀掉子进程`);
proc.kill('SIGKILL');
}, timeoutMs);
let buf = '';
let lastStage: string | undefined;
proc.stdout.setEncoding('utf-8');
proc.stdout.on('data', (chunk: string) => {
buf += chunk;
let nl: number;
while ((nl = buf.indexOf('\n')) >= 0) {
const line = buf.slice(0, nl).trim();
buf = buf.slice(nl + 1);
if (!line) continue;
// 行可能是进度 JSON,也可能是普通日志;普通日志直接落控制台
if (line.startsWith('{') && line.includes('"progress"')) {
try {
const evt = JSON.parse(line) as PluginProgressEvent & { progress?: number; stage?: string };
// 兼容两种字段名:percent / progress
const rawPercent = (evt as any).percent ?? (evt as any).progress;
const percent = typeof rawPercent === 'number' ? normalizeProgress(rawPercent) : undefined;
const stage = (evt as any).message ?? (evt as any).stage;
lastStage = stage ?? lastStage;
void patchProgress(resultId, manifest.id, percent, stage);
} catch {
process.stdout.write(`[plugin:${manifest.id}] ${line}\n`);
}
} else {
process.stdout.write(`[plugin:${manifest.id}] ${line}\n`);
}
}
});
const exitCode: number | null = await new Promise((resolve) => {
proc.on('error', (err) => {
console.error('[AlgorithmJobRunner] spawn error:', err);
resolve(-1);
});
proc.on('exit', (code) => resolve(code));
});
clearTimeout(timer);
return { exitCode, lastStage };
}
async function runMockRuntime(
manifest: PluginManifest,
workDir: string,
resultId: number,
): Promise<void> {
if (manifest.runtime.kind !== 'mock') return;
const fixturePath = path.join(fixturesDir(), manifest.runtime.fixture);
if (!fs.existsSync(fixturePath)) {
throw new Error(`mock fixture 不存在: ${fixturePath}`);
}
const fixture = JSON.parse(fs.readFileSync(fixturePath, 'utf-8')) as PluginResult;
const simulateMs = Math.max(0, manifest.runtime.simulateMs ?? 1200);
const steps = 8;
for (let i = 1; i <= steps; i++) {
await patchProgress(resultId, manifest.id, i / steps, `Mock 阶段 ${i}/${steps}`);
await sleep(simulateMs / steps);
}
fs.writeFileSync(path.join(workDir, 'result.json'), JSON.stringify(fixture, null, 2), 'utf-8');
}
export async function runAlgorithmJob(
resultId: number,
taskId: number,
pluginId: string,
params: Record<string, unknown>,
pointCloudFileIds?: number[],
cadFileIds?: number[],
): Promise<void> {
const manifest = getPlugin(pluginId);
if (!manifest) {
const demoFlowResultData = await readDemoFlowResultData(resultId);
await db.updateTaskAnalysisResult(resultId, {
status: 'error',
resultData: {
kind: 'plugin',
pluginId,
error: `未注册的插件:${pluginId}`,
...demoFlowResultData,
} as PluginResultData,
});
return;
}
const workDir = workDirFor(taskId, resultId);
ensureDir(workDir);
try {
await patchProgress(resultId, pluginId, 0.02, '准备输入');
const { pointCloudRecords, pointCloudPaths, cadPath, inspectionContext } = await gatherInputs(
taskId,
manifest,
pointCloudFileIds,
cadFileIds,
);
const inputPointCloudFileIds = pointCloudRecords.map((r) => r.id);
const inputCadFileIds = Array.isArray(cadFileIds) ? cadFileIds.filter((id) => Number.isInteger(id)) : [];
const inputJsonPath = await writeInputJson(
workDir,
taskId,
resultId,
manifest,
params,
pointCloudPaths,
cadPath,
inputPointCloudFileIds,
inputCadFileIds,
inspectionContext,
);
let exitCode: number | null = 0;
if (manifest.runtime.kind === 'process') {
const r = await runProcessRuntime(manifest, workDir, inputJsonPath, resultId);
exitCode = r.exitCode;
} else {
await runMockRuntime(manifest, workDir, resultId);
}
const result = readResultJson(workDir);
const finalStatus: 'completed' | 'error' = result?.status === 'error'
? 'error'
: exitCode === 0 || manifest.runtime.kind === 'mock'
? 'completed'
: 'error';
const demoFlowResultData = await readDemoFlowResultData(resultId);
const normalized = normalizeMeasurements({
pluginId,
measurements: result?.measurements,
extra: result?.extra,
context: inspectionContext,
algorithmProtocolVersion: result?.schemaVersion ?? manifest.schemaVersion,
});
logCompatibilityWarnings(resultId, normalized.warnings);
const requestedDemoTaskNos = Array.isArray(params.demoTaskNos)
? new Set(
params.demoTaskNos
.filter((value): value is number => typeof value === 'number' && Number.isInteger(value))
.map(String),
)
: null;
const normalizedMeasurements = Array.isArray(result?.measurements)
? requestedDemoTaskNos
? normalized.measurements.filter((measurement) => (
typeof measurement.taskNo === 'string' && requestedDemoTaskNos.has(measurement.taskNo)
))
: normalized.measurements
: undefined;
const resultData: PluginResultData = {
kind: 'plugin',
pluginId,
progress: finalStatus === 'completed' ? 1 : undefined,
stage: finalStatus === 'completed' ? '完成' : '失败',
exitCode,
inputPointCloudFileIds,
inputCadFileIds,
// 把 result.json 内容平展到 resultData 里,方便前端直接使用
schemaVersion: result?.schemaVersion ?? manifest.schemaVersion,
message: result?.message,
measurements: normalizedMeasurements,
errorStats: result?.errorStats,
overlay3d: result?.overlay3d,
images: result?.images,
files: result?.files,
extra: normalized.extra,
error: finalStatus === 'error'
? result?.message ?? `子进程退出码 ${exitCode}`
: undefined,
...demoFlowResultData,
};
await db.updateTaskAnalysisResult(resultId, {
status: finalStatus,
errorStats: result?.errorStats ?? null,
measurements: normalizedMeasurements ?? null,
resultData,
});
// ── 回写配准产出点云 ─────────────────────────────────────────────────────
// 若 result.json 包含 derivedPointClouds,把对应 PLY 注册为任务点云,
// 供后续测量算法直接选用,无需用户手动上传。
if (finalStatus === 'completed') {
const derived = (result as Record<string, unknown> | null)?.derivedPointClouds;
if (Array.isArray(derived) && derived.length > 0) {
for (const item of derived) {
const d = item as { file?: string; name?: string };
if (!d.file) continue;
const absPath = path.join(workDir, d.file);
if (!fs.existsSync(absPath)) {
console.warn(`[AlgorithmJobRunner] derivedPointCloud not found: ${absPath}`);
continue;
}
try {
const sourcePointcloud = pointCloudRecords[0];
const displayName = registeredPointcloudDisplayName(sourcePointcloud?.name, d.file, resultId);
await db.createRegisteredPointcloudFile(
taskId,
displayName,
absPath,
resultId,
sourcePointcloud?.id ?? null,
);
console.log(`[AlgorithmJobRunner] registered derived pointcloud: ${displayName}`);
} catch (err) {
console.error('[AlgorithmJobRunner] failed to register derived pointcloud:', err);
}
}
}
}
if (finalStatus === 'completed') {
console.log(
`[AlgorithmJobRunner] ok pluginId=${pluginId} resultId=${resultId} taskId=${taskId}`,
);
} else {
console.error(
`[AlgorithmJobRunner] failed pluginId=${pluginId} resultId=${resultId} exit=${exitCode}`,
);
}
} catch (err) {
console.error('[AlgorithmJobRunner]', err);
const message = err instanceof Error ? err.message : String(err);
const demoFlowResultData = await readDemoFlowResultData(resultId);
await db.updateTaskAnalysisResult(resultId, {
status: 'error',
resultData: {
kind: 'plugin',
pluginId,
error: message,
...demoFlowResultData,
} as PluginResultData,
});
}
}
// ─── CLI 入口 ────────────────────────────────────────────────────────────────
//
// node algorithmJobRunner.js <resultId> <taskId> <pluginId> <paramsJSON> [pcFileIdsJSON] [cadFileIdsJSON]
const [, , a, b, c, d, e, f] = process.argv;
if (a && b && c) {
const resultId = Number(a);
const taskId = Number(b);
const pluginId = String(c);
let params: Record<string, unknown> = {};
if (d) {
try {
params = JSON.parse(d) as Record<string, unknown>;
} catch (err) {
console.error('[AlgorithmJobRunner] params JSON 解析失败:', err);
}
}
let pointCloudFileIds: number[] | undefined;
if (e) {
try {
const parsed = JSON.parse(e) as unknown;
if (Array.isArray(parsed) && parsed.length > 0) {
pointCloudFileIds = parsed.map(Number).filter(Number.isFinite);
}
} catch (err) {
console.error('[AlgorithmJobRunner] pcFileIds JSON 解析失败:', err);
}
}
let cadFileIds: number[] | undefined;
if (f) {
try {
const parsed = JSON.parse(f) as unknown;
if (Array.isArray(parsed) && parsed.length > 0) {
cadFileIds = parsed.map(Number).filter(Number.isFinite);
}
} catch (err) {
console.error('[AlgorithmJobRunner] cadFileIds JSON 解析失败:', err);
}
}
if (!Number.isFinite(resultId) || !Number.isFinite(taskId) || resultId <= 0 || taskId <= 0) {
console.error('[AlgorithmJobRunner] 用法: <resultId> <taskId> <pluginId> [paramsJSON] [pcFileIdsJSON] [cadFileIdsJSON]');
process.exit(1);
}
runAlgorithmJob(resultId, taskId, pluginId, params, pointCloudFileIds, cadFileIds)
.then(() => process.exit(0))
.catch((err) => {
console.error('[AlgorithmJobRunner]', err);
process.exit(1);
});
}