kiba-core 0.5.3.dev85__tar.gz → 0.5.3.dev86__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 (143) hide show
  1. {kiba_core-0.5.3.dev85 → kiba_core-0.5.3.dev86}/CHANGELOG.md +4 -0
  2. {kiba_core-0.5.3.dev85 → kiba_core-0.5.3.dev86}/PKG-INFO +1 -1
  3. {kiba_core-0.5.3.dev85 → kiba_core-0.5.3.dev86}/core/queues/aqs.py +8 -0
  4. {kiba_core-0.5.3.dev85 → kiba_core-0.5.3.dev86}/core/queues/cosmos.py +21 -0
  5. {kiba_core-0.5.3.dev85 → kiba_core-0.5.3.dev86}/core/queues/message_queue.py +9 -0
  6. {kiba_core-0.5.3.dev85 → kiba_core-0.5.3.dev86}/core/queues/message_queue_processor.py +88 -10
  7. {kiba_core-0.5.3.dev85 → kiba_core-0.5.3.dev86}/core/queues/sql.py +23 -13
  8. {kiba_core-0.5.3.dev85 → kiba_core-0.5.3.dev86}/core/queues/sqs.py +8 -0
  9. {kiba_core-0.5.3.dev85 → kiba_core-0.5.3.dev86}/kiba_core.egg-info/PKG-INFO +1 -1
  10. {kiba_core-0.5.3.dev85 → kiba_core-0.5.3.dev86}/pyproject.toml +1 -1
  11. {kiba_core-0.5.3.dev85 → kiba_core-0.5.3.dev86}/tests/queues/test_aqs_queue.py +7 -0
  12. {kiba_core-0.5.3.dev85 → kiba_core-0.5.3.dev86}/tests/queues/test_cosmos_queue.py +9 -0
  13. {kiba_core-0.5.3.dev85 → kiba_core-0.5.3.dev86}/tests/queues/test_sql_queue.py +84 -3
  14. {kiba_core-0.5.3.dev85 → kiba_core-0.5.3.dev86}/tests/queues/test_sqs_queue.py +7 -0
  15. {kiba_core-0.5.3.dev85 → kiba_core-0.5.3.dev86}/.github/pull_request_template.md +0 -0
  16. {kiba_core-0.5.3.dev85 → kiba_core-0.5.3.dev86}/.github/workflows/deploy.yml +0 -0
  17. {kiba_core-0.5.3.dev85 → kiba_core-0.5.3.dev86}/.github/workflows/publish.yml +0 -0
  18. {kiba_core-0.5.3.dev85 → kiba_core-0.5.3.dev86}/.github/workflows/pull-request.yml +0 -0
  19. {kiba_core-0.5.3.dev85 → kiba_core-0.5.3.dev86}/.github/workflows/release.yml +0 -0
  20. {kiba_core-0.5.3.dev85 → kiba_core-0.5.3.dev86}/.gitignore +0 -0
  21. {kiba_core-0.5.3.dev85 → kiba_core-0.5.3.dev86}/Dockerfile +0 -0
  22. {kiba_core-0.5.3.dev85 → kiba_core-0.5.3.dev86}/README.md +0 -0
  23. {kiba_core-0.5.3.dev85 → kiba_core-0.5.3.dev86}/core/__init__.py +0 -0
  24. {kiba_core-0.5.3.dev85 → kiba_core-0.5.3.dev86}/core/api/__init__.py +0 -0
  25. {kiba_core-0.5.3.dev85 → kiba_core-0.5.3.dev86}/core/api/api_request.py +0 -0
  26. {kiba_core-0.5.3.dev85 → kiba_core-0.5.3.dev86}/core/api/api_response.py +0 -0
  27. {kiba_core-0.5.3.dev85 → kiba_core-0.5.3.dev86}/core/api/authorizer.py +0 -0
  28. {kiba_core-0.5.3.dev85 → kiba_core-0.5.3.dev86}/core/api/default_routes.py +0 -0
  29. {kiba_core-0.5.3.dev85 → kiba_core-0.5.3.dev86}/core/api/health.py +0 -0
  30. {kiba_core-0.5.3.dev85 → kiba_core-0.5.3.dev86}/core/api/json_route.py +0 -0
  31. {kiba_core-0.5.3.dev85 → kiba_core-0.5.3.dev86}/core/api/middleware/__init__.py +0 -0
  32. {kiba_core-0.5.3.dev85 → kiba_core-0.5.3.dev86}/core/api/middleware/database_connection_middleware.py +0 -0
  33. {kiba_core-0.5.3.dev85 → kiba_core-0.5.3.dev86}/core/api/middleware/exception_handling_middleware.py +0 -0
  34. {kiba_core-0.5.3.dev85 → kiba_core-0.5.3.dev86}/core/api/middleware/logging_middleware.py +0 -0
  35. {kiba_core-0.5.3.dev85 → kiba_core-0.5.3.dev86}/core/api/middleware/origin_ip_middleware.py +0 -0
  36. {kiba_core-0.5.3.dev85 → kiba_core-0.5.3.dev86}/core/api/middleware/server_headers_middleware.py +0 -0
  37. {kiba_core-0.5.3.dev85 → kiba_core-0.5.3.dev86}/core/api/openapi.py +0 -0
  38. {kiba_core-0.5.3.dev85 → kiba_core-0.5.3.dev86}/core/api/rate_limit.py +0 -0
  39. {kiba_core-0.5.3.dev85 → kiba_core-0.5.3.dev86}/core/api/request_context.py +0 -0
  40. {kiba_core-0.5.3.dev85 → kiba_core-0.5.3.dev86}/core/api/route.py +0 -0
  41. {kiba_core-0.5.3.dev85 → kiba_core-0.5.3.dev86}/core/api/route_auth.py +0 -0
  42. {kiba_core-0.5.3.dev85 → kiba_core-0.5.3.dev86}/core/api/route_metadata.py +0 -0
  43. {kiba_core-0.5.3.dev85 → kiba_core-0.5.3.dev86}/core/api/streaming_json_route.py +0 -0
  44. {kiba_core-0.5.3.dev85 → kiba_core-0.5.3.dev86}/core/aws_requester.py +0 -0
  45. {kiba_core-0.5.3.dev85 → kiba_core-0.5.3.dev86}/core/caching/__init__.py +0 -0
  46. {kiba_core-0.5.3.dev85 → kiba_core-0.5.3.dev86}/core/caching/cache.py +0 -0
  47. {kiba_core-0.5.3.dev85 → kiba_core-0.5.3.dev86}/core/caching/dict_cache.py +0 -0
  48. {kiba_core-0.5.3.dev85 → kiba_core-0.5.3.dev86}/core/caching/file_cache.py +0 -0
  49. {kiba_core-0.5.3.dev85 → kiba_core-0.5.3.dev86}/core/cron/__init__.py +0 -0
  50. {kiba_core-0.5.3.dev85 → kiba_core-0.5.3.dev86}/core/cron/api_request_cron_job_processor.py +0 -0
  51. {kiba_core-0.5.3.dev85 → kiba_core-0.5.3.dev86}/core/cron/cron_executor.py +0 -0
  52. {kiba_core-0.5.3.dev85 → kiba_core-0.5.3.dev86}/core/cron/cron_job_processor.py +0 -0
  53. {kiba_core-0.5.3.dev85 → kiba_core-0.5.3.dev86}/core/cron/model.py +0 -0
  54. {kiba_core-0.5.3.dev85 → kiba_core-0.5.3.dev86}/core/exceptions.py +0 -0
  55. {kiba_core-0.5.3.dev85 → kiba_core-0.5.3.dev86}/core/http/__init__.py +0 -0
  56. {kiba_core-0.5.3.dev85 → kiba_core-0.5.3.dev86}/core/http/basic_authentication.py +0 -0
  57. {kiba_core-0.5.3.dev85 → kiba_core-0.5.3.dev86}/core/http/jwt.py +0 -0
  58. {kiba_core-0.5.3.dev85 → kiba_core-0.5.3.dev86}/core/http/rest_method.py +0 -0
  59. {kiba_core-0.5.3.dev85 → kiba_core-0.5.3.dev86}/core/locks/__init__.py +0 -0
  60. {kiba_core-0.5.3.dev85 → kiba_core-0.5.3.dev86}/core/locks/lock.py +0 -0
  61. {kiba_core-0.5.3.dev85 → kiba_core-0.5.3.dev86}/core/locks/model.py +0 -0
  62. {kiba_core-0.5.3.dev85 → kiba_core-0.5.3.dev86}/core/locks/sql.py +0 -0
  63. {kiba_core-0.5.3.dev85 → kiba_core-0.5.3.dev86}/core/logging.py +0 -0
  64. {kiba_core-0.5.3.dev85 → kiba_core-0.5.3.dev86}/core/notifications/__init__.py +0 -0
  65. {kiba_core-0.5.3.dev85 → kiba_core-0.5.3.dev86}/core/notifications/discord_client.py +0 -0
  66. {kiba_core-0.5.3.dev85 → kiba_core-0.5.3.dev86}/core/notifications/notification_client.py +0 -0
  67. {kiba_core-0.5.3.dev85 → kiba_core-0.5.3.dev86}/core/notifications/slack_client.py +0 -0
  68. {kiba_core-0.5.3.dev85 → kiba_core-0.5.3.dev86}/core/py.typed +0 -0
  69. {kiba_core-0.5.3.dev85 → kiba_core-0.5.3.dev86}/core/queues/__init__.py +0 -0
  70. {kiba_core-0.5.3.dev85 → kiba_core-0.5.3.dev86}/core/queues/model.py +0 -0
  71. {kiba_core-0.5.3.dev85 → kiba_core-0.5.3.dev86}/core/requester/__init__.py +0 -0
  72. {kiba_core-0.5.3.dev85 → kiba_core-0.5.3.dev86}/core/requester/requester.py +0 -0
  73. {kiba_core-0.5.3.dev85 → kiba_core-0.5.3.dev86}/core/s3_manager.py +0 -0
  74. {kiba_core-0.5.3.dev85 → kiba_core-0.5.3.dev86}/core/service_client.py +0 -0
  75. {kiba_core-0.5.3.dev85 → kiba_core-0.5.3.dev86}/core/store/__init__.py +0 -0
  76. {kiba_core-0.5.3.dev85 → kiba_core-0.5.3.dev86}/core/store/database.py +0 -0
  77. {kiba_core-0.5.3.dev85 → kiba_core-0.5.3.dev86}/core/store/entity_repository.py +0 -0
  78. {kiba_core-0.5.3.dev85 → kiba_core-0.5.3.dev86}/core/store/retriever.py +0 -0
  79. {kiba_core-0.5.3.dev85 → kiba_core-0.5.3.dev86}/core/store/saver.py +0 -0
  80. {kiba_core-0.5.3.dev85 → kiba_core-0.5.3.dev86}/core/util/__init__.py +0 -0
  81. {kiba_core-0.5.3.dev85 → kiba_core-0.5.3.dev86}/core/util/async_util.py +0 -0
  82. {kiba_core-0.5.3.dev85 → kiba_core-0.5.3.dev86}/core/util/chain_util.py +0 -0
  83. {kiba_core-0.5.3.dev85 → kiba_core-0.5.3.dev86}/core/util/date_util.py +0 -0
  84. {kiba_core-0.5.3.dev85 → kiba_core-0.5.3.dev86}/core/util/dict_util.py +0 -0
  85. {kiba_core-0.5.3.dev85 → kiba_core-0.5.3.dev86}/core/util/file_util.py +0 -0
  86. {kiba_core-0.5.3.dev85 → kiba_core-0.5.3.dev86}/core/util/hashing_util.py +0 -0
  87. {kiba_core-0.5.3.dev85 → kiba_core-0.5.3.dev86}/core/util/http_util.py +0 -0
  88. {kiba_core-0.5.3.dev85 → kiba_core-0.5.3.dev86}/core/util/json_util.py +0 -0
  89. {kiba_core-0.5.3.dev85 → kiba_core-0.5.3.dev86}/core/util/list_util.py +0 -0
  90. {kiba_core-0.5.3.dev85 → kiba_core-0.5.3.dev86}/core/util/process_util.py +0 -0
  91. {kiba_core-0.5.3.dev85 → kiba_core-0.5.3.dev86}/core/util/string_util.py +0 -0
  92. {kiba_core-0.5.3.dev85 → kiba_core-0.5.3.dev86}/core/util/typing_util.py +0 -0
  93. {kiba_core-0.5.3.dev85 → kiba_core-0.5.3.dev86}/core/util/url_util.py +0 -0
  94. {kiba_core-0.5.3.dev85 → kiba_core-0.5.3.dev86}/core/util/value_holder.py +0 -0
  95. {kiba_core-0.5.3.dev85 → kiba_core-0.5.3.dev86}/core/web3/__init__.py +0 -0
  96. {kiba_core-0.5.3.dev85 → kiba_core-0.5.3.dev86}/core/web3/eth_client.py +0 -0
  97. {kiba_core-0.5.3.dev85 → kiba_core-0.5.3.dev86}/core/web3/eth_client_registry.py +0 -0
  98. {kiba_core-0.5.3.dev85 → kiba_core-0.5.3.dev86}/core/web3/multicall3.py +0 -0
  99. {kiba_core-0.5.3.dev85 → kiba_core-0.5.3.dev86}/kiba_core.egg-info/SOURCES.txt +0 -0
  100. {kiba_core-0.5.3.dev85 → kiba_core-0.5.3.dev86}/kiba_core.egg-info/dependency_links.txt +0 -0
  101. {kiba_core-0.5.3.dev85 → kiba_core-0.5.3.dev86}/kiba_core.egg-info/requires.txt +0 -0
  102. {kiba_core-0.5.3.dev85 → kiba_core-0.5.3.dev86}/kiba_core.egg-info/top_level.txt +0 -0
  103. {kiba_core-0.5.3.dev85 → kiba_core-0.5.3.dev86}/makefile +0 -0
  104. {kiba_core-0.5.3.dev85 → kiba_core-0.5.3.dev86}/setup.cfg +0 -0
  105. {kiba_core-0.5.3.dev85 → kiba_core-0.5.3.dev86}/tests/__init__.py +0 -0
  106. {kiba_core-0.5.3.dev85 → kiba_core-0.5.3.dev86}/tests/api/test_api_response.py +0 -0
  107. {kiba_core-0.5.3.dev85 → kiba_core-0.5.3.dev86}/tests/api/test_authorizer.py +0 -0
  108. {kiba_core-0.5.3.dev85 → kiba_core-0.5.3.dev86}/tests/api/test_exception_handling_middleware.py +0 -0
  109. {kiba_core-0.5.3.dev85 → kiba_core-0.5.3.dev86}/tests/api/test_json_route.py +0 -0
  110. {kiba_core-0.5.3.dev85 → kiba_core-0.5.3.dev86}/tests/api/test_openapi.py +0 -0
  111. {kiba_core-0.5.3.dev85 → kiba_core-0.5.3.dev86}/tests/api/test_origin_ip_middleware.py +0 -0
  112. {kiba_core-0.5.3.dev85 → kiba_core-0.5.3.dev86}/tests/api/test_rate_limit.py +0 -0
  113. {kiba_core-0.5.3.dev85 → kiba_core-0.5.3.dev86}/tests/api/test_request_context.py +0 -0
  114. {kiba_core-0.5.3.dev85 → kiba_core-0.5.3.dev86}/tests/api/test_route.py +0 -0
  115. {kiba_core-0.5.3.dev85 → kiba_core-0.5.3.dev86}/tests/api/test_streaming_json_route.py +0 -0
  116. {kiba_core-0.5.3.dev85 → kiba_core-0.5.3.dev86}/tests/caching/test_dict_cache.py +0 -0
  117. {kiba_core-0.5.3.dev85 → kiba_core-0.5.3.dev86}/tests/caching/test_file_cache.py +0 -0
  118. {kiba_core-0.5.3.dev85 → kiba_core-0.5.3.dev86}/tests/cron/__init__.py +0 -0
  119. {kiba_core-0.5.3.dev85 → kiba_core-0.5.3.dev86}/tests/cron/test_cron_executor.py +0 -0
  120. {kiba_core-0.5.3.dev85 → kiba_core-0.5.3.dev86}/tests/locks/test_sql_lock.py +0 -0
  121. {kiba_core-0.5.3.dev85 → kiba_core-0.5.3.dev86}/tests/queues/conftest.py +0 -0
  122. {kiba_core-0.5.3.dev85 → kiba_core-0.5.3.dev86}/tests/queues/test_message_queue_processor.py +0 -0
  123. {kiba_core-0.5.3.dev85 → kiba_core-0.5.3.dev86}/tests/requester/__init__.py +0 -0
  124. {kiba_core-0.5.3.dev85 → kiba_core-0.5.3.dev86}/tests/requester/test_requester.py +0 -0
  125. {kiba_core-0.5.3.dev85 → kiba_core-0.5.3.dev86}/tests/test_database.py +0 -0
  126. {kiba_core-0.5.3.dev85 → kiba_core-0.5.3.dev86}/tests/test_entity_repository.py +0 -0
  127. {kiba_core-0.5.3.dev85 → kiba_core-0.5.3.dev86}/tests/test_exceptions.py +0 -0
  128. {kiba_core-0.5.3.dev85 → kiba_core-0.5.3.dev86}/tests/test_logging.py +0 -0
  129. {kiba_core-0.5.3.dev85 → kiba_core-0.5.3.dev86}/tests/util/__init__.py +0 -0
  130. {kiba_core-0.5.3.dev85 → kiba_core-0.5.3.dev86}/tests/util/test_async_util.py +0 -0
  131. {kiba_core-0.5.3.dev85 → kiba_core-0.5.3.dev86}/tests/util/test_chain_util.py +0 -0
  132. {kiba_core-0.5.3.dev85 → kiba_core-0.5.3.dev86}/tests/util/test_date_util.py +0 -0
  133. {kiba_core-0.5.3.dev85 → kiba_core-0.5.3.dev86}/tests/util/test_dict_util.py +0 -0
  134. {kiba_core-0.5.3.dev85 → kiba_core-0.5.3.dev86}/tests/util/test_file_util.py +0 -0
  135. {kiba_core-0.5.3.dev85 → kiba_core-0.5.3.dev86}/tests/util/test_json_util.py +0 -0
  136. {kiba_core-0.5.3.dev85 → kiba_core-0.5.3.dev86}/tests/util/test_list_util.py +0 -0
  137. {kiba_core-0.5.3.dev85 → kiba_core-0.5.3.dev86}/tests/util/test_process_util.py +0 -0
  138. {kiba_core-0.5.3.dev85 → kiba_core-0.5.3.dev86}/tests/util/test_string_util.py +0 -0
  139. {kiba_core-0.5.3.dev85 → kiba_core-0.5.3.dev86}/tests/util/test_url_util.py +0 -0
  140. {kiba_core-0.5.3.dev85 → kiba_core-0.5.3.dev86}/tests/web3/__init__.py +0 -0
  141. {kiba_core-0.5.3.dev85 → kiba_core-0.5.3.dev86}/tests/web3/test_eth_client_registry.py +0 -0
  142. {kiba_core-0.5.3.dev85 → kiba_core-0.5.3.dev86}/tests/web3/test_rest_eth_client.py +0 -0
  143. {kiba_core-0.5.3.dev85 → kiba_core-0.5.3.dev86}/uv.lock +0 -0
@@ -8,6 +8,10 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/)
8
8
  ## [Unreleased]
9
9
 
10
10
  ### Added
11
+ - [MAJOR] Added abstract `MessageQueue.release_message` to hand a claimed message back to be claimed again straight away without using an attempt (`SqlMessageQueue` and `CosmosMessageQueue` keep `postCount`; `SqsMessageQueue` and `AqsMessageQueue` make the message visible again, though SQS still counts the next receive towards its redrive policy)
12
+ - [MAJOR] Added abstract `MessageQueue.delete_expired_messages`: `SqlMessageQueue` deletes `succeeded` and `deduplicated` messages older than `retentionSeconds` (the other queues remove messages on completion and return 0). `SqlMessageQueue.get_message_count` no longer deletes them and its `shouldSkipRemovingNonRetained` parameter is removed
13
+ - [MINOR] Updated `MessageQueueProcessor` to shut down gracefully: `run`/`run_batches` handle SIGTERM and SIGINT (a second signal falls back to the default handler), as does `request_shutdown()`, by finishing the message being processed, releasing claimed messages not yet started and stopping instead of sleeping. A processed message whose task is cancelled is released rather than left until its lease expires
14
+ - [MINOR] Added `deleteExpiredMessagesIntervalSeconds` (default 600) to `MessageQueueProcessor`, which calls `delete_expired_messages` at most this often while running, logging rather than raising on failure; added `shouldProcessInParallel` to `run_batches`
11
15
  - [MINOR] Added optional `proxyUrl` to `Requester` to send all its requests through an HTTP(S) proxy, e.g. to reach an API that allowlists a fixed egress IP
12
16
  - [MINOR] Added optional `timeoutSeconds` (default 10) to `RestEthClient` to set the timeout of each request, e.g. a shorter one for an archive node so a degraded node fails fast
13
17
  - [MINOR] Added `UNDEFINED`, `Undefined` and `UpdateValue[T]` to `core.util.typing_util` for partial updates: `UpdateValue[T]` is `T` or the `UNDEFINED` sentinel, so an update can tell "not provided" apart from an explicit `None`. Use it as a pydantic field default (`title: UpdateValue[str | None] = UNDEFINED`); request input never validates to `UNDEFINED` (a JSON `"UNDEFINED"` stays a string), and `x is not UNDEFINED` narrows `x` to `T` for type checkers
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: kiba-core
3
- Version: 0.5.3.dev85
3
+ Version: 0.5.3.dev86
4
4
  Summary: Kiba Labs' python utilities
5
5
  Project-URL: repository, https://github.com/kibalabs/core-py
6
6
  Requires-Python: <4,>=3.12
@@ -113,6 +113,14 @@ class AqsMessageQueue(MessageQueue[AqsMessage]):
113
113
  message.popReceipt = updatedMessage.pop_receipt
114
114
  return True
115
115
 
116
+ async def release_message(self, message: AqsMessage) -> None:
117
+ # NOTE(krishan711): a message whose lease was already lost is left alone
118
+ await self.extend_message_lease(message=message, expectedProcessingSeconds=0)
119
+
120
+ async def delete_expired_messages(self) -> int:
121
+ # NOTE(krishan711): azure deletes messages when they are completed and expires the rest itself
122
+ return 0
123
+
116
124
  async def get_message_count(self) -> int:
117
125
  if not self._aqsClient:
118
126
  raise InternalServerErrorException('You need to call .connect() before trying to count messages')
@@ -258,6 +258,27 @@ class CosmosMessageQueue(MessageQueue[CosmosMessage]):
258
258
  message.etag = str(updatedItem['_etag'])
259
259
  return True
260
260
 
261
+ async def release_message(self, message: CosmosMessage) -> None:
262
+ try:
263
+ await self.container.patch_item(
264
+ item=message.id,
265
+ partition_key=self.queueName,
266
+ patch_operations=[
267
+ {'op': 'set', 'path': '/leaseId', 'value': None},
268
+ {'op': 'set', 'path': '/visibleDate', 'value': time.time()},
269
+ ],
270
+ etag=message.etag,
271
+ match_condition=MatchConditions.IfNotModified,
272
+ )
273
+ except cosmos_exceptions.CosmosHttpResponseError as exception:
274
+ # NOTE(krishan711): 412 means another worker now owns the message and 404 that it was already completed
275
+ if exception.status_code not in {404, 412}:
276
+ raise
277
+
278
+ async def delete_expired_messages(self) -> int:
279
+ # NOTE(krishan711): cosmos keeps no message history, completed messages are removed straight away
280
+ return 0
281
+
261
282
  async def get_message_count(self) -> int:
262
283
  counts = self.container.query_items(
263
284
  query='SELECT COUNT(1) AS messageCount FROM c WHERE c.itemType = @itemType AND c.queueName = @queueName AND c.visibleDate <= @now',
@@ -47,6 +47,15 @@ class MessageQueue[MessageType: Message](ABC):
47
47
  async def extend_message_lease(self, message: MessageType, expectedProcessingSeconds: int) -> bool:
48
48
  raise NotImplementedError
49
49
 
50
+ @abc.abstractmethod
51
+ async def release_message(self, message: MessageType) -> None:
52
+ # NOTE(krishan711): hands a claimed message back to be claimed again straight away without using an attempt, e.g. when the worker shuts down
53
+ raise NotImplementedError
54
+
55
+ @abc.abstractmethod
56
+ async def delete_expired_messages(self) -> int:
57
+ raise NotImplementedError
58
+
50
59
  @abc.abstractmethod
51
60
  async def get_message_count(self) -> int:
52
61
  raise NotImplementedError
@@ -1,6 +1,7 @@
1
1
  import abc
2
2
  import asyncio
3
3
  import contextlib
4
+ import signal
4
5
  import sys
5
6
  import time
6
7
  import urllib.parse as urlparse
@@ -18,6 +19,7 @@ from core.util.value_holder import RequestIdHolder
18
19
 
19
20
  LOCKED_MAX_RETRY_COUNT = 3
20
21
  LOCKED_RETRY_DELAY_SECONDS = 30
22
+ SHUTDOWN_SIGNALS = (signal.SIGTERM, signal.SIGINT)
21
23
 
22
24
 
23
25
  class MessageProcessor(ABC):
@@ -40,14 +42,30 @@ class MessageLeaseLostException(InternalServerErrorException):
40
42
 
41
43
 
42
44
  class MessageQueueProcessor[MessageType: Message]:
43
- def __init__(self, queue: MessageQueue[MessageType], messageProcessor: MessageProcessor, notificationClients: list[NotificationClient], requestIdHolder: RequestIdHolder | None = None, pollLogIntervalSeconds: float = 600, shouldNotifyRetriedFailures: bool = True) -> None:
45
+ def __init__(
46
+ self,
47
+ queue: MessageQueue[MessageType],
48
+ messageProcessor: MessageProcessor,
49
+ notificationClients: list[NotificationClient],
50
+ requestIdHolder: RequestIdHolder | None = None,
51
+ pollLogIntervalSeconds: float = 600,
52
+ shouldNotifyRetriedFailures: bool = True,
53
+ deleteExpiredMessagesIntervalSeconds: float = 600,
54
+ ) -> None:
44
55
  self.queue = queue
45
56
  self.messageProcessor = messageProcessor
46
57
  self.notificationClients = notificationClients
47
58
  self.requestIdHolder = requestIdHolder
48
59
  self.pollLogIntervalSeconds = pollLogIntervalSeconds
49
60
  self.shouldNotifyRetriedFailures = shouldNotifyRetriedFailures
61
+ self.deleteExpiredMessagesIntervalSeconds = deleteExpiredMessagesIntervalSeconds
50
62
  self._lastPollLogTime: float | None = None
63
+ self._lastExpiredDeletionTime: float | None = None
64
+ self._shutdownEvent = asyncio.Event()
65
+
66
+ def request_shutdown(self) -> None:
67
+ # NOTE(krishan711): stops claiming new messages; the message being processed finishes and claimed messages not yet started are released
68
+ self._shutdownEvent.set()
51
69
 
52
70
  async def _handle_failure(self, message: MessageType, exception: Exception, requestId: str) -> int:
53
71
  logging.error('Caught exception whilst processing message:')
@@ -95,6 +113,7 @@ class MessageQueueProcessor[MessageType: Message]:
95
113
  # Cancellation (and other non-Exception errors) must still propagate, so those are never replaced.
96
114
  stopKeepAliveEvent = asyncio.Event()
97
115
  keepAliveTask = asyncio.create_task(self._keep_message_alive(message=message, expectedProcessingSeconds=expectedProcessingSeconds, stopEvent=stopKeepAliveEvent))
116
+ isLeaseLost = False
98
117
  try:
99
118
  try:
100
119
  await self.messageProcessor.process_message(message=message)
@@ -105,6 +124,10 @@ class MessageQueueProcessor[MessageType: Message]:
105
124
  if isLeaseLost and (inFlightException is None or isinstance(inFlightException, Exception)):
106
125
  raise MessageLeaseLostException
107
126
  await self.queue.complete_message(message=message)
127
+ except asyncio.CancelledError:
128
+ if not isLeaseLost:
129
+ await self._release_messages(messages=[message])
130
+ raise
108
131
  except MessageNeedsReprocessingException as exception:
109
132
  logging.info(msg=f'Scheduling reprocessing for message:{message.command} due to: {exception.originalException!s}')
110
133
  await self.queue.retry_message(message=message, delaySeconds=((message.postCount or 0) * exception.delaySeconds))
@@ -122,16 +145,44 @@ class MessageQueueProcessor[MessageType: Message]:
122
145
  if self.requestIdHolder:
123
146
  self.requestIdHolder.set_value(value=None)
124
147
 
148
+ async def _release_messages(self, messages: list[MessageType]) -> None:
149
+ for message in messages:
150
+ try:
151
+ await self.queue.release_message(message=message)
152
+ except Exception as exception: # noqa: BLE001
153
+ # NOTE(krishan711): a message that cannot be released is picked up again once its lease expires
154
+ logging.error(f'Failed to release message {message.command}:')
155
+ logging.exception(exception)
156
+
157
+ async def _delete_expired_messages_if_due(self) -> None:
158
+ now = time.monotonic()
159
+ if self._lastExpiredDeletionTime is not None and now - self._lastExpiredDeletionTime < self.deleteExpiredMessagesIntervalSeconds:
160
+ return
161
+ self._lastExpiredDeletionTime = now
162
+ try:
163
+ deletedCount = await self.queue.delete_expired_messages()
164
+ except Exception as exception: # noqa: BLE001
165
+ logging.error('Failed to delete expired messages:')
166
+ logging.exception(exception)
167
+ return
168
+ if deletedCount > 0:
169
+ logging.info(f'Deleted {deletedCount} expired messages')
170
+
125
171
  async def execute_batch(self, batchSize: int, expectedProcessingSeconds: int = 300, longPollSeconds: int = 20, shouldProcessInParallel: bool = False) -> int:
126
172
  now = time.monotonic()
127
173
  if self._lastPollLogTime is None or now - self._lastPollLogTime >= self.pollLogIntervalSeconds:
128
174
  logging.info('Retrieving messages...')
129
175
  self._lastPollLogTime = now
130
176
  messages = await self.queue.get_messages(expectedProcessingSeconds=expectedProcessingSeconds, longPollSeconds=longPollSeconds, limit=batchSize)
131
- if shouldProcessInParallel:
177
+ if self._shutdownEvent.is_set():
178
+ await self._release_messages(messages=messages)
179
+ elif shouldProcessInParallel:
132
180
  await asyncio.gather(*[self._process_message(message=message, expectedProcessingSeconds=expectedProcessingSeconds) for message in messages])
133
181
  else:
134
- for message in messages:
182
+ for index, message in enumerate(messages):
183
+ if self._shutdownEvent.is_set():
184
+ await self._release_messages(messages=messages[index:])
185
+ break
135
186
  await self._process_message(message=message, expectedProcessingSeconds=expectedProcessingSeconds)
136
187
  return len(messages)
137
188
 
@@ -139,14 +190,41 @@ class MessageQueueProcessor[MessageType: Message]:
139
190
  processedMessageCount = await self.execute_batch(batchSize=1, expectedProcessingSeconds=expectedProcessingSeconds, longPollSeconds=longPollSeconds)
140
191
  return processedMessageCount > 0
141
192
 
142
- async def run_batches(self, batchSize: int, expectedProcessingSeconds: int = 300, longPollSeconds: int = 20, sleepTime: int = 30, totalMessageLimit: int | None = None) -> int:
193
+ def _on_shutdown_signal(self, signalNumber: int) -> None:
194
+ logging.info(f'Received {signal.Signals(signalNumber).name}, finishing the current message before shutting down')
195
+ self.request_shutdown()
196
+ # NOTE(krishan711): a second signal falls back to the default handler so an impatient interrupt still stops the process
197
+ loop = asyncio.get_running_loop()
198
+ for shutdownSignal in SHUTDOWN_SIGNALS:
199
+ loop.remove_signal_handler(shutdownSignal)
200
+
201
+ async def run_batches(
202
+ self,
203
+ batchSize: int,
204
+ expectedProcessingSeconds: int = 300,
205
+ longPollSeconds: int = 20,
206
+ sleepTime: int = 30,
207
+ totalMessageLimit: int | None = None,
208
+ shouldProcessInParallel: bool = False,
209
+ ) -> int:
210
+ loop = asyncio.get_running_loop()
211
+ for shutdownSignal in SHUTDOWN_SIGNALS:
212
+ loop.add_signal_handler(shutdownSignal, self._on_shutdown_signal, shutdownSignal)
143
213
  processedMessageCount = 0
144
- while totalMessageLimit is None or processedMessageCount < totalMessageLimit:
145
- innerProcessedMessageCount = await self.execute_batch(expectedProcessingSeconds=expectedProcessingSeconds, longPollSeconds=longPollSeconds, batchSize=batchSize)
146
- if innerProcessedMessageCount == 0:
147
- logging.info('No message received.. sleeping')
148
- await asyncio.sleep(sleepTime)
149
- processedMessageCount += innerProcessedMessageCount
214
+ try:
215
+ while not self._shutdownEvent.is_set() and (totalMessageLimit is None or processedMessageCount < totalMessageLimit):
216
+ await self._delete_expired_messages_if_due()
217
+ innerProcessedMessageCount = await self.execute_batch(expectedProcessingSeconds=expectedProcessingSeconds, longPollSeconds=longPollSeconds, batchSize=batchSize, shouldProcessInParallel=shouldProcessInParallel)
218
+ if innerProcessedMessageCount == 0:
219
+ logging.info('No message received.. sleeping')
220
+ with contextlib.suppress(TimeoutError):
221
+ await asyncio.wait_for(self._shutdownEvent.wait(), timeout=sleepTime)
222
+ processedMessageCount += innerProcessedMessageCount
223
+ finally:
224
+ for shutdownSignal in SHUTDOWN_SIGNALS:
225
+ loop.remove_signal_handler(shutdownSignal)
226
+ if self._shutdownEvent.is_set():
227
+ logging.info('Shutdown requested, stopped processing messages')
150
228
  return processedMessageCount
151
229
 
152
230
  async def run(self, expectedProcessingSeconds: int = 300, longPollSeconds: int = 20, sleepTime: int = 30, totalMessageLimit: int | None = None) -> bool:
@@ -211,7 +211,7 @@ class SqlMessageQueue(MessageQueue[SqlMessage]):
211
211
  return result.first() is not None
212
212
 
213
213
  async def retry_message(self, message: SqlMessage, delaySeconds: int = 0) -> None:
214
- await self._reschedule_message(message=message, delaySeconds=delaySeconds, extraValues={})
214
+ await self._reschedule_message(message=message, delaySeconds=delaySeconds, shouldCountAttempt=True)
215
215
 
216
216
  async def fail_message(self, message: SqlMessage, errorMessage: str | None) -> bool:
217
217
  postCount = message.postCount or 0
@@ -220,21 +220,39 @@ class SqlMessageQueue(MessageQueue[SqlMessage]):
220
220
  query = self._owned_update(message=message).values(status=MESSAGE_STATUS_FAILED, lockToken=None, completedDate=self.database.now(), lastError=errorMessage)
221
221
  await self.database.execute(query=query, connection=connection)
222
222
  return False
223
- await self._reschedule_message(message=message, delaySeconds=self.failureRetryDelaySeconds * postCount, extraValues={'lastError': errorMessage})
223
+ await self._reschedule_message(message=message, delaySeconds=self.failureRetryDelaySeconds * postCount, shouldCountAttempt=True, extraValues={'lastError': errorMessage})
224
224
  return True
225
225
 
226
- async def _reschedule_message(self, message: SqlMessage, delaySeconds: float, extraValues: dict[str, Any]) -> None: # type: ignore[explicit-any]
226
+ async def release_message(self, message: SqlMessage) -> None:
227
+ await self._reschedule_message(message=message, delaySeconds=0, shouldCountAttempt=False)
228
+
229
+ async def _reschedule_message(self, message: SqlMessage, delaySeconds: float, shouldCountAttempt: bool, extraValues: dict[str, Any] | None = None) -> None: # type: ignore[explicit-any]
230
+ values = {'status': MESSAGE_STATUS_PENDING, 'lockToken': None, 'visibleDate': self.database.now(seconds=delaySeconds), **(extraValues or {})}
231
+ if shouldCountAttempt:
232
+ values |= {'postCount': self.table.c.postCount + 1, 'postDate': self.database.now()}
227
233
  async with self.database.create_transaction() as connection:
228
234
  try:
229
235
  async with connection.begin_nested():
230
- query = self._owned_update(message=message).values(status=MESSAGE_STATUS_PENDING, lockToken=None, visibleDate=self.database.now(seconds=delaySeconds), postCount=self.table.c.postCount + 1, postDate=self.database.now(), **extraValues)
236
+ query = self._owned_update(message=message).values(values)
231
237
  await self.database.execute(query=query, connection=connection)
232
238
  except sqlalchemy.exc.IntegrityError:
233
239
  # NOTE(krishan711): an identical message was enqueued while this one ran, so it will do the work instead
234
240
  query = self._owned_update(message=message).values(status=MESSAGE_STATUS_DEDUPLICATED, lockToken=None, completedDate=self.database.now())
235
241
  await self.database.execute(query=query, connection=connection)
236
242
 
237
- async def get_message_count(self, shouldSkipRemovingNonRetained: bool = False) -> int:
243
+ async def delete_expired_messages(self) -> int:
244
+ async with self.database.create_transaction() as connection:
245
+ query = (
246
+ sqlalchemy.delete(self.table)
247
+ .where(self.table.c.queueName == self.queueName)
248
+ .where(self.table.c.status.in_([MESSAGE_STATUS_SUCCEEDED, MESSAGE_STATUS_DEDUPLICATED]))
249
+ .where(self.table.c.completedDate < self.database.now(seconds=-self.retentionSeconds))
250
+ .returning(self.table.c.id)
251
+ )
252
+ result = await self.database.execute(query=query, connection=connection)
253
+ return len(result.all())
254
+
255
+ async def get_message_count(self) -> int:
238
256
  countQuery = (
239
257
  sqlalchemy.select(sqlalchemy.func.count())
240
258
  .select_from(self.table)
@@ -243,14 +261,6 @@ class SqlMessageQueue(MessageQueue[SqlMessage]):
243
261
  .where(self.table.c.visibleDate <= self.database.now())
244
262
  )
245
263
  async with self.database.create_transaction() as connection:
246
- if not shouldSkipRemovingNonRetained:
247
- deleteQuery = (
248
- sqlalchemy.delete(self.table)
249
- .where(self.table.c.queueName == self.queueName)
250
- .where(self.table.c.status.in_([MESSAGE_STATUS_SUCCEEDED, MESSAGE_STATUS_DEDUPLICATED]))
251
- .where(self.table.c.completedDate < self.database.now(seconds=-self.retentionSeconds))
252
- )
253
- await self.database.execute(query=deleteQuery, connection=connection)
254
264
  result = await self.database.execute(query=countQuery, connection=connection)
255
265
  return int(result.scalar_one())
256
266
 
@@ -131,6 +131,14 @@ class SqsMessageQueue(MessageQueue[SqsMessage]):
131
131
  raise
132
132
  return True
133
133
 
134
+ async def release_message(self, message: SqsMessage) -> None:
135
+ # NOTE(krishan711): a message whose lease was already lost is left alone; SQS still counts the next receive towards the queue's redrive policy
136
+ await self.extend_message_lease(message=message, expectedProcessingSeconds=0)
137
+
138
+ async def delete_expired_messages(self) -> int:
139
+ # NOTE(krishan711): SQS deletes messages when they are completed and expires the rest itself
140
+ return 0
141
+
134
142
  async def get_message_count(self) -> int:
135
143
  if not self._sqsClient:
136
144
  raise InternalServerErrorException('You need to call .connect() before trying to count messages')
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: kiba-core
3
- Version: 0.5.3.dev85
3
+ Version: 0.5.3.dev86
4
4
  Summary: Kiba Labs' python utilities
5
5
  Project-URL: repository, https://github.com/kibalabs/core-py
6
6
  Requires-Python: <4,>=3.12
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "kiba-core"
3
- version = "0.5.3.dev85"
3
+ version = "0.5.3.dev86"
4
4
  description = "Kiba Labs' python utilities"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.12, <4"
@@ -43,6 +43,13 @@ async def _claim(queue: AqsMessageQueue, expectedProcessingSeconds: int = 300) -
43
43
  return message
44
44
 
45
45
 
46
+ async def test_released_message_can_be_received_again_straight_away(workers: tuple[AqsMessageQueue, AqsMessageQueue]):
47
+ workerA, workerB = workers
48
+ await workerA.send_message(message=_message())
49
+ await workerA.release_message(message=await _claim(queue=workerA))
50
+ await _claim(queue=workerB)
51
+
52
+
46
53
  async def test_extended_message_stays_invisible_and_can_still_be_completed(workers: tuple[AqsMessageQueue, AqsMessageQueue]):
47
54
  workerA, workerB = workers
48
55
  await workerA.send_message(message=_message())
@@ -85,6 +85,15 @@ async def test_completed_message_is_removed_and_can_be_sent_again(workers: tuple
85
85
  assert await workerA.get_message_count() == 1
86
86
 
87
87
 
88
+ async def test_release_hands_the_message_back_without_using_an_attempt(workers: tuple[CosmosMessageQueue, CosmosMessageQueue]):
89
+ workerA, workerB = workers
90
+ await workerA.send_message(message=_message())
91
+ message = await _claim(queue=workerA)
92
+ await workerA.release_message(message=message)
93
+ releasedMessage = await _claim(queue=workerB)
94
+ assert (releasedMessage.id, releasedMessage.postCount) == (message.id, 1)
95
+
96
+
88
97
  async def test_retry_reschedules_the_same_message_with_a_delay(workers: tuple[CosmosMessageQueue, CosmosMessageQueue]):
89
98
  workerA, _ = workers
90
99
  await workerA.send_message(message=_message())
@@ -1,5 +1,6 @@
1
1
  import asyncio
2
2
  import os
3
+ import signal
3
4
  import typing
4
5
  from collections.abc import AsyncIterator
5
6
  from typing import Any
@@ -262,6 +263,66 @@ async def test_processor_cancellation_propagates_even_when_the_lease_was_lost(wo
262
263
  await executeTask
263
264
 
264
265
 
266
+ async def test_processor_releases_a_cancelled_message_without_using_an_attempt(workers: tuple[SqlMessageQueue, SqlMessageQueue]):
267
+ workerA, _ = workers
268
+
269
+ class SlowProcessor(MessageProcessor):
270
+ async def process_message(self, message: Message) -> None:
271
+ await asyncio.sleep(10)
272
+
273
+ processor = MessageQueueProcessor(queue=workerA, messageProcessor=SlowProcessor(), notificationClients=[])
274
+ await workerA.send_message(message=_message())
275
+ executeTask = asyncio.create_task(processor.execute(longPollSeconds=0))
276
+ await asyncio.sleep(0.3)
277
+ executeTask.cancel()
278
+ with pytest.raises(asyncio.CancelledError):
279
+ await executeTask
280
+ row = (await _rows(queue=workerA))[0]
281
+ assert (row['status'], row['post_count'], row['lock_token']) == ('pending', 1, None)
282
+ assert (await _claim(queue=workerA)).postCount == 1
283
+
284
+
285
+ async def test_processor_finishes_the_current_message_and_releases_the_rest_on_shutdown(workers: tuple[SqlMessageQueue, SqlMessageQueue]):
286
+ workerA, _ = workers
287
+ processedUserIds: list[str] = []
288
+
289
+ class ShutdownRequestingProcessor(MessageProcessor):
290
+ async def process_message(self, message: Message) -> None:
291
+ processedUserIds.append(str(message.content['userId']))
292
+ processor.request_shutdown()
293
+
294
+ processor = MessageQueueProcessor(queue=workerA, messageProcessor=ShutdownRequestingProcessor(), notificationClients=[])
295
+ await workerA.send_messages(messages=[_message(deduplicationId=None, userId=str(index)) for index in range(3)])
296
+ assert await processor.run_batches(batchSize=3, longPollSeconds=0) == 3 # noqa: PLR2004
297
+ assert processedUserIds == ['0']
298
+ assert [(row['status'], row['post_count']) for row in await _rows(queue=workerA)] == [('succeeded', 1), ('pending', 1), ('pending', 1)]
299
+
300
+
301
+ async def test_processor_stops_promptly_when_signalled_while_idle(workers: tuple[SqlMessageQueue, SqlMessageQueue]):
302
+ workerA, _ = workers
303
+ processor = MessageQueueProcessor(queue=workerA, messageProcessor=typing.cast(MessageProcessor, None), notificationClients=[])
304
+ runTask = asyncio.create_task(processor.run(longPollSeconds=0, sleepTime=30))
305
+ await asyncio.sleep(0.3)
306
+ os.kill(os.getpid(), signal.SIGTERM)
307
+ assert await asyncio.wait_for(runTask, timeout=5) is False
308
+
309
+
310
+ async def test_processor_deletes_expired_messages_while_running(workers: tuple[SqlMessageQueue, SqlMessageQueue]):
311
+ workerA, _ = workers
312
+ await workerA.send_message(message=_message())
313
+ message = await _claim(queue=workerA)
314
+ await workerA.complete_message(message=message)
315
+ async with workerA.database.create_transaction() as connection:
316
+ await workerA.database.execute(query=sqlalchemy.update(workerA.table).values(completedDate=date_util.datetime_from_now(days=-8)), connection=connection)
317
+ processor = MessageQueueProcessor(queue=workerA, messageProcessor=typing.cast(MessageProcessor, None), notificationClients=[])
318
+ runTask = asyncio.create_task(processor.run(longPollSeconds=0, sleepTime=30))
319
+ await asyncio.sleep(0.3)
320
+ processor.request_shutdown()
321
+ await asyncio.wait_for(runTask, timeout=5)
322
+ assert await _rows(queue=workerA) == []
323
+
324
+
325
+
265
326
  async def test_expired_lease_with_no_attempts_left_is_failed(workers: tuple[SqlMessageQueue, SqlMessageQueue]):
266
327
  workerA, _ = workers
267
328
  await workerA.send_message(message=_message())
@@ -315,7 +376,27 @@ async def test_retry_is_folded_into_an_identical_pending_message(workers: tuple[
315
376
  assert [row['status'] for row in await _rows(queue=workerA)] == ['deduplicated', 'pending']
316
377
 
317
378
 
318
- async def test_message_count_removes_only_old_finished_messages_unless_skipped(workers: tuple[SqlMessageQueue, SqlMessageQueue]):
379
+ async def test_release_hands_the_message_back_without_using_an_attempt(workers: tuple[SqlMessageQueue, SqlMessageQueue]):
380
+ workerA, workerB = workers
381
+ await workerA.send_message(message=_message())
382
+ message = await _claim(queue=workerA)
383
+ await workerA.release_message(message=message)
384
+ releasedMessage = await _claim(queue=workerB)
385
+ assert (releasedMessage.id, releasedMessage.postCount) == (message.id, 1)
386
+
387
+
388
+ async def test_release_after_losing_the_lease_leaves_the_new_holder_untouched(workers: tuple[SqlMessageQueue, SqlMessageQueue]):
389
+ workerA, workerB = workers
390
+ await workerA.send_message(message=_message())
391
+ staleMessage = await _claim(queue=workerA)
392
+ await _make_visible(queue=workerA, messageId=staleMessage.id)
393
+ await _claim(queue=workerB)
394
+ await workerA.release_message(message=staleMessage)
395
+ row = await _row(queue=workerA, messageId=staleMessage.id)
396
+ assert (row['status'], row['owner']) == ('running', 'worker-b')
397
+
398
+
399
+ async def test_delete_expired_messages_removes_only_old_finished_messages(workers: tuple[SqlMessageQueue, SqlMessageQueue]):
319
400
  workerA, _ = workers
320
401
  await workerA.send_messages(messages=[_message(deduplicationId=None, userId=str(index)) for index in range(4)])
321
402
  succeededMessage, failedMessage, recentMessage = await workerA.get_messages(limit=3)
@@ -324,9 +405,9 @@ async def test_message_count_removes_only_old_finished_messages_unless_skipped(w
324
405
  await workerA.complete_message(message=recentMessage)
325
406
  async with workerA.database.create_transaction() as connection:
326
407
  await workerA.database.execute(query=sqlalchemy.update(workerA.table).where(workerA.table.c.id.in_([succeededMessage.id, failedMessage.id])).values(completedDate=date_util.datetime_from_now(days=-8)), connection=connection)
327
- assert await workerA.get_message_count(shouldSkipRemovingNonRetained=True) == 1
328
- assert len(await _rows(queue=workerA)) == 4 # noqa: PLR2004
329
408
  assert await workerA.get_message_count() == 1
409
+ assert len(await _rows(queue=workerA)) == 4 # noqa: PLR2004
410
+ assert await workerA.delete_expired_messages() == 1
330
411
  assert [row['status'] for row in await _rows(queue=workerA)] == ['failed', 'succeeded', 'pending']
331
412
 
332
413
 
@@ -38,6 +38,13 @@ async def _claim(queue: SqsMessageQueue, expectedProcessingSeconds: int = 300) -
38
38
  return message
39
39
 
40
40
 
41
+ async def test_released_message_can_be_received_again_straight_away(workers: tuple[SqsMessageQueue, SqsMessageQueue]):
42
+ workerA, workerB = workers
43
+ await workerA.send_message(message=_message())
44
+ await workerA.release_message(message=await _claim(queue=workerA))
45
+ await _claim(queue=workerB)
46
+
47
+
41
48
  async def test_extended_message_stays_invisible_and_can_still_be_completed(workers: tuple[SqsMessageQueue, SqsMessageQueue]):
42
49
  workerA, workerB = workers
43
50
  await workerA.send_message(message=_message())
File without changes