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,5 +1,6 @@
|
||||
// services/api/src/sync/dto.ts —— 内部同步端点请求 DTO(P2 Task 3)。
|
||||
// 契约权威:openapi.yaml [internal](syncBootstrap/syncReconcile)。
|
||||
// services/api/src/sync/dto.ts —— 内部同步端点请求 DTO(P2 Task 3
|
||||
// bootstrap/reconcile;Task 4 增 digest)。
|
||||
// 契约权威:openapi.yaml [internal](syncBootstrap/syncReconcile/syncDigest)。
|
||||
// 经全局 ValidationPipe(whitelist)剥离未知字段;校验失败 400 VALIDATION_FAILED。
|
||||
import {
|
||||
IsIn,
|
||||
@@ -44,3 +45,10 @@ export class ReconcileRequestDto {
|
||||
@Max(1000)
|
||||
limit!: number;
|
||||
}
|
||||
|
||||
/** 契约 DigestRequest(Task 4):albumId 缺省 = 全部相册。 */
|
||||
export class DigestRequestDto {
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
albumId?: string;
|
||||
}
|
||||
|
||||
@@ -50,10 +50,17 @@ type PhotoVariantRow = Prisma.photo_variantsGetPayload<
|
||||
Prisma.photo_variantsDefaultArgs
|
||||
>;
|
||||
|
||||
/** album.updated 载荷:albums_ov 展示所需标量 + 翻译全集(locale 升序)。 */
|
||||
/**
|
||||
* album.updated 载荷:albums_ov 展示所需标量 + 翻译全集(locale 升序)。
|
||||
* categories 可选参数:仅 bootstrap/reconcile 快照事件传入(ov 整体替换
|
||||
* albums_ov.categories 的收敛原语,见 contracts events.ts 注释);业务事务内
|
||||
* 增量事件不传(分类元素归 category.* 事件管)。输出按 (sortOrder, id) 升序
|
||||
* 确定性排序(与公开列表端点一致),元素翻译 locale 升序。
|
||||
*/
|
||||
export function buildAlbumUpdatedPayload(
|
||||
album: AlbumRow,
|
||||
translations: AlbumTranslationRow[],
|
||||
categories?: AlbumCategoryInput[],
|
||||
): AlbumUpdatedPayload {
|
||||
return {
|
||||
albumId: album.id,
|
||||
@@ -76,10 +83,35 @@ export function buildAlbumUpdatedPayload(
|
||||
venue: t.venue,
|
||||
address: t.address,
|
||||
})),
|
||||
...(categories === undefined
|
||||
? {}
|
||||
: {
|
||||
categories: [...categories]
|
||||
.sort((a, b) =>
|
||||
a.category.sortOrder !== b.category.sortOrder
|
||||
? a.category.sortOrder - b.category.sortOrder
|
||||
: a.category.id < b.category.id
|
||||
? -1
|
||||
: 1,
|
||||
)
|
||||
.map(({ category, translations: categoryTranslations }) => ({
|
||||
id: category.id,
|
||||
sortOrder: category.sortOrder,
|
||||
translations: [...categoryTranslations]
|
||||
.sort((a, b) => (a.locale < b.locale ? -1 : 1))
|
||||
.map((t) => ({ locale: t.locale, name: t.name })),
|
||||
})),
|
||||
}),
|
||||
entityVersion: album.entityVersion,
|
||||
};
|
||||
}
|
||||
|
||||
/** 快照 categories 参数形状:分类行 + 该分类的翻译行(见函数注释)。 */
|
||||
export interface AlbumCategoryInput {
|
||||
category: CategoryRow;
|
||||
translations: CategoryTranslationRow[];
|
||||
}
|
||||
|
||||
/** category.updated 载荷:分类标量 + 翻译(locale 升序)。 */
|
||||
export function buildCategoryUpdatedPayload(
|
||||
category: CategoryRow,
|
||||
|
||||
@@ -1,11 +1,16 @@
|
||||
// services/api/src/sync/sync.controller.ts —— cn 侧内部同步端点(P2 Task 3)。
|
||||
// 契约权威:openapi.yaml [internal](POST /internal/sync/bootstrap、
|
||||
// POST /internal/sync/reconcile)。鉴权:InternalSyncGuard(HMAC 原始请求体
|
||||
// services/api/src/sync/sync.controller.ts —— cn 侧内部同步端点(P2 Task 3:
|
||||
// bootstrap/reconcile;Task 4 增 digest)。契约权威:openapi.yaml [internal]
|
||||
// (POST /internal/sync/bootstrap、POST /internal/sync/reconcile、
|
||||
// POST /internal/sync/digest)。鉴权:InternalSyncGuard(HMAC 原始请求体
|
||||
// 签名 + 时间戳 ±300s,密钥 SYNC_INTERNAL_KEY 仅 env)。仅 cn 入口装配
|
||||
// (app-cn.module);ov 入口的 content-batches 在 sync-inbox 模块(同守卫)。
|
||||
import { Body, Controller, HttpCode, Post, UseGuards } from '@nestjs/common';
|
||||
import { InternalSyncGuard } from '../shared/internal-sync.guard';
|
||||
import { BootstrapRequestDto, ReconcileRequestDto } from './dto';
|
||||
import {
|
||||
BootstrapRequestDto,
|
||||
DigestRequestDto,
|
||||
ReconcileRequestDto,
|
||||
} from './dto';
|
||||
import { SyncService } from './sync.service';
|
||||
|
||||
@Controller('internal/sync')
|
||||
@@ -26,4 +31,11 @@ export class SyncController {
|
||||
reconcile(@Body() dto: ReconcileRequestDto) {
|
||||
return this.sync.reconcile(dto);
|
||||
}
|
||||
|
||||
/** 对账摘要:每相册实体计数(Task 4 ov 副本计数比对 → 不一致触发 bootstrap)。 */
|
||||
@Post('digest')
|
||||
@HttpCode(200)
|
||||
digest(@Body() dto: DigestRequestDto) {
|
||||
return this.sync.digest(dto.albumId);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,29 +1,37 @@
|
||||
// services/api/src/sync/sync.service.ts —— cn 侧内部同步服务(P2 Task 3):
|
||||
// bootstrap(全量快照引导)+ reconcile(缺失事件对账清单)。两端的共同设计
|
||||
// 决策(与计划文档 §2A「首次全量快照携带水位」对齐,登记为 Task 3 设计):
|
||||
// services/api/src/sync/sync.service.ts —— cn 侧内部同步服务(P2 Task 3:
|
||||
// bootstrap/reconcile)+ Task 4 digest(每相册实体计数摘要)。共同设计决策
|
||||
// (与计划文档 §2A「首次全量快照携带水位」对齐,登记为 Task 3 设计):
|
||||
//
|
||||
// · bootstrap = 「把当前状态快照事件**写回 outbox**」,而不是另建拉取通道——
|
||||
// ov 冷启动时经本端点触发 cn 入队,随后由常规发送器(services/sync)批量
|
||||
// HMAC 推送、ov 按既有 content-batches 幂等/版本逻辑应用。好处:快照与增量
|
||||
// 走同一条已被验证的管道(无第二条拉取路径),ov 侧无需新增消费代码;
|
||||
// 重复 bootstrap 幂等(同实体同版本的新 eventId 在 ov 被 entityVersion
|
||||
// 比较忽略)。eventCount=入队事件数,watermark=入队事件的最高 entityVersion
|
||||
// (契约语义「后续增量对账的 sinceVersion 起点」的全局保守值;reconcile 的
|
||||
// sinceVersion 按 per-entity 版本过滤,见下)。
|
||||
// 走同一条已被验证的管道(无第二条拉取路径),ov 侧无需新增消费代码。
|
||||
// 重复 bootstrap 幂等:ov 对**同版本新 eventId** 事件做当前态重断言应用
|
||||
// (Task 4 起 sync-inbox 仅拒绝更旧版本;同一版本 = 同一状态,重放无副作用,
|
||||
// 且正是 digest→bootstrap 补漏通道修复 ov 侧缺失副本行的机制)。
|
||||
// eventCount=入队事件数,watermark=入队事件的最高 entityVersion(契约语义
|
||||
// 「后续增量对账的 sinceVersion 起点」的全局保守值;reconcile 的 sinceVersion
|
||||
// 按 per-entity 版本过滤,见下)。
|
||||
//
|
||||
// · reconcile = 按 entityType 返回「当前状态」事件清单(entityVersion >
|
||||
// sinceVersion 的实体,升序、limit 截断)——数据源是 cn 权威表而非 outbox
|
||||
// 历史流水(快照即最终状态,ov 重放即补齐;outbox 中的中间版本无需重放,
|
||||
// 高版本载荷已包含最终状态)。eventId 为确定性合成 ID(recon:<type>:<id>:
|
||||
// <version>):与真实 outbox eventId(cuid)永不冲突,同一 (实体,版本) 的
|
||||
// 合成事件无论重放多少次在 ov 侧都幂等(eventId 唯一 + 版本比较双保险)。
|
||||
// 已知边界(登记):硬删除实体(category 墓碑后行已删)不再出现在清单中,
|
||||
// 删除语义依赖事件推送而非对账补漏。
|
||||
// 合成事件无论重放多少次在 ov 侧都幂等(eventId 唯一锚)。
|
||||
// 已知边界(Task 4 收敛):硬删除实体(category 墓碑后行已删)不再出现在
|
||||
// 清单中——错过的墓碑经 digest 计数比对 → 该相册 bootstrap 重发 album.updated
|
||||
// (携带分类全量清单,ov 整体替换 categories)收敛。
|
||||
//
|
||||
// · digest(Task 4)= 每相册 {categoryCount, publishedPhotoCount, photoCount,
|
||||
// assetCount} 权威计数。ov 与副本计数比对(照片取区间语义,见 digest 注释)
|
||||
// → 不一致相册触发 bootstrap。
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import type { components } from '@photo/contracts';
|
||||
import type { Prisma } from '@photo/db-cn';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import {
|
||||
type AlbumCategoryInput,
|
||||
buildAlbumUpdatedPayload,
|
||||
buildAssetUpdatedPayload,
|
||||
buildCategoryUpdatedPayload,
|
||||
@@ -32,6 +40,7 @@ import {
|
||||
|
||||
export type BootstrapResponse = components['schemas']['BootstrapResponse'];
|
||||
export type ReconcileResponse = components['schemas']['ReconcileResponse'];
|
||||
export type DigestResponse = components['schemas']['DigestResponse'];
|
||||
export type SyncEventWire = components['schemas']['SyncEvent'];
|
||||
|
||||
/** 线格式 schema 版本(契约 SyncEvent.schemaVersion 当前为 1)。 */
|
||||
@@ -128,6 +137,21 @@ export class SyncService {
|
||||
bucket.push(row);
|
||||
}
|
||||
}
|
||||
// album.updated 快照载荷的分类全量清单(Task 4:ov 整体替换 categories
|
||||
// 的收敛原语)——分类行与其翻译行按 albumId 配对。
|
||||
const categoryInputsByAlbum = new Map<string, AlbumCategoryInput[]>();
|
||||
for (const category of categories) {
|
||||
const bucket = categoryInputsByAlbum.get(category.albumId);
|
||||
const input: AlbumCategoryInput = {
|
||||
category,
|
||||
translations: translationsByCategory.get(category.id) ?? [],
|
||||
};
|
||||
if (bucket === undefined) {
|
||||
categoryInputsByAlbum.set(category.albumId, [input]);
|
||||
} else {
|
||||
bucket.push(input);
|
||||
}
|
||||
}
|
||||
|
||||
const rows: Prisma.outbox_eventsCreateManyInput[] = [];
|
||||
let watermark = 0;
|
||||
@@ -159,6 +183,7 @@ export class SyncService {
|
||||
buildAlbumUpdatedPayload(
|
||||
album,
|
||||
translationsByAlbum.get(album.id) ?? [],
|
||||
categoryInputsByAlbum.get(album.id) ?? [],
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -256,6 +281,10 @@ export class SyncService {
|
||||
bucket.push(row);
|
||||
}
|
||||
}
|
||||
// album 快照事件携带分类全量清单(与 bootstrap 同形,见文件头说明)。
|
||||
const categoryInputs = await this.categoryInputsByAlbumIds(
|
||||
albums.map((a) => a.id),
|
||||
);
|
||||
for (const album of albums) {
|
||||
events.push(
|
||||
this.toWireEvent(
|
||||
@@ -264,7 +293,11 @@ export class SyncService {
|
||||
album.id,
|
||||
album.entityVersion,
|
||||
false,
|
||||
buildAlbumUpdatedPayload(album, byAlbum.get(album.id) ?? []),
|
||||
buildAlbumUpdatedPayload(
|
||||
album,
|
||||
byAlbum.get(album.id) ?? [],
|
||||
categoryInputs.get(album.id) ?? [],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -394,6 +427,125 @@ export class SyncService {
|
||||
return { events };
|
||||
}
|
||||
|
||||
/**
|
||||
* 对账摘要(Task 4):cn 侧每相册的实体计数(当前权威值)。ov 侧与自己的
|
||||
* 副本计数比对,不一致 → 触发该相册 bootstrap 重建(错过的墓碑/漏掉的
|
||||
* 事件由此收敛,见 sync-inbox/Reconciler 设计说明)。photoCount 拆两档:
|
||||
* publishedPhotoCount = ov **必须**持有的行数(可见照片);photoCount =
|
||||
* published+hidden+deleted 总数 = ov 行数上限——hidden/deleted 照片若从未
|
||||
* 在 cn 侧以 published 态同步过,ov 侧合法地无行(缺席即撤下语义),故 ov
|
||||
* 计数落在 [publishedPhotoCount, photoCount] 区间内即视为一致。assetCount
|
||||
* 供观测(ov 无 asset 副本行,不参与比对)。
|
||||
*/
|
||||
async digest(albumId?: string): Promise<DigestResponse> {
|
||||
const albums = await this.prisma.albums.findMany({
|
||||
where: albumId === undefined ? {} : { id: albumId },
|
||||
select: { id: true },
|
||||
orderBy: { id: 'asc' },
|
||||
});
|
||||
if (albums.length === 0) {
|
||||
return { albums: [] };
|
||||
}
|
||||
const albumIds = albums.map((a) => a.id);
|
||||
const [categoryCounts, photoCounts, assetCounts] = await Promise.all([
|
||||
this.prisma.categories.groupBy({
|
||||
by: ['albumId'],
|
||||
where: { albumId: { in: albumIds } },
|
||||
_count: { _all: true },
|
||||
}),
|
||||
this.prisma.photos.groupBy({
|
||||
by: ['albumId', 'visibility'],
|
||||
where: {
|
||||
albumId: { in: albumIds },
|
||||
visibility: { in: ['published', 'hidden', 'deleted'] },
|
||||
},
|
||||
_count: { _all: true },
|
||||
}),
|
||||
this.prisma.album_assets.groupBy({
|
||||
by: ['albumId'],
|
||||
where: { albumId: { in: albumIds }, state: 'effective' },
|
||||
_count: { _all: true },
|
||||
}),
|
||||
]);
|
||||
const categoriesByAlbum = new Map(
|
||||
categoryCounts.map((row) => [row.albumId, row._count._all]),
|
||||
);
|
||||
const assetsByAlbum = new Map(
|
||||
assetCounts.map((row) => [row.albumId, row._count._all]),
|
||||
);
|
||||
const photosByAlbum = new Map<
|
||||
string,
|
||||
{ published: number; total: number }
|
||||
>();
|
||||
for (const row of photoCounts) {
|
||||
const bucket = photosByAlbum.get(row.albumId) ?? {
|
||||
published: 0,
|
||||
total: 0,
|
||||
};
|
||||
if (row.visibility === 'published') {
|
||||
bucket.published = row._count._all;
|
||||
}
|
||||
bucket.total += row._count._all;
|
||||
photosByAlbum.set(row.albumId, bucket);
|
||||
}
|
||||
return {
|
||||
albums: albums.map(({ id }) => {
|
||||
const photos = photosByAlbum.get(id);
|
||||
return {
|
||||
albumId: id,
|
||||
categoryCount: categoriesByAlbum.get(id) ?? 0,
|
||||
publishedPhotoCount: photos?.published ?? 0,
|
||||
photoCount: photos?.total ?? 0,
|
||||
assetCount: assetsByAlbum.get(id) ?? 0,
|
||||
};
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
/** 相册 id 集合 → 分类快照输入(分类行 + 翻译行配对;升序确定性由构建器排)。 */
|
||||
private async categoryInputsByAlbumIds(
|
||||
albumIds: string[],
|
||||
): Promise<Map<string, AlbumCategoryInput[]>> {
|
||||
const result = new Map<string, AlbumCategoryInput[]>();
|
||||
if (albumIds.length === 0) {
|
||||
return result;
|
||||
}
|
||||
const categories = await this.prisma.categories.findMany({
|
||||
where: { albumId: { in: albumIds } },
|
||||
});
|
||||
const translations =
|
||||
categories.length === 0
|
||||
? []
|
||||
: await this.prisma.category_translations.findMany({
|
||||
where: { categoryId: { in: categories.map((c) => c.id) } },
|
||||
});
|
||||
const translationsByCategory = new Map<
|
||||
string,
|
||||
typeof translations
|
||||
>();
|
||||
for (const row of translations) {
|
||||
const bucket = translationsByCategory.get(row.categoryId);
|
||||
if (bucket === undefined) {
|
||||
translationsByCategory.set(row.categoryId, [row]);
|
||||
} else {
|
||||
bucket.push(row);
|
||||
}
|
||||
}
|
||||
for (const category of categories) {
|
||||
const bucket = result.get(category.albumId);
|
||||
const input: AlbumCategoryInput = {
|
||||
category,
|
||||
translations: translationsByCategory.get(category.id) ?? [],
|
||||
};
|
||||
if (bucket === undefined) {
|
||||
result.set(category.albumId, [input]);
|
||||
} else {
|
||||
bucket.push(input);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/** 当前状态 → 线格式事件(确定性合成 eventId,见文件头设计说明)。 */
|
||||
private toWireEvent(
|
||||
entityType: SyncEventWire['entityType'],
|
||||
|
||||
Reference in New Issue
Block a user