Files

158 lines
5.7 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""vps_mirror_lib — corpus/dmsk NAS→VPS 镜像链纯逻辑核(spec §20.11.2)。
journal 指纹(分区聚合 bytes+max mtime)/diff/水位线计算。bash 端
push_vps_mirror_daily.sh 消费本模块 CLI 输出执行 scp;pytest 直测纯函数。
仅 stdlib(NAS 宿主 python3.8 兼容)。
语义:分区粒度增量、只增不删(远端不镜像本地删除;分区重写靠指纹变化
触发整分区重推吸收)、幂等可重跑、journal 删除=全量重建。
CLI(输出协议 TAB 分隔,bash 消费):
plan [--dry-run] [--journal-dir DIR]
扫描+diff,打印 PUSH 行与 PLAN 摘要;写 pending(除非 --dry-run)
与水位线临时文件 watermark_<family>.json
commit [--journal-dir DIR]
pending 原子晋升为 journal(只在 bash 全部推送成功后调用,
保证 journal 记录的正是推送时扫到的指纹,mid-push 变更次日必重推)
协议行:
PUSH<TAB>family<TAB>remote_parent_posix<TAB>local_partition_abs
PLAN<TAB>push=N<TAB>bytes=N
"""
import argparse
import datetime
import json
import os
import sys
FAMILIES = {
"corpus": {
"domains": ["news_meta", "news_fulltext", "ann_meta", "ann_pdf",
"flash_meta"], # §20.12.3 快讯域(09-22): 新域=指纹全新, 首跑自动全量推一次
"local_root": "/volume1/stock/corpus",
"remote_root": "C:/sanguo_vnpy_v2/data/corpus",
},
"dmsk": {
"domains": ["balance", "cashflow", "express", "forecast", "income"],
"local_root": "/volume1/stock/fundamentals/dmsk",
"remote_root": "C:/sanguo_vnpy_v2/data/fundamentals/dmsk",
},
}
JOURNAL_DIR_DEFAULT = "/volume1/stock/vps-mirror"
WATERMARK_NAME = "_mirror_watermark.json"
def fingerprint(partition_dir):
# type: (str) -> list
"""分区指纹 = [聚合 bytes, max mtime_ns]。任一变化即判该分区需重推。"""
total = 0
max_ns = 0
for dirpath, _dirnames, filenames in os.walk(partition_dir):
for fn in filenames:
st = os.stat(os.path.join(dirpath, fn))
total += st.st_size
if st.st_mtime_ns > max_ns:
max_ns = st.st_mtime_ns
return [total, max_ns]
def scan_family(root, domains):
# type: (str, list) -> dict
"""{domain/分区名: 指纹}。分区=域目录下一级子目录(dt= 或 ann_pdf 年目录)。"""
inv = {}
for domain in domains:
droot = os.path.join(root, domain)
if not os.path.isdir(droot):
continue
for name in sorted(os.listdir(droot)):
p = os.path.join(droot, name)
if os.path.isdir(p):
inv["%s/%s" % (domain, name)] = fingerprint(p)
return inv
def diff(journal, inventory):
# type: (dict, dict) -> tuple
"""返回 (待推送键列表, 新 journal 状态)。只增不删:journal 有而本地无的键
不产生任何动作(远端保留),也不从新状态里显式剔除以外的处理。"""
to_push = [k for k in sorted(inventory) if journal.get(k) != inventory[k]]
return to_push, dict(inventory)
def watermark_payload(inventory):
# type: (dict) -> dict
"""单族水位线:各 domain 的最大分区值(dt= 前缀剥掉;年目录原样)。"""
latest = {}
for key in inventory:
domain, part = key.split("/", 1)
val = part[3:] if part.startswith("dt=") else part
if domain not in latest or val > latest[domain]:
latest[domain] = val
return {
"generated_at": datetime.datetime.now().isoformat(timespec="seconds"),
"domains": latest,
}
def commit(journal_path, pending_path):
# type: (str, str) -> None
"""pending 原子晋升为 journal;无 pending = no-op。"""
if os.path.exists(pending_path):
os.replace(pending_path, journal_path)
def _load_journal(journal_path):
if os.path.exists(journal_path):
with open(journal_path) as f:
return json.load(f)
return {}
def main(argv=None):
ap = argparse.ArgumentParser()
ap.add_argument("mode", choices=["plan", "commit"])
ap.add_argument("--dry-run", action="store_true",
help="plan 只打印不写 pending")
ap.add_argument("--journal-dir", default=JOURNAL_DIR_DEFAULT)
args = ap.parse_args(argv)
journal_path = os.path.join(args.journal_dir, "journal.json")
pending_path = os.path.join(args.journal_dir, "journal.pending.json")
if args.mode == "commit":
commit(journal_path, pending_path)
return 0
os.makedirs(args.journal_dir, exist_ok=True)
old_journal = _load_journal(journal_path)
new_state = {}
push_count = 0
push_bytes = 0
lines = []
for fam in sorted(FAMILIES):
spec = FAMILIES[fam]
inv = scan_family(spec["local_root"], spec["domains"])
to_push, merged = diff(old_journal.get(fam, {}), inv)
new_state[fam] = merged
for key in to_push:
domain, part = key.split("/", 1)
local_abs = os.path.join(spec["local_root"], domain, part)
remote_parent = "%s/%s" % (spec["remote_root"], domain)
lines.append("PUSH\t%s\t%s\t%s" % (fam, remote_parent, local_abs))
push_count += 1
push_bytes += inv[key][0]
wm_path = os.path.join(args.journal_dir, "watermark_%s.json" % fam)
with open(wm_path, "w") as f:
json.dump(watermark_payload(inv), f, ensure_ascii=False)
if not args.dry_run:
with open(pending_path, "w") as f:
json.dump(new_state, f, ensure_ascii=False)
for line in lines:
print(line)
print("PLAN\tpush=%d\tbytes=%d" % (push_count, push_bytes))
return 0
if __name__ == "__main__":
sys.exit(main())