优化(08-28-production-arm-backup-transfer-incident): 收窄生产哈希读取

这个提交包含在:
2026-08-28 14:14:11 +08:00
父节点 573c46fbe7
当前提交 d6a2b85dc2
共修改 5 个文件,包含 106 行新增和 18 行删除
@@ -2872,6 +2872,12 @@ current shared mapping -> fine-grained stages + mutation listeners
bytes and one of `production_single_point` or `no_proven_recovery_point`. bytes and one of `production_single_point` or `no_proven_recovery_point`.
Off-host staleness alone does not distinguish those two materially different Off-host staleness alone does not distinguish those two materially different
production risks. production risks.
- On an online production host, enumerate older local batches with manifest,
file-set, identity and byte metadata only. Fully hash exactly the newest
metadata-valid candidate; report older hashes as `NOT_RUN`. If that latest
candidate fails SHA-256, keep `no_proven_recovery_point` and do not fall back
to multi-GiB reads of older candidates during the same probe. ARM archive
validation remains full-contract because it is the backup node.
- Inspect cleanup semantics as executable ordering, not a remembered claim. - Inspect cleanup semantics as executable ordering, not a remembered claim.
Source publication must precede transfer; local pruning must follow verified Source publication must precede transfer; local pruning must follow verified
off-host finalize and success-state publication; generation staging must be off-host finalize and success-state publication; generation staging must be
@@ -2959,6 +2965,10 @@ current shared mapping -> fine-grained stages + mutation listeners
corruption and malformed manifest. Assert the risk classification and corruption and malformed manifest. Assert the risk classification and
transition recommendation change without emitting paths, run ids, artifact transition recommendation change without emitting paths, run ids, artifact
names, raw diagnostics or tracebacks. names, raw diagnostics or tracebacks.
- Fixture two metadata-valid production batches with a same-size hash mutation
in the older and then newest candidate. Assert only the newest candidate is
fully hashed, older hashes remain `NOT_RUN`, and a newest-candidate hash
failure does not trigger fallback reads.
- Mechanically mutate the wrapper ordering so pruning appears before transfer; - Mechanically mutate the wrapper ordering so pruning appears before transfer;
the cleanup/retention contract must turn false. A prose claim that failure the cleanup/retention contract must turn false. A prose claim that failure
retains data is not sufficient regression evidence. retains data is not sufficient regression evidence.
@@ -31,7 +31,7 @@
## 只读采集合同 ## 只读采集合同
- 生产侧首先输出最新本地候选是否通过完整 manifest/三文件/字节/SHA-256 合同、年龄、完整批次数量和聚合大小;不输出 run id、文件名或路径。该结果先于传输根因分析,决定当前是“生产单点”还是“无已证明可用备份”。 - 生产侧先用 manifest/三文件/身份/字节元数据枚举全部本地候选,只对最新一个元数据合格候选完整读取 database/storage 并计算 SHA-256;旧候选明确输出 `older_metadata_batch_hash_status=NOT_RUN`,不得为了计数连续读取数 GiB。最新候选只有通过完整 SHA-256 合同才输出 `complete_local_recovery_point=yes`;其哈希失败时不得现场回退扫描旧候选,而应保守标为“无已证明可用备份”。输出年龄、元数据合格数量和聚合大小,不输出 run id、文件名或路径。该结果先于传输根因分析,决定当前是“生产单点”还是“无已证明可用备份”。ARM 仍对正式归档执行完整哈希合同。
- 对成功与失败路径分别输出本地完整批次、生成期临时对象和保留策略的聚合结果,明确 `retained_after_failure`、`pruned_after_success` 和连续失败累积趋势;不从单次目录计数猜测脚本语义。 - 对成功与失败路径分别输出本地完整批次、生成期临时对象和保留策略的聚合结果,明确 `retained_after_failure`、`pruned_after_success` 和连续失败累积趋势;不从单次目录计数猜测脚本语义。
- partial 只输出每个匿名样本的总字节数、预期总字节数、安全比例和年龄;不输出名称、路径、run id 或 artifact 名称。 - partial 只输出每个匿名样本的总字节数、预期总字节数、安全比例和年龄;不输出名称、路径、run id 或 artifact 名称。
- 生产私有诊断只输出固定的 `transfer_exit_code`、`failure_category`、开始/结束 epoch 和持续秒数。若现有状态没有持久化真实 rsync 码或开始时刻,记录 `NOT_AVAILABLE`,需要本地设计可观测性修订并另行批准部署,不能现场改脚本。 - 生产私有诊断只输出固定的 `transfer_exit_code`、`failure_category`、开始/结束 epoch 和持续秒数。若现有状态没有持久化真实 rsync 码或开始时刻,记录 `NOT_AVAILABLE`,需要本地设计可观测性修订并另行批准部署,不能现场改脚本。
@@ -16,7 +16,7 @@
### 本地准备结果(2026-08-28) ### 本地准备结果(2026-08-28)
- 跟踪调查器:`scripts/qa/inspect-backup-transfer-incident.py`;仅接受显式绝对路径参数,不回显参数值,未知异常也只输出稳定类别。 - 跟踪调查器:`scripts/qa/inspect-backup-transfer-incident.py`;仅接受显式绝对路径参数,不回显参数值,未知异常也只输出稳定类别。
- 可复跑合同:`scripts/qa/run-backup-transfer-incident-contract.sh`;17 项合成检查通过,fixture 与 Python cache 零残留;权限拒绝不会被误写成“无有效备份”,其他阶段的旧日志不会冒充本轮 rsync 诊断。 - 可复跑合同:`scripts/qa/run-backup-transfer-incident-contract.sh`;19 项合成检查通过,fixture 与 Python cache 零残留;权限拒绝不会被误写成“无有效备份”,其他阶段的旧日志不会冒充本轮 rsync 诊断;生产多个批次时只哈希最新元数据合格候选,最新哈希失败不回退扫描旧批次。
- 既有 `run-automatic-backup-contract.sh` 74 项通过;生产 wrapper 未改。现状没有持久化传输专属开始/结束时刻,故持续时间必须为 `NOT_AVAILABLE`。 - 既有 `run-automatic-backup-contract.sh` 74 项通过;生产 wrapper 未改。现状没有持久化传输专属开始/结束时刻,故持续时间必须为 `NOT_AVAILABLE`。
- rsync 原始码只在安全摘要明确为 `phase_offhost_transfer_failed` 时从 root 私有 `last-error.log` 固定正则提取,避免把 paired-backup/finalize 的旧诊断错误归因给传输。 - rsync 原始码只在安全摘要明确为 `phase_offhost_transfer_failed` 时从 root 私有 `last-error.log` 固定正则提取,避免把 paired-backup/finalize 的旧诊断错误归因给传输。
+68 -16
查看文件
@@ -33,6 +33,7 @@ SIMILAR_RELATIVE_PPM = 50_000
SIMILAR_ABSOLUTE_BYTES = 64 * 1024 * 1024 SIMILAR_ABSOLUTE_BYTES = 64 * 1024 * 1024
SAFE_KEY_RE = re.compile(r"^[a-z0-9_]+$") SAFE_KEY_RE = re.compile(r"^[a-z0-9_]+$")
SAFE_VALUE_RE = re.compile(r"^(?:[A-Za-z][A-Za-z0-9_]*|-?[0-9]+)$") SAFE_VALUE_RE = re.compile(r"^(?:[A-Za-z][A-Za-z0-9_]*|-?[0-9]+)$")
SHA256_RE = re.compile(r"^[0-9a-f]{64}$")
class InspectionError(Exception): class InspectionError(Exception):
@@ -115,7 +116,13 @@ def run_epoch(stamp: str) -> int | None:
return int(parsed.timestamp()) return int(parsed.timestamp())
def validate_batch(path: Path, expected_run_id: str, stamp: str) -> BatchResult: def validate_batch(
path: Path,
expected_run_id: str,
stamp: str,
*,
verify_hashes: bool = True,
) -> BatchResult:
total = tree_bytes(path) total = tree_bytes(path)
received_artifact_bytes = 0 received_artifact_bytes = 0
expected_artifact_bytes: int | None = None expected_artifact_bytes: int | None = None
@@ -149,10 +156,15 @@ def validate_batch(path: Path, expected_run_id: str, stamp: str) -> BatchResult:
expected_size = item.get("bytes") expected_size = item.get("bytes")
if not isinstance(expected_size, int) or expected_size < 0: if not isinstance(expected_size, int) or expected_size < 0:
return BatchResult(False, total, created_epoch, None, 0) return BatchResult(False, total, created_epoch, None, 0)
expected_sha = item.get("sha256")
if not isinstance(expected_sha, str) or not SHA256_RE.fullmatch(expected_sha):
return BatchResult(False, total, created_epoch, None, 0)
expected_artifact_bytes += expected_size expected_artifact_bytes += expected_size
actual_size = target.stat().st_size actual_size = target.stat().st_size
received_artifact_bytes += actual_size received_artifact_bytes += actual_size
if actual_size != expected_size or file_sha256(target) != item.get("sha256"): if actual_size != expected_size or (
verify_hashes and file_sha256(target) != expected_sha
):
return BatchResult( return BatchResult(
False, False,
total, total,
@@ -329,14 +341,19 @@ def production(args: argparse.Namespace) -> None:
backup_root = safe_root(args.backup_root) backup_root = safe_root(args.backup_root)
state_dir = safe_root(args.state_dir) state_dir = safe_root(args.state_dir)
now = args.now_epoch now = args.now_epoch
valid: list[BatchResult] = [] metadata_valid: list[tuple[Path, str, str, BatchResult]] = []
invalid_count = invalid_bytes = temp_count = temp_bytes = other_count = 0 invalid_count = invalid_bytes = temp_count = temp_bytes = other_count = 0
for entry in backup_root.iterdir(): for entry in backup_root.iterdir():
match = BATCH_RE.fullmatch(entry.name) match = BATCH_RE.fullmatch(entry.name)
if match and entry.is_dir() and not entry.is_symlink(): if match and entry.is_dir() and not entry.is_symlink():
result = validate_batch(entry, match.group(1), match.group(2)) result = validate_batch(
entry,
match.group(1),
match.group(2),
verify_hashes=False,
)
if result.valid and result.created_epoch is not None and result.created_epoch <= now: if result.valid and result.created_epoch is not None and result.created_epoch <= now:
valid.append(result) metadata_valid.append((entry, match.group(1), match.group(2), result))
else: else:
invalid_count += 1 invalid_count += 1
invalid_bytes += result.total_bytes invalid_bytes += result.total_bytes
@@ -346,27 +363,62 @@ def production(args: argparse.Namespace) -> None:
else: else:
other_count += 1 other_count += 1
valid.sort(key=lambda item: item.created_epoch or -1) metadata_valid.sort(key=lambda item: item[3].created_epoch or -1)
latest = valid[-1] if valid else None latest_metadata = metadata_valid[-1] if metadata_valid else None
latest_verified: BatchResult | None = None
if latest_metadata is not None:
latest_verified = validate_batch(
latest_metadata[0],
latest_metadata[1],
latest_metadata[2],
verify_hashes=True,
)
complete_local_recovery_point = bool(latest_verified and latest_verified.valid)
latest_result = latest_metadata[3] if latest_metadata else None
output: list[tuple[str, object]] = [ output: list[tuple[str, object]] = [
("production_probe_result", "PASS"), ("production_probe_result", "PASS"),
("complete_local_recovery_point", bool(valid)), ("complete_local_recovery_point", complete_local_recovery_point),
("backup_risk_classification", "production_single_point" if valid else "no_proven_recovery_point"),
("complete_local_batch_count", len(valid)),
("complete_local_batch_total_bytes", sum(item.total_bytes for item in valid)),
( (
"latest_complete_local_batch_age_seconds", "backup_risk_classification",
now - latest.created_epoch "production_single_point"
if latest and latest.created_epoch is not None if complete_local_recovery_point
else "no_proven_recovery_point",
),
("fully_verified_local_batch_count", 1 if complete_local_recovery_point else 0),
("metadata_complete_local_batch_count", len(metadata_valid)),
(
"metadata_complete_local_batch_total_bytes",
sum(item[3].total_bytes for item in metadata_valid),
),
(
"older_metadata_batch_hash_status",
"NOT_RUN" if len(metadata_valid) > 1 else "NOT_APPLICABLE",
),
(
"latest_local_candidate_hash_status",
"PASS"
if complete_local_recovery_point
else ("FAIL" if latest_metadata is not None else "NOT_RUN"),
),
(
"latest_local_candidate_age_seconds",
now - latest_result.created_epoch
if latest_result and latest_result.created_epoch is not None
else "NOT_AVAILABLE", else "NOT_AVAILABLE",
), ),
("latest_complete_local_batch_bytes", latest.total_bytes if latest else "NOT_AVAILABLE"), (
"latest_local_candidate_bytes",
latest_result.total_bytes if latest_result else "NOT_AVAILABLE",
),
("invalid_local_batch_count", invalid_count), ("invalid_local_batch_count", invalid_count),
("invalid_local_batch_total_bytes", invalid_bytes), ("invalid_local_batch_total_bytes", invalid_bytes),
("generation_temp_count", temp_count), ("generation_temp_count", temp_count),
("generation_temp_total_bytes", temp_bytes), ("generation_temp_total_bytes", temp_bytes),
("unclassified_local_entry_count", other_count), ("unclassified_local_entry_count", other_count),
("transition_recovery_point_action", "wait_natural" if valid else "request_manual_run"), (
"transition_recovery_point_action",
"wait_natural" if complete_local_recovery_point else "request_manual_run",
),
] ]
output.extend(filesystem_fields("production_backup", backup_root)) output.extend(filesystem_fields("production_backup", backup_root))
output.extend(source_contract(args.wrapper_script, args.backup_script)) output.extend(source_contract(args.wrapper_script, args.backup_script))
@@ -95,6 +95,32 @@ assert_line "$TEST_ROOT/production-valid.out" "transfer_failure_category=partial
assert_line "$TEST_ROOT/production-valid.out" "transfer_duration_seconds=NOT_AVAILABLE" assert_line "$TEST_ROOT/production-valid.out" "transfer_duration_seconds=NOT_AVAILABLE"
pass "生产存在完整本地恢复点时识别为单点风险并保留未测时长" pass "生产存在完整本地恢复点时识别为单点风险并保留未测时长"
PRODUCTION_MULTIPLE="$TEST_ROOT/production-multiple"
make_production_layout "$PRODUCTION_MULTIPLE"
make_batch "$PRODUCTION_MULTIPLE/backups/hyb-oa-backup-auto-20260827T000000Z" "auto-20260827T000000Z"
make_batch "$PRODUCTION_MULTIPLE/backups/hyb-oa-backup-auto-20260828T000000Z" "auto-20260828T000000Z"
printf 'X' | dd of="$PRODUCTION_MULTIPLE/backups/hyb-oa-backup-auto-20260827T000000Z/database.dump" bs=1 seek=0 conv=notrunc status=none
run_production "$PRODUCTION_MULTIPLE" "$TEST_ROOT/production-multiple.out"
assert_line "$TEST_ROOT/production-multiple.out" "complete_local_recovery_point=yes"
assert_line "$TEST_ROOT/production-multiple.out" "fully_verified_local_batch_count=1"
assert_line "$TEST_ROOT/production-multiple.out" "metadata_complete_local_batch_count=2"
assert_line "$TEST_ROOT/production-multiple.out" "older_metadata_batch_hash_status=NOT_RUN"
assert_line "$TEST_ROOT/production-multiple.out" "latest_local_candidate_hash_status=PASS"
pass "生产多个批次时只对最新元数据合格候选执行全量 SHA-256"
PRODUCTION_LATEST_HASH_FAIL="$TEST_ROOT/production-latest-hash-fail"
make_production_layout "$PRODUCTION_LATEST_HASH_FAIL"
make_batch "$PRODUCTION_LATEST_HASH_FAIL/backups/hyb-oa-backup-auto-20260827T000000Z" "auto-20260827T000000Z"
make_batch "$PRODUCTION_LATEST_HASH_FAIL/backups/hyb-oa-backup-auto-20260828T000000Z" "auto-20260828T000000Z"
printf 'X' | dd of="$PRODUCTION_LATEST_HASH_FAIL/backups/hyb-oa-backup-auto-20260828T000000Z/database.dump" bs=1 seek=0 conv=notrunc status=none
run_production "$PRODUCTION_LATEST_HASH_FAIL" "$TEST_ROOT/production-latest-hash-fail.out"
assert_line "$TEST_ROOT/production-latest-hash-fail.out" "complete_local_recovery_point=no"
assert_line "$TEST_ROOT/production-latest-hash-fail.out" "fully_verified_local_batch_count=0"
assert_line "$TEST_ROOT/production-latest-hash-fail.out" "metadata_complete_local_batch_count=2"
assert_line "$TEST_ROOT/production-latest-hash-fail.out" "older_metadata_batch_hash_status=NOT_RUN"
assert_line "$TEST_ROOT/production-latest-hash-fail.out" "latest_local_candidate_hash_status=FAIL"
pass "最新候选哈希失败时不回退扫描旧批次并保持无已证明恢复点"
PRODUCTION_EMPTY="$TEST_ROOT/production-empty" PRODUCTION_EMPTY="$TEST_ROOT/production-empty"
make_production_layout "$PRODUCTION_EMPTY" make_production_layout "$PRODUCTION_EMPTY"
run_production "$PRODUCTION_EMPTY" "$TEST_ROOT/production-empty.out" run_production "$PRODUCTION_EMPTY" "$TEST_ROOT/production-empty.out"