同步异常兜底

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

+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,
type BackupJob
} from './jobs'
import {
beginJobCancellationScope,
endJobCancellationScope,
requestJobCancellation
} from './cancel-state'
interface BackupRunnerState {
running: boolean
@@ -42,11 +47,8 @@ export function cancelBackupJob(jobId: string): boolean {
if (!job || (job.status !== 'running' && job.status !== 'pending')) {
return false
}
markJobFailed(jobId, '任务已取消')
if (runnerState.currentJobId === jobId) {
runnerState.running = false
runnerState.currentJobId = null
}
requestJobCancellation(jobId)
markJobFailed(jobId, '任务已取消', '已请求取消,正在停止…')
return true
}
@@ -59,14 +61,18 @@ export function scheduleBackupJob(
}
runnerState.running = true
runnerState.currentJobId = job.id
beginJobCancellationScope(job.id)
markJobRunning(job.id)
setImmediate(() => {
runner()
.catch(() => {})
.finally(() => {
runnerState.running = false
runnerState.currentJobId = null
endJobCancellationScope(job.id)
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 { fetchSiteSyncJson, retrySiteSyncOperation } from './runner'
import {
beginJobCancellationScope,
endJobCancellationScope,
getJobAbortSignal,
requestJobCancellation
} from '../backup/cancel-state'
afterEach(() => {
vi.unstubAllGlobals()
@@ -52,4 +58,17 @@ describe('fetchSiteSyncJson', () => {
await expect(retrySiteSyncOperation(operation)).rejects.toThrow('database is busy')
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 type { Readable } from 'node:stream'
import { contentTypeFromKey } from '../content-type'
import {
getImageIndexRow,
@@ -22,7 +23,19 @@ import {
markJobFailed,
updateBackupJob
} from '../backup/jobs'
import {
createJobCancelledError,
getJobAbortSignal,
getJobOperationSignal,
isJobCancellationRequested,
isJobCancelledError,
throwIfJobCancellationRequested
} from '../backup/cancel-state'
import { applySiteSyncMedia } from './apply'
import {
completeSiteSyncMediaArtifacts,
recordSiteSyncArtifact
} from './artifacts'
import { toSiteSyncMediaTransferWithTags } from './manifest'
import type {
SiteSyncDirection,
@@ -49,9 +62,19 @@ export function registerSiteSyncToken(jobId: string, token: string): void {
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[] = []
for await (const chunk of stream) {
if (signal?.aborted) {
stream.destroy(createJobCancelledError())
throw createJobCancelledError()
}
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk))
}
return Buffer.concat(chunks)
@@ -71,21 +94,46 @@ async function responseError(response: Response): Promise<Error> {
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>(
operation: () => Promise<T>
operation: () => Promise<T>,
signal?: AbortSignal
): Promise<T> {
if (signal?.aborted) throw createJobCancelledError()
let lastError: Error | null = null
for (let attempt = 1; attempt <= SITE_SYNC_MAX_ATTEMPTS; attempt++) {
try {
return await operation()
} catch (error) {
if (signal?.aborted) throw createJobCancelledError()
lastError = error instanceof Error ? error : new Error('两站点同步失败')
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>(
@@ -107,14 +155,25 @@ async function fetchSiteSyncJsonOnce<T>(
export async function fetchSiteSyncJson<T>(
url: string,
token: string,
init?: RequestInit
init?: RequestInit,
signal?: AbortSignal
): Promise<T> {
return retrySiteSyncOperation(() => (
fetchSiteSyncJsonOnce<T>(url, token, init)
))
return retrySiteSyncOperation(
() => 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(
jobId: string,
options: SiteSyncOptions,
token: string,
backend: StorageBackend,
@@ -124,9 +183,15 @@ async function uploadObject(
): Promise<void> {
const stored = await backend.head(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 isPrimary = key === row.key
form.append('jobId', jobId)
form.append('mediaKey', row.key)
form.append('key', key)
form.append('conflictMode', options.conflictMode)
form.append('ownerUsername', ownerUsername ?? '')
@@ -143,11 +208,16 @@ async function uploadObject(
await fetchSiteSyncJsonOnce<{ success: boolean }>(
targetUrl(options.targetBaseUrl, '/api/site-sync/object'),
token,
{ method: 'POST', body: form }
{
method: 'POST',
body: form,
signal: getJobOperationSignal(jobId, SITE_SYNC_OBJECT_TIMEOUT_MS)
}
)
}
async function downloadSiteSyncObject(
jobId: string,
options: SiteSyncOptions,
token: string,
key: string
@@ -160,7 +230,8 @@ async function downloadSiteSyncObject(
{
headers: {
'Auth-Token': token
}
},
signal: getJobOperationSignal(jobId, SITE_SYNC_OBJECT_TIMEOUT_MS)
}
)
if (!response.ok) throw await responseError(response)
@@ -173,6 +244,7 @@ async function runSiteSyncPull(
token: string
): Promise<void> {
const job = getBackupJob(jobId)
const jobSignal = getJobAbortSignal(jobId)
const checkpoint = job?.payload ?? {}
let lastMediaKey = typeof checkpoint.lastMediaKey === 'string'
? checkpoint.lastMediaKey
@@ -189,7 +261,7 @@ async function runSiteSyncPull(
: []
)
const saveCheckpoint = (message: string) => {
const saveCheckpoint = (message: string, cancelled = false) => {
updateBackupJob(jobId, {
progress: scanned,
total: totalMedia,
@@ -198,6 +270,7 @@ async function runSiteSyncPull(
targetBaseUrl: options.targetBaseUrl,
conflictMode: options.conflictMode,
direction: 'pull',
cancelled,
lastMediaKey,
scanned,
added,
@@ -215,11 +288,19 @@ async function runSiteSyncPull(
`扫描 ${scanned}/${totalMedia || scanned} · 新增 ${added} · 跳过 ${skipped} · 失败 ${failedKeys.size}${suffix}`
)
const failAndCheckpoint = (error: unknown) => {
const message = progressMessage(' · 已保存断点')
saveCheckpoint(message)
const cancelled = isJobCancellationRequested(jobId)
|| isJobCancelledError(error)
const message = cancelled
? progressMessage(' · 已取消,已保存断点')
: progressMessage(' · 已保存断点')
saveCheckpoint(message, cancelled)
markJobFailed(
jobId,
error instanceof Error ? error.message : '从目标站点拉取失败',
cancelled
? '任务已取消'
: error instanceof Error
? error.message
: '从目标站点拉取失败',
message
)
}
@@ -228,21 +309,22 @@ async function runSiteSyncPull(
const targetUsers = new Set(
(await fetchSiteSyncJson<SiteSyncUsers>(
targetUrl(options.targetBaseUrl, '/api/site-sync/users'),
token
token,
undefined,
jobSignal
)).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 readMediaPage = (path: string) => fetchSiteSyncJson<SiteSyncMediaPage>(
targetUrl(options.targetBaseUrl, path),
token
token,
undefined,
jobSignal
)
const processBundleOnce = async (
@@ -250,11 +332,16 @@ async function runSiteSyncPull(
isRetry: boolean
): Promise<'added' | 'skipped'> => {
const { media, assets } = bundle
throwIfJobCancellationRequested(jobId)
if (media.ownerUsername && !targetUsers.has(media.ownerUsername)) {
throw new Error(`当前站点缺少同名用户:${media.ownerUsername}`)
}
if (
!isRetry
&& options.conflictMode === 'skip'
&& getImageIndexRow(media.key)
) {
completeSiteSyncMediaArtifacts(jobId, media.key)
return 'skipped'
}
@@ -262,23 +349,31 @@ async function runSiteSyncPull(
? findUserByUsername(media.ownerUsername)
: null
for (const asset of assets) {
if (
options.conflictMode === 'skip'
&& await backend.head(asset.key)
) {
throwIfJobCancellationRequested(jobId)
const existedBefore = Boolean(await backend.head(asset.key))
if (options.conflictMode === 'skip' && existedBefore) {
continue
}
const bytes = await downloadSiteSyncObject(
jobId,
options,
token,
asset.key
)
throwIfJobCancellationRequested(jobId)
if (bytes.length !== asset.size) {
throw new Error(
`同步对象大小不一致:${asset.key},预期 ${asset.size},实际 ${bytes.length}`
)
}
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), {
originalName: isPrimary ? media.originalName : basename(asset.key),
uploadedAt: media.uploadedAt,
@@ -295,13 +390,18 @@ async function runSiteSyncPull(
? 'file'
: 'image'
})
throwIfJobCancellationRequested(jobId)
}
await applySiteSyncMedia(media, 'overwrite', actorUserId)
completeSiteSyncMediaArtifacts(jobId, media.key)
return 'added'
}
const processBundle = (bundle: SiteSyncMediaBundle, isRetry: boolean) => (
retrySiteSyncOperation(() => processBundleOnce(bundle, isRetry))
retrySiteSyncOperation(
() => processBundleOnce(bundle, isRetry),
jobSignal
)
)
const recordOutcome = (outcome: 'added' | 'skipped', fileCount: number) => {
if (outcome === 'added') added += 1
@@ -382,6 +482,7 @@ async function runSiteSyncPull(
return
}
throwIfJobCancellationRequested(jobId)
totalFiles = filesSynced
markJobDone(jobId, {
message: `扫描 ${scanned}/${totalMedia || scanned} · 新增 ${added} · 跳过 ${skipped} · 失败 0`,
@@ -412,6 +513,7 @@ export async function runSiteSyncJob(
): Promise<void> {
const token = tokens.get(jobId)
if (!token) throw new Error('同步 Token 不可用,请重新发起任务')
const jobSignal = getJobAbortSignal(jobId)
try {
if (options.direction === 'pull') {
@@ -421,7 +523,9 @@ export async function runSiteSyncJob(
const inventory = await fetchSiteSyncJson<SiteSyncInventory>(
targetUrl(options.targetBaseUrl, '/api/site-sync/inventory'),
token
token,
undefined,
jobSignal
)
const targetUsers = new Set(inventory.users)
const targetMedia = new Set(inventory.mediaKeys)
@@ -466,6 +570,7 @@ export async function runSiteSyncJob(
})
for (const row of rows) {
throwIfJobCancellationRequested(jobId)
const backend = await getBackendForKey(row.key)
let assetKeys: string[]
try {
@@ -487,7 +592,7 @@ export async function runSiteSyncJob(
})
}
const saveCheckpoint = (message: string) => {
const saveCheckpoint = (message: string, cancelled = false) => {
updateBackupJob(jobId, {
progress: scanned,
total: rows.length,
@@ -496,6 +601,7 @@ export async function runSiteSyncJob(
targetBaseUrl: options.targetBaseUrl,
conflictMode: options.conflictMode,
direction: 'push',
cancelled,
lastMediaKey,
scanned,
added,
@@ -520,11 +626,22 @@ export async function runSiteSyncJob(
let lastCheckpointAt = 0
const processPlanOnce = async (plan: typeof plans[number], isRetry: boolean) => {
const { row, ownerUsername, backend, keys } = plan
throwIfJobCancellationRequested(jobId)
if (
!isRetry
&& options.conflictMode === 'skip'
&& 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
filesSynced = Math.min(totalFiles, filesSynced + keys.length)
failedKeys.delete(row.key)
@@ -532,6 +649,7 @@ export async function runSiteSyncJob(
}
for (const key of keys) {
throwIfJobCancellationRequested(jobId)
const stored = await backend.head(key)
if (!stored) throw new Error(`源文件不存在:${key}`)
if (
@@ -540,9 +658,18 @@ export async function runSiteSyncJob(
) {
continue
}
await uploadObject(options, token, backend, row, ownerUsername, key)
await uploadObject(
jobId,
options,
token,
backend,
row,
ownerUsername,
key
)
}
throwIfJobCancellationRequested(jobId)
await fetchSiteSyncJsonOnce<{ success: boolean }>(
targetUrl(options.targetBaseUrl, '/api/site-sync/media'),
token,
@@ -550,9 +677,11 @@ export async function runSiteSyncJob(
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
jobId,
media: toSiteSyncMediaTransferWithTags(row, ownerUsername),
conflictMode: options.conflictMode
})
}),
signal: getJobOperationSignal(jobId, SITE_SYNC_JSON_TIMEOUT_MS)
}
)
@@ -563,18 +692,29 @@ export async function runSiteSyncJob(
const processPlan = (
plan: typeof plans[number],
isRetry: boolean
) => retrySiteSyncOperation(() => processPlanOnce(plan, isRetry))
) => retrySiteSyncOperation(
() => processPlanOnce(plan, isRetry),
jobSignal
)
const retryPlans = plans.filter(plan => failedKeys.has(plan.row.key))
for (const plan of retryPlans) {
try {
await processPlan(plan, true)
lastMediaKey = plan.row.key
} catch {
} catch (error) {
failedKeys.add(plan.row.key)
const message = `扫描 ${scanned}/${rows.length} · 新增 ${added} · 跳过 ${skipped} · 失败 ${failedKeys.size} · 已保存断点`
saveCheckpoint(message)
markJobFailed(jobId, '连续 3 次重试失败,已保存断点', message)
const cancelled = isJobCancellationRequested(jobId)
|| isJobCancelledError(error)
const message = `扫描 ${scanned}/${rows.length} · 新增 ${added} · 跳过 ${skipped} · 失败 ${failedKeys.size} · ${cancelled ? '已取消,' : ''}已保存断点`
saveCheckpoint(message, cancelled)
markJobFailed(
jobId,
cancelled
? '任务已取消'
: '连续 3 次重试失败,已保存断点',
message
)
return
}
}
@@ -584,12 +724,20 @@ export async function runSiteSyncJob(
scanned += 1
try {
await processPlan(plan, false)
} catch {
} catch (error) {
failedKeys.add(plan.row.key)
lastMediaKey = plan.row.key
const message = `扫描 ${scanned}/${rows.length} · 新增 ${added} · 跳过 ${skipped} · 失败 ${failedKeys.size} · 已保存断点`
saveCheckpoint(message)
markJobFailed(jobId, '连续 3 次重试失败,已保存断点', message)
const cancelled = isJobCancellationRequested(jobId)
|| isJobCancelledError(error)
const message = `扫描 ${scanned}/${rows.length} · 新增 ${added} · 跳过 ${skipped} · 失败 ${failedKeys.size} · ${cancelled ? '已取消,' : ''}已保存断点`
saveCheckpoint(message, cancelled)
markJobFailed(
jobId,
cancelled
? '任务已取消'
: '连续 3 次重试失败,已保存断点',
message
)
return
}
lastMediaKey = plan.row.key
@@ -614,6 +762,7 @@ export async function runSiteSyncJob(
return
}
throwIfJobCancellationRequested(jobId)
markJobDone(jobId, {
message: `扫描 ${rows.length}/${rows.length} · 新增 ${added} · 跳过 ${skipped} · 失败 0`,
payload: {
@@ -632,6 +781,10 @@ export async function runSiteSyncJob(
}
})
} catch (error) {
if (isJobCancellationRequested(jobId) || isJobCancelledError(error)) {
markJobFailed(jobId, '任务已取消', '任务已取消')
return
}
const message = error instanceof Error ? error.message : '两站点同步失败'
markJobFailed(jobId, message)
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 { S3StorageBackend, buildObjectKey, parseS3Config, validateS3Config } from './s3'
export {
+59 -1
View File
@@ -1,3 +1,4 @@
import { randomUUID } from 'node:crypto'
import { createReadStream, promises as fs } from 'node:fs'
import type { ReadStream } from 'node:fs'
import { dirname, join } from 'node:path'
@@ -7,6 +8,7 @@ import { validateStorageKey } from '../image-key'
import type { StoredImage, StoredImageMeta, StorageBackend } from './types'
const META_SUFFIX = '.meta.json'
const ATOMIC_TEMP_DIR = '.pichost-tmp'
function keyToFilePath(key: string): string {
return join(getDataDir(), ...key.split('/'))
@@ -39,8 +41,36 @@ export class LocalStorageBackend implements StorageBackend {
async put(key: string, bytes: Uint8Array, _meta: StoredImageMeta): Promise<void> {
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.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> {
@@ -93,3 +123,31 @@ export class LocalStorageBackend implements StorageBackend {
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 {
const fromEnv = createBackendFromEnv()
if (fromEnv) {