redis-message-queue 8.2.2__tar.gz → 8.2.3__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 (26) hide show
  1. {redis_message_queue-8.2.2 → redis_message_queue-8.2.3}/PKG-INFO +2 -2
  2. {redis_message_queue-8.2.2 → redis_message_queue-8.2.3}/README.md +1 -1
  3. {redis_message_queue-8.2.2 → redis_message_queue-8.2.3}/pyproject.toml +2 -2
  4. {redis_message_queue-8.2.2 → redis_message_queue-8.2.3}/redis_message_queue/_config.py +49 -2
  5. {redis_message_queue-8.2.2 → redis_message_queue-8.2.3}/redis_message_queue/_redis_gateway.py +86 -11
  6. {redis_message_queue-8.2.2 → redis_message_queue-8.2.3}/redis_message_queue/asyncio/_redis_gateway.py +86 -11
  7. {redis_message_queue-8.2.2 → redis_message_queue-8.2.3}/redis_message_queue/asyncio/redis_message_queue.py +15 -1
  8. {redis_message_queue-8.2.2 → redis_message_queue-8.2.3}/redis_message_queue/redis_message_queue.py +12 -1
  9. {redis_message_queue-8.2.2 → redis_message_queue-8.2.3}/.gitignore +0 -0
  10. {redis_message_queue-8.2.2 → redis_message_queue-8.2.3}/LICENSE +0 -0
  11. {redis_message_queue-8.2.2 → redis_message_queue-8.2.3}/redis_message_queue/__init__.py +0 -0
  12. {redis_message_queue-8.2.2 → redis_message_queue-8.2.3}/redis_message_queue/_abstract_redis_gateway.py +0 -0
  13. {redis_message_queue-8.2.2 → redis_message_queue-8.2.3}/redis_message_queue/_callable_utils.py +0 -0
  14. {redis_message_queue-8.2.2 → redis_message_queue-8.2.3}/redis_message_queue/_event.py +0 -0
  15. {redis_message_queue-8.2.2 → redis_message_queue-8.2.3}/redis_message_queue/_exceptions.py +0 -0
  16. {redis_message_queue-8.2.2 → redis_message_queue-8.2.3}/redis_message_queue/_payload_limits.py +0 -0
  17. {redis_message_queue-8.2.2 → redis_message_queue-8.2.3}/redis_message_queue/_queue_key_manager.py +0 -0
  18. {redis_message_queue-8.2.2 → redis_message_queue-8.2.3}/redis_message_queue/_redis_cluster.py +0 -0
  19. {redis_message_queue-8.2.2 → redis_message_queue-8.2.3}/redis_message_queue/_stored_message.py +0 -0
  20. {redis_message_queue-8.2.2 → redis_message_queue-8.2.3}/redis_message_queue/asyncio/__init__.py +0 -0
  21. {redis_message_queue-8.2.2 → redis_message_queue-8.2.3}/redis_message_queue/asyncio/_abstract_redis_gateway.py +0 -0
  22. {redis_message_queue-8.2.2 → redis_message_queue-8.2.3}/redis_message_queue/interrupt_handler/__init__.py +0 -0
  23. {redis_message_queue-8.2.2 → redis_message_queue-8.2.3}/redis_message_queue/interrupt_handler/_event_driven.py +0 -0
  24. {redis_message_queue-8.2.2 → redis_message_queue-8.2.3}/redis_message_queue/interrupt_handler/_implementation.py +0 -0
  25. {redis_message_queue-8.2.2 → redis_message_queue-8.2.3}/redis_message_queue/interrupt_handler/_interface.py +0 -0
  26. {redis_message_queue-8.2.2 → redis_message_queue-8.2.3}/redis_message_queue/py.typed +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: redis-message-queue
3
- Version: 8.2.2
3
+ Version: 8.2.3
4
4
  Summary: Python message queuing with Redis and message deduplication
5
5
  Project-URL: Homepage, https://github.com/Elijas/redis-message-queue
6
6
  Project-URL: Repository, https://github.com/Elijas/redis-message-queue
@@ -34,7 +34,7 @@ Description-Content-Type: text/markdown
34
34
  **Lightweight Python message queuing with Redis and built-in publish-side deduplication.** Deduplicate publishes within a TTL window, with optional crash recovery — across any number of producers and consumers.
35
35
 
36
36
  ```bash
37
- pip install "redis-message-queue>=8.2.2,<9.0.0"
37
+ pip install "redis-message-queue>=8.2.3,<9.0.0"
38
38
  ```
39
39
 
40
40
  Requires Redis server >= 6.2.
@@ -11,7 +11,7 @@
11
11
  **Lightweight Python message queuing with Redis and built-in publish-side deduplication.** Deduplicate publishes within a TTL window, with optional crash recovery — across any number of producers and consumers.
12
12
 
13
13
  ```bash
14
- pip install "redis-message-queue>=8.2.2,<9.0.0"
14
+ pip install "redis-message-queue>=8.2.3,<9.0.0"
15
15
  ```
16
16
 
17
17
  Requires Redis server >= 6.2.
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "redis-message-queue"
3
- version = "8.2.2"
3
+ version = "8.2.3"
4
4
  description = "Python message queuing with Redis and message deduplication"
5
5
  authors = [{ name = "Elijas", email = "4084885+Elijas@users.noreply.github.com" }]
6
6
  readme = "README.md"
@@ -48,7 +48,7 @@ default-groups = ["dev", "test"]
48
48
  ##############################
49
49
 
50
50
  [tool.bumpversion]
51
- current_version = "8.2.2"
51
+ current_version = "8.2.3"
52
52
  parse = "(?P<major>\\d+)\\.(?P<minor>\\d+)\\.(?P<patch>\\d+)"
53
53
  serialize = ["{major}.{minor}.{patch}"]
54
54
  search = "{current_version}"
@@ -585,6 +585,53 @@ return removed
585
585
  """
586
586
  )
587
587
 
588
+ RETURN_MESSAGE_TO_PENDING_LUA_SCRIPT = (
589
+ _LUA_KEY_TYPE_GUARD
590
+ + """
591
+ local err = redis_message_queue_require_type(KEYS[1], 'list')
592
+ if err then
593
+ return err
594
+ end
595
+
596
+ local err = redis_message_queue_require_type(KEYS[2], 'list')
597
+ if err then
598
+ return err
599
+ end
600
+
601
+ local err = redis_message_queue_require_type(KEYS[3], 'hash')
602
+ if err then
603
+ return err
604
+ end
605
+
606
+ local err = redis_message_queue_require_type(KEYS[4], 'hash')
607
+ if err then
608
+ return err
609
+ end
610
+
611
+ local err = redis_message_queue_require_type(KEYS[5], 'string')
612
+ if err then
613
+ return err
614
+ end
615
+
616
+ local cached_result = redis.call('GET', KEYS[5])
617
+ if cached_result then
618
+ return tonumber(cached_result)
619
+ end
620
+
621
+ redis.call('RPUSH', KEYS[2], ARGV[1])
622
+ local removed = redis.call('LREM', KEYS[1], 1, ARGV[1])
623
+ if removed == 1 then
624
+ redis.call('HDEL', KEYS[3], ARGV[2])
625
+ redis.call('HDEL', KEYS[4], ARGV[1])
626
+ else
627
+ redis.call('LREM', KEYS[2], 1, ARGV[1])
628
+ end
629
+
630
+ redis.call('SET', KEYS[5], tostring(removed), 'PX', tonumber(ARGV[3]))
631
+ return removed
632
+ """
633
+ )
634
+
588
635
  REMOVE_MESSAGE_LUA_SCRIPT = (
589
636
  _LUA_KEY_TYPE_GUARD
590
637
  + """
@@ -876,8 +923,6 @@ while claim_attempts < 100 do
876
923
 
877
924
  local count = redis.call('HINCRBY', KEYS[6], stored, 1)
878
925
  if max_delivery_count > 0 and count > max_delivery_count then
879
- redis.call('LREM', KEYS[2], 1, stored)
880
- redis.call('HDEL', KEYS[6], stored)
881
926
  -- Strip envelope to store raw payload in DLQ, consistent with completed/failed queues.
882
927
  -- The per-delivery UUID in the envelope is lost; see README dead-letter notes.
883
928
  local dead_letter_value = stored
@@ -886,6 +931,8 @@ while claim_attempts < 100 do
886
931
  dead_letter_value = envelope['payload']
887
932
  end
888
933
  redis.call('LPUSH', KEYS[7], dead_letter_value)
934
+ redis.call('LREM', KEYS[2], 1, stored)
935
+ redis.call('HDEL', KEYS[6], stored)
889
936
  table.insert(dead_lettered_events, {redis_message_queue_message_id(stored), tostring(count)})
890
937
  else
891
938
  return store_claim_and_return(stored)
@@ -31,6 +31,7 @@ from redis_message_queue._config import (
31
31
  REMOVE_MESSAGE_LUA_SCRIPT,
32
32
  REMOVE_MESSAGE_WITH_LEASE_TOKEN_LUA_SCRIPT,
33
33
  RENEW_MESSAGE_LEASE_LUA_SCRIPT,
34
+ RETURN_MESSAGE_TO_PENDING_LUA_SCRIPT,
34
35
  _ChainedInterrupt,
35
36
  build_retry_strategy,
36
37
  is_redis_retryable_exception,
@@ -79,6 +80,8 @@ _VISIBILITY_TIMEOUT_POLL_INTERVAL_SECONDS = 0.25
79
80
  _PENDING_OVERLOAD_INITIAL_BACKOFF_SECONDS = 0.010
80
81
  _PENDING_OVERLOAD_MAX_BACKOFF_SECONDS = 0.500
81
82
  _CLAIM_STORE_FAILED_LUA_SENTINEL_BYTES = CLAIM_STORE_FAILED_LUA_SENTINEL.encode("utf-8")
83
+ _PENDING_QUEUE_SUFFIX = "pending"
84
+ _PROCESSING_QUEUE_SUFFIX = "processing"
82
85
 
83
86
 
84
87
  class _DrainDeadlineExceeded(Exception):
@@ -1225,6 +1228,53 @@ class RedisGateway(AbstractRedisGateway):
1225
1228
  _raise_if_drain_deadline_expired(deadline_monotonic)
1226
1229
  return ClaimedMessage(stored_message=stored_message, lease_token=lease_token)
1227
1230
 
1231
+ def _pending_queue_from_processing_queue(self, processing_queue: str) -> str:
1232
+ if not processing_queue.endswith(_PROCESSING_QUEUE_SUFFIX):
1233
+ raise RuntimeError(f"cannot derive pending queue key from processing queue {processing_queue!r}")
1234
+ return f"{processing_queue.removesuffix(_PROCESSING_QUEUE_SUFFIX)}{_PENDING_QUEUE_SUFFIX}"
1235
+
1236
+ def _return_recovered_non_visibility_timeout_claim_to_pending(
1237
+ self,
1238
+ processing_queue: str,
1239
+ stored_message: MessageData,
1240
+ claim_id: str,
1241
+ *,
1242
+ deadline_monotonic: float | None,
1243
+ ) -> bool:
1244
+ pending_queue = self._pending_queue_from_processing_queue(processing_queue)
1245
+ operation_id = uuid.uuid4().hex
1246
+ operation_result_key = self._operation_result_key(processing_queue, operation_id)
1247
+
1248
+ try:
1249
+ _raise_if_drain_deadline_expired(deadline_monotonic)
1250
+ result = _call_with_drain_deadline(
1251
+ lambda: self._eval(
1252
+ RETURN_MESSAGE_TO_PENDING_LUA_SCRIPT,
1253
+ 5,
1254
+ processing_queue,
1255
+ pending_queue,
1256
+ self._claim_result_ids_key(processing_queue),
1257
+ self._claim_result_backrefs_key(processing_queue),
1258
+ operation_result_key,
1259
+ stored_message,
1260
+ claim_id,
1261
+ self._operation_result_ttl_ms(),
1262
+ ),
1263
+ deadline_monotonic=deadline_monotonic,
1264
+ )
1265
+ _raise_if_drain_deadline_expired(deadline_monotonic)
1266
+ return bool(_coerce_lua_count(result))
1267
+ except RedisMessageQueueError as exc:
1268
+ _set_exception_context(
1269
+ exc,
1270
+ queue=processing_queue,
1271
+ message_id=extract_stored_message_id(stored_message),
1272
+ operation="drain",
1273
+ )
1274
+ raise
1275
+ finally:
1276
+ self._delete_operation_result_key(operation_result_key)
1277
+
1228
1278
  def _is_interrupted(self, is_interrupted: BaseGracefulInterruptHandler | None = None) -> bool:
1229
1279
  return (self._interrupt is not None and self._interrupt.is_interrupted()) or (
1230
1280
  is_interrupted is not None and is_interrupted.is_interrupted()
@@ -1241,8 +1291,10 @@ class RedisGateway(AbstractRedisGateway):
1241
1291
  Walks the same recovery path as ``_wait_for_claim`` but without
1242
1292
  gating on the interrupt flag, so a soft shutdown can flush
1243
1293
  ambiguous-claim state that would otherwise be dropped on process
1244
- exit (AA-05-F2). Returns True if no pending ids remain; False if
1245
- the deadline fired or transient Redis errors prevented full drain.
1294
+ exit (AA-05-F2). No-visibility-timeout recoveries are returned to
1295
+ the pending queue before their claim ids are cleared. Returns True
1296
+ if no pending ids remain; False if the deadline fired or Redis
1297
+ errors prevented full drain.
1246
1298
  """
1247
1299
  with self._drain_pending_claim_ids_lock:
1248
1300
  self._last_drain_error = None
@@ -1258,11 +1310,8 @@ class RedisGateway(AbstractRedisGateway):
1258
1310
  deadline_monotonic: float | None,
1259
1311
  ) -> bool:
1260
1312
  """Recover every in-memory pending claim id for ``processing_queue``."""
1261
- if self._message_visibility_timeout_seconds is not None:
1262
- recover = self._recover_pending_visibility_timeout_claim
1263
- else:
1264
- recover = self._recover_pending_non_visibility_timeout_claim
1265
- skipped_transient: set[str] = set()
1313
+ has_visibility_timeout = self._message_visibility_timeout_seconds is not None
1314
+ skipped_unresolved: set[str] = set()
1266
1315
  last_error: BaseException | None = None
1267
1316
  while True:
1268
1317
  # ``>=`` (not ``>``) makes ``timeout=0`` deterministically take
@@ -1278,7 +1327,7 @@ class RedisGateway(AbstractRedisGateway):
1278
1327
  return True
1279
1328
  recovering = self._recovering_claim_ids.setdefault(processing_queue, set())
1280
1329
  claim_id = next(
1281
- (cid for cid in pending if cid not in recovering and cid not in skipped_transient),
1330
+ (cid for cid in pending if cid not in recovering and cid not in skipped_unresolved),
1282
1331
  None,
1283
1332
  )
1284
1333
  if claim_id is None:
@@ -1287,8 +1336,34 @@ class RedisGateway(AbstractRedisGateway):
1287
1336
  clear = False
1288
1337
  try:
1289
1338
  try:
1290
- recover(processing_queue, claim_id, deadline_monotonic=deadline_monotonic)
1291
- clear = True
1339
+ if has_visibility_timeout:
1340
+ self._recover_pending_visibility_timeout_claim(
1341
+ processing_queue,
1342
+ claim_id,
1343
+ deadline_monotonic=deadline_monotonic,
1344
+ )
1345
+ clear = True
1346
+ continue
1347
+
1348
+ recovered_claim = self._recover_pending_non_visibility_timeout_claim(
1349
+ processing_queue,
1350
+ claim_id,
1351
+ deadline_monotonic=deadline_monotonic,
1352
+ )
1353
+ if recovered_claim is None:
1354
+ clear = True
1355
+ elif self._return_recovered_non_visibility_timeout_claim_to_pending(
1356
+ processing_queue,
1357
+ recovered_claim,
1358
+ claim_id,
1359
+ deadline_monotonic=deadline_monotonic,
1360
+ ):
1361
+ clear = True
1362
+ else:
1363
+ last_error = RuntimeError(
1364
+ f"drain recovered claim {claim_id!r} but message was not present in processing queue"
1365
+ )
1366
+ skipped_unresolved.add(claim_id)
1292
1367
  except _DrainDeadlineExceeded:
1293
1368
  last_error = TimeoutError("drain pending-claim recovery deadline expired")
1294
1369
  break
@@ -1301,7 +1376,7 @@ class RedisGateway(AbstractRedisGateway):
1301
1376
  claim_id,
1302
1377
  type(exc).__name__,
1303
1378
  )
1304
- skipped_transient.add(claim_id)
1379
+ skipped_unresolved.add(claim_id)
1305
1380
  finally:
1306
1381
  self._finish_pending_claim_recovery(processing_queue, claim_id, clear=clear)
1307
1382
  with self._pending_claim_ids_lock:
@@ -29,6 +29,7 @@ from redis_message_queue._config import (
29
29
  REMOVE_MESSAGE_LUA_SCRIPT,
30
30
  REMOVE_MESSAGE_WITH_LEASE_TOKEN_LUA_SCRIPT,
31
31
  RENEW_MESSAGE_LEASE_LUA_SCRIPT,
32
+ RETURN_MESSAGE_TO_PENDING_LUA_SCRIPT,
32
33
  _ChainedInterrupt,
33
34
  build_retry_strategy,
34
35
  is_redis_retryable_exception,
@@ -78,6 +79,8 @@ _VISIBILITY_TIMEOUT_POLL_INTERVAL_SECONDS = 0.25
78
79
  _PENDING_OVERLOAD_INITIAL_BACKOFF_SECONDS = 0.010
79
80
  _PENDING_OVERLOAD_MAX_BACKOFF_SECONDS = 0.500
80
81
  _CLAIM_STORE_FAILED_LUA_SENTINEL_BYTES = CLAIM_STORE_FAILED_LUA_SENTINEL.encode("utf-8")
82
+ _PENDING_QUEUE_SUFFIX = "pending"
83
+ _PROCESSING_QUEUE_SUFFIX = "processing"
81
84
 
82
85
 
83
86
  class _DrainDeadlineExceeded(Exception):
@@ -1206,6 +1209,53 @@ class RedisGateway(AbstractRedisGateway):
1206
1209
  _raise_if_drain_deadline_expired(deadline_monotonic)
1207
1210
  return ClaimedMessage(stored_message=stored_message, lease_token=lease_token)
1208
1211
 
1212
+ def _pending_queue_from_processing_queue(self, processing_queue: str) -> str:
1213
+ if not processing_queue.endswith(_PROCESSING_QUEUE_SUFFIX):
1214
+ raise RuntimeError(f"cannot derive pending queue key from processing queue {processing_queue!r}")
1215
+ return f"{processing_queue.removesuffix(_PROCESSING_QUEUE_SUFFIX)}{_PENDING_QUEUE_SUFFIX}"
1216
+
1217
+ async def _return_recovered_non_visibility_timeout_claim_to_pending(
1218
+ self,
1219
+ processing_queue: str,
1220
+ stored_message: MessageData,
1221
+ claim_id: str,
1222
+ *,
1223
+ deadline_monotonic: float | None,
1224
+ ) -> bool:
1225
+ pending_queue = self._pending_queue_from_processing_queue(processing_queue)
1226
+ operation_id = uuid.uuid4().hex
1227
+ operation_result_key = self._operation_result_key(processing_queue, operation_id)
1228
+
1229
+ try:
1230
+ _raise_if_drain_deadline_expired(deadline_monotonic)
1231
+ result = await _call_with_drain_deadline(
1232
+ lambda: self._eval(
1233
+ RETURN_MESSAGE_TO_PENDING_LUA_SCRIPT,
1234
+ 5,
1235
+ processing_queue,
1236
+ pending_queue,
1237
+ self._claim_result_ids_key(processing_queue),
1238
+ self._claim_result_backrefs_key(processing_queue),
1239
+ operation_result_key,
1240
+ stored_message,
1241
+ claim_id,
1242
+ self._operation_result_ttl_ms(),
1243
+ ),
1244
+ deadline_monotonic=deadline_monotonic,
1245
+ )
1246
+ _raise_if_drain_deadline_expired(deadline_monotonic)
1247
+ return bool(_coerce_lua_count(result))
1248
+ except RedisMessageQueueError as exc:
1249
+ _set_exception_context(
1250
+ exc,
1251
+ queue=processing_queue,
1252
+ message_id=extract_stored_message_id(stored_message),
1253
+ operation="drain",
1254
+ )
1255
+ raise
1256
+ finally:
1257
+ await self._delete_operation_result_key(operation_result_key)
1258
+
1209
1259
  def _is_interrupted(self, is_interrupted: BaseGracefulInterruptHandler | None = None) -> bool:
1210
1260
  return (self._interrupt is not None and self._interrupt.is_interrupted()) or (
1211
1261
  is_interrupted is not None and is_interrupted.is_interrupted()
@@ -1222,8 +1272,10 @@ class RedisGateway(AbstractRedisGateway):
1222
1272
  Kept separate from the sync implementation per AF11 (sync/async
1223
1273
  gateways stay duplicated). Walks the same recovery path as
1224
1274
  ``_wait_for_claim`` but without gating on the interrupt flag so a
1225
- soft shutdown can flush ambiguous-claim state. Returns True if no
1226
- pending ids remain; False on deadline expiry or transient errors.
1275
+ soft shutdown can flush ambiguous-claim state. No-visibility-timeout
1276
+ recoveries are returned to the pending queue before their claim ids
1277
+ are cleared. Returns True if no pending ids remain; False on deadline
1278
+ expiry or Redis errors.
1227
1279
  """
1228
1280
  async with self._drain_pending_claim_ids_lock:
1229
1281
  self._last_drain_error = None
@@ -1239,11 +1291,8 @@ class RedisGateway(AbstractRedisGateway):
1239
1291
  deadline_monotonic: float | None,
1240
1292
  ) -> bool:
1241
1293
  """Recover every in-memory pending claim id for ``processing_queue``."""
1242
- if self._message_visibility_timeout_seconds is not None:
1243
- recover = self._recover_pending_visibility_timeout_claim
1244
- else:
1245
- recover = self._recover_pending_non_visibility_timeout_claim
1246
- skipped_transient: set[str] = set()
1294
+ has_visibility_timeout = self._message_visibility_timeout_seconds is not None
1295
+ skipped_unresolved: set[str] = set()
1247
1296
  loop = asyncio.get_running_loop()
1248
1297
  last_error: BaseException | None = None
1249
1298
  while True:
@@ -1258,7 +1307,7 @@ class RedisGateway(AbstractRedisGateway):
1258
1307
  return True
1259
1308
  recovering = self._recovering_claim_ids.setdefault(processing_queue, set())
1260
1309
  claim_id = next(
1261
- (cid for cid in pending if cid not in recovering and cid not in skipped_transient),
1310
+ (cid for cid in pending if cid not in recovering and cid not in skipped_unresolved),
1262
1311
  None,
1263
1312
  )
1264
1313
  if claim_id is None:
@@ -1267,8 +1316,34 @@ class RedisGateway(AbstractRedisGateway):
1267
1316
  clear = False
1268
1317
  try:
1269
1318
  try:
1270
- await recover(processing_queue, claim_id, deadline_monotonic=deadline_monotonic)
1271
- clear = True
1319
+ if has_visibility_timeout:
1320
+ await self._recover_pending_visibility_timeout_claim(
1321
+ processing_queue,
1322
+ claim_id,
1323
+ deadline_monotonic=deadline_monotonic,
1324
+ )
1325
+ clear = True
1326
+ continue
1327
+
1328
+ recovered_claim = await self._recover_pending_non_visibility_timeout_claim(
1329
+ processing_queue,
1330
+ claim_id,
1331
+ deadline_monotonic=deadline_monotonic,
1332
+ )
1333
+ if recovered_claim is None:
1334
+ clear = True
1335
+ elif await self._return_recovered_non_visibility_timeout_claim_to_pending(
1336
+ processing_queue,
1337
+ recovered_claim,
1338
+ claim_id,
1339
+ deadline_monotonic=deadline_monotonic,
1340
+ ):
1341
+ clear = True
1342
+ else:
1343
+ last_error = RuntimeError(
1344
+ f"drain recovered claim {claim_id!r} but message was not present in processing queue"
1345
+ )
1346
+ skipped_unresolved.add(claim_id)
1272
1347
  except _DrainDeadlineExceeded:
1273
1348
  last_error = TimeoutError("drain pending-claim recovery deadline expired")
1274
1349
  break
@@ -1281,7 +1356,7 @@ class RedisGateway(AbstractRedisGateway):
1281
1356
  claim_id,
1282
1357
  type(exc).__name__,
1283
1358
  )
1284
- skipped_transient.add(claim_id)
1359
+ skipped_unresolved.add(claim_id)
1285
1360
  finally:
1286
1361
  self._finish_pending_claim_recovery(processing_queue, claim_id, clear=clear)
1287
1362
  with self._pending_claim_ids_lock:
@@ -920,6 +920,18 @@ class RedisMessageQueue:
920
920
  "Use an async callable or return an awaitable."
921
921
  )
922
922
  await result
923
+ except asyncio.CancelledError as exc:
924
+ current_task = asyncio.current_task()
925
+ if current_task is not None and current_task.cancelling() > 0:
926
+ raise
927
+ logger.exception("on_event callback raised an exception")
928
+ with warnings.catch_warnings():
929
+ warnings.simplefilter("always", RuntimeWarning)
930
+ warnings.warn(
931
+ f"on_event callback raised {type(exc).__name__}",
932
+ RuntimeWarning,
933
+ stacklevel=2,
934
+ )
923
935
  except Exception as exc:
924
936
  logger.exception("on_event callback raised an exception")
925
937
  with warnings.catch_warnings():
@@ -1414,7 +1426,9 @@ class RedisMessageQueue:
1414
1426
  calls raise ``QueueDrainedError``. It then awaits the gateway's
1415
1427
  pending-claim-id recovery loop. Returns ``True`` if all pending claim
1416
1428
  ids were recovered, ``False`` if the deadline fired or a transient
1417
- Redis error left ids pending.
1429
+ Redis error left ids pending. In no-visibility-timeout queues,
1430
+ recovered messages are returned to pending before the claim id is
1431
+ cleared.
1418
1432
 
1419
1433
  Unlike ``asyncio.CancelledError`` (hard-abort, leaves messages
1420
1434
  claimed for VT-reclaim), ``aclose()`` is the explicit-drain
@@ -1,3 +1,4 @@
1
+ import asyncio
1
2
  import hashlib
2
3
  import inspect
3
4
  import logging
@@ -883,6 +884,15 @@ class RedisMessageQueue:
883
884
  "'on_event' returned an awaitable; use the async RedisMessageQueue "
884
885
  "from redis_message_queue.asyncio instead"
885
886
  )
887
+ except asyncio.CancelledError as exc:
888
+ logger.exception("on_event callback raised an exception")
889
+ with warnings.catch_warnings():
890
+ warnings.simplefilter("always", RuntimeWarning)
891
+ warnings.warn(
892
+ f"on_event callback raised {type(exc).__name__}",
893
+ RuntimeWarning,
894
+ stacklevel=2,
895
+ )
886
896
  except Exception as exc:
887
897
  logger.exception("on_event callback raised an exception")
888
898
  with warnings.catch_warnings():
@@ -1374,7 +1384,8 @@ class RedisMessageQueue:
1374
1384
  calls raise ``QueueDrainedError``. It then walks the gateway's
1375
1385
  in-memory ``_pending_claim_ids`` to recover any ambiguous claims that
1376
1386
  an interrupt-aware shutdown would otherwise drop on process exit
1377
- (AA-05-F2).
1387
+ (AA-05-F2). In no-visibility-timeout queues, recovered messages are
1388
+ returned to pending before the claim id is cleared.
1378
1389
 
1379
1390
  ``timeout`` bounds the pending-claim-id recovery loop in seconds;
1380
1391
  ``None`` waits indefinitely, ``0`` skips the loop entirely. The