lili 8200aa08d0
Some checks failed
contract-gates / contract-gates (push) Has been cancelled
docs-gate / docs-gate (push) Has been cancelled
feat(tier2): W-ASSET-SRC 源工程存储 manifest-first 落库 + 治理对账门 + 取回重建 harness + 孤儿清扫 job (SRC T1-T4)
背景/目标:tier2 富游戏 BackendStore 真实现已写好却未通电,且 save 走 MinIO-first、半落库会留无 manifest
指向的隐形对象,manifest 表游离在治理外(in-code DDL + 留档 .sql 双源无机器门)。本切片按已拍板的
manifest-first 方案把 SRC 一面推到「接生产、schema 受治理、取回可重建、半落库孤儿有修法」。只碰富游戏这
一面,不动便宜档 Java 面、不做素材、不接 fetch 生产消费方。

改动(六件核心 + T1/T3/T4):
- T2① status 列(pending/committed):新建走 CREATE DDL、存量走受治理 ALTER;BackendStore._ensure_schema
  按 information_schema 探测缺列才补(每实例一次,跨 MySQL/MariaDB 稳)。
- T2② save 改写入序:MySQL 落 pending 行 → MinIO put → 翻 committed;命中 committed 幂等返回、命中
  pending 复用 versionId 续跑自愈(同前缀真覆盖)。半落库崩溃只留可见 pending 行、无隐形对象。
- T2③ fetch 两条分支(取最新 / 取指定版本)都补 AND status='committed',pending 半成品对取回不可见。
- T2④ 治理归属:.sql 留 tier2/config/schema/(owner=tier2、不走 huijing Flyway、无执行副本),
  contracts/README.md 加治理指针认领、明说不参与 Flyway 对账;不塞 db-schemas/ 根。
- T2⑤ 可跑对账机器门 test_store_ddl_reconcile.py:规范化比对 in-code(_DDL+_ALTER)与 .sql,改一处
  漏改另一处即红。
- T2⑥ 修 store.py 模块头 docstring 整块(占位/seam/绝不实连/forbidden-import 守着 的陈旧误述改为与真
  实现一致,并纠正守卫范围:check-forbidden-import.sh 只禁 wg1.*/SAA/L2,不禁 pymysql/minio)。
- T1 接线单点:TIER2_STORE=backend 激活配方 + 前置 checklist(CREATE DATABASE / status 列状态确认)落
  staging-ops §7(tier2 gen-worker 部署 env 单点);不改 default_store 工厂形态。
- T3 取回重建 harness store_roundtrip_harness.py:五条(确定性重建优先 bundle sha256 字节相等、非字节稳
  才退九门 verdict 逐项一致+无 missingContent、不拿近乎恒真 buildInputHash 承重;版本寻址;幂等;缺源
  显式失败;半落库两失败点),本地 LocalFsStore 自检覆盖 1-4、真库+bundle 字节相等标 follow-up 窗口。
- T4 孤儿清扫 store_cleanup_job.py + BackendStore.cleanup_orphans:乐观删(status=pending CAS 先删行
  再删对象)防与续 put 的 TOCTOU 竞态,幂等,owner=tier2,可追溯日志。

契约(contract-first):.sql DDL additive 加 status 列 + 首份受治理 ALTER;contracts/README.md 治理指针;
落库寻址 doc↔code 断言点(表名/双唯一键/「pending 对 fetch 不可见」)。不改 tier2-source-project.schema.json
addressing、不改 buildInputHash 字段、不改 GamePackage / 便宜档 source-project.schema。

验证:本地 14 个新单测真跑全绿(DDL 对账 3 + manifest-first 行为 8 + harness 本地 3);harness 脚本
LocalFsStore 自检 5/5;docs-gate 七检全绿;forbidden-import 守卫通过。真 MySQL+MinIO 往返 + bundle 字节
相等 = mini-infra 窗口 follow-up(worktree 无真库/无 esbuild,诚实未跑)。缺重依赖的既有测试(agentscope/
nacos)本极简 venv 未跑,均不 import store、与本改动无关。

计划锚点:docs/plans/2026-07-05-001-feat-asset-src-源工程存储-plan.md §4 T1-T4 / §5。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-05 18:03:28 -07:00

53 lines
2.6 KiB
Python

#!/usr/bin/env python3
"""store_cleanup_job.py —— T4 tier2 源工程孤儿清扫 job 的可跑入口(部署窗口挂定时任务用)。
【这个 job 做什么】manifest-first 把「半落库」从隐形对象变成【可见 pending 行】。若某 pending 行既没被
续跑自愈、也没被清扫,它连同 MinIO 里的半写对象会长期累积。本 job 定期扫超时 pending 行、乐观删(CAS)
回收该行 + 其 MinIO 前缀对象。真正的回收逻辑在 worker/store.py 的 BackendStore.cleanup_orphans
(乐观删防与续 put 的 TOCTOU 竞态、幂等、可追溯日志),本文件只是给它一个能被 cron / systemd-timer /
apscheduler 调起的薄入口。owner = tier2 生成线。
【为什么单独一个入口而不塞进 service 常驻进程】清扫是低频、幂等、可独立重跑的运维动作,做成独立可调
入口(与 demo_nacos_hot_read.py 同款薄脚本)最省心:部署窗口按需 cron(如每小时)即可,不必给 Agent
Service 常驻进程加一条定时线程。若后续要收进 service 常驻,直接在 bootstrap 调 cleanup_orphans 即可。
【跑法(真库,mini-infra 窗口)】
TIER2_STORE=backend PYTHONPATH=tier2/gen-worker <venv>/bin/python \
tier2/gen-worker/scripts/store_cleanup_job.py --ttl 3600
(需 pymysql/minio + infra.yaml 可达真 MySQL+MinIO;6c6g 无真库跑不了,逻辑由内存 fake 单测覆盖。)
【诚实边界】本机 worktree 无真库,本脚本不在 6c6g 上真跑;回收正确性由
tests/test_store_backend_manifest_first.py 的 cleanup 用例(内存 fake)坐实,真库真跑排 mini-infra 窗口。
"""
from __future__ import annotations
import argparse
import os
import sys
_HERE = os.path.dirname(os.path.abspath(__file__))
_GW = os.path.dirname(_HERE) # gen-worker/
if _GW not in sys.path:
sys.path.insert(0, _GW)
from worker import store # noqa: E402
def main() -> int:
ap = argparse.ArgumentParser(description="tier2 源工程孤儿清扫 job(超时 pending 行 → 回收行+对象)")
ap.add_argument("--ttl", type=int, default=3600,
help="pending 行超过此秒数才判孤儿(默认 3600=1 小时,给续跑留足自愈窗口)")
args = ap.parse_args()
# 只对 BackendStore 有意义(LocalFsStore 无 pending/committed 中间态)。
print(f"[store-cleanup-job] 启动:ttl={args.ttl}s", flush=True)
bs = store.BackendStore()
res = bs.cleanup_orphans(ttl_seconds=args.ttl)
print(f"[store-cleanup-job] 完成:扫描={res['scanned']} 回收={res['reclaimed']} "
f"删对象={res['objects_deleted']}", flush=True)
return 0
if __name__ == "__main__":
sys.exit(main())