fix+feat: 修复打包/解包主链路,并把存储层重建为内容寻址 + 快照
起始状态:主链路 100% 不可用,而且失败被静默吞掉——打包后库里 0 个文件、 界面仍打印"文件数量: N";解包导出 0 个文件并在磁盘上生成几百层 sub/sub/sub 垃圾目录。 缺陷修复(4 个 P0 + 12 个 P1,逐条见 BUG_REPORT.md): - 建表列名 compresse_algo 与 INSERT/read_file 用的 compress_algo 不一致 - list_directory 按 path 而非 parent_path 过滤,导致导出无限自递归 - CLI --ignore-timestamps 把 export_filesystem 赋成引用自身的 lambda - add_file 缺少 commit(直接调用 API 时静默丢数据) - LIKE 前缀误伤兄弟目录(删 doc 连带删 docs);mv_dir 二次改名 - 失败路径一律返回 exit code 0;--exclude 是子串匹配而非 glob - FolderPacker.add_file 反向调用 export_file_safe,会用库内内容覆盖源文件 存储层(schema v3,格式变更,提供 vfsmigrate 迁移): - 内容移入 blobs(hash PRIMARY KEY):相同内容只存一份,跨路径/跨快照共享 - files 的唯一约束改为 UNIQUE(snapshot_id, path):一个库存多个版本 - 完整性:每文件 sha256 + 快照级摘要 + HMAC-SHA256 签名,只读连接即可验证 - 增量打包:按 mtime+size 或 sha256 只写变化项,未变文件不重写不重压 - get_file_info 不再返回 content(元数据查询不该搬 BLOB) 新增能力: - 可选 FastAPI 静态文件插件(extras,核心仍零第三方依赖) - 内容寻址发布到对象存储 + 逐对象校验的取回客户端 - 压缩默认改回 zlib:实测比自研哈夫曼体积小 1/3~1/170、快数百倍 其它: - P2-4:时间戳显式转字符串,清掉 1240 条 DeprecationWarning(盘上格式不变) - read_file 负数 offset/size 改为报错(原先会走切片语义从末尾取) - requires-python 从 >=3.7 改为按实际 API 推导的 >=3.9 测试:0 → 343 个用例,纯标准库 unittest,有/无 FastAPI 两种环境都跑通。 证据见 docs/evidence-*.txt。
This commit is contained in:
@@ -0,0 +1,122 @@
|
||||
#!/usr/bin/env python3
|
||||
"""vfspublish:把 .svfs 发布成"一堆按 hash 命名的对象 + 一份清单"。
|
||||
|
||||
产出可以直接 `aws s3 sync` / `rclone copy` 到对象存储或静态托管:
|
||||
对象名就是内容的 sha256,所以客户端的缓存策略可以是"永久",
|
||||
而且每个对象都能端到端校验(CDN 换不掉内容)。
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import os
|
||||
import sys
|
||||
|
||||
from .. import publish
|
||||
from ..core import VFSFormatError
|
||||
|
||||
|
||||
def publish_objects():
|
||||
parser = argparse.ArgumentParser(
|
||||
description="把虚拟文件系统导出成内容寻址的对象目录 + 清单"
|
||||
)
|
||||
parser.add_argument("db_file", help="SQLite数据库文件路径")
|
||||
parser.add_argument("out_dir", help="输出目录(会创建 blobs/ 与 manifest.json)")
|
||||
parser.add_argument(
|
||||
"--snapshot", metavar="ID", help="发布指定快照(默认取最新快照)"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--shard",
|
||||
type=int,
|
||||
default=publish.DEFAULT_SHARD,
|
||||
help=f"blobs 的分片层数,0 表示不分片(默认 {publish.DEFAULT_SHARD})",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--overwrite",
|
||||
action="store_true",
|
||||
help="输出目录非空时也继续(会覆盖同名对象与清单)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--no-objects",
|
||||
action="store_true",
|
||||
help="只生成清单,不导出对象(用于只更新清单的场景)",
|
||||
)
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
if not os.path.exists(args.db_file):
|
||||
print(f"错误: 数据库文件不存在: {args.db_file}")
|
||||
sys.exit(1)
|
||||
|
||||
done = [0]
|
||||
|
||||
def progress(current, total):
|
||||
done[0] = current
|
||||
if total and (current == total or current % 200 == 0):
|
||||
print(f" 导出对象: {current}/{total}", flush=True)
|
||||
|
||||
try:
|
||||
if args.no_objects:
|
||||
from ..core import SQLiteVFS
|
||||
|
||||
vfs = SQLiteVFS(args.db_file, readonly=True, snapshot_id=args.snapshot)
|
||||
try:
|
||||
manifest = publish.build_manifest(
|
||||
vfs, snapshot_id=args.snapshot, shard=args.shard
|
||||
)
|
||||
finally:
|
||||
vfs.close()
|
||||
os.makedirs(args.out_dir, exist_ok=True)
|
||||
import json
|
||||
|
||||
path = os.path.join(args.out_dir, publish.MANIFEST_NAME)
|
||||
payload = json.dumps(manifest, ensure_ascii=False, indent=2, sort_keys=True)
|
||||
with open(path, "w", encoding="utf-8") as f:
|
||||
f.write(payload)
|
||||
result = {
|
||||
"out_dir": os.path.abspath(args.out_dir),
|
||||
"manifest": path,
|
||||
"manifest_bytes": len(payload.encode("utf-8")),
|
||||
"objects": 0,
|
||||
"objects_bytes": 0,
|
||||
"files": manifest["stats"]["files"],
|
||||
"directories": manifest["stats"]["directories"],
|
||||
"dedup_saved": 0,
|
||||
"snapshot_id": manifest["snapshot"]["id"],
|
||||
"digest": manifest["snapshot"]["digest"],
|
||||
}
|
||||
else:
|
||||
result = publish.export_objects(
|
||||
args.db_file,
|
||||
args.out_dir,
|
||||
snapshot_id=args.snapshot,
|
||||
shard=args.shard,
|
||||
overwrite=args.overwrite,
|
||||
progress=progress,
|
||||
)
|
||||
except VFSFormatError as e:
|
||||
print(f"错误: {e}")
|
||||
sys.exit(1)
|
||||
except FileExistsError as e:
|
||||
print(f"错误: {e}")
|
||||
sys.exit(1)
|
||||
except Exception as e:
|
||||
print(f"错误: 发布失败: {e}")
|
||||
sys.exit(1)
|
||||
|
||||
print("发布完成!")
|
||||
print(f" 快照: {result['snapshot_id']} 摘要: {(result['digest'] or '')[:16]}")
|
||||
print(f" 文件: {result['files']} 目录: {result['directories']}")
|
||||
print(f" 对象: {result['objects']} 个,共 {result['objects_bytes'] / 1024:.1f} KB(去重后的原始字节)")
|
||||
if result["dedup_saved"]:
|
||||
print(f" 去重省下: {result['dedup_saved'] / 1024:.1f} KB(原始总大小 - 去重后)")
|
||||
if result.get("compression_saved"):
|
||||
print(
|
||||
f" 库内实际占用: {result['stored_bytes'] / 1024:.1f} KB"
|
||||
f"(压缩省下 {result['compression_saved'] / 1024:.1f} KB)"
|
||||
)
|
||||
print(f" 清单: {result['manifest']}({result['manifest_bytes'] / 1024:.1f} KB)")
|
||||
print(f" 输出目录: {result['out_dir']}")
|
||||
print(" 下一步: 把整个输出目录同步到对象存储/静态托管即可")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
publish_objects()
|
||||
Reference in New Issue
Block a user