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

395 lines
20 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.

# -*- coding: utf-8 -*-
"""常驻 worker: 从 COS 自动认领任务分片、处理、回写完成标记, 没任务就空闲轮询;
每天指定时刻把本地 数据.jsonl 备份到 COS。
用法:
python run_worker.py --max-workers 32 --executor process
机器ID、取任务地址(jobs_root)、每日备份位置(daily_backup_path)等全部读 config.dispatch。
"""
import argparse
import logging
from logging.handlers import RotatingFileHandler
import os
import random
import threading
import time
from datetime import datetime
from config.config_utils import config
from tool import job_cos
# 复用 main.py 既有的处理流水线与清理逻辑
from main import (VideoProcessor, load_processed,
force_rmtree, JSONL_PATH, get_available_disk_gb)
logging.basicConfig(
level=logging.INFO,
format='%(asctime)s [%(levelname)s] %(message)s',
handlers=[
# 常驻worker一跑就是数天/数周, 普通FileHandler会无限增长; 100M/份, 最多留5份(共500M封顶)
RotatingFileHandler('batch_processor.log', maxBytes=100 * 1024 * 1024, backupCount=5, encoding='utf-8'),
logging.StreamHandler(),
],
)
# ------------------------------------------------------------ 取下一片 ----
def _next_shard(machine_id: str, shuffle: bool, claim_match=None):
"""返回下一片要做的 shard 名; 没有可领返回 None。
优先级: ①续做我名下未完成的(重启续跑, 不受 claim_match 限制) -> ②原子认领一片新的(受 claim_match 过滤)。"""
# ① 粘性续做: 我上次没跑完的分片(别的机器不会碰; 已经是我的, 不再过滤)
for shard in job_cos.my_open_claims(machine_id):
logging.info(f"[续做] 接回我名下未完成分片: {shard}")
return shard
# ② 认领新分片
todo = job_cos.list_todo()
# 排除已完成 / 已被锁的
todo = [s for s in todo if not job_cos.is_done(s)]
# 只认领名称匹配的(claim_match 留空=全部)
if claim_match:
todo = [s for s in todo if job_cos.shard_matches(s, claim_match)]
if shuffle:
random.shuffle(todo)
for shard in todo:
if job_cos.read_lock(shard) is not None:
continue # 已被人锁
if job_cos.claim(shard, machine_id):
logging.info(f"[认领] 抢到分片: {shard}")
return shard
# 409: 刚好被别人抢走, 试下一片
return None
def _claim_long_batch(machine_id: str, shuffle: bool, min_batch: int):
"""长视频队列专用: 一次性多领几片, 累计任务数够 min_batch(或没片可领了)才停手 ——
长视频队列每天上传的还是各机器各自攒的小分片(可能就几十条), 这里只是在【领取】这一侧
把多个小分片攒成一大批一起处理, 不改上传那边的行为。
先照 _next_shard() 同样的优先级续做粘性未完成分片(不受 min_batch 限制, 必须先了结),
没有才开始新领; min_batch<=0 视为1(至少领一片,不做批量)。
返回 [(shard, tasks), ...];某一片没片可领时提前结束(不会因为凑不够 min_batch 空等)。"""
min_batch = max(1, int(min_batch or 1))
batch = []
total = 0
# ① 粘性续做: 我名下所有未完成分片先原样接回来, 不受 min_batch 限制(必须先了结)
open_claims = job_cos.my_open_claims(machine_id)
for shard in open_claims:
tasks = job_cos.download_shard(shard)
batch.append((shard, tasks))
total += len(tasks)
if batch:
logging.info(f"[长视频批量] 接回 {len(batch)} 片未完成分片, 共{total}条, 直接处理(不再新领)")
return batch
# ② 新领: 不断认领新分片直到攒够 min_batch 条(或没片可领了)
todo = job_cos.list_todo()
todo = [s for s in todo if not job_cos.is_done(s)]
if shuffle:
random.shuffle(todo)
for shard in todo:
if total >= min_batch:
break
if job_cos.read_lock(shard) is not None:
continue # 已被人锁
if not job_cos.claim(shard, machine_id):
continue # 409: 刚好被别人抢走
tasks = job_cos.download_shard(shard)
batch.append((shard, tasks))
total += len(tasks)
logging.info(f"[长视频批量] 认领分片: {shard} ({len(tasks)}条, 累计{total}/{min_batch})")
return batch
def _process_shard_batch(batch, processor, machine_id, split_cfg,
coscli_path, cos_config_path, hb_interval, global_processed=None):
"""处理一批长视频分片(多片任务清单先合并成一个大列表再统一跑一次 process_batch,
而不是一片一片分别跑) —— 逻辑和 _process_shard 一致, 只是输入是 [(shard, tasks), ...]。
完成后把批次里每一片都各自 mark_done + release(全部标同一份聚合统计, 只是省事,
不是精确到每片的独立统计, 不影响正确性)。"""
shards = [s for s, _ in batch]
tasks = [t for _, ts in batch for t in ts]
processed = load_processed(JSONL_PATH)
if global_processed:
processed = processed | global_processed
todo_tasks = [t for t in tasks if t not in processed]
skipped = len(tasks) - len(todo_tasks)
logging.info(f"长视频批次({len(shards)}片): 共 {len(tasks)} 条, 跳过已处理 {skipped}, "
f"待处理 {len(todo_tasks)}")
hb_stop = threading.Event()
def _hb():
while not hb_stop.wait(hb_interval):
for shard in shards:
try:
job_cos.heartbeat(shard, machine_id)
except Exception as e:
logging.warning(f"心跳刷新失败 {shard}: {e}")
threading.Thread(target=_hb, daemon=True).start()
stats_dict = {"total": len(todo_tasks), "success": 0, "no_subtitle": 0, "error": 0}
try:
if todo_tasks:
stats = processor.process_batch(todo_tasks, coscli_path, cos_config_path, split_cfg)
stats_dict.update({"success": stats.success,
"no_subtitle": stats.no_subtitle, "error": stats.error})
finally:
hb_stop.set()
for shard in shards:
job_cos.mark_done(shard, machine_id, stats_dict)
job_cos.release(shard)
logging.info(f"长视频批次({len(shards)}片)完成并释放: {stats_dict}")
for d in ["./info", "./info_error", "./log", "./video"]:
if os.path.exists(d):
force_rmtree(d)
os.makedirs("./video", exist_ok=True)
def _fetch_global_processed(attempts: int = 3, backoff: float = 10.0) -> set:
"""启动时拉全局已处理集合, 失败重试(COS 抖动/网络问题很常见, 直接放弃太可惜)。
每次失败等 backoff 秒(线性,不是指数,重试次数少没必要搞复杂)再试; 全部失败返回空集合,
调用方会据此退化为只按本机 数据.jsonl 去重, 不阻塞 worker 启动。"""
last_err = None
for i in range(1, attempts + 1):
try:
return job_cos.global_processed_set()
except Exception as e:
last_err = e
logging.warning(f"[全局去重] 第{i}/{attempts}次拉取失败: {e}")
if i < attempts:
time.sleep(backoff)
logging.warning(f"[全局去重] 重试{attempts}次仍失败, 本次运行退化为只按本机 数据.jsonl 去重: {last_err}")
return set()
def _process_shard(shard, processor, machine_id, split_cfg,
coscli_path, cos_config_path, hb_interval, global_processed=None):
"""处理一个分片: 下载清单 -> 跳过(本机已处理 ∪ 全局已处理) -> 跑流水线 -> 写 done -> 释放。
global_processed: 启动时拉取的全局已处理集合(见 main() 里的 job_cos.global_processed_set()),
用于跨机器去重 —— 本机 数据.jsonl 只挡得住"这台机器"重复跑同一个视频, 挡不住这个视频被
错误地灌进了另一台机器认领的分片; 传 None/空集合时退化为只按本机去重(旧行为)。"""
tasks = job_cos.download_shard(shard)
processed = load_processed(JSONL_PATH) # 本机已处理(续跑去重)
if global_processed:
processed = processed | global_processed # 并上全局已处理(跨机器去重)
todo_tasks = [t for t in tasks if t not in processed]
skipped = len(tasks) - len(todo_tasks)
logging.info(f"分片 {shard}: 共 {len(tasks)} 条, 跳过已处理 {skipped}, 待处理 {len(todo_tasks)}")
# 心跳线程: 处理期间周期刷新锁的 hb_ts(留作监控/未来回收用)
hb_stop = threading.Event()
def _hb():
while not hb_stop.wait(hb_interval):
try:
job_cos.heartbeat(shard, machine_id)
except Exception as e:
logging.warning(f"心跳刷新失败 {shard}: {e}")
threading.Thread(target=_hb, daemon=True).start()
stats_dict = {"total": len(todo_tasks), "success": 0, "no_subtitle": 0, "error": 0}
try:
if todo_tasks:
stats = processor.process_batch(todo_tasks, coscli_path, cos_config_path, split_cfg)
stats_dict.update({"success": stats.success,
"no_subtitle": stats.no_subtitle, "error": stats.error})
finally:
hb_stop.set()
# 完成: 写 done -> 删 todo + lock
job_cos.mark_done(shard, machine_id, stats_dict)
job_cos.release(shard)
logging.info(f"分片 {shard} 完成并释放: {stats_dict}")
# 清理共享临时目录(沿用 main 的批次后清理)
for d in ["./info", "./info_error", "./log", "./video"]:
if os.path.exists(d):
force_rmtree(d)
os.makedirs("./video", exist_ok=True)
def main():
ap = argparse.ArgumentParser(description="常驻 worker: 从 COS 认领任务并处理")
ap.add_argument('--max-workers', type=int, default=None,
help='不填/0 = 自动按本机CPU核数选')
ap.add_argument('--mem-guard-gb', type=float, default=2.0)
ap.add_argument('--disk-guard-gb', type=float, default=10.0,
help='本地磁盘可用空间低于此值(GB)时暂停启动新视频,给数据处理预留空间;设 0 关闭')
ap.add_argument('--max-tasks-per-child', type=int, default=30)
ap.add_argument('--executor', choices=['process', 'thread'], default='process')
args = ap.parse_args()
if not args.max_workers:
cpu = os.cpu_count() or 0
args.max_workers = job_cos.auto_max_workers(cpu, disk_guard_gb=args.disk_guard_gb)
logging.info(f"--max-workers 未指定, 自动选为 {args.max_workers}(本机CPU核数={cpu or '未知'}; "
f"若本机还跑了OCR服务但不在 tencentcloud.ocr.endpoints 里, 自动探测不到, 请手动传小一点的值; "
f"同时按可用磁盘 ÷ 每视频预估占用(dispatch.est_disk_gb_per_video)做了收紧)")
# 供子进程(forkserver fork出来的worker)读取: splitframe.ffmpeg_threads=auto 时按此摊分CPU
os.environ['OCR_MAX_WORKERS'] = str(args.max_workers)
dcfg = config.get_dispatch_config() or {}
if not dcfg.get("enabled", True):
logging.error("config.dispatch.enabled=false, 退出。如需启用请改 config。")
return
machine_id = job_cos.get_machine_id()
keep_alive = bool(dcfg.get("keep_alive", True))
idle_poll = int(dcfg.get("idle_poll_seconds", 60))
shuffle = bool(dcfg.get("claim_shuffle", True))
hb_interval = int(dcfg.get("heartbeat_seconds", 300))
backup_hour = int(dcfg.get("daily_backup_hour", 10))
claim_match = dcfg.get("claim_match") or None # 只领匹配的分片(留空=全部)
run_long = bool(dcfg.get("run_long_video"))
long_root = dcfg.get("long_list_backup_path") or None
# 只有"本来跑主队列"的机器(非专职长视频机)、且配了长视频队列地址、且没有被显式关掉时,
# 才会在主队列空闲时顺手兼跑长视频队列 —— 专职长视频机本来就一直在长视频队列里, 不涉及
# 这个开关。auto_fallback_long_video 默认 true(维持原行为); 想让某台机器只专心跑主队列、
# 空闲就等着(不去碰长视频)的话, 在这台机器的 config.json 里配 false 即可, 不影响它自己
# 超长待重跑清单的每日备份(那个走 job_cos.upload_longlist_backup 各自独立)。
fallback_enabled = dcfg.get("auto_fallback_long_video", True)
auto_fallback_long = (not run_long) and bool(long_root) and bool(fallback_enabled)
active_root = job_cos._active_jobs_root()
logging.info("=" * 56)
logging.info(f"机器ID: {machine_id} | {'【专跑长视频】队列' if run_long else '主队列'}: {active_root}")
if auto_fallback_long:
logging.info(f"主队列空闲时自动兼跑长视频队列: {long_root}")
if claim_match:
logging.info(f"只认领匹配分片: {claim_match}")
logging.info(f"常驻: {keep_alive} 空闲轮询: {idle_poll}s | 每日备份: 每天{backup_hour}点")
disk = get_available_disk_gb(".")
disk_str = f"{disk:.1f}G" if disk is not None else "未知"
logging.info(f"磁盘保护阈值: {args.disk_guard_gb}G | 当前可用磁盘: {disk_str}")
if disk is not None and args.disk_guard_gb >= disk:
logging.warning(f"⚠️ 磁盘保护阈值({args.disk_guard_gb}G) ≥ 当前可用磁盘({disk:.1f}G),"
f"几乎所有视频都会被卡住等待,请调小 --disk-guard-gb 或清理磁盘")
logging.info("=" * 56)
# 启动时拉一次全局已处理集合(各机器最新一天的已处理清单, 见 job_cos.global_processed_set),
# 跟本机 数据.jsonl 一起用于分片去重 —— 挡住"这个视频被错误地灌进了另一台机器分片"的情况。
# 只在启动时拉一次、缓存到本次运行结束, 不是每领一片分片都重新拉(清单本身也就每天更新一次,
# 没必要频繁重拉); 失败会重试(见 _fetch_global_processed), 重试仍失败才退化为只按本机去重。
# global_dedup_on_startup=false 时直接跳过这一步、只按本机 数据.jsonl 去重 —— 每台机器的
# 已处理清单可能有几MB, 20多台机器挨个拉下来这一步可能耗时较久(且下载期间没有中间日志输出,
# 看起来像卡住了), 网络不好/机器多的场合可以关掉。
global_processed = set()
if dcfg.get("daily_backup_path") and dcfg.get("global_dedup_on_startup", True):
global_processed = _fetch_global_processed()
if global_processed:
logging.info(f"[全局去重] 启动时拉取全局已处理集合: {len(global_processed)} 条")
else:
logging.info("[全局去重] 已关闭(global_dedup_on_startup=false), 只按本机 数据.jsonl 去重")
split_cfg = config.get_splitframe_config()
coscli_path = split_cfg.get('coscli_path', './coscli-linux-amd64')
cos_config_path = split_cfg.get('cosconfig_path', '.cos.yaml')
processor = VideoProcessor(max_workers=args.max_workers, mem_guard_gb=args.mem_guard_gb,
executor=args.executor, max_tasks_per_child=args.max_tasks_per_child,
disk_guard_gb=args.disk_guard_gb)
os.makedirs("./video", exist_ok=True)
# 每日备份后台线程(数据.jsonl + 超长列表)
stop = threading.Event()
threading.Thread(target=job_cos.run_daily_backup_loop,
args=(machine_id, backup_hour, stop, JSONL_PATH),
daemon=True).start()
# 每小时监控摘要上传线程(常态化监控用, 见 docs/superpowers/specs/2026-07-08-fleet-monitoring-design.md)
threading.Thread(target=job_cos.run_hourly_digest_loop,
args=(machine_id, stop, JSONL_PATH),
daemon=True).start()
# 长视频队列每次【领取】攒够这么多条任务才开始处理(领的时候多片一起领, 上传那边还是各机器
# 各自每天传小分片不受影响) —— 见 _claim_long_batch。<=0 视为1(不做批量, 领一片处理一片)。
min_long_batch = int(dcfg.get("long_list_min_batch", 1000) or 1)
try:
while True:
if job_cos.stop_requested():
logging.info("检测到 COS STOP 标记, 优雅停机。")
break
# 专职长视频机: 全程都在长视频队列, 直接批量领+批量处理, 不走主队列那套单片逻辑
if run_long:
try:
batch = _claim_long_batch(machine_id, shuffle, min_long_batch)
except Exception as e:
logging.error(f"✗ 访问长视频队列失败(稍后重试): {e}")
time.sleep(min(idle_poll, 30))
continue
if not batch:
if keep_alive:
logging.info(f"长视频队列无可领任务, 空闲等待 {idle_poll}s...")
time.sleep(idle_poll)
continue
else:
logging.info("长视频队列无可领任务, keep_alive=false, 退出。")
break
try:
_process_shard_batch(batch, processor, machine_id, split_cfg,
coscli_path, cos_config_path, hb_interval, global_processed)
except Exception as e:
logging.error(f"✗ 长视频批次处理出错(锁保留, 重启可续做): {e}")
time.sleep(5)
continue
# ① 先看主队列(单片领取, 行为不变)
try:
shard = _next_shard(machine_id, shuffle, claim_match)
except Exception as e:
logging.error(f"✗ 访问 COS 队列失败(稍后重试): {e}")
time.sleep(min(idle_poll, 30))
continue
# ② 主队列没活 -> 顺手看看长视频队列有没有能领的(批量领, 不是单片)
used_long = False
long_batch = None
if shard is None and auto_fallback_long:
job_cos.set_active_root_override(long_root)
try:
long_batch = _claim_long_batch(machine_id, shuffle, min_long_batch)
except Exception as e:
logging.error(f"✗ 访问长视频队列失败(稍后重试): {e}")
job_cos.set_active_root_override(None)
time.sleep(min(idle_poll, 30))
continue
if not long_batch:
job_cos.set_active_root_override(None) # 长视频队列也没活, 恢复主队列上下文
else:
used_long = True
logging.info(f"主队列空闲, 转去处理长视频队列批次: {len(long_batch)}片")
if shard is None and not long_batch:
# 两个队列都真·没活了
if keep_alive:
logging.info(f"无可领任务, 空闲等待 {idle_poll}s(加新任务到 todo/ 会自动接上)...")
time.sleep(idle_poll)
continue
else:
logging.info("无可领任务, keep_alive=false, 退出。")
break
try:
if long_batch:
_process_shard_batch(long_batch, processor, machine_id, split_cfg,
coscli_path, cos_config_path, hb_interval, global_processed)
else:
_process_shard(shard, processor, machine_id, split_cfg,
coscli_path, cos_config_path, hb_interval, global_processed)
except Exception as e:
logging.error(f"✗ 分片处理出错(锁保留, 重启可续做): {e}")
time.sleep(5)
finally:
if used_long:
job_cos.set_active_root_override(None) # 这批长视频处理完/出错, 下一轮先回去看主队列
finally:
stop.set()
logging.info("worker 退出。")
if __name__ == '__main__':
main()