gongxiangp2/lib/task-detail-upload-jobs.ts

463 lines
15 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import type { Prisma } from '@prisma/client'
import { prisma } from '@/lib/prisma'
import {
completeProductPackageUpload,
type ProductPackageAssetsInput,
type CompleteProductPackageUploadInput,
} from '@/lib/product-package-completion'
export const TASK_DETAIL_UPLOAD_JOB_STATUS = {
QUEUED: 'queued',
PROCESSING: 'processing',
COMPLETED: 'completed',
FAILED: 'failed',
} as const
export const TASK_DETAIL_UPLOAD_JOB_STAGE = {
QUEUED: 'queued',
VALIDATING: 'validating',
ASSEMBLING: 'assembling',
PREVIEWS: 'previews',
ZIPS: 'zips',
DATABASE: 'database',
CLEANUP: 'cleanup',
COMPLETED: 'completed',
FAILED: 'failed',
} as const
export type TaskDetailUploadJobStatus =
(typeof TASK_DETAIL_UPLOAD_JOB_STATUS)[keyof typeof TASK_DETAIL_UPLOAD_JOB_STATUS]
export interface CreateTaskDetailUploadJobInput {
materialId: string
taskNo?: string | null
dingtalkApprovalNo?: string | null
operatorUserId: string
assets: ProductPackageAssetsInput
options?: CompleteProductPackageUploadInput['options']
}
export interface TaskDetailUploadJobStatusView {
jobId: string
materialId: string
status: TaskDetailUploadJobStatus | string
stage: string
progress: number
error: string | null
createdAt: string
startedAt: string | null
completedAt: string | null
}
const MAX_CONCURRENT_JOBS = 1
// 有心跳的正常任务不会触发此门禁;进程被杀后最多等待 30 分钟即可变成
// failed,而不是把素材永久卡在 processing。超大任务可通过环境变量放宽。
const DEFAULT_STALE_JOB_MS = 30 * 60 * 1000
const RECOVERY_BATCH_SIZE = 20
let ensureTablePromise: Promise<void> | null = null
const pendingJobIds = new Set<string>()
let activeJobCount = 0
let pumpRunning = false
/**
* 在当前事务内获取任务-详情上传的 advisory lock。
*
* pg_advisory_xact_lock() 本身返回 PostgreSQL 的 void 类型。直接用
* Prisma $queryRaw 读取它会触发 `Failed to deserialize column of type 'void'`,
* 因此把函数放进子查询,只返回一个普通整数列给 Prisma;锁仍会在当前
* 事务结束时自动释放。
*/
export async function acquireTaskDetailUploadLock(
tx: Prisma.TransactionClient,
lockName: string,
) {
await tx.$queryRaw`
SELECT 1 AS locked
FROM (
SELECT pg_advisory_xact_lock(hashtext(${lockName}))
) AS lock_result
`
}
function getStaleJobMs() {
const value = Number(process.env.TASK_DETAIL_UPLOAD_STALE_MS)
return Number.isFinite(value) && value > 0 ? value : DEFAULT_STALE_JOB_MS
}
function toJsonValue(value: unknown): Prisma.InputJsonValue {
return JSON.parse(JSON.stringify(value)) as Prisma.InputJsonValue
}
function toIso(value: Date | null | undefined) {
return value ? value.toISOString() : null
}
function toStatusView(job: {
id: string
materialId: string
status: string
stage: string
progress: number
errorMessage: string | null
createdAt: Date
startedAt: Date | null
completedAt: Date | null
}): TaskDetailUploadJobStatusView {
return {
jobId: job.id,
materialId: job.materialId,
status: job.status,
stage: job.stage,
progress: Math.max(0, Math.min(100, job.progress)),
error: job.errorMessage || null,
createdAt: job.createdAt.toISOString(),
startedAt: toIso(job.startedAt),
completedAt: toIso(job.completedAt),
}
}
/**
* 任务表由 reviewed Prisma migration 创建。请求路径不执行 DDL,避免滚动发布时
* 多个容器同时抢迁移锁;若迁移未执行,调用方会收到明确的数据库错误并阻止入队。
*/
export async function ensureProductPackageUploadJobTable() {
if (!ensureTablePromise) {
ensureTablePromise = Promise.resolve()
}
return ensureTablePromise
}
async function updateJobProgress(
jobId: string,
update: { stage: string; progress: number; message?: string },
) {
const progress = Math.max(0, Math.min(99, Math.round(update.progress)))
await prisma.productPackageUploadJob.updateMany({
where: { id: jobId, status: TASK_DETAIL_UPLOAD_JOB_STATUS.PROCESSING },
data: {
stage: update.stage,
progress,
...(update.message ? { errorMessage: update.message } : {}),
},
})
}
async function claimTaskDetailUploadJob(jobId: string) {
return prisma.$transaction(async (tx) => {
// 用事务级 advisory lock 串行化所有容器的“领取任务”动作,保证全局最多一个
// 任务进入 Sharp/ZIP 重处理,而不是只在单个 Node 进程内限流。
await acquireTaskDetailUploadLock(tx, 'product-package-upload-worker')
const activeCount = await tx.productPackageUploadJob.count({
where: { status: TASK_DETAIL_UPLOAD_JOB_STATUS.PROCESSING },
})
if (activeCount > 0) return false
const claimed = await tx.productPackageUploadJob.updateMany({
where: {
id: jobId,
status: TASK_DETAIL_UPLOAD_JOB_STATUS.QUEUED,
},
data: {
status: TASK_DETAIL_UPLOAD_JOB_STATUS.PROCESSING,
stage: TASK_DETAIL_UPLOAD_JOB_STAGE.VALIDATING,
progress: 5,
startedAt: new Date(),
completedAt: null,
errorMessage: null,
},
})
return claimed.count === 1
})
}
async function reconcileCommittedTaskDetailUploadJob(jobId: string) {
const job = await prisma.productPackageUploadJob.findUnique({
where: { id: jobId },
select: {
id: true,
status: true,
materialId: true,
material: { select: { zipFilePath: true, taskStatus: true } },
},
})
if (!job || (job.status !== TASK_DETAIL_UPLOAD_JOB_STATUS.QUEUED && job.status !== TASK_DETAIL_UPLOAD_JOB_STATUS.PROCESSING)) {
return !job
}
const assetCount = await prisma.materialAsset.count({ where: { materialId: job.materialId } })
if (job.material.taskStatus !== 'completed' || !job.material.zipFilePath || assetCount === 0) return false
// 主资产事务已提交但容器可能在 job.completed 写入前被杀掉:不要拿已清理的
// upload session 重跑,直接把持久化任务补记为完成。
await prisma.productPackageUploadJob.updateMany({
where: { id: jobId, status: { in: [TASK_DETAIL_UPLOAD_JOB_STATUS.QUEUED, TASK_DETAIL_UPLOAD_JOB_STATUS.PROCESSING] } },
data: {
status: TASK_DETAIL_UPLOAD_JOB_STATUS.COMPLETED,
stage: TASK_DETAIL_UPLOAD_JOB_STAGE.COMPLETED,
progress: 100,
completedAt: new Date(),
errorMessage: null,
},
})
return true
}
async function processOneTaskDetailUploadJob(jobId: string) {
if (await reconcileCommittedTaskDetailUploadJob(jobId)) return
const claimed = await claimTaskDetailUploadJob(jobId)
if (!claimed) {
const pendingJob = await prisma.productPackageUploadJob.findUnique({
where: { id: jobId },
select: { status: true },
})
if (pendingJob?.status === TASK_DETAIL_UPLOAD_JOB_STATUS.QUEUED) {
setTimeout(() => triggerTaskDetailUploadJob(jobId), 5_000)
}
return
}
const job = await prisma.productPackageUploadJob.findUnique({ where: { id: jobId } })
if (!job) {
return
}
const heartbeatTimer = setInterval(() => {
void prisma.productPackageUploadJob.updateMany({
where: { id: jobId, status: TASK_DETAIL_UPLOAD_JOB_STATUS.PROCESSING },
data: { updatedAt: new Date() },
}).catch((heartbeatError) => {
console.warn(`[TaskDetailUploadJob] heartbeat failed job=${jobId}:`, heartbeatError)
})
}, 30_000)
try {
try {
await completeProductPackageUpload({
materialId: job.materialId,
taskNo: job.taskNo,
dingtalkApprovalNo: job.dingtalkApprovalNo,
operatorUserId: job.operatorUserId,
assets: job.assets as ProductPackageAssetsInput,
options: {
...(job.options && typeof job.options === 'object' ? job.options as Record<string, unknown> : {}),
preserveMaterialOnError: true,
onProgress: (update) => updateJobProgress(jobId, update),
},
})
} catch (error) {
const errorMessage = error instanceof Error ? error.message : '任务-详情上传处理失败'
console.error(`[TaskDetailUploadJob] failed job=${jobId}:`, error)
try {
await prisma.productPackageUploadJob.updateMany({
where: { id: jobId },
data: {
status: TASK_DETAIL_UPLOAD_JOB_STATUS.FAILED,
stage: TASK_DETAIL_UPLOAD_JOB_STAGE.FAILED,
errorMessage: errorMessage.slice(0, 2000),
completedAt: new Date(),
},
})
} catch (jobFailureRecordError) {
console.error(`[TaskDetailUploadJob] failed record write failed job=${jobId}:`, jobFailureRecordError)
}
await prisma.material.updateMany({
where: { id: job.materialId, taskStatus: { not: 'completed' } },
data: { taskStatus: 'failed' },
}).catch((statusError) => {
console.error(`[TaskDetailUploadJob] failed to mark material status job=${jobId}:`, statusError)
})
return
}
try {
await prisma.productPackageUploadJob.updateMany({
where: { id: jobId },
data: {
status: TASK_DETAIL_UPLOAD_JOB_STATUS.COMPLETED,
stage: TASK_DETAIL_UPLOAD_JOB_STAGE.COMPLETED,
progress: 100,
completedAt: new Date(),
errorMessage: null,
},
})
} catch (completionRecordError) {
// 素材处理已经成功时,不能把 job 反写成 failed。保留 processing,交给
// reconcile/周期恢复在数据库恢复后补记 completed。
console.error(`[TaskDetailUploadJob] completed record write failed job=${jobId}:`, completionRecordError)
try {
await reconcileCommittedTaskDetailUploadJob(jobId)
} catch (reconcileError) {
console.error(`[TaskDetailUploadJob] completed record reconcile failed job=${jobId}:`, reconcileError)
}
}
} finally {
clearInterval(heartbeatTimer)
}
}
async function pumpTaskDetailUploadJobs() {
if (pumpRunning) {
return
}
pumpRunning = true
try {
while (activeJobCount < MAX_CONCURRENT_JOBS && pendingJobIds.size > 0) {
const nextJobId = pendingJobIds.values().next().value as string | undefined
if (!nextJobId) {
break
}
pendingJobIds.delete(nextJobId)
activeJobCount += 1
void processOneTaskDetailUploadJob(nextJobId)
.catch((error) => {
console.error(`[TaskDetailUploadJob] worker crashed job=${nextJobId}:`, error)
})
.finally(() => {
activeJobCount -= 1
void pumpTaskDetailUploadJobs()
})
}
} finally {
pumpRunning = false
}
}
export function triggerTaskDetailUploadJob(jobId: string) {
pendingJobIds.add(jobId)
void pumpTaskDetailUploadJobs().catch((error) => {
console.error('[TaskDetailUploadJob] queue pump failed:', error)
})
}
export async function createTaskDetailUploadJob(input: CreateTaskDetailUploadJobInput) {
await ensureProductPackageUploadJobTable()
const job = await prisma.$transaction(async (tx) => {
await acquireTaskDetailUploadLock(tx, 'product-package-upload-create')
const existing = await tx.productPackageUploadJob.findFirst({
where: {
materialId: input.materialId,
operatorUserId: input.operatorUserId,
status: {
in: [
TASK_DETAIL_UPLOAD_JOB_STATUS.QUEUED,
TASK_DETAIL_UPLOAD_JOB_STATUS.PROCESSING,
TASK_DETAIL_UPLOAD_JOB_STATUS.COMPLETED,
],
},
},
orderBy: { createdAt: 'desc' },
})
if (existing) return existing
const created = await tx.productPackageUploadJob.create({
data: {
materialId: input.materialId,
operatorUserId: input.operatorUserId,
taskNo: input.taskNo?.trim() || null,
dingtalkApprovalNo: input.dingtalkApprovalNo?.trim() || null,
assets: toJsonValue(input.assets),
options: input.options ? toJsonValue(input.options) : undefined,
status: TASK_DETAIL_UPLOAD_JOB_STATUS.QUEUED,
stage: TASK_DETAIL_UPLOAD_JOB_STAGE.QUEUED,
progress: 0,
errorMessage: null,
},
})
// 素材列表/详情页可以据此识别“正在后台处理”的空素材,用户关闭上传页后
// 仍然能知道它不是一个普通的空卡片。完成函数会把状态推进到 completed。
await tx.material.update({
where: { id: input.materialId },
data: { taskStatus: 'uploading' },
})
return created
})
triggerTaskDetailUploadJob(job.id)
return toStatusView(job)
}
export async function getTaskDetailUploadJobStatus(jobId: string, operatorUserId: string) {
await ensureProductPackageUploadJobTable()
const job = await prisma.productPackageUploadJob.findFirst({
where: {
id: jobId,
operatorUserId,
},
})
return job ? toStatusView(job) : null
}
export async function getLatestTaskDetailUploadJobStatus(materialId: string, operatorUserId: string) {
await ensureProductPackageUploadJobTable()
const job = await prisma.productPackageUploadJob.findFirst({
where: {
materialId,
operatorUserId,
},
orderBy: { createdAt: 'desc' },
})
return job ? toStatusView(job) : null
}
/**
* 服务重启后恢复未完成队列。processing 会在心跳停止并超过 stale 窗口后标记失败,
* 不直接重跑可能已经写入部分资产的非幂等处理链路。
*/
export async function recoverTaskDetailUploadJobs() {
await ensureProductPackageUploadJobTable()
const staleBefore = new Date(Date.now() - getStaleJobMs())
const staleJobs = await prisma.productPackageUploadJob.findMany({
where: {
status: TASK_DETAIL_UPLOAD_JOB_STATUS.PROCESSING,
updatedAt: { lt: staleBefore },
},
select: { id: true, materialId: true },
})
// 处理链路会写入 ZIP、预览图和资产记录,无法安全地把中断中的任务直接
// 重跑,否则可能重复资产或覆盖仍被数据库引用的文件。只有确认完成标记已
// 落库的任务才补记 completed,其余任务标记 failed,交给用户重新提交。
for (const staleJob of staleJobs) {
if (await reconcileCommittedTaskDetailUploadJob(staleJob.id)) continue
await prisma.productPackageUploadJob.updateMany({
where: { id: staleJob.id, status: TASK_DETAIL_UPLOAD_JOB_STATUS.PROCESSING },
data: {
status: TASK_DETAIL_UPLOAD_JOB_STATUS.FAILED,
stage: TASK_DETAIL_UPLOAD_JOB_STAGE.FAILED,
errorMessage: '后台处理进程中断,请重新提交上传',
completedAt: new Date(),
},
})
await prisma.material.updateMany({
where: { id: staleJob.materialId, taskStatus: { not: 'completed' } },
data: { taskStatus: 'failed' },
}).catch((statusError) => {
console.error(`[TaskDetailUploadJob] failed to mark stale material=${staleJob.materialId}:`, statusError)
})
}
const queuedJobs = await prisma.productPackageUploadJob.findMany({
where: { status: TASK_DETAIL_UPLOAD_JOB_STATUS.QUEUED },
orderBy: { createdAt: 'asc' },
take: RECOVERY_BATCH_SIZE,
select: { id: true },
})
for (const job of queuedJobs) {
triggerTaskDetailUploadJob(job.id)
}
if (queuedJobs.length > 0) {
console.log(`[TaskDetailUploadJob] recovered ${queuedJobs.length} queued job(s)`)
}
return queuedJobs.length
}