This commit is contained in:
+32
-13
@@ -163,7 +163,10 @@ function writeJob(sqlite: Database.Database | undefined, jobId: string, values:
|
||||
requested_at=COALESCE(excluded.requested_at, update_jobs.requested_at),
|
||||
started_at=COALESCE(excluded.started_at, update_jobs.started_at),
|
||||
operation=excluded.operation,
|
||||
status=CASE WHEN update_jobs.status='cancelled' THEN update_jobs.status ELSE excluded.status END,
|
||||
-- Terminal rows are immutable from the runner's ordinary progress
|
||||
-- writes. In particular, a stale/replayed request must not resurrect a
|
||||
-- failed job as queued/downloading/etc.
|
||||
status=CASE WHEN update_jobs.status IN ('cancelled', 'failed', 'completed') THEN update_jobs.status ELSE excluded.status END,
|
||||
version=excluded.version, platform=excluded.platform,
|
||||
release_url=COALESCE(excluded.release_url, update_jobs.release_url),
|
||||
asset_name=COALESCE(excluded.asset_name, update_jobs.asset_name),
|
||||
@@ -176,6 +179,11 @@ function writeJob(sqlite: Database.Database | undefined, jobId: string, values:
|
||||
error_message=COALESCE(excluded.error_message, update_jobs.error_message),
|
||||
updated_at=excluded.updated_at,
|
||||
completed_at=COALESCE(excluded.completed_at, update_jobs.completed_at)
|
||||
-- Do not let a delayed runner replay overwrite any field on a terminal
|
||||
-- row. The predicate is part of the same SQLite upsert, so a finalizer
|
||||
-- racing this write still wins atomically instead of leaving a partially
|
||||
-- mutated completed/failed/cancelled record.
|
||||
WHERE update_jobs.status NOT IN ('cancelled', 'failed', 'completed')
|
||||
`).run(
|
||||
jobId,
|
||||
values.adminId ?? null,
|
||||
@@ -230,6 +238,7 @@ async function resolveRelease(options: UpdateRunOptions, platform: ReturnType<ty
|
||||
allowedHosts: options.allowedHosts ?? [],
|
||||
baseUrl: metadataUrl.toString(),
|
||||
maxBytes: options.maxBytes ?? 512 * 1024 * 1024,
|
||||
timeoutMs: options.timeoutMs,
|
||||
publicKey: options.publicKey,
|
||||
requireSignature: options.requireSignature,
|
||||
});
|
||||
@@ -398,19 +407,28 @@ export function finalizeUpdateJob(
|
||||
status: "completed" | "failed",
|
||||
message?: string,
|
||||
): void {
|
||||
const row = sqlite.prepare(`
|
||||
SELECT id, status, version, platform, admin_id AS adminId,
|
||||
request_id AS requestId, session_hash AS sessionHash
|
||||
FROM update_jobs WHERE id=?
|
||||
`).get(jobId) as { id: string; status: UpdateJobStatus; version: string; platform: string; adminId: string | null; requestId: string | null; sessionHash: string | null } | undefined;
|
||||
if (!row) throw new Error("更新任务不存在");
|
||||
const canComplete = row.status === "applying" || row.status === "completed";
|
||||
const canFail = ACTIVE_UPDATE_STATUSES.includes(row.status) || row.status === "completed" || row.status === "failed";
|
||||
if (status === "completed" ? !canComplete : !canFail) throw new Error("更新任务状态不允许完成");
|
||||
const now = Date.now();
|
||||
const safeFailureMessage = status === "failed" ? "新版本健康检查失败,已恢复上一版本" : null;
|
||||
sqlite.transaction(() => {
|
||||
sqlite.prepare("UPDATE update_jobs SET status=?, error_message=?, completed_at=?, updated_at=? WHERE id=?").run(status, safeFailureMessage, now, now, jobId);
|
||||
const row = sqlite.prepare(`
|
||||
SELECT id, status, version, platform, admin_id AS adminId,
|
||||
request_id AS requestId, session_hash AS sessionHash
|
||||
FROM update_jobs WHERE id=?
|
||||
`).get(jobId) as { id: string; status: UpdateJobStatus; version: string; platform: string; adminId: string | null; requestId: string | null; sessionHash: string | null } | undefined;
|
||||
if (!row) throw new Error("更新任务不存在");
|
||||
// A failed finalization can be retried by the runner. Once it has been
|
||||
// committed, make retries a no-op so the error and audit trail stay stable.
|
||||
if (row.status === status) return;
|
||||
// A completed release is terminal. A delayed recovery process must never
|
||||
// be able to downgrade it to failed after the service was healthy.
|
||||
if (row.status === "completed" && status === "failed") throw new Error("更新任务状态不允许完成");
|
||||
const canComplete = row.status === "applying" || row.status === "completed";
|
||||
const canFail = ACTIVE_UPDATE_STATUSES.includes(row.status) || row.status === "completed" || row.status === "failed";
|
||||
if (status === "completed" ? !canComplete : !canFail) throw new Error("更新任务状态不允许完成");
|
||||
const now = Date.now();
|
||||
const safeFailureMessage = status === "failed"
|
||||
? (message?.trim() ? safeErrorMessage(new Error(message)) : "新版本健康检查失败,已恢复上一版本")
|
||||
: null;
|
||||
const result = sqlite.prepare("UPDATE update_jobs SET status=?, error_message=?, completed_at=?, updated_at=? WHERE id=? AND status=?").run(status, safeFailureMessage, now, now, jobId, row.status);
|
||||
if (result.changes !== 1) return;
|
||||
writeAudit(sqlite, {
|
||||
requestId: row.requestId || randomUUID(),
|
||||
actorAdminId: row.adminId,
|
||||
@@ -569,6 +587,7 @@ export async function main(config: AppConfig = loadConfig()): Promise<void> {
|
||||
...(dataBackupArchive ? { dataBackupArchivePath: dataBackupArchive, dataBackupSource: config.dataDir } : {}),
|
||||
...((arg("--backup-dir")) ? { backupDir: arg("--backup-dir") } : {}),
|
||||
allowedHosts: allowedHosts.length ? allowedHosts : config.updateAllowedHosts,
|
||||
timeoutMs: config.updateTimeoutMs,
|
||||
maxBytes: config.updateMaxBytes,
|
||||
dataBackupMaxBytes: config.maxTotalBytes,
|
||||
currentVersion: config.appVersion,
|
||||
|
||||
Reference in New Issue
Block a user