# -*- coding: utf-8 -*- """COS 任务分发 / 成功记录采集。 核心原语: COS PutObject 带 ForbidOverwrite(x-cos-forbid-overwrite:true) —— 同名对象 只能被创建一次, 并发写同一个锁文件只有第一个成功(其余 409)。以此实现跨机器原子认领, 无需任何额外服务(Redis/DB)。 策略: 粘性认领(sticky)。认领某分片的机器是它的唯一所有者;机器重启后只续做自己名下 未完成的分片, 别的机器永不抢占 -> 零重复。机器永久报废时, 人工跑 tools/release_machine.py 把它名下的任务放回队列。 COS 布局(jobs_root 下): todo/.txt 待处理分片(每行一个视频 COS 路径)。完成才删, 是"任务存在性"的唯一凭证。 claimed/.lock 认领锁, 内容 JSON {machine_id, host, claim_ts, hb_ts}。 done/.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/.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/.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}")