Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions docs/SKILL-INDEX.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@
- [`addon-pvc-rebind-via-workload-intent-guide.md`](addon-pvc-rebind-via-workload-intent-guide.md) — 当一条 OpsRequest 需要把同名 PVC 从一块 PV 改绑到另一块(rebuild / restore-into-place / PV migration),用 Workload CR annotation 把意图交给 Workload 控制器(唯一写者),并按 intent-before-release + helper PV `claimRef` prebind 顺序交接所有权,避免 OpsRequest 控制器、Workload 控制器、动态 provisioner 三方抢同名 PVC 所有权造成 `PersistentVolume "" not found`、helper PV 消失或绑错 PV
- [`addon-fake-server-protocol-validation-guide.md`](addon-fake-server-protocol-validation-guide.md) — FakeSentinel / FakeMySQL / fake mongos 等 fake protocol server 的 golden real-server 对照验收方法:raw protocol request、协议感知 reader、命令矩阵分类、known delta 记录、client-consumed fields 覆盖证明;附 Redis Sentinel RESP 案例
- [`addon-engine-image-entrypoint-env-name-collision-guide.md`](addon-engine-image-entrypoint-env-name-collision-guide.md) — chart cmpd 定义的 env name 与 engine docker image 内 docker-entrypoint.sh reserved env name 撞名时, image entrypoint 会静默执行副作用 (典型: CREATE USER 无 IF NOT EXISTS 写 binlog → secondary replay 撞 1396)。识别信号 / grep entrypoint script 排查 / rename chart env 修法 (KB_ prefix 推荐) / 验证收敛 6-gate / N=1 GREEN ≠ release-ready。附 MariaDB Addon 2026-05-13 案例 (mariadb:11.4 entrypoint grep + chart diff + 6-gate verify) 进 Case Appendix
- [`addon-postready-bounded-timeout-failure-classification-guide.md`](addon-postready-bounded-timeout-failure-classification-guide.md) — addon DataProtection ActionSet `spec.restore.postReady` 长操作(`CREATE STANDBY TENANT` / secondary 重建 / `RESTORE DATABASE` 第二阶段)必须外层 `timeout -k 1 N` + bounded retry deadline + 5 层失败分类(ENV_PRE_FAIL / RUNNER_INVALID / CONTROL_PLANE_STALL / ENGINE_ERROR / ENGINE_CONVERGE_TIMEOUT)+ trap EXIT INT TERM;caller budget > 内层 step bounded retry > 单步 timeout 的预算层级;patch-gate 4 指标仅证修复在位、不证 PITR / 功能 PASS / acceptance / release-ready;附 OceanBase enterprise addon `oceanbase-physical-backup` postReady 改造后 6 样本无 hang 案例(边界明写"不外推")

### 2. 写新 smoke / chaos 测试

Expand Down
239 changes: 239 additions & 0 deletions docs/addon-postready-bounded-timeout-failure-classification-guide.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,239 @@
# Addon ActionSet `postReady` — Long-Running Engine Hooks Need Bounded Timeout + Failure Classification

> **Audience**: addon dev / test / TL
> **Status**: stable methodology
> **Applies to**: any KB addon ActionSet whose `spec.restore.postReady` (or equivalent post-restore reconciliation hook) issues a multi-step engine command that can legitimately take minutes but might also hang indefinitely (e.g. `CREATE STANDBY TENANT`, `RESTORE DATABASE` second-stage, `attach replication`, schema warm-up, role re-elect, full-table verify).
> **Applies to KB version**: any (methodology).

属于:方法论主题文档(不绑定单一引擎)。

## 1. 这篇要解决的问题

addon 在 KubeBlocks DataProtection `ActionSet.spec.restore.postReady` 里经常会跑「长操作」——把 restore 出来的 base + archive 在引擎层 finalize 成可用副本。这类操作的两个共同形态:

1. **正常时长跨度大**——secondary 重建 / standby tenant 创建 / 索引重建可能 30s 到 30min 不等,取决于数据量与硬件。
2. **故障态没有自显式失败**——卡在 wait-for-something(lock、journal、catalog 改变、协调线程)就一直 Running 下去,引擎本身不超时退出。

**反模式**:postReady 里写一个无限循环或没有外层 timeout 的命令,让它"自然结束"。一旦碰上故障态:

- DataProtection controller 看到 Restore CR phase=`Running` 一直不变,没办法分类。
- 测试 runner / OpsRequest 等到自己的 budget 超时才 fail,错过现场。
- 没有可分类的 first-blocker 信号——只能写"hang"。

**目标**:postReady 必须有 **bounded timeout + 失败分类**,让 hang 在 budget 内变成可读 reason,让 controller / runner / 测试都能据此决定下一步。

> **边界 framing**:本 doc 只讲 ActionSet `postReady` 这一类 hook(以及形态类似的 `preBackup` / `preReady` 重 SQL hook)。`backupData` 主备份阶段、`prepareData` 单纯文件拷贝、lifecycle action 短探针(roleProbe / readinessProbe)的 timeout 设计另开主题。

## 2. 硬规则

### 规则 A — 任何长操作必须有外层 `timeout`(bash level),budget 与单步预算分离

```bash
# 反例:无外层 timeout,命令自己跑多久都不退
mysql --defaults-file="$cnf" -e "CREATE STANDBY TENANT IF NOT EXISTS ..."
```

```bash
# OK:外层 timeout 锁住单条客户端调用 ≤ N 秒
timeout -k 1 60 mysql --defaults-file="$cnf" -e "CREATE STANDBY TENANT IF NOT EXISTS ..."
```

- `60` 是单步预算上限。**单步预算 ≠ postReady 总 budget**:postReady 总 budget 在测试侧(如 `TIMEOUT_RESTORE=1800`);单步 timeout 是 hook 内每一条 SQL 自己的上限,防止一条 SQL 永久阻塞 hook 整体。
- `-k 1` 软超时后 1 秒再发 KILL,避免僵进程。
- `timeout` 退出 rc=124 / 137 时一定写到日志,让上层分类得到信号。

### 规则 B — 多步骤等待(poll)必须用 bounded retry,不允许 `while true`

postReady 里常见模式:先发命令,再 poll 引擎元数据等收敛。规则:

```bash
# 反例:while true 等收敛,没退路
while true; do
status=$(query_status)
[ "$status" = "NORMAL" ] && break
sleep 5
done
```

```bash
# OK:bounded retry 给上限,给失败 reason
deadline=$(( SECONDS + 600 )) # 10 min 上限
status=""
while [ $SECONDS -lt $deadline ]; do
status=$(timeout -k 1 15 query_status 2>/dev/null || true)
if [ "$status" = "NORMAL" ]; then
break
fi
sleep 5
done

if [ "$status" != "NORMAL" ]; then
echo "ERROR: postReady standby_create not NORMAL after $((SECONDS-deadline+600))s, last_status=${status:-<empty>}"
exit 1
fi
```

- 单次 `query_status` 也包 `timeout`(规则 A)。
- deadline 到时**必须**有可读的失败 reason 输出:写到 stderr / pod log,包含**最后一次观测到的状态值**和**总等待时长**。无 reason 的 exit 1 在控制面看到的只是 `phase=Failed`,没有分类依据。

### 规则 C — 失败必须分到 5 层之一,由命令行能区分

每个长操作的失败出口至少能映射到下面 5 类(参考 `addon-test-acceptance-and-first-blocker-guide.md`):

| 层 | 触发条件 | 退出形式 |
|---|---|---|
| env / pre-condition | client 都连不上 / DNS 不可解析 / 鉴权失败 / 必备 resource 缺失 | exit 70 + "ENV_PRE_FAIL: <reason>" |
| runner / 口径 | 脚本本身参数错(手写错 user / 错 db / 错 host) | exit 71 + "RUNNER_INVALID: <reason>" |
| 控制面 / control-plane | OpsRequest / Restore CR / Workload CR 在 K8s 控制面层异常(GC、reconcile 卡) | exit 72 + "CONTROL_PLANE_STALL: <reason>" |
| 引擎产品 | 引擎报硬错(OB error code、MySQL 1xxx error) | exit 73 + "ENGINE_ERROR: <code> <msg>" |
| 引擎 hang(最后兜底) | 上面 4 类都没匹配,等到 deadline 仍未收敛 | exit 74 + "ENGINE_CONVERGE_TIMEOUT: last_status=<x> waited=<s>" |

写 `exit` 时直接退出码不同 + reason 字符串规整,下游 DataProtection controller、test runner、closeout 报告就可以按 grep 分类,不是猜。

### 规则 D — 兜底 trap:postReady 任何退出都要清掉临时凭据文件 / 副作用

参见 `addon-mysql-credential-hygiene-no-argv-guide.md` §2 规则 B:

```bash
trap 'rm -rf "${tmp_dir}"' EXIT INT TERM
```

`SIGTERM` 路径不挂 trap 的话,kubelet 给 pod 发终止信号时临时密码文件就留下了。

### 规则 E — postReady 之上的 controller / runner budget 必须独立可观察

addon 写 postReady 时**不**假设 caller 一定给了 budget。测试侧 / DataProtection controller 必须独立设置一个 outer budget(如 `TIMEOUT_RESTORE=1800`),二者关系:

```
caller budget (e.g. TIMEOUT_RESTORE=1800)
> sum of internal step bounded retries
> each timeout(60s) wrapping one mysql/obclient call
```

外层 > 内层每一层。任何一层穿透到外层,分类信号已经丢失。

## 3. 测试侧验收(patch-gate)

加新 postReady 或改 postReady 后,pre-run gate 维护:

```
postReady_has_outer_timeout=<bool> # grep -cE 'timeout[ -]'
postReady_no_while_true_unbounded=<bool> # grep -cE 'while true' == 0
postReady_failure_reason_strings=<count> # 至少出现 ENV_PRE_FAIL / RUNNER_INVALID / CONTROL_PLANE_STALL / ENGINE_ERROR / ENGINE_CONVERGE_TIMEOUT 中 ≥1 个 token
postReady_trap_cleanup=<int> # grep -cE '^[[:space:]]*trap '
```

`patch-gate` 通过只能说明上述 4 条**在 live ActionSet 里**;**不**能写"产品 / addon / KB 已被证明稳定"。closeout 必须独立列 readback / cleanup / runtime 三类硬事实。

## 4. PR 评审 checklist

新或改 postReady 时按这 6 条扫:

1. 每条客户端调用是否包 `timeout -k 1 N`?
2. 是否有 `while true` 无 break / 无 deadline 的 loop?grep `'while true'` 应 0 命中。
3. 失败出口是否带 reason 字符串(ENGINE_CONVERGE_TIMEOUT / CONTROL_PLANE_STALL / ENGINE_ERROR / RUNNER_INVALID / ENV_PRE_FAIL)?至少一个分类 token 命中。
4. 退出码是否按 §2 规则 C 用了不同数字(70/71/72/73/74),不是统一 `exit 1`?
5. `trap 'rm -rf ${tmp_dir}' EXIT INT TERM` 是否覆盖 3 信号(避免 SIGTERM 不触发 cleanup)?
6. closeout 模板里有没有把 patch-gate 通过当 PITR / 功能 PASS 证据?(不该当)

任一条不过,PR 不合并。

## 5. 反例 vs 正例

### 反例 1(无外层 timeout)

```bash
mysql --defaults-file="$cnf" -e "CREATE STANDBY TENANT std FROM PRIMARY ..."
```

`CREATE STANDBY TENANT` 在引擎里卡 wait-for-something → 此 hook Block,DataProtection controller 看到 Restore phase=Running 一直不变。

### 正例

```bash
set -euo pipefail
tmp_dir="$(mktemp -d /tmp/addon-pr-XXXXXX)"; chmod 700 "$tmp_dir"
trap 'rm -rf "${tmp_dir}"' EXIT INT TERM
umask 077
cnf="${tmp_dir}/client.cnf"
cat > "$cnf" <<EOF
[client]
host=${HOST}
user=root
password=${OB_ROOT_PASSWD}
EOF

# 单步 timeout
if ! timeout -k 1 60 mysql --defaults-file="$cnf" -e \
"CREATE STANDBY TENANT IF NOT EXISTS std FROM PRIMARY ..." 2>/tmp/cs.err; then
rc=$?
if [ $rc -eq 124 ] || [ $rc -eq 137 ]; then
echo "ERROR: ENGINE_CONVERGE_TIMEOUT step=CREATE_STANDBY_TENANT waited=60s"
exit 74
fi
# engine 报错(OB code / SQLSTATE)
echo "ERROR: ENGINE_ERROR step=CREATE_STANDBY_TENANT rc=$rc stderr=$(cat /tmp/cs.err)"
exit 73
fi

# bounded retry 等收敛
deadline=$(( SECONDS + 600 ))
last_status=""
while [ $SECONDS -lt $deadline ]; do
last_status=$(timeout -k 1 15 mysql --defaults-file="$cnf" -BN -e \
"SELECT status FROM oceanbase.DBA_OB_TENANTS WHERE tenant_name='std'" 2>/dev/null || true)
[ "$last_status" = "NORMAL" ] && break
sleep 5
done
if [ "$last_status" != "NORMAL" ]; then
echo "ERROR: ENGINE_CONVERGE_TIMEOUT step=standby_tenant_normalize last_status=${last_status:-<empty>} waited=$((SECONDS - (deadline-600)))s"
exit 74
fi
```

### 反例 2(`while true` 无退路)

```bash
while true; do
status=$(query_status)
[ "$status" = "NORMAL" ] && break
sleep 5
done
```

修:见正例。

### 反例 3(统一 exit 1,无分类)

```bash
echo "ERROR something failed"
exit 1
```

修:见 §2 规则 C。

## 6. 与其他 skill 的关系

- `addon-mysql-credential-hygiene-no-argv-guide.md` — 本 doc §2 规则 D 对临时凭据 trap 引用其规则 B;外层 `timeout` 与凭据卫生是两条正交规则。
- `addon-bounded-eventual-convergence-guide.md` — 本 doc §2 规则 B 是它在 postReady 场景的特化;bounded retry 与 single-snapshot 反模式同源。
- `addon-test-acceptance-and-first-blocker-guide.md` — §2 规则 C 的 5 层分类直接取自该 doc;postReady 的失败必须能 map 到那 5 层。
- `addon-evidence-discipline-guide.md` — patch-gate 三指标只能证明"修复在位",不能写"产品 / addon / release-ready"。本 doc §3 与 §4 第 6 条复用其结论。

## Appendix A — OceanBase enterprise addon 案例(仅证据,不外推)

### Case A.1 — `oceanbase-physical-backup` ActionSet postReady `CREATE STANDBY TENANT` 修复

OceanBase 早期版本(在某个 closeout 标为 `postreadyfix2`)出过一次 postReady hang:`CREATE STANDBY TENANT` 卡在 `CREATING_STANDBY`,进程在 `CREATE STANDBY TENANT`,超出测试 runner outer budget 1800s 仍未退;closeout 标 `FIRST_BLOCKER:PITR_postReady_standby_create_hang`。

按 §2 改造后(加 outer `timeout` + bounded retry deadline + 5 层失败分类):
- live postReady sha `b74912857084451adbf707166a82bd8f55efce5ca74f3b2ce964c65451fc9925`
- patch-gate:`postReady_has_outer_timeout=true / postReady_no_while_true_unbounded=true / postReady_failure_reason_strings≥1 / postReady_trap_cleanup=1`
- 后续在两条独立运行路径(Mac+port-forward 与 idc4 host-runner+in-cluster DNS)上共累计 6 个 PITR runtime 样本,**无**一例再现 `standby_create_hang`。

边界声明:6 样本无 hang **不**等于「hang 永远不再现」;只支持「在该 6 样本中,postReady 改造后没有进入卡死的可观察窗口」。**不**作为 PITR 验收 / addon acceptance / release-ready / cross-env 稳定的证据。

### Case A.2 — 失败分类落地

OceanBase enterprise addon `oceanbase-physical-backup` postReady 在改造后用 `ENGINE_CONVERGE_TIMEOUT step=standby_tenant_normalize last_status=<...> waited=<...>s` 这条标准 reason;DataProtection controller 把它附在 Restore CR `status.conditions`,runner / closeout 报告直接 grep 这个 token 做 first-blocker 分类,不再写"hang"无分类口径。

附录到此为止。后续若在其它引擎或其它 postReady 路径上有实测数据,请另起 appendix;**不**回写到上方方法论正文。