test: add mysql outage recovery rehearsal
This commit is contained in:
@@ -64,6 +64,7 @@ P4/P5定时等待与后续查询轮次的实现及回归记录见[时钟报告](
|
||||
4. 已补[`orbit-admin retention inventory`](api/retention.md)只读盘点和[阶段报告](reports/phase-6-retention-inventory.md),能区分过期幂等、确定终态、未知效果、未终结流程、已确认/未确认交付、已消费/未消费Inbox、事件和审计规模;它不执行归档或删除。后续仍需评审各类保留周期、归档格式、受保护记录核对,再执行备份恢复、兼容升级和六类容量负载。未确认交付、有效幂等身份、当前/历史执行证据和未终结流程不能被普通清理破坏。生产HA及RPO/RTO继续按实际拓扑验证,不缩减为本机多进程检查。
|
||||
5. 已补[`orbit-load`](api/load.md)有界受理负载工具:固定请求形状、并发上限、每请求超时、无自动重试、延迟百分位和有限错误分类;已完成一次隔离 MySQL + HTTP 小样本实测,证据见[负载报告](reports/phase-6-load.md)。标准 Go 1.27.1 隔离回归仍需在工具链恢复后复跑;该工具只提供受理层测量,真实 Worker/流程/数据库拓扑容量仍需独立报告。
|
||||
6. 已补[隔离备份恢复演练](api/backup-restore.md):源库和恢复库均为一次性 MySQL 容器,逻辑备份恢复后核对迁移账本、核心记录数量、固定索引和零外键;证据见[恢复报告](reports/phase-6-backup-restore.md)。该演练不替代生产备份介质、复制切换、跨版本恢复或 RPO/RTO 验收。
|
||||
7. 已补[A18 数据库短暂不可用恢复演练](api/db-recovery.md):readiness 在数据库停止期间返回 503,同卷恢复后返回 200,并用原权限范围继续受理任务;证据见[数据库恢复报告](reports/phase-6-db-recovery.md)。该演练不替代主从切换、复制确认、跨故障域或 RPO/RTO 验收。
|
||||
|
||||
### P3 功能范围核对
|
||||
|
||||
|
||||
@@ -24,6 +24,7 @@
|
||||
| `orbit-admin retention inventory` 保留清单 | 已实现带 UTC 截止时间的只读盘点;不执行归档、删除或权限变更 | [保留清单](retention.md) |
|
||||
| `sdk/go/cmd/orbit-load` 有界受理负载工具 | 已实现固定并发、无自动重试、延迟百分位和错误分类;只用于隔离环境 | [负载工具](load.md) |
|
||||
| `scripts/test-backup-restore.sh` 隔离备份恢复演练 | 已实现一次性 MySQL 逻辑备份、独立恢复、核心计数及零外键核验 | [备份恢复演练](backup-restore.md) |
|
||||
| `scripts/test-db-recovery.sh` MySQL 短暂不可用恢复演练 | 已实现 readiness 503、同卷恢复 200 及恢复后任务受理核验 | [数据库恢复演练](db-recovery.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) |
|
||||
|
||||
26
docs/api/db-recovery.md
Normal file
26
docs/api/db-recovery.md
Normal file
@@ -0,0 +1,26 @@
|
||||
# MySQL 短暂不可用恢复演练
|
||||
|
||||
Orbit 不在运行时自动执行迁移,也不把数据库错误伪装成业务成功。`GET /health/ready` 每次都在有限上下文内执行数据库 Ping、迁移账本和零外键检查;数据库不可用时返回 `503`,恢复后由 `database/sql` 连接池重新建立连接。
|
||||
|
||||
## 可复现命令
|
||||
|
||||
``@@BT@sh
|
||||
DOCKER=/usr/local/bin/docker \
|
||||
ORBIT_TEST_MYSQL_IMAGE=mysql:8.4 \
|
||||
sh scripts/test-db-recovery.sh
|
||||
``@@BT@
|
||||
|
||||
脚本会创建一次性 MySQL 容器和本地卷,迁移当前结构,初始化通用应用/命名空间/队列,启动 Orbit;然后停止并移除数据库容器,在相同主机端口和卷上创建恢复容器。恢复后会再次请求 readiness,并提交一条原权限范围内的任务。临时凭据、容器和卷均由脚本清理。
|
||||
|
||||
成功输出:
|
||||
|
||||
``@@BT@json
|
||||
{"initial_readiness":200,"during_database_outage":503,"after_database_recovery":200,"task_accepted_after_recovery":true}
|
||||
``@@BT@
|
||||
|
||||
## 运行边界
|
||||
|
||||
- 503 只表示当前节点不能确认数据库和结构,不代表任务已回滚或外部效果已撤销;写请求超时仍必须保留原幂等键核对。
|
||||
- 数据库恢复后只证明新连接可以读取迁移账本并继续受理,不证明正在执行的任务、租约、复制确认或外部副作用已经自动完成。
|
||||
- 该演练使用本地 MySQL 卷模拟单节点短暂中断,不等同于主从切换、复制延迟、跨故障域、磁盘故障、RPO/RTO 或生产容量验收。
|
||||
- 数据库保持无外键;`orbit-admin verify` 和 readiness 都会拒绝实际外键漂移。
|
||||
29
docs/reports/phase-6-db-recovery.md
Normal file
29
docs/reports/phase-6-db-recovery.md
Normal file
@@ -0,0 +1,29 @@
|
||||
# P6:MySQL 短暂不可用恢复基线
|
||||
|
||||
验证日期:2026-09-20。本批针对生产验收项 A18,验证 Orbit 在 MySQL 容器短暂停止并以同一数据卷恢复后,readiness 和任务受理行为保持可核对。
|
||||
|
||||
## 实际环境与命令
|
||||
|
||||
环境为 macOS arm64、当前 `bin/orbit`/`bin/orbit-migrate`/`bin/orbit-admin`、Docker MySQL 8.4。脚本创建随机本地卷和端口,迁移 28 个版本,初始化一个通用命名空间和队列:
|
||||
|
||||
``@@BT@sh
|
||||
DOCKER=/usr/local/bin/docker \
|
||||
ORBIT_TEST_MYSQL_IMAGE=mysql:8.4 \
|
||||
sh scripts/test-db-recovery.sh
|
||||
``@@BT@
|
||||
|
||||
数据库容器停止并移除后,脚本在相同端口和卷上创建恢复容器;Orbit 进程保持运行。
|
||||
|
||||
## 实际结果
|
||||
|
||||
``@@BT@json
|
||||
{"initial_readiness":200,"during_database_outage":503,"after_database_recovery":200,"task_accepted_after_recovery":true}
|
||||
``@@BT@
|
||||
|
||||
恢复后的任务受理使用原命名空间凭据和固定 `Idempotency-Key`,说明新数据库连接能重新通过鉴权、结构检查、权限和受理事务。脚本没有启动 Worker,因此没有把任务受理误报为业务处理完成。
|
||||
|
||||
脚本语法检查和文档链接检查通过;实际数据库结构继续由 `orbit-admin verify` 核对零外键。标准全量回归仍需在资源充足环境执行,不能用本次短暂中断演练替代。
|
||||
|
||||
## 边界
|
||||
|
||||
本证据只覆盖单节点数据库连接中断和同卷恢复。它不证明 MySQL 主节点切换、复制确认、跨故障域、磁盘损坏、备份介质持久性、进行中租约接管、外部副作用核对、生产容量或 RPO/RTO。生产上线仍需按目标拓扑独立演练。
|
||||
157
scripts/test-db-recovery.sh
Executable file
157
scripts/test-db-recovery.sh
Executable file
@@ -0,0 +1,157 @@
|
||||
#!/bin/sh
|
||||
# 验证一次性 MySQL 短暂不可用后的 readiness 和连接池重连。
|
||||
set -eu
|
||||
|
||||
TASK_DOCKER=${DOCKER:-docker}
|
||||
TASK_MYSQL_IMAGE=${ORBIT_TEST_MYSQL_IMAGE:-mysql:8.4}
|
||||
TASK_ORBIT=${ORBIT_BIN:-bin/orbit}
|
||||
TASK_MIGRATE=${ORBIT_MIGRATE_BIN:-bin/orbit-migrate}
|
||||
TASK_ADMIN=${ORBIT_ADMIN_BIN:-bin/orbit-admin}
|
||||
TASK_NAME="orbit-db-recovery-$$"
|
||||
TASK_VOLUME="orbit-db-recovery-volume-$$"
|
||||
TASK_DIR=$(mktemp -d "${TMPDIR:-/tmp}/orbit-db-recovery.XXXXXX")
|
||||
TASK_SERVER_PID=
|
||||
|
||||
cleanup() {
|
||||
if [ -n "$TASK_SERVER_PID" ]; then
|
||||
kill "$TASK_SERVER_PID" >/dev/null 2>&1 || true
|
||||
wait "$TASK_SERVER_PID" 2>/dev/null || true
|
||||
fi
|
||||
if [ "${ORBIT_TEST_KEEP_ARTIFACTS:-0}" = 1 ] && [ -f "$TASK_DIR/server.log" ]; then
|
||||
cp "$TASK_DIR/server.log" "${ORBIT_TEST_KEEP_ARTIFACTS_PATH:-/tmp/orbit-db-recovery-last.log}"
|
||||
fi
|
||||
"$TASK_DOCKER" container stop "$TASK_NAME" >/dev/null 2>&1 || true
|
||||
"$TASK_DOCKER" container rm "$TASK_NAME" >/dev/null 2>&1 || true
|
||||
"$TASK_DOCKER" volume rm "$TASK_VOLUME" >/dev/null 2>&1 || true
|
||||
for task_artifact in "$TASK_DIR"/*; do
|
||||
if [ -f "$task_artifact" ]; then unlink "$task_artifact"; fi
|
||||
done
|
||||
rmdir "$TASK_DIR" 2>/dev/null || true
|
||||
}
|
||||
trap cleanup EXIT HUP INT TERM
|
||||
|
||||
umask 077
|
||||
TASK_DB_PASSWORD=$(openssl rand -hex 24)
|
||||
"$TASK_DOCKER" volume create "$TASK_VOLUME" >/dev/null
|
||||
"$TASK_DOCKER" run -d --name "$TASK_NAME" \
|
||||
-e MYSQL_ROOT_PASSWORD="$TASK_DB_PASSWORD" \
|
||||
-e MYSQL_DATABASE=orbit_test \
|
||||
-v "$TASK_VOLUME:/var/lib/mysql" \
|
||||
-p 127.0.0.1::3306 "$TASK_MYSQL_IMAGE" >/dev/null
|
||||
|
||||
TASK_READY=0
|
||||
for task_attempt in $(seq 1 60); do
|
||||
if "$TASK_DOCKER" exec "$TASK_NAME" sh -c 'MYSQL_PWD="$MYSQL_ROOT_PASSWORD" mysql -uroot --protocol=tcp -e "SELECT 1" >/dev/null 2>&1'; then
|
||||
TASK_READY=1
|
||||
break
|
||||
fi
|
||||
sleep 1
|
||||
done
|
||||
if [ "$TASK_READY" -ne 1 ]; then
|
||||
printf '%s\n' 'Disposable MySQL failed to become ready.' >&2
|
||||
"$TASK_DOCKER" logs --tail 30 "$TASK_NAME" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
TASK_DB_PORT=$("$TASK_DOCKER" port "$TASK_NAME" 3306/tcp | sed 's/.*://')
|
||||
export ORBIT_TEST_MYSQL_DSN="root:$TASK_DB_PASSWORD@tcp(127.0.0.1:$TASK_DB_PORT)/orbit_test"
|
||||
ORBIT_MYSQL_DSN="$ORBIT_TEST_MYSQL_DSN" "$TASK_MIGRATE" up >/dev/null
|
||||
ORBIT_MYSQL_DSN="$ORBIT_TEST_MYSQL_DSN" "$TASK_ADMIN" bootstrap --application db-recovery-example --namespace default --environment test --queue recovery --output "$TASK_DIR/credentials.json" >/dev/null
|
||||
|
||||
ORBIT_MYSQL_DSN="$ORBIT_TEST_MYSQL_DSN" ORBIT_HTTP_ADDR=127.0.0.1:0 "$TASK_ORBIT" >"$TASK_DIR/server.log" 2>&1 &
|
||||
TASK_SERVER_PID=$!
|
||||
TASK_HTTP_PORT=
|
||||
for task_attempt in $(seq 1 30); do
|
||||
TASK_HTTP_PORT=$(sed -n 's/.*"address":"127.0.0.1:\([0-9]*\)".*/\1/p' "$TASK_DIR/server.log" | head -n 1)
|
||||
if [ -n "$TASK_HTTP_PORT" ]; then break; fi
|
||||
if ! kill -0 "$TASK_SERVER_PID" 2>/dev/null; then
|
||||
cat "$TASK_DIR/server.log" >&2
|
||||
exit 1
|
||||
fi
|
||||
sleep 1
|
||||
done
|
||||
if [ -z "$TASK_HTTP_PORT" ]; then
|
||||
printf '%s\n' 'Orbit HTTP server failed to start.' >&2
|
||||
exit 1
|
||||
fi
|
||||
TASK_URL="http://127.0.0.1:$TASK_HTTP_PORT"
|
||||
|
||||
TASK_INITIAL_STATUS=$(curl -sS -o /dev/null -w '%{http_code}' --max-time 3 "$TASK_URL/health/ready")
|
||||
if [ "$TASK_INITIAL_STATUS" != 200 ]; then
|
||||
printf 'initial readiness returned %s\n' "$TASK_INITIAL_STATUS" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
"$TASK_DOCKER" container stop "$TASK_NAME" >/dev/null
|
||||
"$TASK_DOCKER" container rm "$TASK_NAME" >/dev/null
|
||||
TASK_DOWN_STATUS=
|
||||
for task_attempt in $(seq 1 30); do
|
||||
TASK_DOWN_STATUS=$(curl -sS -o /dev/null -w '%{http_code}' --max-time 3 "$TASK_URL/health/ready" || true)
|
||||
if [ "$TASK_DOWN_STATUS" = 503 ]; then break; fi
|
||||
sleep 1
|
||||
done
|
||||
if [ "$TASK_DOWN_STATUS" != 503 ]; then
|
||||
printf 'readiness did not report database outage, status=%s\n' "$TASK_DOWN_STATUS" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
"$TASK_DOCKER" run -d --name "$TASK_NAME" \
|
||||
-e MYSQL_ROOT_PASSWORD="$TASK_DB_PASSWORD" \
|
||||
-e MYSQL_DATABASE=orbit_test \
|
||||
-v "$TASK_VOLUME:/var/lib/mysql" \
|
||||
-p "127.0.0.1:$TASK_DB_PORT:3306" "$TASK_MYSQL_IMAGE" >/dev/null
|
||||
TASK_READY=0
|
||||
for task_attempt in $(seq 1 60); do
|
||||
if "$TASK_DOCKER" exec "$TASK_NAME" sh -c 'MYSQL_PWD="$MYSQL_ROOT_PASSWORD" mysql -uroot --protocol=tcp -e "SELECT 1" >/dev/null 2>&1'; then
|
||||
TASK_READY=1
|
||||
break
|
||||
fi
|
||||
sleep 1
|
||||
done
|
||||
if [ "$TASK_READY" -ne 1 ]; then
|
||||
printf '%s\n' 'MySQL did not recover after restart.' >&2
|
||||
exit 1
|
||||
fi
|
||||
ORBIT_MYSQL_DSN="$ORBIT_TEST_MYSQL_DSN" "$TASK_ADMIN" verify >/dev/null
|
||||
|
||||
TASK_RECOVERED_STATUS=
|
||||
for task_attempt in $(seq 1 30); do
|
||||
TASK_RECOVERED_STATUS=$(curl -sS -o /dev/null -w '%{http_code}' --max-time 3 "$TASK_URL/health/ready" || true)
|
||||
if [ "$TASK_RECOVERED_STATUS" = 200 ]; then break; fi
|
||||
sleep 1
|
||||
done
|
||||
if [ "$TASK_RECOVERED_STATUS" != 200 ]; then
|
||||
printf 'readiness did not recover, status=%s\n' "$TASK_RECOVERED_STATUS" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
read TASK_TOKEN TASK_QUEUE <<EOF
|
||||
$(python3 - "$TASK_DIR/credentials.json" <<'PY'
|
||||
import json
|
||||
import sys
|
||||
|
||||
with open(sys.argv[1], encoding="utf-8") as f:
|
||||
value = json.load(f)
|
||||
print(value["token"], value["queue_id"])
|
||||
PY
|
||||
)
|
||||
EOF
|
||||
cat >"$TASK_DIR/task.json" <<EOF
|
||||
{"queue_id":"$TASK_QUEUE","handler":"db.recovery.noop","handler_version":"v1","payload_version":"v1","payload":{"source":"database-recovery"},"recovery_safety":"RECONCILE_ONLY","retry":{"max_attempts":1}}
|
||||
EOF
|
||||
TASK_RESPONSE=$(curl -sS --max-time 5 -X POST "$TASK_URL/v1/tasks" \
|
||||
-H "Authorization: Bearer $TASK_TOKEN" \
|
||||
-H 'Content-Type: application/json' \
|
||||
-H 'Idempotency-Key: db-recovery-operation' \
|
||||
--data-binary @"$TASK_DIR/task.json")
|
||||
python3 - "$TASK_RESPONSE" <<'PY'
|
||||
import json
|
||||
import sys
|
||||
|
||||
value = json.loads(sys.argv[1])
|
||||
if not value.get("data", {}).get("task", {}).get("id"):
|
||||
raise SystemExit("recovered database did not accept a task")
|
||||
PY
|
||||
|
||||
printf '{"initial_readiness":%s,"during_database_outage":%s,"after_database_recovery":%s,"task_accepted_after_recovery":true}\n' \
|
||||
"$TASK_INITIAL_STATUS" "$TASK_DOWN_STATUS" "$TASK_RECOVERED_STATUS"
|
||||
Reference in New Issue
Block a user