本文是“Phorge 现代化改造实战”系列第八篇。前七篇已经建立容器基线、入口信任、模块边界,以及 diff、通知和邮件三类不同的兼容方法;这一篇继续检查搜索服务的写入、查询、配置、就绪和数据迁移能否真正形成闭环。

系列导航

  1. 从没有官方镜像到 Docker Compose 跑起来:建立最小可运行基线
  2. 改进容器化的七个细节:补齐权限、持久化、依赖和探活
  3. 接入 Stargate:把 Forward Auth 的信任边界做完整
  4. 拆分模块到 Gorge:无侵入改造不等于不碰文件
  5. 替换 diff 子进程:兼容不等于逐字一致
  6. 替换实时通知服务:为什么 HTTP 501 反而表示正常
  7. 迁移邮件服务:先分清哪些失败不该重试
  8. 迁移搜索服务:写进索引不等于搜得到
  9. 迁移文件存储:写得进去也要读得回来
  10. 迁移 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.urlcluster.mailers 两处,启动脚本只写一半时,管理员会看到地址已经生效,却一封信都发不出去。

search 不能原样照抄。

Phorge 的 cluster.search 使用固定键表校验,允许 typehostsrolesportprotocolpathversion 等字段,多一个键就拒绝整条配置。这里没有放 token 的位置。

有三个选择:

  1. 修改 Phorge 核心的配置结构,给所有 search engine 增加 token;
  2. 把 URI 和 token 都放到新的全局配置;
  3. 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_exact
  • letter_stop
  • english_stem

其中 letter tokenizer 对 CJK 文本基本无效,标准 tokenizer 也无法提供适合中文短语的匹配与评分。继续平迁意味着中文内容能写入索引,却很难按用户预期检索。

为什么 bin/search ngrams 解决不了

Phorge 旧文档里有一条看似现成的中文方案:运行 bin/search ngrams

但这条链路属于 Ferret/MySQL。PhabricatorSearchNgramsPhabricatorFerretEngine 和 ngram 清理都发生在 MySQL 索引中。切换到 Gorge + Elasticsearch 后,那个命令不会改变 Elasticsearch mapping,也不会改变 Gorge 发出的查询。

把它继续写在 Gorge 的部署说明里,比没有说明更危险:管理员会执行一个成功退出的命令,以为中文搜索已经开启。

不引入插件的 CJK 分析器

r2 使用 Elasticsearch 自带的 cjk_widthcjk_bigram,不要求安装 analysis-icusmartcn

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 的交付结构把新增二进制的常规成本压到了三处:

  1. 增加 go/cmd/gorge-search/main.go,复用平台层引导;
  2. Compose 增加 service,传入 build.args.SERVICE=gorge-search
  3. 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 读取时得到空值。

两边合流后做的不是“发一个请求看看通不通”,而是逐项检查耦合面。

七条路由与方法

indexqueryinitexistsstatssanebackends 必须逐字匹配,GET/POST 也不能颠倒。PHP 的 PhabricatorGorgeSearchClient 按常量调用,Go 的路由测试则锁住注册结果。

线上 JSON 字段

文档与查询使用 camelCase:dateCreateddateModifiedrelatedPHIDauthorPHIDswithAnyOwner。唯一的历史例外是统计中的 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:falsesane: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.cjkbody.cjkcmnt.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 能不能跑通。替换已有能力时,还必须问另一组问题:

  1. 老调用方依赖了哪些字面值,而不是哪些抽象语义?
  2. 哪些成功响应其实没有产生任何效果?
  3. 哪些错误会被正常结果吞掉?
  4. 哪些配置同时存在于两层,最后到底是谁在做决定?
  5. 改 mapping、协议或角色以后,已有数据需要付出什么迁移代价?

搜索域最能说明这两类工作的区别:一个新字段写进索引很容易,一个新字段真的参与查询、能被真实后端验证、能触发旧索引重建,并且在配置切换后确实由新引擎回答,才算完成替换。

最后

凡是只完成一半也不会报错的能力,都必须把另一半写进测试、配置或迁移步骤里。

–EOF