From b4e18c6f0cbf4d209c28b6a2486229779fb9e4e9 Mon Sep 17 00:00:00 2001 From: Zhan Ziyang Date: Sat, 15 Aug 2026 02:30:36 +0800 Subject: [PATCH] feat: transaction implement --- UPDATE_PLAN.md | 1663 ++++++++++++++++++ go.mod | 11 + go.sum | 10 + integration/transaction_kernel_linux_test.go | 182 ++ internal/filestore/store.go | 213 +++ internal/filestore/store_test.go | 111 ++ internal/logging/logger.go | 52 + internal/logging/logger_test.go | 35 + internal/processlock/lock.go | 10 + internal/processlock/lock_linux.go | 84 + internal/processlock/lock_linux_test.go | 40 + internal/processlock/lock_unsupported.go | 16 + internal/transaction/coordinator.go | 169 ++ internal/transaction/coordinator_test.go | 154 ++ internal/transaction/errors.go | 26 + internal/transaction/model.go | 69 + internal/transaction/state.go | 79 + internal/transaction/state_test.go | 34 + internal/transaction/store.go | 704 ++++++++ internal/transaction/store_test.go | 220 +++ main.go | 4 + 21 files changed, 3886 insertions(+) create mode 100644 UPDATE_PLAN.md create mode 100644 go.mod create mode 100644 go.sum create mode 100644 integration/transaction_kernel_linux_test.go create mode 100644 internal/filestore/store.go create mode 100644 internal/filestore/store_test.go create mode 100644 internal/logging/logger.go create mode 100644 internal/logging/logger_test.go create mode 100644 internal/processlock/lock.go create mode 100644 internal/processlock/lock_linux.go create mode 100644 internal/processlock/lock_linux_test.go create mode 100644 internal/processlock/lock_unsupported.go create mode 100644 internal/transaction/coordinator.go create mode 100644 internal/transaction/coordinator_test.go create mode 100644 internal/transaction/errors.go create mode 100644 internal/transaction/model.go create mode 100644 internal/transaction/state.go create mode 100644 internal/transaction/state_test.go create mode 100644 internal/transaction/store.go create mode 100644 internal/transaction/store_test.go create mode 100644 main.go diff --git a/UPDATE_PLAN.md b/UPDATE_PLAN.md new file mode 100644 index 0000000..b53131d --- /dev/null +++ b/UPDATE_PLAN.md @@ -0,0 +1,1663 @@ +# YMS Daemon 更新体系设计与实施计划 + +> 状态:研讨中,核心方向已收敛 +> 当前阶段:冻结 native/container 混合过渡、Docker standalone、OpenResty 蓝绿切换、客户 PC 交付工作台、离线包协议与 Jenkins 高频更新语义 +> 本文档只描述设计和实施计划。除本文档外,现网代码、脚本、systemd、Nginx 和容器均未修改。 + +## 1. 目标 + +使用独立 Go 程序 `yms-daemon` 替换客户服务器上现有更新脚本,统一处理: + +- 离线更新包接收、验签、校验和暂存。 +- `backend`、`frontend`、`nodeSsr` 三个组件的独立更新与整体更新。 +- 过渡阶段的 native、container 和混合部署。 +- Docker standalone 环境下的零停机蓝绿更新。 +- 更新失败后的自动回切和人工回滚。 +- 开发环境的高频自动更新。 +- 客户 PC 按客户查询 repack 版本、轮询新包、下载、断点续传、服务器交付、更新触发和状态展示。 +- 后续分布式主机与 Kubernetes 部署执行器。 + +这套更新体系必须同时服务客户现场和开发环境,不能再出现: + +```text +开发环境一套快捷脚本 +客户现场另一套交付脚本 +``` + +container 目标链路是: + +```text +CI 构建一次镜像 + -> 记录 multi-platform image index digest 和每个平台 manifest digest + -> 开发环境验证其实际平台 manifest digest + -> 发布中台归档同一次构建的全部平台 digest + -> 客户离线包交付客户平台对应的原始 digest +``` + +native 过渡链路中,backend 保持同一次 CI 生成的 JAR 及其 SHA-256。frontend 保持同一次 CI 生成的源归档及其 SHA-256;当前 repack 会把该归档展开到客户 ZIP 的 `dist/`,因此客户侧最终以 manifest 逐文件声明的 `dist/...` 路径、长度和 SHA-256 为安装身份。 + +## 2. 已确认的精确事实 + +### 2.1 仓库与组件 + +- 主业务系统:`ymswell` +- 前端:`YMSwellClient` +- 发布中台:`deploy` +- 更新助手:`yms-daemon` + +业务组件标识固定为: + +- `backend` +- `frontend` +- `nodeSsr` + +发布归档中的产物类型为: + +- `BACKEND` +- `FRONTEND` +- `NODE_SSR` + +不对上述标识进行大小写转换、别名匹配或模糊匹配。 + +### 2.2 当前发布包 + +当前 repack ZIP 根目录包含 `artifact-selection.json`。现有字段包括: + +- `customerCode` +- `customerDisplayName` +- `versionId` +- `items` +- `backendArtifacts` +- `frontendArtifacts` +- `nodeSsrArtifacts` +- `remark` + +现有 manifest 尚未完整描述 ZIP 中 native 文件的最终路径、长度和 SHA-256,也未完整描述 container 归档的最终路径、长度、SHA-256 和 OCI digest,因此不能直接作为 daemon 的最终部署协议。 + +### 2.3 当前容器制品基础 + +发布归档已经管理以下五类制品: + +- `BACKEND/native` +- `BACKEND/container` +- `FRONTEND/native` +- `FRONTEND/container` +- `NODE_SSR/container` + +当前发布中台要求的容器平台包括: + +- `linux/amd64` +- `linux/arm64` + +repack 当前通过以下步骤生成离线镜像归档: + +```text +docker pull --platform +docker image save --platform ... +``` + +### 2.4 当前前端镜像 + +`YMSwellClient/scripts/deploy/Dockerfile` 已经执行: + +```dockerfile +COPY apps/web-antd/dist /usr/share/nginx/html +``` + +因此容器模式下: + +- `dist` 在 CI 构建阶段进入 frontend 镜像。 +- 客户服务器不再接收和覆盖独立 `dist/` 目录。 +- daemon 不逐文件安装前端资源。 +- frontend 镜像 digest 覆盖静态资源和静态服务配置。 + +当前 frontend 容器内 Nginx 监听 `8080`。 + +实际 `dist` 顶层目前包含: + +```text +index.html +_app.config.js +assets/ +css/ +js/ +jse/ +static/ +svg/ +templates/ +``` + +当前生产配置使用相对地址: + +```text +VITE_GLOB_API_URL=/ +VITE_GLOB_YMS_SERVICE=/yms_services +``` + +同一 frontend 镜像不需要写入客户服务器 IP。 + +### 2.5 当前运行端口和健康检查 + +从现有仓库确认的值: + +| 组件 | 容器内部端口 | 已确认健康路径 | +| --- | ---: | --- | +| `backend` | `8080` | `/yms/actuator/health` | +| `frontend` | `8080` | 尚未冻结 | +| `nodeSsr` | `8910` | `/health` | + +前端健康检查的成功状态码、响应内容以及静态资源检查规则仍需冻结。 + +### 2.6 当前数据库迁移 + +现有 `deploy/src/main/resources/deploy/env/yms.env` 包含: + +```text +YMS_FLYWAY_ENABLED=true +``` + +蓝绿期间新旧 backend 会重叠运行,Flyway 不能由两个 backend 实例无约束地并发执行。数据库迁移必须从普通 backend 启动过程里形成明确的单次执行边界。 + +上述约束同时适用于两个 native JVM 实例。Flyway 的单次执行边界不能依赖 backend 的运行类型。 + +### 2.7 当前客户 repack 查询与下载接口 + +客户 PC 查询的“版本”不是通用 release 版本列表,而是发布中台已经按客户生成的 repack ZIP。 + +`deploy` 当前已有以下接口: + +- `GET /repack/packages?customerCode=...`:按 `customerCode` 查询该客户目录下的 ZIP。 +- `POST /repack/packages/page`:按客户编码和文件名关键字分页查询。 +- `POST /repack/packages/{customerCode}/files/{fileName}/ticket`:为指定客户包生成一次性下载令牌。 +- `GET /repack/download-tickets/{ticket}`:使用下载令牌获取文件。 + +当前 `RepackPackageFileVO` 精确包含: + +- `customerCode` +- `fileName` +- `size` +- `sizeHuman` +- `lastModifiedAt` +- `manifest` + +当前包列表从客户包目录扫描 ZIP,不读取 `customer_package_record` 老表。查询和创建下载令牌需要登录认证;下载令牌接口允许使用令牌直接下载。现有下载服务已经通过测试确认支持 HTTP Range。 + +当前返回值没有整包 SHA-256。PC 下载完成后的强身份校验需要发布中台扩展整包 SHA-256,并与包列表或下载令牌响应一同返回;精确字段名在修改 `deploy` 前同时冻结。 + +## 3. 总体架构 + +daemon 内部先选择服务器已经明确登记的组件执行器,再进入相同的事务、健康检查、切流和回滚流程: + +```text +更新请求 + -> manifest 严格校验 + -> 读取本机显式部署配置 + -> 组件执行器 + ├── backend native systemd executor + ├── backend Docker executor + ├── frontend native directory executor + ├── frontend Docker executor + └── nodeSsr Docker executor + -> gateway controller + ├── 过渡期 host Nginx + └── 目标态 OpenResty container +``` + +本机部署配置必须为每个组件明确记录精确值 `native` 或 `container`。daemon 不根据文件名、目录、进程、systemd unit 或 Docker 容器自行推断运行类型。 + +当前制品事实决定第一阶段允许: + +| 组件 | `native` | `container` | +| --- | --- | --- | +| `backend` | 支持 | 支持 | +| `frontend` | 支持 | 支持 | +| `nodeSsr` | 不支持,当前没有 `NODE_SSR/native` 制品 | 支持 | + +因此合法的过渡部署可以是: + +```text +backend = native +frontend = native +nodeSsr = container +``` + +也可以在迁移完成后变为三个组件全部 `container`。 + +### 3.1 Docker standalone 目标态 + +```text +发布中台 deploy + -> 按 customerCode 提供 repack ZIP 列表 + -> 创建下载令牌并提供 Range 下载 + -> 客户 PC yms-daemon client service + -> SQLite 记录查询、下载、交付和更新任务 + -> Wails GUI 展示并接受操作 + -> 下载 repack ZIP 到 PC + -> HTTPS 可续传上传到客户服务器 daemon + -> WSS 提交更新并接收状态 + +客户服务器 +├── yms-daemon.service RPM 安装,宿主机常驻 +└── Docker Engine + ├── OpenResty gateway 稳定入口,不随业务更新替换 + ├── frontend blue/green 镜像内包含 dist 和静态 Nginx + ├── backend blue/green + └── nodeSsr blue/green +``` + +### 3.2 native/container 混合过渡态 + +```text +客户服务器 +├── yms-daemon.service +├── host Nginx 或 OpenResty gateway +├── backend native blue/green JVM + systemd +├── frontend native blue/green 版本目录 +└── Docker Engine + └── nodeSsr blue/green containers +``` + +native 兼容不是继续调用 `deploy-sync.sh`、`yms-update.sh`。native executor 仍由 Go daemon 直接完成预检、版本目录管理、systemd 操作、健康检查、gateway 切流和回滚。 + +native 到 container 必须是显式迁移事务,普通 `update` 不暗中改变组件运行类型。 + +### 3.3 开发环境 + +开发环境使用相同运行结构: + +```text +Jenkins / deploy + │ + │ 构建完成后调用 yms-daemon CLI + ▼ +开发服务器 yms-daemon + -> 同一 native/Docker executor + -> 同一 gateway controller + -> 同一健康检查 + -> 同一事务恢复 +``` + +客户现场与开发环境只允许在以下方面采用不同策略: + +- 制品传输方式。 +- 签名和审批策略。 +- 旧实例或旧容器保留时长。 +- 自动更新触发策略。 +- 日志详细程度。 + +组件准备、健康检查、切流、验证和回滚实现必须完全复用。开发环境必须持续覆盖 native 和 container 两条执行路径,不能只验证目标态 Docker executor。 + +## 4. 进程和命令入口 + +RPM 全局安装单一二进制: + +```text +/usr/bin/yms-daemon +``` + +同一个二进制提供: + +```text +yms-daemon +├── serve 客户服务器或开发服务器后台常驻服务 +├── client 客户 PC 交付工作台 +├── update 发起更新事务 +├── rollback 发起人工回滚事务 +├── status 查询状态 +├── history 查询历史事务 +├── logs 查询事务日志 +└── version 查询 daemon 版本 +``` + +### 4.1 `serve` + +```bash +/usr/bin/yms-daemon serve +``` + +职责: + +- 监听本地 Unix Socket。 +- 为客户 PC 提供经过认证和授权的 HTTPS 可续传上传入口。 +- 为客户 PC 提供经过认证和授权的 WSS 控制与状态通道。 +- 串行执行更新事务。 +- 访问 Docker Engine API。 +- 通过 gateway controller 管理 host Nginx 或 OpenResty 配置和 graceful reload。 +- 执行健康检查和业务验证。 +- daemon 或服务器重启后恢复未完成事务。 + +`serve` 是唯一有权修改运行状态的进程。 + +### 4.2 `client` + +`client` 精确指安装在客户 PC 上的交付工作台,兼具离线中转能力: + +```bash +yms-daemon client +``` + +职责: + +- 使用明确登记的 `customerCode` 周期轮询发布中台已有 repack ZIP。 +- 接受 GUI 的手动刷新,立即重新查询该客户的 repack ZIP。 +- 把远端包信息、下载状态、服务器交付状态和更新结果持久化到本地 SQLite。 +- 接受“获取”操作,通过一次性下载令牌把指定 ZIP 下载到客户 PC。 +- PC 下载支持暂停、恢复、失败重试和整包校验。 +- 接受“更新”操作,把已经完整校验的 ZIP 传输到指定客户服务器。 +- 传输完成后在服务器侧触发对应的 `yms-daemon update --service ... -f ...` 请求。 +- 持续查询服务器事务状态并同步到 GUI。 +- 不修改 manifest、不重新签名,也不在客户 PC 本地执行服务器业务更新。 + +自动轮询与手动刷新进入同一个查询流程。重复查询不得重复下载、重复传输或重复发起更新;是否执行更新只能由用户点击或后续明确配置的自动策略决定。 + +PC 到服务器固定使用 daemon 提供的管理协议:HTTPS 负责 ZIP 可续传上传,WSS 负责控制请求、事务状态、进度和日志摘要。两者可以共用一个 TLS 监听入口,精确监听地址和端口尚未冻结。 + +WSS 不执行远程 Shell。PC 提交结构化更新请求,`serve` 将其转换为与本地 CLI 完全相同的内部更新请求。服务器侧业务状态只能由 `serve` 修改。 + +### 4.3 `update` + +已讨论的离线命令: + +```bash +yms-daemon update --service all -f xxx.zip +yms-daemon update --service backend -f xxx.zip +yms-daemon update --service frontend -f xxx.zip +yms-daemon update --service nodeSsr -f xxx.zip +``` + +规则: + +1. `--service` 必填。 +2. 只接受 `all`、`backend`、`frontend`、`nodeSsr`。 +3. `-f` 在离线包模式下必填。 +4. CLI 只向 `serve` 提交请求,不直接操作 Docker 或业务文件。 +5. CLI 或 SSH 连接中断不得中止服务器端事务。 +6. daemon 未运行时明确失败,不退化为 CLI 特权执行。 + +客户 PC 点击“更新”后,通过 WSS 在服务器形成与上述 CLI 完全相同的内部更新请求。ZIP 必须先通过 HTTPS 完整落到服务器暂存位置并通过校验,WSS 再引用该已验证上传提交事务;PC 断开不能中止已经提交到 `serve` 的事务。 + +开发环境由 Jenkins 构建步骤调用同一 `yms-daemon` CLI 快速触发更新。在线更新的具体参数尚未冻结;它必须进入同一个内部更新请求模型,但不强制先生成包含完整镜像层的 ZIP。 + +### 4.4 `rollback` + +```bash +yms-daemon rollback --transaction +``` + +人工回滚创建新的事务,不删除、不覆盖原更新记录。 + +## 5. 过渡运行模式与 Docker standalone + +### 5.1 显式运行类型 + +daemon 本机配置和更新包 manifest 都必须明确给出每个组件的 `type`: + +```text +native +container +``` + +执行前必须满足: + +```text +本机 services..type + == 更新包 services..type +``` + +不一致时在创建暂存文件、启动进程或装载镜像之前失败。`update --service backend` 只选择组件,不改变该组件已经登记的运行类型。 + +### 5.2 native 过渡边界 + +native 模式支持: + +- `backend`:版本化 JAR 目录、两个互不冲突的 systemd 实例和 gateway 蓝绿切流。 +- `frontend`:两个版本目录并存,由 gateway 切换当前静态资源目标。 +- `nodeSsr`:当前无 native 制品,不提供 native executor。 + +当前 `yms.service` 与 `ymsback.service` 存在 `Conflicts`,不能直接承担 native 零停机双实例。首次启用 native 零停机前必须执行独立的基础设施迁移,安装互不冲突的双实例 unit 或 systemd template,并让每个实例固定引用自己的版本目录。 + +基础设施迁移失败必须恢复现有 native unit 和 gateway 配置;不能把该迁移隐藏在普通业务更新里。 + +### 5.3 Docker standalone 宿主机边界 + +宿主机保留: + +- `yms-daemon.service` +- Docker Engine 及其 systemd 服务。 +- daemon 服务端 SQLite、详细日志和 gateway 配置目录。 + +全部组件迁移到 container 后,三个业务组件不再由独立 systemd unit 管理,业务生命周期统一交给 daemon 和 Docker Engine。native 过渡期仍保留由 daemon 管理的 backend 双实例 unit。 + +### 5.4 gateway + +gateway 使用独立 OpenResty 容器,固定占用外部业务端口。它不随 `backend`、`frontend`、`nodeSsr` 的普通更新替换。 + +请求链路是分支结构: + +```text +浏览器 + -> OpenResty gateway + ├── / 和静态资源 -> frontend 静态容器 + ├── /yms_services/ -> backend 容器 + └── 8910 -> nodeSsr 容器 +``` + +不存在以下链路: + +```text +OpenResty -> frontend Nginx -> backend +``` + +frontend 静态 Nginx 只负责: + +- 从镜像内 `/usr/share/nginx/html` 提供静态文件。 +- SPA fallback。 +- 静态缓存响应头。 +- 对不存在的资源返回可识别的真实错误。 + +backend 和 nodeSsr 由 OpenResty 直接代理。 + +### 5.5 gateway 分阶段迁移 + +为避免 native 客户在安装 daemon 时被迫同时迁移全部运行结构,gateway 分为: + +```text +第一阶段:复用现有宿主机 Nginx,由 daemon 管理配置检查、原子替换和 reload +第二阶段:显式迁移到 OpenResty gateway 容器 +第三阶段:业务组件按 customer/site 计划逐个迁移到 container +``` + +host Nginx 和 OpenResty container 必须实现同一 gateway controller 语义: + +- 生成完整下一配置。 +- 配置检查。 +- 原子替换。 +- graceful reload。 +- 恢复更新前配置。 +- 查询当前实际流量目标。 + +单机从占用业务端口的 host Nginx 切换到 OpenResty container 是独立基础设施迁移。没有外部负载均衡时,不承诺该一次性迁移绝对零中断;迁移完成后的普通业务更新必须零停机。 + +### 5.6 为什么 gateway 目标态使用 OpenResty + +- 兼容现有 Nginx 路由和超时语义。 +- 支持 SSE、gRPC-Web、大文件上传和 graceful reload。 +- 后续可使用 Lua 增加灰度、鉴权、限流、审计和静态资源兼容。 +- 官方构建支持 `amd64` 和 `arm64/aarch64`。 + +第一版 Lua 不参与核心切流事务。切流依赖经过检查的配置文件和 OpenResty graceful reload,避免同时维护文件状态、共享内存状态和内部路由状态。 + +gateway: + +- 不挂载 Docker Socket。 +- 不创建或删除业务容器。 +- 不接受更新包下发任意 Lua 文件。 +- Lua 代码只能随受控 gateway 镜像发布。 + +### 5.7 gateway 自身升级 + +gateway 自身升级不属于三个业务组件的普通更新。单机只有一个对外入口时,gateway 进程或整机故障无法提供绝对零中断。 + +gateway 自身零停机升级需要以下一种外部条件: + +- 双机入口和外部负载均衡。 +- VRRP 或等价入口漂移机制。 +- Kubernetes Service。 + +第一版只保证三个业务组件的普通更新不停止 gateway。 + +## 6. 蓝绿双槽与零停机切流 + +### 6.1 双槽模型 + +每个业务组件最多保留两个逻辑更新槽: + +```text +blue +green +``` + +任意时刻: + +- 一个槽是当前接流槽。 +- 另一个槽是待更新、待验证或上一可回退槽。 +- 不允许无限累积运行中的版本。 + +逻辑槽按运行类型落地为: + +| 组件类型 | blue/green 实体 | +| --- | --- | +| backend `native` | 两个 systemd 管理的 JVM 实例,各自固定版本化 JAR | +| frontend `native` | 两个只读版本目录 | +| backend/frontend/nodeSsr `container` | 两个 Docker 容器 | + +systemd unit、版本目录、容器、Docker network 和 label 的最终命名进入实现前单独冻结,不能从现有脚本名称推断。 + +### 6.2 流量唯一依据 + +第一版不建立独立路由数据库,不使用动态路由 shared dict,也不设置第二个持久化活动指针。 + +```text +当前 gateway 配置 = 当前流量指向的唯一依据 +systemd/Docker/目录 = 组件实际运行状态 +daemon 服务端 SQLite = 操作意图、过程、历史和恢复线索 +``` + +OpenResty 配置目录整体以 bind mount 提供给 gateway。不能只 bind mount 单个配置文件,否则宿主机原子重命名后容器可能继续引用旧 inode。host Nginx 由 daemon 直接管理宿主机配置目录。 + +### 6.3 单组件更新 + +容器模式下,以当前 `blue` 接流、更新 `backend` 为例: + +```text +1. 验证包签名、平台、文件摘要和 image digest +2. docker load 或按 digest pull 新镜像 +3. 使用新镜像创建 backend green +4. green 不发布固定宿主机端口 +5. Docker inspect 取得实际容器信息 +6. daemon 直接检查 green 健康状态 +7. 生成完整的下一份 gateway 配置 +8. 在 gateway 内执行配置检查 +9. 同目录原子替换当前配置 +10. 向 OpenResty master 发送 HUP +11. 新 worker 把新请求发往 green +12. 旧 worker 继续把已有连接交给 blue +13. 通过 gateway 执行业务验证 +14. 成功后提交事务 +15. blue 保留到连接排空和保留策略满足 +``` + +核心切换: + +```text +旧 OpenResty worker -> blue -> 处理已有连接 +新 OpenResty worker -> green -> 接收新请求 +``` + +配置检查失败时不得替换当前配置。reload 失败时旧 worker 继续使用旧配置,daemon 恢复更新前配置并再次检查。 + +native backend 使用相同切流步骤,但准备阶段替换为: + +```text +1. 校验 native JAR 的 manifest 路径、长度和 SHA-256 +2. 写入 green 专属版本目录 +3. 确认 green systemd 实例固定引用该目录中的 JAR +4. 启动 green JVM +5. 直连 `/yms/actuator/health` +6. 健康后生成指向 green 的完整 gateway 配置 +7. 配置检查、原子替换和 graceful reload +8. blue JVM 保留到连接排空和保留策略满足 +``` + +native frontend 准备阶段为: + +```text +1. 按 manifest 精确写入并校验 green 版本目录 +2. 目录完成前不进入 gateway 配置 +3. 生成指向 green 目录或对应静态入口的完整 gateway 配置 +4. 配置检查、原子替换和 graceful reload +5. blue 目录保留到旧资源策略允许清理 +``` + +native executor 不覆盖正在运行实例引用的 JAR 或 frontend 目录。 + +### 6.4 `all` 更新 + +`--service all` 不逐个切流: + +```text +1. 为三个组件准备非活动槽 +2. 三个新实例、目录或容器全部通过对应预检和健康检查 +3. 生成同时指向三个新槽的一份完整 gateway 配置 +4. 配置检查通过后只执行一次 HUP +5. 通过 gateway 验证完整业务组合 +6. 全部成功后提交 +``` + +切换期间会暂时存在旧 worker 与新 worker: + +```text +旧 worker +├── frontend blue +├── backend blue +└── nodeSsr blue + +新 worker +├── frontend green +├── backend green +└── nodeSsr green +``` + +因此新旧完整组合必须在兼容窗口内同时可用。 + +### 6.5 自动回切 + +切流后业务验证失败: + +```text +1. 恢复更新前 gateway 配置 +2. 执行配置检查 +3. 原子替换当前配置 +4. HUP +5. 新请求重新进入旧槽 +6. 验证旧槽健康 +7. 停止失败的新槽 +8. 事务进入 ROLLED_BACK 或 FAILED +``` + +### 6.6 daemon 异常恢复 + +daemon 启动后: + +```text +1. 读取未完成事务 +2. 读取当前 gateway 配置,确定实际接流目标 +3. 按显式运行类型检查 systemd 实例、版本目录或 Docker 容器 +4. 执行健康检查 +5. 当前目标健康:继续验证、提交或完成 drain +6. 当前目标故障且旧目标健康:恢复旧配置并 HUP +7. 两个目标都不健康:停止自动写操作,输出精确人工恢复信息 +``` + +关键故障语义: + +| 故障阶段 | 预期结果 | +| --- | --- | +| native 文件写入或校验失败 | 当前槽不受影响 | +| native systemd 实例启动失败 | 当前槽不受影响 | +| 新镜像装载失败 | 当前槽不受影响 | +| 新容器启动失败 | 当前槽不受影响 | +| 新实例或容器健康检查失败 | 当前槽不受影响 | +| gateway 配置检查失败 | 不替换当前配置 | +| HUP 失败 | 旧 worker 继续服务,恢复旧配置 | +| 切流后业务检查失败 | 回切旧配置,旧槽仍在 | +| daemon 被强制终止 | 按当前配置与 systemd、目录或 Docker 实际状态恢复 | + +### 6.7 长连接和旧槽 + +已有 SSE、上传和其他长连接可能长期停留在旧 worker 和旧实例。native JVM 或旧容器不能在 reload 后立即停止。 + +实现前必须冻结: + +- 最大 drain 时间。 +- 是否允许主动终止超时长连接。 +- 前端和客户端是否具备断线自动重连。 +- 旧实例、目录或容器达到保留上限后的行为。 + +### 6.8 frontend 旧资源 + +已经打开的旧页面可能在切流后继续请求旧 JS、CSS、图片或模板。仅保留旧 frontend 目录或容器不足以自动保证这些请求仍会路由到旧目标。 + +需要冻结一项前端资源兼容方案: + +1. 资源 URL 使用发布版本命名空间,由 gateway 路由到对应 frontend 目录或容器。 +2. 新 frontend 对文件请求返回真实 `404`,gateway 对静态资源回源上一槽。 +3. 组合使用版本命名空间和上一槽 fallback。 + +普通 `proxy_cache` 只能保存访问过的文件,不能单独作为完整旧版本存储。 + +## 7. 开发环境高频更新 + +### 7.1 目标 + +开发环境必须持续使用真实 daemon 更新路径,承担以下验证: + +- native JAR 和 repack 后 frontend `dist/...` 文件的暂存、校验与双槽切换。 +- 镜像装载或拉取。 +- native 实例与 Docker 容器的 blue/green 并行运行。 +- host Nginx 与 OpenResty 的配置检查和 graceful reload。 +- backend 新旧版本兼容。 +- Flyway 单次迁移边界。 +- SSE、上传和会话兼容。 +- frontend 旧资源处理。 +- 自动回切和 daemon 重启恢复。 + +不能等到客户离线交付时才首次执行 daemon 更新链路。 + +### 7.2 同一次构建的制品身份晋级 + +```text +CI 构建 multi-platform 镜像 + -> 记录 image index digest + -> 记录每个平台 manifest digest + -> 开发环境按其实际平台 digest 更新 + -> 自动化健康检查和业务测试 + -> 发布中台归档同一次构建的全部平台 digest + -> repack 保存客户平台对应镜像 + -> 客户 daemon 验证并安装该平台的原始 digest +``` + +不同平台的 manifest digest 本身不同。这里要求的是同一次 multi-platform 构建中,每个平台的 digest 在 CI、归档、repack 和目标服务器之间保持不变。开发验证后不得为客户重新构建同版本业务镜像,否则开发环境验证的制品与客户收到的制品不再属于同一次构建。 + +native backend 的制品身份链路为: + +```text +CI 生成 backend JAR + -> 记录 JAR 长度和 SHA-256 + -> native 开发环境安装并验证同一 JAR SHA-256 + -> 发布中台归档同一 JAR + -> 客户 repack 交付同一 JAR SHA-256 +``` + +native frontend 当前存在一次明确的形态转换: + +```text +CI 生成 frontend 源归档 + -> 记录源归档长度和 SHA-256 + -> native 开发环境从同一源归档展开并验证 + -> 发布中台归档同一源归档 + -> repack 把源归档展开到客户 ZIP 的 dist/ + -> 客户 manifest 枚举每个 dist/... 普通文件的实际路径、长度和 SHA-256 + -> 客户 daemon 按逐文件身份写入非活动槽 +``` + +frontend 源归档本身不进入当前客户 ZIP,不能在客户 manifest 中把它写成安装文件。开发验证后不得重新构建 backend JAR 或 frontend 源归档;repack 只执行已确认的 frontend 展开操作,并为展开结果生成逐文件身份。 + +### 7.3 在线与离线传输 + +```text +开发 container:从受控 Registry 按 digest pull,复用镜像层缓存 +开发 native:backend 获取 manifest 明确声明的 JAR;frontend 获取已声明身份的源归档并展开、校验 dist 文件 +客户现场:client 按 customerCode 查询并下载签名 repack ZIP,再传输到服务器并触发 update;container 执行 docker load,native 校验并写入非活动槽 +``` + +两种方式在进入执行器前必须归一为同一份经过校验的组件安装描述,后续步骤完全相同。 + +### 7.4 环境策略差异 + +| 策略 | 开发环境 | 客户现场 | +| --- | --- | --- | +| 更新触发 | CI/deploy 自动触发 | 交付人员或受控中转触发 | +| 制品获取 | container 使用 Registry digest;native 使用 manifest 声明文件 | 签名离线包 | +| 旧槽保留 | 较短,支持高频复用 | 较长,优先保证回滚 | +| 自动回切 | 必须启用 | 必须启用 | +| 健康检查 | 与客户一致并增加测试 | 冻结的生产检查 | +| 日志 | 详细诊断 | 可审计且不泄漏凭据 | + +开发环境不得通过关闭签名、跳过 digest/SHA-256、跳过健康检查、直接覆盖 native 当前槽或直接修改容器来形成另一套行为。开发环境可以使用独立信任密钥,但协议和校验路径必须相同。 + +### 7.5 高频更新与双槽复用 + +高频更新时: + +```text +blue 接流 -> 更新 green -> green 接流 -> 排空 blue +green 接流 -> 更新 blue -> blue 接流 -> 排空 green +``` + +只有非活动槽已经排空并完成清理后才能复用。存在未结束长连接时,不能直接删除该槽,需要按已冻结的 drain 策略处理。 + +### 7.6 Jenkins CLI 快速触发 + +开发环境不经过客户 PC GUI。Jenkins 在业务制品构建和身份信息生成完成后调用 `yms-daemon` CLI: + +```text +Jenkins 构建成功 + -> 取得本次构建的 manifest、native 文件 SHA-256 或 container digest + -> 调用开发环境 yms-daemon CLI + -> CLI 向 serve 提交更新事务 + -> Jenkins 等待或查询事务结果 + -> 成功后晋级该制品,失败则保留 daemon 诊断和回滚结果 +``` + +Jenkins 调用 CLI 不能绕过签名、SHA-256/digest、健康检查、切流和回滚。CLI 进程退出、Jenkins agent 断开或流水线超时不能取消已经提交的服务器事务。 + +Jenkins agent 与开发服务器是否位于同一主机、具体制品输入位置以及 CLI 参数,需要读取实际 Jenkinsfile 或 Job 配置后冻结;当前文档不推断这些值。 + +## 8. 数据库、任务和会话兼容 + +零停机不仅是网关切流问题。backend 新旧版本会短时间并行,必须检查: + +- Flyway 是否只执行一次。 +- 数据库变更是否同时兼容新旧代码。 +- 是否存在不可逆 SQL。 +- 定时任务是否重复执行。 +- PowerJob 执行器或其他调度进程如何选主。 +- 消息消费者是否重复消费或产生版本语义冲突。 +- Session 是否保存在进程内。 +- 本地缓存是否影响新旧版本并行。 +- 文件写入和共享目录是否具备并发安全性。 + +推荐的数据库发布边界: + +```text +Prepare + -> 执行一次向前兼容迁移 + -> 启动新 backend + -> 新旧 backend 兼容运行 + -> 切流 + -> 后续版本再清理旧字段 +``` + +破坏性数据库迁移不能依赖镜像回滚恢复。 + +在读取并确认 `ymswell` 中 Flyway、定时任务、消费者、Session 和缓存的实际配置前,不实现 backend 蓝绿执行器。 + +## 9. 离线包协议 + +### 9.1 协议要求 + +daemon 必须从 manifest 精确获得: + +- `packageFormatVersion` +- `versionId` +- `customerCode` +- 包含的业务组件 +- 每个组件的制品类型 +- ZIP 中实际路径 +- 文件长度和 SHA-256 +- native 安装文件列表 +- container 平台 +- container OCI image digest +- container 镜像引用 +- 健康检查定义 +- 签发方和签名 + +daemon 不通过文件名、扩展名、目录名称或正则表达式推断组件归属。 + +### 9.2 manifest 扩展方向 + +在现有 `artifact-selection.json` 中增加: + +```json +{ + "packageFormatVersion": 1, + "versionId": "V1.1.8", + "services": { + "backend": { + "type": "native", + "files": [ + { + "path": "", + "size": 123456, + "sha256": "" + } + ] + }, + "frontend": { + "type": "native", + "files": [ + { + "path": "dist/index.html", + "size": 123456, + "sha256": "" + }, + { + "path": "dist/_app.config.js", + "size": 123456, + "sha256": "" + } + ] + }, + "nodeSsr": { + "type": "container", + "artifacts": [ + { + "platform": "linux/amd64", + "path": "", + "size": 123456, + "sha256": "<离线归档文件SHA-256>", + "imageRef": "", + "imageDigest": "sha256:" + } + ] + } + } +} +``` + +上述新增字段是协议设计,不代表现有 `artifact-selection.json` 已经具备这些字段。 + +字段规则: + +- `packageFormatVersion` 必须为 daemon 明确支持的值。 +- `services` 的键只允许 `backend`、`frontend`、`nodeSsr`。 +- `backend.type` 和 `frontend.type` 只接受精确值 `native`、`container`。 +- `nodeSsr.type` 当前只接受精确值 `container`。 +- manifest 中的组件 `type` 必须与本机显式部署配置完全相同。 +- `native` 使用 `files`,每个文件都必须声明 `path`、`size`、`sha256`。 +- native frontend 的 `files` 必须枚举 repack 后客户 ZIP 的 `dist/` 下每个普通文件;示例中的两个文件不代表完整文件集合。 +- `container` 使用 `artifacts`,并声明 `platform`、`path`、`size`、`sha256`、`imageRef`、`imageDigest`。 +- container `platform` 必须与服务器精确平台相同。 +- `path` 必须是 ZIP 内明确的相对路径。 +- `path` 禁止绝对路径、`..`、空路径、重复路径和符号链接。 +- `size` 必须与 ZIP 条目及解压后文件一致。 +- native `sha256` 校验实际进入非活动槽的文件。 +- container `sha256` 校验离线镜像归档文件。 +- container `imageDigest` 校验装载后的 OCI 镜像身份。 +- container `imageRef` 不作为版本唯一依据。 +- manifest 未声明的业务载荷不得进入安装阶段。 + +完整三个组件 schema、健康检查 schema 和多平台数组规则需要与 `deploy` repack 同时冻结。 + +### 9.3 签名 + +建议更新包增加 `artifact-selection.sig`: + +1. 发布中台使用 Ed25519 私钥签名 `artifact-selection.json` 原始字节。 +2. daemon 使用预置公钥验签。 +3. manifest 中的 SHA-256 覆盖全部离线载荷。 +4. native 文件由各自的 `sha256` 绑定,container 镜像由 `imageDigest` 绑定。 +5. 客户 PC 中转助手不得重写 manifest 或签名。 + +签名强制启用时间、旧包兼容和密钥轮换仍需确认。 + +## 10. 更新事务 + +### 10.1 三层事务控制 + +服务端不在本地文件、SQLite 和 `.lock` 中三选一,三者职责固定为: + +| 层 | 唯一职责 | 明确不承担 | +| --- | --- | --- | +| OS advisory file lock | 保证只有一个 `serve` 进程拥有服务器更新权限 | 事务状态和历史 | +| 服务端 SQLite | 事务状态机、步骤、幂等、恢复和 WSS 事件位置的唯一持久化记录 | ZIP、镜像归档、配置正文和完整日志 | +| 本地文件 | ZIP、暂存文件、解压结果、gateway 配置快照、native 文件和详细日志 | 第二套事务状态真相 | + +客户 PC SQLite 与服务端 SQLite 是两个独立数据库。PC SQLite 恢复查询、下载和交付工作;服务端 SQLite 恢复上传和业务更新事务,二者不能共享数据库文件。 + +更新事务是跨 SQLite、本地文件、systemd、Docker 和 gateway 的持久化 Saga。SQLite 事务只能原子提交 daemon 自己的状态记录,不能回滚已经发生的外部操作;外部操作失败时依赖明确的补偿步骤。 + +### 10.2 进程锁与更新互斥 + +`serve` 启动时打开固定锁文件,并通过 Linux advisory `flock` 获取非阻塞排他锁。锁由打开的文件描述符持有到进程退出: + +- 不能根据 `.lock` 文件是否存在判断 daemon 是否持锁。 +- 锁文件可以永久保留,不作为事务结束时需要删除的标志。 +- daemon 正常退出、崩溃或被强制终止后,由内核释放锁。 +- 第二个 `serve` 无法取得锁时必须拒绝启动。 +- 本地 CLI 不获取该锁,只通过 Unix Socket 调用已经持锁的 `serve`。 +- 不为每个更新事务创建额外 `.lock` 文件。 + +单台服务器的并发保护分为三层: + +```text +OS flock + -> 保证只有一个 serve + +serve 进程内更新互斥 + -> 同一时间只调度一个更新事务 + +SQLite 持久化约束 + -> 最多存在一个未结束更新 + -> serve 重启后先恢复旧事务,再接受新事务 +``` + +开发环境高频更新请求进入队列或明确返回更新锁占用,不能并发修改 blue/green 槽和 gateway 配置。 + +### 10.3 服务端 SQLite 边界 + +服务端 SQLite 只允许 `serve` 打开,数据库必须位于服务器本地文件系统,禁止放在 NFS 或其他网络文件系统。 + +第一版数据库策略固定为: + +- 使用禁用 CGO 的 SQLite 驱动。 +- 只使用一个实际数据库连接。 +- 使用 rollback journal,不启用 WAL。 +- 使用 `synchronous=EXTRA`;启动时必须验证生效,未生效则禁止进入可更新状态。 +- SQLite 写事务必须保持短小。 +- 不在 SQLite 写事务中等待 systemd、Docker、gateway、HTTP 健康检查或 drain。 +- 事务状态变化与对应的 WSS 可恢复事件在同一个 SQLite 事务中提交。 +- schema 迁移失败时禁止更新,不能跳过迁移继续写入。 + +ZIP、镜像归档、完整配置、native 文件和详细日志不作为 BLOB 写入 SQLite。SQLite 只保存这些文件的身份、路径、长度、SHA-256 和生命周期状态。 + +不能同时使用 JSON 文件保存另一套可写事务状态。诊断导出的 JSON 只能是 SQLite 和实际运行状态的只读快照,不能参与恢复决策。 + +### 10.4 状态机 + +```text +CREATED + -> VALIDATING + -> PREPARED + -> STARTING + -> SWITCHING + -> VERIFYING + -> DRAINING + -> COMMITTED + +任一步骤失败: + -> ROLLING_BACK + -> ROLLED_BACK + +无法自动恢复: + -> FAILED +``` + +每次状态变化必须先提交到服务端 SQLite,再执行下一项外部操作。 + +### 10.5 每个事务至少记录 + +- 事务 ID。 +- 来源请求的幂等标识。 +- 更新来源类型。 +- 离线 ZIP 路径和 SHA-256,或在线 manifest 身份。 +- `packageFormatVersion`。 +- `customerCode`。 +- `versionId`。 +- 请求的 `service`。 +- 每个组件明确的 `type`。 +- native 更新前后的文件路径、长度和 SHA-256。 +- native blue/green systemd 实例和版本目录。 +- container 更新前后的 image digest、镜像 ID 和容器完整 ID。 +- 更新前 gateway 配置副本。 +- 更新后 gateway 配置副本。 +- 当前状态机阶段。 +- 每一步开始和结束时间。 +- systemd 或 Docker Engine 操作结果。 +- host Nginx 或 OpenResty 配置检查与 reload 结果。 +- 健康检查和业务验证结果。 +- drain 状态。 +- 提交、失败或回滚结果。 + +daemon 必须先持久化事务和幂等标识,再向本地 CLI 或远程 WSS 调用方确认提交成功。相同幂等标识重复提交时返回已经创建的事务,不创建第二个更新事务。 + +日志不得记录签名私钥、数据库密码、Registry 凭据或完整环境变量。 + +### 10.6 本地文件提交边界 + +业务载荷和配置快照按以下顺序进入可使用状态: + +```text +写入同文件系统临时位置 + -> flush 文件内容 + -> 校验路径、长度和 SHA-256 + -> 原子 rename 到最终位置 + -> flush 最终文件所在目录 + -> 提交 SQLite 文件身份和生命周期状态 +``` + +如果进程在原子 rename 后、SQLite 提交前崩溃,恢复流程把没有 SQLite 引用的文件视为未提交文件,不得直接安装。未提交文件的隔离和清理期限需要在固定目录设计时冻结。 + +gateway 更新前配置、新配置和它们的 SHA-256 必须在切流前持久化。当前 gateway 配置仍是实际流量目标的唯一依据,SQLite 不建立第二个活动槽指针。 + +### 10.7 外部步骤提交顺序 + +每个 systemd、Docker、文件安装或 gateway 操作遵循: + +```text +1. SQLite 记录待执行步骤、预期外部身份和补偿信息 +2. 提交 SQLite +3. 执行一项外部操作 +4. 查询该操作对应的实际外部状态 +5. SQLite 提交实际结果和 WSS 事件 +6. 进入下一步骤 +``` + +不允许在一个 SQLite 写事务中连续执行多个外部操作。daemon 可能在第 3 步完成后、第 5 步提交前崩溃,因此每项外部操作必须能够通过 inspect、文件摘要、systemd 状态、gateway 配置或健康检查确认实际结果。 + +恢复时不能只根据 SQLite 中的“待执行”状态盲目重复外部操作。无法判断实际结果的步骤进入恢复失败,不继续切流。 + +### 10.8 启动恢复 + +`serve` 启动顺序固定为: + +```text +1. 获取 OS 文件锁 +2. 打开服务端 SQLite 并执行已冻结的 schema 迁移 +3. 读取未结束事务和未完成上传 +4. 校验事务引用的本地文件 +5. 读取当前 gateway 配置,确定实际流量目标 +6. inspect systemd、Docker 和版本目录实际状态 +7. 判断中断步骤未执行、已执行或失败 +8. 继续、提交或执行补偿回滚 +9. 恢复完成后才接受新更新 +``` + +如果服务端 SQLite 无法打开、迁移失败或确认损坏: + +- 不停止当前 gateway、native 进程或业务容器。 +- 不接受新的上传完成、更新或 rollback 请求。 +- 保留数据库、本地文件和运行现场用于恢复。 +- 进入明确的不可更新维护状态,不能自动创建空数据库覆盖原状态。 + +数据库备份、恢复、完整性检查时机和 schema 迁移回退规则在选定禁用 CGO 的 SQLite 驱动后冻结。 + +## 11. 组件执行器集成 + +### 11.1 native executor + +native executor 通过明确参数调用 systemd,并直接使用 Go 文件 API 管理非活动版本目录: + +- 不执行交付包中的脚本。 +- 不拼接 Shell 命令。 +- 不覆盖当前接流实例正在使用的 JAR 或 frontend 目录。 +- systemd 操作只允许 manifest、本机配置和固定模板共同确定的 unit。 +- 所有文件先写入同文件系统临时位置,校验完成后进入非活动槽。 +- rollback 使用事务记录中的旧实例和旧版本目录。 + +### 11.2 Docker executor + +daemon 通过 Docker Engine API 管理: + +- 镜像装载和检查。 +- 容器创建、启动、停止和删除。 +- Docker network。 +- 容器 inspect。 +- 日志获取。 +- 向 gateway master 发送 HUP。 + +不依赖拼接 Shell 命令。是否完全不调用 Docker CLI,需要在选定 Go Docker 客户端并完成 API 版本兼容测试后冻结。 + +镜像装载后必须同时记录和验证: + +- 签名 manifest 中的 `imageDigest`。 +- Docker Engine 返回的镜像 ID。 +- Docker 容器实际使用的镜像 ID。 + +不能只依赖 tag 或 `latest`。 + +## 12. RPM、systemd 与构建矩阵 + +### 12.1 RPM + +RPM 负责: + +- 安装 `/usr/bin/yms-daemon`。 +- 安装和启用 `yms-daemon.service`。 +- 创建 daemon 状态、日志和配置目录。 +- 安装受信任公钥。 +- 安装默认配置模板。 +- daemon 自身升级与卸载。 + +daemon 不通过业务更新事务自更新。daemon 自身版本升级由 RPM 处理。 + +Docker Engine 是否由 RPM 安装、作为前置依赖检查还是使用客户已有安装,需要根据客户操作系统和现场 Docker 版本冻结。 + +### 12.2 systemd + +全部组件进入 Docker standalone 后,YMS 自有 unit 原则上只保留: + +```text +yms-daemon.service +``` + +native 过渡期还需要 daemon 管理的 backend blue/green unit。现有 `yms.service` 与 `ymsback.service` 的 `Conflicts` 必须通过独立基础设施迁移消除,不能直接复用为双实例。 + +业务容器由 Docker Engine 承载。现有 PowerJob 和其他独立 JVM 的最终归属需要逐项确认,不能直接删除现网 unit。 + +### 12.3 CGO 与平台 + +全部正式 Go 交付物必须使用 `CGO_ENABLED=0`。 + +| 交付物 | GOOS | GOARCH | GUI | CGO | +| --- | --- | --- | --- | --- | +| 服务器 daemon | `linux` | `amd64` | 无 | 禁用 | +| 服务器 daemon | `linux` | `arm64` | 无 | 禁用 | +| 客户 PC 助手 | `windows` | `amd64` | Wails 3 | 禁用 | +| 客户 PC 助手 | `windows` | `arm64` | Wails 3 | 禁用 | + +约束: + +- Linux daemon 不链接 Wails、GTK、WebKit。 +- Wails 3 只进入 Windows `client gui` 构建。 +- Windows Service 与 GUI 使用同一 Windows 二进制的不同入口。 +- 事务、传输、验签和协议代码由 Linux 与 Windows 构建共享。 +- 不使用依赖 CGO 的 SQLite 驱动。 +- CI 显式设置 `CGO_ENABLED=0`。 +- Linux 构建验证 ELF 不存在非预期动态依赖。 +- 其他 ARM 目标取得客户精确 `GOARCH`、`GOARM` 或 `uname -m` 后再增加。 + +OpenResty gateway 也必须生成并验证 `linux/amd64` 和 `linux/arm64` 镜像。官方 `x86_64` OpenResty 预编译包存在 CPU 指令要求,客户老旧 CPU 的精确能力需要纳入现场采集和 CI 兼容基线。 + +## 13. 客户 PC 工作台与 GUI + +客户 PC 使用同一 Windows 二进制的两个运行入口: + +```text +yms-daemon client service +yms-daemon client gui +``` + +- `client service`:Windows 后台服务,负责中台认证、轮询、下载、持久化、传输、远程更新触发和状态同步。 +- `client gui`:Wails 3 GUI,负责版本列表、手动刷新、“获取”、“更新”、进度、历史和错误展示。 +- 两者通过本地 IPC 通信。 +- GUI 不直接访问发布中台、不直接传输更新包、不直接打开 SQLite,也不直接持有服务器执行权限。 + +Windows Service 不能依赖交互式桌面,因此 service 与 GUI 不能作为同一个常驻交互进程。 + +### 13.1 客户 PC 操作流程 + +```text +后台轮询或用户点击“刷新” + -> client service 使用 customerCode 查询 deploy repack ZIP + -> 写入或更新本地版本记录 + -> GUI 展示该客户可用包 + +用户点击“获取” + -> 创建一次性下载令牌 + -> Range 下载到 PC 临时文件 + -> 校验长度、整包 SHA-256、签名和 manifest + -> 原子进入本地可交付状态 + +用户点击“更新” + -> 选择已登记的客户服务器和 service + -> 把完整 ZIP 传输到服务器暂存位置 + -> 校验服务器接收文件身份 + -> 服务器侧提交 yms-daemon update + -> 持续同步事务状态、日志摘要和最终结果 +``` + +“获取”和“更新”是两个独立动作。未完整下载并校验的包不能执行更新;已经下载的包允许在 PC 断网后交付给客户服务器。 + +### 13.2 SQLite 边界 + +客户 PC 引入 SQLite 是合理且必要的,原因不是 GUI 展示本身,而是 Windows Service 需要在进程重启、PC 重启和网络中断后恢复业务状态。 + +SQLite 至少承载以下业务记录类别: + +- 已登记的发布中台连接和客户身份关联。 +- 每次轮询与手动查询得到的客户 repack 包元数据。 +- 本地下载文件、临时文件、长度、摘要和校验状态。 +- 客户服务器登记信息。 +- PC 到服务器的传输进度和恢复信息。 +- 已提交的服务器更新事务及其状态同步结果。 +- 操作时间、失败原因和可审计历史。 + +ZIP 文件本身保存在受控文件目录中,不作为 BLOB 写入 SQLite。SQLite 只保存文件身份、位置和业务状态。 + +`client service` 是 SQLite 的唯一读写者。Wails GUI 只能通过本地 IPC 查询和提交操作,避免两个进程直接竞争数据库锁,也保证 GUI 退出不影响轮询、下载和更新状态跟踪。 + +SQLite 驱动必须在 `CGO_ENABLED=0` 下构建。具体驱动、数据库固定路径、schema、迁移工具、备份恢复和损坏处理在实现前通过 Windows `amd64`、Windows `arm64` 构建与故障测试后冻结。认证凭据和长期令牌不得以明文写入 SQLite;Windows 凭据保护方式需要单独冻结。 + +### 13.3 轮询与一致性 + +- 自动轮询和手动刷新复用同一查询实现。 +- 远端包以发布中台返回的客户归属和文件身份登记,不根据文件名解析客户或版本。 +- 同一个远端包重复出现时更新本地元数据,不创建重复业务记录。 +- 轮询只发现并记录新包,不自动执行“获取”或“更新”。 +- 下载令牌过期时重新申请令牌,并从已确认位置继续下载。 +- client service 重启后从 SQLite 和本地文件实际状态恢复,不只相信数据库中的进度值。 +- 服务器更新提交结果不明确时,必须先查询服务器事务状态,不能直接重复提交。 + +### 13.4 PC 到 daemon 的管理协议 + +PC 到客户服务器采用两个职责分离的通道: + +```text +HTTPS + -> 创建上传 + -> 查询 daemon 已确认的上传偏移 + -> 从确认偏移继续上传 ZIP + -> 完成整包校验 + +WSS + -> 提交结构化更新请求 + -> 查询事务快照 + -> 接收进度、状态和日志摘要事件 + -> 断联后恢复事件 +``` + +HTTPS 上传遵循 tus 1.0 core 的偏移语义:创建上传资源,使用 `HEAD` 查询服务器当前确认偏移,使用 `PATCH` 从该偏移继续写入。请求偏移与服务器偏移不一致时拒绝本次追加,不覆盖已确认内容。精确 URL、元数据和是否声明完整 tus 兼容在协议实现前冻结。 + +daemon 只有在上传字节已经写入暂存文件并持久化确认偏移后,才向 PC 确认新偏移。上传达到声明长度后必须校验整包 SHA-256、签名和 manifest;全部通过后才能原子进入可更新状态。 + +WSS 是控制与状态通道,不传输 ZIP,也不提供远程 Shell。WebSocket Ping/Pong 和读写超时只用于发现失联,不能作为事务存活条件。 + +断联恢复规则固定为: + +| 断联阶段 | daemon 行为 | PC 重连行为 | +| --- | --- | --- | +| ZIP 上传未完成 | 保留已确认偏移和暂存文件 | 查询服务器偏移并继续上传 | +| ZIP 已校验、更新未提交 | 保留可更新上传记录 | 查询上传状态后提交更新 | +| 更新请求已发送、确认响应丢失 | 按幂等标识保留唯一事务 | 先按幂等标识查询,不直接重复创建 | +| 更新事务执行中 | 独立继续事务、切流或回滚 | 获取事务快照后恢复状态订阅 | +| 状态事件传输中断 | 持久化事务状态和可恢复事件位置 | 从最后确认的事件位置继续;无法续接时重新获取完整快照 | +| PC 或 client service 重启 | 不影响服务器事务 | 从 SQLite 恢复上传、幂等标识、事务 ID 和最后确认事件位置 | + +WSS 与 HTTPS 可以由 `serve` 的同一个 TLS 管理监听入口提供,但管理入口与普通业务 gateway 分离。TLS、PC 身份认证、服务器身份校验、客户与服务器授权绑定、监听地址和端口必须在实现前冻结。 + +本地 CLI 继续通过 Unix Socket 调用 `serve`。远程 WSS 请求和本地 CLI 请求在认证入口之后进入同一个更新请求模型、状态机和并发锁。 + +## 14. 分布式主机与 Kubernetes + +核心事务不能把“更新目标”等同于本机 Docker 或 systemd。内部结构需要保留拓扑执行器边界: + +```text +签名发布描述 + -> 事务协调器 + -> Docker standalone executor + -> distributed host executor + -> Kubernetes executor +``` + +### 14.1 分布式主机 + +- 每个节点安装 RPM daemon。 +- 一个协调节点创建全局事务。 +- 所有节点先完成签名、平台、空间和镜像预检。 +- 按批次 drain、更新、验证和恢复流量。 +- 每个节点保存子事务。 +- 失败按相反批次回滚。 +- 节点间使用明确身份认证和加密通信。 + +精确节点发现、负载均衡摘除和恢复方式必须读取客户现场实际配置后确定。 + +### 14.2 Kubernetes + +Kubernetes executor 不在每个业务 Pod 中运行 daemon。它负责: + +- 将离线 OCI 镜像导入客户内部 Registry。 +- 使用不可变 digest 更新 workload。 +- 观察 rollout、readiness 和业务健康。 +- 失败时恢复旧 digest。 +- 将数据库迁移作为独立且只执行一次的步骤。 + +仓库中尚未发现实际 Kubernetes/Helm/kustomize 资源。实现前必须取得: + +- Namespace。 +- workload 的准确类型和名称。 +- 内部 Registry。 +- Ingress。 +- ConfigMap 和 Secret。 +- StorageClass、PVC 和挂载。 +- readiness/liveness/startup probes。 +- rollout 参数。 +- Service 和流量入口。 + +## 15. 故障测试矩阵 + +### 15.1 包与镜像 + +- ZIP 不存在、截断或重复条目。 +- manifest 或签名缺失。 +- 不支持的 `packageFormatVersion`。 +- 签名失败。 +- 路径穿越、绝对路径或符号链接。 +- 文件长度或 SHA-256 不一致。 +- 服务器平台与制品平台不一致。 +- `docker load` 失败。 +- 装载后 image digest 不一致。 +- tag 指向与 manifest digest 不一致。 + +### 15.2 容器准备 + +- Docker Engine 不可用。 +- Docker Engine API 版本不兼容。 +- Docker network 不存在或创建失败。 +- 新容器创建、启动或 inspect 失败。 +- 新容器退出。 +- 直连健康检查失败或超时。 +- 容器资源不足。 + +### 15.3 native 准备 + +- 本机配置要求 `native`,更新包只包含 `container`。 +- 本机配置要求 `container`,更新包只包含 `native`。 +- native 文件路径、长度或 SHA-256 不一致。 +- 非活动版本目录空间不足或权限错误。 +- backend 双实例 unit 未完成基础设施迁移。 +- systemd unit 存在 `Conflicts`,无法同时运行。 +- 非活动 JVM 启动失败或健康检查超时。 +- frontend 非活动目录未完整写入。 +- native rollback 时旧 JAR 或旧 frontend 目录缺失。 + +### 15.4 gateway + +- host Nginx 或 OpenResty gateway 不存在或未运行。 +- 配置目录不可写。 +- 生成配置失败。 +- 配置检查失败。 +- 原子替换失败。 +- HUP 失败。 +- reload 后新 worker 未启动。 +- reload 后业务检查失败。 +- 恢复旧配置失败。 + +### 15.5 长连接和前端资源 + +- SSE 在切流前已建立。 +- 大文件上传跨越切流时间点。 +- 旧页面切流后加载旧 JS chunk。 +- 新旧版本存在同名无 hash 静态文件。 +- drain 超时。 +- 高频更新时上一槽仍未排空。 + +### 15.6 backend 兼容 + +- Flyway 重复执行。 +- 新迁移与旧 backend 不兼容。 +- 两个版本重复执行定时任务。 +- 消费者重复运行。 +- Session 无法跨版本使用。 +- 本地缓存导致新旧版本行为不一致。 + +### 15.7 进程和服务器恢复 + +- 第二个 `serve` 启动并尝试获取同一 OS 文件锁。 +- `.lock` 文件存在但当前没有进程持锁。 +- daemon 在每个状态机阶段被强制终止。 +- daemon 在外部操作完成、SQLite 结果提交前被强制终止。 +- gateway 在更新期间重启。 +- Docker Engine 在更新期间重启。 +- 服务器在切流前、切流后、提交前重启。 +- 当前槽和旧槽均不健康。 +- 服务端 SQLite 无法打开、schema 迁移失败或数据库损坏。 +- SQLite 所在路径被错误配置为网络文件系统。 +- SQLite 提交、文件 flush、原子 rename 或父目录 flush 失败。 +- 服务端 SQLite 可用,但事务引用的 ZIP、配置快照或版本文件缺失。 +- 存在已经原子 rename、但尚未被 SQLite 引用的未提交文件。 + +### 15.8 开发环境高频更新 + +- 连续快速提交多次更新。 +- 更新锁占用时收到新请求。 +- Registry 短暂不可用。 +- 镜像层缓存命中和未命中。 +- CI 更新成功但业务验证失败。 +- 自动回切后继续下一次更新。 +- 开发环境验证 digest 与 repack digest 不一致时拒绝发布。 +- native 开发验证 SHA-256 与 repack SHA-256 不一致时拒绝发布。 + +### 15.9 客户 PC 工作台 + +- 发布中台不可达、认证过期或权限不足。 +- `customerCode` 不存在或没有 repack ZIP。 +- 自动轮询与手动刷新同时发生。 +- 下载令牌在下载前或断点续传时过期。 +- Range 下载被中断或服务端返回内容与续传位置不一致。 +- PC 磁盘空间不足、临时文件丢失或整包 SHA-256 不一致。 +- client service 在下载、传输或更新提交时终止。 +- GUI 退出或升级时后台任务继续运行。 +- SQLite 事务中断、迁移失败或数据库损坏。 +- PC 到服务器传输中断后恢复。 +- WSS 心跳超时、网络切换或中间设备关闭连接。 +- ZIP 已传输,但远程更新提交结果未知。 +- 更新请求已经持久化,但 WSS 确认响应丢失。 +- 服务器事务已创建,但 PC 随后断网。 +- 状态事件丢失、重复或恢复位置已经过期。 +- 同一包、同一服务器和同一 service 被重复点击更新。 + +## 16. 分阶段实施计划 + +### 阶段一:冻结设计 + +- [x] 确认 `client` 指安装在客户 PC 上的交付工作台,兼具离线中转能力。 +- [x] 确认 RPM 全局安装单一 Go 二进制。 +- [x] 确认正式 Go 交付物禁用 CGO。 +- [x] 确认过渡阶段支持显式 `native`、`container` 和混合部署。 +- [x] 确认 native 兼容层不执行现有更新脚本。 +- [x] 确认 Docker standalone 是 native 客户的主要迁移方向。 +- [x] 确认 frontend `dist` 在 CI 阶段进入 frontend 镜像。 +- [x] 确认 OpenResty 作为稳定 gateway。 +- [x] 确认业务组件使用 blue/green 和 graceful reload 零停机切流。 +- [x] 确认第一版不使用 Lua shared dict 作为核心切流状态。 +- [x] 确认开发环境使用同一更新体系进行高频更新。 +- [x] 确认 PC 到 daemon 使用 HTTPS 可续传上传和 WSS 控制状态通道。 +- [x] 确认 WSS 断联不取消上传记录或服务器更新事务。 +- [x] 确认服务端使用 OS file lock、SQLite 和本地文件三层事务控制。 +- [x] 确认服务端 SQLite 是 daemon 事务状态的唯一持久化记录,本地文件不保存第二套事务状态。 +- [x] 确认第一版服务端 SQLite 使用单连接、rollback journal 和 `synchronous=EXTRA`。 +- [ ] 冻结 frontend 旧资源兼容方案。 +- [ ] 冻结本机组件运行类型配置 schema。 +- [ ] 冻结 native backend 双实例 systemd 模板和版本目录。 +- [ ] 冻结 native frontend 双版本目录。 +- [ ] 冻结 backend 蓝绿期间 Flyway、任务、消费者和 Session 约束。 +- [ ] 冻结 gateway 配置目录、容器命名、network 和 labels。 +- [ ] 冻结健康检查和业务验证协议。 +- [ ] 冻结 drain 和旧槽保留规则。 +- [ ] 冻结离线 manifest 完整 schema 和签名规则。 +- [ ] 冻结开发环境在线更新接口。 +- [ ] 冻结 Jenkins 调用 CLI 的拓扑、输入和退出码语义。 +- [ ] 冻结 PC 到服务器管理协议的 URL、TLS 身份、授权、监听地址、端口和事件恢复规则。 +- [ ] 冻结客户 PC SQLite 边界、schema、驱动和文件保留策略。 +- [ ] 冻结服务端锁文件、SQLite、事务文件和日志路径,以及状态机和退出码。 +- [ ] 冻结服务端 SQLite schema、迁移、备份、完整性检查和损坏恢复流程。 + +### 阶段二:开发环境最小闭环 + +- [ ] 实现 `serve`、`update`、`rollback` 和查询命令。 +- [ ] 实现本地 Unix Socket、Linux advisory `flock` 和进程内更新互斥。 +- [ ] 实现服务端禁用 CGO 的 SQLite 驱动、单连接配置、rollback journal 和 `synchronous=EXTRA` 启动校验。 +- [ ] 实现服务端事务、步骤、幂等和 WSS 事件的 SQLite 持久化约束。 +- [ ] 实现临时写入、文件 flush、摘要校验、原子 rename 和父目录 flush。 +- [ ] 实现统一组件执行器和 gateway controller 接口。 +- [ ] 实现本机显式部署配置及 `type` 严格校验。 +- [ ] 实现 Docker Engine API 客户端和兼容性检查。 +- [ ] 实现镜像 digest 校验。 +- [ ] 实现 blue/green 统一生命周期。 +- [ ] 实现 OpenResty gateway controller 的配置生成、检查、原子替换和 HUP。 +- [ ] 实现 backend、frontend、nodeSsr 健康检查。 +- [ ] 实现基于 SQLite 意图、gateway 配置和 systemd/Docker/文件实际状态的重启恢复。 +- [ ] 在开发环境按 native 文件 SHA-256 或 container digest 完成高频更新闭环。 +- [ ] 接入 Jenkins 构建后 CLI 触发、事务查询和构建结果回写。 + +### 阶段三:native/hybrid 与应用兼容改造 + +- [ ] 实现 backend native systemd executor。 +- [ ] 实现 frontend native directory executor。 +- [ ] 实现 host Nginx gateway controller。 +- [ ] 实现 native/container 混合 `all` 事务。 +- [ ] 实现现有冲突 unit 到双实例 unit 的独立基础设施迁移。 +- [ ] 在开发环境持续执行 native 与 container 两条更新链路。 +- [ ] Flyway 从并发 JVM 或容器启动中形成单次执行边界。 +- [ ] 定时任务、PowerJob 和消费者支持蓝绿并行。 +- [ ] 确认 Session 和缓存兼容。 +- [ ] 完成 frontend 旧资源兼容。 +- [ ] 完成 SSE、上传和 drain 验证。 + +### 阶段四:离线交付 + +- [ ] 扩展 `artifact-selection.json`。 +- [ ] 为 native 文件生成实际 ZIP 路径、长度和 SHA-256,为 container 归档生成实际 ZIP 路径、长度、SHA-256 和 image digest。 +- [ ] 扩展客户包查询或下载令牌响应,提供整包 SHA-256。 +- [ ] 实现 Ed25519 签名和验签。 +- [ ] 实现安全 ZIP 读取和镜像暂存。 +- [ ] 实现符合已冻结 tus 1.0 core 语义的 HTTPS 上传和断点续传。 +- [ ] 实现 WSS 结构化更新提交、事务快照、事件推送和断联恢复。 +- [ ] 使用真实 repack 包完成端到端更新与回滚。 + +### 阶段五:RPM 与客户迁移 + +- [ ] 构建 `linux/amd64`、`linux/arm64` RPM。 +- [ ] 验证 `CGO_ENABLED=0` 和 ELF 依赖。 +- [ ] 冻结 Docker Engine 前置条件。 +- [ ] 设计现有 native 到 daemon 管理 native 双槽的显式迁移事务。 +- [ ] 设计 daemon 管理 native 到 Docker standalone 的显式迁移事务。 +- [ ] 任一迁移失败时恢复迁移前运行类型和服务。 +- [ ] 在独立现场等价环境完成演练。 + +### 阶段六:客户 PC GUI + +- [ ] 实现 Windows `client service`。 +- [ ] 实现发布中台认证和 `customerCode` 绑定。 +- [ ] 实现 repack ZIP 自动轮询和手动刷新。 +- [ ] 实现一次性令牌、Range 下载、暂停恢复和整包校验。 +- [ ] 引入禁用 CGO 的 SQLite 驱动并实现本地状态恢复。 +- [ ] 实现本地包文件目录、空间检查和保留策略。 +- [ ] 实现 PC 到服务器 HTTPS 上传、偏移续传和接收校验。 +- [ ] 实现 WSS 心跳、重连、服务器更新触发、事务快照、事件恢复和重复提交防护。 +- [ ] 实现 Wails 3 `client gui`。 +- [ ] 实现本地 IPC。 +- [ ] 实现查询、获取、更新、进度、历史和错误界面。 +- [ ] 复用签名、摘要、传输和状态协议并完成断网演练。 + +### 阶段七:分布式和 Kubernetes + +- [ ] 获取真实分布式客户拓扑和负载均衡操作。 +- [ ] 设计全局事务与节点子事务。 +- [ ] 获取真实 Kubernetes 资源。 +- [ ] 实现 OCI 导入、digest rollout、readiness 和 rollback。 + +## 17. 已确认决定 + +1. `client` 精确指安装在客户 PC 上的交付工作台,兼具离线中转能力。 +2. RPM 全局安装一个 `yms-daemon` Go 二进制。 +3. 同一二进制提供 `serve`、`client`、`update`、`rollback` 和查询入口。 +4. 特权更新只由常驻 `serve` 执行。 +5. 全部正式 Go 交付物使用 `CGO_ENABLED=0`。 +6. Docker standalone 是 native 客户向容器化迁移的主要方向。 +7. container 制品使用 OCI digest 作为身份,不依赖 tag 判断版本;native 制品使用 manifest 声明的文件路径、长度和 SHA-256。 +8. container frontend 的 `dist` 在 CI 阶段烘焙进 frontend 镜像;native frontend 由 repack 展开源归档,客户 manifest 逐文件声明 `dist/...` 的路径、长度和 SHA-256,daemon 把这些文件写入非活动版本目录。 +9. 目标态由 OpenResty 作为稳定 gateway;过渡期允许 host Nginx 实现同一 gateway controller 语义;frontend 静态 Nginx 不代理 backend。 +10. 三个业务组件通过 blue/green 双槽和 gateway graceful reload 实现普通更新零停机。 +11. 第一版核心切流不引入动态 Lua 路由 shared dict 和独立路由数据库。 +12. 开发环境必须使用同一 daemon;container 验证同一次 multi-platform 构建的实际平台 digest,native 验证 backend JAR 或 frontend 源归档及展开文件的 SHA-256,并复用同一切流和回滚执行器进行高频更新。 +13. 开发环境验证通过后,客户交付不得重新构建同版本业务镜像、backend JAR 或 frontend 源归档。 +14. gateway 自身升级与业务组件普通更新分离。 +15. 过渡阶段正式支持 `native`、`container` 和按组件混合部署。 +16. `backend`、`frontend` 支持 native/container 双执行器;`nodeSsr` 当前只支持 container。 +17. 本机配置和 manifest 必须明确声明组件 `type`,daemon 不从现场状态推断。 +18. 普通 `update` 不改变运行类型;native 到 container 使用独立迁移事务。 +19. native executor 使用 Go 直接管理文件和 systemd,不执行现有更新脚本。 +20. native 与 container 复用事务、健康检查、gateway 切流和回滚框架。 +21. 客户 PC 查询的是按 `customerCode` 隔离的 repack ZIP,不是通用 release 版本列表。 +22. 客户 PC 同时支持后台轮询和 GUI 手动刷新;轮询只发现版本,不自动下载或更新。 +23. “获取”负责从发布中台下载并校验到 PC,“更新”负责传输到服务器并触发服务器 `yms-daemon update`。 +24. 客户 PC 使用 SQLite 保存可恢复的业务状态;SQLite 由 `client service` 独占读写,Wails GUI 只通过本地 IPC 访问。 +25. 客户 PC SQLite 驱动必须支持 `CGO_ENABLED=0`,ZIP 文件不存入数据库。 +26. 开发环境由 Jenkins 调用 `yms-daemon` CLI 快速触发更新,并进入与客户现场相同的服务器事务、校验、切流和回滚框架。 +27. PC 到 daemon 使用 HTTPS 传输 ZIP,上传遵循 tus 1.0 core 的偏移恢复语义。 +28. PC 到 daemon 使用 WSS 提交结构化更新请求并接收事务状态、进度和日志摘要;WSS 不传输 ZIP,也不执行远程 Shell。 +29. daemon 更新事务独立于 WSS 连接存活;PC 断联或重启后通过 SQLite、上传偏移、幂等标识、事务快照和事件位置恢复。 +30. daemon 必须先持久化更新事务再确认提交成功;重复幂等请求只返回原事务。 +31. 远程 WSS 与本地 Unix Socket CLI 进入同一个内部更新请求模型、状态机和并发锁。 +32. 服务端事务采用 OS advisory file lock、服务端 SQLite 和本地文件三层控制;三层职责不重叠。 +33. OS file lock 只保证单一 `serve`,不能根据 `.lock` 文件是否存在判断锁状态,也不为每个更新创建锁文件。 +34. 服务端 SQLite 是事务状态、步骤、幂等和 WSS 恢复事件的唯一持久化记录;本地文件保存载荷和快照,不保存第二套可写事务状态。 +35. 第一版服务端 SQLite 只使用一个实际连接、rollback journal 和 `synchronous=EXTRA`,数据库禁止放在网络文件系统。 +36. SQLite 状态提交和外部操作分步执行,SQLite 写事务中禁止等待 systemd、Docker、gateway、健康检查或 drain。 +37. gateway 配置、systemd、Docker 和本地文件是恢复时的实际状态依据;SQLite 记录执行意图和历史,不能取代现场核对。 +38. 服务端 SQLite 损坏时保留当前业务运行状态并拒绝新更新,不能用空数据库自动覆盖。 + +## 18. 当前必须继续研讨的精确问题 + +1. frontend 健康检查 URL、状态码、响应内容和静态文件检查规则。 +2. frontend 旧资源采用版本命名空间、上一槽 fallback 还是组合方案。 +3. backend 新旧版本并行时 Flyway 的单次执行方式。 +4. backend 定时任务、PowerJob、消费者、Session 和缓存的实际行为。 +5. 本机组件运行类型配置的精确 schema 和固定路径。 +6. native backend 双实例 systemd unit、JAR 版本目录和端口的精确值。 +7. native frontend 双版本目录的精确值。 +8. gateway 容器镜像、配置目录、Docker network、blue/green 容器名和 label 的精确值。 +9. host Nginx 和 OpenResty 配置 reload 成功的确认方式。 +10. SSE 和上传的最大 drain 时间与强制结束规则。 +11. 三个组件的旧槽保留时间、磁盘清理和镜像清理规则。 +12. daemon OS 锁文件、服务端 SQLite、事务文件、上传暂存和日志的固定路径与权限。 +13. Docker Engine 支持的最低版本和 API 版本范围。 +14. RPM 是安装 Docker Engine、声明依赖还是只做运行前检查。 +15. 现有 native 到 daemon 管理 native 双槽的迁移命令、路径、权限和失败恢复流程。 +16. native 到 Docker standalone 的迁移命令、数据目录、配置目录、权限和失败恢复流程。 +17. 开发环境在线更新使用 CLI、deploy RPC、Webhook 还是任务拉取;需结合现有 `deploy` 接口设计。 +18. 是否从第一版强制要求 Ed25519,未签名旧包如何处理。 +19. 签名密钥轮换流程。 +20. PowerJob 是否纳入业务容器和 daemon 更新范围。 +21. 客户实际 CPU、操作系统、Docker 版本和 ARM 精确架构。 +22. 分布式现场的节点清单、负载均衡入口和 drain 操作。 +23. Kubernetes 的真实 Namespace、workload、Registry、Ingress、ConfigMap、Secret、PVC 和 probes。 +24. 客户 PC 使用哪个发布中台认证流程,以及登录身份与 `customerCode` 的精确授权关系。 +25. 客户 PC 自动轮询间隔、失败退避、手动刷新限流和多客户切换规则。 +26. 发布中台整包 SHA-256 的字段名、生成时机和返回接口。 +27. PC 本地包目录、临时文件后缀、磁盘配额和清理保留规则。 +28. daemon 管理协议的 TLS 身份、PC 与服务器授权绑定、监听地址、端口和证书轮换方式。 +29. HTTPS 上传的精确 URL、元数据、服务器暂存路径、过期清理规则以及是否声明完整 tus 1.0 兼容。 +30. 客户 PC SQLite 驱动、固定路径、schema、迁移版本和损坏恢复流程。 +31. Windows 认证凭据和长期令牌的保护方式。 +32. Jenkins agent 与开发服务器的实际拓扑、构建产物位置、CLI 参数和结果回写方式。 +33. WSS 消息 schema、幂等标识生成规则、事务快照结构、事件位置、保留时间和无法续接时的完整同步规则。 +34. WSS 心跳、读写超时、重连退避和最大离线时间。 +35. 服务端禁用 CGO 的 SQLite 驱动及其精确 SQLite 版本。 +36. 服务端 SQLite schema、迁移版本、备份、完整性检查和损坏恢复流程。 +37. 未提交文件的隔离目录、识别规则、保留期限和清理流程。 + +上述精确项冻结前,不修改现网更新脚本、systemd、Nginx 配置和发布包格式。 diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..efe66ef --- /dev/null +++ b/go.mod @@ -0,0 +1,11 @@ +module yms-daemon + +go 1.26 + +require github.com/ncruces/go-sqlite3 v0.35.3 + +require ( + github.com/ncruces/go-sqlite3-wasm/v3 v3.2.35304 // indirect + github.com/ncruces/julianday v1.0.0 // indirect + golang.org/x/sys v0.47.0 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..ae032de --- /dev/null +++ b/go.sum @@ -0,0 +1,10 @@ +github.com/ncruces/go-sqlite3 v0.35.3 h1:Ei07Zv1qfV/vyXzelhFsyS5Oh9TArBZHsmFk14Xv3GY= +github.com/ncruces/go-sqlite3 v0.35.3/go.mod h1:i1rhym/NIiB5xeEfzbN+e24Y+i7NGUpf7C2xZ3Dpwks= +github.com/ncruces/go-sqlite3-wasm/v3 v3.2.35304 h1:5NoQAewtgKNK3G4bjNPxVoGXu6F6NzLXWCTdD5FFAEY= +github.com/ncruces/go-sqlite3-wasm/v3 v3.2.35304/go.mod h1:o8gr9w/50fXA5TDskg6bNUjvqmFfw4KaXth4q+yDSjg= +github.com/ncruces/julianday v1.0.0 h1:fH0OKwa7NWvniGQtxdJRxAgkBMolni2BjDHaWTxqt7M= +github.com/ncruces/julianday v1.0.0/go.mod h1:Dusn2KvZrrovOMJuOt0TNXL6tB7U2E8kvza5fFc9G7g= +golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs= +golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/text v0.40.0 h1:Ub2Z6/xjgF1WrYQz2nuITOEegKFtiIy+rieRJ5lHZKs= +golang.org/x/text v0.40.0/go.mod h1:hpnzDAfGV753zIKo+wk3u1bVKCGPbrnF7+7LBF/UHVY= diff --git a/integration/transaction_kernel_linux_test.go b/integration/transaction_kernel_linux_test.go new file mode 100644 index 0000000..80614c3 --- /dev/null +++ b/integration/transaction_kernel_linux_test.go @@ -0,0 +1,182 @@ +//go:build linux + +package integration_test + +import ( + "context" + "errors" + "os" + "os/exec" + "path/filepath" + "testing" + + "yms-daemon/internal/processlock" + "yms-daemon/internal/transaction" +) + +const ( + lockHelperModeEnvironment = "YMS_DAEMON_INTEGRATION_LOCK_HELPER_MODE" + lockHelperPathEnvironment = "YMS_DAEMON_INTEGRATION_LOCK_HELPER_PATH" + lockHelperExpectBlocked = "EXPECT_BLOCKED" + lockHelperExpectAcquired = "EXPECT_ACQUIRED" +) + +// TestTransactionKernelIntegration 使用真实子进程、flock 和 SQLite 文件验证事务底座。 +// 测试期间父进程始终持有 serve 锁,完整事务提交并重开数据库后才释放锁。 +func TestTransactionKernelIntegration(t *testing.T) { + ctx := context.Background() + root := t.TempDir() + lockPath := filepath.Join(root, "serve.lock") + databasePath := filepath.Join(root, "transaction.db") + + serveLock, err := processlock.Acquire(lockPath) + if err != nil { + t.Fatalf("acquire serve lock: %v", err) + } + t.Cleanup(func() { + if err := serveLock.Close(); err != nil { + t.Errorf("release serve lock during cleanup: %v", err) + } + }) + + // 同进程重复加锁不足以证明客户服务器上的进程互斥,因此必须启动独立进程核对。 + runLockHelper(t, lockHelperExpectBlocked, lockPath) + + store, err := transaction.OpenStore(ctx, databasePath) + if err != nil { + t.Fatalf("open transaction store: %v", err) + } + record, created, err := store.CreateTransaction(ctx, transaction.CreateRequest{ + ID: "integration-transaction", + IdempotencyKey: "integration-request", + Source: "integration-test", + Service: "all", + }) + if err != nil || !created { + t.Fatalf("create transaction: record=%+v created=%v err=%v", record, created, err) + } + + transitions := []transaction.State{ + transaction.StateValidating, + transaction.StatePrepared, + transaction.StateStarting, + transaction.StateSwitching, + transaction.StateVerifying, + transaction.StateDraining, + transaction.StateCommitted, + } + for index, next := range transitions { + record, err = store.Transition(ctx, record.ID, next, "integration transition") + if err != nil { + t.Fatalf("transition to %s: %v", next, err) + } + expectedVersion := int64(index + 2) + if record.State != next || record.Version != expectedVersion { + t.Fatalf("unexpected transition result: state=%s version=%d, want state=%s version=%d", + record.State, record.Version, next, expectedVersion) + } + } + if err := store.Close(); err != nil { + t.Fatalf("close transaction store: %v", err) + } + + // 重新打开同一文件,证明终态和事件不是内存中的临时结果。 + reopened, err := transaction.OpenStore(ctx, databasePath) + if err != nil { + t.Fatalf("reopen transaction store: %v", err) + } + t.Cleanup(func() { + if err := reopened.Close(); err != nil { + t.Errorf("close reopened transaction store: %v", err) + } + }) + persisted, err := reopened.Transaction(ctx, record.ID) + if err != nil { + t.Fatalf("read committed transaction: %v", err) + } + if persisted.State != transaction.StateCommitted || persisted.Version != 8 { + t.Fatalf("unexpected committed transaction: %+v", persisted) + } + events, err := reopened.EventsAfter(ctx, record.ID, 0, 100) + if err != nil { + t.Fatalf("read transaction events: %v", err) + } + assertStateEvents(t, events, transitions) + + if err := serveLock.Close(); err != nil { + t.Fatalf("release serve lock: %v", err) + } + runLockHelper(t, lockHelperExpectAcquired, lockPath) +} + +// TestTransactionKernelLockHelper 只由 TestTransactionKernelIntegration 启动。 +// 独立测试进程让 flock 的互斥语义得到端到端验证。 +func TestTransactionKernelLockHelper(t *testing.T) { + mode, enabled := os.LookupEnv(lockHelperModeEnvironment) + if !enabled { + t.Skip("lock helper process is not enabled") + } + lockPath, present := os.LookupEnv(lockHelperPathEnvironment) + if !present || lockPath == "" { + t.Fatal("lock helper path is missing") + } + + lock, err := processlock.Acquire(lockPath) + switch mode { + case lockHelperExpectBlocked: + if lock != nil { + _ = lock.Close() + } + if !errors.Is(err, processlock.ErrAlreadyLocked) { + t.Fatalf("expected kernel lock contention, lock=%v err=%v", lock, err) + } + case lockHelperExpectAcquired: + if err != nil || lock == nil { + t.Fatalf("expected released kernel lock to be acquired, lock=%v err=%v", lock, err) + } + if err := lock.Close(); err != nil { + t.Fatalf("release helper lock: %v", err) + } + default: + t.Fatalf("unknown lock helper mode: %q", mode) + } +} + +func runLockHelper(t *testing.T, mode, lockPath string) { + t.Helper() + command := exec.Command(os.Args[0], "-test.run=^TestTransactionKernelLockHelper$", "-test.v") + command.Env = []string{ + lockHelperModeEnvironment + "=" + mode, + lockHelperPathEnvironment + "=" + lockPath, + } + output, err := command.CombinedOutput() + if err != nil { + t.Fatalf("run lock helper in mode %s: %v\n%s", mode, err, output) + } + t.Logf("lock helper mode %s passed", mode) +} + +func assertStateEvents(t *testing.T, events []transaction.Event, transitions []transaction.State) { + t.Helper() + if len(events) != len(transitions)+1 { + t.Fatalf("unexpected event count: got %d, want %d", len(events), len(transitions)+1) + } + if events[0].Kind != "TRANSACTION_CREATED" || events[0].ToState != transaction.StateCreated { + t.Fatalf("unexpected transaction creation event: %+v", events[0]) + } + previous := transaction.StateCreated + for index, next := range transitions { + event := events[index+1] + if event.Kind != "TRANSACTION_STATE_CHANGED" || event.FromState != previous || event.ToState != next { + t.Fatalf("unexpected state event at index %d: %+v", index+1, event) + } + if event.Sequence <= events[index].Sequence { + t.Fatalf("event sequence is not increasing at index %d: previous=%d current=%d", + index+1, events[index].Sequence, event.Sequence) + } + previous = next + } + if previous != transaction.StateCommitted { + t.Fatalf("state flow ended at %s", previous) + } +} diff --git a/internal/filestore/store.go b/internal/filestore/store.go new file mode 100644 index 0000000..c147d36 --- /dev/null +++ b/internal/filestore/store.go @@ -0,0 +1,213 @@ +package filestore + +import ( + "crypto/sha256" + "crypto/subtle" + "encoding/hex" + "errors" + "fmt" + "hash" + "io" + "os" + "path/filepath" +) + +var ErrDestinationConflict = errors.New("destination already exists with different content") + +// Identity 是不可变文件在进入事务目录前必须满足的身份。 +type Identity struct { + Size int64 + SHA256 string +} + +// File 是一次原子提交的结果。 +type File struct { + Path string + Identity Identity + Reused bool +} + +// Store 在一个 daemon 独占管理的本地根目录中保存不可变文件。 +type Store struct { + root string +} + +// New 创建文件存储并固定其规范根目录。 +func New(root string) (*Store, error) { + if root == "" { + return nil, errors.New("file store root is required") + } + absRoot, err := filepath.Abs(root) + if err != nil { + return nil, fmt.Errorf("resolve file store root: %w", err) + } + if err := os.MkdirAll(absRoot, 0o750); err != nil { + return nil, fmt.Errorf("create file store root: %w", err) + } + resolvedRoot, err := filepath.EvalSymlinks(absRoot) + if err != nil { + return nil, fmt.Errorf("resolve file store symlinks: %w", err) + } + return &Store{root: resolvedRoot}, nil +} + +// Commit 把内容写入同目录临时文件,校验后通过硬链接原子创建最终文件。 +// 最终文件已经存在且身份相同时按幂等成功处理,内容不同时拒绝覆盖。 +func (s *Store) Commit(relativePath string, source io.Reader, expected Identity) (File, error) { + if source == nil { + return File{}, errors.New("file source is required") + } + expectedDigest, err := validateIdentity(expected) + if err != nil { + return File{}, err + } + if !filepath.IsLocal(relativePath) || relativePath == "." { + return File{}, fmt.Errorf("file store path is not a local relative path: %q", relativePath) + } + target := filepath.Join(s.root, filepath.Clean(relativePath)) + parent := filepath.Dir(target) + if err := os.MkdirAll(parent, 0o750); err != nil { + return File{}, fmt.Errorf("create destination directory: %w", err) + } + if err := s.verifyParent(parent); err != nil { + return File{}, err + } + if existing, found, err := verifyExisting(target, expected, expectedDigest); err != nil { + return File{}, err + } else if found { + return existing, nil + } + + temp, err := os.CreateTemp(parent, ".incoming-*") + if err != nil { + return File{}, fmt.Errorf("create temporary file: %w", err) + } + tempPath := temp.Name() + preserveTemp := false + defer func() { + if !preserveTemp { + _ = os.Remove(tempPath) + } + }() + if err := temp.Chmod(0o640); err != nil { + _ = temp.Close() + return File{}, fmt.Errorf("set temporary file permissions: %w", err) + } + + digest := sha256.New() + size, copyErr := io.Copy(io.MultiWriter(temp, digest), source) + if copyErr != nil { + _ = temp.Close() + return File{}, fmt.Errorf("write temporary file: %w", copyErr) + } + if size != expected.Size { + _ = temp.Close() + return File{}, fmt.Errorf("file size mismatch: got %d, want %d", size, expected.Size) + } + if !sameDigest(digest, expectedDigest) { + _ = temp.Close() + return File{}, errors.New("file SHA-256 mismatch") + } + if err := temp.Sync(); err != nil { + _ = temp.Close() + return File{}, fmt.Errorf("flush temporary file: %w", err) + } + if err := temp.Close(); err != nil { + return File{}, fmt.Errorf("close temporary file: %w", err) + } + + if err := os.Link(tempPath, target); err != nil { + if existing, found, verifyErr := verifyExisting(target, expected, expectedDigest); verifyErr != nil { + return File{}, verifyErr + } else if found { + return existing, nil + } + return File{}, fmt.Errorf("atomically create destination file: %w", err) + } + if err := syncDirectory(parent); err != nil { + // 最终路径已经可见,保留临时硬链接,避免在目录落盘失败时继续改变现场。 + preserveTemp = true + return File{}, err + } + if err := os.Remove(tempPath); err != nil { + preserveTemp = true + return File{}, fmt.Errorf("remove committed temporary link: %w", err) + } + if err := syncDirectory(parent); err != nil { + return File{}, err + } + return File{Path: target, Identity: expected}, nil +} + +func (s *Store) verifyParent(parent string) error { + resolvedParent, err := filepath.EvalSymlinks(parent) + if err != nil { + return fmt.Errorf("resolve destination directory symlinks: %w", err) + } + relative, err := filepath.Rel(s.root, resolvedParent) + if err != nil { + return fmt.Errorf("compare destination directory with store root: %w", err) + } + if relative != "." && !filepath.IsLocal(relative) { + return fmt.Errorf("destination directory escapes file store root: %q", parent) + } + return nil +} + +func validateIdentity(identity Identity) ([]byte, error) { + if identity.Size < 0 { + return nil, errors.New("expected file size must not be negative") + } + digest, err := hex.DecodeString(identity.SHA256) + if err != nil || len(digest) != sha256.Size { + return nil, errors.New("expected SHA-256 must be a 64-character hexadecimal value") + } + return digest, nil +} + +func verifyExisting(path string, expected Identity, expectedDigest []byte) (File, bool, error) { + info, err := os.Lstat(path) + if errors.Is(err, os.ErrNotExist) { + return File{}, false, nil + } + if err != nil { + return File{}, false, fmt.Errorf("inspect destination file: %w", err) + } + if !info.Mode().IsRegular() || info.Mode()&os.ModeSymlink != 0 { + return File{}, false, fmt.Errorf("destination is not a regular file: %s", path) + } + if info.Size() != expected.Size { + return File{}, false, ErrDestinationConflict + } + file, err := os.Open(path) + if err != nil { + return File{}, false, fmt.Errorf("open existing destination file: %w", err) + } + digest := sha256.New() + _, copyErr := io.Copy(digest, file) + closeErr := file.Close() + if err := errors.Join(copyErr, closeErr); err != nil { + return File{}, false, fmt.Errorf("hash existing destination file: %w", err) + } + if !sameDigest(digest, expectedDigest) { + return File{}, false, ErrDestinationConflict + } + return File{Path: path, Identity: expected, Reused: true}, true, nil +} + +func sameDigest(actual hash.Hash, expected []byte) bool { + return subtle.ConstantTimeCompare(actual.Sum(nil), expected) == 1 +} + +func syncDirectory(path string) error { + directory, err := os.Open(path) + if err != nil { + return fmt.Errorf("open directory for flush: %w", err) + } + syncErr := directory.Sync() + closeErr := directory.Close() + if err := errors.Join(syncErr, closeErr); err != nil { + return fmt.Errorf("flush directory: %w", err) + } + return nil +} diff --git a/internal/filestore/store_test.go b/internal/filestore/store_test.go new file mode 100644 index 0000000..8dacbfb --- /dev/null +++ b/internal/filestore/store_test.go @@ -0,0 +1,111 @@ +package filestore + +import ( + "bytes" + "crypto/sha256" + "encoding/hex" + "errors" + "os" + "path/filepath" + "testing" +) + +func TestStoreCommitsAndReusesImmutableFile(t *testing.T) { + t.Parallel() + store, err := New(t.TempDir()) + if err != nil { + t.Fatalf("create file store: %v", err) + } + content := []byte("signed update package") + identity := identityOf(content) + committed, err := store.Commit("transactions/one/package.zip", bytes.NewReader(content), identity) + if err != nil { + t.Fatalf("commit file: %v", err) + } + if committed.Reused { + t.Fatal("new file reported as reused") + } + actual, err := os.ReadFile(committed.Path) + if err != nil || !bytes.Equal(actual, content) { + t.Fatalf("read committed file: content=%q err=%v", actual, err) + } + reused, err := store.Commit("transactions/one/package.zip", bytes.NewReader(content), identity) + if err != nil { + t.Fatalf("reuse committed file: %v", err) + } + if !reused.Reused || reused.Path != committed.Path { + t.Fatalf("unexpected reused file: %+v", reused) + } +} + +func TestStoreRejectsMismatchAndNeverPublishesInvalidFile(t *testing.T) { + t.Parallel() + root := t.TempDir() + store, err := New(root) + if err != nil { + t.Fatalf("create file store: %v", err) + } + content := []byte("actual") + expected := identityOf([]byte("different")) + expected.Size = int64(len(content)) + _, err = store.Commit("package.zip", bytes.NewReader(content), expected) + if err == nil { + t.Fatal("SHA-256 mismatch was accepted") + } + if _, statErr := os.Stat(filepath.Join(root, "package.zip")); !errors.Is(statErr, os.ErrNotExist) { + t.Fatalf("invalid destination was published: %v", statErr) + } +} + +func TestStoreRejectsNilSource(t *testing.T) { + t.Parallel() + store, err := New(t.TempDir()) + if err != nil { + t.Fatalf("create file store: %v", err) + } + if _, err := store.Commit("package.zip", nil, identityOf(nil)); err == nil { + t.Fatal("nil file source was accepted") + } +} + +func TestStoreRejectsExistingDifferentContentAndPathEscape(t *testing.T) { + t.Parallel() + root := t.TempDir() + store, err := New(root) + if err != nil { + t.Fatalf("create file store: %v", err) + } + if err := os.WriteFile(filepath.Join(root, "package.zip"), []byte("old"), 0o640); err != nil { + t.Fatalf("seed destination: %v", err) + } + _, err = store.Commit("package.zip", bytes.NewReader([]byte("new")), identityOf([]byte("new"))) + if !errors.Is(err, ErrDestinationConflict) { + t.Fatalf("expected destination conflict, got %v", err) + } + _, err = store.Commit("../outside", bytes.NewReader(nil), identityOf(nil)) + if err == nil { + t.Fatal("path traversal was accepted") + } +} + +func TestStoreRejectsSymlinkedParentOutsideRoot(t *testing.T) { + t.Parallel() + root := t.TempDir() + outside := t.TempDir() + if err := os.Symlink(outside, filepath.Join(root, "linked")); err != nil { + t.Fatalf("create parent symlink: %v", err) + } + store, err := New(root) + if err != nil { + t.Fatalf("create file store: %v", err) + } + _, err = store.Commit("linked/package.zip", bytes.NewReader(nil), identityOf(nil)) + if err == nil { + t.Fatal("symlinked parent outside root was accepted") + } +} + +func identityOf(content []byte) Identity { + digest := sha256.Sum256(content) + return Identity{Size: int64(len(content)), SHA256: hex.EncodeToString(digest[:])} +} diff --git a/internal/logging/logger.go b/internal/logging/logger.go new file mode 100644 index 0000000..b66829a --- /dev/null +++ b/internal/logging/logger.go @@ -0,0 +1,52 @@ +package logging + +import ( + "errors" + "fmt" + "io" + "log/slog" + "os" + "path/filepath" +) + +// New 创建同时写入 stdout 和本地文件的结构化文本日志。 +// stdout 由 systemd/journald 收集,本地文件用于现场诊断和受控远程读取。 +func New(filePath string) (*slog.Logger, io.Closer, error) { + return NewWithConsole(os.Stdout, filePath) +} + +// NewWithConsole 允许调用方指定控制台 writer,主要用于测试和嵌入运行。 +func NewWithConsole(console io.Writer, filePath string) (*slog.Logger, io.Closer, error) { + if console == nil { + return nil, nil, errors.New("console writer is required") + } + if filePath == "" { + return nil, nil, errors.New("log file path is required") + } + absPath, err := filepath.Abs(filePath) + if err != nil { + return nil, nil, fmt.Errorf("resolve log file path: %w", err) + } + if err := os.MkdirAll(filepath.Dir(absPath), 0o750); err != nil { + return nil, nil, fmt.Errorf("create log directory: %w", err) + } + file, err := os.OpenFile(absPath, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0o640) + if err != nil { + return nil, nil, fmt.Errorf("open log file: %w", err) + } + handler := slog.NewTextHandler(io.MultiWriter(console, file), &slog.HandlerOptions{Level: slog.LevelInfo}) + return slog.New(handler), &syncFileCloser{file: file}, nil +} + +type syncFileCloser struct { + file *os.File +} + +func (c *syncFileCloser) Close() error { + if c == nil || c.file == nil { + return nil + } + file := c.file + c.file = nil + return errors.Join(file.Sync(), file.Close()) +} diff --git a/internal/logging/logger_test.go b/internal/logging/logger_test.go new file mode 100644 index 0000000..58800d7 --- /dev/null +++ b/internal/logging/logger_test.go @@ -0,0 +1,35 @@ +package logging + +import ( + "bytes" + "os" + "path/filepath" + "strings" + "testing" +) + +func TestLoggerWritesConsoleAndFile(t *testing.T) { + t.Parallel() + var console bytes.Buffer + path := filepath.Join(t.TempDir(), "daemon.log") + logger, closer, err := NewWithConsole(&console, path) + if err != nil { + t.Fatalf("create logger: %v", err) + } + logger.Info("transaction created", "transaction_id", "transaction-1") + if err := closer.Close(); err != nil { + t.Fatalf("close logger: %v", err) + } + fileContent, err := os.ReadFile(path) + if err != nil { + t.Fatalf("read log file: %v", err) + } + for name, content := range map[string]string{ + "console": console.String(), + "file": string(fileContent), + } { + if !strings.Contains(content, "transaction created") || !strings.Contains(content, "transaction_id=transaction-1") { + t.Fatalf("%s log is missing fields: %q", name, content) + } + } +} diff --git a/internal/processlock/lock.go b/internal/processlock/lock.go new file mode 100644 index 0000000..7f73f6c --- /dev/null +++ b/internal/processlock/lock.go @@ -0,0 +1,10 @@ +package processlock + +import "errors" + +var ( + // ErrAlreadyLocked 表示另一个 serve 进程已经持有内核锁。 + ErrAlreadyLocked = errors.New("process lock is already held") + // ErrUnsupported 表示当前操作系统不能运行服务端进程锁。 + ErrUnsupported = errors.New("process lock is not supported on this operating system") +) diff --git a/internal/processlock/lock_linux.go b/internal/processlock/lock_linux.go new file mode 100644 index 0000000..18579bc --- /dev/null +++ b/internal/processlock/lock_linux.go @@ -0,0 +1,84 @@ +//go:build linux + +package processlock + +import ( + "errors" + "fmt" + "os" + "path/filepath" + "sync" + + "golang.org/x/sys/unix" +) + +// Lock 持有与文件描述符绑定的 Linux advisory flock。 +// 锁文件可以永久存在;只有内核锁状态表示当前所有权。 +type Lock struct { + mu sync.Mutex + file *os.File +} + +// Acquire 以非阻塞方式获取排他锁,并把当前 PID 写入文件用于诊断。 +func Acquire(path string) (*Lock, error) { + if path == "" { + return nil, errors.New("process lock path is required") + } + absPath, err := filepath.Abs(path) + if err != nil { + return nil, fmt.Errorf("resolve process lock path: %w", err) + } + if err := os.MkdirAll(filepath.Dir(absPath), 0o750); err != nil { + return nil, fmt.Errorf("create process lock directory: %w", err) + } + file, err := os.OpenFile(absPath, os.O_CREATE|os.O_RDWR, 0o640) + if err != nil { + return nil, fmt.Errorf("open process lock: %w", err) + } + if err := unix.Flock(int(file.Fd()), unix.LOCK_EX|unix.LOCK_NB); err != nil { + _ = file.Close() + if errors.Is(err, unix.EWOULDBLOCK) || errors.Is(err, unix.EAGAIN) { + return nil, ErrAlreadyLocked + } + return nil, fmt.Errorf("acquire process lock: %w", err) + } + if err := writeOwnerPID(file); err != nil { + _ = unix.Flock(int(file.Fd()), unix.LOCK_UN) + _ = file.Close() + return nil, err + } + return &Lock{file: file}, nil +} + +func writeOwnerPID(file *os.File) error { + if err := file.Truncate(0); err != nil { + return fmt.Errorf("truncate process lock metadata: %w", err) + } + if _, err := file.Seek(0, 0); err != nil { + return fmt.Errorf("seek process lock metadata: %w", err) + } + if _, err := fmt.Fprintf(file, "%d\n", os.Getpid()); err != nil { + return fmt.Errorf("write process lock metadata: %w", err) + } + if err := file.Sync(); err != nil { + return fmt.Errorf("flush process lock metadata: %w", err) + } + return nil +} + +// Close 释放内核锁并关闭文件描述符,不删除锁文件。 +func (l *Lock) Close() error { + if l == nil { + return nil + } + l.mu.Lock() + defer l.mu.Unlock() + if l.file == nil { + return nil + } + file := l.file + l.file = nil + unlockErr := unix.Flock(int(file.Fd()), unix.LOCK_UN) + closeErr := file.Close() + return errors.Join(unlockErr, closeErr) +} diff --git a/internal/processlock/lock_linux_test.go b/internal/processlock/lock_linux_test.go new file mode 100644 index 0000000..8bd5309 --- /dev/null +++ b/internal/processlock/lock_linux_test.go @@ -0,0 +1,40 @@ +//go:build linux + +package processlock + +import ( + "errors" + "os" + "path/filepath" + "testing" +) + +func TestLockUsesKernelOwnershipAndLeavesFileInPlace(t *testing.T) { + t.Parallel() + path := filepath.Join(t.TempDir(), "serve.lock") + first, err := Acquire(path) + if err != nil { + t.Fatalf("acquire first lock: %v", err) + } + second, err := Acquire(path) + if !errors.Is(err, ErrAlreadyLocked) || second != nil { + t.Fatalf("expected second acquire to fail with lock ownership, lock=%v err=%v", second, err) + } + metadata, err := os.ReadFile(path) + if err != nil || len(metadata) == 0 { + t.Fatalf("read diagnostic PID: data=%q err=%v", metadata, err) + } + if err := first.Close(); err != nil { + t.Fatalf("release first lock: %v", err) + } + if _, err := os.Stat(path); err != nil { + t.Fatalf("lock file should remain after release: %v", err) + } + third, err := Acquire(path) + if err != nil { + t.Fatalf("reacquire existing lock file: %v", err) + } + if err := third.Close(); err != nil { + t.Fatalf("release reacquired lock: %v", err) + } +} diff --git a/internal/processlock/lock_unsupported.go b/internal/processlock/lock_unsupported.go new file mode 100644 index 0000000..3f56c93 --- /dev/null +++ b/internal/processlock/lock_unsupported.go @@ -0,0 +1,16 @@ +//go:build !linux + +package processlock + +// Lock 仅用于让共享代码在非 Linux client 构建中保持可编译。 +type Lock struct{} + +// Acquire 明确拒绝在非 Linux 系统启动服务端进程锁。 +func Acquire(string) (*Lock, error) { + return nil, ErrUnsupported +} + +// Close 对未获取的非 Linux 锁不执行操作。 +func (l *Lock) Close() error { + return nil +} diff --git a/internal/transaction/coordinator.go b/internal/transaction/coordinator.go new file mode 100644 index 0000000..67f5948 --- /dev/null +++ b/internal/transaction/coordinator.go @@ -0,0 +1,169 @@ +package transaction + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "log/slog" +) + +// InspectionStatus 表示外部系统中某一步的实际结果。 +type InspectionStatus string + +const ( + InspectionApplied InspectionStatus = "APPLIED" + InspectionNotApplied InspectionStatus = "NOT_APPLIED" + InspectionUnknown InspectionStatus = "UNKNOWN" +) + +// Inspection 是执行器通过 inspect、摘要、健康检查等方式得到的实际状态。 +type Inspection struct { + Status InspectionStatus + Result json.RawMessage +} + +// Operation 是一个可核对实际结果的外部副作用。 +// Apply 返回成功只代表调用完成;最终成功必须由 Inspect 确认。 +type Operation interface { + Apply(context.Context) error + Inspect(context.Context) (Inspection, error) +} + +// UncertainStepError 表示当前无法确认外部副作用是否已经发生。 +// 这种错误必须保留 INTENT_RECORDED,等待恢复流程再次核对。 +type UncertainStepError struct { + TransactionID string + StepKey string + Cause error +} + +func (e *UncertainStepError) Error() string { + return fmt.Sprintf("external step result is uncertain: transaction=%s step=%s: %v", e.TransactionID, e.StepKey, e.Cause) +} + +func (e *UncertainStepError) Unwrap() error { + return e.Cause +} + +// Coordinator 串行化单机更新,并实现“先记录意图、再执行、最后 inspect”的步骤协议。 +type Coordinator struct { + store *Store + logger *slog.Logger + permit chan struct{} +} + +func NewCoordinator(store *Store, logger *slog.Logger) (*Coordinator, error) { + if store == nil { + return nil, errors.New("transaction store is required") + } + if logger == nil { + logger = slog.Default() + } + permit := make(chan struct{}, 1) + permit <- struct{}{} + return &Coordinator{store: store, logger: logger, permit: permit}, nil +} + +// RunExclusive 在一个进程内只允许一个完整更新流程进入执行区。 +func (c *Coordinator) RunExclusive(ctx context.Context, run func(context.Context) error) error { + if run == nil { + return errors.New("exclusive update function is required") + } + select { + case <-ctx.Done(): + return ctx.Err() + case <-c.permit: + } + defer func() { c.permit <- struct{}{} }() + return run(ctx) +} + +// ExecuteStep 执行或恢复一个外部步骤。 +// 相同 step key 再次调用时先核对现场,禁止直接重复 Apply。 +func (c *Coordinator) ExecuteStep(ctx context.Context, transactionID string, intent StepIntent, operation Operation) (Step, error) { + if operation == nil { + return Step{}, errors.New("external operation is required") + } + step, created, err := c.store.RecordStepIntent(ctx, transactionID, intent) + if err != nil { + return Step{}, err + } + if step.Status == StepSucceeded { + return step, nil + } + if step.Status == StepFailed { + return step, fmt.Errorf("external step already failed: %s", step.Error) + } + + if !created { + inspection, err := operation.Inspect(ctx) + if err != nil { + return step, c.uncertain(ctx, transactionID, intent.Key, err) + } + switch inspection.Status { + case InspectionApplied: + return c.completeApplied(ctx, transactionID, intent.Key, inspection) + case InspectionUnknown: + return step, c.uncertain(ctx, transactionID, intent.Key, errors.New("inspect returned UNKNOWN")) + case InspectionNotApplied: + // 现场明确未发生副作用后,才允许恢复流程重新 Apply。 + default: + return step, c.uncertain(ctx, transactionID, intent.Key, fmt.Errorf("inspect returned invalid status %q", inspection.Status)) + } + } + + c.logger.InfoContext(ctx, "external step apply started", + "transaction_id", transactionID, + "step_key", intent.Key, + "step_name", intent.Name, + ) + applyErr := operation.Apply(ctx) + inspection, inspectErr := operation.Inspect(ctx) + if inspectErr != nil { + return step, c.uncertain(ctx, transactionID, intent.Key, errors.Join(applyErr, inspectErr)) + } + switch inspection.Status { + case InspectionApplied: + completed, err := c.completeApplied(ctx, transactionID, intent.Key, inspection) + if err == nil { + c.logger.InfoContext(ctx, "external step applied", + "transaction_id", transactionID, + "step_key", intent.Key, + ) + } + return completed, err + case InspectionUnknown: + return step, c.uncertain(ctx, transactionID, intent.Key, errors.Join(applyErr, errors.New("inspect returned UNKNOWN"))) + case InspectionNotApplied: + failure := applyErr + if failure == nil { + failure = errors.New("operation completed without reaching the expected external state") + } + completed, err := c.store.CompleteStep(ctx, transactionID, intent.Key, StepFailed, inspection.Result, failure.Error()) + if err != nil { + return Step{}, errors.Join(failure, err) + } + c.logger.ErrorContext(ctx, "external step failed", + "transaction_id", transactionID, + "step_key", intent.Key, + "error", failure, + ) + return completed, failure + default: + return step, c.uncertain(ctx, transactionID, intent.Key, fmt.Errorf("inspect returned invalid status %q", inspection.Status)) + } +} + +func (c *Coordinator) completeApplied(ctx context.Context, transactionID, stepKey string, inspection Inspection) (Step, error) { + return c.store.CompleteStep(ctx, transactionID, stepKey, StepSucceeded, inspection.Result, "") +} + +func (c *Coordinator) uncertain(ctx context.Context, transactionID, stepKey string, cause error) error { + c.logger.WarnContext(ctx, "external step result is uncertain", + "transaction_id", transactionID, + "step_key", stepKey, + "error", cause, + ) + return &UncertainStepError{TransactionID: transactionID, StepKey: stepKey, Cause: cause} +} diff --git a/internal/transaction/coordinator_test.go b/internal/transaction/coordinator_test.go new file mode 100644 index 0000000..5a00dc4 --- /dev/null +++ b/internal/transaction/coordinator_test.go @@ -0,0 +1,154 @@ +package transaction + +import ( + "context" + "encoding/json" + "errors" + "io" + "log/slog" + "sync/atomic" + "testing" +) + +func TestCoordinatorRecoversIntentWithoutRepeatingAppliedOperation(t *testing.T) { + t.Parallel() + ctx := context.Background() + store := openTestStore(t) + record := createTestTransaction(t, store, "recover-applied") + intent := StepIntent{Key: "start-green", Name: "start green", Intent: json.RawMessage(`{"slot":"green"}`)} + if _, _, err := store.RecordStepIntent(ctx, record.ID, intent); err != nil { + t.Fatalf("record crash-window intent: %v", err) + } + operation := &fakeOperation{applied: true, result: json.RawMessage(`{"running":true}`)} + coordinator := newTestCoordinator(t, store) + step, err := coordinator.ExecuteStep(ctx, record.ID, intent, operation) + if err != nil { + t.Fatalf("recover applied operation: %v", err) + } + if step.Status != StepSucceeded || operation.applyCalls.Load() != 0 || operation.inspectCalls.Load() != 1 { + t.Fatalf("unexpected recovery result: step=%+v apply=%d inspect=%d", step, operation.applyCalls.Load(), operation.inspectCalls.Load()) + } +} + +func TestCoordinatorRecoversNotAppliedIntentThenExecutesOnce(t *testing.T) { + t.Parallel() + ctx := context.Background() + store := openTestStore(t) + record := createTestTransaction(t, store, "recover-not-applied") + intent := StepIntent{Key: "write-files", Name: "write files", Intent: json.RawMessage(`{}`)} + if _, _, err := store.RecordStepIntent(ctx, record.ID, intent); err != nil { + t.Fatalf("record crash-window intent: %v", err) + } + operation := &fakeOperation{result: json.RawMessage(`{"written":true}`)} + coordinator := newTestCoordinator(t, store) + step, err := coordinator.ExecuteStep(ctx, record.ID, intent, operation) + if err != nil { + t.Fatalf("recover not-applied operation: %v", err) + } + if step.Status != StepSucceeded || operation.applyCalls.Load() != 1 || operation.inspectCalls.Load() != 2 { + t.Fatalf("unexpected recovery result: step=%+v apply=%d inspect=%d", step, operation.applyCalls.Load(), operation.inspectCalls.Load()) + } +} + +func TestCoordinatorLeavesIntentPendingWhenInspectionIsUnknown(t *testing.T) { + t.Parallel() + ctx := context.Background() + store := openTestStore(t) + record := createTestTransaction(t, store, "recover-unknown") + intent := StepIntent{Key: "reload-gateway", Name: "reload gateway", Intent: json.RawMessage(`{}`)} + operation := &fakeOperation{inspectErr: errors.New("gateway unavailable")} + coordinator := newTestCoordinator(t, store) + step, err := coordinator.ExecuteStep(ctx, record.ID, intent, operation) + var uncertain *UncertainStepError + if !errors.As(err, &uncertain) { + t.Fatalf("expected uncertain step error, got step=%+v err=%v", step, err) + } + pending, err := store.PendingSteps(ctx, record.ID) + if err != nil { + t.Fatalf("read pending steps: %v", err) + } + if len(pending) != 1 || pending[0].Status != StepIntentRecorded { + t.Fatalf("uncertain step did not remain pending: %+v", pending) + } +} + +func TestCoordinatorExclusiveExecutionHonorsContext(t *testing.T) { + t.Parallel() + store := openTestStore(t) + coordinator := newTestCoordinator(t, store) + firstEntered := make(chan struct{}) + releaseFirst := make(chan struct{}) + firstDone := make(chan error, 1) + go func() { + firstDone <- coordinator.RunExclusive(context.Background(), func(context.Context) error { + close(firstEntered) + <-releaseFirst + return nil + }) + }() + <-firstEntered + cancelled, cancel := context.WithCancel(context.Background()) + cancel() + err := coordinator.RunExclusive(cancelled, func(context.Context) error { + t.Fatal("cancelled update entered exclusive section") + return nil + }) + if !errors.Is(err, context.Canceled) { + t.Fatalf("expected context cancellation, got %v", err) + } + close(releaseFirst) + if err := <-firstDone; err != nil { + t.Fatalf("first exclusive update failed: %v", err) + } +} + +type fakeOperation struct { + applied bool + result json.RawMessage + applyErr error + inspectErr error + applyCalls atomic.Int32 + inspectCalls atomic.Int32 +} + +func (o *fakeOperation) Apply(context.Context) error { + o.applyCalls.Add(1) + if o.applyErr == nil { + o.applied = true + } + return o.applyErr +} + +func (o *fakeOperation) Inspect(context.Context) (Inspection, error) { + o.inspectCalls.Add(1) + if o.inspectErr != nil { + return Inspection{}, o.inspectErr + } + if o.applied { + return Inspection{Status: InspectionApplied, Result: o.result}, nil + } + return Inspection{Status: InspectionNotApplied}, nil +} + +func createTestTransaction(t *testing.T, store *Store, suffix string) Transaction { + t.Helper() + record, _, err := store.CreateTransaction(context.Background(), CreateRequest{ + ID: "transaction-" + suffix, + IdempotencyKey: "request-" + suffix, + Source: "test", + Service: "backend", + }) + if err != nil { + t.Fatalf("create test transaction: %v", err) + } + return record +} + +func newTestCoordinator(t *testing.T, store *Store) *Coordinator { + t.Helper() + coordinator, err := NewCoordinator(store, slog.New(slog.NewTextHandler(io.Discard, nil))) + if err != nil { + t.Fatalf("create coordinator: %v", err) + } + return coordinator +} diff --git a/internal/transaction/errors.go b/internal/transaction/errors.go new file mode 100644 index 0000000..42b3431 --- /dev/null +++ b/internal/transaction/errors.go @@ -0,0 +1,26 @@ +package transaction + +import ( + "errors" + "fmt" +) + +var ( + ErrNotFound = errors.New("transaction record not found") + ErrActiveExists = errors.New("an unfinished transaction already exists") + ErrStepConflict = errors.New("step key already refers to different intent") + ErrStepNotPending = errors.New("step is not waiting for an execution result") +) + +// ActiveTransactionError 告知调用方当前阻塞新请求的事务。 +type ActiveTransactionError struct { + TransactionID string +} + +func (e *ActiveTransactionError) Error() string { + return fmt.Sprintf("%s: %s", ErrActiveExists, e.TransactionID) +} + +func (e *ActiveTransactionError) Unwrap() error { + return ErrActiveExists +} diff --git a/internal/transaction/model.go b/internal/transaction/model.go new file mode 100644 index 0000000..d5783e5 --- /dev/null +++ b/internal/transaction/model.go @@ -0,0 +1,69 @@ +package transaction + +import ( + "encoding/json" + "time" +) + +// Transaction 是一次更新请求的服务端持久化快照。 +type Transaction struct { + ID string + IdempotencyKey string + Source string + Service string + Request json.RawMessage + State State + Version int64 + CreatedAt time.Time + UpdatedAt time.Time +} + +// CreateRequest 包含创建事务所需的不可变请求信息。 +type CreateRequest struct { + ID string + IdempotencyKey string + Source string + Service string + Request json.RawMessage +} + +// StepStatus 是外部步骤的持久化执行状态。 +type StepStatus string + +const ( + StepIntentRecorded StepStatus = "INTENT_RECORDED" + StepSucceeded StepStatus = "SUCCEEDED" + StepFailed StepStatus = "FAILED" +) + +// Step 记录一次外部副作用的意图和最终核对结果。 +type Step struct { + TransactionID string + Key string + Name string + Status StepStatus + Intent json.RawMessage + Result json.RawMessage + Error string + CreatedAt time.Time + UpdatedAt time.Time +} + +// StepIntent 是执行外部操作前必须先持久化的内容。 +type StepIntent struct { + Key string + Name string + Intent json.RawMessage +} + +// Event 是供查询和 WSS 断联恢复使用的顺序事件。 +type Event struct { + Sequence int64 + TransactionID string + StepKey string + Kind string + FromState State + ToState State + Message string + CreatedAt time.Time +} diff --git a/internal/transaction/state.go b/internal/transaction/state.go new file mode 100644 index 0000000..ba576d3 --- /dev/null +++ b/internal/transaction/state.go @@ -0,0 +1,79 @@ +package transaction + +import "fmt" + +// State 是服务端更新事务的持久化状态。 +type State string + +const ( + StateCreated State = "CREATED" + StateValidating State = "VALIDATING" + StatePrepared State = "PREPARED" + StateStarting State = "STARTING" + StateSwitching State = "SWITCHING" + StateVerifying State = "VERIFYING" + StateDraining State = "DRAINING" + StateCommitted State = "COMMITTED" + StateRollingBack State = "ROLLING_BACK" + StateRolledBack State = "ROLLED_BACK" + StateFailed State = "FAILED" +) + +var forwardTransitions = map[State]State{ + StateCreated: StateValidating, + StateValidating: StatePrepared, + StatePrepared: StateStarting, + StateStarting: StateSwitching, + StateSwitching: StateVerifying, + StateVerifying: StateDraining, + StateDraining: StateCommitted, +} + +// Valid 报告状态是否属于当前状态机协议。 +func (s State) Valid() bool { + switch s { + case StateCreated, + StateValidating, + StatePrepared, + StateStarting, + StateSwitching, + StateVerifying, + StateDraining, + StateCommitted, + StateRollingBack, + StateRolledBack, + StateFailed: + return true + default: + return false + } +} + +// Terminal 报告事务是否已经不可再推进。 +func (s State) Terminal() bool { + return s == StateCommitted || s == StateRolledBack || s == StateFailed +} + +// CanTransitionTo 校验正常推进、回滚和不可恢复失败三类转换。 +func (s State) CanTransitionTo(next State) bool { + if !s.Valid() || !next.Valid() || s.Terminal() { + return false + } + if s == StateRollingBack { + return next == StateRolledBack || next == StateFailed + } + if next == StateRollingBack || next == StateFailed { + return true + } + return forwardTransitions[s] == next +} + +// TransitionError 表示状态机拒绝了一次转换。 +type TransitionError struct { + From State + To State +} + +func (e *TransitionError) Error() string { + return fmt.Sprintf("transaction state transition is not allowed: %s -> %s", e.From, e.To) +} diff --git a/internal/transaction/state_test.go b/internal/transaction/state_test.go new file mode 100644 index 0000000..dcc6a07 --- /dev/null +++ b/internal/transaction/state_test.go @@ -0,0 +1,34 @@ +package transaction + +import "testing" + +func TestStateTransitions(t *testing.T) { + t.Parallel() + forward := []State{ + StateCreated, + StateValidating, + StatePrepared, + StateStarting, + StateSwitching, + StateVerifying, + StateDraining, + StateCommitted, + } + for index := 0; index < len(forward)-1; index++ { + if !forward[index].CanTransitionTo(forward[index+1]) { + t.Fatalf("expected transition %s -> %s", forward[index], forward[index+1]) + } + } + if StateCreated.CanTransitionTo(StatePrepared) { + t.Fatal("state machine accepted a skipped forward state") + } + if !StateSwitching.CanTransitionTo(StateRollingBack) { + t.Fatal("state machine rejected rollback") + } + if !StateRollingBack.CanTransitionTo(StateRolledBack) { + t.Fatal("state machine rejected rollback completion") + } + if StateCommitted.CanTransitionTo(StateRollingBack) { + t.Fatal("terminal state accepted another transition") + } +} diff --git a/internal/transaction/store.go b/internal/transaction/store.go new file mode 100644 index 0000000..080d9f0 --- /dev/null +++ b/internal/transaction/store.go @@ -0,0 +1,704 @@ +package transaction + +import ( + "bytes" + "context" + "crypto/rand" + "database/sql" + "encoding/json" + "errors" + "fmt" + "net/url" + "os" + "path/filepath" + "strings" + "time" + + "github.com/ncruces/go-sqlite3/driver" +) + +const schemaVersion = 1 + +const schemaV1 = ` +CREATE TABLE transactions ( + id TEXT PRIMARY KEY, + idempotency_key TEXT NOT NULL UNIQUE, + source TEXT NOT NULL, + service TEXT NOT NULL, + request_json TEXT NOT NULL, + state TEXT NOT NULL CHECK (state IN ( + 'CREATED', 'VALIDATING', 'PREPARED', 'STARTING', 'SWITCHING', + 'VERIFYING', 'DRAINING', 'COMMITTED', 'ROLLING_BACK', + 'ROLLED_BACK', 'FAILED' + )), + version INTEGER NOT NULL, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL +) STRICT; + +CREATE UNIQUE INDEX one_unfinished_transaction + ON transactions ((1)) + WHERE state NOT IN ('COMMITTED', 'ROLLED_BACK', 'FAILED'); + +CREATE TABLE transaction_steps ( + transaction_id TEXT NOT NULL REFERENCES transactions(id) ON DELETE CASCADE, + step_key TEXT NOT NULL, + name TEXT NOT NULL, + status TEXT NOT NULL CHECK (status IN ('INTENT_RECORDED', 'SUCCEEDED', 'FAILED')), + intent_json TEXT NOT NULL, + result_json TEXT, + error_message TEXT NOT NULL DEFAULT '', + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + PRIMARY KEY (transaction_id, step_key) +) STRICT; + +CREATE TABLE transaction_events ( + sequence INTEGER PRIMARY KEY AUTOINCREMENT, + transaction_id TEXT NOT NULL REFERENCES transactions(id) ON DELETE CASCADE, + step_key TEXT NOT NULL DEFAULT '', + kind TEXT NOT NULL, + from_state TEXT NOT NULL DEFAULT '', + to_state TEXT NOT NULL DEFAULT '', + message TEXT NOT NULL DEFAULT '', + created_at TEXT NOT NULL +) STRICT; + +CREATE INDEX transaction_events_by_transaction + ON transaction_events (transaction_id, sequence); + +PRAGMA user_version = 1; +` + +// Store 是服务端 SQLite 事务记录。一个进程只应创建一个 Store。 +type Store struct { + db *sql.DB + now func() time.Time +} + +// OpenStore 打开本地 SQLite,并强制校验第一版持久化参数。 +func OpenStore(ctx context.Context, path string) (*Store, error) { + if path == "" { + return nil, errors.New("sqlite path is required") + } + absPath, err := filepath.Abs(path) + if err != nil { + return nil, fmt.Errorf("resolve sqlite path: %w", err) + } + if err := os.MkdirAll(filepath.Dir(absPath), 0o750); err != nil { + return nil, fmt.Errorf("create sqlite directory: %w", err) + } + + dsn := (&url.URL{ + Scheme: "file", + Path: absPath, + RawQuery: url.Values{"_txlock": {"immediate"}}.Encode(), + }).String() + db, err := driver.Open(dsn) + if err != nil { + return nil, fmt.Errorf("open sqlite driver: %w", err) + } + // 单连接是服务端事务串行化的一部分,也保证连接级 PRAGMA 始终生效。 + db.SetMaxOpenConns(1) + db.SetMaxIdleConns(1) + + closeOnError := func(cause error) (*Store, error) { + _ = db.Close() + return nil, cause + } + if err := db.PingContext(ctx); err != nil { + return closeOnError(fmt.Errorf("ping sqlite: %w", err)) + } + if err := configureSQLite(ctx, db); err != nil { + return closeOnError(err) + } + if err := migrate(ctx, db); err != nil { + return closeOnError(err) + } + if err := os.Chmod(absPath, 0o600); err != nil { + return closeOnError(fmt.Errorf("set sqlite permissions: %w", err)) + } + return &Store{db: db, now: time.Now}, nil +} + +func configureSQLite(ctx context.Context, db *sql.DB) error { + var journalMode string + if err := db.QueryRowContext(ctx, "PRAGMA journal_mode = DELETE").Scan(&journalMode); err != nil { + return fmt.Errorf("set sqlite journal mode: %w", err) + } + if journalMode != "delete" { + return fmt.Errorf("sqlite journal mode mismatch: got %q, want %q", journalMode, "delete") + } + if _, err := db.ExecContext(ctx, "PRAGMA synchronous = EXTRA"); err != nil { + return fmt.Errorf("set sqlite synchronous: %w", err) + } + var synchronous int + if err := db.QueryRowContext(ctx, "PRAGMA synchronous").Scan(&synchronous); err != nil { + return fmt.Errorf("read sqlite synchronous: %w", err) + } + if synchronous != 3 { + return fmt.Errorf("sqlite synchronous mismatch: got %d, want 3 (EXTRA)", synchronous) + } + if _, err := db.ExecContext(ctx, "PRAGMA foreign_keys = ON"); err != nil { + return fmt.Errorf("enable sqlite foreign keys: %w", err) + } + var foreignKeys int + if err := db.QueryRowContext(ctx, "PRAGMA foreign_keys").Scan(&foreignKeys); err != nil { + return fmt.Errorf("read sqlite foreign_keys: %w", err) + } + if foreignKeys != 1 { + return errors.New("sqlite foreign_keys is not enabled") + } + return nil +} + +func migrate(ctx context.Context, db *sql.DB) error { + var version int + if err := db.QueryRowContext(ctx, "PRAGMA user_version").Scan(&version); err != nil { + return fmt.Errorf("read sqlite schema version: %w", err) + } + if version > schemaVersion { + return fmt.Errorf("sqlite schema version %d is newer than supported version %d", version, schemaVersion) + } + if version == schemaVersion { + return nil + } + tx, err := db.BeginTx(ctx, &sql.TxOptions{Isolation: sql.LevelSerializable}) + if err != nil { + return fmt.Errorf("begin sqlite migration: %w", err) + } + defer tx.Rollback() + if _, err := tx.ExecContext(ctx, schemaV1); err != nil { + return fmt.Errorf("apply sqlite schema version 1: %w", err) + } + if err := tx.Commit(); err != nil { + return fmt.Errorf("commit sqlite migration: %w", err) + } + return nil +} + +// Close 关闭服务端 SQLite。 +func (s *Store) Close() error { + return s.db.Close() +} + +// CreateTransaction 原子处理幂等重试和单活动事务约束。created=false 表示返回已有幂等事务。 +func (s *Store) CreateTransaction(ctx context.Context, request CreateRequest) (record Transaction, created bool, err error) { + if err := validateCreateRequest(&request); err != nil { + return Transaction{}, false, err + } + if request.ID == "" { + request.ID = rand.Text() + } + now := s.now().UTC() + tx, err := s.db.BeginTx(ctx, &sql.TxOptions{Isolation: sql.LevelSerializable}) + if err != nil { + return Transaction{}, false, fmt.Errorf("begin create transaction: %w", err) + } + defer tx.Rollback() + + existing, err := getTransactionByIdempotencyKey(ctx, tx, request.IdempotencyKey) + if err == nil { + return existing, false, nil + } + if !errors.Is(err, ErrNotFound) { + return Transaction{}, false, err + } + active, err := getActiveTransaction(ctx, tx) + if err == nil { + return Transaction{}, false, &ActiveTransactionError{TransactionID: active.ID} + } + if !errors.Is(err, ErrNotFound) { + return Transaction{}, false, err + } + + requestJSON := string(request.Request) + _, err = tx.ExecContext(ctx, ` + INSERT INTO transactions ( + id, idempotency_key, source, service, request_json, + state, version, created_at, updated_at + ) VALUES (?, ?, ?, ?, ?, ?, 1, ?, ?)`, + request.ID, + request.IdempotencyKey, + request.Source, + request.Service, + requestJSON, + StateCreated, + formatTime(now), + formatTime(now), + ) + if err != nil { + return Transaction{}, false, fmt.Errorf("insert transaction: %w", err) + } + if err := insertEvent(ctx, tx, request.ID, "", "TRANSACTION_CREATED", "", StateCreated, "", now); err != nil { + return Transaction{}, false, err + } + if err := tx.Commit(); err != nil { + return Transaction{}, false, fmt.Errorf("commit create transaction: %w", err) + } + return Transaction{ + ID: request.ID, + IdempotencyKey: request.IdempotencyKey, + Source: request.Source, + Service: request.Service, + Request: cloneJSON(request.Request), + State: StateCreated, + Version: 1, + CreatedAt: now, + UpdatedAt: now, + }, true, nil +} + +func validateCreateRequest(request *CreateRequest) error { + if strings.TrimSpace(request.IdempotencyKey) == "" { + return errors.New("idempotency key is required") + } + if strings.TrimSpace(request.Source) == "" { + return errors.New("transaction source is required") + } + if strings.TrimSpace(request.Service) == "" { + return errors.New("transaction service is required") + } + if len(request.Request) == 0 { + request.Request = json.RawMessage(`{}`) + } + if !json.Valid(request.Request) { + return errors.New("transaction request is not valid JSON") + } + return nil +} + +// Transaction 返回指定事务的最新持久化快照。 +func (s *Store) Transaction(ctx context.Context, id string) (Transaction, error) { + return getTransactionByID(ctx, s.db, id) +} + +// ActiveTransaction 返回当前唯一未结束事务。 +func (s *Store) ActiveTransaction(ctx context.Context) (Transaction, error) { + return getActiveTransaction(ctx, s.db) +} + +// Transition 校验并原子提交状态变化及其恢复事件。 +func (s *Store) Transition(ctx context.Context, id string, next State, message string) (Transaction, error) { + if !next.Valid() { + return Transaction{}, fmt.Errorf("unknown transaction state: %q", next) + } + tx, err := s.db.BeginTx(ctx, &sql.TxOptions{Isolation: sql.LevelSerializable}) + if err != nil { + return Transaction{}, fmt.Errorf("begin state transition: %w", err) + } + defer tx.Rollback() + record, err := getTransactionByID(ctx, tx, id) + if err != nil { + return Transaction{}, err + } + if record.State == next { + return record, nil + } + if !record.State.CanTransitionTo(next) { + return Transaction{}, &TransitionError{From: record.State, To: next} + } + now := s.now().UTC() + result, err := tx.ExecContext(ctx, ` + UPDATE transactions + SET state = ?, version = version + 1, updated_at = ? + WHERE id = ? AND version = ?`, + next, formatTime(now), id, record.Version, + ) + if err != nil { + return Transaction{}, fmt.Errorf("update transaction state: %w", err) + } + rows, err := result.RowsAffected() + if err != nil { + return Transaction{}, fmt.Errorf("read updated transaction rows: %w", err) + } + if rows != 1 { + return Transaction{}, errors.New("transaction changed concurrently") + } + if err := insertEvent(ctx, tx, id, "", "TRANSACTION_STATE_CHANGED", record.State, next, message, now); err != nil { + return Transaction{}, err + } + if err := tx.Commit(); err != nil { + return Transaction{}, fmt.Errorf("commit state transition: %w", err) + } + record.State = next + record.Version++ + record.UpdatedAt = now + return record, nil +} + +// RecordStepIntent 先于外部副作用持久化步骤意图。created=false 表示相同意图已经存在。 +func (s *Store) RecordStepIntent(ctx context.Context, transactionID string, intent StepIntent) (record Step, created bool, err error) { + if strings.TrimSpace(intent.Key) == "" { + return Step{}, false, errors.New("step key is required") + } + if strings.TrimSpace(intent.Name) == "" { + return Step{}, false, errors.New("step name is required") + } + if len(intent.Intent) == 0 { + intent.Intent = json.RawMessage(`{}`) + } + if !json.Valid(intent.Intent) { + return Step{}, false, errors.New("step intent is not valid JSON") + } + + tx, err := s.db.BeginTx(ctx, &sql.TxOptions{Isolation: sql.LevelSerializable}) + if err != nil { + return Step{}, false, fmt.Errorf("begin record step intent: %w", err) + } + defer tx.Rollback() + transactionRecord, err := getTransactionByID(ctx, tx, transactionID) + if err != nil { + return Step{}, false, err + } + if transactionRecord.State.Terminal() { + return Step{}, false, fmt.Errorf("cannot record a step for terminal transaction %s", transactionID) + } + existing, err := getStep(ctx, tx, transactionID, intent.Key) + if err == nil { + if existing.Name != intent.Name || !bytes.Equal(existing.Intent, intent.Intent) { + return Step{}, false, ErrStepConflict + } + return existing, false, nil + } + if !errors.Is(err, ErrNotFound) { + return Step{}, false, err + } + + now := s.now().UTC() + _, err = tx.ExecContext(ctx, ` + INSERT INTO transaction_steps ( + transaction_id, step_key, name, status, intent_json, + result_json, error_message, created_at, updated_at + ) VALUES (?, ?, ?, ?, ?, NULL, '', ?, ?)`, + transactionID, + intent.Key, + intent.Name, + StepIntentRecorded, + string(intent.Intent), + formatTime(now), + formatTime(now), + ) + if err != nil { + return Step{}, false, fmt.Errorf("insert step intent: %w", err) + } + if err := insertEvent(ctx, tx, transactionID, intent.Key, "STEP_INTENT_RECORDED", "", "", intent.Name, now); err != nil { + return Step{}, false, err + } + if err := tx.Commit(); err != nil { + return Step{}, false, fmt.Errorf("commit step intent: %w", err) + } + return Step{ + TransactionID: transactionID, + Key: intent.Key, + Name: intent.Name, + Status: StepIntentRecorded, + Intent: cloneJSON(intent.Intent), + CreatedAt: now, + UpdatedAt: now, + }, true, nil +} + +// CompleteStep 原子记录外部状态核对后的最终结果。 +func (s *Store) CompleteStep(ctx context.Context, transactionID, stepKey string, status StepStatus, result json.RawMessage, errorMessage string) (Step, error) { + if status != StepSucceeded && status != StepFailed { + return Step{}, fmt.Errorf("invalid final step status: %q", status) + } + if len(result) > 0 && !json.Valid(result) { + return Step{}, errors.New("step result is not valid JSON") + } + tx, err := s.db.BeginTx(ctx, &sql.TxOptions{Isolation: sql.LevelSerializable}) + if err != nil { + return Step{}, fmt.Errorf("begin complete step: %w", err) + } + defer tx.Rollback() + record, err := getStep(ctx, tx, transactionID, stepKey) + if err != nil { + return Step{}, err + } + if record.Status == status { + if !bytes.Equal(record.Result, result) || record.Error != errorMessage { + return Step{}, ErrStepConflict + } + return record, nil + } + if record.Status != StepIntentRecorded { + return Step{}, ErrStepNotPending + } + now := s.now().UTC() + var resultValue any + if len(result) > 0 { + resultValue = string(result) + } + updateResult, err := tx.ExecContext(ctx, ` + UPDATE transaction_steps + SET status = ?, result_json = ?, error_message = ?, updated_at = ? + WHERE transaction_id = ? AND step_key = ? AND status = ?`, + status, + resultValue, + errorMessage, + formatTime(now), + transactionID, + stepKey, + StepIntentRecorded, + ) + if err != nil { + return Step{}, fmt.Errorf("update step result: %w", err) + } + updatedRows, err := updateResult.RowsAffected() + if err != nil { + return Step{}, fmt.Errorf("read updated step rows: %w", err) + } + if updatedRows != 1 { + return Step{}, errors.New("step changed concurrently") + } + eventKind := "STEP_SUCCEEDED" + if status == StepFailed { + eventKind = "STEP_FAILED" + } + if err := insertEvent(ctx, tx, transactionID, stepKey, eventKind, "", "", errorMessage, now); err != nil { + return Step{}, err + } + if err := tx.Commit(); err != nil { + return Step{}, fmt.Errorf("commit step result: %w", err) + } + record.Status = status + record.Result = cloneJSON(result) + record.Error = errorMessage + record.UpdatedAt = now + return record, nil +} + +// PendingSteps 返回重启后必须先核对实际外部状态的步骤。 +func (s *Store) PendingSteps(ctx context.Context, transactionID string) ([]Step, error) { + rows, err := s.db.QueryContext(ctx, ` + SELECT transaction_id, step_key, name, status, intent_json, + result_json, error_message, created_at, updated_at + FROM transaction_steps + WHERE transaction_id = ? AND status = ? + ORDER BY created_at, step_key`, transactionID, StepIntentRecorded) + if err != nil { + return nil, fmt.Errorf("query pending steps: %w", err) + } + defer rows.Close() + var records []Step + for rows.Next() { + record, err := scanStep(rows) + if err != nil { + return nil, err + } + records = append(records, record) + } + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("iterate pending steps: %w", err) + } + return records, nil +} + +// EventsAfter 返回指定顺序位置之后的事务事件。 +func (s *Store) EventsAfter(ctx context.Context, transactionID string, afterSequence int64, limit int) ([]Event, error) { + if afterSequence < 0 { + return nil, errors.New("event sequence must not be negative") + } + if limit < 1 || limit > 1000 { + return nil, errors.New("event limit must be between 1 and 1000") + } + rows, err := s.db.QueryContext(ctx, ` + SELECT sequence, transaction_id, step_key, kind, + from_state, to_state, message, created_at + FROM transaction_events + WHERE transaction_id = ? AND sequence > ? + ORDER BY sequence + LIMIT ?`, transactionID, afterSequence, limit) + if err != nil { + return nil, fmt.Errorf("query transaction events: %w", err) + } + defer rows.Close() + var events []Event + for rows.Next() { + var event Event + var fromState, toState, createdAt string + if err := rows.Scan( + &event.Sequence, + &event.TransactionID, + &event.StepKey, + &event.Kind, + &fromState, + &toState, + &event.Message, + &createdAt, + ); err != nil { + return nil, fmt.Errorf("scan transaction event: %w", err) + } + event.FromState = State(fromState) + event.ToState = State(toState) + event.CreatedAt, err = parseTime(createdAt) + if err != nil { + return nil, err + } + events = append(events, event) + } + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("iterate transaction events: %w", err) + } + return events, nil +} + +type queryRower interface { + QueryRowContext(context.Context, string, ...any) *sql.Row +} + +type rowScanner interface { + Scan(...any) error +} + +func getTransactionByID(ctx context.Context, query queryRower, id string) (Transaction, error) { + return scanTransaction(query.QueryRowContext(ctx, ` + SELECT id, idempotency_key, source, service, request_json, + state, version, created_at, updated_at + FROM transactions + WHERE id = ?`, id)) +} + +func getTransactionByIdempotencyKey(ctx context.Context, query queryRower, key string) (Transaction, error) { + return scanTransaction(query.QueryRowContext(ctx, ` + SELECT id, idempotency_key, source, service, request_json, + state, version, created_at, updated_at + FROM transactions + WHERE idempotency_key = ?`, key)) +} + +func getActiveTransaction(ctx context.Context, query queryRower) (Transaction, error) { + return scanTransaction(query.QueryRowContext(ctx, ` + SELECT id, idempotency_key, source, service, request_json, + state, version, created_at, updated_at + FROM transactions + WHERE state NOT IN (?, ?, ?) + LIMIT 1`, StateCommitted, StateRolledBack, StateFailed)) +} + +func scanTransaction(row rowScanner) (Transaction, error) { + var record Transaction + var requestJSON, state, createdAt, updatedAt string + if err := row.Scan( + &record.ID, + &record.IdempotencyKey, + &record.Source, + &record.Service, + &requestJSON, + &state, + &record.Version, + &createdAt, + &updatedAt, + ); err != nil { + if errors.Is(err, sql.ErrNoRows) { + return Transaction{}, ErrNotFound + } + return Transaction{}, fmt.Errorf("scan transaction: %w", err) + } + record.Request = json.RawMessage(requestJSON) + record.State = State(state) + var err error + record.CreatedAt, err = parseTime(createdAt) + if err != nil { + return Transaction{}, err + } + record.UpdatedAt, err = parseTime(updatedAt) + if err != nil { + return Transaction{}, err + } + return record, nil +} + +func getStep(ctx context.Context, query queryRower, transactionID, stepKey string) (Step, error) { + return scanStep(query.QueryRowContext(ctx, ` + SELECT transaction_id, step_key, name, status, intent_json, + result_json, error_message, created_at, updated_at + FROM transaction_steps + WHERE transaction_id = ? AND step_key = ?`, transactionID, stepKey)) +} + +func scanStep(row rowScanner) (Step, error) { + var record Step + var status, intentJSON, createdAt, updatedAt string + var resultJSON sql.NullString + if err := row.Scan( + &record.TransactionID, + &record.Key, + &record.Name, + &status, + &intentJSON, + &resultJSON, + &record.Error, + &createdAt, + &updatedAt, + ); err != nil { + if errors.Is(err, sql.ErrNoRows) { + return Step{}, ErrNotFound + } + return Step{}, fmt.Errorf("scan transaction step: %w", err) + } + record.Status = StepStatus(status) + record.Intent = json.RawMessage(intentJSON) + if resultJSON.Valid { + record.Result = json.RawMessage(resultJSON.String) + } + var err error + record.CreatedAt, err = parseTime(createdAt) + if err != nil { + return Step{}, err + } + record.UpdatedAt, err = parseTime(updatedAt) + if err != nil { + return Step{}, err + } + return record, nil +} + +func insertEvent( + ctx context.Context, + tx *sql.Tx, + transactionID string, + stepKey string, + kind string, + fromState State, + toState State, + message string, + createdAt time.Time, +) error { + _, err := tx.ExecContext(ctx, ` + INSERT INTO transaction_events ( + transaction_id, step_key, kind, from_state, to_state, message, created_at + ) VALUES (?, ?, ?, ?, ?, ?, ?)`, + transactionID, + stepKey, + kind, + fromState, + toState, + message, + formatTime(createdAt), + ) + if err != nil { + return fmt.Errorf("insert transaction event: %w", err) + } + return nil +} + +func formatTime(value time.Time) string { + return value.UTC().Format(time.RFC3339Nano) +} + +func parseTime(value string) (time.Time, error) { + parsed, err := time.Parse(time.RFC3339Nano, value) + if err != nil { + return time.Time{}, fmt.Errorf("parse persisted time %q: %w", value, err) + } + return parsed, nil +} + +func cloneJSON(value json.RawMessage) json.RawMessage { + if len(value) == 0 { + return nil + } + return bytes.Clone(value) +} diff --git a/internal/transaction/store_test.go b/internal/transaction/store_test.go new file mode 100644 index 0000000..29d88d8 --- /dev/null +++ b/internal/transaction/store_test.go @@ -0,0 +1,220 @@ +package transaction + +import ( + "context" + "encoding/json" + "errors" + "path/filepath" + "sync" + "testing" + "time" +) + +func TestStoreCreateIsIdempotentAndAllowsOnlyOneActiveTransaction(t *testing.T) { + t.Parallel() + ctx := context.Background() + store := openTestStore(t) + request := CreateRequest{ + ID: "transaction-1", + IdempotencyKey: "request-1", + Source: "test", + Service: "backend", + Request: json.RawMessage(`{"service":"backend"}`), + } + created, isNew, err := store.CreateTransaction(ctx, request) + if err != nil { + t.Fatalf("create transaction: %v", err) + } + if !isNew || created.State != StateCreated { + t.Fatalf("unexpected created transaction: %+v new=%v", created, isNew) + } + + retried, isNew, err := store.CreateTransaction(ctx, request) + if err != nil { + t.Fatalf("retry transaction: %v", err) + } + if isNew || retried.ID != created.ID { + t.Fatalf("idempotent retry created another transaction: %+v", retried) + } + + _, _, err = store.CreateTransaction(ctx, CreateRequest{ + ID: "transaction-2", + IdempotencyKey: "request-2", + Source: "test", + Service: "frontend", + }) + var activeErr *ActiveTransactionError + if !errors.As(err, &activeErr) || activeErr.TransactionID != created.ID { + t.Fatalf("expected active transaction error, got %v", err) + } + + if _, err := store.Transition(ctx, created.ID, StateFailed, "test terminal state"); err != nil { + t.Fatalf("finish first transaction: %v", err) + } + second, isNew, err := store.CreateTransaction(ctx, CreateRequest{ + ID: "transaction-2", + IdempotencyKey: "request-2", + Source: "test", + Service: "frontend", + }) + if err != nil || !isNew || second.ID != "transaction-2" { + t.Fatalf("create transaction after terminal state: record=%+v new=%v err=%v", second, isNew, err) + } +} + +func TestStoreTransitionStepAndEventPersistence(t *testing.T) { + t.Parallel() + ctx := context.Background() + databasePath := filepath.Join(t.TempDir(), "transaction.db") + store, err := OpenStore(ctx, databasePath) + if err != nil { + t.Fatalf("open store: %v", err) + } + fixedTime := time.Date(2026, time.August, 15, 10, 0, 0, 0, time.UTC) + store.now = func() time.Time { return fixedTime } + record, _, err := store.CreateTransaction(ctx, CreateRequest{ + ID: "transaction-persisted", + IdempotencyKey: "request-persisted", + Source: "test", + Service: "all", + }) + if err != nil { + t.Fatalf("create transaction: %v", err) + } + if _, err := store.Transition(ctx, record.ID, StateValidating, "validation started"); err != nil { + t.Fatalf("transition transaction: %v", err) + } + if _, err := store.Transition(ctx, record.ID, StatePrepared, "validation completed"); err != nil { + t.Fatalf("transition transaction: %v", err) + } + step, isNew, err := store.RecordStepIntent(ctx, record.ID, StepIntent{ + Key: "prepare-files", + Name: "prepare immutable files", + Intent: json.RawMessage(`{"sha256":"abc"}`), + }) + if err != nil || !isNew || step.Status != StepIntentRecorded { + t.Fatalf("record step intent: step=%+v new=%v err=%v", step, isNew, err) + } + if err := store.Close(); err != nil { + t.Fatalf("close store: %v", err) + } + + reopened, err := OpenStore(ctx, databasePath) + if err != nil { + t.Fatalf("reopen store: %v", err) + } + t.Cleanup(func() { _ = reopened.Close() }) + persisted, err := reopened.Transaction(ctx, record.ID) + if err != nil { + t.Fatalf("read persisted transaction: %v", err) + } + if persisted.State != StatePrepared || persisted.Version != 3 { + t.Fatalf("unexpected persisted transaction: %+v", persisted) + } + pending, err := reopened.PendingSteps(ctx, record.ID) + if err != nil { + t.Fatalf("read pending steps: %v", err) + } + if len(pending) != 1 || pending[0].Key != step.Key { + t.Fatalf("unexpected pending steps: %+v", pending) + } + completed, err := reopened.CompleteStep(ctx, record.ID, step.Key, StepSucceeded, json.RawMessage(`{"installed":true}`), "") + if err != nil || completed.Status != StepSucceeded { + t.Fatalf("complete step: step=%+v err=%v", completed, err) + } + events, err := reopened.EventsAfter(ctx, record.ID, 0, 100) + if err != nil { + t.Fatalf("read events: %v", err) + } + if len(events) != 5 { + t.Fatalf("unexpected event count: got %d events=%+v", len(events), events) + } + for index := 1; index < len(events); index++ { + if events[index].Sequence <= events[index-1].Sequence { + t.Fatalf("events are not ordered: %+v", events) + } + } +} + +func TestStoreRejectsInvalidTransitionAndConflictingStepIntent(t *testing.T) { + t.Parallel() + ctx := context.Background() + store := openTestStore(t) + record, _, err := store.CreateTransaction(ctx, CreateRequest{ + ID: "transaction-conflict", + IdempotencyKey: "request-conflict", + Source: "test", + Service: "backend", + }) + if err != nil { + t.Fatalf("create transaction: %v", err) + } + _, err = store.Transition(ctx, record.ID, StatePrepared, "skip validation") + var transitionErr *TransitionError + if !errors.As(err, &transitionErr) { + t.Fatalf("expected transition error, got %v", err) + } + intent := StepIntent{Key: "same-key", Name: "first", Intent: json.RawMessage(`{"value":1}`)} + if _, _, err := store.RecordStepIntent(ctx, record.ID, intent); err != nil { + t.Fatalf("record first step intent: %v", err) + } + _, _, err = store.RecordStepIntent(ctx, record.ID, StepIntent{ + Key: intent.Key, + Name: "different", + Intent: intent.Intent, + }) + if !errors.Is(err, ErrStepConflict) { + t.Fatalf("expected step conflict, got %v", err) + } +} + +func TestStoreSerializesConcurrentCreates(t *testing.T) { + t.Parallel() + ctx := context.Background() + store := openTestStore(t) + const workers = 12 + var wait sync.WaitGroup + wait.Add(workers) + results := make(chan error, workers) + for index := 0; index < workers; index++ { + go func(index int) { + defer wait.Done() + _, _, err := store.CreateTransaction(ctx, CreateRequest{ + IdempotencyKey: "concurrent-" + string(rune('A'+index)), + Source: "test", + Service: "backend", + }) + results <- err + }(index) + } + wait.Wait() + close(results) + var created, rejected int + for err := range results { + switch { + case err == nil: + created++ + case errors.Is(err, ErrActiveExists): + rejected++ + default: + t.Fatalf("unexpected create error: %v", err) + } + } + if created != 1 || rejected != workers-1 { + t.Fatalf("unexpected concurrent result: created=%d rejected=%d", created, rejected) + } +} + +func openTestStore(t *testing.T) *Store { + t.Helper() + store, err := OpenStore(context.Background(), filepath.Join(t.TempDir(), "transaction.db")) + if err != nil { + t.Fatalf("open test store: %v", err) + } + t.Cleanup(func() { + if err := store.Close(); err != nil { + t.Errorf("close test store: %v", err) + } + }) + return store +} diff --git a/main.go b/main.go new file mode 100644 index 0000000..b3bb151 --- /dev/null +++ b/main.go @@ -0,0 +1,4 @@ +package main + +// 命令入口会在事务内核稳定后接入。当前提交只构建和验证底层能力。 +func main() {}