Files
ocr_data/tool/job_cos.py
2026-09-28 16:09:31 +08:00

815 lines
38 KiB
Python
Raw 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.

# -*- 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}")