Skip to content
bitzorcas
中EN

Guide

Search 事件索引、版本顺序与重建

追踪 search.index.changed 的生产、CAP 绑定、Created/Updated/Deleted 分支、失败与幂等,并设计数据源、影子重建、对账和多副本切换。

Last updated

索引的正确性来自“源事实可重复地折叠为文档”,不是来自 Lucene 本身。当前 Search 定义了足够描述变更的 typed event,却没有 publisher、文档字段 provider 或可用的重建数据源;consumer 因此只能真正处理删除。

1. 事件契约

SearchIndexChangedIntegrationEvent 包含:

字段目标语义当前实际用途
EventIdconsumer inbox 幂等键只写日志
IndexKey路由索引场景传给 delete/notifier
DocumentId文档自然键传给 delete/notifier
TenantId租户分区传给 engine/notifier
EntityTypeowner 类型/解析consumer 完全不用
ActionCreated/Updated/Deleted分支选择
SourceVersion单调源版本只传给 notifier
OccurredAt延迟/诊断consumer 完全不用

类型实现 IIntegrationEvent,但没有 [IntegrationTopic("search.index.changed")]。仓库只有 event/published/subscribed catalog 常量,没有 new SearchIndexChangedIntegrationEvent(...) 或 typed publisher 调用。

2. 实际消费状态机

CreatedUpdatedDeletedexceptionexception

search.index.changed

Action

Warning: provider missing
不 Upsert

ISearchEngine.DeleteAsync

NotifyIndexChangedAsync

CAP success

记录 Error 后吞掉

重抛,CAP retry

Created/Updated 在成功路径虽未改索引,仍广播 local resource “已变更”;下游只能收到 invalidation,拿不到文档。这个通知不能作为索引已更新的证据。

未知 enum 值不会进入任何 switch case,随后 MapAction 按 Updated 广播并成功确认。更安全的做法是把未知 action 视为永久契约错误并 quarantine。

3. Created/Updated 需要什么

事件没有文档字段。consumer 注释等待 IIndexDocumentProvider,但源码没有此端口。可选设计:

事件携带完整 projection

优点:不反查 owner、可重放;缺点:payload 大、敏感字段复制、schema 版本复杂。

事件携带 identity/version,Search 回源

优点:owner 控制可索引字段;缺点:需稳定 batch snapshot API,删除后可能无法回源,跨服务失败增加。

owner 直接发布 versioned search document

通常最清晰:owner 生成最小、已分类的搜索 projection;Search 只验证 schema/tenant/version 并 Upsert。

目标文档投影契约
[IntegrationTopic("search.document.changed.v1")]
public sealed record SearchDocumentChangedV1(
string EventId,
string TenantId,
string IndexKey,
string DocumentId,
string BusinessType,
long SourceVersion,
SearchDocumentAction Action,
IReadOnlyDictionary<string, string> SearchFields,
IReadOnlyDictionary<string, string> StoredFields,
IReadOnlyList<string> AccessTokens,
DateTimeOffset OccurredAt,
int SchemaVersion = 1);
// SearchFields 与 StoredFields 必须按 IndexKey schema 校验;任意字典不是安全边界。
// 删除可只携带 identity/version,创建和更新必须提供可重建的完整快照。

4. 幂等与顺序

当前 EventId、SourceVersion 未参与写前判断。Deleted A 重放会再次 delete,可能由 provider 天然幂等;但 Updated v8 晚于 Deleted v9 到达时,未来简单 Upsert 会复活已删除文档。

每个 (TenantId,IndexKey,DocumentId) 保存 LastAppliedSourceVersion 和 tombstone:

  • version=current:duplicate;
  • version < current:stale;
  • version=current+1:apply;
  • version>current+1:gap,进入修复;
  • delete 写 tombstone/version,阻止旧 update 复活;
  • EventId inbox 处理同一版本的重复/冲突 payload。

Lucene 文档本身可保存 source version,但 inbox/checkpoint 与文档写入的原子性需设计。单进程本地引擎与关系数据库无法自然形成一个 ACID 事务,通常通过幂等重试 + 可对账重建实现收敛。

5. 错误分类

当前规则只有:Deleted exception 重抛;Created/Updated exception 吞掉。应按原因,而不是 action 分类:

  • payload/schema/unknown index/action:永久失败,DLQ/quarantine;
  • duplicate/stale:成功确认并计数;
  • gap:持久化修复请求,是否重试取决于 source 可用性;
  • Lucene I/O/磁盘锁/临时源超时:有界 retry;
  • ACL/schema violation:拒绝并告警,不能写入;
  • notifier 失败:不应回滚已成功文档,但必须可重试/补广播。

Created/Updated 缺 provider 是系统未就绪,不应记录 warning 后确认成成功。

6. RebuildIndex 当前行为

POST /api/search/index/{indexKey}/rebuild 已完整实现,不再是占位端点。Handler RebuildIndex 注入 ISearchEngine、IEnumerable<ISearchIndexRebuildSource>、ISensitiveOperationApprovalPort 与 IActivityAuditSink,执行一次原子全量重建。

请求侧约束:

  • 要求 IdempotencyKey,命令标记为 INonTransactionalCommand + IIdempotentRequest;
  • ConfirmToken 固定为字面量 "REBUILD INDEX"(两个词,含空格);
  • ExpectedVersion 在重建前与 engine.GetStatisticsAsync 的版本比对,不一致即拒绝,防止重建期间覆盖他方改动;
  • 授权为 resource search/index,Action Manage;rate limit 策略 sensitivePolicy,timeout DatabaseMaintenance。

执行流程:

  1. 写入意图审计记录;
  2. 调用 engine.RebuildAsync,传入一个 RebuildDataSource;
  3. 该数据源按 IndexKey 匹配 ISearchIndexRebuildSource,以 keyset cursor 流式拉取权威批次(GetBatchAsync(tenantId, lastCursor, pageSize, ct)),租户过滤在数据源内部完成;
  4. 完成后写完成审计记录,审计失败按 fail-closed 处理。

返回填充的 RebuildResultDto,含重建前后的文档数与版本。下文第 8 节的影子重建/对账仍是更高阶运维设计,本端点只做就地全量重建,不创建 shadow 目录或原子别名切换。

重建数据源契约

GlobalSearchContracts.cs 定义了重建所需的服务端可信契约:

  • ISearchIndexRebuildSource:业务 owner 提供的权威批量源。成员 string IndexKey 与 Task<IReadOnlyList<SearchIndexRebuildDocument>> GetBatchAsync(string tenantId, string? lastCursor, int pageSize, CancellationToken)。IndexKey 由服务端注册决定,客户端不能提交任意 index key。
  • IGlobalSearchSourceCatalog:成员 IReadOnlyList<GlobalSearchSourceDefinition> GetSources(),枚举已注册的全局搜索源定义。

现有具体实现:TicketSearchIndexRebuildSource、WorkflowTaskSearchIndexRebuildSource,对应 index key tickets、workflow-tasks。未注册的 IndexKey 在匹配阶段即失败,不会被当成可重建场景。

7. 管理总览端点

GET /api/search/admin/overview(const Route = /api/search/admin/overview)返回跨索引的运维快照。授权 resource search/index,Action View;rate limit userPolicy,timeout ShortRead。

返回 SearchAdministrationOverviewDto:

字段类型含义
ItemsSearchAdministrationItemDto每个索引一行
IsPartialbool任一来源异常被降级时为 true
RefreshedAt时间戳快照采集时刻

每个 SearchAdministrationItemDto 含 IndexKey、DisplayName、EntityType、Status(取值 "ready"/"read-only"/"unavailable")、DocumentCount、Version、CanRebuild(bool)与 ErrorCode(string?)。

数据来源是 IGlobalSearchSourceCatalog 注册项、ISearchIndexRebuildSource 注册项与各索引 engine.GetStatisticsAsync 的实时结果。任一来源抛异常会被单独降级(该项标记降级),不影响其余项,因此 IsPartial 为 true 时仍返回已采集部分。

8. 数据源契约

每个 IndexKey 的重建数据源至少需要:

  • authoritative owner 与 schema version;
  • tenant/partition enumeration;
  • keyset cursor,不用高 offset;
  • consistent high-watermark/snapshot;
  • full document projection,包括 ACL token;
  • deletion/tombstone 策略;
  • cancellation、rate limit 与 backpressure;
  • source count/hash 用于 reconciliation。

不要让 Search 反射扫描所有业务表。owner 通过 Contracts 提供窄 snapshot port 或导出流。

9. 影子重建

Query routerReconcilerLive event bufferLucene shadow versionOwner data sourceOperatorQuery routerReconcilerLive event bufferLucene shadow versionOwner data sourceOperatorcapture source watermark Wkeyset stream through Wapply versions after Wcount/hash/query samplesinvariants passedatomically switch active versionwrite to new active version

完整步骤:校验目录/空间 → 创建唯一 shadow path → 记录 W → 分租户批量写 → 捕获 live delta → 追平 → force commit → 对账 → 原子切别名/目录 → 观察 → 保留旧版本回滚 → 清理。

外部 Lucene 包 README 声称支持 IndexNew/Bak/Temp 原子目录替换,但平台目前没有调用 Rebuild 或暴露切换编排。包能力不等于本模块已经完成运维链路。

10. 查询期间重建

需要定义:旧 reader 是否持续服务;新查询何时看到新版本;切换时 in-flight query 是否失败;Windows/Linux rename 差异;进程崩溃后 Temp/Bak 恢复;磁盘翻倍预算;多个重建请求互斥。

目录状态建议:Active → RebuildQueued → Rebuilding → CatchingUp → Verifying → Switching → Active;失败进入 Failed 并保留旧 Active。当前 IndexStatus 由外部包定义,但 Application 没有状态机或锁。

11. 多副本拓扑

默认 BasePath=search-index 是进程相对目录。若 API 有多个副本:

  • 每个副本有独立 local volume;
  • CAP 同一 consumer group 通常只让一个副本处理某条事件;
  • invalidation notifier 不复制 Lucene 文档;
  • 查询落到不同副本可能得到不同结果。

可选生产拓扑:单写 Search service + 查询 API;每副本完整 fan-out 重放;外部集中式 provider;或可安全共享的专用存储设计。普通共享 NFS 上让多个 Lucene writer 指向同一目录不是默认安全答案,必须由 provider 文档和压力测试证明。

12. 对账与擦除

Reconciliation 按 tenant/index/version 比较 source count、ID hash、抽样内容 hash、tombstone、ACL token 与 orphan。发现 drift 可单文档 repair 或触发 shadow rebuild。

隐私擦除/权限撤销是高优先级删除:必须可追踪到每个索引版本和副本,且旧目录/备份不能在 rollback 后恢复敏感文档。重建也不能从过期 source snapshot 重新引入已删除内容。

13. 必测序列

  1. 真实发布字节可绑定 typed event;
  2. Created/Updated 写完整 schema document;
  3. Deleted 重试和 tombstone;
  4. A,A 与 A,B,A EventId 重放;
  5. Update v8 / Delete v9 / late Update v8;
  6. version gap repair;
  7. provider I/O 与 notifier 分别失败;
  8. rebuild + live delta + atomic cutover;
  9. 崩溃发生在 commit/rename 每个阶段;
  10. 多租户、多个 API replica 一致结果;
  11. ACL 撤权和隐私擦除穿透旧版本;
  12. source↔index reconciliation 可定位 drift。

14. 审查命令

Terminal window
# typed event 有 consumer/catalog,但当前没有 publisher 或 IntegrationTopic。
rg -n "SearchIndexChangedIntegrationEvent|search\.index\.changed|IntegrationTopic|PublishAsync" \
src/Platform/Search src/Platform -g '*.cs'
# 当前 Created/Updated no-op,Deleted 才调用 engine;SourceVersion 只进入 notifier。
rg -n "SearchIndexAction|UpsertAsync|DeleteAsync|SourceVersion|NotifyIndexChangedAsync" \
src/Platform/Search -g '*.cs'
# 重建端点已实现:应出现 ISearchIndexRebuildSource、RebuildDataSource、审计和 ExpectedVersion 比对。
rg -n "ISearchIndexRebuildSource|RebuildDataSource|ExpectedVersion|RebuildAsync|SensitiveOperationApproval" \
src/Platform/Search tests -g '*.cs'

返回 Search 总览 · Provider 与目录 · 测试与 GA

100%

滚轮或按钮缩放 · 放大后拖动画面 · 双击切换 100% / 200%