sycommon-python-lib 0.2.6a14__py3-none-any.whl → 0.2.7a0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- sycommon/rabbitmq/rabbitmq_client.py +121 -85
- sycommon/tests/test_queue_priority_fallback.py +259 -0
- {sycommon_python_lib-0.2.6a14.dist-info → sycommon_python_lib-0.2.7a0.dist-info}/METADATA +1 -1
- {sycommon_python_lib-0.2.6a14.dist-info → sycommon_python_lib-0.2.7a0.dist-info}/RECORD +7 -6
- {sycommon_python_lib-0.2.6a14.dist-info → sycommon_python_lib-0.2.7a0.dist-info}/WHEEL +0 -0
- {sycommon_python_lib-0.2.6a14.dist-info → sycommon_python_lib-0.2.7a0.dist-info}/entry_points.txt +0 -0
- {sycommon_python_lib-0.2.6a14.dist-info → sycommon_python_lib-0.2.7a0.dist-info}/top_level.txt +0 -0
|
@@ -92,14 +92,51 @@ class RabbitMQClient:
|
|
|
92
92
|
except Exception:
|
|
93
93
|
return False
|
|
94
94
|
|
|
95
|
+
async def _probe_queue_state(self) -> Optional[tuple]:
|
|
96
|
+
"""被动声明检查队列状态,返回 (消息数, 消费者数)。队列不存在返回 None。"""
|
|
97
|
+
channel, _ = await self.connection_pool.acquire_channel()
|
|
98
|
+
try:
|
|
99
|
+
queue = await channel.declare_queue(name=self.queue_name, passive=True)
|
|
100
|
+
if queue.declaration_result:
|
|
101
|
+
return (
|
|
102
|
+
queue.declaration_result.message_count,
|
|
103
|
+
queue.declaration_result.consumer_count,
|
|
104
|
+
)
|
|
105
|
+
return (0, 0)
|
|
106
|
+
finally:
|
|
107
|
+
await channel.close()
|
|
108
|
+
|
|
109
|
+
async def _refresh_channel(self) -> None:
|
|
110
|
+
"""PRECONDITION_FAILED 会关闭 self._channel,需重建通道并重新声明交换机。
|
|
111
|
+
|
|
112
|
+
重建后 self._channel / self._channel_conn / self._exchange 保持一致,
|
|
113
|
+
self._queue 被置空,由调用方重新声明。
|
|
114
|
+
"""
|
|
115
|
+
if self._channel and not self._channel.is_closed:
|
|
116
|
+
try:
|
|
117
|
+
await self._channel.close()
|
|
118
|
+
except Exception:
|
|
119
|
+
pass
|
|
120
|
+
self._channel = None
|
|
121
|
+
self._exchange = None
|
|
122
|
+
self._queue = None
|
|
123
|
+
self._channel, self._channel_conn = await self.connection_pool.acquire_channel()
|
|
124
|
+
self._exchange = await self._channel.declare_exchange(
|
|
125
|
+
name=self.exchange_name,
|
|
126
|
+
type=self.exchange_type,
|
|
127
|
+
durable=self.durable,
|
|
128
|
+
auto_delete=self.auto_delete,
|
|
129
|
+
)
|
|
130
|
+
|
|
95
131
|
async def _try_migrate_queue(self, original_error: Exception) -> bool:
|
|
96
132
|
"""尝试迁移队列以支持 x-max-priority。
|
|
97
133
|
|
|
98
|
-
|
|
99
|
-
|
|
134
|
+
仅当队列为空且无消费者时,删除并重建为带优先级队列(破坏性,但安全)。
|
|
135
|
+
非空或有消费者时跳过迁移,由调用方走降级复用路径(见 _fallback_passive_declare)。
|
|
100
136
|
|
|
101
137
|
Returns:
|
|
102
|
-
True
|
|
138
|
+
True 迁移成功(self._queue 已就绪);
|
|
139
|
+
False 未迁移(队列非空/有消费者/非参数冲突),交由降级逻辑处理
|
|
103
140
|
"""
|
|
104
141
|
from aiormq.exceptions import ChannelPreconditionFailed
|
|
105
142
|
|
|
@@ -110,32 +147,24 @@ class RabbitMQClient:
|
|
|
110
147
|
return False
|
|
111
148
|
|
|
112
149
|
try:
|
|
113
|
-
# PRECONDITION_FAILED 会关闭当前 channel
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
if queue.declaration_result:
|
|
120
|
-
message_count = queue.declaration_result.message_count
|
|
121
|
-
consumer_count = queue.declaration_result.consumer_count
|
|
122
|
-
else:
|
|
123
|
-
message_count = 0
|
|
124
|
-
consumer_count = 0
|
|
125
|
-
finally:
|
|
126
|
-
await channel.close()
|
|
150
|
+
# PRECONDITION_FAILED 会关闭当前 channel,用独立 channel 检查队列状态
|
|
151
|
+
state = await self._probe_queue_state()
|
|
152
|
+
if state is None:
|
|
153
|
+
# 队列不存在,不是参数冲突,交由上层处理
|
|
154
|
+
return False
|
|
155
|
+
message_count, consumer_count = state
|
|
127
156
|
|
|
128
|
-
if message_count > 0:
|
|
157
|
+
if message_count > 0 or consumer_count > 0:
|
|
129
158
|
logger.warning(
|
|
130
159
|
f"队列 '{self.queue_name}' 迁移跳过: "
|
|
131
|
-
f"存在 {message_count} 条消息"
|
|
160
|
+
f"存在 {message_count} 条消息 / {consumer_count} 个消费者"
|
|
132
161
|
)
|
|
133
162
|
return False
|
|
134
163
|
|
|
135
164
|
logger.info(
|
|
136
165
|
f"开始迁移队列 '{self.queue_name}',添加 x-max-priority={self.max_priority}")
|
|
137
166
|
|
|
138
|
-
#
|
|
167
|
+
# 删除旧队列(空且无消费者,安全)
|
|
139
168
|
channel, _ = await self.connection_pool.acquire_channel()
|
|
140
169
|
try:
|
|
141
170
|
await channel.queue_delete(
|
|
@@ -146,26 +175,8 @@ class RabbitMQClient:
|
|
|
146
175
|
finally:
|
|
147
176
|
await channel.close()
|
|
148
177
|
|
|
149
|
-
#
|
|
150
|
-
|
|
151
|
-
try:
|
|
152
|
-
await self._channel.close()
|
|
153
|
-
except Exception:
|
|
154
|
-
pass
|
|
155
|
-
self._channel = None
|
|
156
|
-
self._exchange = None
|
|
157
|
-
self._queue = None
|
|
158
|
-
|
|
159
|
-
# 重建 self._channel(旧 channel 已被 PRECONDITION_FAILED 关闭)
|
|
160
|
-
self._channel, self._channel_conn = await self.connection_pool.acquire_channel()
|
|
161
|
-
|
|
162
|
-
# 重新声明交换机
|
|
163
|
-
self._exchange = await self._channel.declare_exchange(
|
|
164
|
-
name=self.exchange_name,
|
|
165
|
-
type=self.exchange_type,
|
|
166
|
-
durable=self.durable,
|
|
167
|
-
auto_delete=self.auto_delete,
|
|
168
|
-
)
|
|
178
|
+
# 重建通道并重新声明交换机(旧通道已被 PRECONDITION_FAILED 关闭)
|
|
179
|
+
await self._refresh_channel()
|
|
169
180
|
|
|
170
181
|
# 重新声明带 priority 的队列
|
|
171
182
|
self._queue = await self._channel.declare_queue(
|
|
@@ -183,6 +194,72 @@ class RabbitMQClient:
|
|
|
183
194
|
logger.error(f"队列 '{self.queue_name}' 迁移失败: {migrate_error}")
|
|
184
195
|
return False
|
|
185
196
|
|
|
197
|
+
async def _fallback_passive_declare(self, original_error: Exception) -> Optional[AbstractQueue]:
|
|
198
|
+
"""降级:参数不匹配且无法安全迁移时,被动声明复用现有队列。
|
|
199
|
+
|
|
200
|
+
保证消费可正常启动、不丢消息,代价是该队列不应用 x-max-priority。
|
|
201
|
+
适用于:队列已存在但参数(如 x-max-priority)与期望不一致,且队列非空或正被消费。
|
|
202
|
+
"""
|
|
203
|
+
from aiormq.exceptions import ChannelPreconditionFailed
|
|
204
|
+
|
|
205
|
+
if not isinstance(original_error, ChannelPreconditionFailed):
|
|
206
|
+
# 非 PRECONDITION_FAILED(真实错误),向上抛出
|
|
207
|
+
raise original_error
|
|
208
|
+
|
|
209
|
+
logger.warning(
|
|
210
|
+
f"⚠️ 队列 '{self.queue_name}' 参数不匹配(x-max-priority 等)且无法安全迁移,"
|
|
211
|
+
f"降级为被动复用现有队列(x-max-priority={self.max_priority} 未生效,消费不受影响)"
|
|
212
|
+
)
|
|
213
|
+
# 旧通道已被 PRECONDITION_FAILED 关闭,重建后被动声明
|
|
214
|
+
await self._refresh_channel()
|
|
215
|
+
queue = await self._channel.declare_queue(name=self.queue_name, passive=True)
|
|
216
|
+
await queue.bind(exchange=self._exchange, routing_key=self.routing_key)
|
|
217
|
+
logger.info(f"已被动复用现有队列 '{self.queue_name}',消费可正常启动")
|
|
218
|
+
return queue
|
|
219
|
+
|
|
220
|
+
async def _ensure_consumer_queue(self) -> Optional[AbstractQueue]:
|
|
221
|
+
"""声明并绑定消费者队列,自动处理 x-max-priority 等参数不匹配。
|
|
222
|
+
|
|
223
|
+
仅当 queue_name 符合消费者命名规范(以 app_name 结尾)时初始化队列。
|
|
224
|
+
非破坏性策略(绝不丢消息):
|
|
225
|
+
1. 用期望参数声明(含 x-max-priority)。
|
|
226
|
+
2. PRECONDITION_FAILED 参数不匹配时:
|
|
227
|
+
- 队列空且无消费者 → 删除重建(升级为优先级队列)。
|
|
228
|
+
- 否则 → 被动声明复用现有队列(降级,不丢消息,保证消费可启动)。
|
|
229
|
+
"""
|
|
230
|
+
# 【核心保留逻辑】只有当 queue_name 存在且符合消费者命名规范时,才初始化队列
|
|
231
|
+
if not (self.queue_name and self.queue_name.endswith(f".{self.app_name}")):
|
|
232
|
+
return None
|
|
233
|
+
|
|
234
|
+
queue_args = {}
|
|
235
|
+
if self.max_priority is not None:
|
|
236
|
+
queue_args["x-max-priority"] = self.max_priority
|
|
237
|
+
|
|
238
|
+
try:
|
|
239
|
+
queue = await self._channel.declare_queue(
|
|
240
|
+
name=self.queue_name,
|
|
241
|
+
durable=self.durable,
|
|
242
|
+
auto_delete=self.auto_delete,
|
|
243
|
+
passive=not self.create_if_not_exists,
|
|
244
|
+
arguments=queue_args or None,
|
|
245
|
+
)
|
|
246
|
+
await queue.bind(exchange=self._exchange, routing_key=self.routing_key)
|
|
247
|
+
logger.debug(f"队列就绪: {self.queue_name}")
|
|
248
|
+
return queue
|
|
249
|
+
except Exception as e:
|
|
250
|
+
if not self.create_if_not_exists:
|
|
251
|
+
self._queue = None
|
|
252
|
+
logger.warning(
|
|
253
|
+
f"⚠️ 队列 '{self.queue_name}' 不存在或无权访问 (Passive模式),跳过绑定。")
|
|
254
|
+
return None
|
|
255
|
+
|
|
256
|
+
# 参数不匹配:尝试迁移(空队列)或降级复用(非破坏性,绝不丢消息)
|
|
257
|
+
migrated = await self._try_migrate_queue(e)
|
|
258
|
+
if migrated:
|
|
259
|
+
return self._queue
|
|
260
|
+
|
|
261
|
+
return await self._fallback_passive_declare(e)
|
|
262
|
+
|
|
186
263
|
async def _rebuild_resources(self) -> None:
|
|
187
264
|
if not self._channel or self._channel.is_closed:
|
|
188
265
|
raise RuntimeError("无有效通道,无法重建资源")
|
|
@@ -202,37 +279,8 @@ class RabbitMQClient:
|
|
|
202
279
|
raise
|
|
203
280
|
|
|
204
281
|
# 2. 声明队列 (仅消费者需要)
|
|
205
|
-
#
|
|
206
|
-
|
|
207
|
-
queue_args = {}
|
|
208
|
-
if self.max_priority is not None:
|
|
209
|
-
queue_args["x-max-priority"] = self.max_priority
|
|
210
|
-
|
|
211
|
-
try:
|
|
212
|
-
self._queue = await self._channel.declare_queue(
|
|
213
|
-
name=self.queue_name,
|
|
214
|
-
durable=self.durable,
|
|
215
|
-
auto_delete=self.auto_delete,
|
|
216
|
-
passive=not self.create_if_not_exists,
|
|
217
|
-
arguments=queue_args or None,
|
|
218
|
-
)
|
|
219
|
-
await self._queue.bind(exchange=self._exchange, routing_key=self.routing_key)
|
|
220
|
-
logger.debug(f"队列就绪: {self.queue_name}")
|
|
221
|
-
except Exception as e:
|
|
222
|
-
if not self.create_if_not_exists:
|
|
223
|
-
self._queue = None
|
|
224
|
-
logger.warning(
|
|
225
|
-
f"⚠️ 队列 '{self.queue_name}' 不存在或无权访问 (Passive模式),跳过绑定。")
|
|
226
|
-
return
|
|
227
|
-
|
|
228
|
-
# 尝试自动迁移:参数不匹配时删除重建
|
|
229
|
-
migrated = await self._try_migrate_queue(e)
|
|
230
|
-
if not migrated:
|
|
231
|
-
# 迁移失败(非参数不匹配 / 队列非空 / 有消费者),抛出原始异常
|
|
232
|
-
raise
|
|
233
|
-
else:
|
|
234
|
-
# 不符合命名规范或无队列名,清空队列引用
|
|
235
|
-
self._queue = None
|
|
282
|
+
# 委托给 _ensure_consumer_queue 统一处理参数不匹配(含 x-max-priority)
|
|
283
|
+
self._queue = await self._ensure_consumer_queue()
|
|
236
284
|
|
|
237
285
|
async def _cleanup_running_tasks(self) -> None:
|
|
238
286
|
"""仅清理后台 handler 任务(不做任何破坏性清理,不调 queue.cancel、不关通道)"""
|
|
@@ -370,21 +418,9 @@ class RabbitMQClient:
|
|
|
370
418
|
if not self._queue and self.queue_name and self.queue_name.endswith(f".{self.app_name}"):
|
|
371
419
|
logger.warning(f"队列对象在消费前为空,尝试强制初始化: {self.queue_name}")
|
|
372
420
|
try:
|
|
373
|
-
|
|
374
|
-
if self.max_priority is not None:
|
|
375
|
-
queue_args["x-max-priority"] = self.max_priority
|
|
376
|
-
self._queue = await self._channel.declare_queue(
|
|
377
|
-
name=self.queue_name,
|
|
378
|
-
durable=self.durable,
|
|
379
|
-
auto_delete=self.auto_delete,
|
|
380
|
-
passive=False,
|
|
381
|
-
arguments=queue_args or None,
|
|
382
|
-
)
|
|
383
|
-
await self._queue.bind(exchange=self._exchange, routing_key=self.routing_key)
|
|
421
|
+
self._queue = await self._ensure_consumer_queue()
|
|
384
422
|
except Exception as e:
|
|
385
|
-
|
|
386
|
-
if not migrated:
|
|
387
|
-
raise RuntimeError(f"无法初始化队列 {self.queue_name}")
|
|
423
|
+
raise RuntimeError(f"无法初始化队列 {self.queue_name}: {e}")
|
|
388
424
|
|
|
389
425
|
if not self._queue:
|
|
390
426
|
raise RuntimeError("未配置队列名或队列初始化失败")
|
|
@@ -0,0 +1,259 @@
|
|
|
1
|
+
"""
|
|
2
|
+
验证 _ensure_consumer_queue 在 x-max-priority 参数不匹配时的非破坏性决策。
|
|
3
|
+
|
|
4
|
+
覆盖日志报告的场景:
|
|
5
|
+
PRECONDITION_FAILED - inequivalent arg 'x-max-priority' ... received the value '10' but current is none
|
|
6
|
+
|
|
7
|
+
之前实现:参数不匹配时仅当队列为空才迁移,否则直接 raise → 消费者无法启动、连接失败。
|
|
8
|
+
本次修复:参数不匹配时
|
|
9
|
+
- 队列空且无消费者 → 删除重建为优先级队列(升级)
|
|
10
|
+
- 队列非空或有消费者 → 被动声明复用现有队列(降级,绝不丢消息)
|
|
11
|
+
|
|
12
|
+
运行: cd sycommon-python-lib && python -m pytest src/sycommon/tests/test_queue_priority_fallback.py -v
|
|
13
|
+
"""
|
|
14
|
+
import asyncio
|
|
15
|
+
import sys
|
|
16
|
+
import os
|
|
17
|
+
from unittest.mock import AsyncMock, MagicMock
|
|
18
|
+
|
|
19
|
+
sys.path.insert(0, os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", ".."))
|
|
20
|
+
|
|
21
|
+
from aiormq.exceptions import (
|
|
22
|
+
ChannelPreconditionFailed,
|
|
23
|
+
ChannelNotFoundEntity,
|
|
24
|
+
ChannelAccessRefused,
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
from sycommon.rabbitmq.rabbitmq_client import RabbitMQClient
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class FakeDeclResult:
|
|
31
|
+
def __init__(self, message_count=0, consumer_count=0):
|
|
32
|
+
self.message_count = message_count
|
|
33
|
+
self.consumer_count = consumer_count
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class FakeQueue:
|
|
37
|
+
"""模拟声明成功后返回的 aio_pika Queue"""
|
|
38
|
+
|
|
39
|
+
def __init__(self, declaration_result=None):
|
|
40
|
+
self.declaration_result = declaration_result
|
|
41
|
+
self.bind = AsyncMock()
|
|
42
|
+
self.consume = AsyncMock(return_value="ctag-fake")
|
|
43
|
+
self.cancel = AsyncMock()
|
|
44
|
+
self.name = "fake"
|
|
45
|
+
self._consumers = {}
|
|
46
|
+
|
|
47
|
+
async def declare(self, timeout=None):
|
|
48
|
+
return self.declaration_result
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class FakeChannel:
|
|
52
|
+
"""模拟 aio_pika Channel。
|
|
53
|
+
|
|
54
|
+
declare_queue 语义:
|
|
55
|
+
passive=True → 返回 _passive_queue,或抛 ChannelNotFoundEntity
|
|
56
|
+
否则 → 若 _declare_exc 非空则抛它(模拟 RPC 层声明失败),成功则返回 _declared_queue
|
|
57
|
+
queue_delete / declare_exchange 为 AsyncMock。
|
|
58
|
+
"""
|
|
59
|
+
|
|
60
|
+
def __init__(self, *, declared_queue=None, passive_queue=None, declare_exc=None):
|
|
61
|
+
self.is_closed = False
|
|
62
|
+
self.close = AsyncMock()
|
|
63
|
+
self.declare_exchange = AsyncMock(return_value=MagicMock())
|
|
64
|
+
self.queue_delete = AsyncMock()
|
|
65
|
+
self._declared_queue = declared_queue
|
|
66
|
+
self._passive_queue = passive_queue
|
|
67
|
+
self._declare_exc = declare_exc # 单次:声明成功后置 None
|
|
68
|
+
|
|
69
|
+
async def declare_queue(self, name=None, *, durable=False, auto_delete=False,
|
|
70
|
+
passive=False, arguments=None, **kw):
|
|
71
|
+
if passive:
|
|
72
|
+
if self._passive_queue is not None:
|
|
73
|
+
return self._passive_queue
|
|
74
|
+
raise ChannelNotFoundEntity(404, "NOT_FOUND - no queue")
|
|
75
|
+
# 非被动声明:可能抛出声明异常
|
|
76
|
+
if self._declare_exc is not None:
|
|
77
|
+
exc = self._declare_exc
|
|
78
|
+
self._declare_exc = None # 单次触发,后续重建声明成功
|
|
79
|
+
raise exc
|
|
80
|
+
return self._declared_queue or FakeQueue()
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _make_client(*, max_priority=10, create_if_not_exists=True):
|
|
84
|
+
pool = MagicMock()
|
|
85
|
+
pool.acquire_channel = AsyncMock(return_value=(FakeChannel(), MagicMock()))
|
|
86
|
+
client = RabbitMQClient.__new__(RabbitMQClient)
|
|
87
|
+
client.connection_pool = pool
|
|
88
|
+
client.exchange_name = "system.topic.exchange"
|
|
89
|
+
client.exchange_type = "topic"
|
|
90
|
+
client.app_name = "shengye-platform-invoice-assist"
|
|
91
|
+
client.queue_name = (
|
|
92
|
+
"jms.queue.zhongdeng.search_complate_notify_AC.shengye-platform-invoice-assist"
|
|
93
|
+
)
|
|
94
|
+
client.routing_key = "jms.queue.#"
|
|
95
|
+
client.durable = True
|
|
96
|
+
client.auto_delete = False
|
|
97
|
+
client.max_priority = max_priority
|
|
98
|
+
client.create_if_not_exists = create_if_not_exists
|
|
99
|
+
client._channel = None
|
|
100
|
+
client._channel_conn = None
|
|
101
|
+
client._exchange = MagicMock()
|
|
102
|
+
client._queue = None
|
|
103
|
+
return client
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
CPF = lambda: ChannelPreconditionFailed(
|
|
107
|
+
406, "PRECONDITION_FAILED - inequivalent arg 'x-max-priority'")
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def _run(coro_fn):
|
|
111
|
+
loop = asyncio.new_event_loop()
|
|
112
|
+
try:
|
|
113
|
+
return loop.run_until_complete(coro_fn())
|
|
114
|
+
finally:
|
|
115
|
+
loop.close()
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def test_migrate_when_queue_empty_and_no_consumers():
|
|
119
|
+
"""队列存在(无 priority) + 要 x-max-priority=10 + 队列空且无消费者
|
|
120
|
+
→ 删除重建为优先级队列(迁移成功),返回非 None 且触发了删除"""
|
|
121
|
+
client = _make_client(max_priority=10)
|
|
122
|
+
|
|
123
|
+
probe_channel = FakeChannel(passive_queue=FakeQueue(
|
|
124
|
+
declaration_result=FakeDeclResult(message_count=0, consumer_count=0)))
|
|
125
|
+
delete_channel = FakeChannel()
|
|
126
|
+
rebuilt_queue = FakeQueue(declaration_result=FakeDeclResult())
|
|
127
|
+
rebuilt_channel = FakeChannel(declared_queue=rebuilt_queue)
|
|
128
|
+
|
|
129
|
+
client.connection_pool.acquire_channel = AsyncMock(side_effect=[
|
|
130
|
+
(probe_channel, MagicMock()),
|
|
131
|
+
(delete_channel, MagicMock()),
|
|
132
|
+
(rebuilt_channel, MagicMock()),
|
|
133
|
+
])
|
|
134
|
+
|
|
135
|
+
main_channel = FakeChannel(declare_exc=CPF())
|
|
136
|
+
client._channel = main_channel
|
|
137
|
+
|
|
138
|
+
result = _run(client._ensure_consumer_queue)
|
|
139
|
+
|
|
140
|
+
assert result is not None, "迁移成功应返回非 None 队列"
|
|
141
|
+
assert client._queue is rebuilt_queue
|
|
142
|
+
assert delete_channel.queue_delete.assert_awaited_once() is None, "应删除旧空队列"
|
|
143
|
+
assert rebuilt_channel is client._channel, "应切换到重建通道"
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def test_fallback_when_queue_has_messages():
|
|
147
|
+
"""队列有 5 条消息 → 不删除,被动声明复用现有队列(降级,绝不丢消息)"""
|
|
148
|
+
client = _make_client(max_priority=10)
|
|
149
|
+
|
|
150
|
+
probe_channel = FakeChannel(passive_queue=FakeQueue(
|
|
151
|
+
declaration_result=FakeDeclResult(message_count=5, consumer_count=0)))
|
|
152
|
+
fallback_queue = FakeQueue(declaration_result=FakeDeclResult())
|
|
153
|
+
fallback_channel = FakeChannel(passive_queue=fallback_queue)
|
|
154
|
+
|
|
155
|
+
client.connection_pool.acquire_channel = AsyncMock(side_effect=[
|
|
156
|
+
(probe_channel, MagicMock()),
|
|
157
|
+
(fallback_channel, MagicMock()),
|
|
158
|
+
])
|
|
159
|
+
|
|
160
|
+
client._channel = FakeChannel(declare_exc=CPF())
|
|
161
|
+
|
|
162
|
+
result = _run(client._ensure_consumer_queue)
|
|
163
|
+
|
|
164
|
+
assert result is fallback_queue, "应被动复用现有队列"
|
|
165
|
+
# 关键:从未删除有数据的队列(queue_delete 是 AsyncMock,断言未调用)
|
|
166
|
+
probe_channel.queue_delete.assert_not_awaited()
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def test_fallback_when_queue_has_consumer():
|
|
170
|
+
"""队列有消费者(consumer_count=1)→ 不删除(保护在线消费者),被动复用"""
|
|
171
|
+
client = _make_client(max_priority=10)
|
|
172
|
+
|
|
173
|
+
probe_channel = FakeChannel(passive_queue=FakeQueue(
|
|
174
|
+
declaration_result=FakeDeclResult(message_count=0, consumer_count=1)))
|
|
175
|
+
fallback_queue = FakeQueue(declaration_result=FakeDeclResult())
|
|
176
|
+
fallback_channel = FakeChannel(passive_queue=fallback_queue)
|
|
177
|
+
|
|
178
|
+
client.connection_pool.acquire_channel = AsyncMock(side_effect=[
|
|
179
|
+
(probe_channel, MagicMock()),
|
|
180
|
+
(fallback_channel, MagicMock()),
|
|
181
|
+
])
|
|
182
|
+
|
|
183
|
+
client._channel = FakeChannel(declare_exc=CPF())
|
|
184
|
+
|
|
185
|
+
result = _run(client._ensure_consumer_queue)
|
|
186
|
+
|
|
187
|
+
assert result is fallback_queue
|
|
188
|
+
probe_channel.queue_delete.assert_not_awaited()
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def test_non_precondition_error_propagates():
|
|
192
|
+
"""非 PRECONDITION_FAILED 的真实错误(如 ACCESS_REFUSED)→ 不降级,向上抛出"""
|
|
193
|
+
client = _make_client(max_priority=10)
|
|
194
|
+
client._channel = FakeChannel(
|
|
195
|
+
declare_exc=ChannelAccessRefused(403, "ACCESS_REFUSED"))
|
|
196
|
+
|
|
197
|
+
raised = False
|
|
198
|
+
loop = asyncio.new_event_loop()
|
|
199
|
+
try:
|
|
200
|
+
loop.run_until_complete(client._ensure_consumer_queue())
|
|
201
|
+
except ChannelAccessRefused:
|
|
202
|
+
raised = True
|
|
203
|
+
finally:
|
|
204
|
+
loop.close()
|
|
205
|
+
assert raised, "非 PRECONDITION_FAILED 的错误必须向上抛出,不能被吞掉或降级"
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
def test_max_priority_none_skips_migration_but_fallback_handles_cpf():
|
|
209
|
+
"""max_priority=None:_try_migrate_queue 直接返回 False,但 CPF 仍由 _fallback_passive_declare 处理"""
|
|
210
|
+
client = _make_client(max_priority=None)
|
|
211
|
+
|
|
212
|
+
fallback_queue = FakeQueue(declaration_result=FakeDeclResult())
|
|
213
|
+
fallback_channel = FakeChannel(passive_queue=fallback_queue)
|
|
214
|
+
client.connection_pool.acquire_channel = AsyncMock(side_effect=[
|
|
215
|
+
(fallback_channel, MagicMock()),
|
|
216
|
+
])
|
|
217
|
+
client._channel = FakeChannel(declare_exc=CPF())
|
|
218
|
+
|
|
219
|
+
result = _run(client._ensure_consumer_queue)
|
|
220
|
+
assert result is fallback_queue, "max_priority=None 时 CPF 仍应降级复用,保证可启动"
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
def test_non_consumer_queue_name_returns_none():
|
|
224
|
+
"""queue_name 不以 app_name 结尾(非消费者命名)→ 返回 None,不声明"""
|
|
225
|
+
client = _make_client()
|
|
226
|
+
client.queue_name = "some.producer.queue"
|
|
227
|
+
|
|
228
|
+
result = _run(client._ensure_consumer_queue)
|
|
229
|
+
assert result is None
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
def test_normal_declare_success():
|
|
233
|
+
"""参数匹配/队列不存在(create_if_not_exists)→ 正常声明,不进入迁移/降级"""
|
|
234
|
+
client = _make_client(max_priority=10)
|
|
235
|
+
ok_queue = FakeQueue(declaration_result=FakeDeclResult())
|
|
236
|
+
client._channel = FakeChannel(declared_queue=ok_queue)
|
|
237
|
+
|
|
238
|
+
result = _run(client._ensure_consumer_queue)
|
|
239
|
+
assert result is ok_queue
|
|
240
|
+
ok_queue.bind.assert_awaited_once()
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
if __name__ == "__main__":
|
|
244
|
+
for fn in [
|
|
245
|
+
test_migrate_when_queue_empty_and_no_consumers,
|
|
246
|
+
test_fallback_when_queue_has_messages,
|
|
247
|
+
test_fallback_when_queue_has_consumer,
|
|
248
|
+
test_non_precondition_error_propagates,
|
|
249
|
+
test_max_priority_none_skips_migration_but_fallback_handles_cpf,
|
|
250
|
+
test_non_consumer_queue_name_returns_none,
|
|
251
|
+
test_normal_declare_success,
|
|
252
|
+
]:
|
|
253
|
+
try:
|
|
254
|
+
fn()
|
|
255
|
+
print(f"✅ {fn.__name__}")
|
|
256
|
+
except AssertionError as e:
|
|
257
|
+
print(f"❌ {fn.__name__}: {e}")
|
|
258
|
+
except Exception as e:
|
|
259
|
+
print(f"💥 {fn.__name__}: {type(e).__name__}: {e}")
|
|
@@ -240,7 +240,7 @@ sycommon/notice/__init__.py,sha256=cVYbKlLLMFHckyDpx21CTZCMTufqZk_uHuEIxUNxwx4,1
|
|
|
240
240
|
sycommon/notice/uvicorn_monitor.py,sha256=O4R25z032dAlvlbU0WLo0LJqoL1Z52fMFgFdb1e3Cls,11833
|
|
241
241
|
sycommon/notice/wecom_message.py,sha256=_oA6hg0Ask7NkFbvUpL78WOmyUb6uW7KcY71GhNKiJY,12363
|
|
242
242
|
sycommon/rabbitmq/process_pool_consumer.py,sha256=EXdNypgathjTfPqkccoAW0HiVB9HuZLGhPbWUcYh0_Q,29750
|
|
243
|
-
sycommon/rabbitmq/rabbitmq_client.py,sha256=
|
|
243
|
+
sycommon/rabbitmq/rabbitmq_client.py,sha256=AEQJmpm5n4fuuRo8pSV_QX47XFu6y8JB0dvDN5YUF2s,23501
|
|
244
244
|
sycommon/rabbitmq/rabbitmq_pool.py,sha256=mUo1-Nlj0xXKG9MdBA0hvkt1NIvKD4AGsDouzQDY5nQ,13835
|
|
245
245
|
sycommon/rabbitmq/rabbitmq_service.py,sha256=q9c9-9RSvJaY_PE62PQ4mX7ropNsGr0Frcrm5gjaPao,9774
|
|
246
246
|
sycommon/rabbitmq/rabbitmq_service_client_manager.py,sha256=UZgpD6n0mdo5u3gaGW-jhUuPV50bRNdXFESzL9ABsR4,10623
|
|
@@ -278,6 +278,7 @@ sycommon/tests/test_mcp_server.py,sha256=2T87sSTdigdAl0BeWdxSeyII9ot2JOltySwLY2J
|
|
|
278
278
|
sycommon/tests/test_minimax_reasoning.py,sha256=IKrPEf_Ybxg_vz4AITStkb-GKo5NooaLiLK3RbmW_Cw,9354
|
|
279
279
|
sycommon/tests/test_mq.py,sha256=Gpr9Eep-osRkcnlwGeshROEf83Ai3qYbAMHwpoML68o,4366
|
|
280
280
|
sycommon/tests/test_oa_login.py,sha256=5psNnmUst20x-LdjPa_liunhMLGky2uTDVpQzefBnQE,6239
|
|
281
|
+
sycommon/tests/test_queue_priority_fallback.py,sha256=fO0M37PKnxAYbROenx_iUUvmXcwhrnBczPsI_e1PdgE,9766
|
|
281
282
|
sycommon/tests/test_real_summarization.py,sha256=7B89es7-UwULk-kq9xUiWH1ylXUO3QDJm4oZWzJNPk0,6193
|
|
282
283
|
sycommon/tests/test_summarization_config.py,sha256=Ztb-eJXt2NrpBXNp7xST4Cwq4x8DK9pFuC7-bXUUOaE,16860
|
|
283
284
|
sycommon/tests/test_summarization_real.py,sha256=iTwwA_xQd5Zgkn849lu2M0zIHPnMp-3szsIGrg6G9J4,12406
|
|
@@ -292,8 +293,8 @@ sycommon/tools/syemail.py,sha256=BDFhgf7WDOQeTcjxJEQdu0dQhnHFPO_p3eI0-Ni3LhQ,561
|
|
|
292
293
|
sycommon/tools/timing.py,sha256=OiiE7P07lRoMzX9kzb8sZU9cDb0zNnqIlY5pWqHcnkY,2064
|
|
293
294
|
sycommon/xxljob/__init__.py,sha256=7eoBlQxv-B39IfRSCY2bkqdGYs1QRe1umAWd88VMEEM,86
|
|
294
295
|
sycommon/xxljob/xxljob_service.py,sha256=1yifwIBNGsCIxLnQjHKiBlbsigc_zvPH-dMTZcNxe-Q,7649
|
|
295
|
-
sycommon_python_lib-0.2.
|
|
296
|
-
sycommon_python_lib-0.2.
|
|
297
|
-
sycommon_python_lib-0.2.
|
|
298
|
-
sycommon_python_lib-0.2.
|
|
299
|
-
sycommon_python_lib-0.2.
|
|
296
|
+
sycommon_python_lib-0.2.7a0.dist-info/METADATA,sha256=FQOS-AGgoRf1zGics2rRSVxTlZ9d_FN7cTsCj8ElY0c,7944
|
|
297
|
+
sycommon_python_lib-0.2.7a0.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
|
|
298
|
+
sycommon_python_lib-0.2.7a0.dist-info/entry_points.txt,sha256=gsR4SssKxDWjRU8ggidzNcdMXDPRSKRS7UaGyNP84Qg,92
|
|
299
|
+
sycommon_python_lib-0.2.7a0.dist-info/top_level.txt,sha256=RgphKrg7nJyZ7irJqbxFr-5H2LUYTvI7ivoWZH2hcD0,29
|
|
300
|
+
sycommon_python_lib-0.2.7a0.dist-info/RECORD,,
|
|
File without changes
|
{sycommon_python_lib-0.2.6a14.dist-info → sycommon_python_lib-0.2.7a0.dist-info}/entry_points.txt
RENAMED
|
File without changes
|
{sycommon_python_lib-0.2.6a14.dist-info → sycommon_python_lib-0.2.7a0.dist-info}/top_level.txt
RENAMED
|
File without changes
|