diff --git a/Makefile b/Makefile index 7fcc38a..792ed88 100644 --- a/Makefile +++ b/Makefile @@ -1,6 +1,6 @@ GO ?= go -.PHONY: fmt test vet build integration sdk-check +.PHONY: fmt test vet build integration sdk-check protocol-check fmt: $(GO) fmt ./... cd sdk/go && $(GO) fmt ./... @@ -31,3 +31,6 @@ integration: sdk-check: ./scripts/test-sdk-isolation.sh + +protocol-check: + ./scripts/test-protocol-compatibility.sh diff --git a/README.md b/README.md index 6321452..0c9fe3a 100644 --- a/README.md +++ b/README.md @@ -73,6 +73,7 @@ make test make vet make build make integration +make protocol-check ``` `make integration` 要求 Docker、OpenSSL 等标准命令,启动独立的临时 MySQL 容器,使用随机凭据和回环随机端口;完成后删除该测试容器。不会连接已有业务数据库。可通过 `GO`、`DOCKER` 和 `ORBIT_TEST_MYSQL_IMAGE` 指定工具/测试镜像。 diff --git a/docs/08-development-plan.md b/docs/08-development-plan.md index dd3042c..1dc3dfa 100644 --- a/docs/08-development-plan.md +++ b/docs/08-development-plan.md @@ -31,7 +31,7 @@ | P4 Weave 主流程 | SDK 定义构建与发布校验、实例、顺序/分支/结构化并行汇合、信号/定时等待、版本绑定 | 重启接续、早到/重复信号、并行竞争、内部事件重放、发布版本兼容;端到端通用流程 | 已完成平台功能阶段验收:[原功能及W01–W17逐项核对](reports/phase-4-5.md)、[版本/暂停/全停回归](reports/phase-4-5-races.md)、[提交故障边界](reports/phase-6-fault-boundaries.md)。研发发布、固定版本、顺序/分支/有限并行、信号/时间等待、公共API/SDK及可运行示例均已交付;生产拓扑与发布兼容继续P6/P7 | | P5 Weave 恢复与查询 | 有界查询、暂停继续、取消、人工核查恢复、控制门控与审计 | 轮次与尝试独立预算;暂停/开始/回执/超时竞争;恢复保留预算;未知结果不盲目重复 | 已完成平台功能阶段验收:[原功能核对](reports/phase-4-5.md)。有界查询、独立预算、暂停继续/取消、六类恢复、历史/筛选/事件/来源与SDK场景均已交付;真实竞争、COMMIT断线及进程全停保留原身份、截止和预算。运维、容量与首版发布继续P6/P7 | | P6 生产验证与运维 | 完整监控、凭据轮换、备份恢复、滚动升级、运行手册、可复现负载和故障工具 | 对照生产验收清单执行,报告环境/指标/遗留项;容量、RPO/RTO 按真实拓扑验证 | 进行中;已补[精确故障边界](reports/phase-6-fault-boundaries.md)和[进程观测](reports/phase-6-observability.md);继续业务积压/阶段延迟、凭据轮换及其余生产门槛 | -| P7 首版交付核对 | SDK 兼容与发布准备、完整 API 手册、场景示例、全量回归及交付清单 | 新接入者按文档跑通全部示例;所有首版能力可追溯到实现与测试;报告实际生产边界 | 进行中;已补[通用文件转换示例](reports/phase-7-file-conversion.md)和[SDK独立发布门禁](reports/phase-7-sdk-release.md),仍需协议兼容矩阵、全量资源充足回归和交付清单 | +| P7 首版交付核对 | SDK 兼容与发布准备、完整 API 手册、场景示例、全量回归及交付清单 | 新接入者按文档跑通全部示例;所有首版能力可追溯到实现与测试;报告实际生产边界 | 进行中;已补[通用文件转换示例](reports/phase-7-file-conversion.md)、[SDK独立发布门禁](reports/phase-7-sdk-release.md)和[当前协议兼容矩阵](reports/phase-7-compatibility.md),仍需带标签的跨版本矩阵、全量资源充足回归和交付清单 | SDK、文档与示例随 P1–P5 同步开发,P7 是全量核对,不是最后才开始补文档。 diff --git a/docs/reports/phase-7-compatibility.md b/docs/reports/phase-7-compatibility.md new file mode 100644 index 0000000..e6427a4 --- /dev/null +++ b/docs/reports/phase-7-compatibility.md @@ -0,0 +1,20 @@ +# P7 协议兼容矩阵与门禁 + +验证日期:2026-09-20。本批把当前服务端、独立 Go SDK 和机器可读 WebSocket schema 的版本边界固定为可执行检查,并明确历史版本组合尚未具备证据。 + +## 检查内容 + +[`scripts/test-protocol-compatibility.sh`](../../scripts/test-protocol-compatibility.sh) 检查: + +- 服务端和 SDK 的 Worker 子协议均为 `pulse.worker.v1`,连接路径均为 `/v1/worker/connect`; +- 服务端和 SDK 的结果事件子协议均为 `orbit.events.v1`,连接路径均为 `/v1/events/connect`; +- Worker/事件帧构造和 JSON schema 的 `protocol_version` 均为 `1`,schema `$id` 分别固定为 worker.v1 与 events.v1; +- HTTP 接口主版本为 `/v1`,流程定义 schema 版本为 `1`,当前数据库迁移版本为 v28。 + +## 实际结果 + +```json +{"http_api":"/v1","worker_subprotocol":"pulse.worker.v1","event_subprotocol":"orbit.events.v1","worker_frame_version":"1","event_frame_version":"1","workflow_schema_version":1,"status":"current_contracts_match","historical_pairs":"not_verified"} +``` + +当前同一 checkout 的 SDK 单元/竞态测试、服务端协议测试和真实 MySQL/HTTP/WSS 集成测试覆盖当前组合。仓库尚未有正式 SDK 或服务端标签,因此“旧 SDK + 新服务端”和“新 SDK + 旧服务端”不能标记为通过;发布候选需建立至少一个带标签的旧版本 fixture 后再补两组矩阵回归。 diff --git a/docs/reports/phase-7-sdk-release.md b/docs/reports/phase-7-sdk-release.md index 7f31912..e7ee3b8 100644 --- a/docs/reports/phase-7-sdk-release.md +++ b/docs/reports/phase-7-sdk-release.md @@ -20,7 +20,7 @@ {"sdk_module":"git.yuntuo.hk/k2safe/orbit/sdk/go","independent":true,"package_count":27,"forbidden_dependencies":0} ``@@BT@ -该轮覆盖 SDK 核心、Worker/事件客户端、流程/任务 API、负载工具和全部可运行示例;新增文件转换示例的单元测试也通过。文档本地链接、shell 语法和 `git diff --check` 同步通过。 +该轮覆盖 SDK 核心、Worker/事件客户端、流程/任务 API、负载工具和全部可运行示例;新增文件转换示例的单元测试也通过。协议常量、路由和 schema 的当前版本同步检查由 [`make protocol-check`](../sdk/compatibility.md) 通过。文档本地链接、shell 语法和 `git diff --check` 同步通过。 ## 发布边界 diff --git a/docs/sdk/README.md b/docs/sdk/README.md index 98f2e5e..4777438 100644 --- a/docs/sdk/README.md +++ b/docs/sdk/README.md @@ -4,7 +4,7 @@ module:`git.yuntuo.hk/k2safe/orbit/sdk/go`。SDK 使用独立 go.mod,依赖 Go 标准库与 `github.com/coder/websocket v1.8.15`,不引用服务端模型或数据库实现。 -发布候选先执行 [SDK 独立发布门禁](release.md),确认 `GOWORK=off` 下依赖图、竞态测试、vet 和全包构建均通过;门禁不替代协议兼容矩阵或业务副作用回归。 +发布候选先执行 [SDK 独立发布门禁](release.md) 和[协议兼容矩阵](compatibility.md),确认 `GOWORK=off` 下依赖图、竞态测试、vet 和全包构建均通过;门禁不替代业务副作用回归。 任务相对延迟使用SubmitTaskRequest.DelayMS,由数据库首次受理时间计算,原键重报不重新计时;[完整延迟示例](../examples/delayed-task.md)包含Worker执行和历史核对。非零DelayMS不能与AvailableAt同用,旧服务须先升级。 diff --git a/docs/sdk/compatibility.md b/docs/sdk/compatibility.md new file mode 100644 index 0000000..d3b0110 --- /dev/null +++ b/docs/sdk/compatibility.md @@ -0,0 +1,27 @@ +# SDK 与协议兼容矩阵 + +当前发布线是 HTTP `/v1`、Worker WebSocket `pulse.worker.v1`、结果事件 WebSocket `orbit.events.v1`、Worker/事件帧 `protocol_version=1`、流程定义 `schema_version=1`,数据库要求 MySQL 8.4+ 与 Orbit schema v28。协议门禁由 `make protocol-check` 执行,检查服务端常量、SDK 常量、路由和机器可读 schema 是否一致。 + +| SDK / 客户端 | 服务端 | 当前证据 | 结论 | +| --- | --- | --- | --- | +| 当前仓库 SDK | 当前仓库服务端 | `scripts/test-protocol-compatibility.sh`、SDK 单元/竞态测试、HTTP/WSS 集成测试 | 已验证 | +| 上一个正式 SDK 标签 | 当前服务端 | 仓库尚未发布正式 SDK 标签 | 未验证,不得宣称兼容 | +| 当前 SDK | 上一个正式服务端标签 | 仓库尚未发布正式服务端标签 | 未验证,不得宣称兼容 | +| 非 Go HTTP 客户端 | 当前服务端 | `/v1` 接口手册、严格输入和错误码回归 | 按接口手册逐项验证 | + +## 兼容规则 + +同一协议线内只允许保持字段语义、状态值和错误码含义不变的兼容扩展。新增响应字段通常由 HTTP JSON 客户端忽略;新增请求必填字段、修改既有字段类型/单位、修改状态终结语义或改变 WebSocket 帧方向,都不属于兼容扩展。Worker 和事件 SDK 的帧解码会拒绝未知字段或错误版本,避免把协议漂移当作业务结果。 + +发生不兼容变化时必须新建 HTTP 路径版本、WebSocket 子协议或 schema `$id`,并保留旧线的服务端/SDK 实现与回归,直到发布说明明确移除日期。不能仅增加一个文档版本字符串来掩盖行为变化。流程业务版本(例如定义的 `version`)与传输协议版本分开,业务版本固定在实例上,不改变旧实例的执行语义。 + +## 发布前组合 + +候选版本至少执行: + +```sh +make protocol-check +make sdk-check +``` + +然后在干净 checkout 中用候选 SDK module 标签运行健康、任务、Worker、事件订阅、流程定义/实例/恢复和文件转换示例。当前仓库已验证的是同一 checkout 的服务端与 SDK;旧/新跨版本组合、TLS 终止、外部副作用和生产容量仍需在发布候选及接入方环境单独验证。 diff --git a/docs/sdk/release.md b/docs/sdk/release.md index aca385b..6b81b40 100644 --- a/docs/sdk/release.md +++ b/docs/sdk/release.md @@ -25,6 +25,8 @@ sh scripts/test-sdk-isolation.sh `package_count` 随示例增加而变化,不能作为兼容性版本号。门禁只证明源码在当前 Go 工具链下保持独立和可构建,不代表已经发布到远端模块代理,也不替代旧 SDK/新服务端和新 SDK/旧服务端的协议兼容矩阵。 +协议版本、WebSocket 子协议、流程 schema 和数据库版本见[兼容矩阵](compatibility.md)。发布候选还必须执行仓库根目录的 `make protocol-check`;它检查当前服务端、SDK 和机器可读 schema 是否仍在同一协议线,不把历史版本组合标记为已验证。 + ## 接入方式 SDK 尚未发布正式标签时,开发项目可以使用本地 replace;发布候选必须使用独立 module 的版本标签和干净 checkout 验证。接入方应固定 SDK 版本,升级前先执行自己的接口和业务幂等回归: diff --git a/scripts/test-protocol-compatibility.sh b/scripts/test-protocol-compatibility.sh new file mode 100755 index 0000000..3f89c84 --- /dev/null +++ b/scripts/test-protocol-compatibility.sh @@ -0,0 +1,107 @@ +#!/bin/sh +# Check that the server, SDK and machine-readable WebSocket schemas name the +# same current protocol line. This is a contract gate, not a historical pair +# compatibility test. +set -eu + +TASK_ROOT=${ORBIT_ROOT_DIR:-.} +python3 - "$TASK_ROOT" <<'PY' +import json +import re +import sys +from pathlib import Path + +root = Path(sys.argv[1]).resolve() +errors = [] + +def read(relative): + path = root / relative + if not path.is_file(): + errors.append(f"missing {relative}") + return "" + return path.read_text() + +def exact_constant(relative, name): + text = read(relative) + match = re.search(rf'\bconst\s+{re.escape(name)}\s*=\s*"([^"]+)"', text) + if not match: + errors.append(f"missing constant {name} in {relative}") + return "" + return match.group(1) + +worker_server = exact_constant("internal/transport/httpserver/worker.go", "WorkerSubprotocol") +worker_sdk = exact_constant("sdk/go/worker_connection.go", "workerSubprotocol") +event_server = exact_constant("internal/transport/httpserver/event_codec.go", "EventSubprotocol") +event_sdk = exact_constant("sdk/go/event_connection.go", "eventSubprotocol") +if worker_server != worker_sdk or worker_server != "pulse.worker.v1": + errors.append(f"worker subprotocol mismatch: server={worker_server!r} sdk={worker_sdk!r}") +if event_server != event_sdk or event_server != "orbit.events.v1": + errors.append(f"event subprotocol mismatch: server={event_server!r} sdk={event_sdk!r}") + +def version_literals(relative): + text = read(relative) + # Frame constructors and schema-facing values must stay on protocol line 1. + return set(re.findall(r'\bVersion\s*:\s*"([^"]+)"', text)) + +for relative in ( + "internal/transport/httpserver/worker.go", + "internal/transport/httpserver/event_hub.go", + "sdk/go/worker_connection.go", + "sdk/go/event_connection.go", +): + values = version_literals(relative) + if values and values != {"1"}: + errors.append(f"unexpected frame versions in {relative}: {sorted(values)}") + +def schema_protocol_versions(relative, expected_id): + path = root / relative + try: + document = json.loads(path.read_text()) + except Exception as exc: + errors.append(f"cannot parse {relative}: {exc}") + return + if document.get("$id") != expected_id: + errors.append(f"schema id mismatch in {relative}: {document.get('$id')!r}") + values = [] + def walk(value): + if isinstance(value, dict): + field = value.get("protocol_version") + if isinstance(field, dict) and "const" in field: + values.append(field["const"]) + for child in value.values(): + walk(child) + elif isinstance(value, list): + for child in value: + walk(child) + walk(document) + if not values or set(values) != {"1"}: + errors.append(f"schema protocol versions in {relative}: {sorted(set(values))}") + +schema_protocol_versions("protocol/workerv1/worker.schema.json", "https://git.yuntuo.hk/k2safe/orbit/schema/worker.v1.json") +schema_protocol_versions("protocol/eventsv1/events.schema.json", "https://git.yuntuo.hk/k2safe/orbit/schema/events.v1.json") + +worker_server_text = read("internal/transport/httpserver/worker.go") +worker_sdk_text = read("sdk/go/worker_connection.go") +event_server_text = read("internal/transport/httpserver/event_hub.go") +event_sdk_text = read("sdk/go/event_connection.go") +if 'GET /v1/worker/connect' not in worker_server_text or '"/v1/worker/connect"' not in worker_sdk_text: + errors.append("worker v1 route mismatch") +if 'GET /v1/events/connect' not in event_server_text or '"/v1/events/connect"' not in event_sdk_text: + errors.append("event v1 route mismatch") + +if errors: + for error in errors: + print(error, file=sys.stderr) + raise SystemExit(1) + +print(json.dumps({ + "http_api": "/v1", + "worker_subprotocol": worker_server, + "event_subprotocol": event_server, + "worker_frame_version": "1", + "event_frame_version": "1", + "workflow_schema_version": 1, + "status": "current_contracts_match", + "historical_pairs": "not_verified", +}, separators=(",", ":"))) +PY diff --git a/sdk/go/doc.go b/sdk/go/doc.go index 1262e07..25f6284 100644 --- a/sdk/go/doc.go +++ b/sdk/go/doc.go @@ -2,6 +2,6 @@ // WorkerConnection 是底层接口;NewWorker/Run 自动管理处理器、续租、恢复与排空。 // EventConnection 是结果交付协议;EventConsumer 自动管理心跳、重连与确认,业务端负责事务去重。 // SubmitTasks 提供有界批量受理,每项沿用独立稳定身份并返回可核对的受理状态。 -// 流程 SDK 等后续能力按阶段继续开发。 +// 流程定义、实例控制、信号、查询和恢复也通过独立 SDK 提供;具体版本边界见仓库文档。 // SDK 不导入服务端模型、迁移或数据库依赖。 package orbit