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

8.2 KiB
Raw Blame History

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 移交项①兑现,真实数据实证)

  1. (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) 兜底)。
  2. 双序终态等价(含 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 钉死)

  1. 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 回收与心跳列

  1. 维持 created_at 近似 + 24h 回收,不加心跳列:每次建 job 前把同 site 超过 STALE_AFTER_MS = 24h 仍 queued/running 的 job 置 stale(崩溃/中断残留终态化,finished_at 记宣判时刻)。评估一句话:单进程 CLI 模式下 job 就地推进、24h 远超任何真实执行时长,心跳列收益低不值当扩列;REST 化(多进程/远程执行)时重审。
  2. 报告 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 审查移交钉死)

  1. 事件期严格串行 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)。
  2. 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 化或多写方并发出现时重审。