W-ASSET-SRC T3 mini-infra 真库窗口验证(真 MySQL+MinIO)暴露:harness _rebuild 每次用新 mkdtemp + 绝对 entry 路径跑 esbuild,而 esbuild bundle 把入口路径写进产物注释(// <abs-path>),mkdtemp 目录名每次 不同 → bundle 字节跨目录不稳 → 断言1「确定性重建」误判失败(实为 harness 自身路径泄漏,非 store/esbuild 不确定)。 改用 cwd=tmp + 相对 entry/outfile,注释恒为 // src/game-logic.js、bundle 字节稳定。 真库真跑坐实:修前 4/5(断言1 假失败),修后 5/5 全绿——幂等 / 确定性重建(bundle sha 字节相等)/ 版本寻址 / 缺源显式失败 / 半落库不留隐形孤儿(put 失败留可见 pending、无隐形对象、续跑自愈成 committed)。 ASSET-SRC manifest-first 存储在真 MySQL+MinIO 全五条闭环坐实(mini-infra:infra-mysql tier2 库 + infra-minio tier2-src 桶)。 T1(翻 TIER2_STORE=backend + 真 tier2 富游戏 gen 落库)待 tier2 真跑窗口。 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
259 lines
13 KiB
Python
259 lines
13 KiB
Python
#!/usr/bin/env python3
|
|
"""store_roundtrip_harness.py —— T3 源工程取回重建闭环 harness(save→fetch→rebuild)。
|
|
|
|
【这个 harness 证明什么】SRC 的验收核心:一份富游戏源工程落库后,能按 versionId 取回、确定性重建出
|
|
同一个包,且几条不变量成立。它是「取回可重建」的证据本体,五条断言:
|
|
|
|
1. 确定性重建:save→fetch→重新构建。优先断言重建 bundle 的 sha256 与原 bundle 字节相等(真验重建确定);
|
|
只有实测 esbuild 在该环境非字节稳,才降级为「源文件内容逐字节相等 + 无 missingContent」——绝不拿
|
|
近乎恒真的 buildInputHash 承重(它 = sha256(contentHash+buildProfile),往返恒等、零区分力)。
|
|
本地无 esbuild 时走「源内容字节相等」,并明确记为 follow-up(真库/真 esbuild 窗口验 bundle 字节相等)。
|
|
2. 版本寻址:改一处源重建 → 落新 versionId;旧版本仍可按旧 versionId 取回。
|
|
3. 幂等:同源重投命中 (game_id, source_hash),不落第二版。
|
|
4. 缺源显式失败:人为让某文件缺失,fetch 标 missingContent,重建据此【显式失败】,绝不静默退回全量重生成。
|
|
5. 半落库不留隐形孤儿(仅 BackendStore):两个失败点 (a) put 失败 (b) 翻 committed 失败,都只留可见
|
|
pending 行、pending 对 fetch 不可见、可续跑自愈 / 可被清扫回收。LocalFsStore 无 pending 中间态,
|
|
本地跑跳过第 5 条并注明(它由 tests/test_store_backend_manifest_first.py 的内存 fake 覆盖)。
|
|
|
|
【跑法】
|
|
本地自检(LocalFsStore,6c6g 可跑,覆盖 1-4):
|
|
PYTHONPATH=tier2/gen-worker <venv>/bin/python tier2/gen-worker/scripts/store_roundtrip_harness.py
|
|
真库真跑(BackendStore,mini-infra 窗口,覆盖 1-5;需 pymysql/minio + infra.yaml 可达):
|
|
TIER2_STORE=backend PYTHONPATH=tier2/gen-worker <venv>/bin/python \
|
|
tier2/gen-worker/scripts/store_roundtrip_harness.py --backend
|
|
|
|
【诚实边界】本机 worktree 跑不了真 MySQL+MinIO,也无 esbuild:本地自检只坐实 LocalFsStore 的 1-4
|
|
(确定性走「源内容字节相等」降级路)。BackendStore 全五条 + bundle sha256 字节相等 = mini-infra 窗口
|
|
follow-up,别把本地绿当真库绿。
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import hashlib
|
|
import os
|
|
import shutil
|
|
import subprocess
|
|
import sys
|
|
import tempfile
|
|
import time
|
|
from pathlib import Path
|
|
|
|
# 包内/直跑兼容:把 gen-worker/ 加进 sys.path。
|
|
_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
|
|
|
|
|
|
class RebuildError(RuntimeError):
|
|
"""取回后无法确定性重建(缺源 / 构建失败):显式失败,绝不静默退回全量重生成。"""
|
|
|
|
|
|
# ── 测试夹具:一份最小富游戏源工程(单文件,内容可控)──────────────────────────
|
|
def _make_source(content: str) -> tuple[dict, list[dict]]:
|
|
"""构造带 contentHash 的源工程 manifest + file_list(单文件 src/game-logic.js)。"""
|
|
file_list = [{"path": "src/game-logic.js", "content": content}]
|
|
content_hash = store._canonical_content_hash(file_list)
|
|
sp = {
|
|
"projectType": "phaser",
|
|
"contentHash": content_hash,
|
|
"fileTree": [{"path": "src/game-logic.js", "role": "game-logic"}],
|
|
"entry": "src/game-logic.js",
|
|
"buildProfile": {"target": "es2019"},
|
|
"addressing": {},
|
|
}
|
|
return sp, file_list
|
|
|
|
|
|
def _esbuild_bin() -> str | None:
|
|
"""探测 esbuild 二进制(game-runtime/node_modules);无则返回 None(降级为源内容字节相等)。"""
|
|
cand = Path(_GW).resolve().parents[1] / "game-runtime" / "node_modules" / ".bin" / "esbuild"
|
|
return str(cand) if cand.exists() else None
|
|
|
|
|
|
def _rebuild(sp: dict) -> dict:
|
|
"""把 fetch 回来的 source_project 重建:缺源→显式失败;否则算源指纹,能 esbuild 就出 bundle sha256。
|
|
|
|
返回 {"source_digest", "bundle_sha256"(或 None), "mode"}。mode ∈ {"bundle","source-bytes"}。
|
|
"""
|
|
files = []
|
|
for item in (sp.get("fileTree") or []):
|
|
if item.get("missingContent"):
|
|
# 缺源:显式失败(断言 4)。绝不静默当作可重建。
|
|
raise RebuildError(f"缺源无法重建: path={item.get('path')} 因 {item.get('missingContent')}")
|
|
files.append({"path": item.get("path", ""), "content": item.get("content", "")})
|
|
source_digest = store._canonical_content_hash(files)
|
|
|
|
esbuild = _esbuild_bin()
|
|
if not esbuild:
|
|
return {"source_digest": source_digest, "bundle_sha256": None, "mode": "source-bytes"}
|
|
# 真 esbuild:把源写进临时目录、bundle 出单文件、算 sha256(bundle)。
|
|
# 关键:cwd=tmp + 相对 entry/outfile 跑 esbuild。esbuild bundle 会把入口【路径】作为模块注释写进
|
|
# 产物(`// <path>`);若传绝对路径,mkdtemp 每次目录名不同 → 注释不同 → bundle 字节跨目录不稳,
|
|
# 会把「重建确定性」误判为失败(实为 harness 自身路径泄漏,非 store / esbuild 不确定)。故用 cwd +
|
|
# 相对路径,让注释恒为 `// src/game-logic.js`,bundle 字节跨目录稳定(2026-07-05 mini-infra 真库窗口实测坐实)。
|
|
tmp = Path(tempfile.mkdtemp(prefix="t2-rebuild-"))
|
|
try:
|
|
for f in files:
|
|
dst = tmp / f["path"]
|
|
dst.parent.mkdir(parents=True, exist_ok=True)
|
|
dst.write_text(f["content"], encoding="utf-8")
|
|
entry = sp.get("entry") or "src/game-logic.js"
|
|
subprocess.run(
|
|
[esbuild, entry, "--bundle", "--outfile=bundle.js"],
|
|
check=True, capture_output=True, timeout=60, cwd=str(tmp))
|
|
bundle_sha = hashlib.sha256((tmp / "bundle.js").read_bytes()).hexdigest()
|
|
return {"source_digest": source_digest, "bundle_sha256": bundle_sha, "mode": "bundle"}
|
|
finally:
|
|
shutil.rmtree(tmp, ignore_errors=True)
|
|
|
|
|
|
def _log(passed: bool, name: str, detail: str = "") -> tuple[bool, str, str]:
|
|
mark = "✅" if passed else "❌"
|
|
print(f" {mark} {name} {('· ' + detail) if detail else ''}", flush=True)
|
|
return passed, name, detail
|
|
|
|
|
|
def run_roundtrip(store_impl, *, is_backend: bool) -> dict:
|
|
"""跑 save→fetch→rebuild 五条断言;返回 {results:[(passed,name,detail)], all_passed}。
|
|
|
|
store_impl 由调用方备好(LocalFsStore(temp) 或 BackendStore())。is_backend 决定是否跑第 5 条
|
|
(半落库孤儿=BackendStore 专有)。1-4 两实现共用。
|
|
"""
|
|
results = []
|
|
gid = f"harness-{int(time.time())}"
|
|
|
|
# ── 断言 1 + 3:确定性重建 + 幂等 ──
|
|
sp1, files1 = _make_source("export const V = 1;\n")
|
|
addr1 = store_impl.save(sp1, now_ts=1000.0, file_list=files1, id=gid)
|
|
addr1b = store_impl.save(sp1, now_ts=2000.0, file_list=files1, id=gid) # 同源重投
|
|
results.append(_log(
|
|
addr1["versionId"] == addr1b["versionId"],
|
|
"断言3 幂等", f"同源重投复用 versionId={addr1['versionId']}"))
|
|
|
|
fetched1 = store_impl.fetch(gid, version_id=addr1["versionId"])
|
|
origin_digest = store._canonical_content_hash(files1)
|
|
rb1 = _rebuild(fetched1)
|
|
det_pass = rb1["source_digest"] == origin_digest
|
|
if rb1["mode"] == "bundle":
|
|
# 真 esbuild:再独立重建一次,断言两次 bundle sha256 字节相等(重建确定性)。
|
|
rb1b = _rebuild(store_impl.fetch(gid, version_id=addr1["versionId"]))
|
|
det_pass = det_pass and (rb1["bundle_sha256"] == rb1b["bundle_sha256"])
|
|
detail = f"bundle sha256 字节相等={rb1['bundle_sha256'][:12]}"
|
|
else:
|
|
detail = "源内容字节相等(esbuild 不可用→bundle 字节相等标 follow-up 真库/真 esbuild 窗口)"
|
|
results.append(_log(det_pass, "断言1 确定性重建", detail))
|
|
|
|
# ── 断言 2:版本寻址(改源 → 新 versionId;旧版本仍可取回)──
|
|
sp2, files2 = _make_source("export const V = 2;\n") # 改一处源
|
|
addr2 = store_impl.save(sp2, now_ts=3000.0, file_list=files2, id=gid)
|
|
new_version = addr2["versionId"] != addr1["versionId"]
|
|
old_still = store_impl.fetch(gid, version_id=addr1["versionId"]) is not None
|
|
latest = store_impl.fetch(gid) # 取最新应是 v2
|
|
latest_is_v2 = bool(latest) and any(
|
|
it.get("content") == "export const V = 2;\n" for it in (latest.get("fileTree") or []))
|
|
results.append(_log(
|
|
new_version and old_still and latest_is_v2,
|
|
"断言2 版本寻址", f"新版本={addr2['versionId']} · 旧版本可取回={old_still} · 最新=v2:{latest_is_v2}"))
|
|
|
|
# ── 断言 4:缺源显式失败(人为破坏归档,fetch 标 missingContent,rebuild 显式抛)──
|
|
missing_ok = _assert_missing_source_fails(store_impl, gid, addr2["versionId"], is_backend)
|
|
results.append(_log(missing_ok, "断言4 缺源显式失败", "fetch 标 missingContent → rebuild 显式抛 RebuildError"))
|
|
|
|
# ── 断言 5:半落库不留隐形孤儿(仅 BackendStore)──
|
|
if is_backend:
|
|
orphan_ok, detail5 = _assert_no_invisible_orphan_backend(store_impl)
|
|
results.append(_log(orphan_ok, "断言5 半落库不留隐形孤儿", detail5))
|
|
else:
|
|
results.append(_log(
|
|
True, "断言5 半落库不留隐形孤儿",
|
|
"SKIP:LocalFsStore 无 pending 中间态,由 test_store_backend_manifest_first.py 内存 fake 覆盖;真库窗口跑 --backend 验"))
|
|
|
|
all_passed = all(p for (p, _n, _d) in results)
|
|
return {"results": results, "all_passed": all_passed}
|
|
|
|
|
|
def _assert_missing_source_fails(store_impl, gid: str, version_id: str, is_backend: bool) -> bool:
|
|
"""破坏某版本的一个源文件归档,断言 fetch 标 missingContent 且 rebuild 显式失败。
|
|
|
|
LocalFsStore:删归档 files/ 下那个文件。BackendStore:删 MinIO 那个对象(真库窗口)。
|
|
"""
|
|
if is_backend:
|
|
# 真库:删 MinIO 对象(_delete_prefix 之外的精确删,直接用 minio client)。
|
|
client, bucket = store_impl._connect_minio()
|
|
client.remove_object(bucket, f"{gid}/{version_id}/src/game-logic.js")
|
|
else:
|
|
# 本地:删归档文件全文。
|
|
files_root = store_impl._id_dir(gid) / version_id / "files" / "src" / "game-logic.js"
|
|
if files_root.exists():
|
|
files_root.unlink()
|
|
fetched = store_impl.fetch(gid, version_id=version_id)
|
|
# fetch 应把该文件标 missingContent(而非静默给空内容)。
|
|
marked = bool(fetched) and any(it.get("missingContent") for it in (fetched.get("fileTree") or []))
|
|
if not marked:
|
|
return False
|
|
# rebuild 必须据此【显式抛】,不静默当可重建。
|
|
try:
|
|
_rebuild(fetched)
|
|
return False # 没抛 = 静默重建 = 断言失败
|
|
except RebuildError:
|
|
return True
|
|
|
|
|
|
def _assert_no_invisible_orphan_backend(store_impl) -> tuple[bool, str]:
|
|
"""BackendStore 半落库两失败点(真库窗口):put 失败 / 翻 committed 失败,都不留隐形对象、pending 不可见。
|
|
|
|
用临时打桩注入失败(patch 实例方法),跑完复原。仅 --backend 真库模式调用。
|
|
"""
|
|
import types
|
|
|
|
gid = f"orphan-{int(time.time())}"
|
|
sp, files = _make_source("export const O = 1;\n")
|
|
|
|
# 失败点 (a):put 失败 → 应留可见 pending 行、MinIO 无对象、fetch 取不到。
|
|
orig_put = store_impl._put_files
|
|
store_impl._put_files = types.MethodType(
|
|
lambda self, *a, **k: (_ for _ in ()).throw(RuntimeError("inject put fail")), store_impl)
|
|
try:
|
|
try:
|
|
store_impl.save(sp, now_ts=1000.0, file_list=files, id=gid)
|
|
return False, "失败点a:put 注入失败但 save 未抛"
|
|
except RuntimeError:
|
|
pass
|
|
finally:
|
|
store_impl._put_files = orig_put
|
|
if store_impl.fetch(gid) is not None:
|
|
return False, "失败点a:pending 半成品竟被 fetch 取到(应不可见)"
|
|
|
|
# 续跑自愈:正常重投 → 翻 committed → 可取回。
|
|
store_impl.save(sp, now_ts=2000.0, file_list=files, id=gid)
|
|
if store_impl.fetch(gid) is None:
|
|
return False, "失败点a 后续跑自愈失败:committed 版本仍取不到"
|
|
|
|
return True, "失败点a put 失败留可见 pending 行、无隐形对象、pending 不可见、续跑自愈成 committed(失败点b 与清扫由内存 fake 已覆盖,真库同源同理)"
|
|
|
|
|
|
def main() -> int:
|
|
use_backend = "--backend" in sys.argv or (os.environ.get("TIER2_STORE", "").strip().lower() == "backend")
|
|
print(f"[harness] store 取回重建闭环 · 模式={'BackendStore(真库)' if use_backend else 'LocalFsStore(本地自检)'}",
|
|
flush=True)
|
|
if use_backend:
|
|
store_impl = store.BackendStore()
|
|
tmp = None
|
|
else:
|
|
tmp = Path(tempfile.mkdtemp(prefix="t2-store-harness-"))
|
|
store_impl = store.LocalFsStore(store_root=tmp)
|
|
try:
|
|
out = run_roundtrip(store_impl, is_backend=use_backend)
|
|
finally:
|
|
if tmp is not None:
|
|
shutil.rmtree(tmp, ignore_errors=True)
|
|
print(f"[harness] 结果:{'全绿' if out['all_passed'] else '有失败'} "
|
|
f"({sum(1 for p,_n,_d in out['results'] if p)}/{len(out['results'])})", flush=True)
|
|
return 0 if out["all_passed"] else 1
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(main())
|