sing-ui/lib/jobs.ts

378 lines
12 KiB
TypeScript

import { spawn, ChildProcess } from "child_process";
import fs from "fs";
import path from "path";
import {
DATA_DIR,
Pipeline,
Run,
createRun,
db,
getConnection,
getPipeline,
getRun,
updateRunStatus,
} from "./db";
import { SLING_HOME, buildReplicationYaml, connectionUrl } from "./sling";
import { buildApplyArgs, computeSchemaExcludes, prepareDevDatabase } from "./atlas";
import { resolveBinary } from "./binaries";
export interface JobEvent {
line: string;
ts: number;
}
export type LineListener = (ev: JobEvent) => void;
export type DoneListener = (status: string) => void;
// Mask credentials in echoed command lines (e.g. mysql://user:pass@host/db)
function maskSecret(arg: string): string {
return arg.replace(/(\w[\w-]*:\/\/[^:/\s]+:)[^@\s]+(@)/, "$1****$2");
}
export class AlreadyRunningError extends Error {
constructor(pipelineId: number) {
super(`pipeline ${pipelineId} already has a running run`);
this.name = "AlreadyRunningError";
}
}
interface ActiveRun {
runId: number;
pipelineId: number;
logPath: string;
buffer: JobEvent[];
tail: string[]; // last lines, used for the error summary
listeners: Set<LineListener>;
doneListeners: Set<DoneListener>;
child: ChildProcess | null; // current subprocess (atlas or sling)
cancelled: boolean;
}
const TAIL_LIMIT = 20;
// True if a process with this pid exists (EPERM means it exists but is owned
// by another user).
export function isProcessAlive(pid: number): boolean {
try {
process.kill(pid, 0);
return true;
} catch (e) {
return (e as NodeJS.ErrnoException).code === "EPERM";
}
}
export interface StopResult {
ok: boolean;
message?: string;
run?: Run | null;
}
class JobManager {
private active = new Map<number, ActiveRun>(); // runId -> state
private byPipeline = new Map<number, number>(); // pipelineId -> runId
isRunning(pipelineId: number): boolean {
return this.byPipeline.has(pipelineId);
}
startRun(pipelineId: number): Run {
const pipeline = getPipeline(pipelineId);
if (!pipeline) throw new Error(`pipeline ${pipelineId} not found`);
if (this.isRunning(pipelineId)) throw new AlreadyRunningError(pipelineId);
const run = createRun({
pipeline_id: pipelineId,
status: "running",
started_at: new Date().toISOString(),
});
const runsDir = path.join(DATA_DIR, "runs");
fs.mkdirSync(runsDir, { recursive: true });
const logPath = path.join(runsDir, `${run.id}.log`);
updateRunStatus(run.id, { log_path: logPath });
const state: ActiveRun = {
runId: run.id,
pipelineId,
logPath,
buffer: [],
tail: [],
listeners: new Set(),
doneListeners: new Set(),
child: null,
cancelled: false,
};
this.active.set(run.id, state);
this.byPipeline.set(pipelineId, run.id);
void this.execute(pipeline, state);
return getRun(run.id)!;
}
// Subscribe to live output. Replays the in-memory buffer first, then pushes
// new lines. Returns an unsubscribe function, or null if the run is not
// active in this process (e.g. after a dev HMR reload).
subscribe(
runId: number,
onLine: LineListener,
onDone?: DoneListener
): (() => void) | null {
const state = this.active.get(runId);
if (!state) return null;
for (const ev of state.buffer) onLine(ev);
state.listeners.add(onLine);
if (onDone) state.doneListeners.add(onDone);
return () => {
state.listeners.delete(onLine);
if (onDone) state.doneListeners.delete(onDone);
};
}
private emit(state: ActiveRun, line: string) {
const ev: JobEvent = { line, ts: Date.now() };
state.buffer.push(ev);
state.tail.push(line);
if (state.tail.length > TAIL_LIMIT) state.tail.shift();
try {
fs.appendFileSync(state.logPath, line + "\n");
} catch {
// keep streaming to subscribers even if the log file fails
}
for (const l of state.listeners) l(ev);
}
// Stop a run. If it is active in this process, flag it cancelled and
// SIGTERM the current subprocess (SIGKILL after 5s if it ignores SIGTERM);
// the execute loop observes `cancelled` and finishes as 'cancelled'.
// If the run is not active here but the DB still says running/queued, it
// belongs to a dead server process (HMR/restart): SIGTERM the recorded pid
// if still alive, otherwise just repair the stale DB row.
stopRun(runId: number): StopResult {
const state = this.active.get(runId);
if (state) {
state.cancelled = true;
const child = state.child;
if (child && child.pid && child.exitCode === null) {
this.emit(state, "[sling-ui] 收到停止请求,正在终止进程…");
try {
child.kill("SIGTERM");
} catch {
// already exiting
}
setTimeout(() => {
if (state.child === child && child.exitCode === null) {
try {
child.kill("SIGKILL");
} catch {
// already gone
}
}
}, 5000).unref();
}
return { ok: true, run: getRun(runId) };
}
const run = getRun(runId);
if (!run) return { ok: false, message: "run not found" };
if (run.status !== "running" && run.status !== "queued") {
return { ok: false, message: `run ${runId} already ${run.status}`, run };
}
const now = new Date().toISOString();
if (run.pid != null && isProcessAlive(run.pid)) {
try {
process.kill(run.pid, "SIGTERM");
} catch {
// died between the check and the kill
}
updateRunStatus(runId, {
status: "cancelled",
error: "用户手动停止",
finished_at: now,
});
} else {
updateRunStatus(runId, {
status: "failed",
error: "进程已退出(服务重启或异常)",
finished_at: now,
});
}
return { ok: true, run: getRun(runId) };
}
private finish(
state: ActiveRun,
status: "success" | "failed" | "cancelled",
error: string | null
) {
updateRunStatus(state.runId, {
status,
error,
finished_at: new Date().toISOString(),
});
this.active.delete(state.runId);
this.byPipeline.delete(state.pipelineId);
for (const cb of state.doneListeners) cb(status);
state.listeners.clear();
state.doneListeners.clear();
}
private async execute(pipeline: Pipeline, state: ActiveRun) {
const finishCancelled = () => {
this.emit(state, "[sling-ui] 运行已被用户停止");
this.finish(state, "cancelled", "用户手动停止");
};
try {
if (state.cancelled) return finishCancelled();
if (pipeline.schema_sync) {
const src = getConnection(pipeline.source_conn_id);
const tgt = getConnection(pipeline.target_conn_id);
if (!src || !tgt) throw new Error("pipeline connections not found");
const excludes = await computeSchemaExcludes(pipeline);
const scope = pipeline.schema_scope || "selected";
const tableExcludes = pipeline.include_fk
? excludes.length
: excludes.filter((p) => p !== "*[type=foreign-key]").length;
this.emit(
state,
`[schema] scope=${scope}, excluding ${tableExcludes} objects, fk=${pipeline.include_fk ? "on" : "off"}`
);
const devUrl = prepareDevDatabase(tgt);
const code = await this.runProcess(
state,
resolveBinary("atlas") ?? "atlas",
buildApplyArgs(connectionUrl(src), connectionUrl(tgt), devUrl, excludes),
{}
);
if (state.cancelled) return finishCancelled();
if (code !== 0) {
throw new Error(
`atlas schema apply exited with code ${code}\n${state.tail.join("\n")}`
);
}
}
if (state.cancelled) return finishCancelled();
const yamlPath = buildReplicationYaml(pipeline, state.runId);
const code = await this.runProcess(
state,
resolveBinary("sling") ?? "sling",
["run", "-r", yamlPath, "--home-dir", SLING_HOME],
{ SLING_LOGGING: "JSON", SLING_DISABLE_TELEMETRY: "1" }
);
if (state.cancelled) return finishCancelled();
if (code === 0) {
this.finish(state, "success", null);
} else {
this.finish(
state,
"failed",
`sling exited with code ${code}\n${state.tail.join("\n")}`
);
}
} catch (err) {
if (state.cancelled) return finishCancelled();
this.emit(state, `[error] ${(err as Error).message}`);
this.finish(state, "failed", (err as Error).message);
}
}
private runProcess(
state: ActiveRun,
bin: string,
args: string[],
extraEnv: Record<string, string>
): Promise<number> {
return new Promise((resolve, reject) => {
this.emit(state, `$ ${bin} ${args.map(maskSecret).join(" ")}`);
const child = spawn(bin, args, {
env: { ...process.env, ...extraEnv },
});
state.child = child;
if (child.pid) {
updateRunStatus(state.runId, { pid: child.pid });
}
child.on("error", reject);
const hookStream = (stream: NodeJS.ReadableStream | null) => {
if (!stream) return;
let buf = "";
stream.on("data", (chunk: Buffer) => {
buf += chunk.toString();
let idx: number;
while ((idx = buf.indexOf("\n")) >= 0) {
this.emit(state, buf.slice(0, idx));
buf = buf.slice(idx + 1);
}
});
stream.on("end", () => {
if (buf) this.emit(state, buf);
});
};
hookStream(child.stdout);
hookStream(child.stderr);
child.on("close", (code) => {
if (state.child === child) state.child = null;
resolve(code ?? 1);
});
});
}
}
// Singleton surviving dev HMR reloads.
const g = globalThis as unknown as { __jobManager?: JobManager };
export const jobManager: JobManager =
g.__jobManager ?? (g.__jobManager = new JobManager());
// Reconcile runs left in running/queued by a previous server process.
// A run whose recorded pid is dead is stale (the subprocess died with the
// old server or never existed) -> failed. A run whose pid is still alive is
// an orphaned sling/atlas subprocess that kept running after the old server
// exited -> SIGTERM it and mark the run cancelled.
function reconcileOrphanRuns() {
const stale = db
.prepare("SELECT * FROM runs WHERE status IN ('running', 'queued')")
.all() as Run[];
const now = new Date().toISOString();
for (const run of stale) {
const alive = run.pid != null && isProcessAlive(run.pid);
if (alive) {
try {
process.kill(run.pid!, "SIGTERM");
} catch {
// died between the check and the kill
}
updateRunStatus(run.id, {
status: "cancelled",
error: "服务重启,运行已终止",
finished_at: now,
});
} else {
updateRunStatus(run.id, {
status: "failed",
error: "服务重启或进程异常退出",
finished_at: now,
});
}
if (run.log_path && fs.existsSync(run.log_path)) {
try {
fs.appendFileSync(run.log_path, "[sling-ui] 服务重启,运行终止\n");
} catch {
// log append is best-effort
}
}
console.log(
`[sling-ui] reconciled orphan run #${run.id} -> ${alive ? "cancelled" : "failed"} (pid ${run.pid ?? "none"})`
);
}
}
const gReconcile = globalThis as unknown as { __runsReconciled?: boolean };
if (!gReconcile.__runsReconciled) {
gReconcile.__runsReconciled = true;
try {
reconcileOrphanRuns();
} catch (e) {
console.error("[sling-ui] orphan run reconcile failed", e);
}
}