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:
@@ -34,8 +34,10 @@ export interface PhotoPublishedPayload {
|
||||
}
|
||||
|
||||
// album.updated 载荷:ov 副本(albums_ov)展示一张相册所需的标量 + 翻译全集。
|
||||
// categories 不在此列——分类是独立事件(category.*,各自携带 entityVersion);
|
||||
// categories 默认不在此列——分类是独立事件(category.*,各自携带 entityVersion);
|
||||
// banner 对象键经 asset.updated 事件同步(albums_ov.bannerAssetKey)。
|
||||
// 例外:bootstrap/reconcile 的快照事件携带可选 categories 全量清单(Task 4
|
||||
// digest 补漏的收敛原语,见 AlbumCategorySnapshot 注释)。
|
||||
export interface AlbumUpdatedPayload {
|
||||
albumId: string;
|
||||
publicToken: string;
|
||||
@@ -49,9 +51,22 @@ export interface AlbumUpdatedPayload {
|
||||
startAt: string; // ISO UTC
|
||||
endAt: string | null; // ISO UTC;null 表示未定
|
||||
translations: AlbumTranslationPayload[]; // locale 升序(确定性输出)
|
||||
/** 分类全量清单(可选):存在时 ov **整体替换** albums_ov.categories。 */
|
||||
categories?: AlbumCategorySnapshot[];
|
||||
entityVersion: number;
|
||||
}
|
||||
|
||||
// albums_ov.categories JSON 元素的载荷形状。日常增量由 category.updated 逐个
|
||||
// upsert / category.deleted 墓碑逐个移除;但**错过的墓碑**(cn 行已删,对账
|
||||
// 无法为不存在的行重放事件)只有「全量清单整体替换」才能收敛——bootstrap/
|
||||
// reconcile 的 album.updated 快照事件据此携带当前全集(Task 4 digest→bootstrap
|
||||
// 补漏通道)。缺省(增量事件不携带)时 ov 不触碰 categories,语义不变。
|
||||
export interface AlbumCategorySnapshot {
|
||||
id: string;
|
||||
sortOrder: number;
|
||||
translations: CategoryTranslationPayload[]; // locale 升序
|
||||
}
|
||||
|
||||
export interface AlbumTranslationPayload {
|
||||
locale: string;
|
||||
title: string;
|
||||
|
||||
+94
-2
@@ -664,6 +664,30 @@ export interface paths {
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/internal/sync/digest": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
get?: never;
|
||||
put?: never;
|
||||
/**
|
||||
* 每相册实体计数摘要
|
||||
* @description cn 侧权威计数(P2 Task 4):每相册 {categoryCount, publishedPhotoCount,
|
||||
* photoCount, assetCount}。ov 对账调度定期拉取并与副本计数比对——不一致
|
||||
* 的相册触发该相册 bootstrap(当前状态快照重发,含分类全量清单整体替换),
|
||||
* 收敛错过的墓碑与漏掉的事件。albumId 缺省表示全部相册。鉴权见 [internal]
|
||||
* tag 描述。
|
||||
*/
|
||||
post: operations["syncDigest"];
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
}
|
||||
export type webhooks = Record<string, never>;
|
||||
export interface components {
|
||||
@@ -1376,9 +1400,14 @@ export interface components {
|
||||
*/
|
||||
limit: number;
|
||||
};
|
||||
/** @description 对账结果:按 entityVersion 升序的事件(无缺失为空数组)。 */
|
||||
/**
|
||||
* @description 对账结果:按 entityVersion 升序的**当前状态事件**(可直接重放的完整
|
||||
* SyncEvent,非仅版本清单——P2 Task 4 设计决策:重放走既有
|
||||
* content-batches 管道,无需第二条拉取通道;sinceVersion 之后无实体时为
|
||||
* 空数组)。
|
||||
*/
|
||||
ReconcileResponse: {
|
||||
/** @description 缺失事件(升序);请求方按序重放。 */
|
||||
/** @description 当前状态事件(升序);请求方按序经 content-batches 重放。 */
|
||||
events: components["schemas"]["SyncEvent"][];
|
||||
};
|
||||
/** @description 全量快照引导请求。 */
|
||||
@@ -1396,6 +1425,35 @@ export interface components {
|
||||
*/
|
||||
watermark: number;
|
||||
};
|
||||
/** @description 对账摘要请求(P2 Task 4)。 */
|
||||
DigestRequest: {
|
||||
/** @description 指定相册;缺省为全部相册。 */
|
||||
albumId?: string;
|
||||
};
|
||||
/**
|
||||
* @description 单相册权威计数摘要。ov 侧比对口径:albums_ov.categories 元素数应等于
|
||||
* categoryCount;photos_ov 行数落在 [publishedPhotoCount, photoCount]
|
||||
* 区间内即视为一致(hidden/deleted 照片若从未以 published 态同步过,ov
|
||||
* 合法地无行——缺席即撤下语义);相册行缺失即不一致。assetCount 供观测
|
||||
* (ov 无 asset 副本行,不参与比对)。
|
||||
*/
|
||||
AlbumDigest: {
|
||||
/** @description 相册 ID。 */
|
||||
albumId: string;
|
||||
/** @description 当前分类数(cn 权威行数)。 */
|
||||
categoryCount: number;
|
||||
/** @description 可见(published)照片数——ov 必须持有的副本行数下界。 */
|
||||
publishedPhotoCount: number;
|
||||
/** @description published+hidden+deleted 照片总数——ov 副本行数上界。 */
|
||||
photoCount: number;
|
||||
/** @description 生效(effective)资产数(观测用;ov 无 asset 副本行)。 */
|
||||
assetCount: number;
|
||||
};
|
||||
/** @description 每相册计数摘要(albumId 升序)。 */
|
||||
DigestResponse: {
|
||||
/** @description 相册摘要清单;空库为空数组。 */
|
||||
albums: components["schemas"]["AlbumDigest"][];
|
||||
};
|
||||
};
|
||||
responses: {
|
||||
/** @description 未认证:令牌缺失、无效或已过期(AUTH_TOKEN_EXPIRED)。 */
|
||||
@@ -2916,4 +2974,38 @@ export interface operations {
|
||||
500: components["responses"]["InternalError"];
|
||||
};
|
||||
};
|
||||
syncDigest: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody: {
|
||||
content: {
|
||||
"application/json": components["schemas"]["DigestRequest"];
|
||||
};
|
||||
};
|
||||
responses: {
|
||||
/** @description 每相册计数摘要(albumId 升序)。 */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["DigestResponse"];
|
||||
};
|
||||
};
|
||||
/** @description 请求体校验失败(VALIDATION_FAILED)。 */
|
||||
400: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["Error"];
|
||||
};
|
||||
};
|
||||
500: components["responses"]["InternalError"];
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user