docs: simplify README prose, break long compound sentences

Break long compound sentences into shorter ones across both EN and ZH
READMEs without losing any detail. Improves readability of security
model, deployment, and operational sections.
This commit is contained in:
anonsaber 2026-10-10 19:44:14 +08:00
parent 2bb061cf28
commit d0d1cde465
2 changed files with 33 additions and 70 deletions

View File

@ -25,10 +25,7 @@ State is an architectural boundary here, not a preference.
- **No long-lived connections.** No SSE, no WebSocket, no long polling.
- **Logs go to stdout only.** The platform's collector owns log storage and retention.
- **What it is not.** It is not a mail client, not a proxy, and not a relay. It forwards
metadata to one bot; it does not read arbitrary URLs, open tunnels, or broker traffic. It has
no resident `server` mode flag, holds no EventSource/SSE or Telegram long-polling connection,
persists nothing to SQLite or a local volume, and is not bound to a specific serverless platform
— every notification path is a short request-response.
metadata to one bot; it does not read arbitrary URLs, open tunnels, or broker traffic. It has no resident `server` mode flag. It holds no EventSource/SSE or Telegram long-polling connection. It persists nothing to SQLite or a local volume. It is not bound to a serverless platform — every notification path is a short request-response.
## 2. Two ways to run it
@ -40,9 +37,7 @@ backend runs identically with or without it. It exists for one reason: Cloudflar
Balancer product is not available on the free plan, so a Worker is the way a free-plan account
puts load sharing and failover in front of several backends.
**It is also not a security layer, and it is not meant to be one.** There is no intent to hide
the backends and no intent to harden them; the origins stay directly reachable whether the
load balancer is present or not, and no access control is added at the edge. The fixed route safelist
**It is also not a security layer, and it is not meant to be one.** There is no intent to hide or harden the backends. The origins stay directly reachable with or without the load balancer. No access control is added at the edge. The fixed route safelist
is what makes it a load balancer for a bounded set of paths, not a firewall.
| | Form A: backend origin only | Form B: behind the Cloudflare Worker load balancer |
@ -59,21 +54,15 @@ is what makes it a load balancer for a bounded set of paths, not a firewall.
Pick Form A for a single origin with no HA requirement. Pick Form B for two or more origins, or
whenever you want one stable callback URL while you roll or rebuild an instance.
Switching is a re-registration, not a migration. Registration is idempotent and registering a
new address retires the previous subscription, so moving from Form A to Form B, or back, is one
field in the SPA. Do not register both origins at once: there is a single registration slot, and
Switching is a re-registration, not a migration. Registration is idempotent: registering a new address retires the previous subscription. So moving from Form A to Form B, or back, is one field in the SPA. Do not register both origins at once: there is a single registration slot, and
the platform entrance must stay internal (`C-LB-SINGLE-REG-URL`).
No secret moves to the load balancer in Form B. It holds no business credential and checks no auth
header. Instead every backend must carry the same `SAF-AUTH-*` secrets and the same encrypted
business config, because a callback can land on any instance and no instance can prove it was
the one asked for (`C-LB-SHARED-SECRETS`). Mis-matched secrets show up as a random 401, not as
header. Instead every backend must carry the same `SAF-AUTH-*` secrets and the same encrypted business config. A callback can land on any instance, and no instance can prove it was the one asked for (`C-LB-SHARED-SECRETS`). Mis-matched secrets show up as a random 401, not as
a routing error.
`POST /api/bootstrap` and `/debug/*` are not in the load balancer's route set, so requests addressed
to the Worker URL never reach them. That is the safelist working as designed for the paths that
need to be load balanced; it is not hiding anything, because the same origins are reachable at
their own addresses in both forms.
to the Worker URL never reach them. That is the safelist working as designed for the paths that need load balancing. It is not hiding anything: the same origins are reachable at their own addresses in both forms.
## 3. Setup, from zero
@ -95,9 +84,7 @@ load-balancer operation live in §6 below.
Nothing else is required: no Postgres, no message broker, no Kubernetes.
The platform or proxy terminates TLS; the process listens on one HTTP port (`PORT`, default
`8080`) and exposes no other TCP port, and the public HTTPS URL is provided by the platform, not
the bot. The Docker image uses a Rust builder and a Debian slim runtime, links `rustls` with
The platform or proxy terminates TLS. The process listens on one HTTP port (`PORT`, default `8080`) and exposes no other TCP port. The public HTTPS URL comes from the platform, not the bot. The Docker image uses a Rust builder and a Debian slim runtime, links `rustls` with
native roots for outbound TLS, and carries no secret in its layers or build arguments. Back up
Redis with AOF persistence — it is the only state store.
@ -160,9 +147,7 @@ curl localhost:8080/healthz # → ok
For local builds without Docker: `cargo build --locked` produces the `message-weave` binary.
`GET /healthz` is a static liveness reply and never touches Redis. The readiness report is
`GET /ready`: it answers 503 until the configuration exists and every configured upstream is
reachable, and 200 with a JSON component report once they are. Point your HTTPS reverse proxy or
`GET /healthz` is a static liveness reply and never touches Redis. The readiness report is `GET /ready`. It answers 503 until the configuration exists and every configured upstream is reachable. Once they are, it returns 200 with a JSON component report. Point your HTTPS reverse proxy or
tunnel at this port now — steps 6 and 7 need a public HTTPS origin.
If the service answers requests but does nothing useful, check the logs for a startup warning:
@ -304,20 +289,11 @@ secrets from the environment in normal operation.
**Health.**
`GET /healthz` returns 200 unconditionally — it is liveness, not readiness. `GET /ready`
returns 503 until configuration, Redis, and both upstreams (a JMAP session `GET` and
Telegram `getMe`, 3s each) are reachable. Probe `/ready`, not `/healthz`, when deciding
whether to route traffic — but `/ready` is the heavier probe (~3s worst case), so a monitor
that must stay cheap should watch the load balancer aggregate `GET /healthz-worker` when the load balancer
is enabled.
Telegram `getMe`, 3s each) are reachable. Probe `/ready`, not `/healthz`, when deciding whether to route traffic. But `/ready` is the heavier probe (~3s worst case). A monitor that must stay cheap should watch the load balancer aggregate `GET /healthz-worker` when the load balancer is enabled.
**Remote debug (opt-in, off by default).**
`/debug/*` is an optional remote-debug surface: live JMAP and Telegram probes, the current
business config, and a single Telegram send. It sits behind a two-factor gate — the process
must be started with `--debug` **and** `DEBUG_TOKEN` must be set; miss either and the routes
do not exist at all (requests fall through to a generic 404). Nothing under `/debug/*` is on
the load balancer allowlist, so it is reachable only on the backend origin itself. The only rule for
running it is to keep it off: leave `--debug` out of the start command and leave `DEBUG_TOKEN`
unset. If you do turn it on for a one-off diagnosis, configure the chat allowlist first — with
an empty allowlist the test send is not restricted to any chat. See `docs/reference.md` §3 for
business config, and a single Telegram send. It sits behind a two-factor gate: the process must be started with `--debug` **and** `DEBUG_TOKEN` must be set. Miss either and the routes do not exist at all (requests fall through to a generic 404). Nothing under `/debug/*` is on the load balancer allowlist. It is reachable only on the backend origin itself. The only rule for running it: keep it off. Leave `--debug` out of the start command and `DEBUG_TOKEN` unset. If you do turn it on for a one-off diagnosis, configure the chat allowlist first. With an empty allowlist the test send is not restricted to any chat. See `docs/reference.md` §3 for
the endpoint contracts.
## 5. Security boundary, in one sentence
@ -327,14 +303,10 @@ the endpoint contracts.
One corollary matters for setup: the SPA admin credential — and therefore the bootstrap
trust root — is `CONFIG_ENCRYPTION_KEY` itself, the single 32-byte hex value you supply at
startup. It is only compared in constant time, and is never echoed, logged, or stored. The
Redis ACL password, if you have one, authenticates the Redis connection alone and is not
the credential for any HTTP endpoint.
startup. It is only compared in constant time, and is never echoed, logged, or stored. The Redis ACL password (if you have one) authenticates the Redis connection alone. It is not the credential for any HTTP endpoint.
The optional Cloudflare Worker load balancer in front of the backends is not part of this boundary.
It holds no credential and checks no auth header, and the backends behind it are directly
reachable either way, so nothing about it changes what stands between an attacker and your
mail. See §2.
It holds no credential and checks no auth header. The backends behind it are directly reachable either way. Nothing about it changes what stands between an attacker and your mail. See §2.
## 6. Operations and multi-instance
@ -370,9 +342,7 @@ deploy a second instance pointed at a second Redis rather than distinguishing in
Second, **`CONFIG_ENCRYPTION_KEY` must be byte-identical on every origin.** It is 64 hex
characters (32 bytes) and is used directly as the AES-256-GCM key, with the AAD fixed at
`message-weave/config:business/v1`. It also doubles as the SPA admin credential. An origin holding
a different value can neither decrypt configuration written by another origin nor log in to the
SPA, and both paths fail closed.
`message-weave/config:business/v1`. It also doubles as the SPA admin credential. An origin holding a different value can neither decrypt configuration written by another origin nor log in to the SPA. Both paths fail closed.
Extra origins buy failover rather than throughput. Delivery is spread across the shared consumer
group, but `/reconcile` is serialized globally by `lock:reconcile` and the cursor is singular. If
@ -397,14 +367,11 @@ The entry points are `/ready`, `/worker`, `/reconcile`, `/push/jmap`, `/webhook/
above is an early exit, not a retry guard. When `build_worker` fails for one origin — a JMAP
session URL that is unreachable, or an `llm_api_key` / `llm_base_url` that is missing or invalid —
that origin advances its in-memory revision to the remote value and keeps the runtime it already
had. The early-exit condition then holds forever for that origin, so it does not try again until a
**new** revision is written. Every other failure inside the same function (Redis unreachable, blob
missing, JSON parse failure, validation failure) leaves the local revision untouched and therefore
does retry on the next request; those paths fail before a runtime is built, so no worker is
rebuilt.
had. The early-exit condition then holds forever for that origin; it does not try again until a **new** revision is written.
The symptom is one origin running the new configuration and another still on the old one, with no
error and no log line: the failed path returns silently. To detect it, compare the field values
Every other failure inside the same function (Redis unreachable, blob missing, JSON parse failure, validation failure) leaves the local revision untouched and therefore does retry on the next request. Those paths fail before a runtime is built, so no worker is rebuilt.
The symptom is one origin running the new configuration and another still on the old one — with no error and no log line. The failed path returns silently. To detect it, compare the field values
reported by `GET /debug/config` on each origin — those come from the live in-memory runtime. Do not
use its `revision` field for this: that value is read from Redis and shows the latest global
revision even when the instance has not converged.

View File

@ -23,8 +23,8 @@ MessageWeave 位于 JMAP 邮件服务器(例如 Stalwart)与 Telegram bot
- **无长连接。** 无 SSE、无 WebSocket、无长轮询。
- **日志只写 stdout。** 日志存储与保留由平台采集器负责。
- **它不是什么。** 它不是邮件客户端,不是代理,也不是转发中继。它只把一个 bot 的元数据
送达 Telegram;不抓取任意 URL、不开隧道、不中转流量。它没有常驻 `server` 模式开关,不持有
EventSource/SSE 或 Telegram 长轮询连接,不向 SQLite 或本地卷持久化,也不绑定特定 serverless
送达 Telegram;不抓取任意 URL、不开隧道、不中转流量。它没有常驻 `server` 模式开关。不持有
EventSource/SSE 或 Telegram 长轮询连接。不向 SQLite 或本地卷持久化。也不绑定特定 serverless
平台——每条通知路径都是短请求-响应。
## 2. 两种部署形态
@ -33,7 +33,7 @@ MessageWeave 位于 JMAP 邮件服务器(例如 Stalwart)与 Telegram bot
**Cloudflare Worker 负载均衡器是可选项。** 后端没有任何东西依赖它,有它和没有它跑起来完全一样。它存在的理由是单一的:Cloudflare 的 Load Balancer 产品在免费套餐上不可用,所以 Worker 是免费套餐账户在多个后端前面做负载均衡与故障转移的办法。
**它也不是安全层,本意不是拿来干这个的。** 没有隐藏后端的意图,也没有加固后端的意图;负载均衡器在不在,origin 都同样可以直接访问,边缘上也没有加任何访问控制。固定的路由白名单是让它在有限的一组路径上做负载均衡的手段,不是防火墙。
**它也不是安全层,本意不是拿来干这个的。** 没有隐藏或加固后端的意图。负载均衡器在不在,origin 都同样可以直接访问。边缘上也没有加任何访问控制。固定的路由白名单是让它在有限的一组路径上做负载均衡的手段,不是防火墙。
| | 形态 A:仅后端 origin | 形态 B:前置 Cloudflare Worker 负载均衡器 |
|---|---|---|
@ -48,11 +48,11 @@ MessageWeave 位于 JMAP 邮件服务器(例如 Stalwart)与 Telegram bot
单 origin 且没有 HA 要求时选形态 A;两个及以上 origin、或想在滚动与重建某个实例时保持一个稳定的回调 URL,就选形态 B。
切换是一次重新注册,不是迁移。注册是幂等的,注册新地址会注销旧订阅,所以从形态 A 换到形态 B(或反过来)只是 SPA 里改一个字段。不要同时注册两个 origin:注册槽只有一个,后端的平台入口不对外注册(`C-LB-SINGLE-REG-URL`)。注意这里说的是「不注册」,不是「不可达」——两种形态下 origin 都同样可以直接访问。
切换是一次重新注册,不是迁移。注册是幂等的:注册新地址会注销旧订阅。所以从形态 A 换到形态 B(或反过来)只是 SPA 里改一个字段。不要同时注册两个 origin:注册槽只有一个,后端的平台入口不对外注册(`C-LB-SINGLE-REG-URL`)。注意这里说的是「不注册」,不是「不可达」——两种形态下 origin 都同样可以直接访问。
形态 B 里没有任何 secret 搬到负载均衡器。它不持有任何业务凭据,也不校验任何认证头。真正要求一致的是每个后端都携带同一组 `SAF-AUTH-*` secret 与同一份加密业务配置——因为回调可能落在任何一个实例上,而任何实例都证明不了自己是被点名的那一个(`C-LB-SHARED-SECRETS`)。凭据不一致表现为随机 401,而不是路由错误。
形态 B 里没有任何 secret 搬到负载均衡器。它不持有任何业务凭据,也不校验任何认证头。真正要求一致的是每个后端都携带同一组 `SAF-AUTH-*` secret 与同一份加密业务配置。回调可能落在任何一个实例上,而任何实例都证明不了自己是被点名的那一个(`C-LB-SHARED-SECRETS`)。凭据不一致表现为随机 401,而不是路由错误。
`POST /api/bootstrap` 与 `/debug/*` 不在负载均衡器的路由集里,所以发给 Worker URL 的请求到不了它们。这是白名单在需要被负载均衡的那些路径上按设计工作,并不是在隐藏什么——两种形态下 origin 都同样可以通过自己的地址直接访问。
`POST /api/bootstrap` 与 `/debug/*` 不在负载均衡器的路由集里,所以发给 Worker URL 的请求到不了它们。这是白名单在需要被负载均衡的那些路径上按设计工作。并不是在隐藏什么:两种形态下 origin 都同样可以通过自己的地址直接访问。
## 3. 从零开始
@ -69,7 +69,7 @@ MessageWeave 位于 JMAP 邮件服务器(例如 Stalwart)与 Telegram bot
不需要别的:没有 Postgres、没有消息队列、没有 Kubernetes。
平台或反代终结 TLS;进程监听一个 HTTP 端口(`PORT`,默认 `8080`),不暴露任何其他 TCP 端口,公网 HTTPS URL 由平台提供,不是 bot 自己的。Docker 镜像用 Rust builder 与 Debian slim 运行时,外链 `rustls` 配原生根证书做出站 TLS,镜像层与构建参数里不带任何 secret。用 AOF 持久化备份 Redis——它是唯一的状态存储。
平台或反代终结 TLS。进程监听一个 HTTP 端口(`PORT`,默认 `8080`),不暴露任何其他 TCP 端口。公网 HTTPS URL 由平台提供,不是 bot 自己的。Docker 镜像用 Rust builder 与 Debian slim 运行时,外链 `rustls` 配原生根证书做出站 TLS,镜像层与构建参数里不带任何 secret。用 AOF 持久化备份 Redis——它是唯一的状态存储。
### 3.1 创建 Telegram bot
@ -115,7 +115,7 @@ curl localhost:8080/healthz # → ok
不用 Docker 的本地构建:`cargo build --locked` 产出 `message-weave` 二进制。
`GET /healthz` 是静态存活回复,不碰 Redis。就绪报告是 `GET /ready`:在配置存在且所有已配置上游可达之前回答 503,之后回答 200 并带一份 JSON 组件报告。现在把你的 HTTPS 反代或隧道指向这个端口——第 6、7 步需要一个公网 HTTPS 域名。
`GET /healthz` 是静态存活回复,不碰 Redis。就绪报告是 `GET /ready`。在配置存在且所有已配置上游可达之前回答 503。之后回答 200 并带一份 JSON 组件报告。现在把你的 HTTPS 反代或隧道指向这个端口——第 6、7 步需要一个公网 HTTPS 域名。
如果服务有响应但什么都不做,请查看启动日志的告警:**缺少必需变量不会让进程崩溃**,它会退化为运行在内存态上的只读配置路由。见 §4。
@ -212,25 +212,19 @@ scripts/cron-drain.sh --once \
**健康检查。**
`GET /healthz` 无条件返回 200——它是存活探测,不是就绪探测。`GET /ready` 在配置、Redis 与
两条上游(JMAP session `GET`、Telegram `getMe`,各 3s)可达之前返回 503。判断是否可路由
流量时,请探测 `/ready` 而不是 `/healthz`;但 `/ready` 是较重的探针(最坏约 3s),需要
保持轻量的监控在启用负载均衡器时请看负载均衡器聚合 `GET /healthz-worker`。
两条上游(JMAP session `GET`、Telegram `getMe`,各 3s)可达之前返回 503。判断是否可路由流量时,请探测 `/ready` 而不是 `/healthz`。但 `/ready` 是较重的探针(最坏约 3s)。需要保持轻量的监控在启用负载均衡器时请看负载均衡器聚合 `GET /healthz-worker`。
**远程联调面(可选,默认关闭)。**
`/debug/*` 是一套可选的远程联调面:JMAP 与 Telegram 实时探针、当前业务配置,以及单条
Telegram 发送。它由双因子开关控制——进程必须带 `--debug` **且** 已设置 `DEBUG_TOKEN`;
任一缺失则这些路由完全不存在(请求落到通用 404)。`/debug/*` 不在负载均衡器白名单内,因此只能
直连后端 origin 访问。运行它唯一的规则就是不要打开:启动命令里不加 `--debug`,环境变量里不
设 `DEBUG_TOKEN`。若确实要为一次排障临时开启,请先配好 chat 白名单——白名单为空时,测试
发送不会受限到任何 chat。见 `docs/reference.zh-CN.md` §3 的端点契约。
Telegram 发送。它由双因子开关控制:进程必须带 `--debug` **且** 已设置 `DEBUG_TOKEN`。任一缺失则这些路由完全不存在(请求落到通用 404)。`/debug/*` 不在负载均衡器白名单内。只能直连后端 origin 访问。运行它唯一的规则:不要打开。启动命令里不加 `--debug`,环境变量里不设 `DEBUG_TOKEN`。若确实要为一次排障临时开启,请先配好 chat 白名单。白名单为空时,测试发送不会受限到任何 chat。见 `docs/reference.zh-CN.md` §3 的端点契约。
## 5. 安全边界,一句话
> 所有状态都在 Redis 里;进程只读两个环境变量,不写任何磁盘。
有一条推论与部署直接相关:SPA 管理凭据——也就是 bootstrap 的信任根——就是 `CONFIG_ENCRYPTION_KEY` 本身,即启动时提供的那个 32 字节 hex 值。它只做常数时间比较,绝不回显、记录或落盘。Redis ACL 密码(如有)只认证 Redis 连接本身,不是任何 HTTP 端点的认证凭据。
有一条推论与部署直接相关:SPA 管理凭据——也就是 bootstrap 的信任根——就是 `CONFIG_ENCRYPTION_KEY` 本身,即启动时提供的那个 32 字节 hex 值。它只做常数时间比较,绝不回显、记录或落盘。Redis ACL 密码(如有)只认证 Redis 连接本身。它不是任何 HTTP 端点的认证凭据。
前置在后端前面的可选 Cloudflare Worker 负载均衡器不在这个边界内。它不持有任何凭据,也不校验任何认证头;负载均衡器在不在,后端都同样可以直接访问,所以它不会改变攻击者与你的邮件之间的任何东西。见 §2。
前置在后端前面的可选 Cloudflare Worker 负载均衡器不在这个边界内。它不持有任何凭据,也不校验任何认证头。负载均衡器在不在,后端都同样可以直接访问。它不会改变攻击者与你的邮件之间的任何东西。见 §2。
## 6. 运维与多实例
@ -255,7 +249,7 @@ Telegram 发送。它由双因子开关控制——进程必须带 `--debug` **
第一,**业务配置只有一份,任一源站的 SPA 编辑写入的是同一份副本。** 没有办法只改一个实例:没有任何键带实例维度,`refresh_business_config` 也不接受实例参数。这是有意行为。如果确实需要两份互相独立的配置——比如生产与一个预生产孪生——那就部署第二个实例、指向第二个 Redis,而不是在一个 Redis 内部分区。
第二,**`CONFIG_ENCRYPTION_KEY` 在每个源站上必须逐字节相同。** 它是 64 位十六进制(32 字节),直接用作 AES-256-GCM 密钥,AAD 固定为 `message-weave/config:business/v1`。它还兼作 SPA 管理员凭据。持不同值的源站既解不开别处写入的配置,也登不上 SPA,且两条路径都失败关闭。
第二,**`CONFIG_ENCRYPTION_KEY` 在每个源站上必须逐字节相同。** 它是 64 位十六进制(32 字节),直接用作 AES-256-GCM 密钥,AAD 固定为 `message-weave/config:business/v1`。它还兼作 SPA 管理员凭据。持不同值的源站既解不开别处写入的配置,也登不上 SPA。两条路径都失败关闭。
多挂源站买的是故障转移,不是吞吐。投递在共享消费者组里分散,但 `/reconcile` 被 `lock:reconcile` 全局串行化、游标是单一的。如果某个实例在批次中途死掉,下一个实例会在消息越过 `XAUTOCLAIM` 空闲窗口后把它们捡起来;代价是可能的重复投递,由 `dedup:*` 键兜底,永远不会丢消息。
@ -265,9 +259,11 @@ Telegram 发送。它由双因子开关控制——进程必须带 `--debug` **
入口是 `/ready`、`/worker`、`/reconcile`、`/push/jmap`、`/webhook/tg`、`/api/config`、`/api/business-config` 与 `/api/telegram/register-webhook`。`GET /healthz` **不**在其中——它是静态 `200 "ok"`、从不刷新——`GET /api/status`、`/api/enabled`、`/api/push/register`、`/api/push/disable` 与 `/api/admin/session` 这一对也都不是。
**已知行为:一次失败的重建在下一个版本号到来前不会被重试。** 上面的版本号检查是早退,不是重试守卫。当 `build_worker` 对某个源站失败时——JMAP session URL 不可达,或 `llm_api_key` / `llm_base_url` 缺失或非法——该源站把内存中的版本号推进到远端值,并保留它已有的运行时。于是早退条件对该源站永远成立,所以它直到**新**版本号被写入才会再试。同一函数内的其他失败(Redis 不可达、密文块缺失、JSON 解析失败、校验失败)不碰本地版本号,因此在下次请求时确实重试;这些路径在运行时构建之前就失败了,所以没有 worker 被重建。
**已知行为:一次失败的重建在下一个版本号到来前不会被重试。** 上面的版本号检查是早退,不是重试守卫。当 `build_worker` 对某个源站失败时——JMAP session URL 不可达,或 `llm_api_key` / `llm_base_url` 缺失或非法——该源站把内存中的版本号推进到远端值,并保留它已有的运行时。于是早退条件对该源站永远成立;它直到**新**版本号被写入才会再试。
症状是一个源站跑着新配置、另一个还停在旧配置,没有错误、没有日志行:失败路径静默返回。要检测它,比较各源站 `GET /debug/config` 报告的字段值——那些来自活动内存运行时。不要用它的 `revision` 字段做这件事:那个值是从 Redis 读的,显示的是最新全局版本号,即便该实例尚未收敛。
同一函数内的其他失败(Redis 不可达、密文块缺失、JSON 解析失败、校验失败)不碰本地版本号,因此在下次请求时确实重试。这些路径在运行时构建之前就失败了,所以没有 worker 被重建。
症状是一个源站跑着新配置、另一个还停在旧配置——没有错误、没有日志行。失败路径静默返回。要检测它,比较各源站 `GET /debug/config` 报告的字段值——那些来自活动内存运行时。不要用它的 `revision` 字段做这件事:那个值是从 Redis 读的,显示的是最新全局版本号,即便该实例尚未收敛。
要恢复,从 SPA 再动一次配置让版本号前进;卡住的源站会再尝试一次重建。如果该源站还在 Worker 的源站列表里,在此期间先把它移除,免得请求被拆到两份配置之间。