883 lines
34 KiB
Python
883 lines
34 KiB
Python
# -*- encoding=utf8 -*-
|
||
"""
|
||
Master-Worker 本地模拟测试
|
||
|
||
测试策略:
|
||
1. 直接调用模式 (TestDirectDispatcher): 直接调使用 RedisTaskDispatcher 方法模拟Worker行为
|
||
2. PubSub集成测试 (TestPubSubIntegration): 启动 DispatcherService + 模拟Worker线程
|
||
3. 使用本地 Redis (localhost:6379) DB 15 隔离测试数据
|
||
"""
|
||
|
||
import sys
|
||
import os
|
||
import json
|
||
import time
|
||
import threading
|
||
import tempfile
|
||
import shutil
|
||
import csv
|
||
|
||
import pytest
|
||
import redis
|
||
|
||
# 将项目根目录加入 sys.path,以便导入项目模块
|
||
PROJECT_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||
sys.path.insert(0, PROJECT_ROOT)
|
||
|
||
# ==================== 测试用配置覆盖 ====================
|
||
# 在导入项目模块前,先覆盖 config 中的值以使用本地Redis和测试文件
|
||
import config
|
||
config.REDIS_HOST = 'localhost'
|
||
config.REDIS_PORT = 6379
|
||
config.REDIS_MAX_CONNECTIONS_DISPATCHER = 20
|
||
config.TASK_TIMEOUT = 5 # 测试用: 5秒超时(加速测试)
|
||
config.MAX_RETRY_COUNT = 2 # 测试用: 最多重试2次
|
||
config.WORKER_STALE_TIMEOUT = 3 # 测试用: 3秒Worker超时
|
||
|
||
from redis_task_distribute import RedisTaskDispatcher
|
||
from dispatcher_main import DispatcherService
|
||
|
||
# 测试用CSV路径
|
||
TEST_CSV_PATH = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'test_task_list.csv')
|
||
|
||
# 使用 Redis DB 15 隔离测试数据
|
||
TEST_REDIS_DB = 15
|
||
|
||
|
||
class RedisTestHelper:
|
||
"""Redis测试辅助类:管理DB 15的连接和清理"""
|
||
|
||
def __init__(self):
|
||
self.redis = redis.Redis(host='localhost', port=6379, db=TEST_REDIS_DB, decode_responses=True)
|
||
|
||
def flush(self):
|
||
"""清空DB 15中所有数据"""
|
||
self.redis.flushdb()
|
||
|
||
def is_available(self):
|
||
"""检查Redis是否可用"""
|
||
try:
|
||
return self.redis.ping()
|
||
except redis.ConnectionError:
|
||
return False
|
||
|
||
|
||
def create_test_dispatcher():
|
||
"""创建使用DB 15的测试Dispatcher
|
||
|
||
通过修改连接池使其连接到 DB 15,与生产数据隔离
|
||
"""
|
||
# 强制重置连接池,确保使用新的配置
|
||
RedisTaskDispatcher._connection_pool = None
|
||
|
||
dispatcher = RedisTaskDispatcher(redis_host='localhost', redis_port=6379)
|
||
# 替换redis连接为DB 15
|
||
dispatcher.redis = redis.Redis(host='localhost', port=6379, db=TEST_REDIS_DB, decode_responses=True)
|
||
return dispatcher
|
||
|
||
|
||
def create_test_service():
|
||
"""创建使用DB 15的测试DispatcherService"""
|
||
# 重置连接池
|
||
RedisTaskDispatcher._connection_pool = None
|
||
|
||
service = DispatcherService(redis_host='localhost', redis_port=6379)
|
||
# 替换redis连接为DB 15
|
||
service.dispatcher.redis = redis.Redis(host='localhost', port=6379, db=TEST_REDIS_DB, decode_responses=True)
|
||
service.redis = service.dispatcher.redis
|
||
return service
|
||
|
||
|
||
# ==================== 跳过条件 ====================
|
||
helper = RedisTestHelper()
|
||
redis_available = helper.is_available()
|
||
skip_no_redis = pytest.mark.skipif(not redis_available, reason="本地Redis服务未运行")
|
||
|
||
|
||
# ==================== 直接调用模式测试 ====================
|
||
@skip_no_redis
|
||
class TestDirectDispatcher:
|
||
"""直接调用 RedisTaskDispatcher 方法模拟各种Worker场景"""
|
||
|
||
def setup_method(self):
|
||
"""每个测试前清空DB 15并创建新的dispatcher"""
|
||
helper.flush()
|
||
self.dispatcher = create_test_dispatcher()
|
||
# 使用临时目录存放CSV输出
|
||
self.temp_dir = tempfile.mkdtemp()
|
||
config.FAILED_TASKS_CSV = os.path.join(self.temp_dir, 'failed_tasks.csv')
|
||
config.SUCCESS_TASKS_CSV = os.path.join(self.temp_dir, 'success_tasks.csv')
|
||
|
||
def teardown_method(self):
|
||
"""每个测试后清理"""
|
||
helper.flush()
|
||
shutil.rmtree(self.temp_dir, ignore_errors=True)
|
||
|
||
def _load_test_tasks(self):
|
||
"""加载测试任务"""
|
||
count = self.dispatcher.load_tasks_from_csv(TEST_CSV_PATH)
|
||
assert count == 10, f"预期加载10个任务,实际加载了{count}个"
|
||
return count
|
||
|
||
def test_country_codes_are_parsed_and_delivered(self):
|
||
fd, csv_path = tempfile.mkstemp(dir=PROJECT_ROOT, suffix='.csv')
|
||
os.close(fd)
|
||
try:
|
||
with open(csv_path, 'w', newline='', encoding='utf-8') as handle:
|
||
writer = csv.DictWriter(handle, fieldnames=['app_name', 'package_name', 'country_code', 'device_type'])
|
||
writer.writeheader()
|
||
writer.writerow({
|
||
'app_name': 'Country App',
|
||
'package_name': 'com.test.country',
|
||
'country_code': 'cn, us,JP,cn',
|
||
'device_type': '0',
|
||
})
|
||
|
||
count = self.dispatcher.load_tasks_from_csv(csv_path)
|
||
assert count == 1
|
||
|
||
task_key = 'Country App_com.test.country'
|
||
task_details = json.loads(self.dispatcher.redis.hget('task:details', task_key))
|
||
assert task_details['country_code'] == 'cn, us,JP,cn'
|
||
assert task_details['country_codes'] == ['CN', 'US', 'JP']
|
||
|
||
task = self.dispatcher.worker_init(
|
||
worker_id='192.168.1.1_AA:BB:CC:DD:EE:01',
|
||
ip_address='192.168.1.1',
|
||
mac_address='AA:BB:CC:DD:EE:01',
|
||
hostname='test-country-worker',
|
||
platform='Windows',
|
||
)
|
||
assert task is not None
|
||
assert task['country_code'] == 'cn, us,JP,cn'
|
||
assert task['country_codes'] == ['CN', 'US', 'JP']
|
||
finally:
|
||
if os.path.exists(csv_path):
|
||
os.remove(csv_path)
|
||
|
||
# -------- 场景1: 正常流程 --------
|
||
def test_normal_flow(self):
|
||
"""多Worker并行领取任务 → 上报success → 领取下一个 → 直到队列耗尽"""
|
||
self._load_test_tasks()
|
||
|
||
# 创建3个Worker
|
||
workers = {}
|
||
for i in range(3):
|
||
worker_id = f"192.168.1.{i+1}_AA:BB:CC:DD:EE:0{i}"
|
||
task = self.dispatcher.worker_init(
|
||
worker_id=worker_id,
|
||
ip_address=f"192.168.1.{i+1}",
|
||
mac_address=f"AA:BB:CC:DD:EE:0{i}",
|
||
hostname=f"test-pc-{i}",
|
||
platform="Windows"
|
||
)
|
||
assert task is not None, f"Worker {i} 应该能领取到任务"
|
||
workers[worker_id] = task
|
||
|
||
# 验证3个Worker各领到不同任务
|
||
task_keys = [t['task_key'] for t in workers.values()]
|
||
assert len(set(task_keys)) == 3, "3个Worker应领取到3个不同的任务"
|
||
|
||
# 统计信息
|
||
stats = self.dispatcher.get_statistics()
|
||
assert stats['pending'] == 7, "应剩余7个待分发任务"
|
||
assert stats['running'] == 3, "应有3个运行中任务"
|
||
|
||
# 每个Worker循环: 上报成功 → 领取下一个
|
||
completed_count = 3 # 已领取了3个
|
||
for worker_id in list(workers.keys()):
|
||
while True:
|
||
current_task = workers[worker_id]
|
||
next_task = self.dispatcher.worker_report(
|
||
worker_id=worker_id,
|
||
previous_task_key=current_task['task_key'],
|
||
status='success'
|
||
)
|
||
completed_count += 0 # 上报时才算完成
|
||
if next_task is None:
|
||
break
|
||
workers[worker_id] = next_task
|
||
|
||
# 最终统计
|
||
stats = self.dispatcher.get_statistics()
|
||
assert stats['pending'] == 0, "所有任务应分发完毕"
|
||
assert stats['completed'] == 10, f"所有10个任务应标记为完成,实际: {stats['completed']}"
|
||
assert stats['failed'] == 0, "不应有失败任务"
|
||
|
||
# -------- 场景2: 任务失败重试 --------
|
||
def test_failed_retry(self):
|
||
"""Worker上报failed → 任务重新入队 → 可被其他Worker领取"""
|
||
self._load_test_tasks()
|
||
|
||
# Worker A 领取任务
|
||
worker_a = "192.168.1.1_AA:BB:CC:DD:EE:01"
|
||
task_a = self.dispatcher.worker_init(
|
||
worker_id=worker_a,
|
||
ip_address="192.168.1.1",
|
||
mac_address="AA:BB:CC:DD:EE:01",
|
||
hostname="test-pc-a",
|
||
platform="Windows"
|
||
)
|
||
assert task_a is not None
|
||
failed_task_key = task_a['task_key']
|
||
|
||
# Worker A 上报失败
|
||
next_task = self.dispatcher.worker_report(
|
||
worker_id=worker_a,
|
||
previous_task_key=failed_task_key,
|
||
status='failed',
|
||
message='下载APK失败'
|
||
)
|
||
# 失败后应能领取新任务
|
||
assert next_task is not None, "上报failed后应自动领取下一个任务"
|
||
|
||
# 验证失败任务已重新入队(队列中应该包含它)
|
||
queue_items = self.dispatcher.redis.lrange("task:queue", 0, -1)
|
||
assert failed_task_key in queue_items, f"失败的任务 {failed_task_key} 应重新入队"
|
||
|
||
# Worker B 领取任务,最终能领到之前失败的任务
|
||
worker_b = "192.168.1.2_AA:BB:CC:DD:EE:02"
|
||
task_b = self.dispatcher.worker_init(
|
||
worker_id=worker_b,
|
||
ip_address="192.168.1.2",
|
||
mac_address="AA:BB:CC:DD:EE:02",
|
||
hostname="test-pc-b",
|
||
platform="Windows"
|
||
)
|
||
# Worker B 连续领取,直到找到失败过的任务
|
||
found = False
|
||
checked_tasks = [task_b['task_key']] if task_b else []
|
||
while task_b:
|
||
if task_b['task_key'] == failed_task_key:
|
||
found = True
|
||
break
|
||
task_b = self.dispatcher.worker_report(
|
||
worker_id=worker_b,
|
||
previous_task_key=task_b['task_key'],
|
||
status='success'
|
||
)
|
||
if task_b:
|
||
checked_tasks.append(task_b['task_key'])
|
||
|
||
assert found, f"Worker B 应能领到之前失败的任务 {failed_task_key},已检查: {checked_tasks}"
|
||
|
||
# -------- 场景3: Worker stop --------
|
||
def test_worker_stop(self):
|
||
"""Worker上报stop → 从列表移除 → 不再分配任务"""
|
||
self._load_test_tasks()
|
||
|
||
worker_id = "192.168.1.1_AA:BB:CC:DD:EE:01"
|
||
task = self.dispatcher.worker_init(
|
||
worker_id=worker_id,
|
||
ip_address="192.168.1.1",
|
||
mac_address="AA:BB:CC:DD:EE:01",
|
||
hostname="test-pc-stop",
|
||
platform="Windows"
|
||
)
|
||
assert task is not None
|
||
|
||
# 上报stop,附带告警消息
|
||
result = self.dispatcher.worker_report(
|
||
worker_id=worker_id,
|
||
previous_task_key=task['task_key'],
|
||
status='stop',
|
||
message='设备ADB连接异常,无法恢复'
|
||
)
|
||
|
||
# stop后不应返回新任务
|
||
assert result is None, "stop状态不应返回新任务"
|
||
|
||
# Worker应从列表中移除
|
||
workers = self.dispatcher.get_registered_workers()
|
||
worker_ids = [w['worker_id'] for w in workers]
|
||
assert worker_id not in worker_ids, "stop后Worker应从列表移除"
|
||
|
||
# 该Worker再次上报应返回错误码
|
||
error_result = self.dispatcher.worker_report(
|
||
worker_id=worker_id,
|
||
previous_task_key="any_task",
|
||
status='success'
|
||
)
|
||
assert error_result is not None, "已移除的Worker上报应返回错误码"
|
||
assert error_result.get('error_code') == -1, "错误码应为-1"
|
||
|
||
# -------- 场景4: 重复初始化 --------
|
||
def test_duplicate_init(self):
|
||
"""Worker重新调用init → 应返回之前分配的未完成任务"""
|
||
self._load_test_tasks()
|
||
|
||
worker_id = "192.168.1.1_AA:BB:CC:DD:EE:01"
|
||
|
||
# 第一次初始化
|
||
task1 = self.dispatcher.worker_init(
|
||
worker_id=worker_id,
|
||
ip_address="192.168.1.1",
|
||
mac_address="AA:BB:CC:DD:EE:01",
|
||
hostname="test-pc-dup",
|
||
platform="Windows"
|
||
)
|
||
assert task1 is not None
|
||
original_task_key = task1['task_key']
|
||
|
||
# 第二次初始化(模拟Worker重启)
|
||
task2 = self.dispatcher.worker_init(
|
||
worker_id=worker_id,
|
||
ip_address="192.168.1.1",
|
||
mac_address="AA:BB:CC:DD:EE:01",
|
||
hostname="test-pc-dup",
|
||
platform="Windows"
|
||
)
|
||
assert task2 is not None, "重复初始化应返回任务"
|
||
assert task2['task_key'] == original_task_key, \
|
||
f"重复初始化应返回之前的任务 {original_task_key},实际: {task2['task_key']}"
|
||
|
||
# -------- 场景5: Worker超时清理 --------
|
||
def test_stale_worker_cleanup(self):
|
||
"""模拟Worker长时间无心跳 → cleanup_stale_workers清理 → 任务重新入队"""
|
||
self._load_test_tasks()
|
||
|
||
worker_id = "192.168.1.1_AA:BB:CC:DD:EE:01"
|
||
task = self.dispatcher.worker_init(
|
||
worker_id=worker_id,
|
||
ip_address="192.168.1.1",
|
||
mac_address="AA:BB:CC:DD:EE:01",
|
||
hostname="test-pc-stale",
|
||
platform="Windows"
|
||
)
|
||
assert task is not None
|
||
stale_task_key = task['task_key']
|
||
|
||
# 模拟Worker超时:手动修改 last_update 为很久以前
|
||
worker_info_json = self.dispatcher.redis.hget("workers:info", worker_id)
|
||
worker_info = json.loads(worker_info_json)
|
||
worker_info['last_update'] = time.time() - 10000 # 10000秒前
|
||
self.dispatcher.redis.hset("workers:info", worker_id, json.dumps(worker_info))
|
||
|
||
# 执行清理
|
||
cleaned = self.dispatcher.cleanup_stale_workers(timeout=5) # 5秒超时
|
||
assert cleaned == 1, f"应清理1个Worker,实际清理了{cleaned}个"
|
||
|
||
# 验证Worker已被移除
|
||
workers = self.dispatcher.get_registered_workers()
|
||
assert len(workers) == 0, "超时Worker应被移除"
|
||
|
||
# 验证超时Worker的任务记录了失败(任务被 _record_failed_task 处理)
|
||
status_json = self.dispatcher.redis.hget("task:status", stale_task_key)
|
||
assert status_json is not None, "超时任务应有状态记录"
|
||
status = json.loads(status_json)
|
||
assert status['status'] == 'failed', f"超时任务状态应为failed,实际: {status['status']}"
|
||
|
||
# 验证任务已重新入队(因为retry_count < MAX_RETRY_COUNT)
|
||
queue_items = self.dispatcher.redis.lrange("task:queue", 0, -1)
|
||
assert stale_task_key in queue_items, "超时的任务应重新入队"
|
||
|
||
# -------- 场景6: 任务超时 --------
|
||
def test_task_timeout(self):
|
||
"""模拟任务长时间running → check_timeout → 自动重新入队"""
|
||
self._load_test_tasks()
|
||
|
||
worker_id = "192.168.1.1_AA:BB:CC:DD:EE:01"
|
||
task = self.dispatcher.worker_init(
|
||
worker_id=worker_id,
|
||
ip_address="192.168.1.1",
|
||
mac_address="AA:BB:CC:DD:EE:01",
|
||
hostname="test-pc-timeout",
|
||
platform="Windows"
|
||
)
|
||
assert task is not None
|
||
timeout_task_key = task['task_key']
|
||
|
||
# 模拟任务超时:修改start_time和last_retry为很久以前
|
||
status_json = self.dispatcher.redis.hget("task:status", timeout_task_key)
|
||
status = json.loads(status_json)
|
||
status['start_time'] = time.time() - 10000
|
||
status['last_retry'] = time.time() - 10000
|
||
self.dispatcher.redis.hset("task:status", timeout_task_key, json.dumps(status))
|
||
|
||
# 执行超时检查
|
||
timeout_count = self.dispatcher.check_timeout()
|
||
assert timeout_count == 1, f"应检测到1个超时任务,实际: {timeout_count}"
|
||
|
||
# 验证任务已重新入队
|
||
queue_items = self.dispatcher.redis.lrange("task:queue", 0, -1)
|
||
assert timeout_task_key in queue_items, "超时任务应重新入队"
|
||
|
||
# 验证Worker任务映射已清除(Worker可以领新任务)
|
||
current_task = self.dispatcher.redis.hget("worker:tasks", worker_id)
|
||
assert current_task is None, "超时后Worker的任务映射应被清除"
|
||
|
||
# -------- 场景7: 重试达上限 --------
|
||
def test_max_retry_exceeded(self):
|
||
"""任务连续失败超过MAX_RETRY_COUNT → 标记为永久失败"""
|
||
self._load_test_tasks()
|
||
|
||
worker_id = "192.168.1.1_AA:BB:CC:DD:EE:01"
|
||
task = self.dispatcher.worker_init(
|
||
worker_id=worker_id,
|
||
ip_address="192.168.1.1",
|
||
mac_address="AA:BB:CC:DD:EE:01",
|
||
hostname="test-pc-maxretry",
|
||
platform="Windows"
|
||
)
|
||
assert task is not None
|
||
doomed_task_key = task['task_key']
|
||
|
||
# MAX_RETRY_COUNT = 2,需要连续失败2次达到上限
|
||
for i in range(config.MAX_RETRY_COUNT):
|
||
# 上报失败
|
||
next_task = self.dispatcher.worker_report(
|
||
worker_id=worker_id,
|
||
previous_task_key=doomed_task_key,
|
||
status='failed',
|
||
message=f'第{i+1}次失败'
|
||
)
|
||
|
||
if i < config.MAX_RETRY_COUNT - 1:
|
||
# 还没到上限,任务应该重新入队
|
||
# 消费掉领到的新任务,然后继续让worker去领那个失败任务
|
||
# Worker自动领了新任务,需要先完成它再去拿失败重入的任务
|
||
while next_task and next_task['task_key'] != doomed_task_key:
|
||
next_task2 = self.dispatcher.worker_report(
|
||
worker_id=worker_id,
|
||
previous_task_key=next_task['task_key'],
|
||
status='success'
|
||
)
|
||
next_task = next_task2
|
||
|
||
if next_task is None:
|
||
# 队列中可能还有那个失败任务,手动领取
|
||
# 需要清除worker当前任务映射
|
||
self.dispatcher.redis.hdel("worker:tasks", worker_id)
|
||
self.dispatcher._update_worker_status(worker_id, 'idle', None)
|
||
# 手动分配
|
||
next_task = self.dispatcher._assign_task(worker_id)
|
||
|
||
if next_task and next_task['task_key'] == doomed_task_key:
|
||
continue
|
||
|
||
# 验证任务已标记为永久失败
|
||
failed_tasks = self.dispatcher.redis.smembers("task:failed")
|
||
assert doomed_task_key in failed_tasks, \
|
||
f"任务 {doomed_task_key} 应在永久失败集合中,当前集合: {failed_tasks}"
|
||
|
||
# 验证任务不再入队
|
||
queue_items = self.dispatcher.redis.lrange("task:queue", 0, -1)
|
||
assert doomed_task_key not in queue_items, "永久失败的任务不应在队列中"
|
||
|
||
# -------- 场景8: 并发安全 --------
|
||
def test_concurrent_workers(self):
|
||
"""多线程同时调用worker_init和worker_report,验证不会重复分配"""
|
||
self._load_test_tasks()
|
||
|
||
results = {}
|
||
errors = []
|
||
|
||
def worker_thread(worker_idx):
|
||
"""模拟单个Worker的工作线程"""
|
||
try:
|
||
worker_id = f"192.168.1.{worker_idx}_AA:BB:CC:DD:EE:{worker_idx:02d}"
|
||
task = self.dispatcher.worker_init(
|
||
worker_id=worker_id,
|
||
ip_address=f"192.168.1.{worker_idx}",
|
||
mac_address=f"AA:BB:CC:DD:EE:{worker_idx:02d}",
|
||
hostname=f"test-pc-{worker_idx}",
|
||
platform="Windows"
|
||
)
|
||
|
||
tasks_done = []
|
||
while task:
|
||
tasks_done.append(task['task_key'])
|
||
# 模拟短暂处理
|
||
time.sleep(0.1)
|
||
task = self.dispatcher.worker_report(
|
||
worker_id=worker_id,
|
||
previous_task_key=tasks_done[-1],
|
||
status='success'
|
||
)
|
||
|
||
results[worker_id] = tasks_done
|
||
except Exception as e:
|
||
errors.append(f"Worker {worker_idx}: {e}")
|
||
|
||
# 启动5个并发Worker
|
||
threads = []
|
||
for i in range(1, 6):
|
||
t = threading.Thread(target=worker_thread, args=(i,))
|
||
threads.append(t)
|
||
t.start()
|
||
|
||
# 等待所有线程完成
|
||
for t in threads:
|
||
t.join(timeout=30)
|
||
|
||
# 验证
|
||
assert len(errors) == 0, f"不应有错误: {errors}"
|
||
|
||
# 收集所有完成的任务
|
||
all_tasks = []
|
||
for tasks in results.values():
|
||
all_tasks.extend(tasks)
|
||
|
||
# 排除重试场景,每个任务应该只被一个Worker完成一次
|
||
assert len(all_tasks) == len(set(all_tasks)), \
|
||
f"不应有重复分配的任务,总数: {len(all_tasks)},去重后: {len(set(all_tasks))}"
|
||
assert len(all_tasks) == 10, f"所有10个任务应被完成,实际完成: {len(all_tasks)}"
|
||
|
||
# -------- 场景9: 空队列 --------
|
||
def test_empty_queue(self):
|
||
"""队列为空时Worker初始化应返回None"""
|
||
# 不加载任何任务
|
||
worker_id = "192.168.1.1_AA:BB:CC:DD:EE:01"
|
||
task = self.dispatcher.worker_init(
|
||
worker_id=worker_id,
|
||
ip_address="192.168.1.1",
|
||
mac_address="AA:BB:CC:DD:EE:01",
|
||
hostname="test-pc-empty",
|
||
platform="Windows"
|
||
)
|
||
assert task is None, "空队列时应返回None"
|
||
|
||
# Worker应已注册但状态为idle
|
||
workers = self.dispatcher.get_registered_workers()
|
||
assert len(workers) == 1, "Worker应已注册"
|
||
assert workers[0]['status'] == 'idle', "Worker应为idle状态"
|
||
|
||
# -------- 场景10: worker_retry重新计时 --------
|
||
def test_worker_retry(self):
|
||
"""Worker调用retry → 任务重新计时"""
|
||
self._load_test_tasks()
|
||
|
||
worker_id = "192.168.1.1_AA:BB:CC:DD:EE:01"
|
||
task = self.dispatcher.worker_init(
|
||
worker_id=worker_id,
|
||
ip_address="192.168.1.1",
|
||
mac_address="AA:BB:CC:DD:EE:01",
|
||
hostname="test-pc-retry",
|
||
platform="Windows"
|
||
)
|
||
assert task is not None
|
||
task_key = task['task_key']
|
||
|
||
# 记录原始时间
|
||
status_before = json.loads(self.dispatcher.redis.hget("task:status", task_key))
|
||
original_retry_time = status_before.get('last_retry')
|
||
|
||
# 等一小段时间
|
||
time.sleep(0.5)
|
||
|
||
# 调用retry
|
||
success = self.dispatcher.worker_retry(worker_id, task_key)
|
||
assert success is True, "retry应成功"
|
||
|
||
# 验证时间已更新
|
||
status_after = json.loads(self.dispatcher.redis.hget("task:status", task_key))
|
||
new_retry_time = status_after.get('last_retry')
|
||
assert new_retry_time > original_retry_time, "retry后时间应更新"
|
||
|
||
# -------- 场景11: 无效status验证 --------
|
||
def test_invalid_status(self):
|
||
"""上报无效的status值 → 应返回None"""
|
||
self._load_test_tasks()
|
||
|
||
worker_id = "192.168.1.1_AA:BB:CC:DD:EE:01"
|
||
task = self.dispatcher.worker_init(
|
||
worker_id=worker_id,
|
||
ip_address="192.168.1.1",
|
||
mac_address="AA:BB:CC:DD:EE:01",
|
||
hostname="test-pc-invalid",
|
||
platform="Windows"
|
||
)
|
||
assert task is not None
|
||
|
||
# 上报无效状态
|
||
result = self.dispatcher.worker_report(
|
||
worker_id=worker_id,
|
||
previous_task_key=task['task_key'],
|
||
status='invalid_status'
|
||
)
|
||
assert result is None, "无效status应返回None"
|
||
|
||
# 上报非字符串状态
|
||
result2 = self.dispatcher.worker_report(
|
||
worker_id=worker_id,
|
||
previous_task_key=task['task_key'],
|
||
status=123
|
||
)
|
||
assert result2 is None, "非字符串status应返回None"
|
||
|
||
# -------- 场景12: 统计信息一致性 --------
|
||
def test_statistics_consistency(self):
|
||
"""验证统计信息在各操作后的一致性"""
|
||
self._load_test_tasks()
|
||
|
||
# 初始状态
|
||
stats = self.dispatcher.get_statistics()
|
||
assert stats['pending'] == 10
|
||
assert stats['running'] == 0
|
||
assert stats['completed'] == 0
|
||
assert stats['failed'] == 0
|
||
|
||
# Worker领取任务后
|
||
worker_id = "192.168.1.1_AA:BB:CC:DD:EE:01"
|
||
task = self.dispatcher.worker_init(
|
||
worker_id=worker_id,
|
||
ip_address="192.168.1.1",
|
||
mac_address="AA:BB:CC:DD:EE:01",
|
||
hostname="test-pc-stats",
|
||
platform="Windows"
|
||
)
|
||
stats = self.dispatcher.get_statistics()
|
||
assert stats['pending'] == 9
|
||
assert stats['running'] == 1
|
||
|
||
# 上报成功后
|
||
self.dispatcher.worker_report(
|
||
worker_id=worker_id,
|
||
previous_task_key=task['task_key'],
|
||
status='success'
|
||
)
|
||
stats = self.dispatcher.get_statistics()
|
||
assert stats['completed'] == 1
|
||
assert stats['running'] <= 1 # 可能领了新任务
|
||
|
||
|
||
# ==================== PubSub集成测试 ====================
|
||
@skip_no_redis
|
||
class TestPubSubIntegration:
|
||
"""通过PubSub走完整Master-Worker流程
|
||
|
||
启动 DispatcherService 作为Master,
|
||
多个模拟Worker线程通过PubSub通信
|
||
"""
|
||
|
||
def setup_method(self):
|
||
"""每个测试前清空DB 15"""
|
||
helper.flush()
|
||
self.temp_dir = tempfile.mkdtemp()
|
||
config.FAILED_TASKS_CSV = os.path.join(self.temp_dir, 'failed_tasks.csv')
|
||
config.SUCCESS_TASKS_CSV = os.path.join(self.temp_dir, 'success_tasks.csv')
|
||
|
||
def teardown_method(self):
|
||
helper.flush()
|
||
shutil.rmtree(self.temp_dir, ignore_errors=True)
|
||
|
||
def _graceful_stop_service(self, service, clear_data=True):
|
||
"""优雅停止Master服务,避免daemon线程I/O关闭警告
|
||
|
||
先设置 running=False 让监听线程自然退出循环(每次 get_message 超时1秒),
|
||
等待线程退出后再关闭连接,避免在阻塞读取时关闭socket。
|
||
"""
|
||
service.running = False
|
||
# 等待监听线程退出(线程轮询间隔为1秒,等2秒足够)
|
||
time.sleep(2)
|
||
# 线程已退出,安全关闭pubsub连接
|
||
with service._lock:
|
||
for pubsub in service.pubsub_list:
|
||
try:
|
||
pubsub.unsubscribe()
|
||
pubsub.close()
|
||
except Exception:
|
||
pass
|
||
service.pubsub_list.clear()
|
||
# 按需清理数据
|
||
if clear_data:
|
||
service._clear_all_data()
|
||
|
||
def _create_worker_redis(self):
|
||
"""创建Worker使用的Redis连接(DB 15)"""
|
||
return redis.Redis(host='localhost', port=6379, db=TEST_REDIS_DB, decode_responses=True)
|
||
|
||
def _simulate_worker(self, worker_idx, redis_conn, results, errors, stop_event):
|
||
"""模拟单个Worker通过PubSub与Master通信
|
||
|
||
Args:
|
||
worker_idx: Worker编号
|
||
redis_conn: Redis连接
|
||
results: 存放结果的字典
|
||
errors: 存放错误的列表
|
||
stop_event: 停止信号
|
||
"""
|
||
worker_id = f"192.168.1.{worker_idx}_AA:BB:CC:DD:EE:{worker_idx:02d}"
|
||
tasks_done = []
|
||
|
||
try:
|
||
# --- init ---
|
||
pubsub = redis_conn.pubsub()
|
||
response_channel = f"worker:init:response:{worker_id}"
|
||
pubsub.subscribe(response_channel)
|
||
|
||
init_request = {
|
||
'worker_id': worker_id,
|
||
'ip_address': f"192.168.1.{worker_idx}",
|
||
'mac_address': f"AA:BB:CC:DD:EE:{worker_idx:02d}",
|
||
'hostname': f"test-pc-{worker_idx}",
|
||
'platform': "Windows"
|
||
}
|
||
redis_conn.publish("worker:init", json.dumps(init_request))
|
||
|
||
# 等待响应
|
||
task = None
|
||
start = time.time()
|
||
while time.time() - start < 10:
|
||
msg = pubsub.get_message(timeout=1)
|
||
if msg and msg['type'] == 'message':
|
||
response = json.loads(msg['data'])
|
||
task = response.get('task')
|
||
break
|
||
|
||
pubsub.unsubscribe()
|
||
pubsub.close()
|
||
|
||
if task is None:
|
||
results[worker_id] = tasks_done
|
||
return
|
||
|
||
# --- report 循环 ---
|
||
while task and not stop_event.is_set():
|
||
tasks_done.append(task['task_key'])
|
||
time.sleep(0.2) # 模拟处理
|
||
|
||
# 发送report
|
||
pubsub2 = redis_conn.pubsub()
|
||
report_response_channel = f"worker:report:response:{worker_id}"
|
||
pubsub2.subscribe(report_response_channel)
|
||
|
||
report_request = {
|
||
'worker_id': worker_id,
|
||
'previous_task_key': task['task_key'],
|
||
'status': 'success',
|
||
'message': ''
|
||
}
|
||
redis_conn.publish("worker:report", json.dumps(report_request))
|
||
|
||
task = None
|
||
start = time.time()
|
||
while time.time() - start < 10:
|
||
msg = pubsub2.get_message(timeout=1)
|
||
if msg and msg['type'] == 'message':
|
||
response = json.loads(msg['data'])
|
||
task = response.get('task')
|
||
break
|
||
|
||
pubsub2.unsubscribe()
|
||
pubsub2.close()
|
||
|
||
results[worker_id] = tasks_done
|
||
|
||
except Exception as e:
|
||
errors.append(f"Worker {worker_idx}: {e}")
|
||
|
||
def test_full_pubsub_flow(self):
|
||
"""完整PubSub流程: Master启动 → 3个Worker通过PubSub领取并完成所有任务"""
|
||
# 启动Master
|
||
service = create_test_service()
|
||
service.dispatcher.load_tasks_from_csv(TEST_CSV_PATH)
|
||
|
||
# 启动监听线程
|
||
threads = service.start_listeners()
|
||
time.sleep(0.5) # 等待监听线程就绪
|
||
|
||
# 启动3个模拟Worker
|
||
results = {}
|
||
errors = []
|
||
stop_event = threading.Event()
|
||
worker_threads = []
|
||
|
||
for i in range(1, 4):
|
||
worker_redis = self._create_worker_redis()
|
||
t = threading.Thread(
|
||
target=self._simulate_worker,
|
||
args=(i, worker_redis, results, errors, stop_event)
|
||
)
|
||
worker_threads.append(t)
|
||
t.start()
|
||
|
||
# 等待所有Worker完成
|
||
for t in worker_threads:
|
||
t.join(timeout=60)
|
||
|
||
# 优雅停止Master(先让线程自然退出再关闭连接)
|
||
self._graceful_stop_service(service, clear_data=False)
|
||
|
||
# 验证
|
||
assert len(errors) == 0, f"不应有错误: {errors}"
|
||
|
||
all_tasks = []
|
||
for tasks in results.values():
|
||
all_tasks.extend(tasks)
|
||
|
||
assert len(all_tasks) == 10, \
|
||
f"所有10个任务应被完成,实际: {len(all_tasks)},详情: {results}"
|
||
assert len(set(all_tasks)) == 10, "不应有重复分配"
|
||
|
||
def test_pubsub_worker_stop(self):
|
||
"""PubSub流程: Worker领取任务后上报stop"""
|
||
service = create_test_service()
|
||
service.dispatcher.load_tasks_from_csv(TEST_CSV_PATH)
|
||
threads = service.start_listeners()
|
||
time.sleep(0.5)
|
||
|
||
worker_id = "192.168.1.1_AA:BB:CC:DD:EE:01"
|
||
worker_redis = self._create_worker_redis()
|
||
|
||
try:
|
||
# init
|
||
pubsub = worker_redis.pubsub()
|
||
pubsub.subscribe(f"worker:init:response:{worker_id}")
|
||
worker_redis.publish("worker:init", json.dumps({
|
||
'worker_id': worker_id,
|
||
'ip_address': "192.168.1.1",
|
||
'mac_address': "AA:BB:CC:DD:EE:01",
|
||
'hostname': "test-pc-stop",
|
||
'platform': "Windows"
|
||
}))
|
||
|
||
task = None
|
||
start = time.time()
|
||
while time.time() - start < 10:
|
||
msg = pubsub.get_message(timeout=1)
|
||
if msg and msg['type'] == 'message':
|
||
task = json.loads(msg['data']).get('task')
|
||
break
|
||
pubsub.unsubscribe()
|
||
pubsub.close()
|
||
|
||
assert task is not None, "Worker应能领到任务"
|
||
|
||
# report stop
|
||
pubsub2 = worker_redis.pubsub()
|
||
pubsub2.subscribe(f"worker:report:response:{worker_id}")
|
||
worker_redis.publish("worker:report", json.dumps({
|
||
'worker_id': worker_id,
|
||
'previous_task_key': task['task_key'],
|
||
'status': 'stop',
|
||
'message': '设备异常'
|
||
}))
|
||
|
||
response_task = "NOT_RECEIVED"
|
||
start = time.time()
|
||
while time.time() - start < 10:
|
||
msg = pubsub2.get_message(timeout=1)
|
||
if msg and msg['type'] == 'message':
|
||
response_task = json.loads(msg['data']).get('task')
|
||
break
|
||
pubsub2.unsubscribe()
|
||
pubsub2.close()
|
||
|
||
assert response_task is None, "stop后Master不应返回新任务"
|
||
|
||
# 验证Worker已被移除
|
||
time.sleep(0.5)
|
||
workers = service.dispatcher.get_registered_workers()
|
||
worker_ids = [w['worker_id'] for w in workers]
|
||
assert worker_id not in worker_ids, "Worker应被移除"
|
||
|
||
finally:
|
||
self._graceful_stop_service(service, clear_data=True)
|
||
|
||
|
||
# ==================== 入口 ====================
|
||
if __name__ == '__main__':
|
||
# 支持直接运行: python tests/test_master_worker.py
|
||
pytest.main([__file__, '-v', '--tb=short'])
|