同步异常兜底
This commit is contained in:
1 parent
9cb8552a99
commit
a1b4a63552
20 files changed
+752
-52
No files matched your search
@@ -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">
|
||||||
|
|||||||
@@ -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'))
|
||||||
|
|||||||
@@ -59,4 +59,5 @@ export interface SiteSyncRequest {
|
|||||||
conflictMode: 'skip' | 'overwrite'
|
conflictMode: 'skip' | 'overwrite'
|
||||||
direction: SiteSyncDirection
|
direction: SiteSyncDirection
|
||||||
resumeJobId?: string
|
resumeJobId?: string
|
||||||
|
abandonJobId?: string
|
||||||
}
|
}
|
||||||
@@ -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",
|
||||||
|
|||||||
@@ -665,6 +665,9 @@
|
|||||||
"siteSyncSkip": "跳过已存在",
|
"siteSyncSkip": "跳过已存在",
|
||||||
"siteSyncOverwrite": "覆盖已存在",
|
"siteSyncOverwrite": "覆盖已存在",
|
||||||
"siteSyncResume": "继续上次同步",
|
"siteSyncResume": "继续上次同步",
|
||||||
|
"siteSyncAbandon": "放弃并清理残留",
|
||||||
|
"siteSyncAbandonConfirm": "确定放弃这次同步吗?将删除本次同步新建但尚未归入媒体记录的残留对象。",
|
||||||
|
"siteSyncAbandoned": "同步已放弃,残留对象已清理",
|
||||||
"siteSyncScanProgress": "媒体扫描进度",
|
"siteSyncScanProgress": "媒体扫描进度",
|
||||||
"siteSyncFileProgress": "已同步文件数量",
|
"siteSyncFileProgress": "已同步文件数量",
|
||||||
"siteSyncStarted": "两站点同步任务已开始",
|
"siteSyncStarted": "两站点同步任务已开始",
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
})
|
||||||
@@ -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 }
|
||||||
|
})
|
||||||
@@ -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)
|
||||||
|
|||||||
@@ -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(),
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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()
|
||||||
|
}
|
||||||
@@ -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(() => {
|
||||||
|
endJobCancellationScope(job.id)
|
||||||
|
if (runnerState.currentJobId === job.id) {
|
||||||
runnerState.running = false
|
runnerState.running = false
|
||||||
runnerState.currentJobId = null
|
runnerState.currentJobId = null
|
||||||
|
}
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)!
|
||||||
|
}
|
||||||
@@ -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 }
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
})
|
||||||
})
|
})
|
||||||
@@ -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,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 {
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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) {
|
||||||
|
|||||||
Reference in new issue
Block a user