Protect upload cleanup and atomic completion paths, add durable media worker leases and brand asset validation, and introduce verified migration/bootstrap/deployment gates.
172 lines
5.3 KiB
TypeScript
172 lines
5.3 KiB
TypeScript
/** @jest-environment node */
|
|
|
|
import {
|
|
acquireMaterialUploadArchiveLock,
|
|
acquireMaterialUploadStorageLock,
|
|
acquireTaskMaterialCompletionLock,
|
|
acquireTaskMaterialUploadSessionLocks,
|
|
InProcessMaterialUploadReadWriteLock,
|
|
resolveScopedLockFileName,
|
|
} from '@/lib/material-upload-cross-process-lock'
|
|
|
|
describe('material upload shared/exclusive storage lock', () => {
|
|
test('多个分片可共享读锁,但合并必须等所有分片释放', async () => {
|
|
const lock = new InProcessMaterialUploadReadWriteLock()
|
|
const releaseReaderOne = await lock.acquire('shared', 1_000)
|
|
const releaseReaderTwo = await lock.acquire('shared', 1_000)
|
|
let writerAcquired = false
|
|
const writerPromise = lock.acquire('exclusive', 1_000).then((release) => {
|
|
writerAcquired = true
|
|
return release
|
|
})
|
|
|
|
await Promise.resolve()
|
|
expect(writerAcquired).toBe(false)
|
|
expect(lock.snapshot()).toEqual({
|
|
activeReaders: 2,
|
|
writerActive: false,
|
|
waiting: 1,
|
|
})
|
|
|
|
await releaseReaderOne()
|
|
await Promise.resolve()
|
|
expect(writerAcquired).toBe(false)
|
|
|
|
await releaseReaderTwo()
|
|
const releaseWriter = await writerPromise
|
|
expect(writerAcquired).toBe(true)
|
|
expect(lock.snapshot().writerActive).toBe(true)
|
|
|
|
await releaseWriter()
|
|
expect(lock.snapshot()).toEqual({
|
|
activeReaders: 0,
|
|
writerActive: false,
|
|
waiting: 0,
|
|
})
|
|
})
|
|
|
|
test('写者排队后新分片不能插队,避免大文件合并永久饥饿', async () => {
|
|
const lock = new InProcessMaterialUploadReadWriteLock()
|
|
const releaseInitialReader = await lock.acquire('shared', 1_000)
|
|
const order: string[] = []
|
|
const writerPromise = lock.acquire('exclusive', 1_000).then((release) => {
|
|
order.push('writer')
|
|
return release
|
|
})
|
|
const lateReaderPromise = lock.acquire('shared', 1_000).then((release) => {
|
|
order.push('reader')
|
|
return release
|
|
})
|
|
|
|
await releaseInitialReader()
|
|
const releaseWriter = await writerPromise
|
|
expect(order).toEqual(['writer'])
|
|
await releaseWriter()
|
|
|
|
const releaseLateReader = await lateReaderPromise
|
|
expect(order).toEqual(['writer', 'reader'])
|
|
await releaseLateReader()
|
|
})
|
|
|
|
test('等待超时会返回可重试的结构化错误且不会污染队列', async () => {
|
|
const lock = new InProcessMaterialUploadReadWriteLock()
|
|
const releaseWriter = await lock.acquire('exclusive', 1_000)
|
|
|
|
await expect(lock.acquire('shared', 10)).rejects.toMatchObject({
|
|
status: 503,
|
|
code: 'UPLOAD_STORAGE_BUSY',
|
|
})
|
|
expect(lock.snapshot().waiting).toBe(0)
|
|
|
|
await releaseWriter()
|
|
const releaseReader = await lock.acquire('shared', 1_000)
|
|
await releaseReader()
|
|
})
|
|
})
|
|
|
|
describe('task material completion lock', () => {
|
|
test('同一素材互斥、不同素材可并行,释放后可重试', async () => {
|
|
const releaseMaterialOne = await acquireTaskMaterialCompletionLock('material-1', {
|
|
timeoutMs: 1_000,
|
|
})
|
|
const releaseMaterialTwo = await acquireTaskMaterialCompletionLock('material-2', {
|
|
timeoutMs: 1_000,
|
|
})
|
|
|
|
await expect(
|
|
acquireTaskMaterialCompletionLock('material-1', { timeoutMs: 10 }),
|
|
).rejects.toMatchObject({
|
|
status: 409,
|
|
code: 'TASK_MATERIAL_COMPLETION_BUSY',
|
|
})
|
|
|
|
await releaseMaterialTwo()
|
|
await releaseMaterialOne()
|
|
|
|
const releaseRetry = await acquireTaskMaterialCompletionLock('material-1', {
|
|
timeoutMs: 1_000,
|
|
})
|
|
releaseRetry.assertHeld()
|
|
await releaseRetry()
|
|
})
|
|
|
|
test('非法素材编号在创建锁文件前拒绝', async () => {
|
|
await expect(acquireTaskMaterialCompletionLock('../material')).rejects.toMatchObject({
|
|
status: 400,
|
|
code: 'TASK_MATERIAL_ID_INVALID',
|
|
})
|
|
})
|
|
|
|
test('生产锁名使用 64-bit 哈希,不再只有 256 个桶', () => {
|
|
const names = new Set(
|
|
Array.from({ length: 2_000 }, (_, index) => (
|
|
resolveScopedLockFileName('task-material', `material-${index}`)
|
|
)),
|
|
)
|
|
expect(names.size).toBe(2_000)
|
|
expect(Array.from(names)[0]).toMatch(/^task-material-[a-f0-9]{16}\.lock$/)
|
|
})
|
|
|
|
test('重叠 uploadId 的不同素材不能并发消费源文件', async () => {
|
|
const releaseFirst = await acquireTaskMaterialUploadSessionLocks(
|
|
['upload-b', 'upload-a'],
|
|
{ timeoutMs: 1_000 },
|
|
)
|
|
|
|
await expect(
|
|
acquireTaskMaterialUploadSessionLocks(['upload-a'], { timeoutMs: 10 }),
|
|
).rejects.toMatchObject({
|
|
status: 409,
|
|
code: 'TASK_MATERIAL_UPLOAD_SESSION_BUSY',
|
|
})
|
|
|
|
await releaseFirst()
|
|
const releaseRetry = await acquireTaskMaterialUploadSessionLocks(
|
|
['upload-a'],
|
|
{ timeoutMs: 1_000 },
|
|
)
|
|
releaseRetry.assertHeld()
|
|
await releaseRetry()
|
|
})
|
|
})
|
|
|
|
describe('material upload archive lock', () => {
|
|
test('ZIP 之间串行,但不阻塞分片的 shared 发布锁', async () => {
|
|
const releaseArchive = await acquireMaterialUploadArchiveLock({ timeoutMs: 1_000 })
|
|
|
|
await expect(
|
|
acquireMaterialUploadArchiveLock({ timeoutMs: 10 }),
|
|
).rejects.toMatchObject({
|
|
status: 503,
|
|
code: 'UPLOAD_STORAGE_BUSY',
|
|
})
|
|
|
|
const releaseChunkPublish = await acquireMaterialUploadStorageLock('shared', {
|
|
timeoutMs: 1_000,
|
|
})
|
|
releaseChunkPublish.assertHeld()
|
|
await releaseChunkPublish()
|
|
await releaseArchive()
|
|
})
|
|
})
|