跳到主要内容

插件认证与安装的写入协调

状态:公共 Redis 锁与业务 revision CAS 的简化方案已实现,本轮本地回归通过,尚未部署。

认证、退出登录、安装、重装、更新、卸载及设备安装状态写入使用 Redis 互斥, 不再通过 SELECT ... FOR UPDATE 锁住 users.id。发布和设备注册的锁策略不在本次改造范围内。 本协议不新增表、列或索引,不修改用户创建、注册或 OIDC 流程,也不要求预建内部协调记录。

公共 Redis 锁​

Python 复用 app/core/distributed_lock.py 的严格所有权接口:

distributed_lock.owned_lock(
f"wegent:plugin-write:{user_id}",
expire_seconds=30,
blocking_timeout=3,
redis_client=plugin_redis_client,
)

DistributedLock.owned_lock 接收完整 key,返回 redis-py Lock,设置 thread_local=False, 允许注入插件使用的短超时 Redis 客户端。客户端不可用时抛出 DistributedLockUnavailableError; 插件将 Redis 故障转换为明确错误,不能无锁放行。旧 acquire、release 和 watchdog 调用方保持原有行为。

Python 与已迁移的 Rust 插件写入口使用相同协议:

  • key 为 wegent:plugin-write:{user_id},每次持有使用唯一 token;Python 由公共锁管理持有者状态。
  • 争锁最多等待 3 秒,TTL 为 30 秒。Python 每 10 秒续租;两端均在校验点检测到操作超过 20 秒时拒绝提交,Rust 复用既有 Redis 客户端,使用有界租约而不启动续租任务。
  • 同一用户的插件操作串行,涵盖自动认证偏好、连接迁移及安装状态的跨插件约束。 同一事务需要多个用户时按用户 ID 升序获取锁。批量撤权及删除个人插件在修改业务状态前 获取所有受影响用户的锁,共享一次 3 秒协调预算,不逐用户重新计时;已有租约的剩余 操作时间也限制该预算。获取失败时整个事务回滚并释放已取得的锁,不保留部分撤权。 策略读取计入预算,但同步 I/O 无法中途打断;耗尽预算后不再开始业务修改。 撤权和删除不按安装记录数量设置业务上限;仍受上述协调与操作时间预算约束, 超时或冲突时整笔事务回滚,保留原有授权和安装状态,调用方可显式重试。
  • 续租、所有权检查和释放必须比较 token,旧请求不得释放后继请求的锁。
  • Rust 每次发送争锁 SET 前检查截止时间;最后一次等待耗尽预算后直接返回 plugin_write_busy,避免超时请求仍写入新租约并阻塞后续登录。
  • 拿锁后重新读取策略、安装和连接等业务状态,不沿用等待 Redis 之前的旧状态。
  • 锁保持到最外层数据库事务提交、回滚或关闭;保存点结束不会释放锁。

协调诊断​

排查 plugin_write_busy 时,使用 Backend 的租约获取、释放和争锁失败日志,按 user_id、进程内 session_id 和 native operation 对齐时序。释放日志包含 held_ms,可区分事务持续持锁与故障租约造成的竞争;日志不记录 Redis 所有权 token 或插件凭据。争锁失败仍按原有预算返回,不自动重试或延长超时。 初始化失败的释放和最外层事务结束的释放共用同一日志路径,均记录持锁时长。 桌面 E2E 的 runner 和 CI 清理均保留 backend-rs-logs,以便核对共享租约的 Rust 写入口请求,避免上传诊断前丢失网关日志。

业务版本与策略首建​

Redis 负责正常互斥,已有业务记录的 revision CAS 检查并发变更。 每笔插件写事务先读取当前用户真实的账户级自动认证偏好,再读取依赖它的安装、连接和迁移状态:

kinds
user_id = 当前用户
kind = ConnectorAuthPolicy
namespace = plugin-auth
name = account
json.spec = { enabled: 用户偏好, revision: 并发版本 }

查询始终包含完整身份,并包含 inactive 记录。拿到 Redis 锁后重新查询; 缺失时在独立短事务中创建默认 enabled=true, revision=0 的真实策略。 该短事务提交后,主业务事务重新读取策略版本,再读取依赖它的业务状态。 策略不依赖用户注册预建;初始化成功后业务失败可以留下默认策略,保留原有缺省启用语义。 已有策略保留偏好和版本;恢复 inactive 策略也不重置版本。 已有重复策略或非法版本时拒绝操作,不能任取一条、覆盖禁用选择或自动合并。

业务事务末尾以策略主键、完整身份及旧 revision 为条件,原子递增 revision; 受影响行必须为 1,否则整个业务事务回滚。安装、连接及迁移等变更与共同父 CAS 一起提交或回滚。 安装和账号连接记录自身继续使用 revision CAS;插件版本号 version 不承担并发校验。 旧记录缺少 revision 时按 0 读取,在正常更新时推进,不执行批量版本回填。 策略保留零安装时的偏好,卸载不得删除它。

首次创建策略尚无共同数据库记录可做 CAS,防重完全依赖 Redis 正常互斥。 本方案没有业务唯一索引,也不使用额外的分片记录或初始化脚本。 进程长时间暂停、TTL 失效等极端重叠情况下,不能承诺首次创建严格唯一; 此时可能出现重复策略,后续请求必须明确失败并由维护人员核对处理。 因此不能把已有记录的 CAS 保护推广为“所有首建都具备数据库唯一保障”。

事务与故障边界​

Python 的请求 Session 在第一条 SQL 前调用 configure_plugin_session, HTTP 使用共享 get_plugin_db 依赖,以 READ COMMITTED 读取拿锁后的最新状态,并刷新 ORM 缓存。 Rust 在目录预加载之前保存策略版本,在固定连接的业务事务开始时重查,提交时再做 CAS, 避免将旧目录状态带入新版本。OAuth 和设备网络请求放在写事务之外,写回时重新校验当前业务状态。 保存点回滚恢复预期版本,只有根事务结束才释放锁。

Rust 自动更新批次在独立的 Tokio 任务内执行已有的限时操作,并继承请求追踪上下文。 HTTP 客户端断开或桌面重启不会取消已开始的事务与释放流程;操作仍在原有预算内完成, 在事务提交或回滚后按 token 释放租约,避免后续认证或设备核对等待残留租约的 TTL。 取消回归测试同时校验事务完成前仍持锁、完成后立即释放,以及后继操作可重新取得锁。

对于引用同一策略记录的事务,CAS 保护提交次序:后继事务成功提交后,旧版本事务不能覆盖它。 若旧进程在 Redis 所有权检查后暂停,恢复后抢在后继提交前完成 CAS,它仍可能先提交; 不能保证所有租约过期请求绝不提交。冲突方必须回滚整个业务事务,再读取并重新评估; 不得盲目重放 OAuth 等外部副作用。

设备上线时,插件准备阶段的协调失败只推迟该轮插件同步。失败事务先回滚,随后使用已有的 merge 模式仅发送 Skills/MCP,不读取或发送插件,也不将该轮结果记为插件安装确认。 这样不会因缺少插件列表而删除本地插件;该轮也不删除缺失的 Skills/MCP, 由后续成功的全量同步收敛。正常路径继续进行全量替换和插件状态写回。 插件准备和确认结果持久化在线程池内执行,各自在线程内创建并关闭 Session, 避免同步 Redis 和数据库等待阻塞 WebSocket 事件循环。

正常 INSERT、UPDATE、DELETE 仍有 InnoDB 事务锁,按主键 CAS 也可能等待其他事务。 本次改造解除的是与其他业务对 users 行的显式互斥争用,不能保证所有数据库写入都不等待。

客户端错误使用 HTTP 503 的 plugin_write_busy、plugin_write_unavailable、 plugin_write_lease_lost 或 plugin_write_conflict;认证 broker 返回相同拒绝码。 Python 的插件 Redis 客户端使用独立短超时且不重试;Rust 继续使用既有 Redis 客户端和同一锁协议。 数据库提交成功后若释放失败,仅记录错误,由 token 校验和 TTL 防止误释放后继锁; 不得将已经提交的写入误报为失败。

部署与回滚​

  1. 停止并排空所有旧版插件写入口,包括后台认证、设备同步和 OAuth 回调,避免新旧协议混用。
  2. 检查历史策略的完整身份和数据。重复策略须先核对偏好、引用与版本,不得静默选择或删除。
  3. 本次不包含 Alembic 迁移,迁移链与 main 一致,不需要为插件改造执行数据库升级。 仅曾运行本 PR 旧版本的测试库需要在切换代码前手工清理旧索引,并将旧分支版本号 恢复到其 main 父版本。此类一次性测试库清理由维护者执行,不纳入正式迁移链。
  4. 确认 Redis 可用,Python 和实际生效的 Rust 写入口使用相同 key、TTL 和业务版本协议,再开放插件写入。

无需执行协调记录初始化,也不批量修改旧记录的 revision。 直接 SQL 与维护脚本不得在在线流量期间绕过插件锁修改同一份业务状态。 线上 wecode/cache/kinds.py 扩展不在本仓库,部署前仍需确认其对现有认证 Kind 的处理不会阻断提交。

回滚前同样停止并排空插件写入,确认目标版本能够在没有策略唯一索引的环境工作。 移除索引后不能直接回退到依赖该索引防重的版本。客户端接口保持原协议,本次变更不要求发布客户端。

回归验证​

插件、公共锁、API 和模型兼容测试,以及真实 MySQL/Redis 并发回归均已有本地验证。 Rust 2789 项单元测试、1 项 doctest、格式检查及 Clippy 全部通过。 本轮未运行桌面 E2E,最新 CI 结果以 PR Checks 为准。真实 MySQL、Redis 并发回归接入 test-plugin-auth-mysql CI, 桌面场景沿用现有隔离数据库和 Alembic 迁移,无额外协调数据初始化步骤。

重点验证安装、卸载、认证、退出及版本更新;同用户争锁、不同用户隔离; 拿锁后重查安装和策略;提交、回滚及保存点的锁生命周期;token 校验释放; Redis 不可用时拒绝写入;已有记录的旧版本 CAS 拒绝;缺失 revision 默认 0; 零安装时的禁用偏好、重复策略明确失败,以及卸载后 OAuth 收尾。 仅锁住目标 users 行、其他插件资源及依赖正常时,插件主流程应能完成; 插件业务行本身的数据库锁等待仍按事务规则处理。首次策略创建的极端租约失效重叠场景按上述边界评估。

新增的两个桌面 checkpoint 均独立准备插件与设备数据,并进入现有 Cloud CI 第 20 分片:

checkpoint前置条件与步骤预期结果与恢复
plugin-account-auth-coordination真实 Electron、Executor、MySQL/Redis;锁住用户行后登录并退出;分别持有插件租约、拒绝 Redis SET 后重试认证和退出;在适配器读出凭证后暂停导出并退出登录、凭证登记和退出在 15 秒内完成;故障返回明确错误且账户状态不变;解除故障后显式重试成功;延迟登记结束后凭证为空、设备授权为空且仍需登录
plugin-registration-coordination实际安装插件和旧 Skills/MCP;停用旧能力并添加新能力;持有插件租约重启真实 Executor;释放后再次重启首轮只合并新 Skills/MCP,保留本地插件和旧能力,不写插件安装确认;恢复后全量替换移除旧能力,插件安装状态收敛

故障 helper 在 finally 中回滚用户行锁、恢复隔离 Redis 的 SET 权限或释放租约; 凭证导出栅栏同样在 finally 中清理。用例仅输出状态、摘要或版本信息,不记录凭证。 本轮已通过 152 项后端定向回归;新增桌面 checkpoint 尚未在本地执行。

重装响应与并发清理 ACK​

旧卸载清理可能在重装已提交后重读最新期望状态,并发送新的安装快照。此时较早的重装 RPC 即使返回成功,也可能因新的 generation 意图而无法写入设备状态。接口不能将这次已被替代的 ACK 当成安装完成,否则响应会同时包含同步成功和 uninstalling 设备行。

请求设备的同步现在在持久化 ACK 后检查设备状态与 generation watermark。只有设备尚未收敛、且 ACK 已被更高 generation 替代时,才重新同步当前期望状态,沿用现有三次收敛预算;不重试真实设备失败,不放宽旧 ACK 的栅栏。持续被替代时返回 plugin_sync_superseded 的辅助同步错误,保留已提交的账号安装与后续对账来源。API 回归覆盖单次替代后的完成响应和预算耗尽,原有 plugin-write-coordination E2E 断言保持不变。