autool-dispatcher/dispatcher_main.py
2026-06-17 19:50:39 +08:00

691 lines
29 KiB
Python
Executable File
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.

import csv
import json
import os
import threading
import time
import ctypes
from concurrent.futures import ThreadPoolExecutor
from datetime import datetime
from config import (
channel_name,
DASHBOARD_HOST,
DASHBOARD_PORT,
CHANNEL_NAMESPACE,
FINAL_REPORT_CSV,
INSTANCE_NAME,
REDIS_HOST,
REDIS_DB,
REDIS_PORT,
REPORT_DIR,
WORKER_REPORT_CSV,
WORKER_STALE_TIMEOUT,
WORKER_STATUS_PUSH_INTERVAL,
APK_DOWNLOAD_QUEUE_INTERVAL,
APK_PREFETCH_POLL_INTERVAL,
)
from dashboard_main import run_dashboard
from log_manager import logger
from redis_task_distribute import RedisTaskDispatcher
def _set_console_title():
title = (
f"AUTOOL Dispatcher [{INSTANCE_NAME}] "
f"Redis {REDIS_PORT}/{REDIS_DB} Dashboard {DASHBOARD_PORT} "
f"Channel {CHANNEL_NAMESPACE or 'default'}"
)
if os.name != "nt":
return
try:
ctypes.windll.kernel32.SetConsoleTitleW(title)
except Exception:
pass
class DispatcherService:
def __init__(self, redis_host=REDIS_HOST, redis_port=REDIS_PORT, redis_db=REDIS_DB):
self.dispatcher = self._wait_for_redis(redis_host, redis_port, redis_db)
self.redis = self.dispatcher.redis
version = os.environ.get("AUTOOL_DISPATCHER_VERSION", INSTANCE_NAME)
self.dispatcher.monitor.start_controller_session(version=version)
self.running = True
self.pubsub_list = []
self._threads = []
self._lock = threading.Lock()
self._report_executor = ThreadPoolExecutor(max_workers=32, thread_name_prefix="worker-report")
def _wait_for_redis(self, redis_host, redis_port, redis_db, retry_interval=5):
while True:
try:
dispatcher = RedisTaskDispatcher(redis_host=redis_host, redis_port=redis_port, redis_db=redis_db)
dispatcher.redis.ping()
logger.info(f"[Redis] 成功连接到 {redis_host}:{redis_port}/{redis_db}")
return dispatcher
except Exception as e:
logger.warning(f"[Redis] 连接失败: {e}{retry_interval}秒后重试...")
time.sleep(retry_interval)
def listen_worker_init(self):
pubsub = self.redis.pubsub()
with self._lock:
self.pubsub_list.append(pubsub)
pubsub.subscribe(channel_name("worker:init"))
logger.info("[监听] Worker 初始化频道已启动")
try:
while self.running:
message = pubsub.get_message(timeout=1)
if message is None or message["type"] != "message":
continue
try:
request = json.loads(message["data"])
task = self.dispatcher.worker_init(
request["worker_id"],
request["ip_address"],
request["mac_address"],
request["hostname"],
request["platform"],
request.get("device_type", ""),
)
self.redis.publish(
channel_name(f"worker:init:response:{request['worker_id']}"),
json.dumps({"task": task}),
)
except Exception as e:
logger.error(f"处理 Worker 初始化请求时出错: {e}")
finally:
pubsub.unsubscribe()
pubsub.close()
def listen_worker_report(self):
pubsub = self.redis.pubsub()
with self._lock:
self.pubsub_list.append(pubsub)
pubsub.subscribe(channel_name("worker:report"))
logger.info("[监听] Worker 上报频道已启动")
try:
while self.running:
message = pubsub.get_message(timeout=1)
if message is None or message["type"] != "message":
continue
try:
request = json.loads(message["data"])
self._report_executor.submit(self._handle_worker_report_request, request)
except Exception as e:
logger.error(f"处理 Worker 上报请求时出错: {e}")
finally:
pubsub.unsubscribe()
pubsub.close()
def _handle_worker_report_request(self, request):
worker_id = str(request.get("worker_id", "")).strip()
if not worker_id:
logger.error("处理 Worker 上报请求时出错: missing worker_id")
return
response_channel = channel_name(f"worker:report:response:{worker_id}")
try:
next_task = self.dispatcher.worker_report(
worker_id,
request["previous_task_key"],
request.get("report_data", {}),
)
payload = {"task": next_task}
except Exception as e:
logger.exception(f"处理 Worker 上报请求时出错: worker={worker_id}")
payload = {"task": None, "error": str(e)}
self.redis.publish(response_channel, json.dumps(payload))
def listen_worker_event(self):
pubsub = self.redis.pubsub()
with self._lock:
self.pubsub_list.append(pubsub)
pubsub.subscribe(channel_name("worker:event"))
logger.info("[监听] Worker 事件频道已启动")
try:
while self.running:
message = pubsub.get_message(timeout=1)
if message is None or message["type"] != "message":
continue
try:
request = json.loads(message["data"])
if str(request.get("event_type") or "").strip() == "artifacts_synced":
self.dispatcher.handle_analytics_worker_event(request)
else:
self.dispatcher.monitor.handle_worker_event(request)
except Exception as e:
logger.error(f"处理 Worker 事件时出错: {e}")
finally:
pubsub.unsubscribe()
pubsub.close()
def listen_worker_retry(self):
pubsub = self.redis.pubsub()
with self._lock:
self.pubsub_list.append(pubsub)
pubsub.subscribe(channel_name("worker:retry"))
logger.info("[监听] Worker 重试频道已启动")
try:
while self.running:
message = pubsub.get_message(timeout=1)
if message is None or message["type"] != "message":
continue
try:
request = json.loads(message["data"])
success = self.dispatcher.worker_retry(
request["worker_id"],
request["current_task_key"],
)
self.redis.publish(
channel_name(f"worker:retry:response:{request['worker_id']}"),
json.dumps({"success": success}),
)
except Exception as e:
logger.error(f"处理 Worker 重试请求时出错: {e}")
finally:
pubsub.unsubscribe()
pubsub.close()
def listen_catalog_update(self):
pubsub = self.redis.pubsub()
with self._lock:
self.pubsub_list.append(pubsub)
pubsub.subscribe(channel_name("catalog:updated"))
logger.info("[监听] 应用目录更新频道已启动")
try:
while self.running:
message = pubsub.get_message(timeout=1)
if message is None or message["type"] != "message":
continue
try:
data = json.loads(message["data"])
added = int(data.get("added") or 0)
priority = str(data.get("priority") or "")
if priority == "high_block":
logger.info(f"[目录同步] 收到 block 任务加载通知 (added={added}),任务已直接入队,跳过 DB 刷新")
continue
logger.info(f"[目录同步] 收到 catalog:updated 通知 (added={added}),刷新任务队列...")
new_count = self.dispatcher.refresh_tasks_from_app_summary()
if new_count > 0:
logger.info(f"[目录同步] 已从数据库加载 {new_count} 个新任务")
else:
logger.info("[目录同步] 未发现新任务")
except Exception as e:
logger.error(f"[目录同步] 处理 catalog:updated 消息时出错: {e}")
finally:
pubsub.unsubscribe()
pubsub.close()
def start_listeners(self):
threads = []
init_thread = threading.Thread(target=self.listen_worker_init, name="worker-init-listener")
init_thread.start()
threads.append(init_thread)
report_thread = threading.Thread(target=self.listen_worker_report, name="worker-report-listener")
report_thread.start()
threads.append(report_thread)
event_thread = threading.Thread(target=self.listen_worker_event, name="worker-event-listener")
event_thread.start()
threads.append(event_thread)
retry_thread = threading.Thread(target=self.listen_worker_retry, name="worker-retry-listener")
retry_thread.start()
threads.append(retry_thread)
catalog_thread = threading.Thread(target=self.listen_catalog_update, name="catalog-update-listener")
catalog_thread.start()
threads.append(catalog_thread)
self._threads = threads
return threads
def stop(self, clear_data=True):
logger.info("[停止] 正在停止服务...")
self.running = False
self.dispatcher.monitor.close_controller_session(
stop_reason="clear_data" if clear_data else "completed"
)
for thread in self._threads:
thread.join(timeout=3)
with self._lock:
for pubsub in self.pubsub_list:
try:
pubsub.unsubscribe()
pubsub.close()
except Exception:
pass
self.pubsub_list.clear()
self._report_executor.shutdown(wait=False, cancel_futures=True)
self.dispatcher.close()
if clear_data:
logger.info("[清理] 正在清空任务列表和 Worker 列表...")
self._clear_all_data()
logger.info("[清理] 清理完成")
def _clear_all_data(self):
try:
self.redis.delete("task:queue:high")
self.redis.delete("task:queue:default")
self.redis.delete("task:queue:low")
self.redis.delete("task:queue")
self.redis.delete("task:status")
self.redis.delete("task:details")
self.redis.delete("task:completed")
self.redis.delete("task:failed")
self.redis.delete("workers:info")
self.redis.delete("worker:tasks")
self.redis.delete("workers:idle")
self.redis.delete("workers:busy")
self.redis.delete("workers:meta")
for key in self.redis.scan_iter(match="alert:*"):
self.redis.delete(key)
for key in self.redis.scan_iter(match="cooldown:*"):
self.redis.delete(key)
for key in self.redis.scan_iter(match="worker:recovery_observe:*"):
self.redis.delete(key)
for key in self.redis.scan_iter(match="worker:recovery_pending:*"):
self.redis.delete(key)
logger.info("[清理] 已清空: task:queue:high/default/low, task:status, task:details, task:completed, task:failed")
logger.info("[清理] 已清空: workers:info, worker:tasks, workers:idle, workers:busy, workers:meta")
except Exception as e:
logger.error(f"[清理] 清理数据时出错: {e}")
def start_dashboard_thread(dispatcher):
def _target():
try:
logger.info(f"[Dashboard:{INSTANCE_NAME}] 已启动: http://{DASHBOARD_HOST}:{DASHBOARD_PORT}")
run_dashboard(dispatcher=dispatcher, host=DASHBOARD_HOST, port=DASHBOARD_PORT)
except Exception as e:
logger.warning(f"[Dashboard] 未启动: {e}")
thread = threading.Thread(target=_target, name="dashboard-server", daemon=True)
thread.start()
return thread
def generate_final_report(dispatcher):
try:
os.makedirs(os.path.dirname(FINAL_REPORT_CSV) or ".", exist_ok=True)
with open(FINAL_REPORT_CSV, mode="w", newline="", encoding="utf-8") as handle:
writer = csv.writer(handle)
writer.writerow(["任务键", "应用名称", "包名", "状态", "Worker ID", "IP地址", "开始时间", "结束时间"])
all_status = dispatcher.redis.hgetall("task:status")
for task_key, status_json in sorted(all_status.items()):
status = json.loads(status_json)
worker_id = status.get("worker_id", "N/A")
task_details_json = dispatcher.redis.hget("task:details", task_key)
task_details = json.loads(task_details_json) if task_details_json else {}
ip_address = "N/A"
if worker_id != "N/A":
worker_info_json = dispatcher.redis.hget("workers:info", worker_id)
if worker_info_json:
worker_info = json.loads(worker_info_json)
ip_address = worker_info.get("ip_address", "N/A")
writer.writerow([
task_key,
task_details.get("app_name", "N/A"),
task_details.get("package_name", "N/A"),
status.get("status", "unknown"),
worker_id,
ip_address,
time.strftime("%Y-%m-%d %H:%M:%S", time.localtime(status.get("start_time", 0))),
time.strftime("%Y-%m-%d %H:%M:%S", time.localtime(status.get("end_time", 0))),
])
logger.info(f"最终报告已保存到: {FINAL_REPORT_CSV}")
except Exception as e:
logger.error(f"生成最终报告时出错: {e}")
def generate_block_test_report(dispatcher, report_dir=None):
try:
output_dir = report_dir or REPORT_DIR
block_report_csv = os.path.join(output_dir, "block_test_report.csv")
os.makedirs(os.path.dirname(block_report_csv) or ".", exist_ok=True)
with open(block_report_csv, mode="w", newline="", encoding="utf-8") as handle:
writer = csv.writer(handle)
writer.writerow([
"任务键", "应用名称", "包名", "状态", "Worker ID", "IP地址",
"开始时间", "结束时间", "失败原因", "失败类型", "重试次数",
])
all_details = dispatcher.redis.hgetall("task:details")
all_status = dispatcher.redis.hgetall("task:status")
success_count = 0
failed_count = 0
retry_count = 0
for task_key in sorted(all_details.keys()):
if not task_key.endswith("_block"):
continue
task_details_json = all_details.get(task_key)
task_details = json.loads(task_details_json) if task_details_json else {}
if not task_details.get("is_block_task"):
continue
status_json = all_status.get(task_key)
status = json.loads(status_json) if status_json else {}
task_status = status.get("status", "unknown")
worker_id = status.get("worker_id", "N/A")
ip_address = "N/A"
if worker_id != "N/A":
worker_info_json = dispatcher.redis.hget("workers:info", worker_id)
if worker_info_json:
worker_info = json.loads(worker_info_json)
ip_address = worker_info.get("ip_address", "N/A")
start_time = status.get("start_time", 0)
end_time = status.get("end_time", 0)
start_str = time.strftime("%Y-%m-%d %H:%M:%S", time.localtime(start_time)) if start_time else ""
end_str = time.strftime("%Y-%m-%d %H:%M:%S", time.localtime(end_time)) if end_time else ""
writer.writerow([
task_key,
task_details.get("app_name", "N/A"),
task_details.get("package_name", "N/A"),
task_status,
worker_id,
ip_address,
start_str,
end_str,
status.get("last_fail_message", ""),
status.get("last_fail_type", ""),
status.get("retry_count", 0),
])
if task_status in ("success", "completed", "qualified"):
success_count += 1
elif task_status in ("failed",):
failed_count += 1
else:
retry_count += 1
writer.writerow([])
writer.writerow(["统计"])
writer.writerow(["成功", str(success_count)])
writer.writerow(["失败", str(failed_count)])
writer.writerow(["重试中/待分发", str(retry_count)])
logger.info(f"[Block测试] Block 测试报告已保存到: {block_report_csv}")
except Exception as e:
logger.error(f"生成 Block 测试报告时出错: {e}")
def generate_worker_report(dispatcher):
try:
all_workers = dispatcher.get_registered_workers()
os.makedirs(os.path.dirname(WORKER_REPORT_CSV) or ".", exist_ok=True)
with open(WORKER_REPORT_CSV, mode="w", newline="", encoding="utf-8") as handle:
writer = csv.writer(handle)
writer.writerow(["Worker ID", "IP地址", "MAC地址", "主机名", "平台", "状态", "当前任务", "注册时间", "最后更新时间"])
for worker in all_workers:
writer.writerow([
worker.get("worker_id", "N/A"),
worker.get("ip_address", "N/A"),
worker.get("mac_address", "N/A"),
worker.get("hostname", "N/A"),
worker.get("platform", "N/A"),
worker.get("status", "N/A"),
worker.get("current_task", "N/A"),
time.strftime("%Y-%m-%d %H:%M:%S", time.localtime(worker.get("register_time", 0))),
time.strftime("%Y-%m-%d %H:%M:%S", time.localtime(worker.get("last_update", 0))),
])
logger.info(f"Worker 使用报告已保存到: {WORKER_REPORT_CSV}")
except Exception as e:
logger.error(f"生成 Worker 报告时出错: {e}")
def push_worker_status(dispatcher):
try:
workers = dispatcher.get_dashboard_workers()
summary = dispatcher.get_dashboard_summary()
online_workers = [worker for worker in workers if worker.get("online")]
idle_workers = [worker for worker in online_workers if worker.get("status") == "idle"]
busy_workers = [worker for worker in online_workers if worker.get("status") == "busy"]
message_lines = [
"Worker 状态报告",
f"时间: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}",
"",
f"任务统计: 待分发 {summary['pending']} | 运行中 {summary['running']} | 成功 {summary['completed']} | 失败 {summary['failed']}",
f"Worker 统计: 在线 {len(online_workers)} | 空闲 {len(idle_workers)} | 忙碌 {len(busy_workers)}",
]
if online_workers:
message_lines.append("")
message_lines.append("在线 Worker:")
for worker in online_workers:
state = "idle" if worker.get("status") == "idle" else "busy"
message_lines.append(f"- {worker.get('ip_address', 'N/A')} ({worker.get('hostname', 'N/A')}) [{state}]")
dispatcher.notifier.send_weCom_alert("SYSTEM", "\n".join(message_lines))
logger.info(f"[状态推送] 已推送 Worker 状态,在线 {len(online_workers)}")
except Exception as e:
logger.error(f"[状态推送] 推送 Worker 状态时出错: {e}")
def main():
_set_console_title()
logger.info("=" * 60)
logger.info(f"Redis 任务分发器 [{INSTANCE_NAME}]")
logger.info(f"分发服务: {REDIS_HOST}:{REDIS_PORT}/{REDIS_DB}")
logger.info(f"频道命名空间: {CHANNEL_NAMESPACE or 'default'}")
logger.info(f"Dashboard: http://{DASHBOARD_HOST}:{DASHBOARD_PORT}")
logger.info("=" * 60)
service = DispatcherService(redis_host=REDIS_HOST, redis_port=REDIS_PORT, redis_db=REDIS_DB)
start_dashboard_thread(service.dispatcher)
logger.info("[1] 同步 Worker 注册表...")
service.dispatcher.sync_worker_registry()
logger.info("[2] 从应用全表加载待采集任务...")
task_count = service.dispatcher.load_tasks_from_app_summary()
if task_count == 0:
logger.warning("应用全表中没有待采集任务,请先运行应用名单同步脚本后再启动中控")
return
logger.info("[3] 启动监听服务...")
logger.info("等待 Worker 连接...")
logger.info("按 Ctrl+C 停止程序")
service.start_listeners()
# 启动后立即同步一次下载队列 + 轮询一次APK结果
initial_queue_count = service.dispatcher.sync_download_queue()
if initial_queue_count > 0:
logger.info(f"[APK队列] 启动时已推送 {initial_queue_count} 个任务到下载队列")
else:
logger.info("[APK队列] 启动同步当前无需下载的新APK任务")
initial_poll_count = service.dispatcher.poll_apk_results()
if initial_poll_count > 0:
logger.info(f"[APK轮询] 启动轮询:导入 {initial_poll_count} 个APK")
else:
logger.info("[APK轮询] 启动轮询无新APK结果")
last_status_time = time.time()
last_worker_status_time = time.time()
last_cleanup_time = time.time()
last_recovery_check_time = time.time()
last_worker_push_time = time.time()
last_monitor_heartbeat_time = time.time()
last_task_refresh_time = time.time()
last_apk_queue_time = time.time()
last_apk_poll_time = time.time()
last_apk_cleanup_time = time.time()
last_block_report_time = time.time()
tasks_done_logged = False
try:
while True:
current_time = time.time()
if current_time - last_cleanup_time > 60:
cleaned_count = service.dispatcher.cleanup_stale_workers(timeout=WORKER_STALE_TIMEOUT)
if cleaned_count > 0:
logger.info(f"[清理] 已清理 {cleaned_count} 个超时 Worker")
last_cleanup_time = current_time
if current_time - last_recovery_check_time > 60:
reset_workers = service.dispatcher.check_recovery_windows()
if reset_workers:
logger.info(f"[恢复窗口] 已重置自动恢复标记: {reset_workers}")
last_recovery_check_time = current_time
if current_time - last_worker_push_time >= WORKER_STATUS_PUSH_INTERVAL:
push_worker_status(service.dispatcher)
last_worker_push_time = current_time
if current_time - last_task_refresh_time > 120:
new_count = service.dispatcher.refresh_tasks_from_app_summary()
if new_count > 0:
logger.info(f"[任务刷新] 定期检查:从数据库加载了 {new_count} 个新任务")
last_task_refresh_time = current_time
if current_time - last_block_report_time >= 300:
generate_block_test_report(service.dispatcher)
last_block_report_time = current_time
if current_time - last_apk_queue_time >= APK_DOWNLOAD_QUEUE_INTERVAL:
queue_count = service.dispatcher.sync_download_queue()
if queue_count > 0:
logger.info(f"[APK队列] 已推送 {queue_count} 个任务到下载队列")
last_apk_queue_time = current_time
if current_time - last_apk_poll_time >= APK_PREFETCH_POLL_INTERVAL:
imported = service.dispatcher.poll_apk_results()
if imported > 0:
logger.info(f"[APK轮询] 本轮导入 {imported} 个APK文件")
last_apk_poll_time = current_time
if current_time - last_apk_cleanup_time >= 3600:
service.dispatcher._enforce_apk_storage_limits()
service.dispatcher._manage_stale_apks()
last_apk_cleanup_time = current_time
# 心跳间隔从15秒增大到30秒减少SQLite写入频率
if current_time - last_monitor_heartbeat_time >= 30:
service.dispatcher.monitor.heartbeat_controller_session()
last_monitor_heartbeat_time = current_time
# 状态日志间隔从15秒增大到60秒减少日志I/O和Redis全量查询频率
if current_time - last_status_time > 60:
stats = service.dispatcher.get_statistics()
logger.info(
f"[状态] 待分发: {stats['pending']} | 运行中: {stats['running']} | "
f"已完成: {stats['completed']} | 失败: {stats['failed']}"
)
running_tasks = service.redis.hgetall("worker:tasks")
if running_tasks:
logger.info("[运行中任务]")
for worker_id, task_key in sorted(running_tasks.items()):
worker_info_json = service.redis.hget("workers:info", worker_id)
if worker_info_json:
worker_info = json.loads(worker_info_json)
logger.info(f" - Worker {worker_id} ({worker_info.get('ip_address', 'N/A')}): {task_key}")
last_status_time = current_time
# Worker状态日志间隔从30秒增大到120秒减少日志I/O
if current_time - last_worker_status_time > 120:
all_workers = service.dispatcher.get_dashboard_workers()
idle_workers = [worker for worker in all_workers if worker.get("online") and worker.get("status") == "idle"]
busy_workers = [worker for worker in all_workers if worker.get("online") and worker.get("status") == "busy"]
logger.info("[Worker 状态]")
logger.info(f" 总数: {len(all_workers)} | 空闲: {len(idle_workers)} | 忙碌: {len(busy_workers)}")
if idle_workers:
logger.info(" 空闲 Worker:")
for worker in idle_workers:
logger.info(f" - {worker.get('worker_id', 'N/A')} ({worker.get('ip_address', 'N/A')})")
if busy_workers:
logger.info(" 忙碌 Worker:")
for worker in busy_workers:
logger.info(
f" - {worker.get('worker_id', 'N/A')} ({worker.get('ip_address', 'N/A')}) "
f"-> {worker.get('current_task', 'N/A')}"
)
last_worker_status_time = current_time
# 使用轻量级Redis命令检测任务是否全部完成避免每次循环都调用全量get_statistics()
queue_len = service.dispatcher.get_pending_queue_length()
running_count = service.redis.hlen("worker:tasks")
if queue_len == 0 and running_count == 0:
stats = service.dispatcher.get_statistics()
if stats["pending"] == 0 and stats["running"] == 0:
if not tasks_done_logged:
logger.info("=" * 60)
logger.info("所有任务已完成,静默等待新任务...")
logger.info(f"总计: 成功 {stats['completed']} | 失败 {stats['failed']}")
logger.info("=" * 60)
tasks_done_logged = True
else:
tasks_done_logged = False
else:
tasks_done_logged = False
# 循环间隔从2秒增大到5秒减少轮询频率
time.sleep(5)
except KeyboardInterrupt:
logger.info("检测到中断信号,正在清理数据并退出...")
service.stop(clear_data=True)
logger.info("程序已中断退出")
return
except Exception as e:
logger.error(f"程序发生错误: {e}", exc_info=True)
service.stop(clear_data=True)
return
service.stop(clear_data=False)
logger.info("[4] 生成最终报告...")
generate_final_report(service.dispatcher)
logger.info("[5] 生成 Worker 使用报告...")
generate_worker_report(service.dispatcher)
logger.info("[6] 生成 Block 测试报告...")
generate_block_test_report(service.dispatcher)
logger.info("程序结束")
if __name__ == "__main__":
main()