Files
cms-website/docs/adr/ADR-017-导入管道决策.md
T

42 lines
8.2 KiB
Markdown
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.
# ADR-017:导入管道决策(幂等语义/自建 term/counts 聚合/stale 回收/编排分派)
- **状态**:已采纳(Plan 3 Task 8 收尾,阶段 3;实现锚点 `apps/api/src/modules/import/`——import.service.ts / content-writer.ts / term-writer.ts / media-writer.ts / route-writer.ts / orchestrator.ts)
## 决策一:幂等语义(spec §7.5,承接 ADR-006)
1. **稳定身份键双轨**:
- **快照行(六列)**:source_records 以 (site, connection, source_type, source_id, locale, **snapshot_id**) 唯一(`source_records_identity_uq`)——每快照各一行,是 normalized_checksum 的对比对象;
- **内容身份(五列)**:六列去掉 snapshot_id——跨快照复用同一 content_entry(permalink 变化、快照重导都**不建新 entry**,只在新快照行上前移 entry 指针)。两键由 content-writer 单写入方持有,五列解析口径与 term-writer.resolveContentEntryId 同源(latest first)。
2. **checksum 输入域**:`canonicalJson`(`apps/api/src/common/canonical.ts`——与 register 的 media_bundle 清单哈希**共享同一实现**,单一序列化基形防两处漂移)作用于 `{ universal_row_version, adapter_version, mapping_version, sanitizer_version, locale, mapped_content: {title, slug, status, bodyHtml, seo, knownFields, extras}, taxonomy_relations, media_reference_identities, route_source_values: {sourceUrl} }` 再 sha256。**rawLocation 显式排除**(位置性字段,入哈希破坏跨快照 skip);三语义版本必入(bump 即新修订);status 必入(draft↔publish 翻转必须可见);sourceUrl 入(permalink 变化产新修订——spec §7.5 一致,但不建新 entry)。
3. **收编 UPDATE 语义**:六列身份命中的既有行不重插——checksum 相同 → skip(0 新修订,二导幂等的根基);不同 → 新修订 + entry_locales.current 前移;**published 永不碰**(ADR-010:导入不发布)。外源 NULL-entry 占位行(六列命中、content_entry_id 为 NULL)走 UPDATE 挂 entry——不删不插,避免撞唯一约束。UPDATE 只落在可变表(source_records / entry_locales.current);content_revisions 纯 INSERT(不可变触发器兼容)。
## 决策二:自建 term 策略(Plan 2 移交项①兑现,真实数据实证)
4. **(domain, nicename) 兜底自建**:真实 WXR 的 wp:term 表可整域缺席(hesupipe:category 148 引用 / post_tag 659 引用零条目——WordPress 7.1.3 导出行为)。row 的 taxonomyRefs miss 即自建 term:source_id = **`auto:<nicename>` 前缀**(与 wp:term 的 term_id 字符串源身份空间隔离)、slug = nicename、**name 回退 = nicename**(UniversalRow.taxonomyRefs 契约不携带显示名 value——契约冻结,显示名回填须升版评审)。同一 nicename 多次引用只建一条(唯一 (site, taxonomy, source_id) 兜底)。
5. **双序终态等价(含 null-id 收编)**:WXR 文档序 items 先于 wp:term——row 先到则按 nicename 自建,对应 wp:term 事件后到时**收编**(就地 UPDATE source_id/name 扶正为权威值);反向序(wp:term 先到)直建权威行。**null-id wp:term** 照建行(source_id=`auto:<slug>`)并记 issue;同 slug 行已见时只做 **name-only 收编**(UPDATE name,source_id 不动——无权威值可扶正)。同 slug 双 term_id → 文档序 first-wins + issue(不静默)。两种事件顺序终态一致(混合序独立探针测试钉死)。
## 决策三:counts 键全局命名空间 + 求和聚合(Task 6 钉死)
6. **counts 是跨写入器的扁平全局命名空间**:汇合(ctx.report 累加器 ⊕ 管道返回值、四家 finish 产出)按**同名键求和**聚合(`orchestrator.sumCounts`/`mergeOutcomes`)——Task 1 的同名覆盖(CLOBBER)语义废弃。四家键前缀互斥(term:termsProcessed/autoCreatedTerms/contentTermsCreated;content:contentEntries/revisionsCreated/sourceRecords/skipped/…;media 六键 media 前缀;route 五键 routes 前缀;orchestrator 仅失败路径占 eventsDispatched),求和是防未来键名撞车的安全网;各写入器在自身 JSDoc 声明键归属,新写入器勿复用他家键名。
## 决策四:stale 回收与心跳列
7. **维持 created_at 近似 + 24h 回收,不加心跳列**:每次建 job 前把同 site 超过 `STALE_AFTER_MS = 24h` 仍 queued/running 的 job 置 stale(崩溃/中断残留终态化,finished_at 记宣判时刻)。评估一句话:单进程 CLI 模式下 job 就地推进、24h 远超任何真实执行时长,心跳列收益低不值当扩列;**REST 化(多进程/远程执行)时重审**。
8. **报告 issues 封顶**(Task 8 兑现 Task 6 推迟项,对齐 Plan 2 ROW_ERRORS_CAP 纪律):import.json 的 flat issues 与 issueGroups 各组按 `REPORT_ISSUES_CAP = 10_000` 截断、`issuesTruncated` 记 flat 溢出(分组是同一集合的分划视图,各组溢出 ≤ flat 溢出不单独入账);counts/sanitize 统计始终完整;终态判定(succeeded_with_issues)在**全量**长度上做——截断不吞终态事实。HTML 版(import.html,renderImportReportHtml)与 JSON 同 try 落盘,动态文本全转义(Plan 2 Task 6 模板纪律)。
## 决策五:编排串行分派契约(锚点依赖推导,Task 5 审查移交钉死)
9. **事件期严格串行 await**:content.handleEvent → term → media → route 逐家 await——term/media/route 三家的 entry 锚点解析依赖 content 对**同事件**的落库产物(source_records.content_entry_id / entry_locales),**禁 Promise.all**(并行下锚点查询可能抢在 content 写入前发生)。wp:attachment 行媒体域归 media-writer 单一 owner,content-writer 只留 record-only 足迹(前置事实 11)。
10. **finish 唯一硬序**:`await media.finish()` 先于 `await route.finish()`(attachment 301 反查与 asset 全表都读 media_assets——资产须已齐);term/content finish 是纯返回(无库依赖)任意位置。**失败报告部分冲刷**:分派/finish 中途抛错时 term/content 纯 finish 就地补算 + eventsDispatched 进度计数冲入累加器后重抛;media/route finish 依赖库与先序产物,失败路径不调用(部分数据不可安全取得)。
- **理由**:幂等三态(miss / checksum 同 skip / checksum 异新修订)全部落在数据库唯一约束与不可变触发器之上(Plan 1 的"约束即规格"),应用层零防御性锁;自建 term 与收编语义保证 WXR 文档序不敏感(真实导出序 items 先于 wp:term 是常态而非例外);串行分派以最小复杂度满足锚点依赖,杜绝并行竞态面。
- **权衡/陷阱**:① 五列身份解析的 latest-first 口径依赖 source_records 无并发写入方(CLI 单进程成立,REST 化时需重审——与决策四同一重审点);② auto: 前缀行被后续 wp:term 收编后 source_id 变更——跨快照的 term 幂等以 (site, taxonomy, slug) 域锚定而非 source_id,行为正确但排查时须知两种 source_id 形态并存;③ counts 求和的前提"键前缀互斥"是**约定不是约束**——新写入器复用他家键名会静默相加,JSDoc 键归属声明是唯一防线;④ 24h 回收窗口内崩溃残留的 job 会阻塞同 key 重提交的语义澄清(复用命中非终态 → CLI 警告 + 显式换 --key 重试),这是有意的"不静默重跑"纪律。
## 决策六(终审补记):content_terms is_primary = first-import-wins
跨快照重导时 content_terms 的 onConflictDoNothing 意味着 is_primary **永不翻转**(首导的 primary 关联固定)——与 schema.ts:168 注释"可在不同关联间翻转"的预期不同。阶段 3 单写方下这是确定性行为;若 Plan 4 主题需渲染"主分类"且源站主分类跨快照变化,需升级为 DO UPDATE SET is_primary 并补跨快照翻转测试。
## 决策七(终审补记):对 spec §7.6 事务粒度的有意偏差
spec 要求"每 Source Record 的内容/Revision/分类/路由写入使用数据库事务";实现为按写入器各自事务边界(content 四行创建共事务;term/media/route 单语句原子)。跨域单记录不共事务——由幂等重跑收敛兜底(JSDoc 与 ctx.db 直连约定明示)。REST 化或多写方并发出现时重审。