feat(sync): reconciliation scheduler with digest and bootstrap healing
P2 Task 4(inbox 对账):
- services/sync 新增 SyncReconciler(runOnce 可测;main 双循环,间隔
SYNC_RECONCILE_INTERVAL_MS env 可调,CN_API_URL 指向 cn 入口):每轮
先按 album/category/photo/asset 逐类分页拉取 cn reconcile「当前状态
事件」并直接签名推送到既有 content-batches 管道。sinceVersion 取 0
(全量当前态)而非 per-type 已应用最大版本——cn reconcile 以类型级
全局版本阈值过滤,类型内单实体滞后会被 max 掩蔽;ov 的 eventId 幂等
+ 版本单调把已追平实体全部判重(recon 确定性 eventId 重扫零成本),
偏差登记:计划原文对账返回「缺失清单」,契约已定为可重放 SyncEvent
数组(免建第二条拉取通道),openapi 描述同步更新
- cn 新增 POST /internal/sync/digest:每相册权威计数 {categoryCount,
publishedPhotoCount, photoCount, assetCount};ov 比对副本计数(照片
取 [publishedPhotoCount, photoCount] 区间口径——hidden/deleted 照片
若从未以 published 态同步过 ov 合法无行,缺席即撤下),不一致相册
自动触发 cn bootstrap 重建;ov-only 相册(cn 行已删,bootstrap 无法
表达删除)告警登记不自动处置
- 收敛语义两处落位:① sync-inbox 版本比较改「严格更旧才拒绝」——
同版本新 eventId(bootstrap/reconcile 快照重发)为当前态幂等重断言
应用,修复 ov 缺失副本行;A26 不受影响(更旧版本仍拒绝,cn 侧
entityVersion 与状态一一对应)② album.updated 快照载荷(bootstrap/
reconcile)携带分类全量清单,ov 存在即整体替换 albums_ov.categories
——修复错过的 category 墓碑(Task 3 登记边界:cn 行已删,无现存实体
事件可重放,唯全量替换可收敛)
- e2e(reconcile.e2e-spec):漏事件追赶(ov v1/cn v5 事件投后即失)→
清扫追平;digest 修复缺失 photos_ov 行(bootstrap 重发 → 同版本
重断言重建);digest 修复丢失的分类墓碑(全量清单替换收敛);稳态
空转(applied=0、无 bootstrap);Task 3 套件保持绿
Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
@@ -1,21 +1,27 @@
|
||||
// services/api/src/sync-inbox/sync-inbox.service.ts —— ov 内容事件收件箱
|
||||
// (P2 Task 3:接收 + 持久化 + 简单应用;对账调度属 Task 4)。逐事件单事务:
|
||||
// (P2 Task 3:接收 + 持久化 + 应用;Task 4 起同版本重断言语义)。逐事件单事务:
|
||||
//
|
||||
// ① eventId 幂等:sync_inbox 主键冲突(含并发窗口)→ duplicate,受理不计应用;
|
||||
// ② entityVersion 单调:sync_state 已应用版本 ≥ 事件版本 → stale,标记
|
||||
// appliedAt(已裁决,非积压)但不投影——旧事件不能复活新状态(A26);
|
||||
// ② entityVersion 单调:sync_state 已应用版本 **严格大于** 事件版本 → stale,
|
||||
// 标记 appliedAt(已裁决,非积压)但不投影——旧事件不能复活新状态(A26);
|
||||
// **等于**已应用版本 → 重断言应用(Task 4 语义):cn 侧 entityVersion 与
|
||||
// 状态一一对应(每次变更事务内自增),同版本 = 同状态,重放为幂等重投影
|
||||
// ——bootstrap/reconcile 快照重发(新 eventId、同版本)正是靠它修复 ov 侧
|
||||
// 缺失/漂移的副本行(digest→bootstrap 补漏通道);正常增量管线不受影响
|
||||
// (同事件重投递走 ① 的 eventId 幂等锚,根本到不了版本比较);
|
||||
// ③ 依赖缺失暂存:非 album 事件在 albums_ov 无所属相册行时保持 appliedAt
|
||||
// null(暂存不 applied,不投影);album 事件应用后按 albumId 回扫暂存
|
||||
// 队列补应用(相册到达即解锁该相册全部暂存事件);
|
||||
// ④ 应用投影(版本已更高):album → albums_ov upsert(不触碰 categories/
|
||||
// bannerAssetKey/emergencyDisabled——分别归 category.* / asset.updated /
|
||||
// 紧急停用通道 Task 5);category → albums_ov.categories JSON 元素
|
||||
// upsert/移除;photo.published → photos_ov upsert(保留 likeTotal/
|
||||
// countVersion 权威计数);photo.hidden/deleted → photos_ov.visibility
|
||||
// 置位(行不存在 = 从未复制即不可见,缺席即撤下语义);asset → banner
|
||||
// 投影 albums_ov.bannerAssetKey(cover/logo 仅推进版本,ov schema 无位);
|
||||
// ⑤ sync_state upsert appliedVersion(watermark=false 实体行;水位行归
|
||||
// Task 4 对账)。
|
||||
// ④ 应用投影(版本不低于已应用):album → albums_ov upsert(不触碰
|
||||
// bannerAssetKey/emergencyDisabled——分别归 asset.updated / 紧急停用通道
|
||||
// Task 5;categories 仅当载荷携带全量清单(bootstrap/reconcile 快照事件)
|
||||
// 时整体替换——错过的 category 墓碑由此收敛,增量事件不携带则不动);
|
||||
// category → albums_ov.categories JSON 元素 upsert/移除;photo.published →
|
||||
// photos_ov upsert(保留 likeTotal/countVersion 权威计数);photo.hidden/
|
||||
// deleted → photos_ov.visibility 置位(行不存在 = 从未复制即不可见,缺席
|
||||
// 即撤下语义);asset → banner 投影 albums_ov.bannerAssetKey(cover/logo
|
||||
// 仅推进版本,ov schema 无位);
|
||||
// ⑤ sync_state upsert appliedVersion(watermark=false 实体行;水位行预留)。
|
||||
//
|
||||
// 墓碑优先由 ② 保证:tombstone 事件版本必高于其前的任意旧版本,旧
|
||||
// photo.published/category.updated 重放被版本比较直接拒绝。
|
||||
@@ -125,7 +131,9 @@ export class SyncInboxService {
|
||||
tx: Prisma.TransactionClient,
|
||||
event: SyncEventWire,
|
||||
): Promise<Outcome> {
|
||||
// ② 版本单调:旧版本裁决即终(appliedAt 置位,不入积压)。
|
||||
// ② 版本单调:**更旧**版本裁决即终(appliedAt 置位,不入积压);**等于**
|
||||
// 已应用版本 → 重断言应用(同版本=同状态,幂等重投影;digest→bootstrap
|
||||
// 补漏修复缺失副本行的机制,见文件头②)。
|
||||
const state = await tx.sync_state.findUnique({
|
||||
where: {
|
||||
entityType_entityId: {
|
||||
@@ -134,7 +142,7 @@ export class SyncInboxService {
|
||||
},
|
||||
},
|
||||
});
|
||||
if (state !== null && state.appliedVersion >= event.entityVersion) {
|
||||
if (state !== null && state.appliedVersion > event.entityVersion) {
|
||||
await tx.sync_inbox.update({
|
||||
where: { eventId: event.eventId },
|
||||
data: { appliedAt: new Date() },
|
||||
@@ -186,7 +194,14 @@ export class SyncInboxService {
|
||||
): Promise<void> {
|
||||
if (event.entityType === 'album') {
|
||||
const p = event.payload as unknown as AlbumUpdatedPayload;
|
||||
// update 分支不触碰 categories/bannerAssetKey/emergencyDisabled(见文件头④)。
|
||||
// categories 全量清单(可选,仅 bootstrap/reconcile 快照事件携带):整体
|
||||
// 替换 albums_ov.categories——错过的 category 墓碑(cn 行已删、无事件可
|
||||
// 重放)只有全量替换才能收敛(Task 4 digest→bootstrap 补漏)。增量事件
|
||||
// 不携带 → 不触碰(分类元素归 category.* 事件管)。bannerAssetKey/
|
||||
// emergencyDisabled 永不触碰(分别归 asset.updated / 紧急停用通道)。
|
||||
const categorySnapshot = Array.isArray(p.categories)
|
||||
? (p.categories as unknown as Prisma.InputJsonValue)
|
||||
: undefined;
|
||||
await tx.albums_ov.upsert({
|
||||
where: { albumId: event.entityId },
|
||||
create: {
|
||||
@@ -197,7 +212,7 @@ export class SyncInboxService {
|
||||
downloadEnabled: p.downloadEnabled,
|
||||
originalDownloadEnabled: p.originalDownloadEnabled,
|
||||
translations: p.translations as unknown as Prisma.InputJsonValue,
|
||||
categories: [],
|
||||
categories: categorySnapshot ?? [],
|
||||
defaultLocale: p.defaultLocale,
|
||||
timezone: p.timezone,
|
||||
startAt: new Date(p.startAt),
|
||||
@@ -211,6 +226,9 @@ export class SyncInboxService {
|
||||
downloadEnabled: p.downloadEnabled,
|
||||
originalDownloadEnabled: p.originalDownloadEnabled,
|
||||
translations: p.translations as unknown as Prisma.InputJsonValue,
|
||||
...(categorySnapshot !== undefined
|
||||
? { categories: categorySnapshot }
|
||||
: {}),
|
||||
defaultLocale: p.defaultLocale,
|
||||
timezone: p.timezone,
|
||||
startAt: new Date(p.startAt),
|
||||
|
||||
Reference in New Issue
Block a user