From 6a251e58431e49c88576b1f34c1c910f69524cfd Mon Sep 17 00:00:00 2001 From: k2safe Date: Sat, 19 Sep 2026 16:50:33 +0800 Subject: [PATCH] feat: add read-only schema preflight --- README.md | 2 +- cmd/orbit-admin/main.go | 37 ++++++++- docs/08-development-plan.md | 2 +- docs/api/README.md | 1 + docs/api/operations.md | 18 ++++ .../reports/phase-6-business-observability.md | 2 +- .../operational_metrics_integration_test.go | 4 + internal/storage/mysql/schema_verify.go | 83 +++++++++++++++++++ scripts/test-mysql.sh | 1 + 9 files changed, 145 insertions(+), 5 deletions(-) create mode 100644 docs/api/operations.md create mode 100644 internal/storage/mysql/schema_verify.go diff --git a/README.md b/README.md index 6dc44a9..303d9cb 100644 --- a/README.md +++ b/README.md @@ -12,7 +12,7 @@ Orbit 是面向多个业务项目的通用任务调度与流程编排平台, Weave已提供[定义发布](docs/api/workflow-definitions.md)、[实例启动、步骤详情、暂停/继续/取消、信号和操作审计](docs/api/workflows.md),以及[六类人工核查恢复](docs/api/workflow-recovery.md)的HTTP与独立Go SDK。持久运行器支持顺序、分支、有限并行、TIMER、SIGNAL及有界POLL,恢复沿用原预算与截止。[并行作业与回执示例](docs/examples/workflow.md)提供完整调用链;[历史激活/轮次/信号及信号审计](docs/api/workflow-history.md)已接入;[实例筛选分页](docs/api/workflow-queries.md)已接入,[状态事件时间线](docs/api/workflow-events.md)已接入,独立[轮询](docs/examples/workflow-poll.md)与[恢复](docs/examples/workflow-recovery.md)示例已接入,已补[版本/暂停竞争/全停回归](docs/reports/phase-4-5-races.md),功能阶段证据见[P4/P5核对](docs/reports/phase-4-5.md),提交断线及派发未送达见[故障边界](docs/reports/phase-6-fault-boundaries.md);[任务流程来源](docs/api/task-source.md#5-流程固定来源)已接入,P4/P5功能阶段已通过,P6生产验证和P7发布交付继续推进。 -数据库禁止外键,关联一致性通过服务端事务校验,索引、唯一约束和 CHECK 保留;readiness 同时检查迁移及零外键要求。[运行观测](docs/api/observability.md)提供固定基数 HTTP、数据库池和后台批次指标。这不表示完整平台已具备生产服务能力。验证证据见 [P0](docs/reports/phase-0.md)、[P1](docs/reports/phase-1.md)、[P2 阶段验收](docs/reports/phase-2.md)、[安全重试](docs/reports/phase-3-safe-retry.md)、[计划调度](docs/reports/phase-3-schedules.md)、[取消与无外键回归](docs/reports/phase-3-cancellation.md) 及 [队列资源回归](docs/reports/phase-3-queue-resources.md)。 +数据库禁止外键,关联一致性通过服务端事务校验,索引、唯一约束和 CHECK 保留;readiness 同时检查迁移及零外键要求。[运行观测](docs/api/observability.md)提供固定基数 HTTP、数据库池和后台批次指标;发布前可执行只读的 [`orbit-admin verify`](docs/api/operations.md) 核验迁移、零外键和固定索引。这不表示完整平台已具备生产服务能力。验证证据见 [P0](docs/reports/phase-0.md)、[P1](docs/reports/phase-1.md)、[P2 阶段验收](docs/reports/phase-2.md)、[安全重试](docs/reports/phase-3-safe-retry.md)、[计划调度](docs/reports/phase-3-schedules.md)、[取消与无外键回归](docs/reports/phase-3-cancellation.md) 及 [队列资源回归](docs/reports/phase-3-queue-resources.md)。 ## 目录 diff --git a/cmd/orbit-admin/main.go b/cmd/orbit-admin/main.go index 1bc8e6e..2a5ed3b 100644 --- a/cmd/orbit-admin/main.go +++ b/cmd/orbit-admin/main.go @@ -23,15 +23,48 @@ func main() { } func run() error { if len(os.Args) < 2 { - return errors.New("usage: orbit-admin bootstrap|credential status|rotate|revoke|apply") + return errors.New("usage: orbit-admin bootstrap|verify|credential status|rotate|revoke|apply") } switch os.Args[1] { case "bootstrap": return runBootstrap(os.Args[2:]) + case "verify": + return runVerify(os.Args[2:]) case "credential": return runCredential(os.Args[2:]) } - return errors.New("usage: orbit-admin bootstrap|credential status|rotate|revoke|apply") + return errors.New("usage: orbit-admin bootstrap|verify|credential status|rotate|revoke|apply") +} + +func runVerify(args []string) error { + flags := flag.NewFlagSet("verify", flag.ContinueOnError) + if err := flags.Parse(args); err != nil { + return err + } + if flags.NArg() != 0 { + return errors.New("verify does not accept positional arguments") + } + cfg, err := config.Load() + if err != nil { + return err + } + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) + defer cancel() + db, err := mysql.Open(ctx, cfg.MySQLDSN, 2, 1) + if err != nil { + return errors.New("database unavailable") + } + defer db.Close() + verification, err := mysql.VerifyOperationalSchema(ctx, db) + encoded, encodeErr := json.Marshal(verification) + if encodeErr != nil { + return encodeErr + } + fmt.Println(string(encoded)) + if err != nil { + return err + } + return nil } func runBootstrap(args []string) error { diff --git a/docs/08-development-plan.md b/docs/08-development-plan.md index 72843d0..8bffd6e 100644 --- a/docs/08-development-plan.md +++ b/docs/08-development-plan.md @@ -59,7 +59,7 @@ P4/P5定时等待与后续查询轮次的实现及回归记录见[时钟报告]( ### P6 首批实施顺序 1. 已补[首批可复现故障工具](reports/phase-6-fault-boundaries.md):A04“派发已提交但消息尚未送达”、W01/W13事务提交结果不明,以及W16最终回执/旧PENDING结果重放/下一轮竞争。每个切点必须有实际数据库或进程证据,不能用响应错误推断事务是否已提交,也不能用普通SIGNAL竞争替代最终查询场景。 -2. 已实现[基础运行观测](api/observability.md)及[有界业务状态与事件采样](reports/phase-6-business-observability.md):HTTP/WS、数据库连接池、后台批次、固定状态计数、最近事件类别、终态耗时样本、最早年龄、截断/新鲜度指标和可离线验证的告警规则;独立采集监听默认关闭,固定指标目录拒绝动态标签。仍需受理/授权阶段端到端延迟与全局持久吞吐、流程Inbox推进延迟、全局发送队列、多节点聚合及生产采集/通知验证。 +2. 已实现[基础运行观测](api/observability.md)及[有界业务状态与事件采样](reports/phase-6-business-observability.md):HTTP/WS、数据库连接池、后台批次、固定状态计数、最近事件类别、终态耗时样本、最早年龄、截断/新鲜度指标和可离线验证的告警规则;独立采集监听默认关闭,固定指标目录拒绝动态标签;发布前只读结构核验由[`orbit-admin verify`](api/operations.md)提供。仍需受理/授权阶段端到端延迟与全局持久吞吐、流程Inbox推进延迟、全局发送队列、多节点聚合及生产采集/通知验证。 3. 已补齐受保护管理环境中的固定凭据操作文件、轮换、撤销、幂等回执和脱敏管理审计;轮换保留授权绑定并要求新令牌重新建立会话,撤销允许在停用范围内执行且不改写历史记录。仍需补齐提交不明故障切点、多节点安全重绑演练、凭据最小权限配置核对和生产密钥托管/审计演练。 4. 实现保留/归档与受保护记录核对,再执行备份恢复、兼容升级和六类容量负载。未确认交付、有效幂等身份、当前/历史执行证据和未终结流程不能被普通清理破坏。生产HA及RPO/RTO继续按实际拓扑验证,不缩减为本机多进程检查。 diff --git a/docs/api/README.md b/docs/api/README.md index 6ee3b5e..6d777cd 100644 --- a/docs/api/README.md +++ b/docs/api/README.md @@ -20,6 +20,7 @@ | 任务 `concurrency_key` 与尝试 `concurrency_token` | P3 已实现跨队列资源键互斥 | [资源键接口与 SDK 场景](keyed-concurrency.md) | | 任务 `priority` 与节点领取池 | P3 已实现有界优先级加速、应用/队列轮转 | [优先级与公平派发](priority-and-fairness.md) | | 凭据校验、初始化、固定操作文件轮换/撤销与回执重放 CLI | P1/P6 已实现;不提供公网管理路由,测试专用提交不明切点已回归 | [鉴权](authentication.md)、[凭据报告](../reports/phase-6-credentials.md) | +| `orbit-admin verify` 发布前结构核验 | 已实现只读迁移、零外键和 000026–000028 固定索引检查 | [运维核验](operations.md) | | `/v1/worker/connect` | P2 已实现并通过阶段验收 | [Worker 契约](worker.md)、[Schema](worker-schema.md) | | 任务相对延迟 `delay_ms` | 已实现数据库计时、原键恢复与实际执行例子 | [任务接口](tasks.md)、[延迟示例](../examples/delayed-task.md) | | 任务冻结策略与 Worker `retryable` | P3 安全重试已实现 | [安全重试](retries.md) | diff --git a/docs/api/operations.md b/docs/api/operations.md new file mode 100644 index 0000000..8596609 --- /dev/null +++ b/docs/api/operations.md @@ -0,0 +1,18 @@ +# 发布前结构核验 + +`orbit-admin verify` 是只读的发布、迁移和恢复前置检查。它不会执行迁移、修复脏账本、删除数据或修改权限;数据库账户通过 `ORBIT_MYSQL_DSN` 注入,建议使用受限只读账户。 + +```sh +ORBIT_MYSQL_DSN='user:password@tcp(127.0.0.1:3306)/orbit' \ + go run ./cmd/orbit-admin verify +``` + +成功时输出类似: + +```json +{"schema_version":28,"migration_count":28,"foreign_key_count":0,"required_index_count":6,"complete":true} +``` + +`complete=true` 表示当前二进制的迁移账本、Orbit 表零外键,以及 000026–000028 的六个有界观测索引均满足要求。命令不会证明备份可恢复、复制已确认、业务副作用已核对或生产 RPO/RTO 达标;这些仍需隔离环境恢复演练和真实拓扑验收。失败时会先输出不含 DSN/密钥的检查结果,再以非零状态退出。 + +发布流程应先停止不兼容的旧写入节点,在隔离环境和目标维护窗口应用 `orbit-migrate up`,随后执行 `orbit-admin verify`;只有输出完整且人工核对备份、权限、连接路由后才启动新版本。verify 不是运行时协调器,多个节点可并发读取,但不能替代一次性迁移锁。 diff --git a/docs/reports/phase-6-business-observability.md b/docs/reports/phase-6-business-observability.md index 2a06e20..7e78ddc 100644 --- a/docs/reports/phase-6-business-observability.md +++ b/docs/reports/phase-6-business-observability.md @@ -17,7 +17,7 @@ - [业务记录器单测](../../internal/observability/metrics_test.go)验证成功状态、年龄、截断、成功计数和失败后保留最后成功值;超时计数单独增加。 - [真实MySQL专项](../../internal/storage/mysql/operational_metrics_integration_test.go)验证三次迁移索引真实存在、数据库外键为0、任务状态、`task.accepted`事件和终态耗时被采样、固定状态集合存在,并验证已取消上下文被拒绝。 -- `go test ./...`覆盖编译和普通单测;真实MySQL专项由标准`GO=... DOCKER=... sh scripts/test-mysql.sh`执行,未设置`ORBIT_TEST_MYSQL_DSN`时专项会跳过,不能把跳过当通过。最新000028标准脚本在Go 1.27.1、darwin/arm64、Docker MySQL 8.4.10环境退出码为0;存储/迁移/事务组、指标开关进程组、流程运行器全停恢复、SDK与两个独立业务示例均通过。日志确认`TestIntegrationOperationalSnapshot`、`TestIntegrationNoForeignKeysUpgrade`、`TestIntegrationCredentialOperations`、`TestIntegrationCredentialCommitFaults`、`TestIntegrationMetricsProcess`及`TestIntegrationWorkflowRuntimeProcess`均PASS,迁移账本到000028且dirty=false。 +- `go test ./...`覆盖编译和普通单测;真实MySQL专项由标准`GO=... DOCKER=... sh scripts/test-mysql.sh`执行,未设置`ORBIT_TEST_MYSQL_DSN`时专项会跳过,不能把跳过当通过。最新000028标准脚本在Go 1.27.1、darwin/arm64、Docker MySQL 8.4.10环境退出码为0;存储/迁移/事务组、指标开关进程组、流程运行器全停恢复、SDK与两个独立业务示例均通过。日志确认`TestIntegrationOperationalSnapshot`、`TestIntegrationNoForeignKeysUpgrade`、`TestIntegrationCredentialOperations`、`TestIntegrationCredentialCommitFaults`、`TestIntegrationMetricsProcess`及`TestIntegrationWorkflowRuntimeProcess`均PASS,迁移账本到000028且dirty=false;脚本新增的`orbit-admin verify`实际输出`schema_version=28`、`migration_count=28`、`foreign_key_count=0`、`required_index_count=6`、`complete=true`。 - 当前固定目录导出的指标夹具为929298字节,包含事件标签回归;`promtool check metrics`成功;`promtool check rules deploy/monitoring/orbit.rules.yml`报告7条规则成功,`promtool test rules deploy/monitoring/orbit.rules.test.yml`成功。标准验证镜像仅用于离线检查,平台运行不依赖Prometheus。 ## 解释和边界 diff --git a/internal/storage/mysql/operational_metrics_integration_test.go b/internal/storage/mysql/operational_metrics_integration_test.go index b27e6de..48ce9e3 100644 --- a/internal/storage/mysql/operational_metrics_integration_test.go +++ b/internal/storage/mysql/operational_metrics_integration_test.go @@ -24,6 +24,10 @@ func TestIntegrationOperationalSnapshot(t *testing.T) { if err := Migrate(ctx, db); err != nil { t.Fatal(err) } + verification, err := VerifyOperationalSchema(ctx, db) + if err != nil || !verification.Complete || verification.ForeignKeyCount != 0 || verification.RequiredIndexCount != 6 { + t.Fatalf("schema preflight=%+v err=%v", verification, err) + } if err := CheckSchema(ctx, db); err != nil { t.Fatal(err) } diff --git a/internal/storage/mysql/schema_verify.go b/internal/storage/mysql/schema_verify.go new file mode 100644 index 0000000..74fe419 --- /dev/null +++ b/internal/storage/mysql/schema_verify.go @@ -0,0 +1,83 @@ +package mysql + +import ( + "context" + "database/sql" + "errors" +) + +// SchemaVerification 是发布和恢复前的只读结构核验结果。 +// 它不修改数据库,也不代表已经完成备份恢复或高可用验收。 +type SchemaVerification struct { + // SchemaVersion 是迁移账本中的最后版本。 + SchemaVersion uint64 `json:"schema_version"` + // MigrationCount 是已记录且通过校验的迁移数量。 + MigrationCount int `json:"migration_count"` + // ForeignKeyCount 是 Orbit 表上的数据库外键数量,必须为零。 + ForeignKeyCount int `json:"foreign_key_count"` + // RequiredIndexCount 是有界观测所需固定索引数量,当前必须为六。 + RequiredIndexCount int `json:"required_index_count"` + // Complete 表示迁移、外键和观测索引均满足当前二进制要求。 + Complete bool `json:"complete"` +} + +var requiredOperationalIndexes = []struct { + table string + name string +}{ + {"pulse_tasks", "ix_task_observe_state_available"}, + {"orbit_deliveries", "ix_delivery_observe_state_available"}, + {"weave_instances", "ix_workflow_observe_state_updated"}, + {"orbit_events", "ix_event_observe_type_occurred"}, + {"pulse_tasks", "ix_task_observe_terminal_completed"}, + {"weave_instances", "ix_workflow_observe_terminal_completed"}, +} + +// VerifyOperationalSchema performs the read-only preflight used before release +// or restore. It never runs migration or repairs a dirty database. +func VerifyOperationalSchema(ctx context.Context, db *sql.DB) (SchemaVerification, error) { + if db == nil { + return SchemaVerification{}, errors.New("database is required") + } + status, err := Status(ctx, db) + if err != nil { + return SchemaVerification{}, errors.New("schema status unavailable") + } + result := SchemaVerification{MigrationCount: len(status)} + if len(status) > 0 { + result.SchemaVersion = status[len(status)-1].Version + } + for _, item := range status { + if item.Dirty { + return result, errors.New("schema has a dirty migration") + } + } + var foreignKeys int + if err := db.QueryRowContext(ctx, `SELECT COUNT(*) FROM information_schema.REFERENTIAL_CONSTRAINTS WHERE CONSTRAINT_SCHEMA=DATABASE() AND LEFT(TABLE_NAME,6) IN ('orbit_','pulse_','weave_')`).Scan(&foreignKeys); err != nil { + return result, errors.New("foreign key status unavailable") + } + result.ForeignKeyCount = foreignKeys + for _, required := range requiredOperationalIndexes { + var count int + if err := db.QueryRowContext(ctx, `SELECT COUNT(*) FROM information_schema.STATISTICS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME=? AND INDEX_NAME=?`, required.table, required.name).Scan(&count); err != nil { + return result, errors.New("operational index status unavailable") + } + if count > 0 { + result.RequiredIndexCount++ + } + } + wanted, err := Migrations() + if err != nil || len(status) != len(wanted) { + return result, errors.New("schema version mismatch") + } + for i, migration := range wanted { + if status[i].Version != migration.Version || status[i].Name != migration.Name || status[i].Checksum != migration.Checksum { + return result, errors.New("schema checksum mismatch") + } + } + result.Complete = result.ForeignKeyCount == 0 && result.RequiredIndexCount == len(requiredOperationalIndexes) + if !result.Complete { + return result, errors.New("schema preflight requirements are not satisfied") + } + return result, nil +} diff --git a/scripts/test-mysql.sh b/scripts/test-mysql.sh index 5ec6612..732021b 100755 --- a/scripts/test-mysql.sh +++ b/scripts/test-mysql.sh @@ -44,6 +44,7 @@ ORBIT_TEST_SERVER_BINARY="$TASK_TEST_DIR/orbit" "$TASK_GO" test -race -count=1 - "$TASK_GO" build -o "$TASK_TEST_DIR/orbit-migrate" ./cmd/orbit-migrate "$TASK_GO" build -o "$TASK_TEST_DIR/orbit-admin" ./cmd/orbit-admin ORBIT_MYSQL_DSN="$ORBIT_TEST_MYSQL_DSN" "$TASK_TEST_DIR/orbit-migrate" status +ORBIT_MYSQL_DSN="$ORBIT_TEST_MYSQL_DSN" "$TASK_TEST_DIR/orbit-admin" verify ORBIT_MYSQL_DSN="$ORBIT_TEST_MYSQL_DSN" "$TASK_TEST_DIR/orbit-admin" bootstrap --application sdk-example --namespace default --environment test --queue files --output "$TASK_TEST_DIR/sdk.credentials.json" ORBIT_MYSQL_DSN="$ORBIT_TEST_MYSQL_DSN" ORBIT_HTTP_ADDR=127.0.0.1:0 "$TASK_TEST_DIR/orbit" > "$TASK_TEST_DIR/server.log" 2>&1 & TASK_SERVER_PID=$!