Files
EasyTier/docs/easytier-web-managed-config-sync-plan.md
KKRainbow c96b6c1961 fix(web): harden managed config sync between console and clients (#2567)
* fix(web): fence managed config runtime reconciliation

Keep runtime reconciliation tied to the currently authorized session so
stale connections cannot mutate a replacement session runtime.

Accumulate only contiguous dirty IDs and load their latest SQLite state.
Require the applied revision to match the earliest Patch base and the
persisted revision to match the latest target. Otherwise, reconcile the
full desired state.

Use separate runtime-state and config-cache epochs. Managed updates can
reuse observed configs; direct mutations invalidate them. Update sync
documentation to match.

* fix(web): interrupt validation retry on state changes

Track meaningful validation state changes separately from periodic dirty signals. Applied revision changes wake a failed validation immediately, while heartbeat-driven revalidation retains the retry backoff.

Treat Notify as a wake-up hint and recheck the state-change epoch after every wake so stored permits and periodic heartbeats cannot cause retry storms.

* fix(web): retry unconfirmed connected webhooks

Retry node-connected webhook delivery on retryable errors with a
short 100ms/500ms backoff and give up immediately on non-retryable
errors. Re-check that the session still owns the connection before
every attempt and before recording the delivery, so a replaced
session can no longer record a stale connected binding.

* fix(web): fence disconnects by session ownership

Return whether session removal actually removed the current route owner, and emit disconnected only for that owner. Replaced sessions can no longer invalidate a newer connected route.

* fix(web): hot-patch managed hostnames

Include hostname changes in the hot-patch path instead of falling
back to a full restart. When a full overwrite run is required and
the desired config has no hostname, inherit the current runtime
hostname so an unmanaged value survives until it is explicitly
cleared.

Read back the runtime config after an overwrite run and verify it
converged instead of assuming the desired state was applied.

* fix(web): retry transient runtime reconciliation failures

Keep the per-session managed runtime reconciliation worker alive when a
single database round fails. Retry from the next heartbeat so persisted
managed revisions can still converge after restart-time contention.

Reserve terminal worker shutdown for destroyed session or storage state,
and cover recovery after a transient revision read failure.

* fix(web): accept omitted hostname after runtime apply

Release 2.6.4 omits hostname from config readback when it matches the device hostname. Trust a successful hostname mutation only when the returned field is absent, while continuing to verify every other field and rejecting explicit mismatches.

* fix(web): ignore unmanaged runtime device names

Windows release 2.6.4 generates a random interface name when the managed config leaves dev_name empty. Exclude that runtime-owned value from reconciliation unless the desired config explicitly sets a non-empty device name, preventing endless overwrite restarts.

* feat(web): report failed network instances to console

Expose stopped Core instances with startup errors in heartbeats.

Merge Core failures with direct managed-run RPC failures in easytier-web.

Send failed instance IDs during token validation without error text.

Prune local run failures when managed configs are deleted.

* fix(web): distinguish unknown runtime application state

Track whether the current session has observed its applied revision
separately from the optional revision value. Report this fact through
validate-token so Console can preserve application state across
receiver restarts while recognizing deliberate pending mutations.

* feat(web): configure heartbeat timing from server

Heartbeat responses now provide the interval and RPC timeout.

Legacy servers use local defaults and remote values are clamped.

Web configuration and session receive timeout follow the policy.

* fix(web): reject inactive control sessions

Route control RPCs by machine id only to sessions whose RPC manager
is still running, so a session that has been stopped or replaced
can no longer receive control traffic addressed to the device.

* fix(core): filter network info before collection

When a collect-network-info request names specific instances,
collect those instances only instead of collecting every instance
and filtering the result afterwards, so unrequested instances no
longer run per-collection work on every request.

* feat(web): enable focused runtime diagnostics

Enable easytier-web info logs by default while preserving explicit log configuration. Record startup settings, session lifecycle, failed instance changes, webhook queue and request latency, and managed runtime operation timings for production diagnosis.

* fix(web): preserve managed revision across reconnects

Keep one runtime identifier for each Core WebClient lifetime.

Reuse its managed runtime state after transport reconnects.

Retain applied revisions and reconcile hints while disconnected.

Preserve runtime epochs so stale work cannot mark a revision applied.

Reject stale sessions from reclaiming routes after reconnect.

Core or Web restarts and legacy clients still use unknown state.

Immediately revalidate a restored revision after authentication.

Document local management RPC drift as an accepted trade-off.

This lets Console converge without waiting for periodic validation.

* fix(web): satisfy clippy across managed config sync tests

Scope managed runtime guards to blocks in runtime revision tests so
no std MutexGuard is held across await points, return the applied
revision directly instead of through a let binding, and pass
WebhookValidationInput to request_heartbeat_validation instead of
expanding it into eight separate arguments.

* fix(core): stop reporting failed instances as running in heartbeats

A stopped instance with a startup error appeared in both
running_network_instances and failed_network_instances, so the
server treated it as running and never re-ran its managed config.
Exclude failed instance ids when building the running list so the
reconciler restarts them.

* fix(core): close missed-wakeup race in instance state changes

wait_for_change created the Notified future before reading the
generation but only registered it when awaited. A change landing in
between fired notify_waiters with no registered waiter and delayed
the heartbeat by a full interval. Enable the future before reading
the generation so every change wakes a waiting heartbeat.

* fix(web): address review findings

Fence webhook validation and connection transitions against stale
state, redact credentials from default-level logs, and stabilize
runtime reconciliation:

- Record connected bindings only while the session still owns the
  machine route, and skip disconnect compensation once a replacement
  owns the route so a stale disconnect cannot revoke it.
- Discard webhook validation results when the change epoch moved
  during the HTTP round, so a stale rejection cannot invalidate the
  current session.
- Drop user_token fields from info and warn logs that became
  visible with info-level defaults.
- Restore a hostname omitted by the 2.6.4 readback into the cached
  runtime config after a successful mutation, so later rounds stop
  re-sending the same hostname patch.
- Reconcile running web configs when no revision is tracked so
  legacy unrevisioned updates converge, and wake sessions for
  unrevisioned full updates instead of waiting for the next
  heartbeat.

* chore(go): regenerate web proto bindings for heartbeat fields

Add failed_network_instances, support_heartbeat_policy, and the
heartbeat policy response fields to the checked-in Go bindings.
Other proto packages are left as-is because their drift predates
this change.

* fix(web): redact user tokens from positional log arguments

Three runtime reconciliation info logs and the user lookup error
contexts printed user_token through format arguments, which the
earlier field-syntax redaction missed. The reconcile log now fires
every round for unrevisioned machines, so remove the token from
these messages as well.

* fix(web): fence stale validation and runtime reconcile rounds

Check webhook validation epochs while holding the session write lock,
so stale success and rejection responses cannot change session state.
Advance the runtime epoch for unrevisioned full config updates, and
exclude failed instances from heartbeat and RPC reconciliation lists
so stopped instances are restarted instead of repeatedly hot-patched.

Release test read guards before awaiting validation apply calls. Set
up the no-pending condition before asserting that an applied revision
is a no-op, and verify that its runtime epoch remains unchanged.

Validation: all 137 client_manager tests passed.

* test(credentials): cover P2P with active VPN portal

Model an admin and temporary credential peer connected as a foreign network through a public server with data relay disabled. Verify their direct connection can be replaced after a WireGuard portal client comes online.

* test(credentials): stabilize two-admins failover assertions

The two-admins non-reusable credential test could fail on slow
convergence: after dropping the winning peer it relied on a single
route sample passing a bare AND condition, then re-asserted the same
expectations through one-shot checks seconds later. A transient route
flap in that window (for example a briefly resurrected winner route
from stale conn info) turned a passing convergence into a hard assert
failure. This matches the 48.9s CI flake of
credential_non_reusable_across_two_admins_allows_only_one_peer
observed on 2026-08-12.

Changes:

- wait for bidirectional admin connectivity (AND) with a 20s budget
  before issuing the credential, instead of a one-directional OR
- replace the failover wait_for_condition with
  wait_stable_failover_visibility_on_admins, which requires three
  consecutive samples of loser-present and winner-absent on both
  admins within the same 60s budget and logs every sample
- enrich the stable-single-winner timeout message with per-admin
  visibility flags and elapsed time for triage

All existing contracts are preserved; only observation windows and
diagnostics change. Validated in the rust container: three passes at
normal speed (54.1s / 53.8s / 53.1s) plus one slow-convergence round
(172.7s) that would have raced the old one-shot sampling; it now
passes with failover samples logged. cargo fmt and clippy -D warnings
clean.
2026-09-13 01:13:28 +08:00

24 KiB
Raw Permalink Blame History

EasyTier Web Managed Config Incremental Sync Plan

Status

  • 状态:Implemented(核心协议、持久化与 Session 增量收敛)
  • 实施范围:EasyTier Web 的 HTTP 接收、校验、SQLite 持久化和 Session 运行态收敛
  • 上游依赖:后续由 Console 计算并发送 Patch
  • 兼容要求:保留现有 Full PUT

本文记录当前接收端方案。Session 合并已持久化 Patch 的 touched instance IDs, 并在运行态收敛时读取这些实例的最新持久化状态。重启、通知丢失或无法安全判断 实例 ownership 时沿用 Full reconcile。

1. 背景与结论

当前 /validate-token webhook 已经只交换 token、机器信息和 revision,不再 携带完整 managed config 集合。剩余的大集合位于独立的配置发布路径:

PUT /api/internal/users/:user-id/machines/:machine-id/networks

Console 每次发布都会向该路径发送完整 Exact Set。实例很多时,请求体、JSON 解析、现有配置扫描和逐条 SQLite 写入都随实例总数增长。

第一阶段采用以下方案:

  1. 保留 PUT,作为完整发布、首次同步和冲突恢复路径。
  2. 在同一路径增加 PATCH;普通变更只发送完整的单实例 upsert 和删除 ID。
  3. PATCH 使用 expected_config_revision 做 compare-and-swap(CAS)。
  4. Full/Patch 的配置变更与 revision 更新在一个 SQLite transaction 中提交。
  5. Patch 只查询和写入 touched instances,不扫描完整 Target。
  6. 写入成功后通知 Session 本次 expected、target 和 transaction 实际 touched instance IDs。
  7. Session 只合并 revision 连续的 touched IDs,并以 SQLite 当前状态为准增量 收敛;可信 runtime base、通知链或 persisted target 无法证明连续时回退 Full。

普通变更的接收端成本由:

O(total instances)

降为:

wire / JSON / persistence transaction / runtime config apply = O(changed instances)

冷启动或 revision 冲突仍需要 O(total) 的 Full。这是没有可用基线时传递完整 目标状态所必需的成本;如果 Full 超过安全的单请求上限,需另行设计 staged snapshot,而不是直接分页写入 live rows。

2. 目标与非目标

2.1 目标

  1. 普通新增、更新和删除只传输、解析、查询并写入变化实例。
  2. Config rows 与 persisted revision 原子提交。
  3. Patch 可安全重试,并能确定性处理并发或乱序请求。
  4. 保持 user-owned 与 web-owned 配置的 ownership 规则。
  5. 保持 Full Exact Set 的删除和显式空集合语义。
  6. 为 Full 和 Patch 设置显式且可测试的容量限制。
  7. 先部署接收端,再允许 Console 使用 Patch。

2.2 非目标

  1. 修改 /validate-token request/response。
  2. 在本阶段实现 Console 的 diff/cache 逻辑。
  3. 优化 Core heartbeat 中的完整运行实例上报。
  4. 实现 Full 分页、上传会话或持久化 delivery FSM。
  5. 让冷启动 Full 的成本低于 O(total)。

3. 必须保持的语义

3.1 Full Exact Set

Full 表示一个 (user_id, machine_id) 下全部期望的 web-owned configs:

  • 请求中存在的实例应被创建或更新;
  • 已存在但请求中缺失的 web-owned 实例应被删除;
  • 空集合应删除该 Target 下全部 web-owned 实例;
  • user-owned 实例不能被覆盖或删除。

3.2 Patch

Patch 只描述从一个已知 revision 到另一个 revision 的变化:

  • upserts:新增或变化实例的完整 config;
  • delete_instance_ids:从目标集合中删除的实例 ID;
  • expected_config_revision:receiver 必须已经处于的 base revision;
  • config_revision:提交完成后的 target revision。

Patch 不是独立的完整目标。当前 revision 与 expected revision 不一致时,必须 返回冲突且不做任何写入。

3.3 Revision invariants

  1. 一个 persisted revision 只对应与其一起提交的 web-owned projection。
  2. Config mutation 和 revision advancement 必须位于同一 transaction。
  3. Patch 只能应用在完全匹配的 expected revision 上。
  4. 当前 revision 已等于 target revision 时,返回幂等成功且不重复写入。
  5. Publisher 不得为不同目标状态复用同一个 target revision。
  6. 任何其他写路径只要改变 web-owned row,就必须在同一 transaction 中清除 managed revision;否则未来 Patch 会基于错误的 base。
  7. Persisted revision 与 Session applied revision 保持为两个不同事实。HTTP 成功只代表本地持久化完成,不代表 Core 已经应用。

4. HTTP contract

4.1 保留 Full PUT

路径不变:

PUT /api/internal/users/:user-id/machines/:machine-id/networks

现有 JSON shape 保持兼容:

{
  "managed_network_configs": [
    {
      "instance_id": "11111111-1111-1111-1111-111111111111",
      "network_config": {}
    }
  ],
  "config_revision": "target-revision",
  "expected_config_revision": "base-revision"
}

expected_config_revision 保持当前含义:

  • 字段缺失:兼容旧调用者,不检查 base;
  • 空字符串:要求当前 persisted revision 不存在;
  • 非空字符串:要求当前 revision 与该值相等。

新 Console 必须发送 expected revision。省略 expected 的形式只用于旧版本兼容 和明确的运维修复。

config_revision 的处理:

  • 非空:配置与 target revision 原子提交;
  • 缺失:保留旧 Full 请求兼容,但清除已有 managed revision,因此该结果不能 作为后续 Patch base;
  • 空字符串:拒绝为 400。

Revisioned Full 遇到 user-owned instance ID 冲突时整体失败。Legacy unrevisioned Full 保持当前兼容行为:跳过 user-owned row,且绝不覆盖它。

4.2 新增 Patch

同一资源增加:

PATCH /api/internal/users/:user-id/machines/:machine-id/networks

请求格式:

{
  "upserts": [
    {
      "instance_id": "11111111-1111-1111-1111-111111111111",
      "network_config": {}
    }
  ],
  "delete_instance_ids": [
    "22222222-2222-2222-2222-222222222222"
  ],
  "config_revision": "target-revision",
  "expected_config_revision": "base-revision"
}

Patch contract:

  1. 两个 revision 字段均必填、非空且不能相同。
  2. upserts 中的 instance ID 不得重复。
  3. delete_instance_ids 中的 ID 不得重复。
  4. 同一个 ID 不得同时出现在 upsert 和 delete 中。
  5. 每个 upsert 必须携带该实例的完整 NetworkConfig,不支持字段级 JSON Patch。
  6. network_config 内部的 instance ID 不受信任,receiver 使用 envelope 中的 instance_id 进行归一化。
  7. 删除不存在的 ID 是幂等 no-op。
  8. Upsert 或 delete 碰到 user-owned row 时,整个 Patch 返回冲突且不写入。
  9. 不允许从“receiver revision 不存在”的未知状态直接 Patch;使用 Full 建立 Exact Set 和首个 revision。
  10. 空 Patch 不能把 revision 改成另一个值;这通常表示 publisher revision 计算错误,因此返回 400。

4.3 HTTP outcomes

条件 HTTP 语义
Full/Patch 新提交成功 204 Config 和 revision 已持久化
Target revision 已经存在 204 幂等成功,无 row mutation
Expected revision 不匹配 409 零写入,调用者重新观察或发送 Full
User-owned ownership 冲突 409 零写入,不能自动覆盖
非法 ID、重复、交集或非法 config 400 调用 contract 错误
请求超过 byte limit 413 未进入 reconciliation
条目数或单 config 超过限制 422 超出接收端容量 contract
SQLite 错误 500 Transaction rollback

409 返回机器可读字段:revision 冲突为 code=managed_config_revision_conflict 并在已知时带 current_config_revision;ownership 冲突为 code=managed_config_ownership_conflict。响应不得返回配置内容。日志不得记录 token、secret 或完整 config JSON。

5. Receiver architecture

5.1 Module responsibilities

Module 本阶段职责
Internal HTTP Adapter 内部鉴权、body/count limit、DTO 解析、HTTP 状态映射
ClientManager 解析 Target,调用 managed-config Interface,成功后通知 Session
client_manager::managed_config Full/Patch 规则、归一化、typed outcome
Db Adapter CAS、ownership fence、批量 mutation、revision transaction
Session runtime reconciliation 校验 applied/base/target fence,增量收敛 touched instances;断链时 Full

HTTP Adapter 不实现 ownership、diff 或 transaction 逻辑。PUT 和 PATCH 共用 managed-config Module,避免两套规则逐渐分叉。

5.2 Internal Interface

Module 接收两种 intent:

Full {
  desired_configs,
  target_revision: Option<Revision>,
  expected_revision: Any | Exact(Option<Revision>)
}

Patch {
  upserts,
  delete_instance_ids,
  target_revision: Revision,
  expected_revision: Revision
}

返回 typed outcome:

Applied {
  previous_revision,
  target_revision
}

AlreadyApplied {
  target_revision
}

RevisionConflict {
  expected_revision,
  current_revision
}

OwnershipConflict {
  instance_id
}

Validation error 与 database error 保持独立类型。HTTP handler 只负责将这些结果 映射到 section 4.3 的状态码。

6. Receiver implementation

6.1 Validation and normalization

在打开 SQLite write transaction 之前完成:

  • request byte/count/per-entry limit;
  • UUID、重复 ID 和 upsert/delete 交集校验;
  • config key 拼写归一化;
  • envelope instance ID 覆盖 nested identity;
  • NetworkConfig 反序列化。

这样非法大请求不会长时间占用 SQLite writer lock。Ownership 必须在 transaction 内重新查询,因为 transaction 外的结果可能已过期。

当 request 带 target revision 时,可以先做一次 O(1) revision read;如果当前值 已经等于 target,可直接返回 AlreadyApplied,避免完整 config 归一化。任何可能 写入的请求仍必须在 transaction 内再次检查 revision。

6.2 Full transaction

在同一个 SQLite connection 上执行:

  1. BEGIN IMMEDIATE。
  2. 读取 (user_id, machine_id) 当前 persisted revision。
  3. 若 supplied target 已经是 current,返回 AlreadyApplied。
  4. 检查 optional expected revision。
  5. 只读取现有 row 的 (instance_id, source);不加载无关 config JSON。
  6. 执行 user-owned ownership fence。
  7. 批量 upsert 全部 desired web-owned rows。
  8. 计算并批量删除 existing_web_ids - desired_ids。
  9. 最后写入 supplied target revision;legacy unrevisioned Full 则删除旧 revision。
  10. Commit。

任一步骤失败都 rollback。Full 仍是 O(total),但不会再逐条独立提交,也不会 出现“部分 rows 已更新、revision 仍是旧值”的中间持久状态。

6.3 Patch transaction

在同一个 SQLite connection 上执行:

  1. BEGIN IMMEDIATE。
  2. 读取 current revision。
  3. 如果 current 等于 target,返回 AlreadyApplied。
  4. 如果 current 不等于 expected,返回 RevisionConflict。
  5. 只查询 upsert/delete IDs 的 source。
  6. 任一 touched ID 属于 user 时,返回 OwnershipConflict。
  7. 批量 upsert changed configs。
  8. 批量删除 requested web-owned IDs。
  9. 最后写入 target revision。
  10. Commit。

Patch 禁止:

  • list 全部 Target rows;
  • 重算完整 Target digest;
  • 根据 touched IDs 之外的数据做 stale-row scan。

因此其数据库工作量只随 upserts + deletes 增长。

6.4 Bounded batch SQL

批量操作不构造无限长 SQL。根据 SQLite bind-variable limit 选取固定 batch size, 并在同一个 transaction 内分批执行:

  • multi-row INSERT ... ON CONFLICT DO UPDATE;
  • 带 source = web 条件的 batch delete;
  • 只返回 instance ID/source 的 ownership query。

Patch statement 数量应为 O(ceil(delta / batch_size));Full 为 O(ceil(total / batch_size))。每个 accepted request 只有一个 transaction 和 一次 revision 写入。

6.5 Alternate-write revision invalidation

现有其他路径可能 save、delete、disable 或改变 web-owned row。若这些路径修改 rows 后仍保留旧 managed revision,Patch CAS 会把错误状态当作正确 base。

因此所有 config mutation Adapter 必须遵守:

  1. 判断 mutation 是否改变 web-owned row;
  2. 在一个 transaction 中执行 mutation;
  3. 在 commit 前删除该 Target 的 managed revision。

Managed Full/Patch 在同一 transaction 内先完成 mutation,最后写入新的 target revision。只影响 user-owned rows 的操作不清除 managed revision。

本方案不在 /validate-token 读取 revision 时重算完整 digest,否则周期性验证会 重新变成 O(total)。Revision 完整性由所有写入 Adapter 局部维护。

6.6 Locking, cancellation and notification

现有 per-target process-local lock 可以保留,用于减少同进程的重复工作,但它不 承担正确性。正确性由 SQLite transaction 和 CAS 提供。

  • Transaction 内不执行 Session RPC、网络请求或无关 async 工作。
  • HTTP future 在 commit 前取消时,transaction drop 必须 rollback。
  • Commit 后即使 response 或 notification 丢失,persisted state 仍然有效;调用者 用同一 target retry 会得到幂等成功。
  • 只有带 target revision 的 Applied 才通知匹配的 live Session; AlreadyApplied、legacy unrevisioned Full、conflict 和失败不重复通知。
  • Notification 必须发生在 commit 之后。
  • Full notification 将 pending reconcile hint 提升为 Full,触发完整收敛。
  • Patch notification 携带 expected revision、target revision、upsert IDs 和本次 transaction 实际接受删除的 web-owned IDs。请求删除但数据库原本不存在的 ID 仍是 no-op,不能借机删除 Core 中同 ID 的 user-owned 实例。
  • Session 将尚未应用的 Patch touched IDs 合并为一个 Dirty set,并始终以 SQLite 最新 revision 下的 rows 为准。它不重放历史 Patch,也不维护 Patch queue 或 delivery FSM。只有 incoming expected 等于 pending target 的通知才能合并;Dirty hint 保留最早 expected 和最新 target。乱序、不连续或无法证明顺序的通知将 hint 提升为 Full。多个连续 Patch 积压时,旧 round 由 runtime epoch 拦截,下一 round 直接收敛到最新 target。
  • Session 分开记录对外报告的 applied revision 和内部可信的 runtime base。开始任何 runtime side effect 前清除 applied;Patch round 的 side effects 完全包含在 Dirty set 中,因此失败或被新通知拦截时仍保留最早 runtime base,以便按最新持久化状态 重试 Dirty set。Full round、direct mutation、授权失败或 Session ownership 中断会 清除 runtime base。
  • 只有可信 runtime base 等于 Dirty 最早 expected,并且 SQLite persisted revision 等于 Dirty 最新 target 时,才允许增量 round。重连后 runtime base 未知、通知 丢失,或 SQLite 已经提交了更靠后的 revision 而通知尚未送达时都回退 Full,避免 不完整的 Dirty set 把完整 target revision 误标为已应用。
  • 增量 round 逐个读取 Dirty set 中的最新 row。仍然存在且启用的 web-owned row 使用其最新 config;已经删除的 row 进入 delete set;遇到 disabled 或非 web-owned row 时回退 Full,以保留 ownership 规则。完成前再次校验 persisted target revision;只有全部 touched instances 成功且 target 仍相同,才推进 applied revision。
  • 任何通过 EasyTier Web mutation route 直接 Run、Save、Delete 或切换实例状态的 操作在执行前和结束后(包括部分 side effect 后返回错误)都清除 Session applied revision、可信 runtime base 与 pending hint,增加运行配置 cache epoch,并唤醒一次 Full reconcile。旧 round 只有 epoch 仍匹配时才能推进 applied revision;新一轮不得 信任 mutation 前缓存的 runtime config。否则 runtime-only mutation 或 Core 成功、 SQLite 失败的复合 mutation 可能在 persisted revision 不变时破坏 Patch base 的 完整性。

7. Capacity contract

当前 route 没有显式 body limit,Axum Json 使用依赖版本的默认 2 MiB 限制。 生产容量不应依赖框架隐式默认值。

本阶段定义并测试四个独立限制:

  • decoded request 最大 bytes;
  • Full entries / Patch upserts 最大数量;
  • Patch deletes 最大数量;
  • 单个 network_config 最大 bytes。

限制只应用于 internal managed-config route,不提高其他 public route 的 limit。 具体默认值不能拍脑袋确定:先采集 1k/10k representative configs 的 encoded size 和 peak memory,再选择有明确 headroom 的默认值及硬上限。

提高 Full limit 只是确保 fallback 覆盖已支持的生产规模,不是稳态优化。请求压缩 同样只能降低 wire bytes,不能降低 JSON materialization 和 SQLite 工作量,因此 不作为 Patch 的前置条件。

8. Failure and recovery

Failure Receiver state Caller action
Invalid payload Unchanged 修复请求,不重试相同 payload
Capacity exceeded Unchanged 使用较小 Patch;Full 需检查支持规模
Revision conflict Unchanged 重新观察;有 base 时重算 Patch,否则 Full
Ownership conflict Unchanged 解决 ownership,不能自动覆盖
SQLite error before commit Rolled back 从相同 observed revision 重试
Response lost after commit Target committed 同一 target retry,幂等成功
Process exits before revisioned Session notify Target committed 现有 revision reconciliation 恢复
Alternate web-row mutation Revision atomically cleared 下一次观察触发 Full 修复
Console cache loss Receiver unchanged Console 发布 Full

Receiver 不保存 Patch delivery ledger。Publisher 根据自己的完整目标和 receiver 当前 revision 重算 Patch 或选择 Full。

9. Rollout and rollback

9.1 Receiver-first rollout

  1. 为现有 Full 行为增加 characterization tests。
  2. 将 Full rows/revision 改为一个 atomic transaction。
  3. 为 alternate web-row mutation 增加 revision invalidation。
  4. 增加 PATCH、typed conflict、capacity limits 和 metrics。
  5. 在 Console 仍只发送 PUT 时部署到全部 EasyTier Web 实例。
  6. 完成旧 Console PUT、新 Console PUT/PATCH contract 测试。
  7. 最后启用 Console Patch 发布。

Patch capability 不通过 /validate-token 协商。部署顺序就是 compatibility gate; 这样不会把配置能力重新耦合回鉴权 Interface。

Console 遇到 409 可以 re-observe 后发送 Full。它不能把 404、401 或 malformed response 当作旧 receiver 并静默换一种 mutation contract;出现 404 表示接收端 部署门禁未满足。

9.2 Rollback

  • Console 尚未发送 Patch 时,EasyTier Web 可正常回滚。
  • Console 已发送 Patch 后,先回滚 Console,使调用恢复为 PUT,再回滚 Web。
  • PUT 在整个发布周期保持兼容。
  • Patch 和 Full 写入相同 rows/revision,不需要格式级数据迁移。

本方案不新增 persistent table。现有 Target/instance unique index 应覆盖 touched-ID 查询;若实现时需要新 index,必须先用实际 SQLite query plan 证明。

10. Verification

10.1 Contract tests

  • 现有 Full JSON 继续接受。
  • 空 Full 删除所有 web-owned rows,保留 user-owned rows。
  • Patch add/update/delete 与等价 Full 得到相同最终 projection。
  • Duplicate/overlap/invalid config 返回 400 且零写入。
  • Patch 缺少 revision 返回 400。
  • Revision conflict 返回 409 和 current revision,不返回 config。
  • Byte/count/per-entry limits 分别有确定性测试。

10.2 Transaction and ownership tests

  • 在 upsert 后、delete 后、revision write 前注入错误,rows/revision 全部 rollback。
  • Revisioned Full/Patch 的 user-owned collision 整体 rollback。
  • 删除不存在的 ID 幂等成功。
  • 两个 target 从同一 base 并发时,一个成功、一个 409。
  • 相同 target retry 只有第一次写入,第二次为 no-op success。
  • Alternate save/delete/disable web row 与 revision invalidation 原子提交。
  • User-owned-only mutation 不清除 managed revision。
  • 数据库重连后,任一 persisted revision 都对应完整一致的 rows。

10.3 Scale tests

至少使用 1k 和 10k representative entries:

  • 单实例 Patch 的 decoded bytes、row reads、writes 和 statement count 不随 Target 总实例数增长;
  • Patch 不执行 list-all query;
  • Full 使用 bounded batches 和一个 transaction;
  • Revision read 保持 O(1);
  • 超限 Full 稳定返回 413/422,而不是耗尽进程内存;
  • 并发请求无 deadlock,且 CAS 结果确定。

Session 测试还必须验证:连续 Patch 的 Dirty IDs 会合并且保留最早 expected;未知 或不匹配的 runtime base、乱序/不连续通知使用 Full;增量 round 读取最新 row;已经 删除的 web-owned row 只删除对应 Dirty ID;Full notification 覆盖 Dirty hint;目标 revision 已变化或 touched runtime apply 失败时不推进 applied revision;直接 runtime mutation 使 revision 与运行配置 cache 同时失效。运行态 Config Get/Patch/Run/Delete 数量应随 touched instances 增长。为确认运行实例身份而进行的一次 list/meta RPC 可以保留,它不发送或重写所有实例配置。

11. Observability

每个请求记录结构化字段,但不记录 config 内容:

  • mode:full / patch;
  • user/machine scope;
  • request bytes;
  • desired/upsert/delete count;
  • normalization、target-lock wait、transaction duration;
  • SQL statement/batch count;
  • result:applied、already-applied、revision-conflict、ownership-conflict、 invalid、oversized、database-error;
  • Session notification 是否发送。

Rollout acceptance:

  • Console 启用后 Patch 占普通变更的绝大多数;
  • 单实例变化的 request size 与 SQLite cost 与单实例成比例;
  • conflict rate 可解释且稳定;
  • 支持规模内的 Full 没有 413/422;
  • validate-token latency 不随 Target 实例数增长。

12. 后续优化

12.1 Session runtime delta apply(已实现)

Patch commit outcome 已携带 expected、target 和 transaction 实际 touched IDs。 Session 只合并 expected/target 连续的 Dirty IDs,并在每一轮从 SQLite 读取最新 target revision 对应的当前 rows;因此正常积压只增加 Dirty set,不需要保留中间 revision 的 Patch queue。可信 runtime base 必须等于 Dirty 最早 expected;Patch side effect 失败可保留该 base 重试,通知丢失、乱序、进程重启或新 Session 尚无 runtime base 时回退 Full。Full notification、disabled row 或 ownership 无法证明时也回退 Full。

12.2 Chunked Full

不能把 Full Exact Set 直接分页写入 live rows:接收端无法在中间页判断哪些旧 实例最终应删除,crash 也会暴露半套目标。

如果测量证明单请求 Full 无法覆盖必须支持的冷恢复规模,需要单独设计带 snapshot ID、staging rows、expiry、finalize 和 atomic swap 的协议。在出现数据 证明前不新增该状态机。

13. Implementation files and checklist

主要涉及:

  • easytier-web/src/restful/network.rs
  • easytier-web/src/client_manager/mod.rs
  • easytier-web/src/client_manager/managed_config.rs
  • easytier-web/src/db/mod.rs
  • 对应 contract、database 和 managed-config tests

完成条件:

  • 现有 Full compatibility tests 固定。
  • Full config rows 与 revision 原子提交。
  • Alternate web-owned mutations 原子清除 revision。
  • PATCH contract 和 typed 409 实现。
  • Patch 只查询、写入 touched IDs。
  • Bulk SQL 遵守 tested bind-count bound。
  • Route byte/count/per-entry limits 有文档和测试。
  • User-owned rows 不能被 Full/Patch 覆盖或删除。
  • Empty Full 语义保持。
  • Applied/AlreadyApplied/conflict 的通知行为符合设计。
  • Session 在 revision 连续时只收敛 touched instances,断链时使用 Full。
  • 1k/10k scale 与 concurrent CAS tests 通过。
  • Receiver-first compatibility matrix 通过。