gongxiangp2/scripts/test-material-purge-integration.ts

364 lines
27 KiB
TypeScript
Raw Permalink 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.

/** 仅使用独立的本机 PostgreSQL 和临时上传目录,不读取生产配置。 */
import assert from 'node:assert/strict'
import { randomUUID } from 'node:crypto'
import { promises as fs } from 'node:fs'
import os from 'node:os'
import path from 'node:path'
import type { Prisma } from '@prisma/client'
import type { PurgeFile } from '../lib/material-purge-files'
async function main() {
const url = new URL(process.env.DATABASE_URL || '')
assert(['127.0.0.1', 'localhost'].includes(url.hostname) && url.port === '55439' && /^\/material_purge(?:_migration)?_qa$/.test(url.pathname), '拒绝连接非独立验收数据库')
process.env.NEXT_BUILD = '1'
process.env.NODE_ENV = 'production'
const s3Enabled = process.argv.includes('--s3')
if (s3Enabled) {
Object.assign(process.env, { S3_ENDPOINT: 'http://127.0.0.1:59000', S3_PUBLIC_DOMAIN: 'http://127.0.0.1:59000/purge-qa',
S3_BUCKET: 'purge-qa', S3_FILE_PATH: 'project-purge-qa', S3_ACCESS_KEY_ID: 'purgeqa', S3_SECRET_ACCESS_KEY: 'purge_local_storage_qa' })
}
const root = await fs.mkdtemp(path.join(os.tmpdir(), 'gongxiangp2-purge-integration-'))
process.env.UPLOAD_DIR = root
// 每次使用独立数据库,原生 SQL 与 Prisma 使用同一个 public schema。
const { PrismaClient } = await import('@prisma/client')
const database = `material_purge_qa_${randomUUID().replace(/-/g, '')}`
const setup = new PrismaClient({ datasourceUrl: url.toString() })
await setup.$executeRawUnsafe(`CREATE DATABASE "${database}"`)
await setup.$disconnect()
url.pathname = `/${database}`
url.searchParams.set('schema', 'public')
process.env.DATABASE_URL = url.toString()
const { spawnSync } = await import('node:child_process')
const pushed = spawnSync(process.execPath, ['node_modules/prisma/build/index.js', 'db', 'push', '--skip-generate'], { encoding: 'utf8', env: process.env })
assert.equal(pushed.status, 0, pushed.stderr)
// 重建迁移前的日志级联关系,在空的专用数据库中执行真实 reviewed SQL。
const migrationSql = await fs.readFile('prisma/migrations/20261009_material_purge_outbox/migration.sql', 'utf8')
const referenceSql = await fs.readFile('prisma/migrations/20261009_material_purge_reference_indexes/migration.sql', 'utf8')
const baselineSql = `DROP TABLE material_purge_audits; DROP TABLE material_purge_jobs;
ALTER TABLE material_logs ADD CONSTRAINT "material_logs_materialId_fkey" FOREIGN KEY ("materialId") REFERENCES materials(id) ON DELETE CASCADE;
ALTER TABLE material_download_logs ADD CONSTRAINT "material_download_logs_materialId_fkey" FOREIGN KEY ("materialId") REFERENCES materials(id) ON DELETE CASCADE;`
const migrated = spawnSync('docker', ['exec', '-i', 'gongxiangp2-purge-qa-20261009', 'psql', '-U', 'postgres', '-d', database, '-v', 'ON_ERROR_STOP=1'], {
encoding: 'utf8', input: `${baselineSql}\n${migrationSql}\n${referenceSql}`,
})
assert.equal(migrated.status, 0, migrated.stderr)
const { prisma } = await import('../lib/prisma')
const { enqueueMaterialPurge, processNextMaterialPurge, retryMaterialPurge, ensureMaterialPurgeWorkerStarted } = await import('../lib/material-purge')
const { collectPurgeFiles, findPurgeFileReferences, purgeFileIdentity, identifyPurgeFile } = await import('../lib/material-purge-files')
const { findIndexedPurgeReferenceSources } = await import('../lib/material-purge-reference-query')
async function explainReferences(db: Prisma.TransactionClient, files: PurgeFile[]) {
let query: { strings: TemplateStringsArray; values: unknown[] } | undefined
const wrapped = { $queryRaw: (strings: TemplateStringsArray, ...values: unknown[]) => {
query = { strings, values }
return db.$queryRaw(strings, ...values)
} } as Prisma.TransactionClient
await findIndexedPurgeReferenceSources(wrapped, files)
assert(query)
const sql = query.strings.reduce((value, part, index) => value + (index ? '$' + index : '') + part, '')
return db.$queryRawUnsafe<any[]>('EXPLAIN (ANALYZE, BUFFERS, FORMAT JSON) ' + sql, ...query.values)
}
const run = randomUUID()
const passed: string[] = []
passed.push('真实升级 SQL 创建清理表、重试索引与审计表,并解除历史日志的素材级联删除')
const admin = await prisma.user.create({ data: { email: `${run}@purge.test`, username: run, password: 'local-qa-only', role: 'ADMIN' } })
const make = async (name: string, extra: any = {}) => prisma.material.create({ data: {
productModel: `${run}-${name}`, previewUrl: 'data:image/svg+xml;base64,Zm9v', categoryName: '验收',
sceneName: '验收', materialType: '标准模型', authorName: '隔离验收', userId: admin.id, deletedAt: new Date(), ...extra,
} })
const file = async (name: string) => { await fs.writeFile(path.join(root, name), 'isolated content'); return `uploads/${name}` }
const exists = async (name: string) => fs.stat(path.join(root, name)).then(() => true, () => false)
try {
const active = await make('active', { deletedAt: null })
const bin = await make('atomic', { zipFilePath: await file('atomic.bin') })
await assert.rejects(enqueueMaterialPurge([bin.id, active.id], admin.id), /只能彻底删除/)
assert(await prisma.material.findUnique({ where: { id: bin.id } }))
assert.equal(await prisma.materialPurgeJob.count({ where: { materialId: bin.id } }), 0)
assert(await exists('atomic.bin'))
passed.push('混合批量整批拒绝,素材/文件/任务均无部分提交')
await assert.rejects(enqueueMaterialPurge([bin.id], 'missing-operator'), /Foreign key/)
assert(await prisma.material.findUnique({ where: { id: bin.id } }))
assert.equal(await prisma.materialPurgeJob.count({ where: { materialId: bin.id } }), 0)
passed.push('审计写入失败时整个删除事务回滚')
const uploading = await make('uploading')
await prisma.productPackageUploadJob.create({ data: { materialId: uploading.id, operatorUserId: admin.id, assets: [], status: 'queued', stage: 'queued' } })
await assert.rejects(enqueueMaterialPurge([uploading.id], admin.id), /仍在处理中/)
const transcoding = await make('transcoding', { transcodeLeaseToken: 'active-lease' })
await assert.rejects(enqueueMaterialPurge([transcoding.id], admin.id), /仍在处理中/)
passed.push('上传/转码未结束的素材拒绝进入清理队列')
const own = await make('single', { zipFilePath: await file('single.bin') })
await prisma.materialAsset.create({ data: { materialId: own.id, assetType: 'arbitrary-type', fileUrl: await file('asset.bin') } })
await prisma.materialSku.create({ data: { materialId: own.id, skuName: 'test', filePath: await file('sku.bin') } })
await prisma.materialLog.create({ data: { materialId: own.id, userId: admin.id, action: 'UPLOAD' } })
await prisma.materialDownloadLog.create({ data: { materialId: own.id, userId: admin.id } })
const requests = await Promise.all([enqueueMaterialPurge([own.id, own.id], admin.id), enqueueMaterialPurge([own.id], admin.id)])
assert.equal(requests[0][0].id, requests[1][0].id)
assert.equal(await prisma.materialPurgeJob.count({ where: { materialId: own.id } }), 1)
assert.equal(await prisma.materialLog.count({ where: { materialId: own.id } }), 2)
assert.equal(await prisma.materialDownloadLog.count({ where: { materialId: own.id } }), 1)
assert.equal(await prisma.materialAsset.count({ where: { materialId: own.id } }), 0)
assert.equal(await prisma.materialSku.count({ where: { materialId: own.id } }), 0)
assert(await exists('single.bin'))
await processNextMaterialPurge()
assert(!await exists('single.bin') && !await exists('asset.bin') && !await exists('sku.bin'))
assert.equal((await prisma.materialPurgeJob.findUniqueOrThrow({ where: { materialId: own.id } })).status, 'completed')
passed.push('并发/重复提交幂等;关联记录清理,旧日志和下载日志保留;所有类型资产纳入清单')
const sharedSource = await file('shared.bin')
const keeper = await make('shared-keeper', { previewUrl: '/uploads/shared.bin' })
const sharer = await make('shared-delete', { zipFilePath: sharedSource })
await enqueueMaterialPurge([sharer.id], admin.id)
await processNextMaterialPurge()
assert(await exists('shared.bin'))
const sharedJob = await prisma.materialPurgeJob.findUniqueOrThrow({ where: { materialId: sharer.id } })
assert.equal((sharedJob.files as any)[0].outcome, 'shared')
assert(await prisma.material.findUnique({ where: { id: keeper.id } }))
passed.push('URL 等价引用与回收站引用保护共用文件')
const specialSource = await file('shared[1]+.bin')
const specialKeeper = await make('special-keeper')
await prisma.materialAsset.create({ data: { materialId: specialKeeper.id, assetType: 'test', fileUrl: '/uploads/shared[1]+.bin' } })
const special = await make('special-delete', { zipFilePath: specialSource })
const specialFiles = await collectPurgeFiles({ ...special, assets: [], skus: [] })
const fullReferences = await findPurgeFileReferences(prisma)
const targetedReferences = await findPurgeFileReferences(prisma, specialFiles)
for (const target of specialFiles) assert.equal(targetedReferences.has(purgeFileIdentity(target)), fullReferences.has(purgeFileIdentity(target)))
await enqueueMaterialPurge([special.id], admin.id)
await processNextMaterialPurge()
assert(await exists('shared[1]+.bin'))
const otherDirectory = await make('same-filename', { zipFilePath: await file('same-filename.bin') })
await prisma.materialAsset.create({ data: { materialId: specialKeeper.id, assetType: 'test', fileUrl: 'uploads/other/same-filename.bin' } })
await enqueueMaterialPurge([otherDirectory.id], admin.id)
await processNextMaterialPurge()
assert(!await exists('same-filename.bin'))
await findPurgeFileReferences(prisma, Array.from({ length: 2000 }, () => ({
source: '', storage: 'local' as const, key: `${randomUUID()}.bin`, outcome: 'pending' as const,
})))
passed.push('真实 PostgreSQL 候选筛选与全量引用结果一致:特殊文件名共用资产保留,同名不同路径文件正常删除')
const batch = await Promise.all([make('batch-a', { zipFilePath: await file('batch-a.bin') }), make('batch-b', { zipFilePath: await file('batch-b.bin') })])
const batchJobs = await enqueueMaterialPurge(batch.map(material => material.id), admin.id)
assert.equal(batchJobs.length, 2)
// 模拟作业进程中断:持久化处理中状态和已过期租约,下一轮自动接管。
await prisma.materialPurgeJob.update({ where: { id: batchJobs[0].id }, data: { status: 'processing', leaseToken: 'interrupted-process', leaseExpiresAt: new Date(0) } })
await Promise.all([processNextMaterialPurge(), processNextMaterialPurge()])
assert(!await exists('batch-a.bin') && !await exists('batch-b.bin'))
assert.equal(await prisma.materialPurgeJob.count({ where: { id: { in: batchJobs.map(job => job.id) }, status: 'completed' } }), 2)
passed.push('批量作业并发领取不重复,重启后过期租约可接管并完成')
const wide = await make('wide-batch')
const wideFiles = await Promise.all(Array.from({ length: 19 }, (_, index) => file(`wide-${index}.bin`)))
await prisma.materialAsset.createMany({ data: wideFiles.map(fileUrl => ({ materialId: wide.id, assetType: 'test', fileUrl })) })
const [wideJob] = await enqueueMaterialPurge([wide.id], admin.id)
const wideStarted = Date.now()
await processNextMaterialPurge()
const wideResult = await prisma.materialPurgeJob.findUniqueOrThrow({ where: { id: wideJob.id } })
assert.equal(wideResult.status, 'completed')
assert.equal((wideResult.files as any[]).filter(f => f.outcome === 'deleted').length, 19)
assert((await Promise.all(wideFiles.map((_, index) => exists(`wide-${index}.bin`)))).every(value => !value))
passed.push(`真实文件系统跨三个清理批次完成 19 个文件,结果全部持久化,耗时 ${Date.now() - wideStarted}ms`)
const broken = await make('retry', { zipFilePath: await file('retry-first.bin'), displayImages: [await file('retry-second.bin')] })
const [retryJob] = await enqueueMaterialPurge([broken.id], admin.id)
await fs.rename(path.join(root, 'retry-second.bin'), path.join(root, 'saved-second.bin'))
await fs.mkdir(path.join(root, 'retry-second.bin'))
for (let attempt = 0; attempt < 3; attempt += 1) {
await prisma.materialPurgeJob.update({ where: { id: retryJob.id }, data: { nextAttemptAt: new Date(0) } })
await processNextMaterialPurge()
}
let failed = await prisma.materialPurgeJob.findUniqueOrThrow({ where: { id: retryJob.id } })
assert.equal(failed.status, 'failed')
assert(!await exists('retry-first.bin'))
assert.equal((failed.files as any)[0].outcome, 'deleted')
await fs.rmdir(path.join(root, 'retry-second.bin'))
await fs.rename(path.join(root, 'saved-second.bin'), path.join(root, 'retry-second.bin'))
await retryMaterialPurge(retryJob.id, admin.id)
await processNextMaterialPurge()
failed = await prisma.materialPurgeJob.findUniqueOrThrow({ where: { id: retryJob.id } })
assert.equal(failed.status, 'completed')
assert(!await exists('retry-second.bin'))
const audits = await prisma.materialPurgeAudit.findMany({ where: { jobId: retryJob.id }, orderBy: { createdAt: 'asc' } })
assert(audits.some(audit => audit.event === 'failed'))
assert(audits.some(audit => audit.event === 'manual_retry' && audit.operatorUserId === admin.id))
passed.push('部分文件失败保存进度,自动重试耗尽后人工续跑;失败/重试操作人审计完整')
const indexedSource = await file('indexed-变更[1]%.bin')
const indexedFiles = [{ ...identifyPurgeFile(indexedSource)!, outcome: 'pending' as const }]
const indexedKeeper = await make('index-maintenance')
const indexedAsset = await prisma.materialAsset.create({ data: { materialId: indexedKeeper.id, assetType: 'test', fileUrl: indexedSource } })
assert((await findPurgeFileReferences(prisma, indexedFiles)).has(purgeFileIdentity(indexedFiles[0])))
await prisma.$executeRaw`UPDATE material_assets SET file_url = 'uploads/unrelated.bin' WHERE id = ${indexedAsset.id}`
assert(!(await findPurgeFileReferences(prisma, indexedFiles)).has(purgeFileIdentity(indexedFiles[0])))
const jsonJob = await prisma.productPackageUploadJob.create({ data: { materialId: indexedKeeper.id, operatorUserId: admin.id,
assets: [{ nested: { fileUrl: indexedSource } }], status: 'queued', stage: 'queued' } })
assert((await findPurgeFileReferences(prisma, indexedFiles)).has(purgeFileIdentity(indexedFiles[0])))
await prisma.productPackageUploadJob.update({ where: { id: jsonJob.id }, data: { status: 'completed' } })
assert(!(await findPurgeFileReferences(prisma, indexedFiles)).has(purgeFileIdentity(indexedFiles[0])))
const aliases = [indexedSource, `/uploads/./indexed-变更[1]%.bin`, path.join(root, 'indexed-变更[1]%.bin'),
'\u00a0/UPLOADS/INDEXED-变更[1]%.BIN\ufeff', 'uploads\\indexed-变更[1]%.bin']
for (const previewUrl of aliases) {
await prisma.material.update({ where: { id: indexedKeeper.id }, data: { previewUrl } })
const full = await findPurgeFileReferences(prisma)
const targeted = await findPurgeFileReferences(prisma, indexedFiles)
assert.equal(targeted.has(purgeFileIdentity(indexedFiles[0])), full.has(purgeFileIdentity(indexedFiles[0])), previewUrl)
}
await prisma.material.update({ where: { id: indexedKeeper.id }, data: { previewUrl: 'data:image/png;base64,Zm9v' } })
passed.push('数据库索引随 ORM/直接 SQL 写入和队列状态变化同步;Unicode、百分号、大小写、绝对路径、点路径引用与全量审计一致')
if (s3Enabled) {
const { S3Client, CreateBucketCommand, PutObjectCommand, HeadObjectCommand } = await import('@aws-sdk/client-s3')
const client = new S3Client({ endpoint: process.env.S3_ENDPOINT, region: 'us-east-1', forcePathStyle: true,
credentials: { accessKeyId: process.env.S3_ACCESS_KEY_ID!, secretAccessKey: process.env.S3_SECRET_ACCESS_KEY! }, maxAttempts: 1 })
const Bucket = process.env.S3_BUCKET!
try { await client.send(new CreateBucketCommand({ Bucket })) } catch (error: any) { if (error.name !== 'BucketAlreadyOwnedByYou') throw error }
const ownKey = `project-purge-qa/${run}/owned.bin`
const sharedKey = `project-purge-qa/${run}/shared.bin`
const foreignKey = `other-project/${run}/sentinel.bin`
for (const Key of [ownKey, sharedKey, foreignKey]) await client.send(new PutObjectCommand({ Bucket, Key, Body: 'isolated-object' }))
const source = (key: string) => `http://127.0.0.1:59000/${Bucket}/${key}`
const objectMaterial = await make('object-own', { zipFilePath: source(ownKey) })
await enqueueMaterialPurge([objectMaterial.id], admin.id)
await processNextMaterialPurge()
await assert.rejects(client.send(new HeadObjectCommand({ Bucket, Key: ownKey })), error => (error as any).$metadata.httpStatusCode === 404)
const objectKeeper = await make('object-keeper', { previewUrl: `${source(sharedKey)}?cache=keeper` })
const objectShared = await make('object-shared', { zipFilePath: source(sharedKey) })
await enqueueMaterialPurge([objectShared.id], admin.id)
await processNextMaterialPurge()
await client.send(new HeadObjectCommand({ Bucket, Key: sharedKey }))
assert(await prisma.material.findUnique({ where: { id: objectKeeper.id } }))
const encodedKey = `project-purge-qa/${run}/encoded[1]+.bin`
await client.send(new PutObjectCommand({ Bucket, Key: encodedKey, Body: 'isolated-object' }))
await prisma.materialAsset.create({ data: { materialId: objectKeeper.id, assetType: 'test',
fileUrl: `https://legacy-storage.test/${Bucket}/${encodedKey.replace('encoded', '%65ncoded')}?cache=1` } })
const encoded = await make('encoded-shared', { zipFilePath: source(encodedKey) })
await enqueueMaterialPurge([encoded.id], admin.id)
await processNextMaterialPurge()
await client.send(new HeadObjectCommand({ Bucket, Key: encodedKey }))
const localWhiteBackground = `uploads/skus/${objectKeeper.id}/white-background/${run}/preview/shared-display.png`
const whiteAsset = await prisma.materialAsset.create({ data: { materialId: objectKeeper.id, assetType: 'test', fileUrl: localWhiteBackground } })
const { buildPublicDisplayUrl, buildWhiteBackgroundPublicDisplayPathFromAssetPath } = await import('../lib/public-display')
const displaySource = buildPublicDisplayUrl(buildWhiteBackgroundPublicDisplayPathFromAssetPath(localWhiteBackground)!)!
const { getTrustedS3MutationKey } = await import('../lib/s3')
const displayKey = getTrustedS3MutationKey(displaySource)
await client.send(new PutObjectCommand({ Bucket, Key: displayKey, Body: 'isolated-display' }))
const display = await make('derived-shared', { zipFilePath: displaySource })
await enqueueMaterialPurge([display.id], admin.id)
await processNextMaterialPurge()
await client.send(new HeadObjectCommand({ Bucket, Key: displayKey }))
const displayTarget = [{ ...identifyPurgeFile(displaySource)!, outcome: 'pending' as const }]
for (const fileUrl of [localWhiteBackground + '#preview', localWhiteBackground + '?cache=1',
localWhiteBackground.replace('/preview/shared-display', '/preview/./shared-display'),
localWhiteBackground.replace('/preview/shared-display', '/preview/sub/../shared-display'),
localWhiteBackground.replace('/preview/shared-display', '/preview/%2e/shared-display')]) {
await prisma.materialAsset.update({ where: { id: whiteAsset.id }, data: { fileUrl } })
const full = await findPurgeFileReferences(prisma)
const next = await findPurgeFileReferences(prisma, displayTarget)
assert(full.has(purgeFileIdentity(displayTarget[0])), '白底派生引用确实存在')
assert(next.has(purgeFileIdentity(displayTarget[0])), `不得漏掉派生 URL 引用 ${fileUrl}`)
}
await prisma.materialAsset.update({ where: { id: whiteAsset.id }, data: { fileUrl: localWhiteBackground } })
const referenceTarget = [{ ...identifyPurgeFile(source(sharedKey))!, outcome: 'pending' as const }]
for (const previewUrl of [
source(sharedKey).replace('shared', '%73hared'), source(sharedKey).replace('shared', '%2573hared'),
`https://legacy.test/${sharedKey.replace('shared', '%73hared')}?cache=1#fragment`,
`https://legacy.test/${sharedKey.replace('shared', 'sha\tred')}`,
source(sharedKey).replace('/shared', '/unused/../shared'),
source(sharedKey).replace('/shared', '/%2e/shared'),
source(sharedKey).replace(`/${Bucket}/`, `//${Bucket}/`),
]) {
await prisma.material.update({ where: { id: objectKeeper.id }, data: { previewUrl } })
const full = await findPurgeFileReferences(prisma)
const targeted = await findPurgeFileReferences(prisma, referenceTarget)
assert.equal(targeted.has(purgeFileIdentity(referenceTarget[0])), full.has(purgeFileIdentity(referenceTarget[0])), previewUrl)
}
const objectForeign = await make('object-foreign', { zipFilePath: source(foreignKey) })
await assert.rejects(enqueueMaterialPurge([objectForeign.id], admin.id), /业务前缀之外/)
await client.send(new HeadObjectCommand({ Bucket, Key: foreignKey }))
assert(await prisma.material.findUnique({ where: { id: objectForeign.id } }))
passed.push('真实 MinIO 验证:本项目对象删除成功,共用对象保留,其他项目前缀拒绝删除')
client.destroy()
}
if (process.argv.includes('--scale')) {
const scaleKeeper = await make('400k-reference-keeper')
for (let offset = 0; offset < 400_000; offset += 80_000) {
await prisma.$executeRaw`INSERT INTO material_assets (id, material_id, asset_type, file_url, sort_order, updated_at)
SELECT ${run + '-scale-'} || i::text, ${scaleKeeper.id}, 'qa-scale',
'https://isolated.test/project/scale/' || i::text || '/image.bin', 0, now() FROM generate_series(${offset}, ${offset + 79_999}) AS i`
console.log(JSON.stringify({ mode: 'isolated-scale-setup', assets: offset + 80_000 }))
}
await prisma.$executeRaw`ANALYZE material_assets`
const scaleSources = await Promise.all(Array.from({ length: 4 }, (_, i) => file(`scale-delete-${i}.bin`)))
const scaleMaterial = await make('scale-delete', { zipFilePath: scaleSources[0], displayImages: scaleSources.slice(1) })
const scaleFiles = await collectPurgeFiles({ ...scaleMaterial, assets: [], skus: [] })
const plan = await explainReferences(prisma, scaleFiles)
assert(JSON.stringify(plan).includes('material_assets_purge_references_idx'), '40万行必须实际使用引用索引')
const referenceStarted = Date.now()
await findPurgeFileReferences(prisma, scaleFiles)
const referenceMs = Date.now() - referenceStarted
const submitted = Date.now()
const [scaleJob] = await enqueueMaterialPurge([scaleMaterial.id], admin.id)
const acceptedMs = Date.now() - submitted
await processNextMaterialPurge()
const completedMs = Date.now() - submitted
const result = await prisma.materialPurgeJob.findUniqueOrThrow({ where: { id: scaleJob.id } })
assert.equal(result.status, 'completed')
assert((await Promise.all(scaleSources.map((_, i) => exists(`scale-delete-${i}.bin`)))).every(value => !value))
assert(referenceMs < 500 && completedMs < 1500, `索引检查 ${referenceMs}ms / 实际删除 ${completedMs}ms 超出验收线`)
const bulkFiles: PurgeFile[] = Array.from({ length: 106 }, (_, index) => ({
source: `https://isolated.test/project/bulk/${index}.bin`, storage: 's3',
bucket: process.env.S3_BUCKET || 'qa-bucket', key: `project/bulk/${index}.bin`, outcome: 'pending',
}))
bulkFiles.push(...specialFiles)
const bulkPlans = []
for (const mode of ['force_custom_plan', 'force_generic_plan']) {
const measurement = await prisma.$transaction(async tx => {
await tx.$executeRawUnsafe('SET LOCAL plan_cache_mode = ' + mode)
const started = Date.now()
const refs = await findPurgeFileReferences(tx, bulkFiles)
const ms = Date.now() - started
assert(refs.has(purgeFileIdentity(specialFiles[0])), '大批量查询仍须保留共享文件')
const executionPlan = await explainReferences(tx, bulkFiles)
assert(JSON.stringify(executionPlan).includes('material_assets_purge_references_idx'), `${mode} 大批量必须使用索引`)
assert(ms < 1500, `${mode} 大批量引用查询 ${ms}ms 超出验收线`)
return { mode, files: bulkFiles.length, ms, plan: executionPlan }
}, { timeout: 30_000 })
bulkPlans.push(measurement)
}
await fs.mkdir('tmp/material-purge-index-qa', { recursive: true })
await fs.writeFile('tmp/material-purge-index-qa/scale.json', JSON.stringify({ assets: await prisma.materialAsset.count(), referenceMs, acceptedMs, completedMs, plan, bulkPlans }, null, 2))
passed.push(`40万资产下只查目标引用 ${referenceMs}ms;提交 ${acceptedMs}ms、4个文件真实删除及审计完成 ${completedMs}ms,执行计划使用 GIN 索引`)
passed.push(`107个文件在 custom/generic 两种执行计划下均使用索引并保护共享引用:${bulkPlans.map(p => p.ms + 'ms').join(' / ')}`)
}
delete process.env.NEXT_BUILD
ensureMaterialPurgeWorkerStarted()
await new Promise(resolve => setTimeout(resolve, 100))
const wake = await make('wake-up', { zipFilePath: await file('wake-up.bin') })
const [wakeJob] = await enqueueMaterialPurge([wake.id], admin.id)
const wakeStarted = Date.now()
ensureMaterialPurgeWorkerStarted()
let wakeResult
do {
await new Promise(resolve => setTimeout(resolve, 25))
wakeResult = await prisma.materialPurgeJob.findUniqueOrThrow({ where: { id: wakeJob.id } })
} while (wakeResult.status !== 'completed' && Date.now() - wakeStarted < 1500)
assert.equal(wakeResult.status, 'completed', '新请求必须直接唤醒已启动的 worker,不能等 5 秒轮询')
process.env.NEXT_BUILD = '1'
passed.push(`已启动的 worker 收到提交立即清理,${Date.now() - wakeStarted}ms 完成,未等待5秒轮询`)
const output = { database: url.pathname.slice(1), passed, checks: passed.length, at: new Date().toISOString() }
await fs.mkdir('tmp/material-purge-qa', { recursive: true })
await fs.writeFile('tmp/material-purge-qa/integration.json', JSON.stringify(output, null, 2))
console.log(JSON.stringify(output, null, 2))
} finally {
await prisma.$disconnect()
// root 是本次 mkdtemp 创建的目录,仅移除这次隔离文件。
assert(path.dirname(root) === os.tmpdir() && path.basename(root).startsWith('gongxiangp2-purge-integration-'))
await fs.rm(root, { recursive: true, force: true })
}
}
main().catch(error => { console.error(error); process.exitCode = 1 })