索引的正确性来自“源事实可重复地折叠为文档”,不是来自 Lucene 本身。当前 Search 定义了足够描述变更的 typed event,却没有 publisher、文档字段 provider 或可用的重建数据源;consumer 因此只能真正处理删除。
1. 事件契约
SearchIndexChangedIntegrationEvent 包含:
| 字段 | 目标语义 | 当前实际用途 |
|---|---|---|
| EventId | consumer inbox 幂等键 | 只写日志 |
| IndexKey | 路由索引场景 | 传给 delete/notifier |
| DocumentId | 文档自然键 | 传给 delete/notifier |
| TenantId | 租户分区 | 传给 engine/notifier |
| EntityType | owner 类型/解析 | consumer 完全不用 |
| Action | Created/Updated/Deleted | 分支选择 |
| SourceVersion | 单调源版本 | 只传给 notifier |
| OccurredAt | 延迟/诊断 | consumer 完全不用 |
类型实现 IIntegrationEvent,但没有 [IntegrationTopic("search.index.changed")]。仓库只有 event/published/subscribed catalog 常量,没有 new SearchIndexChangedIntegrationEvent(...) 或 typed publisher 调用。
2. 实际消费状态机
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,ActionManage;rate limit 策略sensitivePolicy,timeoutDatabaseMaintenance。
执行流程:
- 写入意图审计记录;
- 调用
engine.RebuildAsync,传入一个RebuildDataSource; - 该数据源按 IndexKey 匹配
ISearchIndexRebuildSource,以 keyset cursor 流式拉取权威批次(GetBatchAsync(tenantId, lastCursor, pageSize, ct)),租户过滤在数据源内部完成; - 完成后写完成审计记录,审计失败按 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:
| 字段 | 类型 | 含义 |
|---|---|---|
Items | SearchAdministrationItemDto | 每个索引一行 |
IsPartial | bool | 任一来源异常被降级时为 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. 影子重建
完整步骤:校验目录/空间 → 创建唯一 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. 必测序列
- 真实发布字节可绑定 typed event;
- Created/Updated 写完整 schema document;
- Deleted 重试和 tombstone;
- A,A 与 A,B,A EventId 重放;
- Update v8 / Delete v9 / late Update v8;
- version gap repair;
- provider I/O 与 notifier 分别失败;
- rebuild + live delta + atomic cutover;
- 崩溃发生在 commit/rename 每个阶段;
- 多租户、多个 API replica 一致结果;
- ACL 撤权和隐私擦除穿透旧版本;
- source↔index reconciliation 可定位 drift。
14. 审查命令
# 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'