redqueue 0.11.0__tar.gz → 0.11.2__tar.gz

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.
Files changed (54) hide show
  1. {redqueue-0.11.0 → redqueue-0.11.2}/CHANGELOG.md +36 -2
  2. {redqueue-0.11.0 → redqueue-0.11.2}/PKG-INFO +1 -1
  3. {redqueue-0.11.0 → redqueue-0.11.2}/docs/API.md +7 -3
  4. {redqueue-0.11.0 → redqueue-0.11.2}/pyproject.toml +1 -1
  5. {redqueue-0.11.0 → redqueue-0.11.2}/src/redqueue/_version.py +1 -1
  6. {redqueue-0.11.0 → redqueue-0.11.2}/src/redqueue/async_client.py +106 -61
  7. {redqueue-0.11.0 → redqueue-0.11.2}/src/redqueue/client.py +61 -32
  8. {redqueue-0.11.0 → redqueue-0.11.2}/tests/test_project_skeleton.py +181 -7
  9. {redqueue-0.11.0 → redqueue-0.11.2}/.github/workflows/ci.yml +0 -0
  10. {redqueue-0.11.0 → redqueue-0.11.2}/.gitignore +0 -0
  11. {redqueue-0.11.0 → redqueue-0.11.2}/CODE_OF_CONDUCT.md +0 -0
  12. {redqueue-0.11.0 → redqueue-0.11.2}/CONTRIBUTING.md +0 -0
  13. {redqueue-0.11.0 → redqueue-0.11.2}/LICENSE +0 -0
  14. {redqueue-0.11.0 → redqueue-0.11.2}/NOTICE +0 -0
  15. {redqueue-0.11.0 → redqueue-0.11.2}/README-zh-CN.md +0 -0
  16. {redqueue-0.11.0 → redqueue-0.11.2}/README.md +0 -0
  17. {redqueue-0.11.0 → redqueue-0.11.2}/docs/RELEASE.md +0 -0
  18. {redqueue-0.11.0 → redqueue-0.11.2}/examples/README.md +0 -0
  19. {redqueue-0.11.0 → redqueue-0.11.2}/examples/__init__.py +0 -0
  20. {redqueue-0.11.0 → redqueue-0.11.2}/examples/async_list_queue.py +0 -0
  21. {redqueue-0.11.0 → redqueue-0.11.2}/examples/common.py +0 -0
  22. {redqueue-0.11.0 → redqueue-0.11.2}/examples/compatibility_check.py +0 -0
  23. {redqueue-0.11.0 → redqueue-0.11.2}/examples/custom_serializer.py +0 -0
  24. {redqueue-0.11.0 → redqueue-0.11.2}/examples/delayed_tasks.py +0 -0
  25. {redqueue-0.11.0 → redqueue-0.11.2}/examples/monitoring_hooks.py +0 -0
  26. {redqueue-0.11.0 → redqueue-0.11.2}/examples/stream_queue.py +0 -0
  27. {redqueue-0.11.0 → redqueue-0.11.2}/examples/sync_list_queue.py +0 -0
  28. {redqueue-0.11.0 → redqueue-0.11.2}/requirements.txt +0 -0
  29. {redqueue-0.11.0 → redqueue-0.11.2}/scripts/check.py +0 -0
  30. {redqueue-0.11.0 → redqueue-0.11.2}/src/redqueue/__init__.py +0 -0
  31. {redqueue-0.11.0 → redqueue-0.11.2}/src/redqueue/backends/__init__.py +0 -0
  32. {redqueue-0.11.0 → redqueue-0.11.2}/src/redqueue/backends/async_delay.py +0 -0
  33. {redqueue-0.11.0 → redqueue-0.11.2}/src/redqueue/backends/async_list.py +0 -0
  34. {redqueue-0.11.0 → redqueue-0.11.2}/src/redqueue/backends/async_stream.py +0 -0
  35. {redqueue-0.11.0 → redqueue-0.11.2}/src/redqueue/backends/base.py +0 -0
  36. {redqueue-0.11.0 → redqueue-0.11.2}/src/redqueue/backends/delay.py +0 -0
  37. {redqueue-0.11.0 → redqueue-0.11.2}/src/redqueue/backends/list.py +0 -0
  38. {redqueue-0.11.0 → redqueue-0.11.2}/src/redqueue/backends/stream.py +0 -0
  39. {redqueue-0.11.0 → redqueue-0.11.2}/src/redqueue/compat.py +0 -0
  40. {redqueue-0.11.0 → redqueue-0.11.2}/src/redqueue/config.py +0 -0
  41. {redqueue-0.11.0 → redqueue-0.11.2}/src/redqueue/connection.py +0 -0
  42. {redqueue-0.11.0 → redqueue-0.11.2}/src/redqueue/exceptions.py +0 -0
  43. {redqueue-0.11.0 → redqueue-0.11.2}/src/redqueue/message.py +0 -0
  44. {redqueue-0.11.0 → redqueue-0.11.2}/src/redqueue/monitoring.py +0 -0
  45. {redqueue-0.11.0 → redqueue-0.11.2}/src/redqueue/serialization.py +0 -0
  46. {redqueue-0.11.0 → redqueue-0.11.2}/tests/README.md +0 -0
  47. {redqueue-0.11.0 → redqueue-0.11.2}/tests/__init__.py +0 -0
  48. {redqueue-0.11.0 → redqueue-0.11.2}/tests/fakes.py +0 -0
  49. {redqueue-0.11.0 → redqueue-0.11.2}/tests/test_availability.py +0 -0
  50. {redqueue-0.11.0 → redqueue-0.11.2}/tests/test_backend_contracts.py +0 -0
  51. {redqueue-0.11.0 → redqueue-0.11.2}/tests/test_integration_redis.py +0 -0
  52. {redqueue-0.11.0 → redqueue-0.11.2}/tests/test_performance.py +0 -0
  53. {redqueue-0.11.0 → redqueue-0.11.2}/tests/test_real_redis_availability.py +0 -0
  54. {redqueue-0.11.0 → redqueue-0.11.2}/tests/test_real_redis_performance.py +0 -0
@@ -4,8 +4,42 @@ All notable public release changes are documented here.
4
4
 
5
5
  所有公开发布版本的重要变更都会记录在此文件中。
6
6
 
7
- Development versions are tracked separately from formal release versions.
8
- 开发版本与正式版本分开管理。
7
+ Development versions are tracked separately from formal release versions.
8
+ 开发版本与正式版本分开管理。
9
+
10
+ ## [0.11.2] - 2026-06-21
11
+
12
+ ### Fixed
13
+
14
+ - Fixed cleanup for directly constructed sync clients when owned Redis backend
15
+ initialization fails.
16
+ - Fixed cleanup for directly constructed async clients when lazy backend
17
+ initialization fails.
18
+ - Made sync and async client `close()` idempotent for owned Redis clients.
19
+
20
+ ### 修复
21
+
22
+ - 修复直接构造同步客户端时,如果 owned Redis 的后端初始化失败,Redis client
23
+ 未释放的问题。
24
+ - 修复直接构造异步客户端时,如果懒加载后端初始化失败,Redis client 未释放的问题。
25
+ - 同步和异步客户端的 `close()` 对 owned Redis client 变为幂等。
26
+
27
+ ## [0.11.1] - 2026-06-21
28
+
29
+ ### Fixed
30
+
31
+ - Fixed resource cleanup in sync and async `from_url()` when Redis capability
32
+ detection, configuration validation, or backend initialization fails after the
33
+ client created an owned Redis connection.
34
+ - Added explicit `owns_redis` override support to sync and async `from_url()`
35
+ for advanced ownership control.
36
+
37
+ ### 修复
38
+
39
+ - 修复同步和异步 `from_url()` 在自动创建 Redis 连接后,如果 Redis 能力探测、
40
+ 配置校验或后端初始化失败,已创建连接未释放的问题。
41
+ - 同步和异步 `from_url()` 新增显式 `owns_redis` 覆盖支持,用于高级资源所有权
42
+ 控制。
9
43
 
10
44
  ## [0.11.0] - 2026-06-21
11
45
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: redqueue
3
- Version: 0.11.0
3
+ Version: 0.11.2
4
4
  Summary: Redis-backed Python message queue library with List, Streams, delayed tasks, and monitoring.
5
5
  Project-URL: Homepage, https://github.com/SpringMirror-pear/redqueue
6
6
  Project-URL: Repository, https://github.com/SpringMirror-pear/redqueue.git
@@ -1,8 +1,8 @@
1
1
  # RedQueue API / RedQueue API 文档
2
2
 
3
- This document describes the public API available in RedQueue `0.11.0`.
4
-
5
- 本文档描述 RedQueue `0.11.0` 的公开 API。
3
+ This document describes the public API available in RedQueue `0.11.2`.
4
+
5
+ 本文档描述 RedQueue `0.11.2` 的公开 API。
6
6
 
7
7
  ## Clients / 客户端
8
8
 
@@ -25,6 +25,8 @@ client = QueueClient.from_url(
25
25
  Methods / 方法:
26
26
 
27
27
  - `from_url(url, *, queue, backend="list", connection_manager=None, **options) -> QueueClient`
28
+ - Advanced options include `pool_options`, injected `redis`, injected
29
+ `capabilities`, and `owns_redis`.
28
30
  - `publish(payload, *, delay=None, headers=None, message_id=None) -> str`
29
31
  - `consume(*, timeout=None, batch_size=1) -> Message | list[Message] | None`
30
32
  - `ack(message) -> None`
@@ -57,6 +59,8 @@ client = await AsyncQueueClient.from_url(
57
59
  Methods / 方法:
58
60
 
59
61
  - `await from_url(url, *, queue, backend="list", connection_manager=None, **options) -> AsyncQueueClient`
62
+ - Advanced options include `pool_options`, injected `redis`, injected
63
+ `capabilities`, and `owns_redis`.
60
64
  - `await publish(payload, *, delay=None, headers=None, message_id=None) -> str`
61
65
  - `await consume(*, timeout=None, batch_size=1) -> Message | list[Message] | None`
62
66
  - `await ack(message) -> None`
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "redqueue"
7
- version = "0.11.0"
7
+ version = "0.11.2"
8
8
  description = "Redis-backed Python message queue library with List, Streams, delayed tasks, and monitoring."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.9"
@@ -3,4 +3,4 @@
3
3
 
4
4
  """Package version."""
5
5
 
6
- __version__ = "0.11.0"
6
+ __version__ = "0.11.2"
@@ -56,11 +56,12 @@ class AsyncQueueClient:
56
56
  """
57
57
 
58
58
  self.config = config
59
- self.redis = redis
60
- self.capabilities = capabilities
61
- self._owns_redis = owns_redis
62
- self.backend: AsyncListBackend | AsyncStreamBackend | None = None
63
- self.delay_backend: AsyncDelayBackend | None = None
59
+ self.redis = redis
60
+ self.capabilities = capabilities
61
+ self._owns_redis = owns_redis
62
+ self._closed = False
63
+ self.backend: AsyncListBackend | AsyncStreamBackend | None = None
64
+ self.delay_backend: AsyncDelayBackend | None = None
64
65
  self.config.monitoring.emit(
65
66
  MonitoringEvent(
66
67
  type=MonitoringEventType.CLIENT_CREATED,
@@ -88,7 +89,8 @@ class AsyncQueueClient:
88
89
  connection_manager: Optional async connection manager used to create
89
90
  a client from a shared pool.
90
91
  **options: Additional ``QueueConfig`` options. Tests may also pass
91
- ``redis``, ``capabilities``, or ``pool_options``.
92
+ ``redis``, ``capabilities``, ``pool_options``, or
93
+ ``owns_redis``.
92
94
 
93
95
  Returns:
94
96
  An initialized ``AsyncQueueClient`` with its primary backend ready.
@@ -101,26 +103,46 @@ class AsyncQueueClient:
101
103
 
102
104
  redis = options.pop("redis", None)
103
105
  pool_options = options.pop("pool_options", None) or {}
104
- owns_redis = False
106
+ explicit_owns_redis = options.pop("owns_redis", None)
107
+ owns_redis = (
108
+ bool(explicit_owns_redis)
109
+ if explicit_owns_redis is not None
110
+ else False
111
+ )
105
112
  if redis is None:
106
113
  if connection_manager is not None:
107
114
  redis = connection_manager.redis()
108
115
  else:
109
116
  redis = Redis.from_url(url, **pool_options)
110
- owns_redis = True
111
- capabilities = options.pop(
112
- "capabilities",
113
- None,
114
- ) or await detect_capabilities_async(cast(AsyncRedisInfoClient, redis))
115
- config = QueueConfig(queue=queue, backend=backend, **options)
116
- client = cls(
117
- config=config,
118
- redis=redis,
119
- capabilities=capabilities,
120
- owns_redis=owns_redis,
121
- )
122
- await client._ensure_backend()
123
- return client
117
+ owns_redis = (
118
+ True
119
+ if explicit_owns_redis is None
120
+ else bool(explicit_owns_redis)
121
+ )
122
+ capabilities = options.pop("capabilities", None)
123
+ if capabilities is None:
124
+ try:
125
+ capabilities = await detect_capabilities_async(
126
+ cast(AsyncRedisInfoClient, redis)
127
+ )
128
+ except Exception:
129
+ if owns_redis:
130
+ await cls._close_redis(redis)
131
+ raise
132
+ try:
133
+ config = QueueConfig(queue=queue, backend=backend, **options)
134
+ except Exception:
135
+ if owns_redis:
136
+ await cls._close_redis(redis)
137
+ raise
138
+ client = cls(
139
+ config=config,
140
+ redis=redis,
141
+ capabilities=capabilities,
142
+ owns_redis=owns_redis,
143
+ )
144
+ await client._ensure_backend()
145
+ return client
124
146
 
125
147
  async def publish(
126
148
  self,
@@ -299,21 +321,20 @@ class AsyncQueueClient:
299
321
 
300
322
  await (await self._ensure_backend()).requeue_dead(message)
301
323
 
302
- async def close(self) -> None:
303
- """Close the async Redis client when this client owns it."""
304
-
305
- if not self._owns_redis:
306
- return
307
-
308
- close = getattr(self.redis, "aclose", None) or getattr(
309
- self.redis,
310
- "close",
311
- None,
312
- )
313
- if close is not None:
314
- result = close()
315
- if hasattr(result, "__await__"):
316
- await result
324
+ async def close(self) -> None:
325
+ """Close the async Redis client when this client owns it."""
326
+
327
+ if self._closed or not self._owns_redis:
328
+ return
329
+
330
+ close = getattr(self.redis, "aclose", None) or getattr(
331
+ self.redis,
332
+ "close",
333
+ None,
334
+ )
335
+ if close is not None:
336
+ await self._call_close(close)
337
+ self._closed = True
317
338
 
318
339
  async def __aenter__(self) -> AsyncQueueClient:
319
340
  """Enter an asynchronous resource-management context."""
@@ -343,31 +364,55 @@ class AsyncQueueClient:
343
364
 
344
365
  if self.backend is not None:
345
366
  return self.backend
346
- if self.config.backend_type is BackendType.LIST:
347
- if self.redis is None:
348
- raise TypeError("redis client is required for async List backend")
349
- capabilities = self.capabilities
350
- if capabilities is None:
351
- raise TypeError("Redis capabilities are required before backend use")
352
- self.backend = AsyncListBackend(self.redis, self.config, capabilities)
353
- return self.backend
354
- if self.config.backend_type is BackendType.STREAM:
355
- if self.redis is None:
356
- raise TypeError("redis client is required for async Streams backend")
357
- capabilities = self.capabilities
358
- if capabilities is None:
359
- raise TypeError("Redis capabilities are required before backend use")
360
- self.backend = await AsyncStreamBackend.create(
361
- self.redis,
362
- self.config,
363
- capabilities,
364
- )
365
- return self.backend
366
- raise NotImplementedError(
367
- f"backend {self.config.backend_type.value!r} is not implemented"
368
- )
369
-
370
- async def _ensure_delay_backend(self) -> AsyncDelayBackend:
367
+ if self.config.backend_type is BackendType.LIST:
368
+ if self.redis is None:
369
+ raise TypeError("redis client is required for async List backend")
370
+ capabilities = self.capabilities
371
+ if capabilities is None:
372
+ raise TypeError("Redis capabilities are required before backend use")
373
+ try:
374
+ self.backend = AsyncListBackend(self.redis, self.config, capabilities)
375
+ return self.backend
376
+ except Exception:
377
+ await self.close()
378
+ raise
379
+ if self.config.backend_type is BackendType.STREAM:
380
+ if self.redis is None:
381
+ raise TypeError("redis client is required for async Streams backend")
382
+ capabilities = self.capabilities
383
+ if capabilities is None:
384
+ raise TypeError("Redis capabilities are required before backend use")
385
+ try:
386
+ self.backend = await AsyncStreamBackend.create(
387
+ self.redis,
388
+ self.config,
389
+ capabilities,
390
+ )
391
+ return self.backend
392
+ except Exception:
393
+ await self.close()
394
+ raise
395
+ raise NotImplementedError(
396
+ f"backend {self.config.backend_type.value!r} is not implemented"
397
+ )
398
+
399
+ @staticmethod
400
+ async def _close_redis(redis: Any) -> None:
401
+ """Close an async Redis-like object if it exposes a close method."""
402
+
403
+ close = getattr(redis, "aclose", None) or getattr(redis, "close", None)
404
+ if close is not None:
405
+ await AsyncQueueClient._call_close(close)
406
+
407
+ @staticmethod
408
+ async def _call_close(close: Any) -> None:
409
+ """Call a sync or async close method."""
410
+
411
+ result = close()
412
+ if hasattr(result, "__await__"):
413
+ await result
414
+
415
+ async def _ensure_delay_backend(self) -> AsyncDelayBackend:
371
416
  """Return the initialized async delay scheduler, creating it when needed."""
372
417
 
373
418
  if self.delay_backend is not None:
@@ -61,15 +61,20 @@ class QueueClient:
61
61
  RedisCompatibilityError: If Redis lacks a required command family.
62
62
  """
63
63
 
64
- self.config = config
65
- self.redis = redis
66
- self.capabilities = capabilities
67
- self._owns_redis = owns_redis
68
- self.backend = self._create_backend()
69
- self.delay_backend = self._create_delay_backend()
70
- self.config.monitoring.emit(
71
- MonitoringEvent(
72
- type=MonitoringEventType.CLIENT_CREATED,
64
+ self.config = config
65
+ self.redis = redis
66
+ self.capabilities = capabilities
67
+ self._owns_redis = owns_redis
68
+ self._closed = False
69
+ try:
70
+ self.backend = self._create_backend()
71
+ self.delay_backend = self._create_delay_backend()
72
+ except Exception:
73
+ self.close()
74
+ raise
75
+ self.config.monitoring.emit(
76
+ MonitoringEvent(
77
+ type=MonitoringEventType.CLIENT_CREATED,
73
78
  queue=config.queue,
74
79
  backend=config.backend_type.value,
75
80
  )
@@ -94,8 +99,8 @@ class QueueClient:
94
99
  connection_manager: Optional connection manager used to create a
95
100
  client from a shared pool.
96
101
  **options: Additional ``QueueConfig`` options. Tests may also pass
97
- ``redis``, ``capabilities``, or ``pool_options`` to bypass or
98
- customize connection creation.
102
+ ``redis``, ``capabilities``, ``pool_options``, or
103
+ ``owns_redis`` to bypass or customize connection creation.
99
104
 
100
105
  Returns:
101
106
  A ready-to-use synchronous ``QueueClient``.
@@ -109,23 +114,46 @@ class QueueClient:
109
114
 
110
115
  redis = options.pop("redis", None)
111
116
  pool_options = options.pop("pool_options", None) or {}
112
- owns_redis = False
117
+ explicit_owns_redis = options.pop("owns_redis", None)
118
+ owns_redis = (
119
+ bool(explicit_owns_redis)
120
+ if explicit_owns_redis is not None
121
+ else False
122
+ )
113
123
  if redis is None:
114
124
  if connection_manager is not None:
115
125
  redis = connection_manager.redis()
116
126
  else:
117
127
  redis = Redis.from_url(url, **pool_options)
118
- owns_redis = True
119
- capabilities = options.pop("capabilities", None) or detect_capabilities(
120
- cast(RedisInfoClient, redis)
121
- )
122
- config = QueueConfig(queue=queue, backend=backend, **options)
123
- return cls(
124
- config=config,
125
- redis=redis,
126
- capabilities=capabilities,
127
- owns_redis=owns_redis,
128
- )
128
+ owns_redis = (
129
+ True
130
+ if explicit_owns_redis is None
131
+ else bool(explicit_owns_redis)
132
+ )
133
+ capabilities = options.pop("capabilities", None)
134
+ if capabilities is None:
135
+ try:
136
+ capabilities = detect_capabilities(cast(RedisInfoClient, redis))
137
+ except Exception:
138
+ if owns_redis:
139
+ close = getattr(redis, "close", None)
140
+ if close is not None:
141
+ close()
142
+ raise
143
+ try:
144
+ config = QueueConfig(queue=queue, backend=backend, **options)
145
+ except Exception:
146
+ if owns_redis:
147
+ close = getattr(redis, "close", None)
148
+ if close is not None:
149
+ close()
150
+ raise
151
+ return cls(
152
+ config=config,
153
+ redis=redis,
154
+ capabilities=capabilities,
155
+ owns_redis=owns_redis,
156
+ )
129
157
 
130
158
  def publish(
131
159
  self,
@@ -305,15 +333,16 @@ class QueueClient:
305
333
 
306
334
  self.backend.requeue_dead(message)
307
335
 
308
- def close(self) -> None:
309
- """Close the Redis client when this client owns it."""
310
-
311
- if not self._owns_redis:
312
- return
313
-
314
- close = getattr(self.redis, "close", None)
315
- if close is not None:
316
- close()
336
+ def close(self) -> None:
337
+ """Close the Redis client when this client owns it."""
338
+
339
+ if self._closed or not self._owns_redis:
340
+ return
341
+
342
+ close = getattr(self.redis, "close", None)
343
+ if close is not None:
344
+ close()
345
+ self._closed = True
317
346
 
318
347
  def __enter__(self) -> QueueClient:
319
348
  """Enter a synchronous resource-management context."""
@@ -50,7 +50,7 @@ from tests.fakes import (
50
50
 
51
51
  class ProjectSkeletonTests(unittest.TestCase):
52
52
  def test_version_is_current_dev_version(self) -> None:
53
- self.assertEqual(__version__, "0.11.0")
53
+ self.assertEqual(__version__, "0.11.2")
54
54
 
55
55
  def test_queue_config_accepts_and_normalizes_backend(self) -> None:
56
56
  config = QueueConfig(queue=" emails ", backend="stream")
@@ -592,7 +592,7 @@ class ProjectSkeletonTests(unittest.TestCase):
592
592
 
593
593
  self.assertNotIn("close", redis.commands)
594
594
 
595
- def test_sync_client_context_closes_owned_redis(self) -> None:
595
+ def test_sync_client_context_closes_owned_redis(self) -> None:
596
596
  redis = FakeListRedis()
597
597
 
598
598
  with QueueClient(
@@ -601,8 +601,37 @@ class ProjectSkeletonTests(unittest.TestCase):
601
601
  capabilities=RedisCapabilities(RedisVersion(7, 0, 0)),
602
602
  ):
603
603
  pass
604
-
605
- self.assertIn("close", redis.commands)
604
+
605
+ self.assertIn("close", redis.commands)
606
+
607
+ def test_sync_client_close_is_idempotent(self) -> None:
608
+ redis = FakeListRedis()
609
+ client = QueueClient(
610
+ QueueConfig(queue="emails"),
611
+ redis=redis,
612
+ capabilities=RedisCapabilities(RedisVersion(7, 0, 0)),
613
+ )
614
+
615
+ client.close()
616
+ client.close()
617
+
618
+ self.assertEqual(redis.commands.count("close"), 1)
619
+
620
+ def test_sync_client_closes_owned_redis_when_backend_init_fails(self) -> None:
621
+ class ClosableStreamRedis(FakeStreamRedis):
622
+ def close(self) -> None:
623
+ self.commands.append("close")
624
+
625
+ redis = ClosableStreamRedis()
626
+
627
+ with self.assertRaises(RedisCompatibilityError):
628
+ QueueClient(
629
+ QueueConfig(queue="events", backend="stream"),
630
+ redis=redis,
631
+ capabilities=RedisCapabilities(RedisVersion(4, 0, 14)),
632
+ )
633
+
634
+ self.assertIn("close", redis.commands)
606
635
 
607
636
  def test_async_client_can_leave_injected_redis_open(self) -> None:
608
637
  async def run() -> FakeAsyncListRedis:
@@ -620,7 +649,7 @@ class ProjectSkeletonTests(unittest.TestCase):
620
649
 
621
650
  self.assertNotIn("aclose", redis.commands)
622
651
 
623
- def test_async_client_context_closes_owned_redis(self) -> None:
652
+ def test_async_client_context_closes_owned_redis(self) -> None:
624
653
  async def run() -> FakeAsyncListRedis:
625
654
  redis = FakeAsyncListRedis()
626
655
  async with AsyncQueueClient(
@@ -632,8 +661,44 @@ class ProjectSkeletonTests(unittest.TestCase):
632
661
  return redis
633
662
 
634
663
  redis = asyncio.run(run())
635
-
636
- self.assertIn("aclose", redis.commands)
664
+
665
+ self.assertIn("aclose", redis.commands)
666
+
667
+ def test_async_client_close_is_idempotent(self) -> None:
668
+ async def run() -> FakeAsyncListRedis:
669
+ redis = FakeAsyncListRedis()
670
+ client = AsyncQueueClient(
671
+ QueueConfig(queue="jobs"),
672
+ redis=redis,
673
+ capabilities=RedisCapabilities(RedisVersion(7, 0, 0)),
674
+ )
675
+ await client.close()
676
+ await client.close()
677
+ return redis
678
+
679
+ redis = asyncio.run(run())
680
+
681
+ self.assertEqual(redis.commands.count("aclose"), 1)
682
+
683
+ def test_async_client_closes_owned_redis_when_backend_init_fails(self) -> None:
684
+ async def run() -> FakeAsyncStreamRedis:
685
+ class ClosableAsyncStreamRedis(FakeAsyncStreamRedis):
686
+ async def aclose(self) -> None:
687
+ self.commands.append("aclose")
688
+
689
+ redis = ClosableAsyncStreamRedis()
690
+ client = AsyncQueueClient(
691
+ QueueConfig(queue="events", backend="stream"),
692
+ redis=redis,
693
+ capabilities=RedisCapabilities(RedisVersion(4, 0, 14)),
694
+ )
695
+ with self.assertRaises(RedisCompatibilityError):
696
+ await client.consume(timeout=1)
697
+ return redis
698
+
699
+ redis = asyncio.run(run())
700
+
701
+ self.assertIn("aclose", redis.commands)
637
702
 
638
703
  def test_connection_managers_create_pooled_clients(self) -> None:
639
704
  manager = RedisConnectionManager(
@@ -685,6 +750,56 @@ class ProjectSkeletonTests(unittest.TestCase):
685
750
  finally:
686
751
  manager.close()
687
752
 
753
+ def test_sync_from_url_closes_owned_redis_when_initialization_fails(self) -> None:
754
+ class OwnedRedis(FakeListRedis):
755
+ def info(self, section: str | None = None) -> dict[str, str]:
756
+ return {"redis_version": "7.0.0"}
757
+
758
+ redis = OwnedRedis()
759
+
760
+ with self.assertRaises(QueueConfigError):
761
+ QueueClient.from_url(
762
+ "redis://127.0.0.1:6379/0",
763
+ queue="bad queue",
764
+ redis=redis,
765
+ owns_redis=True,
766
+ )
767
+
768
+ self.assertIn("close", redis.commands)
769
+
770
+ def test_sync_from_url_leaves_injected_redis_open_when_init_fails(self) -> None:
771
+ class InjectedRedis(FakeListRedis):
772
+ def info(self, section: str | None = None) -> dict[str, str]:
773
+ return {"redis_version": "7.0.0"}
774
+
775
+ redis = InjectedRedis()
776
+
777
+ with self.assertRaises(QueueConfigError):
778
+ QueueClient.from_url(
779
+ "redis://127.0.0.1:6379/0",
780
+ queue="bad queue",
781
+ redis=redis,
782
+ )
783
+
784
+ self.assertNotIn("close", redis.commands)
785
+
786
+ def test_sync_from_url_closes_owned_redis_when_capability_probe_fails(self) -> None:
787
+ class BrokenInfoRedis(FakeListRedis):
788
+ def info(self, section: str | None = None) -> dict[str, str]:
789
+ raise TimeoutError("redis unavailable")
790
+
791
+ redis = BrokenInfoRedis()
792
+
793
+ with self.assertRaises(BackendUnavailableError):
794
+ QueueClient.from_url(
795
+ "redis://127.0.0.1:6379/0",
796
+ queue="emails",
797
+ redis=redis,
798
+ owns_redis=True,
799
+ )
800
+
801
+ self.assertIn("close", redis.commands)
802
+
688
803
  def test_async_connection_manager_creates_pooled_clients(self) -> None:
689
804
  async def run() -> None:
690
805
  manager = AsyncRedisConnectionManager(
@@ -740,6 +855,65 @@ class ProjectSkeletonTests(unittest.TestCase):
740
855
 
741
856
  asyncio.run(run())
742
857
 
858
+ def test_async_from_url_closes_owned_redis_when_initialization_fails(self) -> None:
859
+ class OwnedAsyncRedis(FakeAsyncListRedis):
860
+ async def info(self, section: str | None = None) -> dict[str, str]:
861
+ return {"redis_version": "7.0.0"}
862
+
863
+ async def run() -> FakeAsyncListRedis:
864
+ redis = OwnedAsyncRedis()
865
+ with self.assertRaises(QueueConfigError):
866
+ await AsyncQueueClient.from_url(
867
+ "redis://127.0.0.1:6379/0",
868
+ queue="bad queue",
869
+ redis=redis,
870
+ owns_redis=True,
871
+ )
872
+ return redis
873
+
874
+ redis = asyncio.run(run())
875
+
876
+ self.assertIn("aclose", redis.commands)
877
+
878
+ def test_async_from_url_leaves_injected_redis_open_when_init_fails(self) -> None:
879
+ class InjectedAsyncRedis(FakeAsyncListRedis):
880
+ async def info(self, section: str | None = None) -> dict[str, str]:
881
+ return {"redis_version": "7.0.0"}
882
+
883
+ async def run() -> FakeAsyncListRedis:
884
+ redis = InjectedAsyncRedis()
885
+ with self.assertRaises(QueueConfigError):
886
+ await AsyncQueueClient.from_url(
887
+ "redis://127.0.0.1:6379/0",
888
+ queue="bad queue",
889
+ redis=redis,
890
+ )
891
+ return redis
892
+
893
+ redis = asyncio.run(run())
894
+
895
+ self.assertNotIn("aclose", redis.commands)
896
+
897
+ def test_async_from_url_closes_owned_redis_on_probe_failure(self) -> None:
898
+ class BrokenInfoAsyncRedis(FakeAsyncListRedis):
899
+ async def info(self, section: str | None = None) -> dict[str, str]:
900
+ raise TimeoutError("redis unavailable")
901
+
902
+ async def run() -> FakeAsyncListRedis:
903
+ redis = BrokenInfoAsyncRedis()
904
+ with self.assertRaises(BackendUnavailableError):
905
+ await AsyncQueueClient.from_url(
906
+ "redis://127.0.0.1:6379/0",
907
+ queue="jobs",
908
+ redis=redis,
909
+ owns_redis=True,
910
+ )
911
+ return redis
912
+
913
+ redis = asyncio.run(run())
914
+
915
+ self.assertIn("aclose", redis.commands)
916
+
743
917
  def test_async_list_backend_ack_uses_original_serialized_payload(self) -> None:
744
918
  class NonDeterministicSerializer:
745
919
  content_type = "application/x-redqueue-test"
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes