首次提交

This commit is contained in:
2026-09-28 16:09:31 +08:00
commit 4a6839319a
71 changed files with 12144 additions and 0 deletions

814
tool/job_cos.py Normal file
View File

@@ -0,0 +1,814 @@
# -*- coding: utf-8 -*-
"""COS 任务分发 / 成功记录采集。
核心原语: COS PutObject 带 ForbidOverwrite(x-cos-forbid-overwrite:true) —— 同名对象
只能被创建一次, 并发写同一个锁文件只有第一个成功(其余 409)。以此实现跨机器原子认领,
无需任何额外服务(Redis/DB)。
策略: 粘性认领(sticky)。认领某分片的机器是它的唯一所有者;机器重启后只续做自己名下
未完成的分片, 别的机器永不抢占 -> 零重复。机器永久报废时, 人工跑 tools/release_machine.py
把它名下的任务放回队列。
COS 布局(jobs_root 下):
todo/<shard>.txt 待处理分片(每行一个视频 COS 路径)。完成才删, 是"任务存在性"的唯一凭证。
claimed/<shard>.lock 认领锁, 内容 JSON {machine_id, host, claim_ts, hb_ts}。
done/<shard>.done 完成标记, 内容 JSON {machine_id, stats, end_ts}。
独立位置(各自 config 字段, 可指向别处):
daily_backup_path/<日期>/数据_<机器号>.jsonl 每日 数据.jsonl 全量快照备份。
daily_backup_path/<日期>/已处理_<机器号>.txt 上面jsonl的轻量版(只有video_cos_path,
一行一个), 供 global_processed_set() 全局去重用。
long_list_backup_path/todo/超长待重跑_<机器号>_<日期>_<时分秒>.txt 超长视频重跑队列(本身就是另一套 jobs_root)。
"""
import json
import logging
import os
import shutil
import socket
import subprocess
from datetime import datetime, timedelta
from qcloud_cos import CosConfig, CosS3Client
from qcloud_cos.cos_exception import CosServiceError
from config.config_utils import config
# 老版本 cos-python-sdk-v5 的 put_object 不认识 ForbidOverwrite 参数(其 maplist 没有该映射),
# 直接传会报 "No Parameter Named ForbidOverwrite"。这里把映射补进去, 使 ForbidOverwrite='true'
# 注入 x-cos-forbid-overwrite:true 请求头 —— 认领锁的原子性(同名对象只能创建一次)全靠它。
# 不升级 SDK(线上离线机不便升级), 版本无关, 幂等。
try:
from qcloud_cos import cos_comm as _cos_comm
_cos_comm.maplist.setdefault("ForbidOverwrite", "x-cos-forbid-overwrite")
except Exception:
pass
# ---------------------------------------------------------------- 客户端 ----
_CLIENT = None
def client():
"""模块级单例 CosS3Client(复用项目现有 COS 凭据)。"""
global _CLIENT
if _CLIENT is None:
c = config.get_cos_config()
_CLIENT = CosS3Client(CosConfig(
Region=c["region"], SecretId=c["secret_id"], SecretKey=c["secret_key"],
))
return _CLIENT
def parse_cos_url(url: str):
"""cos://bucket/a/b -> ('bucket', 'a/b')。无 cos:// 前缀时按 bucket 配置补全。"""
url = url.strip()
if url.startswith("cos://"):
rest = url[len("cos://"):]
bucket, _, key = rest.partition("/")
return bucket, key.strip("/")
return config.get_cos_config()["bucket"], url.strip("/")
# ----------------------------------------------------------------- 配置 ----
def _cfg():
return config.get_dispatch_config() or {}
def _detect_ip() -> str:
"""取本机对外默认路由的网卡IP:用 UDP socket connect 一个外部地址触发内核选路由
(不会真的发包), 拿到的本地地址天然跳过 docker 网桥(如172.17.0.1)/回环这类非默认路由
接口 —— 通常就是 SSH 连进来那张网卡的IP(前提:机器只有一条默认路由出口, 常见的
'一张主网卡+docker网桥'部署; 若有多张真实网卡且SSH走的不是默认路由那张, 不适用,
建议改用 `ip route get 8.8.8.8` 在目标机上核实后再决定)。
取不到再退化到 `hostname -I` 第一个; 都拿不到返回空串, 上层回退主机名。
仅在 IP 固定(非 DHCP)的机器上用它兜底身份才安全。"""
try:
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
try:
s.connect(("8.8.8.8", 80))
ip = s.getsockname()[0]
finally:
s.close()
if ip and not ip.startswith("127."):
return ip
except OSError:
pass
try:
r = subprocess.run(['hostname', '-I'], capture_output=True, text=True, timeout=5)
ips = (r.stdout or '').split()
return ips[0] if ips else ''
except (OSError, subprocess.SubprocessError):
return ''
def get_machine_id() -> str:
"""机器ID 取值优先级: config.dispatch.machine_id > 环境变量 MACHINE_ID >
本机固定IP(默认路由网卡, 见 _detect_ip) > 主机名。
必须【唯一且重启后稳定】—— 锁靠它判断'这片是不是我的'。克隆机场景下主机名常常相同,
固定IP各机器天然不同,可以直接兜底;但如果 IP 是 DHCP 会变的,不要依赖这个,
换IP等于换身份,旧身份名下未完成的锁会变孤儿——那种环境请在 config 里显式填 machine_id。"""
mid = (_cfg().get("machine_id") or "").strip()
if mid:
return mid
mid = (os.environ.get("MACHINE_ID") or "").strip()
if mid:
return mid
ip = _detect_ip()
if ip:
return ip
return socket.gethostname()
def is_ocr_colocated() -> bool:
"""本机是不是 OCR 服务共置机: 本机默认路由IP是否出现在 config.tencentcloud.ocr.endpoints
列表里。这批端点IP本来就是各台跑OCR服务的机器, 如果本机自己也在这个列表里, 说明这台机器
除了跑抽帧/OCR客户端之外, 本身还扛着一个OCR服务, 需要多留CPU给它, 不能被抽帧吃满。
只覆盖"OCR端点=本机IP"这一种共置方式; 其它不出现在这个列表里的共享方式(比如机器上还跑
了别的无关服务)探测不到, 那种情况还是得手动传小一点的 --max-workers。"""
try:
endpoints = set((config.get_tencent_ocr_config() or {}).get("endpoints") or [])
except Exception:
return False
if not endpoints:
return False
ip = _detect_ip()
return bool(ip) and ip in endpoints
MAX_AUTO_WORKERS = 30 # auto_max_workers() 的硬上限, 见函数docstring③
def auto_max_workers(cpu: int, disk_guard_gb: float = 0.0) -> int:
"""--max-workers 未指定时的自动选择:
① 默认按CPU核数; 如果 is_ocr_colocated() 判断本机还跑着OCR服务, 打对折让出一半CPU。
② 再按本地磁盘算一次上限, 取①②里更小的那个 —— 核多盘小的机器(比如高核数但磁盘只有
100多G)按纯CPU数选出的并发数,同时下载/抽帧/拼合成图会把磁盘写爆;`--disk-guard-gb`只挡
"新启动"挡不住"已经在跑的", 真正决定磁盘峰值占用的是并发数本身, 所以从源头把并发数卡住:
磁盘上限 = (可用磁盘 - disk_guard_gb这层安全垫) ÷ 每视频预估峰值占用(GB)
每视频预估占用读 config.dispatch.est_disk_gb_per_video, 默认 2.0G —— 按 subtitle 配置常见的
30~2400秒短视频估的(2400秒=3fps≈7200帧, 裁剪后的帧+合成图+源视频下载, 峰值同时存在时
量级在1~2G上下; 视频普遍更短/分辨率更低可以调小, 更长/更高清要调大, 保守起见默认按上限估)。
disk_guard_gb 传0(不用磁盘保护)时不再额外扣这层安全垫, 直接用当前可用磁盘算。
③ 最后再套一个硬上限 MAX_AUTO_WORKERS(30) —— 超高核数机器(如64/128核)按纯CPU数选出的
并发数会远超实际有意义的范围(单机瓶颈通常先卡在OCR接口/磁盘IO/内存, 而不是CPU), 且并发
数太高会让 ffmpeg_threads="auto" 摊出来的每进程线程数过低(见 multithread_algo.py)。
cpu<=0(测不到核数, 比如非Linux)时按10兜底。"""
cpu = cpu if cpu and cpu > 0 else 10
workers = cpu
if is_ocr_colocated():
workers = max(1, cpu // 2)
logging.info(f"[资源探测] 本机IP在 tencentcloud.ocr.endpoints 里, 判断为OCR共置机, "
f"max-workers 自动打对折让资源给OCR服务: {cpu}核 → {workers}")
try:
free_gb = shutil.disk_usage(".").free / 1024 / 1024 / 1024
budget_gb = free_gb - max(0.0, disk_guard_gb)
est_gb = float(_cfg().get("est_disk_gb_per_video", 2.0) or 2.0)
if budget_gb <= 0:
logging.warning(f"[资源探测] 可用磁盘({free_gb:.1f}G)已经不够留给磁盘保护垫"
f"({disk_guard_gb}G), max-workers 收紧到1, 建议先清理磁盘")
workers = 1
elif est_gb > 0:
disk_cap = max(1, int(budget_gb / est_gb))
if disk_cap < workers:
logging.info(f"[资源探测] 按磁盘算的并发上限更小, max-workers 收紧: "
f"{workers} → {disk_cap}(可用磁盘{free_gb:.1f}G - 保护垫{disk_guard_gb}G = "
f"{budget_gb:.1f}G预算 ÷ 每视频预估{est_gb}G)")
workers = disk_cap
except OSError:
pass
if workers > MAX_AUTO_WORKERS:
logging.info(f"[资源探测] max-workers 超过硬上限, 收紧: {workers} → {MAX_AUTO_WORKERS}")
workers = MAX_AUTO_WORKERS
return workers
_root_override = {"value": None} # run_worker.py 用: 主队列空闲时临时切去认领长视频队列
def set_active_root_override(root):
"""临时覆盖 _active_jobs_root() 的返回值(仅当前进程内生效, 不改config/不用重启)。
传 None 清除覆盖、恢复按 config 判断。给 run_worker.py 做'主队列没活时顺手兼跑长视频队列'用:
覆盖生效期间, claim/list_todo/download_shard/mark_done/release 等全部原语都会转而操作
覆盖指向的这个队列根, 跟专职长视频机(run_long_video=true)走的是同一套代码路径, 只是切换是
动态的、按需的, 而不是启动时定死。"""
_root_override["value"] = root
def _active_jobs_root():
"""本 worker 实际认领的队列根, 优先级:
_root_override(运行时临时切换) > run_long_video=true(专跑超长视频, 用 long_list_backup_path)
> jobs_root(默认主队列)。"""
if _root_override["value"]:
return _root_override["value"]
c = _cfg()
if c.get("run_long_video"):
root = c.get("long_list_backup_path")
if root:
return root
return c.get("jobs_root") or ""
def _bucket_base():
"""从【当前生效的队列根】解析 (bucket, base_prefix)。base 末尾无 '/'。
只给 _key()(todo/claimed/done 分片路径)用——这个"当前生效"是有意跟着
run_long_video/_root_override 变的(分片本来就该落在当前认领的那个队列下)。"""
root = _active_jobs_root()
if not root:
raise RuntimeError("config.dispatch 未配置队列地址(jobs_root / long_list_backup_path)")
bucket, key = parse_cos_url(root)
return bucket, key.strip("/")
def _primary_bucket_base():
"""固定解析自 jobs_root(主队列), 不受 run_long_video / 运行时 _root_override 影响 ——
daily_backup_path / monitor_root 没显式配置时的兜底基准必须用这个, 不能用 _bucket_base()
(它会跟 _active_jobs_root() 一起变): 专职长视频机(run_long_video=true)跑起来后
_active_jobs_root() 长期指向 long_list_backup_path, 如果兜底基准也跟着它变, 算出来的
monitor_root/daily_backup_path 就会落到 .../long_video/monitor、.../long_video/daily 这种
跟其它机器不一样的路径下 —— fleet_monitor.py 和 global_processed_set() 只认标准的
monitor_root/daily_backup_path, 读不到这些"另起炉灶"的路径, 表现为这台机器的小时摘要/
去重快照对外"消失了"(实际数据都在, 只是传错地方)。"""
root = _cfg().get("jobs_root") or ""
if not root:
raise RuntimeError("config.dispatch.jobs_root 未配置")
bucket, key = parse_cos_url(root)
return bucket, key.strip("/")
def _key(kind: str, shard: str = "") -> str:
"""拼某类对象的 key。kind ∈ {todo, claimed, done}。"""
_, base = _bucket_base()
ext = {"todo": ".txt", "claimed": ".lock", "done": ".done"}[kind]
if shard:
return f"{base}/{kind}/{shard}{ext}"
return f"{base}/{kind}/"
# --------------------------------------------------------------- COS 原语 ----
def _put(key: str, data: bytes, forbid_overwrite: bool = False):
bkt, _ = _bucket_base()
kwargs = {}
if forbid_overwrite:
kwargs["ForbidOverwrite"] = "true" # 经 maplist 注入 x-cos-forbid-overwrite 头
client().put_object(Bucket=bkt, Body=data, Key=key, **kwargs)
def _get_bytes(key: str):
bkt, _ = _bucket_base()
resp = client().get_object(Bucket=bkt, Key=key)
return resp["Body"].get_raw_stream().read()
def _exists(key: str) -> bool:
bkt, _ = _bucket_base()
return client().object_exists(Bucket=bkt, Key=key)
def _delete(key: str):
bkt, _ = _bucket_base()
try:
client().delete_object(Bucket=bkt, Key=key)
except CosServiceError as e:
if e.get_status_code() != 404:
raise
def _list_shards(kind: str):
"""列出某前缀下所有分片名(去掉前缀和扩展名)。"""
bkt, _ = _bucket_base()
prefix = _key(kind)
ext = {"todo": ".txt", "claimed": ".lock", "done": ".done"}[kind]
out, marker = [], ""
while True:
resp = client().list_objects(Bucket=bkt, Prefix=prefix, Marker=marker, MaxKeys=1000)
for c in resp.get("Contents", []) or []:
k = c["Key"]
if k.endswith(ext):
out.append(k[len(prefix):-len(ext)])
if resp.get("IsTruncated") in ("true", True):
marker = resp.get("NextMarker") or (resp["Contents"][-1]["Key"] if resp.get("Contents") else "")
if not marker:
break
else:
break
return out
# ----------------------------------------------------------------- 接口 ----
def list_todo():
return _list_shards("todo")
def shard_matches(name: str, patterns) -> bool:
"""分片名是否匹配 patterns(glob 通配, 如 'cos_urls_8*')。
patterns 为空/None -> 认领全部(返回 True)。可传单个字符串或列表。
匹配时对 '<名>' 和 '<名>.txt' 都试一遍, 写不写 .txt 都行。"""
import fnmatch
if not patterns:
return True
if isinstance(patterns, str):
patterns = [patterns]
cands = (name, name + ".txt")
return any(fnmatch.fnmatch(c, p) for p in patterns for c in cands)
def is_done(shard: str) -> bool:
return _exists(_key("done", shard))
def read_lock(shard: str):
"""读认领锁内容(dict)。不存在返回 None。"""
try:
return json.loads(_get_bytes(_key("claimed", shard)).decode("utf-8"))
except CosServiceError as e:
if e.get_status_code() == 404:
return None
raise
except Exception:
return None
def claim(shard: str, machine_id: str) -> bool:
"""原子认领: 写 claimed/<shard>.lock(禁止覆盖)。成功=True 表示这片归我;
409(已被别人锁) -> False。"""
now = datetime.now()
body = json.dumps({
"machine_id": machine_id, "host": socket.gethostname(),
"claim_ts": now.isoformat(), "hb_ts": now.isoformat(),
}, ensure_ascii=False).encode("utf-8")
try:
_put(_key("claimed", shard), body, forbid_overwrite=True)
return True
except CosServiceError as e:
if e.get_status_code() == 409:
return False # 已被别的机器抢到
raise
def heartbeat(shard: str, machine_id: str):
"""刷新锁的心跳时间(允许覆盖, 仍写回 machine_id 以保持所有权信息)。"""
lk = read_lock(shard) or {}
lk.update({"machine_id": machine_id, "host": socket.gethostname(),
"hb_ts": datetime.now().isoformat()})
lk.setdefault("claim_ts", lk["hb_ts"])
_put(_key("claimed", shard), json.dumps(lk, ensure_ascii=False).encode("utf-8"))
def download_shard(shard: str):
"""读 todo/<shard>.txt, 返回视频 COS 路径列表。"""
text = _get_bytes(_key("todo", shard)).decode("utf-8")
return [ln.strip() for ln in text.splitlines() if ln.strip()]
def mark_done(shard: str, machine_id: str, stats: dict):
_put(_key("done", shard), json.dumps({
"machine_id": machine_id, "stats": stats,
"end_ts": datetime.now().isoformat(),
}, ensure_ascii=False).encode("utf-8"))
def release(shard: str):
"""跑完后清理: 删 todo 和 lock(保留 done 作为完成账)。"""
_delete(_key("todo", shard))
_delete(_key("claimed", shard))
def my_open_claims(machine_id: str):
"""我名下、尚未完成(无 done)的分片 —— 重启后续做用。"""
out = []
for shard in _list_shards("claimed"):
lk = read_lock(shard)
if lk and lk.get("machine_id") == machine_id and not is_done(shard):
out.append(shard)
return out
# ------------------------------------------------------------- 优雅停机 ----
def stop_requested() -> bool:
"""COS 上存在 stop_flag_key(默认 _jobs/STOP)则请求优雅停机。"""
flag = _cfg().get("stop_flag_key")
if not flag:
_, base = _bucket_base()
flag = f"{base}/STOP"
bkt, _ = _bucket_base()
try:
return client().object_exists(Bucket=bkt, Key=flag.strip("/"))
except Exception:
return False
# ----------------------------------------------------- 每日 数据.jsonl 备份 ----
# put_object 是"一次性读进内存 + 单个 HTTP 请求"上传, 底层 send_request 默认超时 30 秒 ——
# 数据.jsonl 是全量快照, 产能高的机器跑几天就上百 MB/上 GB, 30 秒内传不完就
# "The write operation timed out", 然后每几分钟重试又超时, 无限循环从没成功(实测 21.13.80.229)。
# 超过这个大小的文件改用 coscli 上传(它支持分块/断点续传, 几 GB 也没问题); 小文件(如几KB的
# 小时摘要)仍走 put_object —— 起 coscli 子进程有开销, 小文件没必要。
_COSCLI_UPLOAD_THRESHOLD = 50 * 1024 * 1024 # 50MB
def _coscli_put(local_path: str, cos_url: str) -> bool:
"""用 coscli 把 local_path 上传到 cos_url(完整 cos:// 路径)。成功 True, 失败 False。
coscli 支持分块上传, 大文件不像 put_object 那样受单请求超时限制。
coscli 路径/配置从 splitframe 配置读(跟主流程上传 SRT 用的是同一个二进制和配置)。"""
try:
sf = config.get_splitframe_config() or {}
coscli = sf.get("coscli_path", "./coscli-linux-amd64")
coscfg = sf.get("cosconfig_path", ".cos.yaml")
cmd = [coscli, "-c", coscfg, "cp", local_path, cos_url]
r = subprocess.run(cmd, capture_output=True, text=True, timeout=1800)
if r.returncode == 0:
return True
logging.warning(f"[备份] coscli 上传失败(将回退 put_object): {(r.stderr or '').strip()[:200]}")
return False
except Exception as e:
logging.warning(f"[备份] coscli 上传异常(将回退 put_object): {type(e).__name__}: {e}")
return False
def _upload_dated_file(local_path: str, dest_root: str, fname: str) -> str:
"""把 local_path 上传到 dest_root/<日期>/fname。
local_path 不存在 / dest_root 为空 -> 返回 ''。返回上传后的 cos:// 路径。
大文件(> _COSCLI_UPLOAD_THRESHOLD)走 coscli 分块上传, 避免 put_object 30 秒超时;
coscli 失败(二进制不在/权限等)自动回退 put_object 再试一次 —— 保证引入 coscli 不会
比原来更差(最坏情况就是退回原行为)。"""
if not local_path or not dest_root or not os.path.exists(local_path):
return ""
bkt, base_key = parse_cos_url(dest_root)
date = datetime.now().strftime("%Y%m%d") # 按日期建文件夹
key = f"{base_key.strip('/')}/{date}/{fname}"
cos_url = f"cos://{bkt}/{key}"
size = os.path.getsize(local_path)
if size > _COSCLI_UPLOAD_THRESHOLD:
logging.info(f"[备份] {fname} 有 {size / 1024 / 1024:.0f}MB, 超过 "
f"{_COSCLI_UPLOAD_THRESHOLD // 1024 // 1024}MB, 用 coscli 分块上传")
if _coscli_put(local_path, cos_url):
return cos_url
# coscli 没成: 回退 put_object(大文件很可能还是超时, 但至少不比原来差, 且日志会记清楚)
with open(local_path, "rb") as f:
data = f.read()
client().put_object(Bucket=bkt, Body=data, Key=key)
return cos_url
def upload_daily_backup(jsonl_path: str, machine_id: str) -> str:
"""把本地 数据.jsonl 上传到 dispatch.daily_backup_path/<日期>/数据_<机器号>.jsonl,
顺带生成并上传一份轻量的"已处理清单"(见 upload_processed_list) 供 global_processed_set()
用——两者是同一次备份动作的副产物,调这一个函数就够了。"""
dest_root = _cfg().get("daily_backup_path")
if not dest_root:
bucket, base = _bucket_base()
dest_root = f"cos://{bucket}/{base}/daily"
dst = _upload_dated_file(jsonl_path, dest_root, f"数据_{machine_id}.jsonl")
if dst:
try:
upload_processed_list(jsonl_path, machine_id)
except Exception as e:
logging.warning(f"[每日备份] 已处理清单上传失败(不影响 数据.jsonl 备份): {e}")
return dst
def upload_longlist_backup(machine_id: str) -> str:
"""把本地超长待重跑列表(subtitle.long_video_list)上传到
dispatch.long_list_backup_path/todo/<原文件名>_<机器号>_<日期>_<时分秒>.txt。
放进 todo/ 子目录, 使其直接成为可认领的任务分片 —— 把别的机器的 jobs_root
指向 long_list_backup_path, 它们就能自动认领这些超长视频重跑。
文件名带【机器号+日期+时分秒】: 机器号保证各机器不互相覆盖; 时分秒是因为本函数是
"传完即清空本地已传部分"的增量式设计(见下), 一天内不管自动备份触发几次、或人工额外
手动跑几次(如 tools/upload_daily.py / apply_update.sh 更新前那一下), 每次快照的都只是
"上次传完之后新增的那一小段" —— 如果 key 只精确到天, 同一天第二次上传会用 put_object
直接覆盖同名对象, 把第一次已传但可能还没被任何机器认领处理的内容连本地带COS一起冲没。
精确到秒后每次上传各自独立成对象, 不会互相覆盖, 只是分片数量会随调用次数变多(不影响使用,
worker 一样能各自认领)。"""
# 专跑长视频的机器(run_long_video)不再把超长列表传回同一队列, 防回环。
if _cfg().get("run_long_video"):
return ""
local = (config.get_subtitle_config() or {}).get("long_video_list", "./超长待重跑.txt")
if not local or not os.path.exists(local):
return ""
# 快照当前内容(文件 append-only); 全空则不传
with open(local, "r", encoding="utf-8") as f:
snapshot = f.readlines()
if not any(ln.strip() for ln in snapshot):
return ""
dest_root = _cfg().get("long_list_backup_path")
if not dest_root:
bucket, base = _bucket_base()
dest_root = f"cos://{bucket}/{base}/long_video"
bkt, base_key = parse_cos_url(dest_root)
stem = os.path.splitext(os.path.basename(local))[0] # 超长待重跑
ts = datetime.now().strftime("%Y%m%d_%H%M%S")
key = f"{base_key.strip('/')}/todo/{stem}_{machine_id}_{ts}.txt"
client().put_object(Bucket=bkt, Body="".join(snapshot).encode("utf-8"), Key=key)
# 上传成功后清空已上传部分: 只删前 len(snapshot) 行, 保留上传期间新追加的(append 在末尾),
# 这样每天只传增量、跑完即了, 不会和已完成分片重叠。开关 long_list_clear_after_upload(默认开)。
if _cfg().get("long_list_clear_after_upload", True):
try:
with open(local, "r", encoding="utf-8") as f:
cur = f.readlines()
with open(local, "w", encoding="utf-8") as f:
f.writelines(cur[len(snapshot):])
except OSError:
pass
return f"cos://{bkt}/{key}"
def _daily_backup_bucket_base():
"""daily_backup_path 配置解析为 (bucket, base_key),缺省时与 upload_daily_backup 保持一致。
兜底基准用 _primary_bucket_base()(固定挂 jobs_root), 不能用 _bucket_base() —— 见后者docstring
里的解释, 否则专职长视频机会把每日备份传到 long_video 队列自己底下, 全局去重读不到。"""
dest_root = _cfg().get("daily_backup_path")
if not dest_root:
bucket, base = _primary_bucket_base()
dest_root = f"cos://{bucket}/{base}/daily"
bucket, base = parse_cos_url(dest_root)
return bucket, base.strip("/")
def _extract_processed_paths(jsonl_path: str) -> list:
"""从本地 数据.jsonl 逐行解析出 video_cos_path 列表(顺序不去重, 上传前用)。"""
paths = []
try:
with open(jsonl_path, "r", encoding="utf-8") as f:
for line in f:
line = line.strip()
if not line:
continue
try:
vid = json.loads(line).get("video_cos_path")
if vid:
paths.append(vid)
except json.JSONDecodeError:
pass
except OSError:
pass
return paths
def upload_processed_list(jsonl_path: str, machine_id: str) -> str:
"""从本地 数据.jsonl 提取 video_cos_path, 生成一份纯文本清单(一行一个路径, 不含状态/耗时等
其它字段)上传到 daily_backup_path/<日期>/已处理_<机器号>.txt —— 跟 upload_daily_backup 上传的
完整 jsonl 是同一份数据的两种形态, 前者给 global_processed_set() 用: 只关心"这个视频是否
处理过"时, 拉这个纯清单比拉完整jsonl再挨行解析JSON 小得多、快得多(jsonl里每条记录还带
timestamp/duration/image_counts/stage_durations 等一堆跟去重无关的字段)。"""
dest_root = _cfg().get("daily_backup_path")
if not dest_root:
bucket, base = _bucket_base()
dest_root = f"cos://{bucket}/{base}/daily"
paths = _extract_processed_paths(jsonl_path)
if not paths:
return ""
bkt, base_key = parse_cos_url(dest_root)
date = datetime.now().strftime("%Y%m%d")
key = f"{base_key.strip('/')}/{date}/已处理_{machine_id}.txt"
client().put_object(Bucket=bkt, Body=("\n".join(paths) + "\n").encode("utf-8"), Key=key)
return f"cos://{bkt}/{key}"
def list_processed_list_files():
"""列出 daily_backup_path 下所有 <日期>/已处理_<机器号>.txt,返回 [(日期, 机器号, key), ...]。"""
bkt, base = _daily_backup_bucket_base()
prefix = base + "/"
out, marker = [], ""
while True:
resp = client().list_objects(Bucket=bkt, Prefix=prefix, Marker=marker, MaxKeys=1000)
for c in resp.get("Contents", []) or []:
rel = c["Key"][len(prefix):] # <日期>/已处理_<机器号>.txt
parts = rel.split("/")
if len(parts) == 2 and parts[1].startswith("已处理_") and parts[1].endswith(".txt"):
out.append((parts[0], parts[1][len("已处理_"):-len(".txt")], c["Key"]))
if resp.get("IsTruncated") in ("true", True):
marker = resp.get("NextMarker") or (resp["Contents"][-1]["Key"] if resp.get("Contents") else "")
if not marker:
break
else:
break
return out
def global_processed_set(verbose: bool = False, all_dates: bool = False) -> set:
"""聚合"已处理清单"(已处理_<机器号>.txt, 由 upload_processed_list 在每次备份时跟 数据.jsonl
一起生成), 合并成全局已处理 video_cos_path 集合。只拉轻量txt清单、不下载/解析完整jsonl,
省流量也省CPU。语义与本机 load_processed() 一致:不分 success/no_subtitle/error,只要出现过
就算处理过。用于灌任务前过滤,避免同一视频被 30 台机器重复处理。
all_dates=False(默认, 日常灌任务用): 每台机器只取【最新一天】——数据.jsonl 是全量快照(本地
从不清空), 取最新一份即覆盖该机器全部历史, 省得把每天的备份都下载一遍; 不同机器最新日期
可能不同(比如有机器当天没开机备份), 各自独立取最新, 不要求同一天。
all_dates=True(机群已停机/一次性核对时用): 不做"每机器只取最新"这个假设, 把【所有日期】
的清单全部下载合并——机群都停了就没有"之后还会补传更晚日期"这回事, 更保险的做法是把能拿到
的所有历史清单全并起来判断"处理过没有", 而不是赌某一天的快照就是最全的(万一那台机器最新
一天的快照因为某种原因不完整/被截断过, 只取最新反而会漏)。代价是下载的文件数从"机器数"
变成"机器数×历史天数", 视频清单量小(轻量txt)时这个代价可以接受。"""
files = list_processed_list_files()
if not files:
return set()
if all_dates:
targets = [(date_str, machine_id, key) for date_str, machine_id, key in files]
else:
latest = {} # machine_id -> (日期, key)
for date_str, machine_id, key in files:
if machine_id not in latest or date_str > latest[machine_id][0]:
latest[machine_id] = (date_str, key)
targets = [(date_str, machine_id, key) for machine_id, (date_str, key) in latest.items()]
bkt, _ = _daily_backup_bucket_base()
processed = set()
for date_str, machine_id, key in targets:
try:
data = client().get_object(Bucket=bkt, Key=key)["Body"].get_raw_stream().read()
except Exception as e:
logging.warning(f"[全局去重] 读取 {key} 失败, 跳过: {e}")
continue
n = 0
for line in data.decode("utf-8", errors="ignore").splitlines():
line = line.strip()
if line:
processed.add(line)
n += 1
if verbose:
logging.info(f"[全局去重] 机器 {machine_id}({date_str}): {n} 条")
return processed
def run_daily_backup_loop(machine_id: str, hour: int, stop, jsonl_path: str = "数据.jsonl",
check_interval: int = 120):
"""后台线程: 每天到 hour 点把 数据.jsonl + 超长列表上传 COS, 每天只传一次。
main.py(本地模式) 和 run_worker.py(COS模式) 通用。stop 为 threading.Event, set() 即退出。"""
last_date = None
while not stop.wait(check_interval): # 每 check_interval 秒检查一次
now = datetime.now()
today = now.strftime("%Y%m%d")
if now.hour >= hour and last_date != today:
try:
dst = upload_daily_backup(jsonl_path, machine_id)
if dst:
logging.info(f"[每日备份] 数据.jsonl 已上传 -> {dst}")
else:
logging.info("[每日备份] 本地无 数据.jsonl, 跳过")
ll = upload_longlist_backup(machine_id)
if ll:
logging.info(f"[每日备份] 超长待重跑列表已上传 -> {ll}")
last_date = today # 当天标记完成, 避免反复触发
except Exception as e:
logging.error(f"[每日备份] 上传失败(稍后重试): {e}")
# ----------------------------------------------------- 每小时监控摘要 ----
def _monitor_bucket_base():
"""monitor_root 配置解析为 (bucket, base_key),缺省时 = jobs_root 的同级 monitor 目录
(跟 _daily_backup_bucket_base 同一个派生模式)。兜底基准同样必须用 _primary_bucket_base()
而不是 _bucket_base() —— 这就是本函数曾经的实际bug: 专职长视频机(run_long_video=true)
没显式配 monitor_root 时, 旧版用 _bucket_base() 兜底会解析到【当前生效队列根】
(_active_jobs_root(), 长视频机上长期等于 long_list_backup_path), 算出
.../long_video/monitor/... 这种独立于其它机器的路径, fleet_monitor.py 只读标准
monitor_root, 找不到这些机器的小时摘要, 表现为"全队机器数对不上"(实际数据都传了,
只是传错地方, 数据没丢, 改这个函数后新上传的会去对的地方, 旧的仍留在错的路径下)。"""
dest_root = _cfg().get("monitor_root")
if not dest_root:
bucket, base = _primary_bucket_base()
dest_root = f"cos://{bucket}/{base}/monitor"
bucket, base = parse_cos_url(dest_root)
return bucket, base.strip("/")
def compute_hourly_digest(jsonl_path: str, hour_dt: datetime):
"""从本地 数据.jsonl 里筛出 process_start(缺失回退timestamp) 落在 hour_dt 所在整点小时的记录,
算出这个小时的摘要。hour_dt 的分钟/秒会被忽略, 只取年月日时。
本地jsonl不存在/读取失败 -> 返回 None(调用方应跳过上传)。
这一小时确实一条记录都没处理(count==0, 比如COS任务队列暂时被抢空、worker在idle轮询)
-> 仍然返回一份全0的摘要, 不当 None 处理 —— 队列见底idle是这套分布式调度系统的正常状态
(见 run_worker.py 的 keep_alive/idle_poll_seconds), 如果这种情况也不传摘要, fleet_monitor.py
的"机器掉线"检测会把"确认活着只是没活干"误判成"可能停机", 制造假警报。"""
if not os.path.exists(jsonl_path):
return None
hour_prefix = hour_dt.strftime("%Y-%m-%dT%H") # 匹配 ISO 格式 process_start 的前13位
count = success = no_subtitle = error = 0
success_hours = 0.0
try:
with open(jsonl_path, "r", encoding="utf-8") as f:
for line in f:
line = line.strip()
if not line:
continue
try:
rec = json.loads(line)
except json.JSONDecodeError:
continue
ts = rec.get("process_start") or rec.get("timestamp") or ""
if ts[:13] != hour_prefix:
continue
count += 1
status = rec.get("status")
if status == "success":
success += 1
try:
success_hours += float(rec.get("duration") or 0) / 3600.0
except (TypeError, ValueError):
pass
elif status == "no_subtitle":
no_subtitle += 1
else:
error += 1
except OSError:
return None
return {
"machine_id": None, # upload_hourly_digest 里填
"hour": hour_dt.strftime("%Y-%m-%dT%H"),
"count": count, "success": success, "no_subtitle": no_subtitle, "error": error,
"success_hours": round(success_hours, 2),
"uploaded_at": datetime.now().isoformat(),
}
def upload_hourly_digest(machine_id: str, jsonl_path: str, hour_dt: datetime) -> str:
"""算好 hour_dt 那个整点小时的摘要并上传到
monitor_root/hourly/<日期YYYYMMDD>/<小时HH>/<机器号>.json。
即使这一小时count==0(队列见底idle)也会正常上传一份全0摘要——只有本地 数据.jsonl
根本不存在/读取失败(digest为None, 比如worker刚起来还没写过jsonl)才跳过, 返回''。"""
digest = compute_hourly_digest(jsonl_path, hour_dt)
if digest is None:
return ""
digest["machine_id"] = machine_id
bkt, base = _monitor_bucket_base()
date_str = hour_dt.strftime("%Y%m%d")
hour_str = hour_dt.strftime("%H")
key = f"{base}/hourly/{date_str}/{hour_str}/{machine_id}.json"
client().put_object(Bucket=bkt, Body=json.dumps(digest, ensure_ascii=False).encode("utf-8"), Key=key)
return f"cos://{bkt}/{key}"
def list_hourly_digest_files_for_hour(hour_dt: datetime) -> list:
"""列出 monitor_root/hourly/<日期>/<小时>/ 下所有机器的摘要文件, 返回 [(机器号, key), ...]。"""
bkt, base = _monitor_bucket_base()
date_str = hour_dt.strftime("%Y%m%d")
hour_str = hour_dt.strftime("%H")
prefix = f"{base}/hourly/{date_str}/{hour_str}/"
out, marker = [], ""
while True:
resp = client().list_objects(Bucket=bkt, Prefix=prefix, Marker=marker, MaxKeys=1000)
for c in resp.get("Contents", []) or []:
rel = c["Key"][len(prefix):]
if rel.endswith(".json"):
out.append((rel[:-len(".json")], c["Key"]))
if resp.get("IsTruncated") in ("true", True):
marker = resp.get("NextMarker") or (resp["Contents"][-1]["Key"] if resp.get("Contents") else "")
if not marker:
break
else:
break
return out
def download_hourly_digest(key: str) -> dict:
"""下载解析单份小时摘要json, 返回dict。失败抛异常, 调用方负责捕获。"""
bkt, _ = _monitor_bucket_base()
data = client().get_object(Bucket=bkt, Key=key)["Body"].get_raw_stream().read()
return json.loads(data.decode("utf-8"))
def upload_monitor_report(local_path: str, hour_dt: datetime) -> str:
"""把生成好的监控报告文件上传到 monitor_root/reports/<日期YYYYMMDD>_<小时HH>.md。"""
if not os.path.exists(local_path):
return ""
bkt, base = _monitor_bucket_base()
date_str = hour_dt.strftime("%Y%m%d")
hour_str = hour_dt.strftime("%H")
key = f"{base}/reports/{date_str}_{hour_str}.md"
with open(local_path, "rb") as f:
data = f.read()
client().put_object(Bucket=bkt, Body=data, Key=key)
return f"cos://{bkt}/{key}"
def run_hourly_digest_loop(machine_id: str, stop, jsonl_path: str = "数据.jsonl",
check_interval: int = 120):
"""后台线程: 每小时把刚结束的那个整点小时的处理摘要上传COS, 每小时只传一次。
main.py(本地模式) 不需要接入(一次性批处理没有"常驻小时"的概念, 见设计文档)。
stop 为 threading.Event, set() 即退出。"""
last_uploaded_hour = None
while not stop.wait(check_interval):
now = datetime.now()
prev_hour_dt = now.replace(minute=0, second=0, microsecond=0) - timedelta(hours=1)
prev_hour_key = prev_hour_dt.strftime("%Y%m%d%H")
if last_uploaded_hour == prev_hour_key:
continue
try:
dst = upload_hourly_digest(machine_id, jsonl_path, prev_hour_dt)
if dst:
logging.info(f"[小时摘要] {prev_hour_key} 已上传 -> {dst}")
else:
logging.info(f"[小时摘要] {prev_hour_key} 本地 数据.jsonl 尚不存在, 跳过上传")
last_uploaded_hour = prev_hour_key
except Exception as e:
logging.error(f"[小时摘要] {prev_hour_key} 上传失败(下次继续重试): {e}")