本文是“Phorge 现代化改造实战”系列第八篇。前七篇已经建立容器基线、入口信任、模块边界,以及 diff、通知和邮件三类不同的兼容方法;这一篇继续检查搜索服务的写入、查询、配置、就绪和数据迁移能否真正形成闭环。
系列导航
- 从没有官方镜像到 Docker Compose 跑起来:建立最小可运行基线;
- 改进容器化的七个细节:补齐权限、持久化、依赖和探活;
- 接入 Stargate:把 Forward Auth 的信任边界做完整;
- 拆分模块到 Gorge:无侵入改造不等于不碰文件;
- 替换 diff 子进程:兼容不等于逐字一致;
- 替换实时通知服务:为什么 HTTP 501 反而表示正常;
- 迁移邮件服务:先分清哪些失败不该重试;
- 迁移搜索服务:写进索引不等于搜得到;
- 迁移文件存储:写得进去也要读得回来;
- 迁移 Webhook 投递服务:先解决重复投递。
写在前面
上一篇迁移 mailer 时,我把注意力放在“哪些错误不会主动找上门”:永久失败被当成临时错误,worker 会持续重投;没有配置后端的进程仍然健康;两层配置只完成一层,地址看起来已经生效,邮件却永远不会进入服务。
搜索模块把同一类问题往前再推一步。
Gorge 2026.09.07-r2 把原来的 gorge-search 迁进单仓库,补齐 Elasticsearch、Meilisearch、测试后端、七条 HTTP 路由、23 份契约固件和一份可指向真实 Elasticsearch 的端到端测试脚本,具体变化可以从 Gorge r1…r2 的代码差异 中查看;Phorge 2026.09.07-r2 则增加 Gorge 全文检索引擎、search host、HTTP 客户端和配置检查,并把此前几个 Gorge 客户端的公共部分抽成共享基类,对应的宿主改动集中在 Phorge r1…r2 的代码差异 中。
从功能列表看,这像是在“新写一个搜索服务”。实际上,r2 面对的是完全不同的问题:替换一个已经存在、已经有索引、已经被 PHP 调用的实现。
新服务只要能写、能查、能跑就可以继续演进;替代服务却必须回答更多问题:字段名是否逐字符一致,旧索引是否还能用,查询和 mapping 是否同时包含新增字段,失败时返回空结果还是错误,多层 failover 到底由谁负责,以及配置切换之后实际请求是否真的走到了新引擎。
这些问题的共同特点是,做错之后服务往往仍然返回 200。
搜索服务和 Phorge 的功能是什么关系:返回的不是文档,而是 PHID
在产品层面,搜索把 Phorge 中分散的对象重新组织成一个可以统一检索的入口。代码评审、任务、Wiki 文档以及其他实现了搜索文档接口的对象,都可以把标题、正文、评论、作者、项目和状态等信息写入全文索引;用户提交查询后,Phorge 再依据关键词和过滤条件找回候选对象。
但这个过程不能把“全文命中”直接等同于“用户可以看到对象”。索引擅长回答哪些对象可能相关,Phorge 才知道当前用户是否有权访问、对象是否仍然存在,以及页面最终应该怎样展示。因此搜索链路实际分成写入和查询两个方向:
写入:Phorge 搜索文档 → Gorge 协议翻译与调度 → Elasticsearch / Meilisearch
查询:Phorge 保存查询 → Gorge 查询后端 → PHID 列表 → Phorge 加载对象并执行 policy
先说清楚这次服务边界。
Phorge 侧的 PhabricatorGorgeFulltextStorageEngine 接入原生全文检索扩展点;gorge-search 负责把 PhabricatorSearchAbstractDocument 翻译成后端文档,把 PhabricatorSavedQuery 翻译成 Elasticsearch 或 Meilisearch 的查询,再把命中的 PHID 列表交还给 Phorge。
它不返回标题、正文和评论内容:
{
"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 模块文档 描述服务、后端与路由,Gorge 与 Phorge 的兼容契约 则固定跨语言字段和行为边界。迁移完成的判断标准不是“新服务能独立搜索”,而是 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,路由仍然按域命名:
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 启动逻辑。迁入后全部换成平台层:
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:
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 的位置。
有三个选择:
- 修改 Phorge 核心的配置结构,给所有 search engine 增加 token;
- 把 URI 和 token 都放到新的全局配置;
- URI 使用原生
cluster.search.hosts,只有 token 放到隐藏的全局项。
r2 选择第三种:
[
{
"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 条目插在列表首位,而不是追加:
array_unshift($services, $gorge);
默认切换,也要给回滚留路
cluster.search 的默认值本身是一条 MySQL 配置,但 bin/config get 只返回真正写入配置源的值,不会把默认值列出来。一旦 local 中写入 Gorge,隐式默认的 MySQL 条目就消失了。
r2 默认只保留 Gorge;设置 GORGE_SEARCH_KEEP_MYSQL=1 时,脚本会显式加入 MySQL 条目,形成:
[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_exactletter_stopenglish_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:
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 子句:
"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 与“今天重新建索引时使用的配置”对比:
expected := backend.buildIndexConfig(docTypes)
return configDeepMatch(actual, expected), nil
期望配置没有另一份需要人工同步的副本,这是一个很好的设计;同一个函数既负责创建,也负责 sanity check。
代价也很明确:只要 buildIndexConfig() 变化,既有索引就会立即显示 not sane。新增 cjk_text 与子字段以后,所有旧索引都必须执行:
bin/search init
bin/search index --all --force
第一条会删除并重建 mapping,第二条重新写入全部文档。大库可能需要数小时,期间搜索结果不完整。
在一个充满静默失效的迁移里,这条反而是好消息:它会明确报告 false。indexIsSane() 正是为这种结构变化提供信号,所以“必须重建”是迁移代价,不是兼容风险。
也正因为 init 是破坏性操作,entrypoint 绝不自动调用它。把重建放进每次容器启动路径,意味着一次误重启就能删除全部索引。
第五个决定:/readyz 究竟承诺什么
迁入前的三个探针共用同一个 handler:
e.GET("/", healthPing())
e.GET("/healthz", healthPing())
e.GET("/readyz", healthPing())
因此零后端时,服务仍然 health 200、ready 200,每次搜索再单独失败。这精确对应一个很难排查的状态:容器全绿,用户看到的只是“搜不到东西”。
r2 给 readiness 一个真实但克制的判据:至少配置一个带 read 角色的后端。
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 的交付结构把新增二进制的常规成本压到了三处:
- 增加
go/cmd/gorge-search/main.go,复用平台层引导; - Compose 增加 service,传入
build.args.SERVICE=gorge-search; - release workflow 的 matrix 增加
gorge-search。
共享 Dockerfile 与 CI 模板都不用复制。
不过 matrix 条目不只有服务名,还必须有端口:
- service: gorge-search
port: 8120
原因藏在镜像自带的健康检查里:
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 按常量调用,Go 的路由测试则锁住注册结果。
线上 JSON 字段
文档与查询使用 camelCase:dateCreated、dateModified、relatedPHID、authorPHIDs、withAnyOwner。唯一的历史例外是统计中的 storage_bytes。
把它“统一”为 storageBytes 不会让状态接口失败,只会让 Phorge 集群面板的存储列变成空值。
十六个四字符常量
五个文档字段和十一个关系/状态字段来自 Phorge:
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 子字段的索引。
于是接口会自信地返回:
{"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 能不能跑通。替换已有能力时,还必须问另一组问题:
- 老调用方依赖了哪些字面值,而不是哪些抽象语义?
- 哪些成功响应其实没有产生任何效果?
- 哪些错误会被正常结果吞掉?
- 哪些配置同时存在于两层,最后到底是谁在做决定?
- 改 mapping、协议或角色以后,已有数据需要付出什么迁移代价?
搜索域最能说明这两类工作的区别:一个新字段写进索引很容易,一个新字段真的参与查询、能被真实后端验证、能触发旧索引重建,并且在配置切换后确实由新引擎回答,才算完成替换。
最后
凡是只完成一半也不会报错的能力,都必须把另一半写进测试、配置或迁移步骤里。
–EOF