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

883 lines
34 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# -*- 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'])