本文使用「署名 4.0 国际 (CC BY 4.0)」许可协议,欢迎转载、或重新修改使用,但需要注明来源。 [署名 4.0 国际 (CC BY 4.0)](https://creativecommons.org/licenses/by/4.0/deed.zh) 本文作者: 苏洋 创建时间: 2026年09月07日 统计字数: 15930字 阅读时间: 32分钟阅读 本文链接: https://soulteary.com/2026/09/07/phorge-modernization-part-8-migrate-search-service.html ----- # Phorge 现代化改造实战(八):迁移搜索服务,写进索引不等于搜得到 本文是“Phorge 现代化改造实战”系列第八篇。前七篇已经建立容器基线、入口信任、模块边界,以及 diff、通知和邮件三类不同的兼容方法;这一篇继续检查搜索服务的写入、查询、配置、就绪和数据迁移能否真正形成闭环。 ## 系列导航 1. [从没有官方镜像到 Docker Compose 跑起来:建立最小可运行基线](https://soulteary.com/2026/09/06/phorge-modernization-part-1-from-no-official-image-to-docker-compose.html); 2. [改进容器化的七个细节:补齐权限、持久化、依赖和探活](https://soulteary.com/2026/09/06/phorge-modernization-part-2-seven-containerization-details.html); 3. [接入 Stargate:把 Forward Auth 的信任边界做完整](https://soulteary.com/2026/09/06/phorge-modernization-part-3-stargate-forward-auth-trust-boundary.html); 4. [拆分模块到 Gorge:无侵入改造不等于不碰文件](https://soulteary.com/2026/09/06/phorge-modernization-part-4-split-modules-to-gorge.html); 5. [替换 diff 子进程:兼容不等于逐字一致](https://soulteary.com/2026/09/07/phorge-modernization-part-5-replace-diff-subprocess.html); 6. [替换实时通知服务:为什么 HTTP 501 反而表示正常](https://soulteary.com/2026/09/07/phorge-modernization-part-6-replace-realtime-notification-service.html); 7. [迁移邮件服务:先分清哪些失败不该重试](https://soulteary.com/2026/09/07/phorge-modernization-part-7-migrate-mail-service.html); 8. **迁移搜索服务:写进索引不等于搜得到**; 9. [迁移文件存储:写得进去也要读得回来](https://soulteary.com/2026/09/07/phorge-modernization-part-9-migrate-file-storage.html); 10. [迁移 Webhook 投递服务:先解决重复投递](https://soulteary.com/2026/09/07/phorge-modernization-part-10-migrate-webhook-delivery.html)。 ## 写在前面 上一篇迁移 mailer 时,我把注意力放在“哪些错误不会主动找上门”:永久失败被当成临时错误,worker 会持续重投;没有配置后端的进程仍然健康;两层配置只完成一层,地址看起来已经生效,邮件却永远不会进入服务。 搜索模块把同一类问题往前再推一步。 [Gorge 2026.09.07-r2](https://github.com/soulteary/gorge/releases/tag/2026.09.07-r2) 把原来的 `gorge-search` 迁进单仓库,补齐 Elasticsearch、Meilisearch、测试后端、七条 HTTP 路由、23 份契约固件和一份可指向真实 Elasticsearch 的端到端测试脚本,具体变化可以从 [Gorge r1…r2 的代码差异](https://github.com/soulteary/gorge/compare/2026.09.07-r1...2026.09.07-r2) 中查看;[Phorge 2026.09.07-r2](https://github.com/soulteary/phorge/releases/tag/2026.09.07-r2) 则增加 Gorge 全文检索引擎、search host、HTTP 客户端和配置检查,并把此前几个 Gorge 客户端的公共部分抽成共享基类,对应的宿主改动集中在 [Phorge r1…r2 的代码差异](https://github.com/soulteary/phorge/compare/2026.09.07-r1...2026.09.07-r2) 中。 从功能列表看,这像是在“新写一个搜索服务”。实际上,r2 面对的是完全不同的问题:**替换一个已经存在、已经有索引、已经被 PHP 调用的实现。** 新服务只要能写、能查、能跑就可以继续演进;替代服务却必须回答更多问题:字段名是否逐字符一致,旧索引是否还能用,查询和 mapping 是否同时包含新增字段,失败时返回空结果还是错误,多层 failover 到底由谁负责,以及配置切换之后实际请求是否真的走到了新引擎。 这些问题的共同特点是,做错之后服务往往仍然返回 200。 ## 搜索服务和 Phorge 的功能是什么关系:返回的不是文档,而是 PHID 在产品层面,搜索把 Phorge 中分散的对象重新组织成一个可以统一检索的入口。代码评审、任务、Wiki 文档以及其他实现了搜索文档接口的对象,都可以把标题、正文、评论、作者、项目和状态等信息写入全文索引;用户提交查询后,Phorge 再依据关键词和过滤条件找回候选对象。 但这个过程不能把“全文命中”直接等同于“用户可以看到对象”。索引擅长回答哪些对象可能相关,Phorge 才知道当前用户是否有权访问、对象是否仍然存在,以及页面最终应该怎样展示。因此搜索链路实际分成写入和查询两个方向: ```text 写入:Phorge 搜索文档 → Gorge 协议翻译与调度 → Elasticsearch / Meilisearch 查询:Phorge 保存查询 → Gorge 查询后端 → PHID 列表 → Phorge 加载对象并执行 policy ``` 先说清楚这次服务边界。 Phorge 侧的 [`PhabricatorGorgeFulltextStorageEngine`](https://github.com/soulteary/phorge/blob/2026.09.07-r2/src/applications/search/fulltextstorage/PhabricatorGorgeFulltextStorageEngine.php) 接入原生全文检索扩展点;`gorge-search` 负责把 `PhabricatorSearchAbstractDocument` 翻译成后端文档,把 `PhabricatorSavedQuery` 翻译成 Elasticsearch 或 Meilisearch 的查询,再把命中的 PHID 列表交还给 Phorge。 它不返回标题、正文和评论内容: ```json { "data": { "phids": ["PHID-TASK-xxxx", "PHID-DREV-yyyy"], "count": 2 } } ``` 这不是单纯为了省流量,而是在保留权限检查的位置。Phorge 收到 PHID 后,仍然通过自己的 Query 类加载对象;对象可见性、项目策略和用户权限继续由 PHP 侧判断。如果搜索服务直接返回正文,它就绕过了 Phorge 的 policy 层,全文索引会变成另一份缺少访问控制的数据接口。 它也不持有索引。索引仍然属于 Elasticsearch 或 Meilisearch,有自己的数据卷、内存配额、版本升级和备份策略。Gorge 的默认编排因此刻意不附赠一个“开箱即用”的 Elasticsearch 容器:把存储偷偷埋进服务编排,会让一次 `docker compose down -v` 变成删除生产索引的操作。 所以这次迁移的是**协议翻译与调度层**,不是搜索存储本身,也不是把 Phorge 的对象权限和页面查询逻辑复制到 Go。 之所以要迁移这一层,一方面是把独立仓库里重复的 HTTP、鉴权、探针和进程管理收回共享平台;另一方面是让写入字段、查询字段、后端角色、错误语义和索引重建要求形成一份能够同时约束 PHP 与 Go 的契约。搜索最危险的问题通常不是服务完全不可用,而是某一半已经成功:文档写进去了,查询却没使用对应字段;配置出现了 Gorge,请求却仍由 MySQL 回答;后端连接失败,页面却只看到一个正常的空结果。 [Gorge search 模块文档](https://github.com/soulteary/gorge/blob/2026.09.07-r2/docs/modules/search.md) 描述服务、后端与路由,[Gorge 与 Phorge 的兼容契约](https://github.com/soulteary/gorge/blob/2026.09.07-r2/compat/phorge/README.md) 则固定跨语言字段和行为边界。迁移完成的判断标准不是“新服务能独立搜索”,而是 Phorge 的写入、查询、权限回查、配置切换和索引重建能够走完同一个闭环。 ## 第一个决定:独立进程,还是并进 `gorge-render` 迁移到现在,Gorge 已经有一个很清楚的对照组。 | 域 | 外部依赖或状态 | 进程选择 | | ------------ | ------------------------ | ----------------------------- | | highlight | 无外部依赖,纯计算 | 与 diff 共用 `gorge-render` | | diff | 无外部依赖,纯计算 | 与 highlight 共用 `gorge-render` | | notification | WebSocket 长连接、订阅与历史状态 | 独立 `gorge-notification` | | mailer | SMTP/provider 外部依赖、适配器状态 | 独立 `gorge-mailer` | | search | ES/Meili 外部依赖、主机健康表 | 独立 `gorge-search` | search 模块落在后一侧。 Elasticsearch 后端会为每个主机保存健康状态,读请求绕开被摘除的主机;进程还要维护后端角色、索引配置和 HTTP client。把它与 render 合并,得到的不是“少一个容器”,而是搜索后端的一次阻塞、内存峰值或配置错误能同时拖住代码高亮和 diff。 因此 r2 新增独立二进制,默认监听 `:8120`,路由仍然按域命名: ```text POST /api/search/index POST /api/search/query POST /api/search/init GET /api/search/exists GET /api/search/stats POST /api/search/sane GET /api/search/backends ``` 这里再次验证了之前的命名决定:路径叫 `/api/search/*`,而不是 `/api/gorge-search/*`。域与二进制目前一一对应,但协议没有被部署形态绑死;以后拆分或合并进程,不必同步修改 PHP 调用方。 分界从来不是代码量,而是**两个域是否应该共享同一次故障**。 ## 迁入单仓以后,先删掉重复的服务外壳 独立仓时代的 search 和 mailer 一样,自己写了一份响应信封、token 中间件、健康路由和 Echo 启动逻辑。迁入后全部换成平台层: ```go srv := httpx.New(httpx.Config{ ListenAddr: cfg.ListenAddr, Ready: se.Ready, }) search.RegisterRoutes(srv.Echo(), &search.Deps{ Engine: se, Token: cfg.ServiceToken, }) srv.Run() ``` 这带来统一的 `{data,error}` 信封、鉴权、请求 ID、结构化日志、错误恢复与优雅退出。原来的 `/`、`/healthz`、`/readyz` 不能继续由域包重复注册,否则 Echo 会在启动阶段直接 panic。 单仓库在这里的价值不是目录少了,而是服务外壳只有一个实现。以后修正错误信封、探针或关停逻辑,四个二进制不需要各自抄一遍。 但这次 Phorge 侧也出现了同样的信号:search 已经是第三个使用 token、JSON 请求和 `{data,error}` 信封的 Gorge 客户端。继续复制 `setURI()`、`newRequestFuture()` 和 `parseResponseEnvelope()`,漂移只是时间问题。 r2 因此新增了 `PhabricatorGorgeServiceClient`: ```php abstract class PhabricatorGorgeServiceClient extends Phobject { abstract protected static function getServiceName(); abstract protected function getDefaultTimeout(); // URI、token、超时、JSON 请求、响应信封解析 } ``` search client 只保留七条业务路由;render 和 mailer 也迁到这个父类。mailer 仍然覆盖错误构造方法,因为 `ERR_PERMANENT_FAILURE` 必须变成一个会停止队列重投的专用异常;search 暂时使用公共异常。 这个抽取发生在第三个同形客户端出现时,比第一次看到重复就建立“大一统 SDK”更稳妥:此时共同部分已经能被代码证明,而不是靠猜测未来所有服务都会长成一样。 notification 没有被强行塞进这个基类。它为了兼容 Aphlict,不使用 service token,成功响应也不套信封。共享抽象的边界由真实协议决定,不由“它们都叫 Gorge”决定。 ## 第二个决定:配置放在哪里 mailer 的结论是:端点与 token 都放在 `cluster.mailers` 条目的 `options`,不再新增全局配置项。因为旧接入让地址同时存在于 `go-mailer.url` 和 `cluster.mailers` 两处,启动脚本只写一半时,管理员会看到地址已经生效,却一封信都发不出去。 search 不能原样照抄。 Phorge 的 `cluster.search` 使用固定键表校验,允许 `type`、`hosts`、`roles`、`port`、`protocol`、`path`、`version` 等字段,多一个键就拒绝整条配置。这里没有放 token 的位置。 有三个选择: 1. 修改 Phorge 核心的配置结构,给所有 search engine 增加 token; 2. 把 URI 和 token 都放到新的全局配置; 3. URI 使用原生 `cluster.search.hosts`,只有 token 放到隐藏的全局项。 r2 选择第三种: ```json [ { "type": "gorge", "hosts": [ { "host": "gorge-search", "port": 8120, "protocol": "http", "roles": { "read": true, "write": true } } ] } ] ``` 服务 token 则单独存放在隐藏配置 `gorge.search.token`,由客户端放进 `X-Service-Token` 请求头。 表面上看,这仍然是“两处配置”。区别在于:**端点只有一个家。** `cluster.search` 负责描述搜索服务在哪里,隐藏配置只负责注入凭据。密钥与拓扑本来就有不同的生命周期,token 也更适合由 Secret 系统注入,而不是混在一份会被展示、复制和审计的 host 列表里。 这次还顺手确认了一条上游遗留的死路径:`PhabricatorElasticFulltextStorageEngine` 会从 search 配置读取 `timeout`,但固定键表并不允许 `timeout`。换句话说,代码支持了一个用户无法合法写入的选项。Gorge client 没有假装继续兼容这条路径,而是使用明确的 30 秒默认值。 ## `cluster.search` 不是覆盖,也不是简单追加 容器 entrypoint 写 `cluster.search` 时,比 mailer 的按 key 合并更复杂。 ### 不能整体覆盖 这是共享列表,用户可能还有手工配置的 Elasticsearch 或其他搜索服务。脚本必须先读取 local 配置源,理解失败时停止写入,不能把“解析失败”当成“现有列表为空”。否则一次启动会在没有错误现场的情况下删除用户的拓扑。 ### 不能只追加到末尾 Phorge 读取搜索服务时按列表顺序选择第一个可读且成功的服务。默认配置原本有一条 MySQL/Ferret;如果把 Gorge 追加在它后面,MySQL 只要一直可用,Gorge 就永远不会参与读取。 最终表现会非常迷惑:`cluster.search` 里能看到 Gorge,服务健康,索引也能写,中文搜索却完全没有变化。因为真正回答查询的仍然是前面的 MySQL。 所以脚本把 Gorge 条目插在列表首位,而不是追加: ```php array_unshift($services, $gorge); ``` ### 默认切换,也要给回滚留路 `cluster.search` 的默认值本身是一条 MySQL 配置,但 `bin/config get` 只返回真正写入配置源的值,不会把默认值列出来。一旦 local 中写入 Gorge,隐式默认的 MySQL 条目就消失了。 r2 默认只保留 Gorge;设置 `GORGE_SEARCH_KEEP_MYSQL=1` 时,脚本会显式加入 MySQL 条目,形成: ```text [gorge(read/write), mysql(read/write)] ``` 写操作双写两个索引,读操作优先 Gorge,Gorge 失败时回落到 MySQL。这是一条迁移与回滚路径,不是推荐长期保留的复杂拓扑:开启后,“这次查询到底由谁回答”又多了一层不确定性。 脚本还会剔除所有旧的 `type=gorge` 条目,而不是像 mailer 那样只认一个 key。原因是 search 的目标配置就是“Phorge 侧只有一个 Gorge 入口,后端扇出交给 Go”。若用户要完全手工维护多个 Gorge 条目,应当关闭 `GORGE_SEARCH_HOST` 驱动的自动配置,而不是与 entrypoint 同时争夺同一列表。 ## 第三个决定:failover 到底由谁负责 Phorge 的 `cluster.search` 自己支持多个 service、多个 host,以及 read/write 角色。`gorge-search` 内部也支持多个 backend、read/write 角色和 failover。 两个系统都具备能力,反而是风险最大的状态。 如果 Phorge 配两个 Gorge service,Gorge 里又配多个 Elasticsearch backend,最终拓扑会变成两层嵌套:某些写入在 PHP 层扇出,某些在 Go 层扇出;读失败可能先在 Go 内部切换,也可能回到 PHP 切另一个 service。配置文件只看一边,永远看不完整。 r2 明确规定:**Phorge 只配置一个 Gorge 条目,搜索后端的角色、扇出和 failover 全部归 Go 侧。** Go 侧的规则并不完全对称: | 操作 | 调度规则 | 原因 | | ------------- | ------------------- | --------------------- | | 写文档 | 发给所有带 `write` 角色的后端 | 同步维护多份索引,支持不停机迁移 | | 初始化索引 | 发给所有 `write` 后端 | 所有写目标必须拥有同一套结构 | | 查询 | 第一个可读且成功的后端返回 | 其余后端组成 failover 链 | | 索引统计 | 返回第一个能提供统计的后端 | 状态页上第二意见比一次失败更有用 | | `IndexIsSane` | 只问第一个可读后端 | 任意一个 false 都只导向“需要重建” | “没有可写后端”必须返回错误,绝不能当成成功的空操作。否则一次大库全量重建会从头跑到尾,每一份文档都显示 indexed,最后得到一个空索引。 同样,所有可读后端失败时必须返回 `502 ERR_SEARCH_FAILED`,不能返回 `200 + []`。后者与“查询确实没有结果”完全无法区分,页面只会安静地渲染空状态——而空结果恰好是搜索功能最正常的一种输出。 只有一层负责,比“选最聪明的一层负责”更重要。 ## 第四个决定:平迁,还是顺手把中文搜索补上 迁移最稳妥的原则是先保持行为,再单独改功能。把重构与行为变化混在一起,出问题时很难判断是哪一半造成的。 但不是所有旧行为都值得忠实保留。 旧 `gorge-search` 的 Elasticsearch mapping 只有三套英文分析器: * `english_exact` * `letter_stop` * `english_stem` 其中 `letter` tokenizer 对 CJK 文本基本无效,标准 tokenizer 也无法提供适合中文短语的匹配与评分。继续平迁意味着中文内容能写入索引,却很难按用户预期检索。 ### 为什么 `bin/search ngrams` 解决不了 Phorge 旧文档里有一条看似现成的中文方案:运行 `bin/search ngrams`。 但这条链路属于 Ferret/MySQL。`PhabricatorSearchNgrams`、`PhabricatorFerretEngine` 和 ngram 清理都发生在 MySQL 索引中。切换到 Gorge + Elasticsearch 后,那个命令不会改变 Elasticsearch mapping,也不会改变 Gorge 发出的查询。 把它继续写在 Gorge 的部署说明里,比没有说明更危险:管理员会执行一个成功退出的命令,以为中文搜索已经开启。 ### 不引入插件的 CJK 分析器 r2 使用 Elasticsearch 自带的 `cjk_width` 和 `cjk_bigram`,不要求安装 `analysis-icu` 或 `smartcn`: ```go filterCJKBigram: map[string]any{ "type": "cjk_bigram", "output_unigrams": true, } analyzerCJKText: map[string]any{ "tokenizer": "standard", "filter": []string{ "cjk_width", "lowercase", filterCJKBigram, }, } ``` `cjk_width` 先统一全角拉丁字符与半角片假名等宽度差异;`cjk_bigram` 生成二元词组;`output_unigrams: true` 同时保留单字,否则文档里明明出现“猫”,查询单字“猫”仍然可能没有结果。 mapping 为文本字段增加 `cjk` 子字段。查询侧则单独增加一个 should 子句: ```go "fields": []string{ "titl.cjk^4", "body.cjk^3", "cmnt.cjk^1.2", }, "analyzer": "cjk_text", ``` 不能把这三个字段直接塞进原来的英文 should 子句,因为 analyzer 属于**查询子句**,不是字段。让 `english_exact` 去查按 bigram 建立的字段,接口不会报错,评分却近似随机。 这里出现了本次迁移最典型的静默错误:**一个字段被正确写进 mapping,不代表任何查询真的使用了它。** 如果只改索引侧、不改 query builder,索引照常增长,sanity check 也能通过,所有请求仍然返回 200;新增的 `cjk` 字段只是一个永远没有读者的空操作。 ## mapping 改了,为什么必须重建全部索引 CJK 能力不是零成本补丁。`IndexIsSane()` 会把线上 mapping 与“今天重新建索引时使用的配置”对比: ```go expected := backend.buildIndexConfig(docTypes) return configDeepMatch(actual, expected), nil ``` 期望配置没有另一份需要人工同步的副本,这是一个很好的设计;同一个函数既负责创建,也负责 sanity check。 代价也很明确:只要 `buildIndexConfig()` 变化,既有索引就会立即显示 not sane。新增 `cjk_text` 与子字段以后,所有旧索引都必须执行: ```bash bin/search init bin/search index --all --force ``` 第一条会删除并重建 mapping,第二条重新写入全部文档。大库可能需要数小时,期间搜索结果不完整。 在一个充满静默失效的迁移里,这条反而是好消息:它会明确报告 false。`indexIsSane()` 正是为这种结构变化提供信号,所以“必须重建”是迁移代价,不是兼容风险。 也正因为 `init` 是破坏性操作,entrypoint 绝不自动调用它。把重建放进每次容器启动路径,意味着一次误重启就能删除全部索引。 ## 第五个决定:`/readyz` 究竟承诺什么 迁入前的三个探针共用同一个 handler: ```go e.GET("/", healthPing()) e.GET("/healthz", healthPing()) e.GET("/readyz", healthPing()) ``` 因此零后端时,服务仍然 health 200、ready 200,每次搜索再单独失败。这精确对应一个很难排查的状态:容器全绿,用户看到的只是“搜不到东西”。 r2 给 readiness 一个真实但克制的判据:至少配置一个带 `read` 角色的后端。 ```go func (se *SearchEngine) Ready() error { if len(se.backends) == 0 { return errors.New("no search backends configured") } for _, backend := range se.backends { if backend.HasRole("read") { return nil } } return errors.New("no search backend has the read role") } ``` 它刻意不连接 Elasticsearch 或 Meilisearch。第三方短暂抖动应当交给主机健康表和 failover 链处理;若 readiness 跟着外部服务上下翻转,编排会反复摘除容器,反而削弱应用层已经具备的容错能力。 所以 `/readyz` 的 200 只意味着:**服务有能力发起一次检索。** 它不保证下一个请求一定成功。 与 mailer 一样,两套 Compose 使用不同探针: | 部署 | 探针 | 原因 | | ----------- | ---------- | -------------------- | | Gorge 独立服务栈 | `/readyz` | 这个栈的目标就是验证 search 可用 | | Phorge 叠加编排 | `/healthz` | 没配后端不应阻塞整个站点启动 | Phorge 侧的 `PhabricatorGorgeSearchSetupCheck` 会再访问 `/readyz`,把“服务不可达”和“服务可达但没可读后端”拆成两个问题。若 `/healthz` 正常而 `/readyz` 返回 404,它还会把问题指向 host、port 或 path:每个版本都注册 readiness,404 说明请求到了别的服务或被代理转错路径,不是“版本太老”。 探针没有绝对正确的严格度,只有是否回答了调用方真正要问的问题。 ## 新增一个服务只动三处,但第四处最容易漏 Gorge 的交付结构把新增二进制的常规成本压到了三处: 1. 增加 `go/cmd/gorge-search/main.go`,复用平台层引导; 2. Compose 增加 service,传入 `build.args.SERVICE=gorge-search`; 3. release workflow 的 matrix 增加 `gorge-search`。 共享 Dockerfile 与 CI 模板都不用复制。 不过 matrix 条目不只有服务名,还必须有端口: ```yaml - service: gorge-search port: 8120 ``` 原因藏在镜像自带的健康检查里: ```dockerfile ENV GORGE_HEALTHCHECK_PORT=${PORT} HEALTHCHECK CMD wget -qO- \ "http://127.0.0.1:${GORGE_HEALTHCHECK_PORT}/healthz" || exit 1 ``` `GORGE_HEALTHCHECK_PORT` 只供探针使用,服务本身不读它;Dockerfile 默认值又是 render 的 8140。新服务若忘记传 `PORT=8120`,进程会正确监听 8120,日志完全正常,镜像却一直探测 8140 并被判为 unhealthy。 本地 Compose 往往发现不了,因为 compose 文件又覆盖了一份正确的 healthcheck。错误只在别人直接 `docker run` 使用发布镜像时出现。 这也是为什么 release matrix 需要把端口与服务名作为同一条记录维护。新增服务的成本不是“再加一个名字”,而是把二进制、镜像和探针三者的端口镜像对齐。 ## 两边独立实现之后,审计什么 Go 服务与 PHP 接入可以并行开发,但它们只有 HTTP 契约相连。字段名拼错一个,常见结果不是类型错误,而是 Go 解码时忽略未知键、PHP 读取时得到空值。 两边合流后做的不是“发一个请求看看通不通”,而是逐项检查耦合面。 ### 七条路由与方法 `index`、`query`、`init`、`exists`、`stats`、`sane`、`backends` 必须逐字匹配,GET/POST 也不能颠倒。PHP 的 [`PhabricatorGorgeSearchClient`](https://github.com/soulteary/phorge/blob/2026.09.07-r2/src/infrastructure/cluster/PhabricatorGorgeSearchClient.php) 按常量调用,Go 的路由测试则锁住注册结果。 ### 线上 JSON 字段 文档与查询使用 camelCase:`dateCreated`、`dateModified`、`relatedPHID`、`authorPHIDs`、`withAnyOwner`。唯一的历史例外是统计中的 `storage_bytes`。 把它“统一”为 `storageBytes` 不会让状态接口失败,只会让 Phorge 集群面板的存储列变成空值。 ### 十六个四字符常量 五个文档字段和十一个关系/状态字段来自 Phorge: ```text titl body cmnt full core auth book revw subs comm ownr proj repo open clos unow ``` 它们不是内部缩写风格,而是索引里的真实键。一边用 `owner`,另一边用 `ownr`,写入和查询都会成功,只是永远互相找不到。Go 侧把这些值集中在 `esquery` 包,并用 `TestNamesMatchThePHPConstants` 与 PHP 常量逐个对照。 ### 五个域级错误码与失败语义 除平台通用错误外,search 有五个域级错误码: | 错误码 | HTTP | 不能替代成什么 | | ------------------- | ---: | ----------------------------- | | `ERR_INDEX_FAILED` | 502 | 200 indexed | | `ERR_SEARCH_FAILED` | 502 | 200 + 空列表 | | `ERR_INIT_FAILED` | 502 | 500 | | `ERR_CHECK_FAILED` | 502 | `exists:false` 或 `sane:false` | | `ERR_STATS_FAILED` | 502 | 0 文档 | 后端失败用 502,而不是 500。500 表示 Gorge 自己出错;502 表示它调用的存储没有完成工作。混在一起会把排查者送去看错误的日志。 其中 `ERR_CHECK_FAILED` 尤其重要。“索引不存在”“索引结构过期”和“我根本没问到后端”是三种不同状态。若连接失败被转换成 `sane:false`,管理员会被引导执行本域代价最大、也最具破坏性的全量重建。 ### CJK mapping 与查询必须同时出现 审计最后专门检查两件事:mapping 是否包含 `cjk` 子字段,query builder 是否真的以 `cjk_text` 查询 `titl.cjk`、`body.cjk`、`cmnt.cjk`。 缺前者会查询不存在的字段,缺后者则把新索引建成一个无人使用的结构。两种情况下,服务都可能正常启动,写入也都可能返回 200。 审计结果没有发现真实错配。比结果更有价值的是,它已经成为一份可重复执行的检查清单,而不是一句“我把两边大概看了一遍”。 ## 契约测试能测什么,也要写清楚测不到什么 r2 增加了 17 份正常场景固件和 6 份后端不可用场景固件。后者使用一个可注入失败的内存 backend,才能在没有真集群的情况下稳定制造五种域错误。 这里有三个测试边界很值得记录。 ### 一份中文配置证明不了中文搜索 `index-cjk-document.json` 只能证明包含汉字的文档能穿过 JSON 契约并被 handler 接受。配置跑的是内存 test backend,它做子串匹配,完全不经过 Elasticsearch analyzer。 所以它不能证明 `cjk_bigram` 生效,更不能证明查询侧使用了 `cjk` 子字段。把这条配置标成“中文搜索覆盖”会制造一盏假的绿灯。 真正的 CJK 行为只能由 Elasticsearch mapping 单元测试和真实 ES 的 e2e 共同验证。 ### search e2e 是破坏性的 `tests/e2e/search.sh` 会调用 `/api/search/init`,而 init 会先删除再重建索引。它不能像 render 或 notification 的冒烟测试一样随便指向一个共享环境,脚本启动时必须明确警告。 这份 e2e 还不能在写入后立刻只查一次。Elasticsearch refresh 是近实时的,文档写入成功与可搜索之间存在窗口;测试使用轮询,而不是把一次暂时没命中当成实现失败。 ### 测试后端上的 CJK 只能 SKIP,不能 PASS 脚本会先检查 `/backends`。如果当前服务使用 test backend,两条 CJK 场景必须显示 SKIP。内存后端恰好能用子串找到中文,但这条“成功”没有经过待验证的分析器,不能算 PASS。 一个诚实的 SKIP,比一条什么也没证明的绿灯更有价值。 迁入不是“什么都顺手修”,但有些旧行为已经明确会给出错误结论,不能照搬。 项目还有两处行为收紧,以及三处仍然保留的缺口。 ## `/sane` 不再接受空 `docTypes` sanity check 用调用方提供的文档类型生成期望 mapping。如果 `docTypes` 为空,期望值也几乎为空,任何线上索引都可能满足它——包括没有 mapping、没有 CJK 子字段的索引。 于是接口会自信地返回: ```json {"data":{"sane":true}} ``` r2 让 `/init` 与 `/sane` 在空 `docTypes` 时都返回 `400 ERR_BAD_REQUEST`。这不是一般的参数洁癖,而是在阻止一个“永远正确”的健康检查。 ## 其他 ### 配置 JSON 写错仍然不会让进程退出 `GORGE_SEARCH_BACKENDS` 解析失败会记录错误、得到零后端,并让 `/readyz` 返回 503。它已经不会伪装成就绪,但“尚未配置”和“配置写错”在探针与退出码上仍然一样,只有日志能区分。 后续更合理的方案是与 mailer 共用显式严格模式,例如生产环境开启 `GORGE_STRICT_CONFIG=1`,解析失败直接退出;开发或分阶段部署仍然允许空配置启动。 ### Meilisearch 后端仍然是零单元测试覆盖 Elasticsearch 有 mapping、查询构造、HTTP 路径、主机摘除与 failover 测试;Meilisearch 自己翻译查询、管理索引设置、实现 `IndexIsSane`,目前却没有专属测试。 这并不破坏 PHP 契约,因为契约位于后端之上;但配了 Meilisearch 的部署坏掉时,Elasticsearch 测试不会红。这个缺口被明确登记,而不是用总覆盖率掩盖。 ### 五个错误码目前在 PHP 异常类型上会合流 Go 侧保留五个域级错误码,`bin/search` 和运维读取响应时能得到准确原因;但 `PhabricatorGorgeSearchClient` 没有像 mailer 那样覆盖 `newServiceErrorException()`,因此进入 PHP 后仍然是同一种普通异常,错误码只存在于消息文字中。 这是已知边界,不应该据此把 Go 侧错误码合并成一个;但以后若新增一个“PHP 必须据此改变行为”的 search 错误码,就必须同时补上专用异常分支。否则那个错误码看起来已经接通,实际上没有消费者。 ### 回头看:替换服务要检查的是成对出现的东西 这次迁移最危险的地方,不是某个复杂算法,而是很多能力都必须成对存在: | 一侧 | 另一侧 | 只完成一侧的结果 | | ------------------------- | ----------------- | ------------------- | | mapping 新增 `cjk` | query 使用 `*.cjk` | 索引增长,但中文检索不变 | | Go 返回域错误码 | PHP 区分错误语义 | 有错误名,没有行为差异 | | `/healthz` 证明进程活着 | `/readyz` 证明有可读后端 | 容器全绿,每次查询失败 | | `cluster.search` 指向 Gorge | Gorge 位于可读列表首位 | 配置存在,查询仍走 MySQL | | release matrix 增加服务名 | 同时传正确健康端口 | 进程正常,镜像永久 unhealthy | | 写入全部 write 后端 | 读取明确的 failover 链 | 两层各配一半,拓扑无法推理 | 从零写服务时,我们通常先问 happy path 能不能跑通。替换已有能力时,还必须问另一组问题: 1. 老调用方依赖了哪些字面值,而不是哪些抽象语义? 2. 哪些成功响应其实没有产生任何效果? 3. 哪些错误会被正常结果吞掉? 4. 哪些配置同时存在于两层,最后到底是谁在做决定? 5. 改 mapping、协议或角色以后,已有数据需要付出什么迁移代价? 搜索域最能说明这两类工作的区别:一个新字段写进索引很容易,一个新字段真的参与查询、能被真实后端验证、能触发旧索引重建,并且在配置切换后确实由新引擎回答,才算完成替换。 ## 最后 **凡是只完成一半也不会报错的能力,都必须把另一半写进测试、配置或迁移步骤里。** --EOF