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

2922 lines
137 KiB
Python
Executable File
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.

import redis
from redis import ConnectionPool
import json
import os
import threading
import time
import traceback
from datetime import datetime
from typing import Optional, Dict, Any, List, Tuple
from config import (
ANALYTICS_ARTIFACT_WAIT_SECONDS,
ANALYTICS_TRAFFIC_ROOT,
ANALYTICS_TRAFFIC_ROOT_BLOCK,
REDIS_HOST, REDIS_PORT, REDIS_DB, REDIS_MAX_CONNECTIONS_DISPATCHER,
WECHAT_TOKENS, WECOM_TOKENS,
ALERT_WINDOW_SECONDS, ALERT_THRESHOLD, ALERT_COOLDOWN_SECONDS,
WORKER_REINIT_TIMEOUT, WORKER_INIT_MONITOR_WINDOW,
RUN_PIPELINE_DEFAULT_OPTIONS,
AUTO_REBOOT_RECOVERY_DELAY_SECONDS,
WORKER_INVENTORY,
MANAGED_WORKER_IDS, TASK_ROUTING_RULES,
ONLY_MANAGED_WORKERS_CAN_DISPATCH, WORKER_ONLINE_TIMEOUT_SECONDS,
normalize_worker_id,
APK_DOWNLOAD_MODE,
APK_PREFETCH_POLL_INTERVAL,
APK_DOWNLOAD_QUEUE_INTERVAL,
)
from analytics import AnalyticsService, _make_task_key
from log_manager import logger
from monitoring import MonitoringService
from notify_manager import Notifier
from remote_worker_controller import RemoteWorkerController
from result_codes import ErrorInfo, ErrorCategory, AppError, BusinessError, InfraError, DownloadError
class RedisTaskDispatcher:
HOT_APP_RETRY_DOWNLOADS_THRESHOLD = 1_000_000
INFRA_REBOOT_RECOVERY_DELAY_SECONDS = 300
TASK_QUEUE_NAMES = ("task:queue:high", "task:queue:default", "task:queue:low")
TASK_QUEUE_CLEANUP_NAMES = (*TASK_QUEUE_NAMES, "task:queue")
DIRECT_APK_SOURCES = ["google_play", "apkpure"]
APK_CACHE_SOURCES = ["google_play", "local", "apkpure"]
# 类级别的连接池(所有实例共享)
_connection_pool: Optional[ConnectionPool] = None
_connection_pool_config: Optional[Tuple[str, int, int, int]] = None
def __init__(
self,
redis_host=REDIS_HOST,
redis_port=REDIS_PORT,
redis_db=REDIS_DB,
max_connections=REDIS_MAX_CONNECTIONS_DISPATCHER,
):
"""初始化任务分发器
Args:
redis_host: Redis服务器地址
redis_port: Redis端口
redis_db: Redis数据库编号
max_connections: 连接池最大连接数需要支持30+台PC并发
"""
# 使用连接池提高性能
pool_config = (redis_host, redis_port, redis_db, max_connections)
if (
RedisTaskDispatcher._connection_pool is None
or RedisTaskDispatcher._connection_pool_config != pool_config
):
RedisTaskDispatcher._connection_pool = ConnectionPool(
host=redis_host,
port=redis_port,
db=redis_db,
decode_responses=True,
max_connections=max_connections,
socket_timeout=30, # 连接超时时间
socket_connect_timeout=10, # 连接建立超时
retry_on_timeout=True # 超时时自动重试
)
RedisTaskDispatcher._connection_pool_config = pool_config
self.redis = redis.Redis(connection_pool=RedisTaskDispatcher._connection_pool)
self.worker_inventory = {item["worker_id"]: dict(item) for item in WORKER_INVENTORY}
self.managed_worker_ids = set(MANAGED_WORKER_IDS)
self.only_managed_workers_can_dispatch = ONLY_MANAGED_WORKERS_CAN_DISPATCH
self.worker_online_timeout = WORKER_ONLINE_TIMEOUT_SECONDS
self.task_routing_rules = self._normalize_routing_rules(TASK_ROUTING_RULES)
self.remote_controller = RemoteWorkerController(WORKER_INVENTORY)
# 初始化告警推送器
self.notifier = Notifier(WECHAT_TOKENS, WECOM_TOKENS)
self.monitor = MonitoringService(self.redis, managed_worker_ids=MANAGED_WORKER_IDS)
self.analytics = AnalyticsService(
db_path=self.monitor.repo.db_path,
artifact_wait_seconds=ANALYTICS_ARTIFACT_WAIT_SECONDS,
on_snapshot_callback=self._handle_analytics_snapshot,
traffic_root_block=ANALYTICS_TRAFFIC_ROOT_BLOCK,
)
self._apk_registry = None # lazy init
self._minio_storage = None # lazy init
def _normalize_worker_ids(self, worker_ids: Optional[List[str]]) -> List[str]:
normalized = []
seen = set()
for worker_id in worker_ids or []:
candidate = normalize_worker_id(worker_id)
if candidate and candidate not in seen:
seen.add(candidate)
normalized.append(candidate)
return normalized
def _migrate_worker_runtime_id(self, old_worker_id: str, worker_id: str) -> None:
if not old_worker_id or not worker_id or old_worker_id == worker_id:
return
old_info_json = self.redis.hget("workers:info", old_worker_id)
if old_info_json and not self.redis.hget("workers:info", worker_id):
try:
info = json.loads(old_info_json)
except (TypeError, ValueError):
info = {}
info["worker_id"] = worker_id
info.setdefault("ip_address", worker_id)
self.redis.hset("workers:info", worker_id, json.dumps(info))
if old_info_json:
self.redis.hdel("workers:info", old_worker_id)
for set_key in ("workers:idle", "workers:busy"):
if self.redis.sismember(set_key, old_worker_id):
self.redis.srem(set_key, old_worker_id)
self.redis.sadd(set_key, worker_id)
old_task = self.redis.hget("worker:tasks", old_worker_id)
if old_task and not self.redis.hget("worker:tasks", worker_id):
self.redis.hset("worker:tasks", worker_id, old_task)
if old_task:
self.redis.hdel("worker:tasks", old_worker_id)
@staticmethod
def _normalize_device_type(raw_device_type: Any) -> str:
candidate = str(raw_device_type or '').strip().lower()
if candidate in {'emulator', 'physical'}:
return candidate
return ''
def _normalize_task_device_type(self, raw_device_type: Any) -> str:
candidate = str(raw_device_type or '').strip().lower()
if candidate in {'emulator', 'physical', 'any'}:
return candidate
return 'emulator'
def _normalize_routing_rules(self, rules: Optional[Dict[str, Dict[str, List[str]]]]) -> Dict[str, Dict[str, List[str]]]:
normalized = {
'package_name': {},
'task_key': {},
}
for rule_type in normalized:
for key, worker_ids in (rules or {}).get(rule_type, {}).items():
normalized[rule_type][str(key).strip()] = self._normalize_worker_ids(worker_ids)
return normalized
def _resolve_task_targets(self, task_key: str, package_name: str) -> Tuple[List[str], str]:
task_targets = self.task_routing_rules.get('task_key', {}).get(task_key, [])
if task_targets:
return task_targets, 'task_key'
package_targets = self.task_routing_rules.get('package_name', {}).get(package_name, [])
if package_targets:
return package_targets, 'package_name'
return [], 'public'
def _normalize_country_codes(self, raw_country_code: Any) -> List[str]:
if isinstance(raw_country_code, (list, tuple)):
raw_items = raw_country_code
else:
raw_items = str(raw_country_code or '').split(',')
country_codes = ['US']
seen = {'US'}
for item in raw_items:
candidate = str(item or '').strip().upper()
if not candidate or candidate in seen:
continue
seen.add(candidate)
country_codes.append(candidate)
return country_codes
def _build_task_info_from_payload(self, payload: Dict[str, Any]) -> Dict[str, Any]:
normalized_payload = dict(payload or {})
app_name = str(normalized_payload.get('app_name') or '').strip()
package_name = str(normalized_payload.get('package_name') or '').strip()
task_key = str(normalized_payload.get('task_key') or _make_task_key(app_name, package_name)).strip()
target_worker_ids, routing_mode = self._resolve_task_targets(task_key, package_name)
country_code = str(normalized_payload.get('country_code', '') or '').strip()
country_codes = normalized_payload.get('country_codes') or country_code
original_row = normalized_payload.get('original_row') or {}
original_app_magic_label = original_row.get('app_magic_label') if isinstance(original_row, dict) else ''
original_last_updated = original_row.get('last_updated') if isinstance(original_row, dict) else ''
return {
'app_name': app_name,
'package_name': package_name,
'country_code': country_code,
'country_codes': self._normalize_country_codes(country_codes),
'app_magic_label': str(normalized_payload.get('app_magic_label') or original_app_magic_label or '').strip(),
'last_updated': str(normalized_payload.get('last_updated') or original_last_updated or '').strip(),
'collection_task_type': str(normalized_payload.get('collection_task_type') or 'new_app').strip() or 'new_app',
'last_update_interval_days': int(normalized_payload.get('last_update_interval_days') or 0),
'downloads': int(normalized_payload.get('downloads') or 0),
'device_type': self._normalize_task_device_type(normalized_payload.get('device_type', '')),
'available_sources': normalized_payload.get('available_sources') or ['google_play', 'local'],
'original_row': original_row,
'target_worker_ids': target_worker_ids,
'routing_mode': routing_mode,
}
@staticmethod
def _normalize_task_queue_label(raw_queue: Any, default: str = "default") -> str:
candidate = str(raw_queue or default).strip().lower()
if candidate in {"high", "default", "low"}:
return candidate
return default
def _should_auto_reroute_to_physical(self, task_details: Dict[str, Any], error_info: ErrorInfo) -> bool:
current_device_type = self._normalize_task_device_type(task_details.get('device_type', ''))
return (
current_device_type != 'physical'
and error_info.category == ErrorCategory.APP_ERROR
and error_info.code in {AppError.CRASH, AppError.ROOT_MODE_UNSUPPORTED}
)
def _build_physical_reroute_task_details(self, task_details: Dict[str, Any]) -> Dict[str, Any]:
rerouted_task_details = dict(task_details or {})
rerouted_task_details['device_type'] = 'physical'
original_row = rerouted_task_details.get('original_row')
if isinstance(original_row, dict):
original_row_copy = dict(original_row)
original_row_copy['device_type'] = 'physical'
rerouted_task_details['original_row'] = original_row_copy
return rerouted_task_details
def _is_task_queued(self, task_key: str) -> bool:
try:
for queue_name in self.TASK_QUEUE_NAMES:
if self.redis.lpos(queue_name, task_key) is not None:
return True
return False
except Exception:
for queue_name in self.TASK_QUEUE_NAMES:
if task_key in set(self.redis.lrange(queue_name, 0, -1)):
return True
return False
def _remove_task_from_queue(self, task_key: str) -> int:
removed = 0
try:
for queue_name in self.TASK_QUEUE_CLEANUP_NAMES:
removed += int(self.redis.lrem(queue_name, 0, task_key) or 0)
except Exception:
pass
return removed
def _get_queued_task_keys(self) -> List[str]:
task_keys = []
seen = set()
for queue_name in self.TASK_QUEUE_NAMES:
for task_key in reversed(self.redis.lrange(queue_name, 0, -1)):
if task_key in seen:
continue
seen.add(task_key)
task_keys.append(task_key)
return task_keys
def get_pending_queue_length(self) -> int:
return sum(int(self.redis.llen(queue_name) or 0) for queue_name in self.TASK_QUEUE_NAMES)
def _is_task_running(self, task_key: str) -> bool:
status_json = self.redis.hget("task:status", task_key)
if status_json:
try:
status = json.loads(status_json)
if status.get("status") == "running":
return True
except (json.JSONDecodeError, TypeError):
pass
return task_key in set(self.redis.hvals("worker:tasks"))
def _mark_task_pending_for_queue(self, task_key: str) -> None:
status_json = self.redis.hget("task:status", task_key)
retry_count = 0
if status_json:
try:
retry_count = int((json.loads(status_json) or {}).get("retry_count") or 0)
except (json.JSONDecodeError, TypeError, ValueError):
retry_count = 0
self.redis.srem("task:completed", task_key)
self.redis.srem("task:failed", task_key)
self.redis.hset("task:status", task_key, json.dumps({
"status": "pending",
"end_time": time.time(),
"retry_count": retry_count,
}))
def _is_hot_new_app_task(self, task_details: Dict[str, Any]) -> bool:
if str(task_details.get('collection_task_type') or 'new_app').strip() != 'new_app':
return False
try:
downloads = int(task_details.get('downloads') or 0)
except (TypeError, ValueError):
downloads = 0
return downloads >= self.HOT_APP_RETRY_DOWNLOADS_THRESHOLD
def _mark_retry_avoid_worker(self, task_key: str, task_details: Dict[str, Any], worker_id: str) -> Dict[str, Any]:
next_details = dict(task_details or {})
excluded = [
str(item or '').strip()
for item in (next_details.get('excluded_worker_ids') or [])
if str(item or '').strip()
]
if worker_id and worker_id not in excluded:
excluded.append(worker_id)
next_details['excluded_worker_ids'] = excluded[-8:]
next_details['last_failed_worker_id'] = worker_id
self.redis.hset("task:details", task_key, json.dumps(next_details))
return next_details
def _queue_retry_task(self, task_key: str, task_details: Dict[str, Any], *, prioritize: bool = False):
self._remove_task_from_queue(task_key)
queue_name = self._resolve_task_queue(task_details)
if prioritize:
self.redis.rpush(queue_name, task_key)
else:
self.redis.lpush(queue_name, task_key)
def _resolve_task_queue(self, task_details: Dict[str, Any]) -> str:
task_queue = self._normalize_task_queue_label(task_details.get("task_queue"))
if task_queue == "low":
return "task:queue:low"
if task_queue == "high":
return "task:queue:high"
return "task:queue:default"
def _handle_analytics_snapshot(self, package_name: str, summary: Dict[str, Any]) -> None:
payload = dict(summary.get("task_payload") or {})
app_name = str(payload.get("app_name") or summary.get("app_name") or package_name).strip() or package_name
task_key = str(summary.get("latest_task_key") or payload.get("task_key") or _make_task_key(app_name, package_name)).strip()
if not task_key:
return
existing_task_details_json = self.redis.hget("task:details", task_key)
existing_task_details = json.loads(existing_task_details_json) if existing_task_details_json else {}
if existing_task_details.get("is_block_task") or task_key.endswith("_block"):
return
if existing_task_details.get("task_queue") == "low":
if summary.get("model_eligible"):
self._remove_task_from_queue(task_key)
self.redis.srem("task:failed", task_key)
self.redis.srem("task:completed", task_key)
self.redis.hset("task:status", task_key, json.dumps({
"status": "pending",
"end_time": time.time(),
"retry_count": 0,
}))
self.redis.lpush("task:queue:low", task_key)
logger.info(f"[模型流量] 任务 {task_key} 继续留在低优队列 (model_flow={summary.get('model_flow_count', 0)})")
else:
self._remove_task_from_queue(task_key)
self.redis.sadd("task:completed", task_key)
self.redis.hset("task:status", task_key, json.dumps({
"status": "completed",
"end_time": time.time(),
}))
logger.info(f"[模型流量] 任务 {task_key} 已完成,退出低优队列 (model_flow={summary.get('model_flow_count', 0)})")
return
collection_status = str(summary.get("collection_status") or "pending").strip() or "pending"
task_info = self._build_task_info_from_payload(
payload or {
"app_name": app_name,
"package_name": package_name,
"country_code": summary.get("country_code", ""),
"device_type": summary.get("device_type", ""),
}
)
if existing_task_details.get("excluded_worker_ids"):
task_info["excluded_worker_ids"] = existing_task_details.get("excluded_worker_ids") or []
task_info["last_failed_worker_id"] = existing_task_details.get("last_failed_worker_id", "")
existing_queue = self._normalize_task_queue_label(existing_task_details.get("task_queue"))
task_info["task_queue"] = existing_queue if existing_queue in {"high", "default"} else "default"
self.redis.hset("task:details", task_key, json.dumps(task_info))
prev_status_json = self.redis.hget("task:status", task_key)
task_status = json.loads(prev_status_json) if prev_status_json else {}
retry_count = int(task_status.get("retry_count") or 0)
if collection_status == "qualified":
self._remove_task_from_queue(task_key)
self.redis.srem("task:failed", task_key)
self.redis.sadd("task:completed", task_key)
task_status.update({
"status": "qualified",
"end_time": time.time(),
"retry_count": retry_count,
})
self.redis.hset("task:status", task_key, json.dumps(task_status))
return
if collection_status == "failed_terminal":
self._remove_task_from_queue(task_key)
self.redis.srem("task:completed", task_key)
self.redis.sadd("task:failed", task_key)
task_status.update({
"status": "failed",
"end_time": time.time(),
"retry_count": retry_count,
})
self.redis.hset("task:status", task_key, json.dumps(task_status))
return
self.redis.srem("task:completed", task_key)
self.redis.srem("task:failed", task_key)
if task_status.get("status") != "running":
task_status.update({
"status": "pending",
"end_time": task_status.get("end_time", time.time()),
"retry_count": retry_count,
})
self.redis.hset("task:status", task_key, json.dumps(task_status))
if not self._is_task_queued(task_key):
self._queue_retry_task(task_key, task_info, prioritize=self._is_hot_new_app_task(task_info))
def _task_to_payload(self, task_key: str, task_details: Dict[str, Any]) -> Dict[str, Any]:
task_details = self._enrich_task_with_apk(task_details)
payload = {
'task_key': task_key,
'app_name': task_details['app_name'],
'package_name': task_details['package_name'],
'country_code': task_details.get('country_code', ''),
'country_codes': self._normalize_country_codes(task_details.get('country_codes') or task_details.get('country_code', '')),
'app_magic_label': str(task_details.get('app_magic_label') or '').strip(),
'last_updated': str(task_details.get('last_updated') or '').strip(),
'collection_task_type': str(task_details.get('collection_task_type') or 'new_app').strip() or 'new_app',
'last_update_interval_days': int(task_details.get('last_update_interval_days') or 0),
'device_type': self._normalize_task_device_type(task_details.get('device_type', '')),
'available_sources': task_details.get('available_sources') or self._default_available_sources(),
'local_apk_dir': task_details.get('local_apk_dir', ''),
'local_apk_files': task_details.get('local_apk_files', []),
'target_worker_ids': task_details.get('target_worker_ids', []),
'routing_mode': task_details.get('routing_mode', 'public'),
'keep_app_installed': bool(task_details.get('keep_app_installed')),
'blocked': bool(task_details.get('is_block_task', False)),
'traffic_root': ANALYTICS_TRAFFIC_ROOT_BLOCK if task_details.get('is_block_task') else ANALYTICS_TRAFFIC_ROOT,
}
if payload.get('local_apk_dir'):
logger.info("[下发] 任务 %s 附带本地APK: dir=%s files=%s",
task_key, payload['local_apk_dir'], ', '.join(f.get('filename', '') for f in payload['local_apk_files']))
elif payload.get('available_sources') and 'local' in payload['available_sources']:
logger.warning("[下发] 任务 %s 标记了local源但无本地APK路径 (available_sources=%s)",
task_key, payload['available_sources'])
return payload
@staticmethod
def _apk_download_mode() -> str:
return str(APK_DOWNLOAD_MODE or "direct").strip().lower()
@classmethod
def _uses_central_apk_cache(cls) -> bool:
return cls._apk_download_mode() in {"minio", "smb"}
@classmethod
def _default_available_sources(cls) -> List[str]:
if cls._uses_central_apk_cache():
return list(cls.APK_CACHE_SOURCES)
return list(cls.DIRECT_APK_SOURCES)
def _get_worker_info(self, worker_id: str) -> Optional[Dict[str, Any]]:
worker_id = normalize_worker_id(worker_id)
worker_info_json = self.redis.hget("workers:info", worker_id)
if not worker_info_json:
return None
return json.loads(worker_info_json)
def _get_worker_ip(self, worker_id: str) -> str:
worker_id = normalize_worker_id(worker_id)
worker_info = self._get_worker_info(worker_id)
if worker_info:
return worker_info.get('ip_address', '')
inventory = self.worker_inventory.get(worker_id)
if inventory:
return inventory["ssh_target"].split("@")[-1]
return worker_id.split('_', 1)[0] if '_' in worker_id else ''
def _workers_meta_key(self) -> str:
return "workers:meta"
def _worker_recovery_observe_key(self, worker_id: str) -> str:
return f"worker:recovery_observe:{worker_id}"
def _worker_recovery_pending_key(self, worker_id: str) -> str:
return f"worker:recovery_pending:{worker_id}"
def _default_worker_meta(self, worker_id: str) -> Dict[str, Any]:
allow_dispatch = (not self.only_managed_workers_can_dispatch) or self._is_managed_worker_id(worker_id)
return {
'policy': 'enabled' if allow_dispatch else 'disabled',
'auto_recover_used': False,
'last_action': '',
'last_action_status': '',
'last_action_message': '',
'last_action_at': 0.0,
}
def _get_worker_meta(self, worker_id: str) -> Dict[str, Any]:
payload = self.redis.hget(self._workers_meta_key(), worker_id)
if not payload:
return self._default_worker_meta(worker_id)
try:
meta = json.loads(payload)
except (TypeError, ValueError):
return self._default_worker_meta(worker_id)
if not isinstance(meta, dict):
return self._default_worker_meta(worker_id)
normalized = self._default_worker_meta(worker_id)
normalized.update(meta)
normalized['auto_recover_used'] = bool(normalized.get('auto_recover_used'))
return normalized
def _set_worker_meta(self, worker_id: str, meta: Dict[str, Any]) -> Dict[str, Any]:
normalized = self._default_worker_meta(worker_id)
normalized.update(meta)
self.redis.hset(self._workers_meta_key(), worker_id, json.dumps(normalized))
return normalized
def _update_worker_meta(self, worker_id: str, **updates: Any) -> Dict[str, Any]:
meta = self._get_worker_meta(worker_id)
meta.update(updates)
return self._set_worker_meta(worker_id, meta)
def _mark_worker_action(self, worker_id: str, action: str, result: Dict[str, Any]) -> Dict[str, Any]:
return self._update_worker_meta(
worker_id,
last_action=action,
last_action_status='success' if result.get('ok') else 'failed',
last_action_message=result.get('message', ''),
last_action_at=time.time(),
)
def _set_worker_policy(self, worker_id: str, policy: str) -> Dict[str, Any]:
return self._update_worker_meta(worker_id, policy=policy)
@staticmethod
def _is_worker_recovery_action(action: str) -> bool:
return action in {'recover_and_start', 'run_pipeline'}
def _schedule_post_reboot_pipeline(self, worker_id: str, delay_seconds: int = AUTO_REBOOT_RECOVERY_DELAY_SECONDS) -> None:
thread = threading.Thread(
target=self._run_post_reboot_pipeline_after_delay,
args=(worker_id, delay_seconds),
name=f"post-reboot-pipeline-{worker_id}",
daemon=True,
)
thread.start()
def _run_post_reboot_pipeline_after_delay(self, worker_id: str, delay_seconds: int = AUTO_REBOOT_RECOVERY_DELAY_SECONDS) -> None:
pending_key = self._worker_recovery_pending_key(worker_id)
observe_key = self._worker_recovery_observe_key(worker_id)
try:
if delay_seconds > 0:
logger.info(
"[Worker自动恢复] worker=%s 主机重启后等待%s秒,再执行默认恢复 pipeline",
worker_id,
delay_seconds,
)
time.sleep(delay_seconds)
self.redis.delete(observe_key)
self.redis.delete(pending_key)
pipeline_result = self.run_dashboard_action([worker_id], 'run_pipeline')[0]
self._mark_worker_action(worker_id, 'run_pipeline', pipeline_result)
if pipeline_result.get('ok'):
self.redis.setex(pending_key, WORKER_REINIT_TIMEOUT, str(time.time()))
self._update_worker_meta(worker_id, auto_recover_used=True)
logger.warning(f"[Worker自动恢复] Worker {worker_id} 主机重启后已执行默认恢复 pipeline")
self.notifier.send_weCom_alert(
worker_id,
f"【Worker主机重启后自动恢复】\nWorker: {worker_id}\n已在主机重启后执行 dashboard 默认恢复链路,等待 Worker 重新 init"
)
return
self.redis.delete(pending_key)
self._update_worker_meta(worker_id, auto_recover_used=False)
logger.error(f"[Worker自动恢复失败] Worker {worker_id} 主机重启后执行默认恢复 pipeline 失败")
self.notifier.send_weCom_alert(
worker_id,
f"【Worker主机重启后自动恢复失败】\nWorker: {worker_id}\n恢复结果: {pipeline_result.get('message', '')}"
)
except Exception:
detail = traceback.format_exc().strip()
self.redis.delete(pending_key)
self._update_worker_meta(worker_id, auto_recover_used=False)
self._mark_worker_action(
worker_id,
'run_pipeline',
{
'ok': False,
'message': detail,
'stderr': detail,
},
)
logger.exception(f"[Worker自动恢复失败] Worker {worker_id} 主机重启后恢复线程异常")
self.notifier.send_weCom_alert(
worker_id,
f"【Worker主机重启后自动恢复异常】\nWorker: {worker_id}\n异常信息: {detail}"
)
def _get_control_display_status(self, worker_id: str, current_worker: Optional[Dict[str, Any]] = None) -> str:
meta = self._get_worker_meta(worker_id)
if meta.get('policy') == 'disabled':
return 'disabled'
if meta.get('policy') == 'drain':
return 'drain'
if current_worker:
if not current_worker.get('online'):
return 'offline'
if current_worker.get('state') == 'disabled':
return 'disabled'
if current_worker.get('status') == 'busy':
return 'running'
return 'idle'
return 'offline'
def _run_worker_action(
self,
worker_id: str,
action: str,
options: Optional[Dict[str, Any]] = None,
progress_callback=None,
) -> Dict[str, Any]:
logger.info(
"[DashboardAction] start worker=%s action=%s options=%s",
worker_id,
action,
json.dumps(options or {}, ensure_ascii=False, sort_keys=True),
)
result = self.remote_controller.run_action(worker_id, action, options=options, progress_callback=progress_callback)
self._mark_worker_action(worker_id, action, result)
logger.info(
"[DashboardAction] finish worker=%s action=%s ok=%s failed_step=%s stage=%s message=%s",
worker_id,
action,
result.get('ok'),
result.get('failed_step', ''),
result.get('stage', ''),
str(result.get('message', '')).replace('\r', ' ').replace('\n', ' | '),
)
return result
def get_worker_control_rows(
self,
date_str: Optional[str] = None,
worker_id: Optional[str] = None,
) -> List[Dict[str, Any]]:
filter_worker_id = worker_id or ""
monitor_rows = {
row["worker_id"]: row
for row in self.monitor.get_worker_stats(date_str=date_str, dispatcher=self)
}
current_workers = {worker["worker_id"]: worker for worker in self.get_dashboard_workers()}
worker_ids = list(self.managed_worker_ids) or list(current_workers.keys())
rows: List[Dict[str, Any]] = []
for current_worker_id in sorted(set(worker_ids) | set(monitor_rows) | set(current_workers)):
if current_worker_id not in self.worker_inventory:
continue
if filter_worker_id and current_worker_id != filter_worker_id:
continue
inventory = self.worker_inventory.get(current_worker_id, {})
monitor_row = monitor_rows.get(current_worker_id, {})
current_worker = current_workers.get(current_worker_id, {})
meta = self._get_worker_meta(current_worker_id)
current_status = current_worker.get('state', 'offline')
current_status = {
'idle_waiting_task': 'idle',
'running_download': 'download',
'running_collect': 'collect',
}.get(current_status, current_status)
rows.append(
{
'worker_id': current_worker_id,
'ip_address': current_worker.get('ip_address') or monitor_row.get('ip_address') or self._get_worker_ip(current_worker_id),
'device_type': self._normalize_device_type(current_worker.get('device_type', '')),
'status': monitor_row.get('status', current_status),
'control_status': self._get_control_display_status(current_worker_id, current_worker),
'policy': meta.get('policy', 'enabled'),
'dispatch_enabled': self._worker_can_dispatch(current_worker_id),
'online': bool(current_worker.get('online')),
'task_count': monitor_row.get('task_count', 0),
'avg_download_duration_seconds': monitor_row.get('avg_download_duration_seconds', 0.0),
'avg_collect_duration_seconds': monitor_row.get('avg_collect_duration_seconds', 0.0),
'running_seconds': monitor_row.get('running_seconds', 0.0),
'idle_waiting_seconds': monitor_row.get('idle_waiting_seconds', 0.0),
'failed_count': monitor_row.get('failed_count', 0),
'last_task_at': monitor_row.get('last_task_at', '--'),
'current_task': current_worker.get('current_task'),
'last_action': meta.get('last_action', ''),
'last_action_status': meta.get('last_action_status', ''),
'last_action_message': meta.get('last_action_message', ''),
'last_action_at': meta.get('last_action_at', 0.0),
'tags': inventory.get('tags', []),
}
)
return rows
def _is_physical_worker(self, worker_id: str) -> bool:
worker_info = self._get_worker_info(worker_id)
if not worker_info:
return False
return self._normalize_device_type(worker_info.get("device_type", "")) == "physical"
def run_dashboard_action(
self,
worker_ids: List[str],
action: str,
options: Optional[Dict[str, Any]] = None,
progress_callback=None,
) -> List[Dict[str, Any]]:
results = []
if action == 'force_reapk':
pkg = str((options or {}).get("package_name", "")).strip()
if not pkg:
return [{"worker_id": "", "action": "force_reapk", "ok": False, "message": "package_name is required"}]
if not self._uses_central_apk_cache():
return [{
"worker_id": "",
"action": "force_reapk",
"ok": False,
"message": "当前为 direct 模式,未启用中心 APK 缓存,无需 force_reapk",
}]
n = self.force_reapk(pkg)
return [{"worker_id": "", "action": "force_reapk", "ok": True, "message": f"已重置 {pkg} ({n} 个任务),等待中心 APK 缓存刷新"}]
for worker_id in worker_ids:
try:
if action == 'enable':
self.redis.delete(self._worker_recovery_observe_key(worker_id))
self.redis.delete(self._worker_recovery_pending_key(worker_id))
meta = self._update_worker_meta(worker_id, policy='enabled', auto_recover_used=False)
worker_info = self._get_worker_info(worker_id)
if self._get_worker_online_state(worker_info):
self.monitor.set_worker_state(worker_id, 'idle_waiting_task', reason='manual_enable')
meta['ok'] = True
meta['message'] = 'worker enabled'
result = self._mark_worker_action(worker_id, 'enable', meta)
result.update({'worker_id': worker_id, 'action': 'enable', 'ok': True, 'message': 'worker enabled'})
elif action == 'disable':
self.redis.delete(self._worker_recovery_observe_key(worker_id))
self.redis.delete(self._worker_recovery_pending_key(worker_id))
self.monitor.mark_worker_disabled(worker_id, 'manual_disable')
meta = self._set_worker_policy(worker_id, 'disabled')
meta['ok'] = True
meta['message'] = 'worker disabled'
result = self._mark_worker_action(worker_id, 'disable', meta)
result.update({'worker_id': worker_id, 'action': 'disable', 'ok': True, 'message': 'worker disabled'})
elif action == 'drain':
meta = self._set_worker_policy(worker_id, 'drain')
meta['ok'] = True
meta['message'] = 'worker drain enabled'
result = self._mark_worker_action(worker_id, 'drain', meta)
result.update({'worker_id': worker_id, 'action': 'drain', 'ok': True, 'message': 'worker drain enabled'})
elif action == 'start':
self._set_worker_policy(worker_id, 'enabled')
pcap_result = self._run_worker_action(worker_id, 'pull_pcap_files', progress_callback=progress_callback)
if not pcap_result.get('ok'):
self._mark_worker_action(worker_id, 'start', pcap_result)
results.append(pcap_result)
continue
result = self._run_worker_action(worker_id, 'start_worker', progress_callback=progress_callback)
self._mark_worker_action(worker_id, 'start', result)
elif action == 'run_pipeline':
pipeline_options = dict(RUN_PIPELINE_DEFAULT_OPTIONS)
if options:
pipeline_options.update(options)
if self._is_physical_worker(worker_id):
pipeline_options.pop("recover_mumu", None)
pipeline_options.pop("restart_mumu", None)
if pipeline_options.get('start_worker'):
self._set_worker_policy(worker_id, 'enabled')
result = self._run_worker_action(worker_id, 'run_pipeline', options=pipeline_options, progress_callback=progress_callback)
elif action == 'stop_worker':
result = self._run_worker_action(worker_id, 'stop_worker', progress_callback=progress_callback)
elif action == 'recover_and_start':
self._set_worker_policy(worker_id, 'enabled')
self._run_worker_action(worker_id, 'stop_worker', progress_callback=progress_callback)
pcap_result = self._run_worker_action(worker_id, 'pull_pcap_files', progress_callback=progress_callback)
if not pcap_result.get('ok'):
self._mark_worker_action(worker_id, 'recover_and_start', pcap_result)
results.append(pcap_result)
continue
if self._is_physical_worker(worker_id):
result = self._run_worker_action(worker_id, 'start_worker', progress_callback=progress_callback)
self._mark_worker_action(worker_id, 'recover_and_start', result)
results.append(result)
continue
recover_result = self._run_worker_action(worker_id, 'recover_mumu', progress_callback=progress_callback)
if not recover_result.get('ok'):
self._mark_worker_action(worker_id, 'recover_and_start', recover_result)
results.append(recover_result)
continue
restart_result = self._run_worker_action(worker_id, 'restart_mumu', progress_callback=progress_callback)
if not restart_result.get('ok'):
self._mark_worker_action(worker_id, 'recover_and_start', restart_result)
results.append(restart_result)
continue
self._run_worker_action(worker_id, 'configure_mumu_network', progress_callback=progress_callback)
self._run_worker_action(worker_id, 'fix_adb_connection', progress_callback=progress_callback)
result = self._run_worker_action(worker_id, 'start_worker', progress_callback=progress_callback)
self._mark_worker_action(worker_id, 'recover_and_start', result)
elif action == 'clone':
result = self._run_worker_action(worker_id, 'clone', progress_callback=progress_callback)
elif action == 'pull':
result = self._run_worker_action(worker_id, 'pull', progress_callback=progress_callback)
elif action == 'pull_pcap_files':
result = self._run_worker_action(worker_id, 'pull_pcap_files', progress_callback=progress_callback)
elif action == 'setup':
result = self._run_worker_action(worker_id, 'setup', progress_callback=progress_callback)
elif action == 'restart_mumu':
if self._is_physical_worker(worker_id):
result = {"worker_id": worker_id, "action": "restart_mumu", "ok": False, "message": "真机设备跳过重启MuMu"}
results.append(result)
continue
result = self._run_worker_action(worker_id, 'restart_mumu', progress_callback=progress_callback)
elif action == 'recover_mumu':
if self._is_physical_worker(worker_id):
result = {"worker_id": worker_id, "action": "recover_mumu", "ok": False, "message": "真机设备跳过MuMu镜像恢复"}
results.append(result)
continue
result = self._run_worker_action(worker_id, 'recover_mumu', progress_callback=progress_callback)
elif action == 'reboot':
result = self._run_worker_action(worker_id, 'reboot', progress_callback=progress_callback)
elif action == 'status':
result = self._run_worker_action(worker_id, 'status', progress_callback=progress_callback)
elif action == 'execute_command':
result = self._run_worker_action(worker_id, 'execute_command', options=options, progress_callback=progress_callback)
elif action == 'fix_adb_connection':
result = self._run_worker_action(worker_id, 'fix_adb_connection', progress_callback=progress_callback)
elif action == 'configure_mumu_network':
result = self._run_worker_action(worker_id, 'configure_mumu_network', progress_callback=progress_callback)
elif action == 'recover_mumu_full':
if self._is_physical_worker(worker_id):
result = {"worker_id": worker_id, "action": "recover_mumu_full", "ok": False, "message": "真机设备跳过MuMu恢复"}
results.append(result)
continue
result = self._run_worker_action(worker_id, 'recover_mumu_full', progress_callback=progress_callback)
else:
raise ValueError(f'unsupported action: {action}')
except Exception as e:
detail = traceback.format_exc().strip()
result = {
'worker_id': worker_id,
'action': action,
'ok': False,
'message': detail or str(e),
'stderr': detail,
}
self._mark_worker_action(worker_id, action, result)
results.append(result)
return results
def check_recovery_windows(self) -> List[str]:
reset_workers = []
for worker_id in self.managed_worker_ids:
meta = self._get_worker_meta(worker_id)
if not meta.get('auto_recover_used'):
continue
if self.redis.exists(self._worker_recovery_pending_key(worker_id)):
continue
observe_key = self._worker_recovery_observe_key(worker_id)
if self.redis.exists(observe_key):
continue
if self._is_worker_recovery_action(meta.get('last_action', '')) and meta.get('last_action_status') == 'success':
self._update_worker_meta(worker_id, auto_recover_used=False)
reset_workers.append(worker_id)
return reset_workers
def _is_managed_worker_id(self, worker_id: str) -> bool:
worker_id = normalize_worker_id(worker_id)
return not self.managed_worker_ids or worker_id in self.managed_worker_ids
def _worker_can_dispatch(self, worker_id: str) -> bool:
worker_id = normalize_worker_id(worker_id)
if not worker_id:
return False
meta = self._get_worker_meta(worker_id)
if meta.get('policy') != 'enabled':
return False
if self.only_managed_workers_can_dispatch:
return self._is_managed_worker_id(worker_id)
return True
def _worker_matches_task_device_type(
self,
task_details: Dict[str, Any],
worker_info: Optional[Dict[str, Any]],
) -> bool:
required_device_type = self._normalize_task_device_type(task_details.get('device_type', ''))
worker_device_type = self._normalize_device_type((worker_info or {}).get('device_type', ''))
if required_device_type == 'any':
return worker_device_type in {'emulator', 'physical'}
return worker_device_type == required_device_type
def _task_matches_worker(
self,
task_details: Dict[str, Any],
worker_id: str,
worker_info: Optional[Dict[str, Any]] = None,
) -> bool:
excluded_worker_ids = {
normalize_worker_id(item)
for item in (task_details.get('excluded_worker_ids') or [])
if normalize_worker_id(item)
}
if worker_id in excluded_worker_ids:
return False
target_worker_ids = self._normalize_worker_ids(task_details.get('target_worker_ids', []))
if target_worker_ids:
if worker_id not in target_worker_ids:
return False
elif not self._worker_can_dispatch(worker_id):
return False
return self._worker_matches_task_device_type(task_details, worker_info)
def _restore_unmatched_tasks(self, skipped: List[Tuple[str, str]]):
for task_key, queue_name in skipped:
self.redis.rpush(queue_name, task_key)
def _get_worker_online_state(self, worker_info: Optional[Dict[str, Any]], current_time: Optional[float] = None) -> bool:
if not worker_info:
return False
current_time = current_time or time.time()
last_update = worker_info.get('last_update', worker_info.get('register_time', 0))
return (current_time - last_update) <= self.worker_online_timeout
def _get_dashboard_worker_lookup(self) -> Dict[str, Dict[str, Any]]:
current_time = time.time()
worker_lookup: Dict[str, Dict[str, Any]] = {}
for worker in self.get_registered_workers():
worker_copy = dict(worker)
meta = self._get_worker_meta(worker_copy.get('worker_id', ''))
inventory = self.worker_inventory.get(worker_copy.get('worker_id', ''), {})
worker_copy['online'] = self._get_worker_online_state(worker_copy, current_time)
worker_copy['is_managed'] = self._is_managed_worker_id(worker_copy.get('worker_id', ''))
worker_copy['dispatch_enabled'] = self._worker_can_dispatch(worker_copy.get('worker_id', ''))
worker_copy['policy'] = meta.get('policy', 'enabled')
worker_copy['last_action'] = meta.get('last_action', '')
worker_copy['last_action_status'] = meta.get('last_action_status', '')
worker_copy['last_action_message'] = meta.get('last_action_message', '')
worker_copy['last_action_at'] = meta.get('last_action_at', 0.0)
worker_copy['device_type'] = self._normalize_device_type(worker_copy.get('device_type', ''))
worker_copy['tags'] = inventory.get('tags', [])
worker_copy['ssh_target'] = inventory.get('ssh_target', '')
worker_lookup[worker_copy['worker_id']] = worker_copy
return worker_lookup
def _build_pending_task_entry(self, task_key: str, worker_lookup: Dict[str, Dict[str, Any]]) -> Dict[str, Any]:
task_details_json = self.redis.hget("task:details", task_key)
task_details = json.loads(task_details_json) if task_details_json else {}
target_worker_ids = task_details.get('target_worker_ids', [])
routing_state = 'public_pending'
if target_worker_ids:
online_workers = [
worker for worker in worker_lookup.values()
if worker.get('online') and worker.get('worker_id') in target_worker_ids
]
idle_workers = [worker for worker in online_workers if worker.get('status') == 'idle']
if idle_workers:
routing_state = 'routable_pending'
elif online_workers:
routing_state = 'waiting_target_busy'
else:
routing_state = 'waiting_target_offline'
status_json = self.redis.hget("task:status", task_key)
status = json.loads(status_json) if status_json else {}
return {
'task_key': task_key,
'app_name': task_details.get('app_name', ''),
'package_name': task_details.get('package_name', ''),
'status': status.get('status', 'pending'),
'retry_count': status.get('retry_count', 0),
'device_type': self._normalize_task_device_type(task_details.get('device_type', '')),
'target_worker_ids': target_worker_ids,
'routing_mode': task_details.get('routing_mode', 'public'),
'routing_state': routing_state,
}
def load_tasks_from_app_summary(self) -> int:
"""从应用全表加载任务到三个优先级队列。"""
for queue_name in self.TASK_QUEUE_CLEANUP_NAMES:
self.redis.delete(queue_name)
self.redis.delete("task:status")
self.redis.delete("task:details")
self.redis.delete("task:completed")
self.redis.delete("task:failed")
logger.info("[清空] 已清空任务队列和相关数据")
entries = self.analytics.list_pending_collection_tasks()
high_count = 0
default_count = 0
active_base_keys: set = set()
for entry in entries:
payload = entry.get("task_payload") or {}
package_name = str(payload.get("package_name") or entry.get("package_name") or "").strip()
app_name = str(payload.get("app_name") or entry.get("app_name") or "").strip()
if not package_name or not app_name:
continue
task_key = _make_task_key(app_name, package_name)
task_info = self._build_task_info_from_payload(payload)
unmanaged_targets = [
target_worker_id for target_worker_id in task_info.get('target_worker_ids', [])
if not self._is_managed_worker_id(target_worker_id)
]
if unmanaged_targets:
logger.warning(
f"[定向配置] 任务 {task_key} 配置了未纳入名单的目标Worker: {unmanaged_targets}"
)
task_queue = self._normalize_task_queue_label(entry.get("task_queue"), "default")
task_info["task_queue"] = "high" if task_queue == "high" else "default"
self.redis.hset("task:details", task_key, json.dumps(task_info))
active_base_keys.add(task_key)
if task_queue == "high":
self.redis.lpush("task:queue:high", task_key)
high_count += 1
else:
self.redis.lpush("task:queue:default", task_key)
default_count += 1
# 加载低优先级模型流量任务(使用 _model 后缀 task_key 避免与采集任务冲突)
model_entries = self.analytics.list_model_eligible_apps()
low_count = 0
for entry in model_entries:
payload = entry.get("task_payload") or {}
package_name = str(payload.get("package_name") or entry.get("package_name") or "").strip()
app_name = str(payload.get("app_name") or entry.get("app_name") or "").strip()
if not package_name or not app_name:
continue
base_task_key = _make_task_key(app_name, package_name)
if base_task_key in active_base_keys:
continue
task_key = f"{base_task_key}_model"
task_info = self._build_task_info_from_payload(payload)
task_info["keep_app_installed"] = True
task_info["task_queue"] = "low"
task_info["package_name"] = package_name
task_info["app_name"] = app_name
self.redis.hset("task:details", task_key, json.dumps(task_info))
self.redis.lpush("task:queue:low", task_key)
low_count += 1
logger.info(
f"[加载完成] 高优 {high_count} | 默认 {default_count} | 低优(模型) {low_count}"
)
return high_count + default_count + low_count
def refresh_tasks_from_app_summary(self) -> int:
"""增量刷新:从应用全表加载新增的 pending/eligible 任务到三个优先级队列。"""
entries = self.analytics.list_pending_collection_tasks()
added_count = 0
active_base_keys: set = set()
for entry in entries:
payload = entry.get("task_payload") or {}
package_name = str(payload.get("package_name") or entry.get("package_name") or "").strip()
app_name = str(payload.get("app_name") or entry.get("app_name") or "").strip()
if not package_name or not app_name:
continue
task_key = _make_task_key(app_name, package_name)
active_base_keys.add(task_key)
if self._is_task_queued(task_key):
continue
if self._is_task_running(task_key):
continue
task_info = self._build_task_info_from_payload(payload)
unmanaged_targets = [
target_worker_id for target_worker_id in task_info.get('target_worker_ids', [])
if not self._is_managed_worker_id(target_worker_id)
]
if unmanaged_targets:
logger.warning(
f"[定向配置] 任务 {task_key} 配置了未纳入名单的目标Worker: {unmanaged_targets}"
)
task_queue = self._normalize_task_queue_label(entry.get("task_queue"), "default")
task_info["task_queue"] = "high" if task_queue == "high" else "default"
self.redis.hset("task:details", task_key, json.dumps(task_info))
target_queue = "task:queue:high" if task_queue == "high" else "task:queue:default"
self._remove_task_from_queue(task_key)
self._mark_task_pending_for_queue(task_key)
self.redis.lpush(target_queue, task_key)
added_count += 1
model_entries = self.analytics.list_model_eligible_apps()
for entry in model_entries:
payload = entry.get("task_payload") or {}
package_name = str(payload.get("package_name") or entry.get("package_name") or "").strip()
app_name = str(payload.get("app_name") or entry.get("app_name") or "").strip()
if not package_name or not app_name:
continue
base_task_key = _make_task_key(app_name, package_name)
if base_task_key in active_base_keys:
continue
task_key = f"{base_task_key}_model"
if self._is_task_queued(task_key):
continue
if self._is_task_running(task_key):
continue
task_info = self._build_task_info_from_payload(payload)
task_info["keep_app_installed"] = True
task_info["task_queue"] = "low"
task_info["package_name"] = package_name
task_info["app_name"] = app_name
self.redis.hset("task:details", task_key, json.dumps(task_info))
self._remove_task_from_queue(task_key)
self._mark_task_pending_for_queue(task_key)
self.redis.lpush("task:queue:low", task_key)
added_count += 1
if added_count > 0:
logger.info(f"[增量刷新] 从应用全表新增任务 {added_count}")
return added_count
def _clear_block_tasks(self) -> int:
cleared = 0
all_details = dict(self.redis.hgetall("task:details") or {})
for task_key in all_details:
if not task_key.endswith("_block"):
continue
if self._is_task_running(task_key):
continue
self._remove_task_from_queue(task_key)
self.redis.hdel("task:details", task_key)
self.redis.hdel("task:status", task_key)
self.redis.srem("task:completed", task_key)
self.redis.srem("task:failed", task_key)
cleared += 1
return cleared
def load_block_tasks(self, packages: List[Dict[str, Any]]) -> int:
cleared = self._clear_block_tasks()
if cleared:
logger.info(f"[Block测试] 已清除 {cleared} 个旧的 block 任务")
count = 0
for entry in packages:
payload = entry.get("task_payload") or {}
package_name = str(payload.get("package_name") or entry.get("package_name") or "").strip()
app_name = str(payload.get("app_name") or entry.get("app_name") or "").strip()
if not package_name or not app_name:
continue
base_task_key = _make_task_key(app_name, package_name)
task_key = f"{base_task_key}_block"
if self._is_task_running(task_key):
continue
task_info = self._build_task_info_from_payload(payload)
task_info["is_block_task"] = True
task_info["task_queue"] = "high"
self.redis.hset("task:details", task_key, json.dumps(task_info))
self._remove_task_from_queue(task_key)
self._mark_task_pending_for_queue(task_key)
self.redis.lpush("task:queue:high", task_key)
count += 1
logger.info(f"[Block测试] 已加载 {count} 个 block 任务到高优队列")
return count
def load_all_qualified_block_tasks(self) -> int:
entries = self.analytics.repo.list_qualified_apps()
return self.load_block_tasks(entries)
def worker_init(self, worker_id, ip_address, mac_address, hostname, platform, device_type=''):
"""Worker初始化注册Worker并领取第一个任务
Args:
worker_id: Worker标识符IP_MAC
ip_address: IP地址
mac_address: MAC地址
hostname: 主机名
platform: 平台
Returns:
dict: 包含第一个任务信息或None无任务
"""
raw_worker_id = str(worker_id or "").strip()
worker_id = normalize_worker_id(worker_id, ip_address)
self._migrate_worker_runtime_id(raw_worker_id, worker_id)
existing_worker = self.redis.hget("workers:info", worker_id)
previous_task = None
meta = self._get_worker_meta(worker_id)
if existing_worker:
# 获取之前分配的任务
previous_task_key = self.redis.hget("worker:tasks", worker_id)
if previous_task_key:
logger.warning(f"[重复初始化] Worker {worker_id} 再次初始化,将重新分配之前的任务: {previous_task_key}")
# 获取任务详情
task_details_json = self.redis.hget("task:details", previous_task_key)
if task_details_json:
task_details = json.loads(task_details_json)
previous_task = self._task_to_payload(previous_task_key, task_details)
else:
logger.warning(f"[重复初始化] Worker {worker_id} 再次初始化,之前无分配任务")
# 注册/更新Worker信息
worker_info = {
'worker_id': worker_id,
'ip_address': ip_address,
'mac_address': mac_address,
'hostname': hostname,
'platform': platform,
'device_type': self._normalize_device_type(device_type),
'register_time': time.time(),
'last_update': time.time(),
'status': 'idle',
'is_managed': self._is_managed_worker_id(worker_id),
'dispatch_enabled': self._worker_can_dispatch(worker_id),
}
self.redis.hset("workers:info", worker_id, json.dumps(worker_info))
self.redis.sadd("workers:idle", worker_id)
self.monitor.register_worker(worker_id, event_time=worker_info['last_update'])
if (
meta.get('auto_recover_used')
and self._is_worker_recovery_action(meta.get('last_action', ''))
and meta.get('last_action_status') == 'success'
and self.redis.exists(self._worker_recovery_pending_key(worker_id))
):
self.redis.delete(self._worker_recovery_pending_key(worker_id))
self.redis.setex(self._worker_recovery_observe_key(worker_id), WORKER_INIT_MONITOR_WINDOW, str(time.time()))
# 如果有之前的任务,直接返回该任务
if previous_task:
self._update_worker_status(worker_id, 'busy', previous_task['task_key'])
self.monitor.handle_task_assigned(worker_id, previous_task['task_key'])
logger.info(f"[初始化] Worker {worker_id} 重新领取之前的任务: {previous_task['app_name']}")
return previous_task
if not worker_info['dispatch_enabled']:
logger.info(f"[初始化] Worker {worker_id} 已注册,但当前未启用分发")
return None
# 否则领取新任务
task = self._assign_task(worker_id)
if task:
logger.info(f"[初始化] Worker {worker_id} 注册成功,领取任务: {task['app_name']}")
else:
logger.info(f"[初始化] Worker {worker_id} 注册成功,暂无任务")
return task
def worker_report(self, worker_id, previous_task_key, report_data: Optional[Dict[str, Any]] = None, **legacy_kwargs):
"""上报前一个任务的完成状态,并获取下一个任务
Args:
worker_id: Worker标识符
previous_task_key: 前一个任务的键应用名称_包名
report_data: 上报数据字典,结构如下:
{
"status": "success|failed|stop", # 必填
"error": { # 失败时必填
"category": "INFRA_ERROR|DOWNLOAD_ERROR|APP_ERROR|BUSINESS_ERROR",
"code": 1,
"reason": "错误原因描述",
"details": "详细错误信息(可选)",
"crashed_source": "google_play" # APP_CRASH时必填闪退的下载源
},
"metrics": { # 可选
"login_count": 0,
"register_count": 0,
"stuck_reason_code": 0,
"guiagent_message": "GuiAgent消息",
"scenario_triggered": true
},
"statistics": { # 可选
"round": 1,
"exit_code": 0,
"error_reason": "...",
"droidbot_steps": 100,
"guiagent_steps": 50,
"total_steps": 150,
"duration_seconds": 120.5,
"is_retry": false,
"download_source": "google"
}
}
Returns:
dict: 包含下一个任务信息或None无任务/stop状态/无效Worker
"""
VALID_STATUS = ['success', 'failed', 'stop']
raw_worker_id = str(worker_id or "").strip()
worker_id = normalize_worker_id(worker_id)
self._migrate_worker_runtime_id(raw_worker_id, worker_id)
if not isinstance(report_data, dict):
report_data = {
'status': legacy_kwargs.get('status', 'failed'),
'error': {},
'metrics': {},
'statistics': {},
}
message = legacy_kwargs.get('message')
error_type = legacy_kwargs.get('error_type')
if message:
report_data['error']['reason'] = message
report_data['error']['details'] = message
if error_type:
category, _, code_text = str(error_type).partition('/')
report_data['error']['category'] = category or 'INFRA_ERROR'
try:
report_data['error']['code'] = int(code_text)
except (TypeError, ValueError):
report_data['error']['code'] = 0
if not report_data['error']:
report_data.pop('error')
status = report_data.get('status', 'failed')
error_data = report_data.get('error', {})
metrics = report_data.get('metrics', {})
statistics = report_data.get('statistics', {})
download_errors = report_data.get('download_errors', {})
error_info = ErrorInfo.from_report_dict(error_data)
crashed_source = error_data.get('crashed_source') if error_data else None
login_count = metrics.get('login_count', 0) if metrics else 0
register_count = metrics.get('register_count', 0) if metrics else 0
guiagent_message = metrics.get('guiagent_message', '') if metrics else ''
scenario_triggered = metrics.get('scenario_triggered', False) if metrics else False
message = error_info.reason
if download_errors:
source_msgs = [f"{k}: {v['message']}" for k, v in download_errors.items()]
message = f"下载失败: {', '.join(source_msgs)}"
elif guiagent_message:
if guiagent_message != error_info.reason:
message = guiagent_message
elif error_info.details and error_info.details != error_info.reason:
message = error_info.details
if status == 'success':
if login_count > 0 or register_count > 0:
message = f"成功 (登录{login_count}次, 注册{register_count}次)"
if not isinstance(status, str) or status not in VALID_STATUS:
logger.warning(f"Worker {worker_id} 上报了无效的status: {status} (类型: {type(status).__name__})")
logger.warning(f"预期status值: {VALID_STATUS}")
return None
worker_exists = self.redis.hget("workers:info", worker_id)
retry_count = 0
if previous_task_key:
previous_status_json = self.redis.hget("task:status", previous_task_key)
if previous_status_json:
previous_status = json.loads(previous_status_json)
retry_count = previous_status.get('retry_count', 0)
self.monitor.handle_worker_report(
worker_id=worker_id,
previous_task_key=previous_task_key,
report_data=report_data,
current_time=time.time(),
retry_count=retry_count,
resolved_message=message,
)
if not worker_exists:
logger.warning(f"[忽略] Worker {worker_id} 已不在Worker列表中返回错误码")
return {
'task_key': 'ERROR',
'app_name': 'ERROR',
'package_name': '-1',
'country_code': '',
'device_type': '',
'error_code': -1,
'error_message': 'Worker已从列表中移除'
}
if previous_task_key:
if status in ['stop', 'failed'] and message:
logger.warning(f"[告警] Worker {worker_id} [{error_info.category.name}/{error_info.code}]: {message}")
if status in ['stop', 'failed']:
self._record_failed_task(
previous_task_key,
worker_id,
error_info,
message,
statistics,
crashed_source,
download_errors=download_errors,
)
if self.redis.hget("worker:tasks", worker_id) == previous_task_key:
self.redis.hdel("worker:tasks", worker_id)
self._update_worker_status(worker_id, 'idle', None)
self.monitor.set_worker_state(worker_id, 'idle_waiting_task', reason='waiting_task')
elif status == 'success':
self._complete_task(previous_task_key, worker_id, status, message, statistics)
task_details_json = self.redis.hget("task:details", previous_task_key)
package_name = ""
if task_details_json:
package_name = json.loads(task_details_json).get("package_name", "")
if package_name:
self.analytics.enqueue_incremental(
package_name=package_name,
task_key=previous_task_key,
worker_id=worker_id,
report_time=time.time(),
trigger_source="worker_report",
waiting_for_artifacts=True,
)
# stop状态清除Worker并不再分配任务
if status == 'stop':
self.monitor.mark_worker_offline(worker_id, "worker_stopped")
self._remove_worker(worker_id)
logger.info(f"[停止] Worker {worker_id} 请求停止已从Worker列表移除")
return None
# 获取下一个任务
next_task = self._assign_task(worker_id)
if next_task:
logger.info(f"[上报] Worker {worker_id} 上报任务 {previous_task_key}={status},领取新任务: {next_task['app_name']}")
else:
self.monitor.set_worker_state(worker_id, 'idle_waiting_task', reason='waiting_task')
logger.info(f"[上报] Worker {worker_id} 上报任务 {previous_task_key}={status},暂无新任务")
return next_task
def get_analytics_overview(
self,
*,
incremental_batch_tag: str = "",
top_n: int = 3000,
) -> Dict[str, Any]:
return self.analytics.get_overview(
incremental_batch_tag=incremental_batch_tag,
top_n=top_n,
)
def get_analytics_apps(
self,
*,
q: str = "",
latest_status: str = "",
artifact_status: str = "",
collection_status: str = "",
restriction_status: str = "",
retryability: str = "",
incremental_batch_tag: str = "",
top_n: int = 3000,
sort: str = "updated_at",
order: str = "desc",
page: int = 1,
page_size: int = 50,
) -> Dict[str, Any]:
return self.analytics.list_apps(
q=q,
latest_status=latest_status,
artifact_status=artifact_status,
collection_status=collection_status,
restriction_status=restriction_status,
retryability=retryability,
incremental_batch_tag=incremental_batch_tag,
top_n=top_n,
sort=sort,
order=order,
page=page,
page_size=page_size,
)
def get_analytics_app_detail(self, package_name: str) -> Optional[Dict[str, Any]]:
return self.analytics.get_app_detail(package_name)
def get_analytics_jobs(
self,
*,
job_type: str = "",
status: str = "",
package_name: str = "",
limit: int = 50,
) -> List[Dict[str, Any]]:
return self.analytics.list_jobs(
job_type=job_type or None,
status=status or None,
package_name=package_name or None,
limit=limit,
)
def enqueue_analytics_backfill(self, packages: Optional[List[str]] = None) -> Dict[str, Any]:
return self.analytics.enqueue_backfill(packages=packages)
def enqueue_analytics_rebuild(self, package_name: str) -> Dict[str, Any]:
return self.analytics.enqueue_manual_rebuild(package_name)
def handle_analytics_worker_event(self, payload: Dict[str, Any]) -> int:
return self.analytics.handle_worker_event(payload)
def close(self):
try:
self.analytics.close()
except Exception:
logger.exception("Failed to close analytics service")
def worker_retry(self, worker_id, current_task_key):
"""重试:告知该任务需重新计时
Args:
worker_id: Worker标识符
current_task_key: 当前任务的键应用名称_包名
Returns:
bool: 是否成功更新
"""
raw_worker_id = str(worker_id or "").strip()
worker_id = normalize_worker_id(worker_id)
self._migrate_worker_runtime_id(raw_worker_id, worker_id)
# 检查Worker是否已被清除
worker_exists = self.redis.hget("workers:info", worker_id)
if not worker_exists:
logger.warning(f"[忽略] Worker {worker_id} 已不在Worker列表中忽略其retry")
return False
if not current_task_key:
return False
try:
# 获取任务状态
status_json = self.redis.hget("task:status", current_task_key)
if not status_json:
return False
status = json.loads(status_json)
if status['status'] != 'running' or status.get('worker_id') != worker_id:
return False
current_time = time.time()
# 更新重试时间保留原始start_time用于超时计算时以重试时间为准
status['last_retry'] = current_time
self.redis.hset("task:status", current_task_key, json.dumps(status))
# 更新Worker最后更新时间
worker_info_json = self.redis.hget("workers:info", worker_id)
if worker_info_json:
worker_info = json.loads(worker_info_json)
worker_info['last_update'] = current_time
self.redis.hset("workers:info", worker_id, json.dumps(worker_info))
logger.info(f"[重试] Worker {worker_id} 任务 {current_task_key} 重新计时成功")
return True
except Exception as e:
logger.error(f"重试更新失败: {e}")
return False
def _assign_task(self, worker_id) -> Optional[Dict[str, Any]]:
"""分配任务给Worker内部方法按优先级高优 → 默认 → 低优。"""
lock_key = "lock:assign:queue"
lock = self.redis.lock(lock_key, timeout=5, blocking_timeout=1)
try:
with lock:
current_task = self.redis.hget("worker:tasks", worker_id)
if current_task:
return None
if not self._worker_can_dispatch(worker_id):
return None
worker_info = self._get_worker_info(worker_id)
task_key = None
task_details = None
skipped: List[Tuple[str, str]] = []
for queue_name in self.TASK_QUEUE_NAMES:
queue_length = self.redis.llen(queue_name)
for _ in range(queue_length):
candidate_task_key = self.redis.rpop(queue_name)
if not candidate_task_key:
break
task_details_json = self.redis.hget("task:details", candidate_task_key)
if not task_details_json:
logger.warning(f"[任务详情缺失] 任务 {candidate_task_key} 缺少详情,已跳过")
continue
candidate_task_details = json.loads(task_details_json)
if self._task_matches_worker(candidate_task_details, worker_id, worker_info=worker_info):
task_key = candidate_task_key
task_details = candidate_task_details
break
skipped.append((candidate_task_key, queue_name))
if task_key:
break
if skipped:
self._restore_unmatched_tasks(skipped)
if not task_key or not task_details:
return None
current_time = time.time()
if task_details.get('excluded_worker_ids'):
task_details = dict(task_details)
task_details.pop('excluded_worker_ids', None)
self.redis.hset("task:details", task_key, json.dumps(task_details))
# 获取之前的重试次数
prev_status_json = self.redis.hget("task:status", task_key)
retry_count = 0
if prev_status_json:
prev_status = json.loads(prev_status_json)
retry_count = prev_status.get('retry_count', 0)
# 更新任务状态
self.redis.hset("task:status", task_key, json.dumps({
'status': 'running',
'worker_id': worker_id,
'start_time': current_time,
'last_retry': current_time,
'retry_count': retry_count
}))
# 更新Worker任务映射
self.redis.hset("worker:tasks", worker_id, task_key)
# 更新Worker状态
self._update_worker_status(worker_id, 'busy', task_key)
self.monitor.handle_task_assigned(worker_id, task_key, event_time=current_time)
# 发布任务分发事件
self.redis.publish(f"task:assign:{worker_id}", json.dumps({
'task_key': task_key,
'task_details': task_details
}))
logger.info(
f"[分配] 任务 {task_key} -> Worker {worker_id} "
f"(routing={task_details.get('routing_mode', 'public')}, "
f"device_type={task_details.get('device_type', '')}, "
f"targets={task_details.get('target_worker_ids', [])})"
)
return self._task_to_payload(task_key, task_details)
except redis.exceptions.LockNotOwnedError:
# 锁已过期或被其他进程获取
return None
except redis.exceptions.LockError:
# 无法获取锁
return None
except Exception as e:
logger.error(f"分配任务到 Worker {worker_id} 时出错: {e}")
return None
def _update_worker_status(self, worker_id: str, status: str, current_task: Optional[str] = None):
"""更新Worker状态的辅助方法"""
current_time = time.time()
if status == 'busy':
self.redis.sadd("workers:busy", worker_id)
self.redis.srem("workers:idle", worker_id)
else: # idle
self.redis.sadd("workers:idle", worker_id)
self.redis.srem("workers:busy", worker_id)
worker_info_json = self.redis.hget("workers:info", worker_id)
if worker_info_json:
worker_info = json.loads(worker_info_json)
worker_info['status'] = status
worker_info['current_task'] = current_task
worker_info['last_update'] = current_time
self.redis.hset("workers:info", worker_id, json.dumps(worker_info))
def _remove_worker(self, worker_id: str):
"""从Worker列表中移除Worker内部方法
Args:
worker_id: Worker标识符
"""
# 从idle/busy集合中移除
self.redis.srem("workers:idle", worker_id)
self.redis.srem("workers:busy", worker_id)
# 删除Worker信息
self.redis.hdel("workers:info", worker_id)
# 清除Worker的任务映射
self.redis.hdel("worker:tasks", worker_id)
def _complete_task(self, task_key, worker_id, status, message='', statistics=None):
"""任务完成(内部方法)"""
current_time = time.time()
prev_status_json = self.redis.hget("task:status", task_key)
retry_count = 0
if prev_status_json:
prev_status = json.loads(prev_status_json)
retry_count = prev_status.get('retry_count', 0)
task_status = {
'status': status,
'worker_id': worker_id,
'end_time': current_time,
'retry_count': retry_count
}
if statistics:
task_status['statistics'] = statistics
self.redis.hset("task:status", task_key, json.dumps(task_status))
if self.redis.hget("worker:tasks", worker_id) == task_key:
self.redis.hdel("worker:tasks", worker_id)
self._update_worker_status(worker_id, 'idle', None)
self.monitor.set_worker_state(worker_id, 'idle_waiting_task', reason='waiting_task')
if status == 'success':
self.redis.sadd("task:completed", task_key)
else:
new_retry_count = retry_count + 1
task_details_json = self.redis.hget("task:details", task_key)
task_details = json.loads(task_details_json) if task_details_json else {}
retry_task_details = self._mark_retry_avoid_worker(task_key, task_details, worker_id)
self.redis.hset("task:status", task_key, json.dumps({
'status': 'pending_retry',
'worker_id': worker_id,
'end_time': current_time,
'retry_count': new_retry_count
}))
self.redis.srem("task:failed", task_key)
self._queue_retry_task(
task_key,
retry_task_details,
prioritize=self._is_hot_new_app_task(retry_task_details),
)
logger.info(f"[重试] 任务 {task_key} 失败,已重新入队 (第{new_retry_count}次失败)")
self.redis.publish("task:completed", json.dumps({
'task_key': task_key,
'worker_id': worker_id,
'status': status,
'retry_count': retry_count,
'statistics': statistics
}))
def get_statistics(self):
"""获取统计信息
Returns:
dict: 包含各项统计数据
- pending: 待分发任务数(含重试中任务)
- running: 运行中任务数
- completed: 成功任务数
- failed: 永久失败任务数
"""
all_status = self.redis.hgetall("task:status")
running_count = 0
pending_entries = []
worker_lookup = self._get_dashboard_worker_lookup()
queue_task_keys = self._get_queued_task_keys()
for task_key, status_json in all_status.items():
status = json.loads(status_json)
if status.get('status') == 'running':
running_count += 1
for task_key in queue_task_keys:
pending_entries.append(self._build_pending_task_entry(task_key, worker_lookup))
return {
'pending': len(queue_task_keys),
'running': running_count,
'completed': self.redis.scard("task:completed"),
'failed': self.redis.scard("task:failed"),
'pending_targeted': sum(1 for item in pending_entries if item['target_worker_ids']),
'waiting_target_busy': sum(1 for item in pending_entries if item['routing_state'] == 'waiting_target_busy'),
'waiting_target_offline': sum(1 for item in pending_entries if item['routing_state'] == 'waiting_target_offline'),
}
def get_registered_workers(self):
"""获取所有已注册的Worker以 worker_inventory.json 为准)"""
workers_info = self.redis.hgetall("workers:info")
workers = []
for worker_id, inventory_data in self.worker_inventory.items():
info_json = workers_info.get(worker_id)
if info_json:
info = json.loads(info_json)
else:
info = {
"worker_id": worker_id,
"ip_address": inventory_data.get("ssh_target", ""),
"hostname": "",
"platform": "",
"device_type": "",
"status": "offline",
"register_time": 0,
"last_update": 0,
"current_task": None
}
info.setdefault('dispatch_enabled', self._worker_can_dispatch(worker_id))
info.setdefault('is_managed', True)
workers.append(info)
return workers
def get_idle_workers(self):
"""获取空闲的Worker列表"""
idle_workers = self.redis.smembers("workers:idle")
workers_info = self.redis.hgetall("workers:info")
idle_worker_list = []
for worker_id in idle_workers:
if worker_id in self.worker_inventory and worker_id in workers_info:
info = json.loads(workers_info[worker_id])
idle_worker_list.append(info)
return idle_worker_list
def get_busy_workers(self):
"""获取忙碌的Worker列表"""
busy_workers = self.redis.smembers("workers:busy")
workers_info = self.redis.hgetall("workers:info")
busy_worker_list = []
for worker_id in busy_workers:
if worker_id in self.worker_inventory and worker_id in workers_info:
info = json.loads(workers_info[worker_id])
busy_worker_list.append(info)
return busy_worker_list
def get_dashboard_workers(self) -> List[Dict[str, Any]]:
current_time = time.time()
registered_by_id: Dict[str, Dict[str, Any]] = {}
extra_workers: List[Dict[str, Any]] = []
for worker in self.get_registered_workers():
worker_copy = dict(worker)
worker_copy['online'] = self._get_worker_online_state(worker_copy, current_time)
worker_copy['dispatch_enabled'] = self._worker_can_dispatch(worker_copy.get('worker_id', ''))
worker_copy['is_managed'] = self._is_managed_worker_id(worker_copy.get('worker_id', ''))
worker_copy['state'] = worker_copy.get('state', 'idle_waiting_task' if worker_copy['online'] else 'offline')
registered_by_id[worker_copy.get('worker_id', '')] = worker_copy
if not self.managed_worker_ids:
return sorted(
registered_by_id.values(),
key=lambda item: (item.get('ip_address', ''), item.get('worker_id', '')),
)
managed_workers: List[Dict[str, Any]] = []
for managed_worker_id in sorted(self.managed_worker_ids):
worker = registered_by_id.get(managed_worker_id, {})
meta = self._get_worker_meta(managed_worker_id)
inventory = self.worker_inventory.get(managed_worker_id, {})
managed_workers.append({
'worker_id': managed_worker_id,
'ip_address': worker.get('ip_address', self._get_worker_ip(managed_worker_id)),
'hostname': worker.get('hostname', ''),
'platform': worker.get('platform', ''),
'device_type': self._normalize_device_type(worker.get('device_type', '')),
'current_task': worker.get('current_task'),
'status': worker.get('status', 'offline') if worker else 'offline',
'state': worker.get('state', 'idle_waiting_task') if worker else 'offline',
'state_since': worker.get('state_since'),
'pause_reason': worker.get('pause_reason'),
'register_time': worker.get('register_time'),
'last_update': worker.get('last_update'),
'online': self._get_worker_online_state(worker, current_time) if worker else False,
'is_managed': True,
'dispatch_enabled': self._worker_can_dispatch(managed_worker_id),
'policy': meta.get('policy', 'enabled'),
'last_action': meta.get('last_action', ''),
'last_action_status': meta.get('last_action_status', ''),
'last_action_message': meta.get('last_action_message', ''),
'last_action_at': meta.get('last_action_at', 0.0),
'tags': inventory.get('tags', []),
'ssh_target': inventory.get('ssh_target', ''),
})
return managed_workers
def get_dashboard_tasks(
self,
pending_offset: int = 0,
pending_limit: int = 50,
recent_limit: int = 20,
) -> Dict[str, Any]:
worker_lookup = self._get_dashboard_worker_lookup()
running_tasks = []
failed_tasks = []
pending_tasks = []
all_status = self.redis.hgetall("task:status")
for task_key, status_json in all_status.items():
status = json.loads(status_json)
task_details_json = self.redis.hget("task:details", task_key)
task_details = json.loads(task_details_json) if task_details_json else {}
task_entry = {
'task_key': task_key,
'app_name': task_details.get('app_name', ''),
'package_name': task_details.get('package_name', ''),
'worker_id': status.get('worker_id'),
'status': status.get('status', 'unknown'),
'retry_count': status.get('retry_count', 0),
'device_type': self._normalize_task_device_type(task_details.get('device_type', '')),
'target_worker_ids': task_details.get('target_worker_ids', []),
'routing_mode': task_details.get('routing_mode', 'public'),
'last_fail_type': status.get('last_fail_type'),
'last_fail_message': status.get('last_fail_message'),
'start_time': status.get('start_time'),
'end_time': status.get('end_time'),
'last_fail_time': status.get('last_fail_time'),
}
if status.get('status') == 'running':
running_tasks.append(task_entry)
elif status.get('status') == 'failed':
failed_tasks.append(task_entry)
queue_task_keys = self._get_queued_task_keys()
pending_total = len(queue_task_keys)
safe_offset = max(pending_offset, 0)
safe_limit = max(pending_limit, 1)
pending_window = queue_task_keys[safe_offset:safe_offset + safe_limit]
for task_key in pending_window:
pending_tasks.append(self._build_pending_task_entry(task_key, worker_lookup))
running_tasks.sort(key=lambda item: item.get('start_time') or 0, reverse=True)
failed_tasks.sort(key=lambda item: item.get('last_fail_time') or 0, reverse=True)
return {
'running': running_tasks[:recent_limit],
'pending': pending_tasks,
'failed': failed_tasks[:recent_limit],
'pending_total': pending_total,
'pending_offset': safe_offset,
'pending_limit': safe_limit,
'pending_window_end': min(safe_offset + safe_limit, pending_total),
}
def get_dashboard_summary(self) -> Dict[str, Any]:
stats = self.get_statistics()
workers = self.get_dashboard_workers()
total_tasks = stats['pending'] + stats['running'] + stats['completed'] + stats['failed']
managed_workers = [worker for worker in workers if worker.get('is_managed')]
online_workers = [worker for worker in managed_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']
return {
**stats,
'managed_workers_total': len(managed_workers),
'managed_workers_online': len(online_workers),
'managed_workers_idle': len(idle_workers),
'managed_workers_busy': len(busy_workers),
'completion_rate': round((stats['completed'] / total_tasks) * 100, 2) if total_tasks else 0.0,
'generated_at': datetime.now().strftime('%Y-%m-%d %H:%M:%S'),
}
def get_dashboard_snapshot(
self,
pending_offset: int = 0,
pending_limit: int = 50,
recent_limit: int = 20,
) -> Dict[str, Any]:
return {
'summary': self.get_dashboard_summary(),
'workers': self.get_dashboard_workers(),
'tasks': self.get_dashboard_tasks(
pending_offset=pending_offset,
pending_limit=pending_limit,
recent_limit=recent_limit,
),
}
def get_monitoring_overview(self, date_str: Optional[str] = None, worker_id: Optional[str] = None) -> Dict[str, Any]:
return self.monitor.get_overview(date_str=date_str, dispatcher=self, worker_id=worker_id)
def get_monitoring_timeline(
self,
date_str: Optional[str] = None,
bucket_minutes: int = 15,
worker_id: Optional[str] = None,
) -> Dict[str, Any]:
return self.monitor.get_timeline(
date_str=date_str,
dispatcher=self,
bucket_minutes=bucket_minutes,
worker_id=worker_id,
)
def get_monitoring_distributions(self, date_str: Optional[str] = None, worker_id: Optional[str] = None) -> Dict[str, Any]:
return self.monitor.get_distributions(date_str=date_str, worker_id=worker_id, dispatcher=self)
def get_monitoring_workers(self, date_str: Optional[str] = None) -> List[Dict[str, Any]]:
return self.monitor.get_worker_stats(date_str=date_str, dispatcher=self)
def get_monitoring_tasks(
self,
date_str: Optional[str] = None,
worker_id: Optional[str] = None,
limit: int = 200,
) -> List[Dict[str, Any]]:
return self.monitor.get_task_rows(date_str=date_str, worker_id=worker_id, limit=limit)
def reset_monitoring_dashboard(self) -> Dict[str, Any]:
return self.monitor.reset_monitoring_data(worker_snapshots=self.get_dashboard_workers())
def sync_worker_registry(self):
"""启动时同步:以 worker_inventory.json 为准清理 Redis 中的残留 Worker 数据"""
valid_ids = set(self.worker_inventory.keys())
# 清理 workers:info 中不在 inventory 的历史 worker
all_info_ids = set(self.redis.hgetall("workers:info").keys())
stale_info_ids = all_info_ids - valid_ids
for stale_id in stale_info_ids:
self.redis.hdel("workers:info", stale_id)
# 清理 idle/busy 集合中不在 inventory 的条目
for set_key in ("workers:idle", "workers:busy"):
for member in self.redis.smembers(set_key):
if member not in valid_ids:
self.redis.srem(set_key, member)
# 清理 worker:tasks 映射中不在 inventory 的条目
all_task_mappings = self.redis.hgetall("worker:tasks")
for wid in list(all_task_mappings.keys()):
if wid not in valid_ids:
self.redis.hdel("worker:tasks", wid)
cleaned = len(stale_info_ids)
if cleaned:
logger.info(f"[启动同步] 已清理 {cleaned} 个不在 inventory 中的历史 Worker: {sorted(stale_info_ids)}")
else:
logger.info("[启动同步] Worker 注册表与 inventory 一致,无需清理")
def cleanup_stale_workers(self, timeout=7200):
"""清理超时的Worker
Args:
timeout: Worker超时时间(秒)默认7200秒(120分钟)
"""
current_time = time.time()
workers_info = self.redis.hgetall("workers:info")
cleaned_count = 0
for worker_id, info_json in workers_info.items():
info = json.loads(info_json)
last_update = info.get('last_update', info.get('register_time', 0))
if current_time - last_update > timeout:
# Worker超时清理
self.monitor.mark_worker_offline(worker_id, "heartbeat_timeout")
self.redis.srem("workers:idle", worker_id)
self.redis.srem("workers:busy", worker_id)
self.redis.hdel("workers:info", worker_id)
# 获取Worker的任务并标记为失败
current_task = self.redis.hget("worker:tasks", worker_id)
if current_task:
# 记录任务失败并更新运行态/分析态
worker_timeout_error = ErrorInfo.infra(InfraError.WORKER_TIMEOUT, "Worker heartbeat timeout")
self._record_failed_task(current_task, worker_id, worker_timeout_error, "Worker heartbeat timeout")
self.redis.hdel("worker:tasks", worker_id)
cleaned_count += 1
logger.info(f"[清理] Worker {worker_id} 超时,已清理")
return cleaned_count
def _handle_infra_error_recovery_alert(self, task_key: str, worker_id: str, error_type_str: str, message: str) -> bool:
alert_key = f"alert:{worker_id}:infra_errors"
pending_key = self._worker_recovery_pending_key(worker_id)
observe_key = self._worker_recovery_observe_key(worker_id)
current_time = time.time()
pipe = self.redis.pipeline()
pipe.zremrangebyscore(alert_key, 0, current_time - ALERT_WINDOW_SECONDS)
pipe.zadd(alert_key, {str(current_time): current_time})
pipe.zcard(alert_key)
pipe.expire(alert_key, ALERT_WINDOW_SECONDS + 60)
_, _, count, _ = pipe.execute()
if count < ALERT_THRESHOLD:
return False
self.redis.delete(alert_key)
self.redis.delete(observe_key)
self.redis.delete(pending_key)
logger.warning(
f"[Worker基础设施错误高频告警] Worker {worker_id} 高频基础设施错误,"
f"{count}次/{ALERT_WINDOW_SECONDS//60}分钟,自动重启已关闭,请手动处理"
)
self.notifier.send_weCom_alert(
worker_id,
f"【Worker基础设施错误高频告警】\nWorker: {worker_id}\n任务: {task_key}\n"
f"频率: {count}次/{ALERT_WINDOW_SECONDS//60}分钟\n最新错误: {error_type_str}\n消息: {message}\n"
f"自动重启已关闭,请手动处理"
)
return True
def _dispatch_alert(self, task_key, worker_id, error_info: ErrorInfo, message, retry_count, download_errors=None):
"""根据错误类型分发告警逻辑
Args:
task_key: 任务键
worker_id: Worker ID
error_info: 错误信息对象
message: 错误消息
retry_count: 当前重试次数
"""
error_type_str = f"{error_info.category.name}/{error_info.code}"
infra_recovery_triggered = False
if error_info.category == ErrorCategory.INFRA_ERROR:
infra_recovery_triggered = self._handle_infra_error_recovery_alert(
task_key,
worker_id,
error_type_str,
message,
)
if error_info.category == ErrorCategory.INFRA_ERROR and error_info.code == InfraError.WORKER_TIMEOUT:
logger.error(f"[告警推送] Worker超时: {worker_id}")
if not infra_recovery_triggered:
self.notifier.send_weCom_alert(
worker_id,
f"【Worker超时】\nWorker: {worker_id}\n任务: {task_key}\n消息: {message}"
)
return
if error_info.is_no_retry(download_errors=download_errors):
logger.info(f"[直接失败] 任务 {task_key} 不可重试错误: {error_type_str}, Worker: {worker_id}")
return
alert_key = f"alert:{worker_id}:all_errors"
observe_key = self._worker_recovery_observe_key(worker_id)
pending_key = self._worker_recovery_pending_key(worker_id)
current_time = time.time()
pipe = self.redis.pipeline()
pipe.zremrangebyscore(alert_key, 0, current_time - ALERT_WINDOW_SECONDS)
pipe.zadd(alert_key, {str(current_time): current_time})
pipe.zcard(alert_key)
pipe.expire(alert_key, ALERT_WINDOW_SECONDS + 60)
_, _, count, _ = pipe.execute()
if count < ALERT_THRESHOLD or infra_recovery_triggered:
return
meta = self._get_worker_meta(worker_id)
recovery_in_effect = bool(meta.get('auto_recover_used')) and (
self.redis.exists(pending_key) or self.redis.exists(observe_key)
)
self.redis.delete(alert_key)
if recovery_in_effect:
self.redis.delete(observe_key)
self.redis.delete(pending_key)
self._update_worker_meta(worker_id, auto_recover_used=False)
logger.warning(
f"[Worker高频错误告警] Worker {worker_id} 自动恢复后再次高频错误,"
f"自动重启已关闭,请手动处理"
)
self.notifier.send_weCom_alert(
worker_id,
f"【Worker高频错误告警】\nWorker: {worker_id}\n频率: {count}次/{ALERT_WINDOW_SECONDS//60}分钟\n最新错误: {error_type_str}\n消息: {message}\n自动恢复后再次触发连续错误,自动重启已关闭,请手动处理"
)
return
self.redis.delete(observe_key)
self.redis.delete(pending_key)
pipeline_result = self.run_dashboard_action([worker_id], 'run_pipeline')[0]
self._mark_worker_action(worker_id, 'run_pipeline', pipeline_result)
if pipeline_result.get('ok'):
self.redis.setex(pending_key, WORKER_REINIT_TIMEOUT, str(time.time()))
self._update_worker_meta(worker_id, auto_recover_used=True)
logger.warning(f"[Worker自动恢复] Worker {worker_id} 高频错误,已执行默认恢复 pipeline")
self.notifier.send_weCom_alert(
worker_id,
f"【Worker高频错误自动恢复】\nWorker: {worker_id}\n频率: {count}次/{ALERT_WINDOW_SECONDS//60}分钟\n最新错误: {error_type_str}\n消息: {message}\n已执行 dashboard 默认恢复链路"
)
return
self.redis.delete(pending_key)
self._update_worker_meta(worker_id, auto_recover_used=False)
logger.error(f"[Worker自动恢复失败] Worker {worker_id} 高频错误后执行默认恢复 pipeline 失败")
self.notifier.send_weCom_alert(
worker_id,
f"【Worker高频错误自动恢复失败】\nWorker: {worker_id}\n频率: {count}次/{ALERT_WINDOW_SECONDS//60}分钟\n最新错误: {error_type_str}\n消息: {message}\n恢复结果: {pipeline_result.get('message', '')}"
)
def _record_failed_task(self, task_key, worker_id, error_info: ErrorInfo, message='', statistics=None, crashed_source=None, download_errors=None):
"""记录失败任务,分流重试和最终失败
- APP_ERROR.CRASH / APP_ERROR.ROOT_MODE_UNSUPPORTED: 若当前任务不是 physical则自动改派到真机并重新入队
- APP_ERROR / BUSINESS_ERROR: 默认直接标记为永久失败
- 满足终态下载组合条件的 DOWNLOAD_ERRORGoogle Play 终态类错误 + local 任意失败)→ 直接标记为永久失败
- 可重试错误 → 等待应用画像决定是否重新入队
Args:
task_key: 任务键
worker_id: Worker标识符
error_info: 错误信息对象
message: 附加消息/告警信息
statistics: 统计数据字典
crashed_source: 闪退的下载源(已废弃,保留参数兼容)
"""
if error_info.category == ErrorCategory.SUCCESS and error_info.code == 0:
logger.warning(f"[异常上报] Worker {worker_id} 上报失败但错误码为 SUCCESS/0按成功处理: {task_key}")
self._complete_task(task_key, worker_id, 'success', message or 'worker reported failure with SUCCESS/0, treated as success')
return
is_no_retry_error = error_info.is_no_retry(download_errors=download_errors)
is_app_crash = (error_info.category == ErrorCategory.APP_ERROR and error_info.code == AppError.CRASH)
is_terminal_local_fallback_failed = (
error_info.category == ErrorCategory.DOWNLOAD_ERROR
and error_info.code in {
DownloadError.REGION_RESTRICTED,
DownloadError.APP_NOT_FOUND,
DownloadError.INCOMPATIBLE,
DownloadError.COUNTRY_LOCKED,
}
and is_no_retry_error
)
prev_status_json = self.redis.hget("task:status", task_key)
retry_count = 0
if prev_status_json:
prev_status = json.loads(prev_status_json)
retry_count = prev_status.get('retry_count', 0)
new_retry_count = retry_count + 1
task_details_json = self.redis.hget("task:details", task_key)
task_details = {}
app_name = ""
package_name = ""
if task_details_json:
task_details = json.loads(task_details_json)
app_name = task_details.get('app_name', '')
package_name = task_details.get('package_name', '')
app_magic_label = str(task_details.get('app_magic_label') or '').strip()
is_block_task = bool(task_details.get('is_block_task', False))
error_type_str = f"{error_info.category.name}/{error_info.code}"
task_status = {
'status': 'failed',
'worker_id': worker_id,
'retry_count': new_retry_count,
'last_fail_time': time.time(),
'last_fail_type': error_type_str,
'last_fail_message': message
}
if statistics:
task_status['statistics'] = statistics
is_download_error = (
error_info.category == ErrorCategory.DOWNLOAD_ERROR
or (error_info.category == ErrorCategory.INFRA_ERROR and error_info.code == InfraError.DOWNLOAD_FAILED)
)
is_local_install_failed = (
is_download_error
and message
and ("local: install failed" in message.lower()
or "local: Install" in message)
)
if is_local_install_failed and self._uses_central_apk_cache():
prev_status = json.loads(prev_status_json) if prev_status_json else {}
if prev_status.get("force_reapk_done"):
self._log_apk_install_failure(task_key, package_name, app_name, message)
logger.warning("[自动重下] %s 已重下过一次仍失败,不再重试: %s", task_key, message)
self.redis.hset("task:status", task_key, json.dumps(task_status))
self.monitor.set_failure_bucket(task_key, "failed")
self.redis.sadd("task:failed", task_key)
return
logger.info("[自动重下] %s 本地APK安装失败触发force_reapk后重试", task_key)
self.force_reapk(package_name)
self.redis.hset("task:status", task_key, json.dumps({
"status": "pending_retry",
"worker_id": worker_id,
"retry_count": new_retry_count,
"last_fail_time": time.time(),
"last_fail_type": error_type_str,
"last_fail_message": message,
"force_reapk_done": True,
}))
self.monitor.set_failure_bucket(task_key, "retry")
self.redis.srem("task:failed", task_key)
return
if is_block_task:
self.redis.hset("task:status", task_key, json.dumps(task_status))
if is_download_error:
last_updated = str(task_details.get("last_updated") or "").strip()
registry = self._get_apk_registry() if self._uses_central_apk_cache() else None
if registry and registry.is_fresh_enough(package_name, last_updated):
self.monitor.set_failure_bucket(task_key, "retry")
self.redis.srem("task:failed", task_key)
retry_task_details = self._mark_retry_avoid_worker(task_key, task_details, worker_id)
retry_status = dict(task_status)
retry_status["status"] = "pending"
self.redis.hset("task:status", task_key, json.dumps(retry_status))
self._queue_retry_task(task_key, retry_task_details, prioritize=True)
logger.info(f"[Block测试] 任务 {task_key} 下载失败({error_type_str})本地已有APK立即重试")
else:
self._mark_download_error_awaiting_apk(package_name,
app_name=app_name,
last_updated=last_updated)
self.monitor.set_failure_bucket(task_key, "retry")
self.redis.srem("task:failed", task_key)
retry_task_details = self._mark_retry_avoid_worker(task_key, task_details, worker_id)
retry_status = dict(task_status)
retry_status["status"] = "pending_retry"
self.redis.hset("task:status", task_key, json.dumps(retry_status))
if self._uses_central_apk_cache():
logger.info(f"[Block测试] 任务 {task_key} 下载失败({error_type_str}),等待中心 APK 缓存刷新后重试一次")
else:
logger.info(f"[Block测试] 任务 {task_key} 下载失败({error_type_str})direct 模式不等待中心 APK")
else:
self.monitor.set_failure_bucket(task_key, "failed")
self.redis.sadd("task:failed", task_key)
logger.info(f"[Block测试] 任务 {task_key} 因非下载错误({error_type_str})直接标记为永久失败")
self._dispatch_alert(task_key, worker_id, error_info, message, retry_count, download_errors=download_errors)
return
auto_physical_reroute = self._should_auto_reroute_to_physical(task_details, error_info)
self.redis.hset("task:status", task_key, json.dumps(task_status))
related_magic_label_collected = (
bool(app_magic_label)
and bool(package_name)
and self.analytics.repo.has_successful_related_magic_label(app_magic_label, package_name)
)
if related_magic_label_collected:
qualified_status = dict(task_status)
qualified_status['status'] = 'qualified'
qualified_status['end_time'] = time.time()
qualified_status['collection_status_reason'] = '关联应用已采集'
self.redis.hset("task:status", task_key, json.dumps(qualified_status))
self.redis.srem("task:failed", task_key)
self.redis.sadd("task:completed", task_key)
self.monitor.set_failure_bucket(task_key, "light_restricted")
logger.warning(
f"[轻度受限] 任务 {task_key} 测试失败,但 app_magic_label={app_magic_label} 的关联应用已采集成功,"
f"当前任务按轻度受限记录"
)
return
if auto_physical_reroute:
self.monitor.set_failure_bucket(task_key, "retry")
rerouted_task_details = self._build_physical_reroute_task_details(task_details)
rerouted_task_details['auto_physical_fallback_applied'] = True
rerouted_task_details['auto_physical_fallback_from_worker'] = worker_id
rerouted_task_details['auto_physical_fallback_error_type'] = error_type_str
self.redis.hset("task:details", task_key, json.dumps(rerouted_task_details))
reroute_status = dict(task_status)
reroute_status['status'] = 'pending'
reroute_status['retry_count'] = retry_count
self.redis.hset("task:status", task_key, json.dumps(reroute_status))
self.redis.srem("task:completed", task_key)
self.redis.srem("task:failed", task_key)
self._queue_retry_task(task_key, rerouted_task_details, prioritize=self._is_hot_new_app_task(rerouted_task_details))
logger.warning(
f"[真机改派] 任务 {task_key} 在 Worker {worker_id} 上报 {error_type_str}"
f"已改为仅 physical 执行并重新放回待分发"
)
elif is_app_crash:
self.monitor.set_failure_bucket(task_key, "failed")
self.redis.sadd("task:failed", task_key)
logger.error(f"[永久失败] 任务 {task_key} 应用闪退,直接标记为永久失败")
elif is_no_retry_error and not is_terminal_local_fallback_failed:
# 非下载类的不可重试错误APP_ERROR / BUSINESS_ERROR→ 直接永久失败
self.monitor.set_failure_bucket(task_key, "failed")
self.redis.sadd("task:failed", task_key)
logger.error(f"[永久失败] 任务 {task_key} 因不可重试错误({error_type_str}),直接标记为永久失败")
else:
# 可重试的下载错误 或 终态下载错误REGION_RESTRICTED等 + local失败
# 中心 APK 缓存模式下等待 US/MinIO 下载direct 模式下由 Worker 自行下载。
if self._uses_central_apk_cache():
if error_info.category in (ErrorCategory.DOWNLOAD_ERROR,):
self._mark_download_error_awaiting_apk(package_name)
elif error_info.category == ErrorCategory.INFRA_ERROR and error_info.code == InfraError.DOWNLOAD_FAILED:
self._mark_download_error_awaiting_apk(package_name)
if is_terminal_local_fallback_failed and self._uses_central_apk_cache():
logger.warning(
f"[等待中心APK] 任务 {task_key} Google Play 终态错误({error_type_str})且本地兜底失败,"
f"标记为等待中心 APK 下载服务(可能通过不同地区或 APKPure 成功下载)"
)
self.monitor.set_failure_bucket(task_key, "retry")
self.redis.srem("task:failed", task_key)
retry_task_details = self._mark_retry_avoid_worker(task_key, task_details, worker_id)
retry_status = dict(task_status)
retry_status["status"] = "pending_retry"
self.redis.hset("task:status", task_key, json.dumps(retry_status))
if self._is_hot_new_app_task(retry_task_details):
self._queue_retry_task(task_key, retry_task_details, prioritize=True)
logger.info(
f"[热门重试] 新增热门应用任务 {task_key}{new_retry_count}次失败,"
f"已优先重新入队并避开 Worker {worker_id}"
)
logger.info(f"[等待分析] 任务 {task_key} 已记录为可重试(第{new_retry_count}次失败),等待应用画像决定是否重新入队")
self._dispatch_alert(task_key, worker_id, error_info, message, retry_count, download_errors=download_errors)
def clear_all(self):
"""清空所有任务数据(谨慎使用)"""
keys_to_delete = [
*self.TASK_QUEUE_CLEANUP_NAMES,
"task:status",
"task:details",
"worker:tasks",
"task:completed",
"task:failed",
"workers:info",
"workers:idle",
"workers:busy",
"workers:meta",
]
for key in keys_to_delete:
self.redis.delete(key)
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("已清空所有任务数据")
self._apk_registry = None
self._minio_storage = None
# ── APK prefetch integration ───────────────────────────────────
def _get_apk_registry(self):
if self._apk_registry is None:
from apk_cloud.registry import ApkRegistry
self._apk_registry = ApkRegistry()
return self._apk_registry
def _get_minio_storage(self):
"""获取 MinioStorage 实例,如果 MINIO_ENABLED=False 则返回 None"""
if self._apk_download_mode() != "minio":
return None
if self._minio_storage is None:
from config import MINIO_ENABLED
if not MINIO_ENABLED:
return None
from apk_cloud.storage import MinioStorage
self._minio_storage = MinioStorage.create() # 使用工厂方法
return self._minio_storage
def _resolve_apk_for_task(self, package_name: str, last_updated: str) -> dict:
if not self._uses_central_apk_cache():
return {}
registry = self._get_apk_registry()
if not registry.is_fresh_enough(package_name, last_updated):
return {}
smb_dir = registry.get_smb_apk_dir(package_name)
if not smb_dir:
return {}
entry = registry.get(package_name) or {}
apk_files = entry.get("apk_files", [])
return {
"local_apk_dir": smb_dir,
"local_apk_files": apk_files,
"apk_download_date": entry.get("download_date", ""),
}
def _enrich_task_with_apk(self, task_details: dict) -> dict:
package_name = str(task_details.get("package_name") or "").strip()
last_updated = str(task_details.get("last_updated") or "").strip()
if not package_name:
return task_details
apk_info = self._resolve_apk_for_task(package_name, last_updated)
if apk_info:
task_details = dict(task_details)
task_details["available_sources"] = ["local", "google_play", "apkpure"]
task_details["local_apk_dir"] = apk_info["local_apk_dir"]
task_details["local_apk_files"] = apk_info["local_apk_files"]
return task_details
def _mark_download_error_awaiting_apk(self, package_name: str, app_name: str = "", last_updated: str = "", force: bool = False) -> None:
if not package_name:
return
if not self._uses_central_apk_cache():
logger.info(
"[APK] 当前为 direct 模式Worker 将自行下载,跳过中心 APK 等待队列: %s",
package_name,
)
return
registry = self._get_apk_registry()
if not force and registry.is_fresh_enough(package_name, last_updated):
logger.info("[APK] 本地已有新鲜APK无需等待中心 APK 缓存刷新: %s", package_name)
return
registry.mark_pending_download(package_name)
logger.info("[APK] 下载失败标记为等待APK: %s", package_name)
try:
storage = self._get_minio_storage()
if storage is None:
logger.info("[APK] Minio 未启用,跳过优先下载队列推送: %s", package_name)
return
storage.prepend_to_download_queue([{
"package_name": package_name,
"app_name": app_name or package_name,
"last_updated": last_updated or "",
"force": force,
}])
logger.info("[APK] 下载失败,任务已加入下载队列开头: %s force=%s", package_name, force)
except Exception as exc:
logger.warning("[APK] 下载失败,推送优先下载队列失败: %s", exc)
def _fail_block_tasks_waiting_for_apk(self, package_name: str) -> None:
task_keys = self._find_task_keys_by_package(package_name)
for task_key in task_keys:
if not task_key.endswith("_block"):
continue
status_json = self.redis.hget("task:status", task_key)
if not status_json:
continue
status_data = json.loads(status_json)
if status_data.get("status") != "pending_retry":
continue
status_data["status"] = "failed"
status_data["last_fail_message"] = "中心 APK 下载失败block任务永久失败"
self.redis.hset("task:status", task_key, json.dumps(status_data))
self.redis.sadd("task:failed", task_key)
logger.info(f"[Block测试] 中心 APK 下载失败block任务 {task_key} 标记为永久失败")
def _log_apk_install_failure(self, task_key: str, package_name: str, app_name: str, message: str) -> None:
import csv, io
from config import REPORT_DIR
import os as _os
_os.makedirs(REPORT_DIR, exist_ok=True)
report_path = _os.path.join(REPORT_DIR, "apk_install_failures.csv")
existed = _os.path.isfile(report_path)
with open(report_path, "a", newline="", encoding="utf-8") as f:
writer = csv.writer(f)
if not existed:
writer.writerow(["时间", "任务键", "包名", "应用名", "失败原因"])
writer.writerow([
time.strftime("%Y-%m-%d %H:%M:%S"),
task_key,
package_name,
app_name,
message,
])
def force_reapk(self, package_name: str) -> int:
registry = self._get_apk_registry()
pkg_dir = registry.storage_dir.rstrip("/\\") + "/" + package_name
import shutil
if os.path.isdir(pkg_dir):
shutil.rmtree(pkg_dir, ignore_errors=True)
logger.info("[force_reapk] 已清除本地APK目录: %s", pkg_dir)
try:
with registry._connect() as conn:
conn.execute("DELETE FROM apk_registry WHERE package_name = ?", (package_name,))
except Exception:
pass
registry.clear_pending_download(package_name)
task_keys = self._find_task_keys_by_package(package_name)
task_info = None
for tk in task_keys:
d = self.redis.hget("task:details", tk)
if d:
task_info = json.loads(d)
break
app_name = ""
last_updated = ""
if task_info:
app_name = str(task_info.get("app_name") or "").strip()
last_updated = str(task_info.get("last_updated") or "").strip()
self._mark_download_error_awaiting_apk(package_name, app_name=app_name, last_updated=last_updated, force=True)
for task_key in task_keys:
self.redis.hset("task:status", task_key, json.dumps({
"status": "pending_retry",
"end_time": time.time(),
"retry_count": 0,
"last_fail_message": "force_reapk: 等待重新下载",
}))
logger.info("[force_reapk] 已重置 %s (%d 个任务),等待中心 APK 缓存刷新", package_name, len(task_keys))
return len(task_keys)
def _re_enqueue_apk_tasks_for_package(self, package_name: str) -> int:
task_keys = self._find_task_keys_by_package(package_name)
re_enqueued = 0
for task_key in task_keys:
status_json = self.redis.hget("task:status", task_key)
if not status_json:
continue
try:
status = json.loads(status_json)
except (json.JSONDecodeError, TypeError):
continue
if status.get("status") not in ("pending_retry", "failed", "pending"):
continue
task_details_json = self.redis.hget("task:details", task_key)
task_details = json.loads(task_details_json) if task_details_json else {}
self._remove_task_from_queue(task_key)
self.redis.srem("task:completed", task_key)
self.redis.srem("task:failed", task_key)
self.redis.hset("task:status", task_key, json.dumps({
"status": "pending",
"end_time": time.time(),
"retry_count": int(status.get("retry_count") or 0),
}))
self._queue_retry_task(task_key, task_details)
re_enqueued += 1
logger.info("[APK] APK就绪重新入队: %s (%s)", task_key, package_name)
registry = self._get_apk_registry()
registry.clear_pending_download(package_name)
return re_enqueued
def _try_re_enqueue_apk_ready_tasks(self) -> int:
registry = self._get_apk_registry()
ready_packages = registry.get_pending_that_are_available()
if not ready_packages:
return 0
re_enqueued = 0
for package_name in ready_packages:
task_keys = self._find_task_keys_by_package(package_name)
for task_key in task_keys:
status_json = self.redis.hget("task:status", task_key)
if not status_json:
continue
try:
status = json.loads(status_json)
except (json.JSONDecodeError, TypeError):
continue
if status.get("status") not in ("pending_retry", "failed", "pending"):
continue
task_details_json = self.redis.hget("task:details", task_key)
task_details = json.loads(task_details_json) if task_details_json else {}
self._remove_task_from_queue(task_key)
self.redis.srem("task:completed", task_key)
self.redis.srem("task:failed", task_key)
self.redis.hset("task:status", task_key, json.dumps({
"status": "pending",
"end_time": time.time(),
"retry_count": int(status.get("retry_count") or 0),
}))
self._queue_retry_task(task_key, task_details)
re_enqueued += 1
logger.info("[APK] APK就绪重新入队: %s (%s)", task_key, package_name)
registry.clear_pending_download(package_name)
return re_enqueued
def _find_task_keys_by_package(self, package_name: str) -> List[str]:
found = []
all_details = self.redis.hgetall("task:details")
for task_key, details_json in (all_details or {}).items():
try:
details = json.loads(details_json)
except (json.JSONDecodeError, TypeError):
continue
if str(details.get("package_name") or "").strip() == package_name:
found.append(task_key)
return found
def sync_download_queue(self) -> int:
"""同步下载队列(仅当 Minio 启用时)"""
try:
storage = self._get_minio_storage()
if storage is None:
# Minio 未启用,跳过队列同步
return 0
except Exception as exc:
logger.warning("[APK] MinIO不可达跳过下载队列同步: %s", exc)
return 0
registry = self._get_apk_registry()
current_queue = storage.pull_download_queue()
filtered = []
removed_completed = 0
removed_failed = 0
for task in current_queue:
pkg = str(task.get("package_name") or "").strip()
last_updated = str(task.get("last_updated") or "").strip()
if not pkg:
continue
if registry.is_fresh_enough(pkg, last_updated):
removed_completed += 1
continue
if last_updated and registry.has_failed_with_same_version(pkg, last_updated):
removed_failed += 1
continue
filtered.append(task)
if removed_completed or removed_failed:
logger.info("[APK] 队列清理: 移除已完成 %d, 移除已失败 %d, 剩余 %d",
removed_completed, removed_failed, len(filtered))
pending_pkgs = registry.get_all_pending_downloads()
existing_pkgs = {t.get("package_name", "") for t in filtered}
new_tasks = []
for pkg in pending_pkgs:
if pkg in existing_pkgs:
continue
task_info = self._find_task_keys_by_package(pkg)
app_name = ""
last_updated = ""
for task_key in task_info:
details_json = self.redis.hget("task:details", task_key)
if details_json:
details = json.loads(details_json)
app_name = str(details.get("app_name") or app_name or "").strip() or app_name
last_updated = str(details.get("last_updated") or last_updated or "").strip() or last_updated
if app_name and last_updated:
break
if last_updated and registry.has_failed_with_same_version(pkg, last_updated):
continue
new_tasks.insert(0, {
"package_name": pkg,
"app_name": app_name or pkg,
"last_updated": last_updated or "",
})
queue = new_tasks + filtered
max_queue = 1000
if len(queue) > max_queue:
logger.info("[APK] 下载队列超过上限 %d,截断尾部 %d", max_queue, len(queue) - max_queue)
queue = queue[:max_queue]
try:
storage.push_download_queue(queue)
except Exception as exc:
logger.warning("[APK] 推送下载队列失败: %s", exc)
return 0
if new_tasks:
logger.info("[APK] 队列新增 %d 个优先任务, 队列总数 %d", len(new_tasks), len(queue))
return len(new_tasks)
def _get_stale_download_tasks(self, registry) -> List[Dict[str, str]]:
if not hasattr(self, '_last_stale_check'):
self._last_stale_check = 0.0
if time.time() - self._last_stale_check < APK_DOWNLOAD_QUEUE_INTERVAL:
return []
self._last_stale_check = time.time()
try:
stale_records = registry.get_stale_records()
except Exception as exc:
logger.warning("[APK] 查询过期APK记录失败: %s", exc)
return []
tasks = []
for record in stale_records:
package_name = str(record.get("package_name") or "").strip()
if not package_name:
continue
tasks.append({
"package_name": package_name,
"app_name": str(record.get("app_name") or "").strip(),
"last_updated": str(record.get("last_updated") or "").strip(),
})
if tasks:
logger.info("[APK] 检测到 %d 个过期本地APK将重新下载", len(tasks))
return tasks
def poll_apk_results(self) -> int:
"""轮询 APK 下载结果(仅当 Minio 启用时)"""
try:
storage = self._get_minio_storage()
if storage is None:
# Minio 未启用,跳过结果轮询
return 0
except Exception as exc:
logger.warning("[APK] MinIO不可达跳过结果轮询: %s", exc)
return 0
try:
results = storage.list_download_results()
except Exception as exc:
logger.warning("[APK] 轮询下载结果失败: %s", exc)
return 0
if not results:
return self._try_re_enqueue_apk_ready_tasks()
registry = self._get_apk_registry()
imported = 0
for package_name, result in results.items():
status = result.get("status", "")
try:
self._record_result_to_db(package_name, result)
except Exception as exc:
logger.warning("[APK] 记录下载结果到DB失败 %s: %s", package_name, exc)
if status != "ok":
if status == "failed":
storage.delete_download_result(package_name)
registry.clear_pending_download(package_name)
self._fail_block_tasks_waiting_for_apk(package_name)
continue
try:
local_paths = storage.download_apk(package_name, result, registry.storage_dir)
if not registry.verify_apk_versions(package_name):
logger.warning("[APK] 版本码不一致,丢弃: %s", package_name)
import shutil
shutil.rmtree(registry.storage_dir + "/" + package_name, ignore_errors=True)
continue
registry.import_from_download_result(package_name, result, local_dir=registry.storage_dir)
storage.delete_download_result(package_name)
imported += 1
re_enqueued = self._re_enqueue_apk_tasks_for_package(package_name)
logger.info("[APK] 下载完成: %s (%d files), 重新入队 %d 个任务",
package_name, len(local_paths), re_enqueued)
version_dir = f"{result.get('download_date', '')}_{result.get('version_code', '')}"
if version_dir.strip("_"):
storage.delete_apk_version(package_name, version_dir)
logger.info("[APK] 已清理MinIO源文件: %s/%s", package_name, version_dir)
storage.cleanup_old_versions(package_name, keep=0)
except Exception as exc:
logger.warning("[APK] 处理结果失败 %s: %s", package_name, exc)
if imported > 0:
logger.info("[APK] 本轮导入%d个新APK刷新待分发任务...", imported)
self.refresh_tasks_from_app_summary()
self._enforce_apk_storage_limits()
re_enqueued = self._try_re_enqueue_apk_ready_tasks()
if re_enqueued:
logger.info("[APK] 重新入队 %d 个等待APK的任务", re_enqueued)
return imported + re_enqueued
def _record_result_to_db(self, package_name: str, result: dict) -> None:
registry = self._get_apk_registry()
package_name = str(package_name or "").strip()
if not package_name:
return
task_keys = self._find_task_keys_by_package(package_name)
if not task_keys:
registry.record_download_from_result(package_name, "", result)
logger.info("[APK] 记录下载结果(无对应任务): %s status=%s",
package_name, result.get("status"))
return
for task_key in task_keys:
task_details_json = self.redis.hget("task:details", task_key)
if not task_details_json:
continue
try:
task_details = json.loads(task_details_json)
except (json.JSONDecodeError, TypeError):
continue
country_code = str(task_details.get("country_code") or "").strip()
registry.record_download_from_result(package_name, country_code, result)
logger.info("[APK] 记录下载结果: %s/%s status=%s",
package_name, country_code, result.get("status"))
break
def _manage_stale_apks(self) -> int:
"""定期清理过期本地 APK 文件,并推送到下载队列重新下载。
返回清理的过期 APK 数量。
"""
registry = self._get_apk_registry()
try:
stale_records = registry.get_stale_records()
except Exception as exc:
logger.warning("[APK] 查询过期 APK 失败: %s", exc)
return 0
if not stale_records:
return 0
cleaned = 0
for record in stale_records:
package_name = str(record.get("package_name") or "").strip()
if not package_name:
continue
try:
registry.cleanup_stale_local(package_name)
cleaned += 1
logger.info("[APK] 已清理过期本地 APK: %s", package_name)
except Exception as exc:
logger.warning("[APK] 清理过期 APK 失败 %s: %s", package_name, exc)
if cleaned > 0:
logger.info("[APK] 过期 APK 清理: %d 个包已删除,将在下次队列同步时重新下载", cleaned)
return cleaned
def _enforce_apk_storage_limits(self) -> None:
"""强制执行 APK 存储限制(仅当 Minio 启用时)"""
try:
storage = self._get_minio_storage()
if storage is None:
# Minio 未启用,跳过存储限制检查
return
except Exception:
return
try:
minio_result = storage.enforce_storage_limit()
if minio_result.get("deleted_versions"):
logger.info(
"[APK] MinIO 超限清理: %.1fGB → %.1fGB (删除 %d 个版本)",
minio_result["before_bytes"] / (1024 ** 3),
minio_result["after_bytes"] / (1024 ** 3),
minio_result["deleted_versions"],
)
except Exception as exc:
logger.warning("[APK] MinIO 存储限制检查失败: %s", exc)