插件认证与安装的写入协调
状态:公共 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 防止误释放后继锁;
不得将已经提交的写入误报为失败。
部署与回滚
- 停止并排空所有旧版插件写入口,包括后台认证、设备同步和 OAuth 回调,避免新旧协议混用。
- 检查历史策略的完整身份和数据。重复策略须先核对偏好、引用与版本,不得静默选择或删除。
- 本次不包含 Alembic 迁移,迁移链与 main 一致,不需要为插件改造执行数据库升级。 仅曾运行本 PR 旧版本的测试库需要在切换代码前手工清理旧索引,并将旧分支版本号 恢复到其 main 父版本。此类一次性测试库清理由维护者执行,不纳入正式迁移链。
- 确认 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 断言保持不变。