同步异常兜底

This commit is contained in:
tianmo committed 2026-10-17 15:08:21 +08:00
1 parent 9cb8552a99
commit a1b4a63552
20 files changed
+754 -54

No files matched your search

+29
View File
@@ -57,6 +57,11 @@ const fileProgress = computed(() => {
} }
}) })
const isPull = computed(() => direction.value === 'pull') const isPull = computed(() => direction.value === 'pull')
const canAbandon = computed(() =>
!props.busy
&& Boolean(props.resumableJob)
&& (isPull.value || Boolean(token.value.trim()))
)
const directionSubtitle = computed(() => ( const directionSubtitle = computed(() => (
isPull.value isPull.value
? t('storage.backup.siteSyncPullSubtitle') ? t('storage.backup.siteSyncPullSubtitle')
@@ -103,6 +108,19 @@ function resumeSync() {
submit(true) submit(true)
} }
function abandonSync() {
if (!canAbandon.value || !props.resumableJob) return
if (!window.confirm(t('storage.backup.siteSyncAbandonConfirm'))) return
emit('sync', {
targetBaseUrl: targetBaseUrl.value.trim(),
token: token.value.trim(),
conflictMode: conflictMode.value,
direction: direction.value,
abandonJobId: props.resumableJob.id
})
token.value = ''
}
function setConflictMode(mode: 'skip' | 'overwrite') { function setConflictMode(mode: 'skip' | 'overwrite') {
conflictMode.value = mode conflictMode.value = mode
} }
@@ -171,6 +189,17 @@ function setDirection(value: SiteSyncDirection) {
class="lg:col-start-3" class="lg:col-start-3"
@click="resumeSync" @click="resumeSync"
/> />
<UButton
v-if="resumableJob"
:label="t('storage.backup.siteSyncAbandon')"
icon="i-lucide-trash-2"
color="error"
variant="outline"
:loading="busy"
:disabled="!canAbandon"
class="lg:col-start-3"
@click="abandonSync"
/>
</div> </div>
<div class="mt-3 grid gap-3 sm:grid-cols-2"> <div class="mt-3 grid gap-3 sm:grid-cols-2">
+6 -1
View File
@@ -268,7 +268,12 @@ export function useBackup() {
) )
await refreshJobsFirstPage() await refreshJobsFirstPage()
startPolling() startPolling()
toast.add({ title: t('storage.backup.siteSyncStarted'), color: 'success' }) toast.add({
title: body.abandonJobId
? t('storage.backup.siteSyncAbandoned')
: t('storage.backup.siteSyncStarted'),
color: 'success'
})
return data.job return data.job
} catch (error: unknown) { } catch (error: unknown) {
handleError(error, t('storage.backup.siteSyncFailed')) handleError(error, t('storage.backup.siteSyncFailed'))
+1
View File
@@ -59,4 +59,5 @@ export interface SiteSyncRequest {
conflictMode: 'skip' | 'overwrite' conflictMode: 'skip' | 'overwrite'
direction: SiteSyncDirection direction: SiteSyncDirection
resumeJobId?: string resumeJobId?: string
abandonJobId?: string
} }
+3
View File
@@ -665,6 +665,9 @@
"siteSyncSkip": "Skip existing", "siteSyncSkip": "Skip existing",
"siteSyncOverwrite": "Overwrite existing", "siteSyncOverwrite": "Overwrite existing",
"siteSyncResume": "Resume last sync", "siteSyncResume": "Resume last sync",
"siteSyncAbandon": "Abandon and clean up",
"siteSyncAbandonConfirm": "Abandon this sync and delete partial objects created by it that are not yet referenced by a media record?",
"siteSyncAbandoned": "Sync abandoned and partial objects cleaned up",
"siteSyncScanProgress": "Media scan progress", "siteSyncScanProgress": "Media scan progress",
"siteSyncFileProgress": "Synced file count", "siteSyncFileProgress": "Synced file count",
"siteSyncStarted": "Site-to-site sync started", "siteSyncStarted": "Site-to-site sync started",
+3
View File
@@ -665,6 +665,9 @@
"siteSyncSkip": "跳过已存在", "siteSyncSkip": "跳过已存在",
"siteSyncOverwrite": "覆盖已存在", "siteSyncOverwrite": "覆盖已存在",
"siteSyncResume": "继续上次同步", "siteSyncResume": "继续上次同步",
"siteSyncAbandon": "放弃并清理残留",
"siteSyncAbandonConfirm": "确定放弃这次同步吗?将删除本次同步新建但尚未归入媒体记录的残留对象。",
"siteSyncAbandoned": "同步已放弃,残留对象已清理",
"siteSyncScanProgress": "媒体扫描进度", "siteSyncScanProgress": "媒体扫描进度",
"siteSyncFileProgress": "已同步文件数量", "siteSyncFileProgress": "已同步文件数量",
"siteSyncStarted": "两站点同步任务已开始", "siteSyncStarted": "两站点同步任务已开始",
+15 -1
View File
@@ -5,6 +5,7 @@ import {
resumeSiteSyncJob, resumeSiteSyncJob,
startSiteSyncJob startSiteSyncJob
} from '../../utils/backup/runner' } from '../../utils/backup/runner'
import { abandonSiteSyncJob } from '../../utils/site-sync/abandon'
interface SiteSyncBody { interface SiteSyncBody {
targetBaseUrl?: string targetBaseUrl?: string
@@ -12,6 +13,7 @@ interface SiteSyncBody {
conflictMode?: 'skip' | 'overwrite' conflictMode?: 'skip' | 'overwrite'
direction?: 'push' | 'pull' direction?: 'push' | 'pull'
resumeJobId?: string resumeJobId?: string
abandonJobId?: string
} }
export default defineEventHandler(async (event) => { export default defineEventHandler(async (event) => {
@@ -22,11 +24,15 @@ export default defineEventHandler(async (event) => {
const conflictMode = body?.conflictMode === 'skip' ? 'skip' : 'overwrite' const conflictMode = body?.conflictMode === 'skip' ? 'skip' : 'overwrite'
const direction = body?.direction === 'pull' ? 'pull' : 'push' const direction = body?.direction === 'pull' ? 'pull' : 'push'
if (!token) { if (!token && !body?.abandonJobId) {
createApiError(event, 'INVALID_REQUEST', '请输入目标站点管理员 API Token', 400) createApiError(event, 'INVALID_REQUEST', '请输入目标站点管理员 API Token', 400)
} }
try { try {
if (body?.abandonJobId) {
const job = await abandonSiteSyncJob(body.abandonJobId, token, user.id)
return { job: serializeBackupJob(job) }
}
let job let job
if (body?.resumeJobId) { if (body?.resumeJobId) {
job = resumeSiteSyncJob(body.resumeJobId, token, user.id) job = resumeSiteSyncJob(body.resumeJobId, token, user.id)
@@ -54,6 +60,14 @@ export default defineEventHandler(async (event) => {
if (error instanceof Error && error.message === 'SITE_SYNC_JOB_NOT_RESUMABLE') { if (error instanceof Error && error.message === 'SITE_SYNC_JOB_NOT_RESUMABLE') {
createApiError(event, 'INVALID_REQUEST', '该同步任务无法继续', 400) createApiError(event, 'INVALID_REQUEST', '该同步任务无法继续', 400)
} }
if (body?.abandonJobId) {
createApiError(
event,
'INVALID_REQUEST',
error instanceof Error ? error.message : '放弃同步失败',
400
)
}
throw error throw error
} }
}) })
+20
View File
@@ -0,0 +1,20 @@
import { requireSiteSyncAuth } from '../../utils/access'
import { createApiError } from '../../utils/api-error'
import { cleanupSiteSyncJobArtifacts } from '../../utils/site-sync/artifacts'
interface Body {
jobId?: string
}
export default defineEventHandler(async (event) => {
await requireSiteSyncAuth(event)
const body = await readBody<Body>(event).catch(() => null)
const jobId = body?.jobId?.trim() ?? ''
if (!jobId || jobId.length > 128) {
createApiError(event, 'INVALID_REQUEST', '缺少同步任务 ID', 400)
}
return {
success: true,
...await cleanupSiteSyncJobArtifacts(jobId)
}
})
+21
View File
@@ -0,0 +1,21 @@
import { requireSiteSyncAuth } from '../../utils/access'
import { createApiError } from '../../utils/api-error'
import { validateStorageKey } from '../../utils/image-key'
import { completeSiteSyncMediaArtifacts } from '../../utils/site-sync/artifacts'
interface Body {
jobId?: string
mediaKey?: string
}
export default defineEventHandler(async (event) => {
await requireSiteSyncAuth(event)
const body = await readBody<Body>(event).catch(() => null)
const jobId = body?.jobId?.trim() ?? ''
const mediaKey = body?.mediaKey?.trim() ?? ''
if (!jobId || jobId.length > 128 || !validateStorageKey(mediaKey)) {
createApiError(event, 'INVALID_REQUEST', '同步完成标记参数无效', 400)
}
completeSiteSyncMediaArtifacts(jobId, mediaKey)
return { success: true }
})
+7 -1
View File
@@ -2,11 +2,13 @@ import { requireSiteSyncAuth } from '../../utils/access'
import { createApiError } from '../../utils/api-error' import { createApiError } from '../../utils/api-error'
import { validateStorageKey } from '../../utils/image-key' import { validateStorageKey } from '../../utils/image-key'
import { applySiteSyncMedia } from '../../utils/site-sync/apply' import { applySiteSyncMedia } from '../../utils/site-sync/apply'
import { completeSiteSyncMediaArtifacts } from '../../utils/site-sync/artifacts'
import type { SiteSyncMediaTransfer } from '../../utils/site-sync/protocol' import type { SiteSyncMediaTransfer } from '../../utils/site-sync/protocol'
interface Body { interface Body {
media?: SiteSyncMediaTransfer media?: SiteSyncMediaTransfer
conflictMode?: 'skip' | 'overwrite' conflictMode?: 'skip' | 'overwrite'
jobId?: string
} }
export default defineEventHandler(async (event) => { export default defineEventHandler(async (event) => {
@@ -18,7 +20,11 @@ export default defineEventHandler(async (event) => {
} }
const conflictMode = body?.conflictMode === 'skip' ? 'skip' : 'overwrite' const conflictMode = body?.conflictMode === 'skip' ? 'skip' : 'overwrite'
try { try {
return await applySiteSyncMedia(media, conflictMode, admin.id) const result = await applySiteSyncMedia(media, conflictMode, admin.id)
if (body?.jobId) {
completeSiteSyncMediaArtifacts(body.jobId, media.key)
}
return result
} catch (error) { } catch (error) {
const message = error instanceof Error ? error.message : '媒体同步失败' const message = error instanceof Error ? error.message : '媒体同步失败'
createApiError(event, 'INVALID_REQUEST', message, 400) createApiError(event, 'INVALID_REQUEST', message, 400)
+14
View File
@@ -5,6 +5,7 @@ import { MAX_VIDEO_FILE_SIZE } from '../../utils/constants'
import { findUserByUsername } from '../../utils/db' import { findUserByUsername } from '../../utils/db'
import { validateStorageKey } from '../../utils/image-key' import { validateStorageKey } from '../../utils/image-key'
import { getActiveBackend } from '../../utils/storage/resolver' import { getActiveBackend } from '../../utils/storage/resolver'
import { recordSiteSyncArtifact } from '../../utils/site-sync/artifacts'
function field(form: Awaited<ReturnType<typeof readMultipartFormData>>, name: string): string { function field(form: Awaited<ReturnType<typeof readMultipartFormData>>, name: string): string {
const part = form?.find(item => item.name === name) const part = form?.find(item => item.name === name)
@@ -30,6 +31,7 @@ export default defineEventHandler(async (event) => {
if (conflictMode === 'skip' && await backend.head(key)) { if (conflictMode === 'skip' && await backend.head(key)) {
return { success: true, skipped: true } return { success: true, skipped: true }
} }
const existedBefore = Boolean(await backend.head(key))
const ownerUsername = field(form, 'ownerUsername') const ownerUsername = field(form, 'ownerUsername')
const owner = ownerUsername ? findUserByUsername(ownerUsername) : null const owner = ownerUsername ? findUserByUsername(ownerUsername) : null
@@ -37,6 +39,18 @@ export default defineEventHandler(async (event) => {
createApiError(event, 'INVALID_REQUEST', `目标站点缺少用户:${ownerUsername}`, 400) createApiError(event, 'INVALID_REQUEST', `目标站点缺少用户:${ownerUsername}`, 400)
} }
const jobId = field(form, 'jobId')
const mediaKey = field(form, 'mediaKey')
if (jobId && validateStorageKey(mediaKey)) {
recordSiteSyncArtifact({
jobId,
mediaKey,
key,
backendId: backend.id,
created: !existedBefore
})
}
await backend.put(key, new Uint8Array(file.data), { await backend.put(key, new Uint8Array(file.data), {
originalName: field(form, 'originalName') || file.filename || key.split('/').pop() || 'file', originalName: field(form, 'originalName') || file.filename || key.split('/').pop() || 'file',
uploadedAt: field(form, 'uploadedAt') || new Date().toISOString(), uploadedAt: field(form, 'uploadedAt') || new Date().toISOString(),
+34
View File
@@ -5,6 +5,9 @@ import { join } from 'node:path'
import { runAutoDeleteCleanup } from '../utils/auto-delete' import { runAutoDeleteCleanup } from '../utils/auto-delete'
import { migrateLocalImagesToIndex, purgeOrphanImageIndexRows, reconcileLegacyImageKeys, repairLocalImageIndexMetadata } from '../utils/image-index' import { migrateLocalImagesToIndex, purgeOrphanImageIndexRows, reconcileLegacyImageKeys, repairLocalImageIndexMetadata } from '../utils/image-index'
import { ensureDefaultBackends } from '../utils/storage-backends' import { ensureDefaultBackends } from '../utils/storage-backends'
import { cleanupLocalAtomicTempFiles } from '../utils/storage/local'
import { cleanupExpiredSiteSyncArtifacts } from '../utils/site-sync/artifacts'
import { recoverInterruptedBackupJobs } from '../utils/backup/jobs'
import { import {
cleanupTranscodedSources, cleanupTranscodedSources,
killOrphanFfmpeg, killOrphanFfmpeg,
@@ -26,6 +29,37 @@ export default defineNitroPlugin(() => {
ensureDefaultBackends() ensureDefaultBackends()
try {
const interrupted = recoverInterruptedBackupJobs()
if (interrupted > 0) {
logInfo('已将异常退出时未完成的任务标记为失败', { interrupted })
}
} catch (error) {
logException('恢复异常退出的备份任务失败', error)
}
void (async () => {
try {
const removed = await cleanupLocalAtomicTempFiles()
if (removed > 0) {
logInfo('清理过期的原子写入临时文件', { removed })
}
} catch (error) {
logException('清理原子写入临时文件失败', error)
}
})()
void (async () => {
try {
const result = await cleanupExpiredSiteSyncArtifacts()
if (result.jobs > 0) {
logInfo('清理过期站点同步残留', result)
}
} catch (error) {
logException('清理站点同步残留失败', error)
}
})()
/** 清理上次异常退出留下的转码临时目录 */ /** 清理上次异常退出留下的转码临时目录 */
void (async () => { void (async () => {
try { try {
+64
View File
@@ -0,0 +1,64 @@
interface JobCancellationState {
requested: Set<string>
controllers: Map<string, AbortController>
}
const CANCELLED_CODE = 'JOB_CANCELLED'
const state = (globalThis as typeof globalThis & {
__pichostJobCancellationState?: JobCancellationState
}).__pichostJobCancellationState ?? {
requested: new Set<string>(),
controllers: new Map<string, AbortController>()
}
;(globalThis as typeof globalThis & {
__pichostJobCancellationState?: JobCancellationState
}).__pichostJobCancellationState = state
export function createJobCancelledError(): Error {
const error = new Error('任务已取消')
error.name = CANCELLED_CODE
return error
}
export function isJobCancelledError(error: unknown): boolean {
return error instanceof Error && error.name === CANCELLED_CODE
}
export function beginJobCancellationScope(jobId: string): void {
state.requested.delete(jobId)
state.controllers.get(jobId)?.abort()
state.controllers.set(jobId, new AbortController())
}
export function endJobCancellationScope(jobId: string): void {
state.controllers.delete(jobId)
state.requested.delete(jobId)
}
export function requestJobCancellation(jobId: string): void {
state.requested.add(jobId)
state.controllers.get(jobId)?.abort(createJobCancelledError())
}
export function isJobCancellationRequested(jobId: string): boolean {
return state.requested.has(jobId)
}
export function getJobAbortSignal(jobId: string): AbortSignal {
return state.controllers.get(jobId)?.signal ?? new AbortController().signal
}
export function getJobOperationSignal(
jobId: string,
timeoutMs: number
): AbortSignal {
return AbortSignal.any([
getJobAbortSignal(jobId),
AbortSignal.timeout(timeoutMs)
])
}
export function throwIfJobCancellationRequested(jobId: string): void {
if (isJobCancellationRequested(jobId)) throw createJobCancelledError()
}
+13 -7
View File
@@ -10,6 +10,11 @@ import {
recoverInterruptedBackupJobs, recoverInterruptedBackupJobs,
type BackupJob type BackupJob
} from './jobs' } from './jobs'
import {
beginJobCancellationScope,
endJobCancellationScope,
requestJobCancellation
} from './cancel-state'
interface BackupRunnerState { interface BackupRunnerState {
running: boolean running: boolean
@@ -42,11 +47,8 @@ export function cancelBackupJob(jobId: string): boolean {
if (!job || (job.status !== 'running' && job.status !== 'pending')) { if (!job || (job.status !== 'running' && job.status !== 'pending')) {
return false return false
} }
markJobFailed(jobId, '任务已取消') requestJobCancellation(jobId)
if (runnerState.currentJobId === jobId) { markJobFailed(jobId, '任务已取消', '已请求取消,正在停止…')
runnerState.running = false
runnerState.currentJobId = null
}
return true return true
} }
@@ -59,14 +61,18 @@ export function scheduleBackupJob(
} }
runnerState.running = true runnerState.running = true
runnerState.currentJobId = job.id runnerState.currentJobId = job.id
beginJobCancellationScope(job.id)
markJobRunning(job.id) markJobRunning(job.id)
setImmediate(() => { setImmediate(() => {
runner() runner()
.catch(() => {}) .catch(() => {})
.finally(() => { .finally(() => {
runnerState.running = false endJobCancellationScope(job.id)
runnerState.currentJobId = null if (runnerState.currentJobId === job.id) {
runnerState.running = false
runnerState.currentJobId = null
}
}) })
}) })
} }
+66
View File
@@ -0,0 +1,66 @@
import {
getBackupJob,
updateBackupJob,
type BackupJob
} from '../backup/jobs'
import { cleanupSiteSyncJobArtifacts } from './artifacts'
import { fetchSiteSyncJson } from './runner'
interface CleanupResult {
deleted: number
referenced: number
failed: number
}
export async function abandonSiteSyncJob(
jobId: string,
token: string,
userId: number
): Promise<BackupJob> {
const job = getBackupJob(jobId, userId)
if (!job || job.type !== 'site_sync') throw new Error('同步任务不存在')
if (job.status !== 'failed') throw new Error('只能放弃已停止的同步任务')
const direction = job.payload.direction === 'pull' ? 'pull' : 'push'
const targetBaseUrl = typeof job.payload.targetBaseUrl === 'string'
? job.payload.targetBaseUrl.replace(/\/+$/, '')
: ''
let cleanup: CleanupResult
if (direction === 'push') {
if (!targetBaseUrl || !token) {
throw new Error('放弃推送任务并清理目标站残留时,需要目标站 Token')
}
cleanup = await fetchSiteSyncJson<CleanupResult>(
`${targetBaseUrl}/api/site-sync/cleanup`,
token,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ jobId })
}
)
} else {
cleanup = await cleanupSiteSyncJobArtifacts(jobId)
}
if (cleanup.failed > 0) {
throw new Error(`同步残留清理失败:${cleanup.failed} 个对象`)
}
const message = `已放弃同步 · 清理 ${cleanup.deleted} 个残留对象`
updateBackupJob(jobId, {
status: 'failed',
message,
error: '已放弃同步',
finishedAt: new Date().toISOString(),
payload: {
...job.payload,
resumable: false,
abandoned: true,
cancelled: false,
failedKeys: [],
cleanup
}
})
return getBackupJob(jobId, userId)!
}
+172
View File
@@ -0,0 +1,172 @@
import { getDb } from '../db'
import { getImageIndexRow } from '../image-index'
import { getBackendById } from '../storage/resolver'
import { hlsSegmentNames } from '../transcode'
interface ArtifactRow {
job_id: string
media_key: string
key: string
backend_id: string
created: number
updated_at: string
}
function ensureArtifactSchema(): void {
getDb().exec(`
CREATE TABLE IF NOT EXISTS site_sync_artifacts (
job_id TEXT NOT NULL,
media_key TEXT NOT NULL,
key TEXT NOT NULL,
backend_id TEXT NOT NULL,
created INTEGER NOT NULL DEFAULT 0,
updated_at TEXT NOT NULL,
PRIMARY KEY(job_id, key)
);
CREATE INDEX IF NOT EXISTS idx_site_sync_artifacts_updated
ON site_sync_artifacts(updated_at);
CREATE INDEX IF NOT EXISTS idx_site_sync_artifacts_media
ON site_sync_artifacts(job_id, media_key);
`)
}
export function recordSiteSyncArtifact(input: {
jobId: string
mediaKey: string
key: string
backendId: string
created: boolean
}): void {
ensureArtifactSchema()
getDb().prepare(`
INSERT INTO site_sync_artifacts
(job_id, media_key, key, backend_id, created, updated_at)
VALUES (?, ?, ?, ?, ?, ?)
ON CONFLICT(job_id, key) DO UPDATE SET
media_key = excluded.media_key,
backend_id = excluded.backend_id,
created = MAX(site_sync_artifacts.created, excluded.created),
updated_at = excluded.updated_at
`).run(
input.jobId,
input.mediaKey,
input.key,
input.backendId,
input.created ? 1 : 0,
new Date().toISOString()
)
}
export function completeSiteSyncMediaArtifacts(
jobId: string,
mediaKey: string
): void {
ensureArtifactSchema()
getDb().prepare(`
DELETE FROM site_sync_artifacts
WHERE job_id = ? AND media_key = ?
`).run(jobId, mediaKey)
}
async function streamToString(stream: NodeJS.ReadableStream): Promise<string> {
const chunks: Buffer[] = []
for await (const chunk of stream) {
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk))
}
return Buffer.concat(chunks).toString('utf8')
}
async function isReferencedByCommittedMedia(
mediaKey: string,
key: string
): Promise<boolean> {
const row = getImageIndexRow(mediaKey)
if (!row) return false
if (key === row.key || key === row.thumb_key || key === row.hls_key) {
return true
}
if (!row.hls_key) return false
const slash = row.hls_key.lastIndexOf('/')
if (slash < 0 || !key.startsWith(`${row.hls_key.slice(0, slash)}/`)) {
return false
}
try {
const backend = await getBackendById(row.backend_id)
if (!backend) return true
const playlist = await streamToString(await backend.createStream(row.hls_key))
const dir = row.hls_key.slice(0, slash)
return hlsSegmentNames(playlist).some(name => key === `${dir}/${name}`)
} catch {
// If the playlist cannot be read, keep the object rather than risk data loss.
return true
}
}
export async function cleanupSiteSyncJobArtifacts(jobId: string): Promise<{
deleted: number
referenced: number
failed: number
}> {
ensureArtifactSchema()
const rows = getDb().prepare(`
SELECT job_id, media_key, key, backend_id, created, updated_at
FROM site_sync_artifacts
WHERE job_id = ?
ORDER BY updated_at ASC
`).all(jobId) as unknown as ArtifactRow[]
let deleted = 0
let referenced = 0
let failed = 0
for (const row of rows) {
if (Number(row.created) !== 1) {
getDb().prepare(`
DELETE FROM site_sync_artifacts WHERE job_id = ? AND key = ?
`).run(jobId, row.key)
continue
}
try {
if (await isReferencedByCommittedMedia(row.media_key, row.key)) {
referenced++
} else {
const backend = await getBackendById(row.backend_id)
if (!backend) throw new Error('存储后端不存在')
if (await backend.head(row.key)) {
await backend.delete(row.key)
deleted++
}
}
getDb().prepare(`
DELETE FROM site_sync_artifacts WHERE job_id = ? AND key = ?
`).run(jobId, row.key)
} catch {
failed++
}
}
return { deleted, referenced, failed }
}
export async function cleanupExpiredSiteSyncArtifacts(
maxAgeMs = 24 * 60 * 60 * 1000
): Promise<{ jobs: number, deleted: number, failed: number }> {
ensureArtifactSchema()
const cutoff = new Date(Date.now() - maxAgeMs).toISOString()
const rows = getDb().prepare(`
SELECT DISTINCT job_id
FROM site_sync_artifacts
WHERE updated_at < ?
`).all(cutoff) as Array<{ job_id: string }>
let deleted = 0
let failed = 0
for (const row of rows) {
const result = await cleanupSiteSyncJobArtifacts(row.job_id)
deleted += result.deleted
failed += result.failed
}
return { jobs: rows.length, deleted, failed }
}
+19
View File
@@ -1,5 +1,11 @@
import { afterEach, describe, expect, it, vi } from 'vitest' import { afterEach, describe, expect, it, vi } from 'vitest'
import { fetchSiteSyncJson, retrySiteSyncOperation } from './runner' import { fetchSiteSyncJson, retrySiteSyncOperation } from './runner'
import {
beginJobCancellationScope,
endJobCancellationScope,
getJobAbortSignal,
requestJobCancellation
} from '../backup/cancel-state'
afterEach(() => { afterEach(() => {
vi.unstubAllGlobals() vi.unstubAllGlobals()
@@ -52,4 +58,17 @@ describe('fetchSiteSyncJson', () => {
await expect(retrySiteSyncOperation(operation)).rejects.toThrow('database is busy') await expect(retrySiteSyncOperation(operation)).rejects.toThrow('database is busy')
expect(operation).toHaveBeenCalledTimes(3) expect(operation).toHaveBeenCalledTimes(3)
}) })
it('stops retrying immediately when the job is cancelled', async () => {
const jobId = 'cancel-test'
beginJobCancellationScope(jobId)
const operation = vi.fn().mockRejectedValue(new Error('connection reset'))
const result = retrySiteSyncOperation(operation, getJobAbortSignal(jobId))
setTimeout(() => requestJobCancellation(jobId), 25)
await expect(result).rejects.toThrow('任务已取消')
expect(operation).toHaveBeenCalledTimes(1)
endJobCancellationScope(jobId)
})
}) })
+195 -42
View File
@@ -1,4 +1,5 @@
import { basename } from 'node:path' import { basename } from 'node:path'
import type { Readable } from 'node:stream'
import { contentTypeFromKey } from '../content-type' import { contentTypeFromKey } from '../content-type'
import { import {
getImageIndexRow, getImageIndexRow,
@@ -22,7 +23,19 @@ import {
markJobFailed, markJobFailed,
updateBackupJob updateBackupJob
} from '../backup/jobs' } from '../backup/jobs'
import {
createJobCancelledError,
getJobAbortSignal,
getJobOperationSignal,
isJobCancellationRequested,
isJobCancelledError,
throwIfJobCancellationRequested
} from '../backup/cancel-state'
import { applySiteSyncMedia } from './apply' import { applySiteSyncMedia } from './apply'
import {
completeSiteSyncMediaArtifacts,
recordSiteSyncArtifact
} from './artifacts'
import { toSiteSyncMediaTransferWithTags } from './manifest' import { toSiteSyncMediaTransferWithTags } from './manifest'
import type { import type {
SiteSyncDirection, SiteSyncDirection,
@@ -49,9 +62,19 @@ export function registerSiteSyncToken(jobId: string, token: string): void {
tokens.set(jobId, token) tokens.set(jobId, token)
} }
async function streamToBuffer(stream: NodeJS.ReadableStream): Promise<Buffer> { const SITE_SYNC_JSON_TIMEOUT_MS = 2 * 60 * 1000
const SITE_SYNC_OBJECT_TIMEOUT_MS = 15 * 60 * 1000
async function streamToBuffer(
stream: Readable,
signal?: AbortSignal
): Promise<Buffer> {
const chunks: Buffer[] = [] const chunks: Buffer[] = []
for await (const chunk of stream) { for await (const chunk of stream) {
if (signal?.aborted) {
stream.destroy(createJobCancelledError())
throw createJobCancelledError()
}
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)) chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk))
} }
return Buffer.concat(chunks) return Buffer.concat(chunks)
@@ -71,21 +94,46 @@ async function responseError(response: Response): Promise<Error> {
const SITE_SYNC_MAX_ATTEMPTS = 3 const SITE_SYNC_MAX_ATTEMPTS = 3
function normalizeSiteSyncError(error: Error): Error {
if (error.name === 'TimeoutError') {
return new Error('目标站点连接超时或无响应')
}
if (error instanceof TypeError && /fetch failed|terminated/i.test(error.message)) {
return new Error('目标站点无法连接')
}
return error
}
export async function retrySiteSyncOperation<T>( export async function retrySiteSyncOperation<T>(
operation: () => Promise<T> operation: () => Promise<T>,
signal?: AbortSignal
): Promise<T> { ): Promise<T> {
if (signal?.aborted) throw createJobCancelledError()
let lastError: Error | null = null let lastError: Error | null = null
for (let attempt = 1; attempt <= SITE_SYNC_MAX_ATTEMPTS; attempt++) { for (let attempt = 1; attempt <= SITE_SYNC_MAX_ATTEMPTS; attempt++) {
try { try {
return await operation() return await operation()
} catch (error) { } catch (error) {
if (signal?.aborted) throw createJobCancelledError()
lastError = error instanceof Error ? error : new Error('两站点同步失败') lastError = error instanceof Error ? error : new Error('两站点同步失败')
if (attempt < SITE_SYNC_MAX_ATTEMPTS) { if (attempt < SITE_SYNC_MAX_ATTEMPTS) {
await new Promise(resolve => setTimeout(resolve, attempt * 500)) await new Promise<void>((resolve, reject) => {
const onAbort = () => {
clearTimeout(timer)
reject(createJobCancelledError())
}
const timer = setTimeout(() => {
signal?.removeEventListener('abort', onAbort)
resolve()
}, attempt * 500)
signal?.addEventListener('abort', onAbort, { once: true })
})
} }
} }
} }
throw lastError ?? new Error('两站点同步失败') throw lastError
? normalizeSiteSyncError(lastError)
: new Error('两站点同步失败')
} }
async function fetchSiteSyncJsonOnce<T>( async function fetchSiteSyncJsonOnce<T>(
@@ -107,14 +155,25 @@ async function fetchSiteSyncJsonOnce<T>(
export async function fetchSiteSyncJson<T>( export async function fetchSiteSyncJson<T>(
url: string, url: string,
token: string, token: string,
init?: RequestInit init?: RequestInit,
signal?: AbortSignal
): Promise<T> { ): Promise<T> {
return retrySiteSyncOperation(() => ( return retrySiteSyncOperation(
fetchSiteSyncJsonOnce<T>(url, token, init) () => fetchSiteSyncJsonOnce<T>(url, token, {
)) ...init,
signal: signal
? AbortSignal.any([
signal,
AbortSignal.timeout(SITE_SYNC_JSON_TIMEOUT_MS)
])
: AbortSignal.timeout(SITE_SYNC_JSON_TIMEOUT_MS)
}),
signal
)
} }
async function uploadObject( async function uploadObject(
jobId: string,
options: SiteSyncOptions, options: SiteSyncOptions,
token: string, token: string,
backend: StorageBackend, backend: StorageBackend,
@@ -124,9 +183,15 @@ async function uploadObject(
): Promise<void> { ): Promise<void> {
const stored = await backend.head(key) const stored = await backend.head(key)
if (!stored) throw new Error(`源文件不存在:${key}`) if (!stored) throw new Error(`源文件不存在:${key}`)
const bytes = await streamToBuffer(await backend.createStream(key)) const bytes = await streamToBuffer(
await backend.createStream(key),
getJobAbortSignal(jobId)
)
throwIfJobCancellationRequested(jobId)
const form = new FormData() const form = new FormData()
const isPrimary = key === row.key const isPrimary = key === row.key
form.append('jobId', jobId)
form.append('mediaKey', row.key)
form.append('key', key) form.append('key', key)
form.append('conflictMode', options.conflictMode) form.append('conflictMode', options.conflictMode)
form.append('ownerUsername', ownerUsername ?? '') form.append('ownerUsername', ownerUsername ?? '')
@@ -143,11 +208,16 @@ async function uploadObject(
await fetchSiteSyncJsonOnce<{ success: boolean }>( await fetchSiteSyncJsonOnce<{ success: boolean }>(
targetUrl(options.targetBaseUrl, '/api/site-sync/object'), targetUrl(options.targetBaseUrl, '/api/site-sync/object'),
token, token,
{ method: 'POST', body: form } {
method: 'POST',
body: form,
signal: getJobOperationSignal(jobId, SITE_SYNC_OBJECT_TIMEOUT_MS)
}
) )
} }
async function downloadSiteSyncObject( async function downloadSiteSyncObject(
jobId: string,
options: SiteSyncOptions, options: SiteSyncOptions,
token: string, token: string,
key: string key: string
@@ -160,7 +230,8 @@ async function downloadSiteSyncObject(
{ {
headers: { headers: {
'Auth-Token': token 'Auth-Token': token
} },
signal: getJobOperationSignal(jobId, SITE_SYNC_OBJECT_TIMEOUT_MS)
} }
) )
if (!response.ok) throw await responseError(response) if (!response.ok) throw await responseError(response)
@@ -173,6 +244,7 @@ async function runSiteSyncPull(
token: string token: string
): Promise<void> { ): Promise<void> {
const job = getBackupJob(jobId) const job = getBackupJob(jobId)
const jobSignal = getJobAbortSignal(jobId)
const checkpoint = job?.payload ?? {} const checkpoint = job?.payload ?? {}
let lastMediaKey = typeof checkpoint.lastMediaKey === 'string' let lastMediaKey = typeof checkpoint.lastMediaKey === 'string'
? checkpoint.lastMediaKey ? checkpoint.lastMediaKey
@@ -189,7 +261,7 @@ async function runSiteSyncPull(
: [] : []
) )
const saveCheckpoint = (message: string) => { const saveCheckpoint = (message: string, cancelled = false) => {
updateBackupJob(jobId, { updateBackupJob(jobId, {
progress: scanned, progress: scanned,
total: totalMedia, total: totalMedia,
@@ -198,6 +270,7 @@ async function runSiteSyncPull(
targetBaseUrl: options.targetBaseUrl, targetBaseUrl: options.targetBaseUrl,
conflictMode: options.conflictMode, conflictMode: options.conflictMode,
direction: 'pull', direction: 'pull',
cancelled,
lastMediaKey, lastMediaKey,
scanned, scanned,
added, added,
@@ -215,11 +288,19 @@ async function runSiteSyncPull(
`扫描 ${scanned}/${totalMedia || scanned} · 新增 ${added} · 跳过 ${skipped} · 失败 ${failedKeys.size}${suffix}` `扫描 ${scanned}/${totalMedia || scanned} · 新增 ${added} · 跳过 ${skipped} · 失败 ${failedKeys.size}${suffix}`
) )
const failAndCheckpoint = (error: unknown) => { const failAndCheckpoint = (error: unknown) => {
const message = progressMessage(' · 已保存断点') const cancelled = isJobCancellationRequested(jobId)
saveCheckpoint(message) || isJobCancelledError(error)
const message = cancelled
? progressMessage(' · 已取消,已保存断点')
: progressMessage(' · 已保存断点')
saveCheckpoint(message, cancelled)
markJobFailed( markJobFailed(
jobId, jobId,
error instanceof Error ? error.message : '从目标站点拉取失败', cancelled
? '任务已取消'
: error instanceof Error
? error.message
: '从目标站点拉取失败',
message message
) )
} }
@@ -228,21 +309,22 @@ async function runSiteSyncPull(
const targetUsers = new Set( const targetUsers = new Set(
(await fetchSiteSyncJson<SiteSyncUsers>( (await fetchSiteSyncJson<SiteSyncUsers>(
targetUrl(options.targetBaseUrl, '/api/site-sync/users'), targetUrl(options.targetBaseUrl, '/api/site-sync/users'),
token token,
undefined,
jobSignal
)).users )).users
) )
const localUserMap = listUserIdUsernameMap()
const localUsers = new Set(localUserMap.values())
const missingUsers = [...targetUsers].filter(name => !localUsers.has(name))
if (missingUsers.length) {
throw new Error(`当前站点缺少同名用户:${missingUsers.join('、')}`)
}
const backend = await retrySiteSyncOperation(() => getActiveBackend()) const backend = await retrySiteSyncOperation(
() => getActiveBackend(),
jobSignal
)
const actorUserId = job?.userId ?? getPrimaryAdminUserId() ?? 0 const actorUserId = job?.userId ?? getPrimaryAdminUserId() ?? 0
const readMediaPage = (path: string) => fetchSiteSyncJson<SiteSyncMediaPage>( const readMediaPage = (path: string) => fetchSiteSyncJson<SiteSyncMediaPage>(
targetUrl(options.targetBaseUrl, path), targetUrl(options.targetBaseUrl, path),
token token,
undefined,
jobSignal
) )
const processBundleOnce = async ( const processBundleOnce = async (
@@ -250,11 +332,16 @@ async function runSiteSyncPull(
isRetry: boolean isRetry: boolean
): Promise<'added' | 'skipped'> => { ): Promise<'added' | 'skipped'> => {
const { media, assets } = bundle const { media, assets } = bundle
throwIfJobCancellationRequested(jobId)
if (media.ownerUsername && !targetUsers.has(media.ownerUsername)) {
throw new Error(`当前站点缺少同名用户:${media.ownerUsername}`)
}
if ( if (
!isRetry !isRetry
&& options.conflictMode === 'skip' && options.conflictMode === 'skip'
&& getImageIndexRow(media.key) && getImageIndexRow(media.key)
) { ) {
completeSiteSyncMediaArtifacts(jobId, media.key)
return 'skipped' return 'skipped'
} }
@@ -262,23 +349,31 @@ async function runSiteSyncPull(
? findUserByUsername(media.ownerUsername) ? findUserByUsername(media.ownerUsername)
: null : null
for (const asset of assets) { for (const asset of assets) {
if ( throwIfJobCancellationRequested(jobId)
options.conflictMode === 'skip' const existedBefore = Boolean(await backend.head(asset.key))
&& await backend.head(asset.key) if (options.conflictMode === 'skip' && existedBefore) {
) {
continue continue
} }
const bytes = await downloadSiteSyncObject( const bytes = await downloadSiteSyncObject(
jobId,
options, options,
token, token,
asset.key asset.key
) )
throwIfJobCancellationRequested(jobId)
if (bytes.length !== asset.size) { if (bytes.length !== asset.size) {
throw new Error( throw new Error(
`同步对象大小不一致:${asset.key},预期 ${asset.size},实际 ${bytes.length}` `同步对象大小不一致:${asset.key},预期 ${asset.size},实际 ${bytes.length}`
) )
} }
const isPrimary = asset.key === media.key const isPrimary = asset.key === media.key
recordSiteSyncArtifact({
jobId,
mediaKey: media.key,
key: asset.key,
backendId: backend.id,
created: !existedBefore
})
await backend.put(asset.key, new Uint8Array(bytes), { await backend.put(asset.key, new Uint8Array(bytes), {
originalName: isPrimary ? media.originalName : basename(asset.key), originalName: isPrimary ? media.originalName : basename(asset.key),
uploadedAt: media.uploadedAt, uploadedAt: media.uploadedAt,
@@ -295,13 +390,18 @@ async function runSiteSyncPull(
? 'file' ? 'file'
: 'image' : 'image'
}) })
throwIfJobCancellationRequested(jobId)
} }
await applySiteSyncMedia(media, 'overwrite', actorUserId) await applySiteSyncMedia(media, 'overwrite', actorUserId)
completeSiteSyncMediaArtifacts(jobId, media.key)
return 'added' return 'added'
} }
const processBundle = (bundle: SiteSyncMediaBundle, isRetry: boolean) => ( const processBundle = (bundle: SiteSyncMediaBundle, isRetry: boolean) => (
retrySiteSyncOperation(() => processBundleOnce(bundle, isRetry)) retrySiteSyncOperation(
() => processBundleOnce(bundle, isRetry),
jobSignal
)
) )
const recordOutcome = (outcome: 'added' | 'skipped', fileCount: number) => { const recordOutcome = (outcome: 'added' | 'skipped', fileCount: number) => {
if (outcome === 'added') added += 1 if (outcome === 'added') added += 1
@@ -382,6 +482,7 @@ async function runSiteSyncPull(
return return
} }
throwIfJobCancellationRequested(jobId)
totalFiles = filesSynced totalFiles = filesSynced
markJobDone(jobId, { markJobDone(jobId, {
message: `扫描 ${scanned}/${totalMedia || scanned} · 新增 ${added} · 跳过 ${skipped} · 失败 0`, message: `扫描 ${scanned}/${totalMedia || scanned} · 新增 ${added} · 跳过 ${skipped} · 失败 0`,
@@ -412,6 +513,7 @@ export async function runSiteSyncJob(
): Promise<void> { ): Promise<void> {
const token = tokens.get(jobId) const token = tokens.get(jobId)
if (!token) throw new Error('同步 Token 不可用,请重新发起任务') if (!token) throw new Error('同步 Token 不可用,请重新发起任务')
const jobSignal = getJobAbortSignal(jobId)
try { try {
if (options.direction === 'pull') { if (options.direction === 'pull') {
@@ -421,7 +523,9 @@ export async function runSiteSyncJob(
const inventory = await fetchSiteSyncJson<SiteSyncInventory>( const inventory = await fetchSiteSyncJson<SiteSyncInventory>(
targetUrl(options.targetBaseUrl, '/api/site-sync/inventory'), targetUrl(options.targetBaseUrl, '/api/site-sync/inventory'),
token token,
undefined,
jobSignal
) )
const targetUsers = new Set(inventory.users) const targetUsers = new Set(inventory.users)
const targetMedia = new Set(inventory.mediaKeys) const targetMedia = new Set(inventory.mediaKeys)
@@ -466,6 +570,7 @@ export async function runSiteSyncJob(
}) })
for (const row of rows) { for (const row of rows) {
throwIfJobCancellationRequested(jobId)
const backend = await getBackendForKey(row.key) const backend = await getBackendForKey(row.key)
let assetKeys: string[] let assetKeys: string[]
try { try {
@@ -487,7 +592,7 @@ export async function runSiteSyncJob(
}) })
} }
const saveCheckpoint = (message: string) => { const saveCheckpoint = (message: string, cancelled = false) => {
updateBackupJob(jobId, { updateBackupJob(jobId, {
progress: scanned, progress: scanned,
total: rows.length, total: rows.length,
@@ -496,6 +601,7 @@ export async function runSiteSyncJob(
targetBaseUrl: options.targetBaseUrl, targetBaseUrl: options.targetBaseUrl,
conflictMode: options.conflictMode, conflictMode: options.conflictMode,
direction: 'push', direction: 'push',
cancelled,
lastMediaKey, lastMediaKey,
scanned, scanned,
added, added,
@@ -520,11 +626,22 @@ export async function runSiteSyncJob(
let lastCheckpointAt = 0 let lastCheckpointAt = 0
const processPlanOnce = async (plan: typeof plans[number], isRetry: boolean) => { const processPlanOnce = async (plan: typeof plans[number], isRetry: boolean) => {
const { row, ownerUsername, backend, keys } = plan const { row, ownerUsername, backend, keys } = plan
throwIfJobCancellationRequested(jobId)
if ( if (
!isRetry !isRetry
&& options.conflictMode === 'skip' && options.conflictMode === 'skip'
&& targetMedia.has(row.key) && targetMedia.has(row.key)
) { ) {
await fetchSiteSyncJsonOnce<{ success: boolean }>(
targetUrl(options.targetBaseUrl, '/api/site-sync/complete'),
token,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ jobId, mediaKey: row.key }),
signal: getJobOperationSignal(jobId, SITE_SYNC_JSON_TIMEOUT_MS)
}
)
skipped += 1 skipped += 1
filesSynced = Math.min(totalFiles, filesSynced + keys.length) filesSynced = Math.min(totalFiles, filesSynced + keys.length)
failedKeys.delete(row.key) failedKeys.delete(row.key)
@@ -532,6 +649,7 @@ export async function runSiteSyncJob(
} }
for (const key of keys) { for (const key of keys) {
throwIfJobCancellationRequested(jobId)
const stored = await backend.head(key) const stored = await backend.head(key)
if (!stored) throw new Error(`源文件不存在:${key}`) if (!stored) throw new Error(`源文件不存在:${key}`)
if ( if (
@@ -540,9 +658,18 @@ export async function runSiteSyncJob(
) { ) {
continue continue
} }
await uploadObject(options, token, backend, row, ownerUsername, key) await uploadObject(
jobId,
options,
token,
backend,
row,
ownerUsername,
key
)
} }
throwIfJobCancellationRequested(jobId)
await fetchSiteSyncJsonOnce<{ success: boolean }>( await fetchSiteSyncJsonOnce<{ success: boolean }>(
targetUrl(options.targetBaseUrl, '/api/site-sync/media'), targetUrl(options.targetBaseUrl, '/api/site-sync/media'),
token, token,
@@ -550,9 +677,11 @@ export async function runSiteSyncJob(
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ body: JSON.stringify({
jobId,
media: toSiteSyncMediaTransferWithTags(row, ownerUsername), media: toSiteSyncMediaTransferWithTags(row, ownerUsername),
conflictMode: options.conflictMode conflictMode: options.conflictMode
}) }),
signal: getJobOperationSignal(jobId, SITE_SYNC_JSON_TIMEOUT_MS)
} }
) )
@@ -563,18 +692,29 @@ export async function runSiteSyncJob(
const processPlan = ( const processPlan = (
plan: typeof plans[number], plan: typeof plans[number],
isRetry: boolean isRetry: boolean
) => retrySiteSyncOperation(() => processPlanOnce(plan, isRetry)) ) => retrySiteSyncOperation(
() => processPlanOnce(plan, isRetry),
jobSignal
)
const retryPlans = plans.filter(plan => failedKeys.has(plan.row.key)) const retryPlans = plans.filter(plan => failedKeys.has(plan.row.key))
for (const plan of retryPlans) { for (const plan of retryPlans) {
try { try {
await processPlan(plan, true) await processPlan(plan, true)
lastMediaKey = plan.row.key lastMediaKey = plan.row.key
} catch { } catch (error) {
failedKeys.add(plan.row.key) failedKeys.add(plan.row.key)
const message = `扫描 ${scanned}/${rows.length} · 新增 ${added} · 跳过 ${skipped} · 失败 ${failedKeys.size} · 已保存断点` const cancelled = isJobCancellationRequested(jobId)
saveCheckpoint(message) || isJobCancelledError(error)
markJobFailed(jobId, '连续 3 次重试失败,已保存断点', message) const message = `扫描 ${scanned}/${rows.length} · 新增 ${added} · 跳过 ${skipped} · 失败 ${failedKeys.size} · ${cancelled ? '已取消,' : ''}已保存断点`
saveCheckpoint(message, cancelled)
markJobFailed(
jobId,
cancelled
? '任务已取消'
: '连续 3 次重试失败,已保存断点',
message
)
return return
} }
} }
@@ -584,12 +724,20 @@ export async function runSiteSyncJob(
scanned += 1 scanned += 1
try { try {
await processPlan(plan, false) await processPlan(plan, false)
} catch { } catch (error) {
failedKeys.add(plan.row.key) failedKeys.add(plan.row.key)
lastMediaKey = plan.row.key lastMediaKey = plan.row.key
const message = `扫描 ${scanned}/${rows.length} · 新增 ${added} · 跳过 ${skipped} · 失败 ${failedKeys.size} · 已保存断点` const cancelled = isJobCancellationRequested(jobId)
saveCheckpoint(message) || isJobCancelledError(error)
markJobFailed(jobId, '连续 3 次重试失败,已保存断点', message) const message = `扫描 ${scanned}/${rows.length} · 新增 ${added} · 跳过 ${skipped} · 失败 ${failedKeys.size} · ${cancelled ? '已取消,' : ''}已保存断点`
saveCheckpoint(message, cancelled)
markJobFailed(
jobId,
cancelled
? '任务已取消'
: '连续 3 次重试失败,已保存断点',
message
)
return return
} }
lastMediaKey = plan.row.key lastMediaKey = plan.row.key
@@ -614,6 +762,7 @@ export async function runSiteSyncJob(
return return
} }
throwIfJobCancellationRequested(jobId)
markJobDone(jobId, { markJobDone(jobId, {
message: `扫描 ${rows.length}/${rows.length} · 新增 ${added} · 跳过 ${skipped} · 失败 0`, message: `扫描 ${rows.length}/${rows.length} · 新增 ${added} · 跳过 ${skipped} · 失败 0`,
payload: { payload: {
@@ -632,6 +781,10 @@ export async function runSiteSyncJob(
} }
}) })
} catch (error) { } catch (error) {
if (isJobCancellationRequested(jobId) || isJobCancelledError(error)) {
markJobFailed(jobId, '任务已取消', '任务已取消')
return
}
const message = error instanceof Error ? error.message : '两站点同步失败' const message = error instanceof Error ? error.message : '两站点同步失败'
markJobFailed(jobId, message) markJobFailed(jobId, message)
throw error throw error
+1 -1
View File
@@ -1,4 +1,4 @@
export { getActiveBackend, getBackendForKey, getActiveBackendRow, getBackendRowForKey } from './resolver' export { getActiveBackend, getBackendById, getBackendForKey, getActiveBackendRow, getBackendRowForKey } from './resolver'
export { LocalStorageBackend } from './local' export { LocalStorageBackend } from './local'
export { S3StorageBackend, buildObjectKey, parseS3Config, validateS3Config } from './s3' export { S3StorageBackend, buildObjectKey, parseS3Config, validateS3Config } from './s3'
export { export {
+59 -1
View File
@@ -1,3 +1,4 @@
import { randomUUID } from 'node:crypto'
import { createReadStream, promises as fs } from 'node:fs' import { createReadStream, promises as fs } from 'node:fs'
import type { ReadStream } from 'node:fs' import type { ReadStream } from 'node:fs'
import { dirname, join } from 'node:path' import { dirname, join } from 'node:path'
@@ -7,6 +8,7 @@ import { validateStorageKey } from '../image-key'
import type { StoredImage, StoredImageMeta, StorageBackend } from './types' import type { StoredImage, StoredImageMeta, StorageBackend } from './types'
const META_SUFFIX = '.meta.json' const META_SUFFIX = '.meta.json'
const ATOMIC_TEMP_DIR = '.pichost-tmp'
function keyToFilePath(key: string): string { function keyToFilePath(key: string): string {
return join(getDataDir(), ...key.split('/')) return join(getDataDir(), ...key.split('/'))
@@ -39,8 +41,36 @@ export class LocalStorageBackend implements StorageBackend {
async put(key: string, bytes: Uint8Array, _meta: StoredImageMeta): Promise<void> { async put(key: string, bytes: Uint8Array, _meta: StoredImageMeta): Promise<void> {
const filePath = keyToFilePath(key) const filePath = keyToFilePath(key)
const tempDir = join(getDataDir(), ATOMIC_TEMP_DIR)
const tempPath = join(tempDir, `${randomUUID()}.part`)
await fs.mkdir(tempDir, { recursive: true })
await fs.mkdir(dirname(filePath), { recursive: true }) await fs.mkdir(dirname(filePath), { recursive: true })
await fs.writeFile(filePath, bytes)
const handle = await fs.open(tempPath, 'w')
try {
await handle.writeFile(bytes)
await handle.sync()
} finally {
await handle.close()
}
try {
await fs.rename(tempPath, filePath)
} catch (error) {
await fs.rm(tempPath, { force: true }).catch(() => {})
throw error
}
try {
const directory = await fs.open(dirname(filePath), 'r')
try {
await directory.sync()
} finally {
await directory.close()
}
} catch {
// The rename already completed; directory fsync is best-effort on macOS.
}
} }
async head(key: string): Promise<StoredImage | null> { async head(key: string): Promise<StoredImage | null> {
@@ -93,3 +123,31 @@ export class LocalStorageBackend implements StorageBackend {
return { count: 0, bytes: 0 } return { count: 0, bytes: 0 }
} }
} }
export async function cleanupLocalAtomicTempFiles(
maxAgeMs = 60 * 60 * 1000
): Promise<number> {
const tempDir = join(getDataDir(), ATOMIC_TEMP_DIR)
let entries
try {
entries = await fs.readdir(tempDir, { withFileTypes: true })
} catch {
return 0
}
const cutoff = Date.now() - maxAgeMs
let removed = 0
for (const entry of entries) {
if (!entry.isFile() || !entry.name.endsWith('.part')) continue
const path = join(tempDir, entry.name)
try {
const stat = await fs.stat(path)
if (stat.mtimeMs > cutoff) continue
await fs.rm(path, { force: true })
removed++
} catch {
// A later startup will retry stale temporary files.
}
}
return removed
}
+12
View File
@@ -53,6 +53,18 @@ export async function getBackendForKey(key: string): Promise<StorageBackend> {
} }
} }
export async function getBackendById(
backendId: string
): Promise<StorageBackend | null> {
const fromEnv = createBackendFromEnv()
if (fromEnv) return fromEnv.id === backendId ? fromEnv : null
ensureDefaultBackends()
const row = getStorageBackendRow(backendId)
if (!row) return null
return createBackendInstance(row)
}
export function getActiveBackendRow(): StorageBackendRow | null { export function getActiveBackendRow(): StorageBackendRow | null {
const fromEnv = createBackendFromEnv() const fromEnv = createBackendFromEnv()
if (fromEnv) { if (fromEnv) {