fix(import): 复用口径忠实化;管道 Outcome 契约与 succeeded_with_issues;报告写入时序;stale 警告

评审修复(Plan 3 Task 1 复审):
- 复用路径只返回 job 锚定字段(jobId/status/reused/siteId/mapping_version/
  report_path),快照/版本块仅在非复用时解析返回——复用结果不再回显编造数据
- ImportPipeline 契约钉死:返回 Promise<ImportPipelineOutcome>
  ({ counts, issues });服务按"累加器 ⊕ 返回值"合并(counts 同键返回值
  覆盖、issues 累加器在前),issues 非空 → succeeded_with_issues;
  ctx.report 即累加器(管道抛错时部分产出仍入报告)
- 报告时序:mkdir + tmp 写入 + rename 先于终态行更新——report_path 落库
  即文件存在;写失败 → failed(REPORT_WRITE_FAILED),不挂悬空路径
- CLI 复用命中非终态(queued/running)→ 崩溃残留警告(重试需显式换 --key)
- 管道抛 ImportError 保留原 code 入 error_code,summary 冠 [pipeline]
- 确定性 23505 探针:未提交孪生 job 行事务内持唯一键触发真实 PG 唯一
  冲突 → 败者复用既有 job
- 报告补 idempotencyKey / attempt 字段
- 计划文档 Task 6 增补 stale 回收决策项(ADR-017)

测试 282 通过(279 + 3:Outcome 合并/ImportError 前缀/23505 探针);
typecheck 四段链 + lint 全绿。

Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
浅念
2026-10-21 15:56:00 +08:00
parent 0e8b2b5356
commit eb188da5c2
4 changed files with 293 additions and 66 deletions
+157 -4
View File
@@ -9,8 +9,11 @@
// (reused 标记 + 管道零调用 + 恰一行);显式 key 覆盖默认;并发同 key 收敛同 job;
// 4. 无 wxr 快照(站点仅有非 wxr 输入 / 显式 id 指向非 wxr 快照)→ NO_WXR_SNAPSHOT;
// 显式 id 不存在或属他站 → SNAPSHOT_NOT_FOUND;站点不存在 → SITE_NOT_FOUND;均零 job 行;
// 5. failed 路径:管道抛错 → running→failed(error_code/error_summary/finished_at),
// 重提交同 key 复用失败 job 不重跑。
// 5. failed 路径:管道抛错 → running→failed(error_code/error_summary/finished_at,
// '[pipeline]' 前缀),重提交同 key 复用失败 job 不重跑;管道抛 ImportError 保留原 code;
// 6. Outcome 契约:ctx.report 累加 ⊕ 返回值(counts 同名键返回值覆盖)→ succeeded_with_issues;
// 7. 复用口径忠实化:reuse 返回只含 job 行锚定字段(快照/版本字段缺席);
// 8. 23505 收敛确定性探针:未提交孪生 job 持有幂等键(真实 PG 锁语义,零代理)。
// 运行于 globalSetup 临时库(ADR-015);storage/报告根用 os.tmpdir 临时目录,finally 清理。
import { existsSync } from 'node:fs'
import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
@@ -121,6 +124,7 @@ describe('import_jobs 生命周期(Task 1 骨架)', () => {
midAttempt = rows[0].attempt
midStartedAt = rows[0].started_at
midFinishedAt = rows[0].finished_at
return { counts: {}, issues: [] }
},
})
@@ -151,6 +155,10 @@ describe('import_jobs 生命周期(Task 1 骨架)', () => {
const report = JSON.parse(await readFile(reportPath, 'utf8')) as {
jobId: string
status: string
idempotencyKey: string
attempt: number
counts: Record<string, number>
issues: string[]
versions: Record<string, string>
snapshots: {
wxr: { id: string }
@@ -160,6 +168,13 @@ describe('import_jobs 生命周期(Task 1 骨架)', () => {
}
expect(report.jobId).toBe(result.jobId)
expect(report.status).toBe('succeeded')
// 报告携带幂等键与 attempt(审计锚点——Fix 7)
expect(report.idempotencyKey).toBe(
`import:${key}:${wxr.id}:${ADAPTER_VERSION}/${MAPPING_VERSION}/${SANITIZER_VERSION}`,
)
expect(report.attempt).toBe(1)
expect(report.counts).toStrictEqual({})
expect(report.issues).toStrictEqual([])
expect(report.versions).toStrictEqual({
adapter: ADAPTER_VERSION,
mapping: MAPPING_VERSION,
@@ -212,6 +227,7 @@ describe('import_jobs 生命周期(Task 1 骨架)', () => {
reportRoot: ctx.importReportRoot,
pipeline: async () => {
pipelineCalls++
return { counts: {}, issues: [] }
},
}
const a = await createImportJob(db, input)
@@ -219,7 +235,7 @@ describe('import_jobs 生命周期(Task 1 骨架)', () => {
expect(a.status).toBe('succeeded')
// 最新快照 = 第二个(v2)
const job = (await db.execute(
`select source_snapshot_id from import_jobs where id = '${a.jobId}'`,
`select source_snapshot_id, report_path from import_jobs where id = '${a.jobId}'`,
)) as any
expect(job[0].source_snapshot_id).toBe(wxr2.id)
@@ -228,6 +244,13 @@ describe('import_jobs 生命周期(Task 1 骨架)', () => {
expect(b.reused).toBe(true)
expect(b.jobId).toBe(a.jobId)
expect(b.status).toBe('succeeded')
// 复用口径忠实化(Fix 1):只返回 job 行锚定字段——快照/版本解析字段缺席
expect('wxrSnapshot' in b).toBe(false)
expect('mediaBundleSnapshot' in b).toBe(false)
expect('versions' in b).toBe(false)
expect('connectionId' in b).toBe(false)
expect(b.mappingVersion).toBe(MAPPING_VERSION)
expect(b.reportPath).toBe(job[0].report_path)
expect(pipelineCalls).toBe(1)
const count = (await db.execute(
`select count(*)::int as n from import_jobs where site_id = '${r1.siteId}'`,
@@ -265,6 +288,7 @@ describe('import_jobs 生命周期(Task 1 骨架)', () => {
reportRoot: ctx.importReportRoot,
pipeline: async () => {
pipelineCalls++
return { counts: {}, issues: [] }
},
}
const [a, b] = await Promise.all([createImportJob(db, input), createImportJob(db, input)])
@@ -386,7 +410,8 @@ describe('import_jobs 生命周期(Task 1 骨架)', () => {
)) as any
expect(job[0].status).toBe('failed')
expect(job[0].error_code).toBe('PIPELINE_FAILED')
expect(String(job[0].error_summary)).toContain('模拟管道失败')
// '[pipeline]' 前缀:管道阶段错误与解析阶段错误在 error_summary 上可区分(Fix 5)
expect(String(job[0].error_summary)).toMatch(/^\[pipeline\] Error: 模拟管道失败/)
expect(job[0].started_at).not.toBeNull()
expect(job[0].finished_at).not.toBeNull()
@@ -408,4 +433,132 @@ describe('import_jobs 生命周期(Task 1 骨架)', () => {
}
})
})
it('Outcome 契约:ctx.report 累加 ⊕ 返回值(返回值 counts 覆盖同名键),issues 非空 → succeeded_with_issues,计数与问题入报告', async () => {
await withTestDb(async (db) => {
const ctx = await makeCtx()
try {
const key = `sfimp-${rand()}`
const reg = await registerAll(db, ctx, key)
try {
const wxr = reg.snapshots.find((s) => s.kind === 'wxr')!
const result = await createImportJob(db, {
siteKey: key,
snapshotId: wxr.id,
reportRoot: ctx.importReportRoot,
pipeline: async (jobCtx) => {
// 写入器式累加(Task 6 写入器的使用形态)
jobCtx.report.counts['terms'] = 2
jobCtx.report.counts['contentEntries'] = 999 // 被返回值同名键覆盖
jobCtx.report.issues.push('自建缺失 term:1(category/uncategorized)')
return {
counts: { contentEntries: 3, routes: 8 },
issues: ['悬空 attachment 引用:2 条(mini 203/204)'],
}
},
})
expect(result.reused).toBe(false)
expect(result.status).toBe('succeeded_with_issues')
const job = (await db.execute(
`select status, error_code from import_jobs where id = '${result.jobId}'`,
)) as any
expect(job[0].status).toBe('succeeded_with_issues')
expect(job[0].error_code).toBeNull()
const report = JSON.parse(
await readFile(join(ctx.importReportRoot, result.jobId, 'reports', 'import.json'), 'utf8'),
) as { status: string; counts: Record<string, number>; issues: string[] }
expect(report.status).toBe('succeeded_with_issues')
// 汇合规则:counts 按键合并(返回值覆盖累加器同名键)、issues 累加器在前返回值在后
expect(report.counts).toStrictEqual({ terms: 2, contentEntries: 3, routes: 8 })
expect(report.issues).toStrictEqual([
'自建缺失 term:1(category/uncategorized)',
'悬空 attachment 引用:2 条(mini 203/204)',
])
} finally {
await cleanupSiteRows(db, reg.siteId)
}
} finally {
await rm(ctx.work, { recursive: true, force: true })
}
})
})
it('管道抛 ImportError:error_code 保留原 code、error_summary 带 [pipeline] 前缀(Fix 5)', async () => {
await withTestDb(async (db) => {
const ctx = await makeCtx()
try {
const key = `sfimp-${rand()}`
const reg = await registerAll(db, ctx, key)
try {
const wxr = reg.snapshots.find((s) => s.kind === 'wxr')!
const result = await createImportJob(db, {
siteKey: key,
snapshotId: wxr.id,
reportRoot: ctx.importReportRoot,
pipeline: async () => {
// 管道内的可预期失败复用同一套错误码面(如侧车快照在管道期校验不过)
throw new ImportError('NO_WXR_SNAPSHOT', '管道内示例:主快照副本读取失败')
},
})
expect(result.status).toBe('failed')
const job = (await db.execute(
`select error_code, error_summary from import_jobs where id = '${result.jobId}'`,
)) as any
expect(job[0].error_code).toBe('NO_WXR_SNAPSHOT')
expect(String(job[0].error_summary)).toMatch(/^\[pipeline\] ImportError: 管道内示例/)
} finally {
await cleanupSiteRows(db, reg.siteId)
}
} finally {
await rm(ctx.work, { recursive: true, force: true })
}
})
})
it('23505 收敛确定性探针:未提交孪生 job 持有幂等键 → 落败方插入撞唯一约束后重查复用(Fix 6)', async () => {
await withTestDb(async (db) => {
const ctx = await makeCtx()
try {
const key = `sfimp-${rand()}`
const reg = await registerAll(db, ctx, key)
try {
const wxr = reg.snapshots.find((s) => s.kind === 'wxr')!
const idemKey = `import:${key}:${wxr.id}:${ADAPTER_VERSION}/${MAPPING_VERSION}/${SANITIZER_VERSION}`
let pipelineCalls = 0
// 事务内插孪生 job(status=running、不提交):业务连接的预查看不到它(read
// committed),插入撞其未提交唯一索引项——事务提交后 23505 确定性触发,
// 服务重查命中已提交行 → 收敛复用。真实并发窗口的库级语义复现,零代理。
let loser: ReturnType<typeof createImportJob> | undefined
await db.transaction(async (tx) => {
await tx.execute(
`insert into import_jobs (id, site_id, source_snapshot_id, status, mapping_version, idempotency_key, attempt, started_at)
values (gen_random_uuid(), '${reg.siteId}', '${wxr.id}', 'running', '${MAPPING_VERSION}', '${idemKey}', 1, now())`,
)
loser = createImportJob(db, {
siteKey: key,
reportRoot: ctx.importReportRoot,
pipeline: async () => {
pipelineCalls++
return { counts: {}, issues: [] }
},
})
// 让落败方先跑完预查、抵达插入(阻塞在孪生的未提交唯一项上)
await new Promise((r) => setTimeout(r, 150))
})
const result = await loser!
expect(result.reused).toBe(true)
expect(result.status).toBe('running') // 孪生行的原状态——复用不改行
expect(pipelineCalls).toBe(0) // 落败方从未执行管道
const count = (await db.execute(
`select count(*)::int as n from import_jobs where site_id = '${reg.siteId}'`,
)) as any
expect(count[0].n).toBe(1) // 只剩孪生行(孪生 status=running 由清理删除)
} finally {
await cleanupSiteRows(db, reg.siteId)
}
} finally {
await rm(ctx.work, { recursive: true, force: true })
}
})
})
})