fix: recover stuck background updates
TallyNote release / linux-x64 (push) Successful in 6m3s

This commit is contained in:
Qiufeng
2026-09-03 06:49:48 +08:00
parent b431fe167e
commit ee89e04aae
11 changed files with 331 additions and 36 deletions
+6
View File
@@ -59,6 +59,7 @@ import {
checkForUpdate,
publicCheckFromCache,
publicUpdateJob,
reconcileOrphanedUpdateJobs,
readCachedRelease,
writeUpdateRequest,
type UpdateRequest,
@@ -939,6 +940,7 @@ export async function buildApp(database: DatabaseContext, config: AppConfig) {
// Release metadata and task state should never be stored by an upstream
// proxy or a shared browser cache.
reply.header("Cache-Control", "no-store");
reconcileOrphanedUpdateJobs(database.sqlite, config);
const cached = publicCheckFromCache(database.sqlite, config);
const row = database.sqlite.prepare(`
SELECT id, operation, status, version, platform, asset_name AS assetName,
@@ -956,6 +958,7 @@ export async function buildApp(database: DatabaseContext, config: AppConfig) {
app.post("/api/update/check", { preHandler: guard(database, config), bodyLimit: 32 * 1024 }, async (request, reply) => {
try {
reconcileOrphanedUpdateJobs(database.sqlite, config);
// Disabled/dev installs do not contact a release endpoint, so repeated
// checks are local status reads and should remain immediately usable.
if (config.updateStrategy !== "disabled") {
@@ -995,6 +998,7 @@ export async function buildApp(database: DatabaseContext, config: AppConfig) {
let applyAuditRecorded = false;
let applyAuditTarget: string | undefined;
try {
reconcileOrphanedUpdateJobs(database.sqlite, config);
if (config.updateStrategy !== "systemd") {
throw new AppError(503, "UPDATE_NOT_AVAILABLE", "当前安装方式未启用一键更新,请使用命令行更新");
}
@@ -1145,6 +1149,7 @@ export async function buildApp(database: DatabaseContext, config: AppConfig) {
app.post("/api/update/download", { preHandler: guard(database, config), bodyLimit: 32 * 1024 }, async (request, reply) => {
const input = updateDownloadSchema.parse(request.body);
reconcileOrphanedUpdateJobs(database.sqlite, config);
if (config.updateStrategy !== "systemd") throw new AppError(503, "UPDATE_NOT_AVAILABLE", "当前安装方式未启用一键更新,请使用命令行更新");
const active = database.sqlite.prepare(`SELECT id FROM update_jobs WHERE status IN (${ACTIVE_UPDATE_STATUSES.map(() => "?").join(",")}) LIMIT 1`).get(...ACTIVE_UPDATE_STATUSES) as { id: string } | undefined;
if (active) throw new AppError(409, "UPDATE_IN_PROGRESS", "已有更新任务正在进行,请等待完成");
@@ -1175,6 +1180,7 @@ export async function buildApp(database: DatabaseContext, config: AppConfig) {
app.get("/api/update/jobs/:id", { preHandler: guard(database, config) }, async (request, reply) => {
const id = z.string().uuid().parse((request.params as { id: string }).id);
reconcileOrphanedUpdateJobs(database.sqlite, config);
const row = database.sqlite.prepare(`
SELECT id, operation, status, version, platform, asset_name AS assetName,
size_bytes AS sizeBytes, error_message AS errorMessage,
+33 -24
View File
@@ -26,7 +26,7 @@ import {
type ReleaseMetadata,
type UrlPolicy,
} from "../update.js";
import { attachSidecarHash } from "../update-service.js";
import { ACTIVE_UPDATE_STATUSES, attachSidecarHash } from "../update-service.js";
import type { UpdateJobStatus } from "../../shared/contracts.js";
const updateRequestFileSchema = z.object({
@@ -153,7 +153,7 @@ function writeJob(sqlite: Database.Database | undefined, jobId: string, values:
operation, status, version, platform, release_url, asset_name, asset_url,
expected_sha256, actual_sha256, download_path, backup_path, size_bytes, error_message,
created_at, updated_at, completed_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(id) DO UPDATE SET
admin_id=COALESCE(excluded.admin_id, update_jobs.admin_id),
session_hash=COALESCE(excluded.session_hash, update_jobs.session_hash),
@@ -355,7 +355,9 @@ export function finalizeUpdateJob(
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("更新任务不存在");
if (row.status !== "applying" && row.status !== "completed" && row.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" ? "新版本健康检查失败,已恢复上一版本" : null;
sqlite.transaction(() => {
@@ -477,27 +479,34 @@ export async function main(config: AppConfig = loadConfig()): Promise<void> {
const database = openDatabase(config);
try {
if (request?.operation === "apply") {
const staged = database.sqlite.prepare("SELECT download_path AS downloadPath, version FROM update_jobs WHERE id=? AND status='staged' AND operation='apply'").get(request.jobId) as { downloadPath: string | null; version: string } | undefined;
if (!staged?.downloadPath || staged.version !== request.version) throw new Error("更新暂存任务无效");
const root = path.resolve(config.updateWorkspaceDir);
const candidate = await validateStagedWorkspacePath(staged.downloadPath, root);
await applyStagedUpdate({
sqlite: database.sqlite,
jobId: request.jobId,
version: request.version,
stagedPath: candidate,
currentDir,
currentLink: request.currentLink,
releasesDir: request.releasesDir,
workspaceRoot: root,
...(backupArchive ? { backupArchivePath: backupArchive } : {}),
...(dataBackupArchive ? { dataBackupArchivePath: dataBackupArchive } : {}),
dataBackupSource: config.dataDir,
maxBytes: config.updateMaxBytes,
dataBackupMaxBytes: config.maxTotalBytes,
});
console.log(`更新已切换:${request.version}`);
return;
const staged = database.sqlite.prepare("SELECT status, operation, download_path AS downloadPath, version FROM update_jobs WHERE id=?").get(request.jobId) as { status: UpdateJobStatus; operation: "download" | "apply"; downloadPath: string | null; version: string } | undefined;
if (staged?.status === "staged" && staged.operation === "apply") {
if (!staged.downloadPath || staged.version !== request.version) throw new Error("更新暂存任务无效");
const root = path.resolve(config.updateWorkspaceDir);
const candidate = await validateStagedWorkspacePath(staged.downloadPath, root);
await applyStagedUpdate({
sqlite: database.sqlite,
jobId: request.jobId,
version: request.version,
stagedPath: candidate,
currentDir,
currentLink: request.currentLink,
releasesDir: request.releasesDir,
workspaceRoot: root,
...(backupArchive ? { backupArchivePath: backupArchive } : {}),
...(dataBackupArchive ? { dataBackupArchivePath: dataBackupArchive } : {}),
dataBackupSource: config.dataDir,
maxBytes: config.updateMaxBytes,
dataBackupMaxBytes: config.maxTotalBytes,
});
console.log(`更新已切换:${request.version}`);
return;
}
if (staged && !(staged.status === "queued" && staged.operation === "apply")) throw new Error("更新任务状态无效");
// A direct one-click request starts in queued/apply. Older clients do
// not have a separate download step, so fall through to runUpdate,
// which downloads, verifies, backs up, and switches the release in one
// transaction. A staged request still takes the branch above.
}
const result = await runUpdate({
...(effectiveMetadataUrl ? { metadataUrl: effectiveMetadataUrl } : {}),
+77
View File
@@ -1,7 +1,9 @@
import { lstatSync, realpathSync } from "node:fs";
import { chmod, mkdir, rename, writeFile } from "node:fs/promises";
import path from "node:path";
import { createPublicKey, randomUUID, verify as verifySignature } from "node:crypto";
import type Database from "better-sqlite3";
import { writeAudit } from "./audit.js";
import { AppError } from "./errors.js";
import type { AppConfig } from "./config.js";
import {
@@ -30,6 +32,12 @@ export const ACTIVE_UPDATE_STATUSES: readonly UpdateJobStatus[] = [
"applying",
];
// A queued job normally starts within seconds and an applying job completes
// after the service health check. This grace period only applies when both
// hand-off markers are gone, so an active runner is never reclaimed midway
// through a download, backup, or switch.
export const ORPHANED_UPDATE_TIMEOUT_MS = 5 * 60 * 1000;
export type CachedRelease = {
checkedAt: number;
metadataUrl: string;
@@ -355,3 +363,72 @@ export function publicUpdateJob(row: Record<string, unknown> | undefined): Recor
...(row.status === "applying" ? { restartWindowSeconds: 30 } : {}),
};
}
function markerExists(filePath: string): boolean {
try {
const info = lstatSync(filePath);
return info.isFile() || info.isSymbolicLink();
} catch {
return false;
}
}
function currentReleaseVersion(config: AppConfig): string | null {
try {
const target = realpathSync(config.currentLink);
const releases = realpathSync(config.releasesDir);
if (!target.startsWith(`${releases}${path.sep}`)) return null;
return path.basename(target);
} catch {
return null;
}
}
/**
* Release an update row left behind after both privileged hand-off markers
* disappeared. This is deliberately conservative: staged downloads remain
* available for an explicit apply, and any visible marker means the runner
* still owns recovery.
*/
export function reconcileOrphanedUpdateJobs(database: Database.Database, config: AppConfig, now = Date.now()): number {
const placeholders = ACTIVE_UPDATE_STATUSES.map(() => "?").join(",");
const rows = database.prepare(`
SELECT id, status, version, admin_id AS adminId, request_id AS requestId,
updated_at AS updatedAt
FROM update_jobs
WHERE status IN (${placeholders})
ORDER BY updated_at ASC
`).all(...ACTIVE_UPDATE_STATUSES) as Array<{ id: string; status: UpdateJobStatus; version: string; adminId: string | null; requestId: string | null; updatedAt: number | null }>;
if (rows.length === 0) return 0;
const requestPresent = markerExists(config.updateRequestPath);
const statePresent = markerExists(path.join(config.installPrefix, ".update-state"));
if (requestPresent || statePresent) return 0;
const releaseVersion = currentReleaseVersion(config);
let reconciled = 0;
for (const row of rows) {
if (row.status === "staged" || typeof row.updatedAt !== "number" || now - row.updatedAt < ORPHANED_UPDATE_TIMEOUT_MS) continue;
const status: "completed" | "failed" = row.status === "applying" && releaseVersion === row.version ? "completed" : "failed";
const errorMessage = status === "failed" ? "更新任务超时,已释放更新队列" : null;
const changed = database.transaction(() => {
const result = database.prepare(`
UPDATE update_jobs
SET status=?, error_message=?, completed_at=?, updated_at=?
WHERE id=? AND status=? AND updated_at=?
`).run(status, errorMessage, now, now, row.id, row.status, row.updatedAt);
if (result.changes !== 1) return false;
writeAudit(database, {
requestId: row.requestId || randomUUID(),
actorAdminId: row.adminId,
action: "update.reconciled",
targetType: "update",
targetId: row.id,
outcome: status === "completed" ? "success" : "failure",
before: { status: row.status, version: row.version },
after: { status, version: row.version, reason: "orphaned_timeout" },
});
return true;
})();
if (changed) reconciled += 1;
}
return reconciled;
}