Files
photo-live/services/api/src/sync-inbox/sync-inbox.service.ts
T
浅念 36082c5a74 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>
2026-09-18 16:18:31 +08:00

379 lines
15 KiB
TypeScript
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.
// services/api/src/sync-inbox/sync-inbox.service.ts —— ov 内容事件收件箱
// (P2 Task 3:接收 + 持久化 + 应用;Task 4 起同版本重断言语义)。逐事件单事务:
//
// ① eventId 幂等:sync_inbox 主键冲突(含并发窗口)→ duplicate,受理不计应用;
// ② 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(不触碰
// 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 重放被版本比较直接拒绝。
import { Injectable } from '@nestjs/common';
import type { components } from '@photo/contracts';
import type {
AlbumUpdatedPayload,
AssetUpdatedPayload,
CategoryDeletedPayload,
CategoryUpdatedPayload,
PhotoPublishedPayload,
PhotoVisibilityChangedPayload,
} from '@photo/contracts';
import type { Prisma } from '@photo/db-ov';
// 值导入(非 import type):构造器注入的 design:paramtypes 依赖类引用,
// type-only 导入会让 Nest 侧元数据退化为 Function 而 DI 失败(repo 约定)。
import { OvPrismaService } from '../prisma-ov/ov-prisma.module';
export type ContentBatchResponse =
components['schemas']['ContentBatchResponse'];
type SyncEventWire = components['schemas']['SyncEvent'];
/** 单事件裁决结果(响应只聚合 applied,其余内部口径)。 */
type Outcome = 'duplicate' | 'stale' | 'staged' | 'applied';
/** albums_ov.categories JSON 元素形状(ov schema 注释约定的投影结构)。 */
interface CategoryReplicaEntry {
id: string;
sortOrder: number;
translations: Array<{ locale: string; name: string }>;
}
@Injectable()
export class SyncInboxService {
constructor(private readonly prisma: OvPrismaService) {}
/**
* 批量接收:批内按序逐事件事务处理;任一事件失败抛错(整批 500)——发送器
* 整批重试,eventId 幂等保证重试安全。acceptedEventIds 含幂等重复(契约:
* 受理 ≠ 应用);applied 仅统计新版本生效。
*/
async receiveBatch(batch: SyncEventWire[]): Promise<ContentBatchResponse> {
const acceptedEventIds: string[] = [];
let applied = 0;
for (const event of batch) {
const outcome = await this.applyOne(event);
acceptedEventIds.push(event.eventId);
if (outcome === 'applied') {
applied += 1;
// 依赖解锁:相册副本落位后,回扫该相册的暂存事件(photo/category/
// asset 都只依赖相册行;album 事件自身永不暂存,回扫无递归)。
if (event.entityType === 'album') {
applied += await this.sweepStaged(event.albumId);
}
}
}
return { acceptedEventIds, applied };
}
/** 单事件事务(见文件头 ①-⑤)。P2002 = 并发同 eventId → duplicate。 */
async applyOne(event: SyncEventWire): Promise<Outcome> {
try {
return await this.prisma.$transaction(async (tx) => {
// ① eventId 幂等锚:先查后插 + 唯一约束兜底(并发窗口)。
const seen = await tx.sync_inbox.findUnique({
where: { eventId: event.eventId },
select: { eventId: true },
});
if (seen !== null) {
return 'duplicate' as const;
}
await tx.sync_inbox.create({
data: {
eventId: event.eventId,
entityType: event.entityType,
entityId: event.entityId,
albumId: event.albumId,
entityVersion: event.entityVersion,
tombstone: event.tombstone === true,
priority: event.priority,
payload: event.payload as unknown as Prisma.InputJsonValue,
},
});
return this.decideAndApply(tx, event);
});
} catch (err) {
// P2002 鸭子类型判别(同 albums/support.ts isUniqueViolationOn 的约定:
// Prisma 命名空间经 @photo/db-ov 仅 type 出口,不做值导入)——本端点
// 唯一约束只有 sync_inbox 主键,命中即并发同 eventId。
if (
typeof err === 'object' &&
err !== null &&
(err as { code?: unknown }).code === 'P2002'
) {
return 'duplicate';
}
throw err;
}
}
/**
* ②-⑤ 裁决与应用(inbox 行已存在的共用核心):新事件(applyOne 落库后)
* 与暂存回扫(sweepStaged 对既有行重跑)同一出口。回扫**不能**走 applyOne
* 整程——其 eventId 幂等检查会把暂存行判成 duplicate 而永不应用。
*/
private async decideAndApply(
tx: Prisma.TransactionClient,
event: SyncEventWire,
): Promise<Outcome> {
// ② 版本单调:**更旧**版本裁决即终(appliedAt 置位,不入积压);**等于**
// 已应用版本 → 重断言应用(同版本=同状态,幂等重投影;digest→bootstrap
// 补漏修复缺失副本行的机制,见文件头②)。
const state = await tx.sync_state.findUnique({
where: {
entityType_entityId: {
entityType: event.entityType,
entityId: event.entityId,
},
},
});
if (state !== null && state.appliedVersion > event.entityVersion) {
await tx.sync_inbox.update({
where: { eventId: event.eventId },
data: { appliedAt: new Date() },
});
return 'stale' as const;
}
// ③ 依赖检查:非 album 事件须所属相册副本已存在,否则暂存。
if (event.entityType !== 'album') {
const album = await tx.albums_ov.findUnique({
where: { albumId: event.albumId },
select: { albumId: true },
});
if (album === null) {
return 'staged' as const;
}
}
// ④ 投影。
await this.project(tx, event);
// ⑤ 版本账本。
await tx.sync_state.upsert({
where: {
entityType_entityId: {
entityType: event.entityType,
entityId: event.entityId,
},
},
update: {
appliedVersion: event.entityVersion,
appliedAt: new Date(),
},
create: {
entityType: event.entityType,
entityId: event.entityId,
appliedVersion: event.entityVersion,
watermark: false,
},
});
await tx.sync_inbox.update({
where: { eventId: event.eventId },
data: { appliedAt: new Date() },
});
return 'applied' as const;
}
/** ④ 按实体类型投影到副本表(版本已由 ② 保证更高)。 */
private async project(
tx: Prisma.TransactionClient,
event: SyncEventWire,
): Promise<void> {
if (event.entityType === 'album') {
const p = event.payload as unknown as AlbumUpdatedPayload;
// 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: {
albumId: event.entityId,
publicToken: p.publicToken,
status: p.status,
publicEnabled: p.publicEnabled,
downloadEnabled: p.downloadEnabled,
originalDownloadEnabled: p.originalDownloadEnabled,
translations: p.translations as unknown as Prisma.InputJsonValue,
categories: categorySnapshot ?? [],
defaultLocale: p.defaultLocale,
timezone: p.timezone,
startAt: new Date(p.startAt),
endAt: p.endAt === null ? null : new Date(p.endAt),
entityVersion: event.entityVersion,
},
update: {
publicToken: p.publicToken,
status: p.status,
publicEnabled: p.publicEnabled,
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),
endAt: p.endAt === null ? null : new Date(p.endAt),
entityVersion: event.entityVersion,
},
});
return;
}
if (event.entityType === 'category') {
const album = await tx.albums_ov.findUniqueOrThrow({
where: { albumId: event.albumId },
});
const entries = (
Array.isArray(album.categories) ? album.categories : []
) as unknown as CategoryReplicaEntry[];
if (event.tombstone === true) {
const p = event.payload as unknown as CategoryDeletedPayload;
await tx.albums_ov.update({
where: { albumId: event.albumId },
data: {
// 接口类型无 Json 输入所需索引签名(同 outbox 写入侧约定),结构化
// 拷贝落 Json 列。
categories: entries.filter(
(c) => c.id !== p.categoryId,
) as unknown as Prisma.InputJsonValue,
},
});
return;
}
const p = event.payload as unknown as CategoryUpdatedPayload;
const entry: CategoryReplicaEntry = {
id: p.categoryId,
sortOrder: p.sortOrder,
translations: p.translations,
};
const index = entries.findIndex((c) => c.id === p.categoryId);
if (index >= 0) {
entries[index] = entry;
} else {
entries.push(entry);
}
await tx.albums_ov.update({
where: { albumId: event.albumId },
data: { categories: entries as unknown as Prisma.InputJsonValue },
});
return;
}
if (event.entityType === 'photo') {
const payload = event.payload as unknown as Partial<
PhotoPublishedPayload & PhotoVisibilityChangedPayload
>;
// visibility 推导:hidden/deleted 载荷显式携带;published 载荷缺省即发布。
const visibility =
payload.visibility ?? (event.tombstone === true ? 'deleted' : 'published');
const existing = await tx.photos_ov.findUnique({
where: { photoId: event.entityId },
select: { photoId: true },
});
if (existing === null) {
if (visibility !== 'published') {
// 墓碑/隐藏先于任何 published 到达且副本行不存在:缺席即撤下语义,
// 无行可改——版本账本照常推进(旧 published 之后到会被版本比较拒绝)。
return;
}
const p = payload as PhotoPublishedPayload;
await tx.photos_ov.create({
data: {
photoId: event.entityId,
albumId: event.albumId,
originalObjectKey: p.objectKey,
thumbObjectKey: p.thumbKey,
browseObjectKey: p.browseKey,
width: p.width,
height: p.height,
visibility,
publishedAt: new Date(p.publishedAt),
entityVersion: event.entityVersion,
},
});
return;
}
// update 分支保留 likeTotal/countVersion(ov 权威计数,Task 7/9)。
const data: Prisma.photos_ovUpdateInput = {
visibility,
entityVersion: event.entityVersion,
};
if (visibility === 'published') {
const p = payload as PhotoPublishedPayload;
data.originalObjectKey = p.objectKey;
data.thumbObjectKey = p.thumbKey;
data.browseObjectKey = p.browseKey;
data.width = p.width;
data.height = p.height;
data.publishedAt = new Date(p.publishedAt);
}
await tx.photos_ov.update({
where: { photoId: event.entityId },
data,
});
return;
}
if (event.entityType === 'asset') {
const p = event.payload as unknown as AssetUpdatedPayload;
if (p.kind === 'banner') {
await tx.albums_ov.update({
where: { albumId: event.albumId },
data: { bannerAssetKey: p.objectKey },
});
}
// cover/logo:ov schema 无投影位(YAGNI,schema 注释),仅推进版本账本。
return;
}
// slot-state 预留:受理即 applied(无投影),版本账本照常推进。
}
/** album 应用后回扫该相册暂存事件(receivedAt 升序),返回补应用数。 */
private async sweepStaged(albumId: string): Promise<number> {
const staged = await this.prisma.sync_inbox.findMany({
where: { appliedAt: null, albumId },
orderBy: [{ receivedAt: 'asc' }, { eventId: 'asc' }],
});
let applied = 0;
for (const row of staged) {
const event: SyncEventWire = {
eventId: row.eventId,
schemaVersion: 1,
entityType: row.entityType as SyncEventWire['entityType'],
entityId: row.entityId,
albumId: row.albumId ?? albumId,
entityVersion: row.entityVersion,
tombstone: row.tombstone,
priority: row.priority as SyncEventWire['priority'],
payload: row.payload as unknown as SyncEventWire['payload'],
};
// 行已存在(eventId 即本行主键),走 ②-⑤ 核心而非 applyOne 整程
// (幂等检查会把回扫误判 duplicate,见 decideAndApply 注释)。
const outcome = await this.prisma.$transaction((tx) =>
this.decideAndApply(tx, event),
);
if (outcome === 'applied') {
applied += 1;
}
}
return applied;
}
}