kiba-core 0.5.3.dev72__tar.gz → 0.5.3.dev74__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 (140) hide show
  1. {kiba_core-0.5.3.dev72 → kiba_core-0.5.3.dev74}/CHANGELOG.md +3 -0
  2. {kiba_core-0.5.3.dev72 → kiba_core-0.5.3.dev74}/PKG-INFO +1 -1
  3. {kiba_core-0.5.3.dev72 → kiba_core-0.5.3.dev74}/core/queues/aqs.py +2 -2
  4. {kiba_core-0.5.3.dev72 → kiba_core-0.5.3.dev74}/core/queues/cosmos.py +39 -11
  5. {kiba_core-0.5.3.dev72 → kiba_core-0.5.3.dev74}/core/queues/message_queue.py +2 -1
  6. {kiba_core-0.5.3.dev72 → kiba_core-0.5.3.dev74}/core/queues/message_queue_processor.py +18 -9
  7. {kiba_core-0.5.3.dev72 → kiba_core-0.5.3.dev74}/core/queues/sql.py +3 -2
  8. {kiba_core-0.5.3.dev72 → kiba_core-0.5.3.dev74}/core/queues/sqs.py +2 -2
  9. {kiba_core-0.5.3.dev72 → kiba_core-0.5.3.dev74}/kiba_core.egg-info/PKG-INFO +1 -1
  10. {kiba_core-0.5.3.dev72 → kiba_core-0.5.3.dev74}/kiba_core.egg-info/SOURCES.txt +1 -0
  11. {kiba_core-0.5.3.dev72 → kiba_core-0.5.3.dev74}/pyproject.toml +1 -1
  12. {kiba_core-0.5.3.dev72 → kiba_core-0.5.3.dev74}/tests/queues/test_cosmos_queue.py +40 -5
  13. kiba_core-0.5.3.dev74/tests/queues/test_message_queue_processor.py +46 -0
  14. {kiba_core-0.5.3.dev72 → kiba_core-0.5.3.dev74}/tests/queues/test_sql_queue.py +25 -1
  15. {kiba_core-0.5.3.dev72 → kiba_core-0.5.3.dev74}/.github/pull_request_template.md +0 -0
  16. {kiba_core-0.5.3.dev72 → kiba_core-0.5.3.dev74}/.github/workflows/deploy.yml +0 -0
  17. {kiba_core-0.5.3.dev72 → kiba_core-0.5.3.dev74}/.github/workflows/pull-request.yml +0 -0
  18. {kiba_core-0.5.3.dev72 → kiba_core-0.5.3.dev74}/.github/workflows/release.yml +0 -0
  19. {kiba_core-0.5.3.dev72 → kiba_core-0.5.3.dev74}/.gitignore +0 -0
  20. {kiba_core-0.5.3.dev72 → kiba_core-0.5.3.dev74}/Dockerfile +0 -0
  21. {kiba_core-0.5.3.dev72 → kiba_core-0.5.3.dev74}/README.md +0 -0
  22. {kiba_core-0.5.3.dev72 → kiba_core-0.5.3.dev74}/core/__init__.py +0 -0
  23. {kiba_core-0.5.3.dev72 → kiba_core-0.5.3.dev74}/core/api/__init__.py +0 -0
  24. {kiba_core-0.5.3.dev72 → kiba_core-0.5.3.dev74}/core/api/api_request.py +0 -0
  25. {kiba_core-0.5.3.dev72 → kiba_core-0.5.3.dev74}/core/api/api_response.py +0 -0
  26. {kiba_core-0.5.3.dev72 → kiba_core-0.5.3.dev74}/core/api/authorizer.py +0 -0
  27. {kiba_core-0.5.3.dev72 → kiba_core-0.5.3.dev74}/core/api/default_routes.py +0 -0
  28. {kiba_core-0.5.3.dev72 → kiba_core-0.5.3.dev74}/core/api/health.py +0 -0
  29. {kiba_core-0.5.3.dev72 → kiba_core-0.5.3.dev74}/core/api/json_route.py +0 -0
  30. {kiba_core-0.5.3.dev72 → kiba_core-0.5.3.dev74}/core/api/middleware/__init__.py +0 -0
  31. {kiba_core-0.5.3.dev72 → kiba_core-0.5.3.dev74}/core/api/middleware/database_connection_middleware.py +0 -0
  32. {kiba_core-0.5.3.dev72 → kiba_core-0.5.3.dev74}/core/api/middleware/exception_handling_middleware.py +0 -0
  33. {kiba_core-0.5.3.dev72 → kiba_core-0.5.3.dev74}/core/api/middleware/logging_middleware.py +0 -0
  34. {kiba_core-0.5.3.dev72 → kiba_core-0.5.3.dev74}/core/api/middleware/origin_ip_middleware.py +0 -0
  35. {kiba_core-0.5.3.dev72 → kiba_core-0.5.3.dev74}/core/api/middleware/server_headers_middleware.py +0 -0
  36. {kiba_core-0.5.3.dev72 → kiba_core-0.5.3.dev74}/core/api/openapi.py +0 -0
  37. {kiba_core-0.5.3.dev72 → kiba_core-0.5.3.dev74}/core/api/rate_limit.py +0 -0
  38. {kiba_core-0.5.3.dev72 → kiba_core-0.5.3.dev74}/core/api/request_context.py +0 -0
  39. {kiba_core-0.5.3.dev72 → kiba_core-0.5.3.dev74}/core/api/route.py +0 -0
  40. {kiba_core-0.5.3.dev72 → kiba_core-0.5.3.dev74}/core/api/route_auth.py +0 -0
  41. {kiba_core-0.5.3.dev72 → kiba_core-0.5.3.dev74}/core/api/route_metadata.py +0 -0
  42. {kiba_core-0.5.3.dev72 → kiba_core-0.5.3.dev74}/core/api/streaming_json_route.py +0 -0
  43. {kiba_core-0.5.3.dev72 → kiba_core-0.5.3.dev74}/core/aws_requester.py +0 -0
  44. {kiba_core-0.5.3.dev72 → kiba_core-0.5.3.dev74}/core/caching/__init__.py +0 -0
  45. {kiba_core-0.5.3.dev72 → kiba_core-0.5.3.dev74}/core/caching/cache.py +0 -0
  46. {kiba_core-0.5.3.dev72 → kiba_core-0.5.3.dev74}/core/caching/dict_cache.py +0 -0
  47. {kiba_core-0.5.3.dev72 → kiba_core-0.5.3.dev74}/core/caching/file_cache.py +0 -0
  48. {kiba_core-0.5.3.dev72 → kiba_core-0.5.3.dev74}/core/cron/__init__.py +0 -0
  49. {kiba_core-0.5.3.dev72 → kiba_core-0.5.3.dev74}/core/cron/api_request_cron_job_processor.py +0 -0
  50. {kiba_core-0.5.3.dev72 → kiba_core-0.5.3.dev74}/core/cron/cron_executor.py +0 -0
  51. {kiba_core-0.5.3.dev72 → kiba_core-0.5.3.dev74}/core/cron/cron_job_processor.py +0 -0
  52. {kiba_core-0.5.3.dev72 → kiba_core-0.5.3.dev74}/core/cron/model.py +0 -0
  53. {kiba_core-0.5.3.dev72 → kiba_core-0.5.3.dev74}/core/exceptions.py +0 -0
  54. {kiba_core-0.5.3.dev72 → kiba_core-0.5.3.dev74}/core/http/__init__.py +0 -0
  55. {kiba_core-0.5.3.dev72 → kiba_core-0.5.3.dev74}/core/http/basic_authentication.py +0 -0
  56. {kiba_core-0.5.3.dev72 → kiba_core-0.5.3.dev74}/core/http/jwt.py +0 -0
  57. {kiba_core-0.5.3.dev72 → kiba_core-0.5.3.dev74}/core/http/rest_method.py +0 -0
  58. {kiba_core-0.5.3.dev72 → kiba_core-0.5.3.dev74}/core/locks/__init__.py +0 -0
  59. {kiba_core-0.5.3.dev72 → kiba_core-0.5.3.dev74}/core/locks/lock.py +0 -0
  60. {kiba_core-0.5.3.dev72 → kiba_core-0.5.3.dev74}/core/locks/model.py +0 -0
  61. {kiba_core-0.5.3.dev72 → kiba_core-0.5.3.dev74}/core/locks/sql.py +0 -0
  62. {kiba_core-0.5.3.dev72 → kiba_core-0.5.3.dev74}/core/logging.py +0 -0
  63. {kiba_core-0.5.3.dev72 → kiba_core-0.5.3.dev74}/core/notifications/__init__.py +0 -0
  64. {kiba_core-0.5.3.dev72 → kiba_core-0.5.3.dev74}/core/notifications/discord_client.py +0 -0
  65. {kiba_core-0.5.3.dev72 → kiba_core-0.5.3.dev74}/core/notifications/notification_client.py +0 -0
  66. {kiba_core-0.5.3.dev72 → kiba_core-0.5.3.dev74}/core/notifications/slack_client.py +0 -0
  67. {kiba_core-0.5.3.dev72 → kiba_core-0.5.3.dev74}/core/py.typed +0 -0
  68. {kiba_core-0.5.3.dev72 → kiba_core-0.5.3.dev74}/core/queues/__init__.py +0 -0
  69. {kiba_core-0.5.3.dev72 → kiba_core-0.5.3.dev74}/core/queues/model.py +0 -0
  70. {kiba_core-0.5.3.dev72 → kiba_core-0.5.3.dev74}/core/requester/__init__.py +0 -0
  71. {kiba_core-0.5.3.dev72 → kiba_core-0.5.3.dev74}/core/requester/requester.py +0 -0
  72. {kiba_core-0.5.3.dev72 → kiba_core-0.5.3.dev74}/core/s3_manager.py +0 -0
  73. {kiba_core-0.5.3.dev72 → kiba_core-0.5.3.dev74}/core/service_client.py +0 -0
  74. {kiba_core-0.5.3.dev72 → kiba_core-0.5.3.dev74}/core/store/__init__.py +0 -0
  75. {kiba_core-0.5.3.dev72 → kiba_core-0.5.3.dev74}/core/store/database.py +0 -0
  76. {kiba_core-0.5.3.dev72 → kiba_core-0.5.3.dev74}/core/store/retriever.py +0 -0
  77. {kiba_core-0.5.3.dev72 → kiba_core-0.5.3.dev74}/core/store/saver.py +0 -0
  78. {kiba_core-0.5.3.dev72 → kiba_core-0.5.3.dev74}/core/util/__init__.py +0 -0
  79. {kiba_core-0.5.3.dev72 → kiba_core-0.5.3.dev74}/core/util/async_util.py +0 -0
  80. {kiba_core-0.5.3.dev72 → kiba_core-0.5.3.dev74}/core/util/chain_util.py +0 -0
  81. {kiba_core-0.5.3.dev72 → kiba_core-0.5.3.dev74}/core/util/date_util.py +0 -0
  82. {kiba_core-0.5.3.dev72 → kiba_core-0.5.3.dev74}/core/util/dict_util.py +0 -0
  83. {kiba_core-0.5.3.dev72 → kiba_core-0.5.3.dev74}/core/util/file_util.py +0 -0
  84. {kiba_core-0.5.3.dev72 → kiba_core-0.5.3.dev74}/core/util/hashing_util.py +0 -0
  85. {kiba_core-0.5.3.dev72 → kiba_core-0.5.3.dev74}/core/util/http_util.py +0 -0
  86. {kiba_core-0.5.3.dev72 → kiba_core-0.5.3.dev74}/core/util/json_util.py +0 -0
  87. {kiba_core-0.5.3.dev72 → kiba_core-0.5.3.dev74}/core/util/list_util.py +0 -0
  88. {kiba_core-0.5.3.dev72 → kiba_core-0.5.3.dev74}/core/util/process_util.py +0 -0
  89. {kiba_core-0.5.3.dev72 → kiba_core-0.5.3.dev74}/core/util/string_util.py +0 -0
  90. {kiba_core-0.5.3.dev72 → kiba_core-0.5.3.dev74}/core/util/typing_util.py +0 -0
  91. {kiba_core-0.5.3.dev72 → kiba_core-0.5.3.dev74}/core/util/url_util.py +0 -0
  92. {kiba_core-0.5.3.dev72 → kiba_core-0.5.3.dev74}/core/util/value_holder.py +0 -0
  93. {kiba_core-0.5.3.dev72 → kiba_core-0.5.3.dev74}/core/web3/__init__.py +0 -0
  94. {kiba_core-0.5.3.dev72 → kiba_core-0.5.3.dev74}/core/web3/eth_client.py +0 -0
  95. {kiba_core-0.5.3.dev72 → kiba_core-0.5.3.dev74}/core/web3/eth_client_registry.py +0 -0
  96. {kiba_core-0.5.3.dev72 → kiba_core-0.5.3.dev74}/core/web3/multicall3.py +0 -0
  97. {kiba_core-0.5.3.dev72 → kiba_core-0.5.3.dev74}/kiba_core.egg-info/dependency_links.txt +0 -0
  98. {kiba_core-0.5.3.dev72 → kiba_core-0.5.3.dev74}/kiba_core.egg-info/requires.txt +0 -0
  99. {kiba_core-0.5.3.dev72 → kiba_core-0.5.3.dev74}/kiba_core.egg-info/top_level.txt +0 -0
  100. {kiba_core-0.5.3.dev72 → kiba_core-0.5.3.dev74}/makefile +0 -0
  101. {kiba_core-0.5.3.dev72 → kiba_core-0.5.3.dev74}/setup.cfg +0 -0
  102. {kiba_core-0.5.3.dev72 → kiba_core-0.5.3.dev74}/tests/__init__.py +0 -0
  103. {kiba_core-0.5.3.dev72 → kiba_core-0.5.3.dev74}/tests/api/test_api_response.py +0 -0
  104. {kiba_core-0.5.3.dev72 → kiba_core-0.5.3.dev74}/tests/api/test_authorizer.py +0 -0
  105. {kiba_core-0.5.3.dev72 → kiba_core-0.5.3.dev74}/tests/api/test_exception_handling_middleware.py +0 -0
  106. {kiba_core-0.5.3.dev72 → kiba_core-0.5.3.dev74}/tests/api/test_json_route.py +0 -0
  107. {kiba_core-0.5.3.dev72 → kiba_core-0.5.3.dev74}/tests/api/test_openapi.py +0 -0
  108. {kiba_core-0.5.3.dev72 → kiba_core-0.5.3.dev74}/tests/api/test_origin_ip_middleware.py +0 -0
  109. {kiba_core-0.5.3.dev72 → kiba_core-0.5.3.dev74}/tests/api/test_rate_limit.py +0 -0
  110. {kiba_core-0.5.3.dev72 → kiba_core-0.5.3.dev74}/tests/api/test_request_context.py +0 -0
  111. {kiba_core-0.5.3.dev72 → kiba_core-0.5.3.dev74}/tests/api/test_route.py +0 -0
  112. {kiba_core-0.5.3.dev72 → kiba_core-0.5.3.dev74}/tests/api/test_streaming_json_route.py +0 -0
  113. {kiba_core-0.5.3.dev72 → kiba_core-0.5.3.dev74}/tests/caching/test_dict_cache.py +0 -0
  114. {kiba_core-0.5.3.dev72 → kiba_core-0.5.3.dev74}/tests/caching/test_file_cache.py +0 -0
  115. {kiba_core-0.5.3.dev72 → kiba_core-0.5.3.dev74}/tests/cron/__init__.py +0 -0
  116. {kiba_core-0.5.3.dev72 → kiba_core-0.5.3.dev74}/tests/cron/test_cron_executor.py +0 -0
  117. {kiba_core-0.5.3.dev72 → kiba_core-0.5.3.dev74}/tests/locks/test_sql_lock.py +0 -0
  118. {kiba_core-0.5.3.dev72 → kiba_core-0.5.3.dev74}/tests/queues/conftest.py +0 -0
  119. {kiba_core-0.5.3.dev72 → kiba_core-0.5.3.dev74}/tests/queues/test_aqs_queue.py +0 -0
  120. {kiba_core-0.5.3.dev72 → kiba_core-0.5.3.dev74}/tests/queues/test_sqs_queue.py +0 -0
  121. {kiba_core-0.5.3.dev72 → kiba_core-0.5.3.dev74}/tests/requester/__init__.py +0 -0
  122. {kiba_core-0.5.3.dev72 → kiba_core-0.5.3.dev74}/tests/requester/test_requester.py +0 -0
  123. {kiba_core-0.5.3.dev72 → kiba_core-0.5.3.dev74}/tests/test_database.py +0 -0
  124. {kiba_core-0.5.3.dev72 → kiba_core-0.5.3.dev74}/tests/test_exceptions.py +0 -0
  125. {kiba_core-0.5.3.dev72 → kiba_core-0.5.3.dev74}/tests/test_logging.py +0 -0
  126. {kiba_core-0.5.3.dev72 → kiba_core-0.5.3.dev74}/tests/util/__init__.py +0 -0
  127. {kiba_core-0.5.3.dev72 → kiba_core-0.5.3.dev74}/tests/util/test_async_util.py +0 -0
  128. {kiba_core-0.5.3.dev72 → kiba_core-0.5.3.dev74}/tests/util/test_chain_util.py +0 -0
  129. {kiba_core-0.5.3.dev72 → kiba_core-0.5.3.dev74}/tests/util/test_date_util.py +0 -0
  130. {kiba_core-0.5.3.dev72 → kiba_core-0.5.3.dev74}/tests/util/test_dict_util.py +0 -0
  131. {kiba_core-0.5.3.dev72 → kiba_core-0.5.3.dev74}/tests/util/test_file_util.py +0 -0
  132. {kiba_core-0.5.3.dev72 → kiba_core-0.5.3.dev74}/tests/util/test_json_util.py +0 -0
  133. {kiba_core-0.5.3.dev72 → kiba_core-0.5.3.dev74}/tests/util/test_list_util.py +0 -0
  134. {kiba_core-0.5.3.dev72 → kiba_core-0.5.3.dev74}/tests/util/test_process_util.py +0 -0
  135. {kiba_core-0.5.3.dev72 → kiba_core-0.5.3.dev74}/tests/util/test_string_util.py +0 -0
  136. {kiba_core-0.5.3.dev72 → kiba_core-0.5.3.dev74}/tests/util/test_url_util.py +0 -0
  137. {kiba_core-0.5.3.dev72 → kiba_core-0.5.3.dev74}/tests/web3/__init__.py +0 -0
  138. {kiba_core-0.5.3.dev72 → kiba_core-0.5.3.dev74}/tests/web3/test_eth_client_registry.py +0 -0
  139. {kiba_core-0.5.3.dev72 → kiba_core-0.5.3.dev74}/tests/web3/test_rest_eth_client.py +0 -0
  140. {kiba_core-0.5.3.dev72 → kiba_core-0.5.3.dev74}/uv.lock +0 -0
@@ -8,6 +8,9 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/)
8
8
  ## [Unreleased]
9
9
 
10
10
  ### Added
11
+ - [MINOR] Added `shouldNotifyRetriedFailures` (default `True`) to `MessageQueueProcessor`: when `False`, failure notifications are only sent once the queue gives up on a message, not for failures it will retry. `MessageQueue.fail_message` now returns whether the queue scheduled another attempt: `SqlMessageQueue` and `CosmosMessageQueue` return `True` until `maxAttempts` is reached, while `SqsMessageQueue` and `AqsMessageQueue` always return `False` as the platform decides on redelivery, so they keep notifying every failure
12
+ - [MAJOR] Updated `CosmosMessageQueue` to retry failed messages like `SqlMessageQueue`: added `maxAttempts` (default 3) and `failureRetryDelaySeconds` (default 60). `fail_message` reschedules the message with linear backoff and stores `lastError`, and a visible message whose lease expired counts as an attempt when it is claimed again. Once `postCount` reaches `maxAttempts` the message is removed, along with its deduplication item. Previously failed or abandoned messages reappeared once their lease expired and were retried forever
13
+ - [MINOR] Added `pollLogIntervalSeconds` (default 600) to `MessageQueueProcessor`: `Retrieving messages...` is now logged on the first poll and then at most once per interval instead of on every poll, so it still shows the worker is alive without flooding the logs; pass `0` to log every poll as before
11
14
  - [MAJOR] Updated `RestEthClient` to raise `RpcServerException` (a `ServerException` keeping the original status code and message, with the original exception as its cause) when the node responds with a 5xx, so callers can tell node failures apart from other server errors; `RequesterTimeoutException` is still raised unchanged. Code catching `InternalServerErrorException` (or `BadGatewayException`, `ServiceUnavailableException`, …) around `RestEthClient` calls must catch `RpcServerException` instead
12
15
  - [MINOR] Added `RestEthClient.get_code`
13
16
  - [MINOR] Added optional `maxConcurrentRequestCount` and `semaphore` to `RestEthClient` to limit concurrent requests; pass the same `semaphore` to several clients to limit them together (it takes precedence over `maxConcurrentRequestCount`)
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: kiba-core
3
- Version: 0.5.3.dev72
3
+ Version: 0.5.3.dev74
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
@@ -96,9 +96,9 @@ class AqsMessageQueue(MessageQueue[AqsMessage]):
96
96
  await self.send_message(message=message, delaySeconds=delaySeconds)
97
97
  await self.complete_message(message=message)
98
98
 
99
- async def fail_message(self, message: AqsMessage, errorMessage: str | None) -> None:
99
+ async def fail_message(self, message: AqsMessage, errorMessage: str | None) -> bool: # noqa: ARG002
100
100
  # NOTE(krishan711): the message reappears after its visibility timeout
101
- pass
101
+ return False
102
102
 
103
103
  async def extend_message_lease(self, message: AqsMessage, expectedProcessingSeconds: int) -> bool:
104
104
  if not self._aqsClient:
@@ -48,10 +48,12 @@ class CosmosMessage(Message):
48
48
 
49
49
 
50
50
  class CosmosMessageQueue(MessageQueue[CosmosMessage]):
51
- def __init__(self, container: ContainerProxy, queueName: str, pollIntervalSeconds: float = 1.0) -> None:
51
+ def __init__(self, container: ContainerProxy, queueName: str, pollIntervalSeconds: float = 1.0, maxAttempts: int = 3, failureRetryDelaySeconds: int = 60) -> None:
52
52
  self.container = container
53
53
  self.queueName = queueName
54
54
  self.pollIntervalSeconds = pollIntervalSeconds
55
+ self.maxAttempts = maxAttempts
56
+ self.failureRetryDelaySeconds = failureRetryDelaySeconds
55
57
 
56
58
  async def connect(self) -> None:
57
59
  # The caller supplies a shared ContainerProxy and owns CosmosClient lifecycle.
@@ -148,6 +150,12 @@ class CosmosMessageQueue(MessageQueue[CosmosMessage]):
148
150
  messages: list[CosmosMessage] = []
149
151
  async for candidate in candidates:
150
152
  item = {key: value for key, value in candidate.items() if not key.startswith('_')}
153
+ # NOTE(krishan711): a visible message that still has a lease was abandoned by its worker, which counts as an attempt
154
+ if candidate.get('leaseId') is not None:
155
+ if (candidate.get('postCount') or 0) >= self.maxAttempts:
156
+ await self._remove_message_if_unchanged(itemId=candidate['id'], etag=candidate['_etag'], deduplicationId=candidate.get('deduplicationId'))
157
+ continue
158
+ item['postCount'] = (candidate.get('postCount') or 0) + 1
151
159
  leaseId = uuid.uuid4().hex
152
160
  item['leaseId'] = leaseId
153
161
  item['visibleDate'] = now + expectedProcessingSeconds
@@ -170,27 +178,38 @@ class CosmosMessageQueue(MessageQueue[CosmosMessage]):
170
178
  break
171
179
  return messages
172
180
 
173
- async def complete_message(self, message: CosmosMessage) -> None:
174
- if message.deduplicationId is None:
181
+ async def _remove_message(self, itemId: str, etag: str, deduplicationId: str | None) -> None:
182
+ if deduplicationId is None:
175
183
  await self.container.delete_item(
176
- item=message.id,
184
+ item=itemId,
177
185
  partition_key=self.queueName,
178
- etag=message.etag,
186
+ etag=etag,
179
187
  match_condition=MatchConditions.IfNotModified,
180
188
  )
181
189
  return
182
190
 
183
- deduplicationKey = f'{self.queueName}:{message.deduplicationId}'
191
+ deduplicationKey = f'{self.queueName}:{deduplicationId}'
184
192
  deduplicationItemId = f'dedup-{hashlib.sha256(deduplicationKey.encode()).hexdigest()}'
185
193
  await self.container.execute_item_batch(
186
194
  batch_operations=[
187
- ('delete', (message.id,), {'if_match_etag': message.etag}),
195
+ ('delete', (itemId,), {'if_match_etag': etag}),
188
196
  ('delete', (deduplicationItemId,)),
189
197
  ],
190
198
  partition_key=self.queueName,
191
199
  )
192
200
 
193
- async def retry_message(self, message: CosmosMessage, delaySeconds: int = 0) -> None:
201
+ async def _remove_message_if_unchanged(self, itemId: str, etag: str, deduplicationId: str | None) -> None:
202
+ try:
203
+ await self._remove_message(itemId=itemId, etag=etag, deduplicationId=deduplicationId)
204
+ except (cosmos_exceptions.CosmosHttpResponseError, cosmos_exceptions.CosmosBatchOperationError) as exception:
205
+ # NOTE(krishan711): 412 means another worker changed the message first, so it now owns it
206
+ if exception.status_code != 412: # noqa: PLR2004
207
+ raise
208
+
209
+ async def complete_message(self, message: CosmosMessage) -> None:
210
+ await self._remove_message(itemId=message.id, etag=message.etag, deduplicationId=message.deduplicationId)
211
+
212
+ async def _reschedule_message(self, message: CosmosMessage, delaySeconds: float, extraOperations: list[dict[str, Any]]) -> None: # type: ignore[explicit-any]
194
213
  try:
195
214
  await self.container.patch_item(
196
215
  item=message.id,
@@ -200,6 +219,7 @@ class CosmosMessageQueue(MessageQueue[CosmosMessage]):
200
219
  {'op': 'set', 'path': '/visibleDate', 'value': time.time() + delaySeconds},
201
220
  {'op': 'set', 'path': '/postCount', 'value': (message.postCount or 0) + 1},
202
221
  {'op': 'set', 'path': '/postDate', 'value': date_util.datetime_from_now().isoformat()},
222
+ *extraOperations,
203
223
  ],
204
224
  etag=message.etag,
205
225
  match_condition=MatchConditions.IfNotModified,
@@ -209,9 +229,17 @@ class CosmosMessageQueue(MessageQueue[CosmosMessage]):
209
229
  if exception.status_code != 412: # noqa: PLR2004
210
230
  raise
211
231
 
212
- async def fail_message(self, message: CosmosMessage, errorMessage: str | None) -> None:
213
- # NOTE(krishan711): the message reappears once its lease expires
214
- pass
232
+ async def retry_message(self, message: CosmosMessage, delaySeconds: int = 0) -> None:
233
+ await self._reschedule_message(message=message, delaySeconds=delaySeconds, extraOperations=[])
234
+
235
+ async def fail_message(self, message: CosmosMessage, errorMessage: str | None) -> bool:
236
+ postCount = message.postCount or 0
237
+ if postCount >= self.maxAttempts:
238
+ # NOTE(krishan711): cosmos keeps no message history, so a message out of attempts is removed like a completed one
239
+ await self._remove_message_if_unchanged(itemId=message.id, etag=message.etag, deduplicationId=message.deduplicationId)
240
+ return False
241
+ await self._reschedule_message(message=message, delaySeconds=self.failureRetryDelaySeconds * postCount, extraOperations=[{'op': 'set', 'path': '/lastError', 'value': errorMessage}])
242
+ return True
215
243
 
216
244
  async def extend_message_lease(self, message: CosmosMessage, expectedProcessingSeconds: int) -> bool:
217
245
  try:
@@ -39,7 +39,8 @@ class MessageQueue[MessageType: Message](ABC):
39
39
  raise NotImplementedError
40
40
 
41
41
  @abc.abstractmethod
42
- async def fail_message(self, message: MessageType, errorMessage: str | None) -> None:
42
+ async def fail_message(self, message: MessageType, errorMessage: str | None) -> bool:
43
+ # NOTE(krishan711): returns whether the queue itself scheduled another attempt; queues where the platform decides on redelivery return False
43
44
  raise NotImplementedError
44
45
 
45
46
  @abc.abstractmethod
@@ -40,23 +40,29 @@ class MessageLeaseLostException(InternalServerErrorException):
40
40
 
41
41
 
42
42
  class MessageQueueProcessor[MessageType: Message]:
43
- def __init__(self, queue: MessageQueue[MessageType], messageProcessor: MessageProcessor, notificationClients: list[NotificationClient], requestIdHolder: RequestIdHolder | None = None) -> None:
43
+ def __init__(self, queue: MessageQueue[MessageType], messageProcessor: MessageProcessor, notificationClients: list[NotificationClient], requestIdHolder: RequestIdHolder | None = None, pollLogIntervalSeconds: float = 600, shouldNotifyRetriedFailures: bool = True) -> None:
44
44
  self.queue = queue
45
45
  self.messageProcessor = messageProcessor
46
46
  self.notificationClients = notificationClients
47
47
  self.requestIdHolder = requestIdHolder
48
+ self.pollLogIntervalSeconds = pollLogIntervalSeconds
49
+ self.shouldNotifyRetriedFailures = shouldNotifyRetriedFailures
50
+ self._lastPollLogTime: float | None = None
48
51
 
49
52
  async def _handle_failure(self, message: MessageType, exception: Exception, requestId: str) -> int:
50
53
  logging.error('Caught exception whilst processing message:')
51
54
  logging.exception(exception)
52
55
  kibaException = KibaException.from_exception(exception=exception)
53
- await self.queue.fail_message(message=message, errorMessage=kibaException.message)
54
- for client in self.notificationClients:
55
- try:
56
- await client.post(messageText=f'Error processing message: {message.command}\n```\n{requestId}\n{message.content}\n{kibaException.exceptionType}: {kibaException.message}```')
57
- except Exception as notificationException: # noqa: BLE001
58
- logging.error('Failed to send message failure notification:')
59
- logging.exception(notificationException)
56
+ isRetryScheduled = await self.queue.fail_message(message=message, errorMessage=kibaException.message)
57
+ if isRetryScheduled and not self.shouldNotifyRetriedFailures:
58
+ logging.info(msg=f'Skipping failure notification for message:{message.command} as another attempt is scheduled')
59
+ else:
60
+ for client in self.notificationClients:
61
+ try:
62
+ await client.post(messageText=f'Error processing message: {message.command}\n```\n{requestId}\n{message.content}\n{kibaException.exceptionType}: {kibaException.message}```')
63
+ except Exception as notificationException: # noqa: BLE001
64
+ logging.error('Failed to send message failure notification:')
65
+ logging.exception(notificationException)
60
66
  return exception.statusCode if isinstance(exception, KibaException) else 500
61
67
 
62
68
  async def _keep_message_alive(self, message: MessageType, expectedProcessingSeconds: int, stopEvent: asyncio.Event) -> bool:
@@ -117,7 +123,10 @@ class MessageQueueProcessor[MessageType: Message]:
117
123
  self.requestIdHolder.set_value(value=None)
118
124
 
119
125
  async def execute_batch(self, batchSize: int, expectedProcessingSeconds: int = 300, longPollSeconds: int = 20, shouldProcessInParallel: bool = False) -> int:
120
- logging.info('Retrieving messages...')
126
+ now = time.monotonic()
127
+ if self._lastPollLogTime is None or now - self._lastPollLogTime >= self.pollLogIntervalSeconds:
128
+ logging.info('Retrieving messages...')
129
+ self._lastPollLogTime = now
121
130
  messages = await self.queue.get_messages(expectedProcessingSeconds=expectedProcessingSeconds, longPollSeconds=longPollSeconds, limit=batchSize)
122
131
  if shouldProcessInParallel:
123
132
  await asyncio.gather(*[self._process_message(message=message, expectedProcessingSeconds=expectedProcessingSeconds) for message in messages])
@@ -212,14 +212,15 @@ class SqlMessageQueue(MessageQueue[SqlMessage]):
212
212
  async def retry_message(self, message: SqlMessage, delaySeconds: int = 0) -> None:
213
213
  await self._reschedule_message(message=message, delaySeconds=delaySeconds, extraValues={})
214
214
 
215
- async def fail_message(self, message: SqlMessage, errorMessage: str | None) -> None:
215
+ async def fail_message(self, message: SqlMessage, errorMessage: str | None) -> bool:
216
216
  postCount = message.postCount or 0
217
217
  if postCount >= self.maxAttempts:
218
218
  async with self.database.create_transaction() as connection:
219
219
  query = self._owned_update(message=message).values(status=MESSAGE_STATUS_FAILED, lockToken=None, completedDate=self.database.now(), lastError=errorMessage)
220
220
  await self.database.execute(query=query, connection=connection)
221
- return
221
+ return False
222
222
  await self._reschedule_message(message=message, delaySeconds=self.failureRetryDelaySeconds * postCount, extraValues={'lastError': errorMessage})
223
+ return True
223
224
 
224
225
  async def _reschedule_message(self, message: SqlMessage, delaySeconds: float, extraValues: dict[str, Any]) -> None: # type: ignore[explicit-any]
225
226
  async with self.database.create_transaction() as connection:
@@ -113,9 +113,9 @@ class SqsMessageQueue(MessageQueue[SqsMessage]):
113
113
  await self.send_message(message=message, delaySeconds=delaySeconds)
114
114
  await self.complete_message(message=message)
115
115
 
116
- async def fail_message(self, message: SqsMessage, errorMessage: str | None) -> None:
116
+ async def fail_message(self, message: SqsMessage, errorMessage: str | None) -> bool: # noqa: ARG002
117
117
  # NOTE(krishan711): the message reappears after its visibility timeout and the queue's redrive policy decides when to dead-letter it
118
- pass
118
+ return False
119
119
 
120
120
  async def extend_message_lease(self, message: SqsMessage, expectedProcessingSeconds: int) -> bool:
121
121
  if not self._sqsClient:
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: kiba-core
3
- Version: 0.5.3.dev72
3
+ Version: 0.5.3.dev74
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
@@ -117,6 +117,7 @@ tests/locks/test_sql_lock.py
117
117
  tests/queues/conftest.py
118
118
  tests/queues/test_aqs_queue.py
119
119
  tests/queues/test_cosmos_queue.py
120
+ tests/queues/test_message_queue_processor.py
120
121
  tests/queues/test_sql_queue.py
121
122
  tests/queues/test_sqs_queue.py
122
123
  tests/requester/__init__.py
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "kiba-core"
3
- version = "0.5.3.dev72"
3
+ version = "0.5.3.dev74"
4
4
  description = "Kiba Labs' python utilities"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.12, <4"
@@ -130,11 +130,46 @@ async def test_extend_after_losing_the_lease_reports_it_lost(workers: tuple[Cosm
130
130
  assert await workerA.extend_message_lease(message=staleMessage, expectedProcessingSeconds=60) is False
131
131
 
132
132
 
133
- async def test_failed_message_reappears_once_its_lease_expires(workers: tuple[CosmosMessageQueue, CosmosMessageQueue]):
133
+ async def test_failed_message_backs_off_then_is_removed_after_max_attempts(workers: tuple[CosmosMessageQueue, CosmosMessageQueue]):
134
134
  workerA, _ = workers
135
135
  await workerA.send_message(message=_message())
136
- message = await _claim(queue=workerA)
137
- await workerA.fail_message(message=message, errorMessage='boom')
136
+ for attempt in range(1, 4):
137
+ message = await _claim(queue=workerA)
138
+ assert message.postCount == attempt
139
+ isRetryScheduled = await workerA.fail_message(message=message, errorMessage=f'boom {attempt}')
140
+ assert isRetryScheduled == (attempt < 3) # noqa: PLR2004
141
+ if attempt < 3: # noqa: PLR2004
142
+ item = await workerA.container.read_item(item=message.id, partition_key=workerA.queueName)
143
+ assert item['lastError'] == f'boom {attempt}'
144
+ assert await workerA.get_message() is None
145
+ await _make_visible(queue=workerA, messageId=message.id)
146
+ assert await workerA.get_message_count() == 0
147
+ assert await workerA.get_inflight_message_count() == 0
148
+ await workerA.send_message(message=_message())
149
+ assert await workerA.get_message_count() == 1
150
+
151
+
152
+ async def test_abandoned_message_counts_as_an_attempt_and_is_removed_after_max_attempts(workers: tuple[CosmosMessageQueue, CosmosMessageQueue]):
153
+ workerA, _ = workers
154
+ await workerA.send_message(message=_message())
155
+ for attempt in range(1, 4):
156
+ message = await _claim(queue=workerA)
157
+ assert message.postCount == attempt
158
+ await _make_visible(queue=workerA, messageId=message.id)
138
159
  assert await workerA.get_message() is None
139
- await _make_visible(queue=workerA, messageId=message.id)
140
- assert (await _claim(queue=workerA)).id == message.id
160
+ assert await workerA.get_message_count() == 0
161
+ await workerA.send_message(message=_message())
162
+ assert await workerA.get_message_count() == 1
163
+
164
+
165
+ async def test_fail_after_losing_the_lease_leaves_the_new_holder_untouched(workers: tuple[CosmosMessageQueue, CosmosMessageQueue]):
166
+ workerA, workerB = workers
167
+ await workerA.send_message(message=_message())
168
+ staleMessage = await _claim(queue=workerA)
169
+ await _make_visible(queue=workerA, messageId=staleMessage.id)
170
+ newMessage = await _claim(queue=workerB)
171
+ staleMessage.postCount = 3
172
+ assert await workerA.fail_message(message=staleMessage, errorMessage='boom') is False
173
+ assert await workerA.get_inflight_message_count() == 1
174
+ await workerB.complete_message(message=newMessage)
175
+ assert await workerA.get_inflight_message_count() == 0
@@ -0,0 +1,46 @@
1
+ import asyncio
2
+
3
+ import pytest
4
+
5
+ from core.queues.message_queue_processor import MessageProcessor
6
+ from core.queues.message_queue_processor import MessageQueueProcessor
7
+ from core.queues.model import Message
8
+
9
+
10
+ class EmptyQueue:
11
+ async def get_messages(self, expectedProcessingSeconds: int, longPollSeconds: int, limit: int) -> list[Message]: # noqa: ARG002
12
+ return []
13
+
14
+
15
+ class NoopProcessor(MessageProcessor):
16
+ async def process_message(self, message: Message) -> None:
17
+ pass
18
+
19
+
20
+ def _processor(pollLogIntervalSeconds: float) -> MessageQueueProcessor[Message]:
21
+ return MessageQueueProcessor(queue=EmptyQueue(), messageProcessor=NoopProcessor(), notificationClients=[], pollLogIntervalSeconds=pollLogIntervalSeconds) # type: ignore[arg-type]
22
+
23
+
24
+ def _pollLogCount(caplog: pytest.LogCaptureFixture) -> int:
25
+ return sum(1 for record in caplog.records if record.getMessage() == 'Retrieving messages...')
26
+
27
+
28
+ @pytest.mark.asyncio
29
+ async def test_poll_is_logged_once_per_interval(caplog: pytest.LogCaptureFixture) -> None:
30
+ caplog.set_level('INFO')
31
+ processor = _processor(pollLogIntervalSeconds=0.2)
32
+ await processor.execute_batch(batchSize=1)
33
+ await processor.execute_batch(batchSize=1)
34
+ assert _pollLogCount(caplog) == 1
35
+ await asyncio.sleep(0.25)
36
+ await processor.execute_batch(batchSize=1)
37
+ assert _pollLogCount(caplog) == 2 # noqa: PLR2004
38
+
39
+
40
+ @pytest.mark.asyncio
41
+ async def test_zero_interval_logs_every_poll(caplog: pytest.LogCaptureFixture) -> None:
42
+ caplog.set_level('INFO')
43
+ processor = _processor(pollLogIntervalSeconds=0)
44
+ for _ in range(3):
45
+ await processor.execute_batch(batchSize=1)
46
+ assert _pollLogCount(caplog) == 3 # noqa: PLR2004
@@ -132,6 +132,29 @@ async def test_processor_fails_message_even_when_notifications_fail(workers: tup
132
132
  assert (await _rows(queue=workerA))[0]['status'] == 'failed'
133
133
 
134
134
 
135
+ @pytest.mark.parametrize(('shouldNotifyRetriedFailures', 'expectedNotificationCounts'), [(True, [1, 2, 3]), (False, [0, 0, 1])])
136
+ async def test_processor_only_notifies_retried_failures_when_enabled(workers: tuple[SqlMessageQueue, SqlMessageQueue], shouldNotifyRetriedFailures: bool, expectedNotificationCounts: list[int]):
137
+ workerA, _ = workers
138
+ notifications: list[str] = []
139
+
140
+ class BrokenProcessor(MessageProcessor):
141
+ async def process_message(self, message: Message) -> None:
142
+ raise ValueError('boom')
143
+
144
+ class RecordingNotificationClient(NotificationClient):
145
+ async def post(self, messageText: str) -> KibaResponse:
146
+ notifications.append(messageText)
147
+ return typing.cast(KibaResponse, None)
148
+
149
+ processor = MessageQueueProcessor(queue=workerA, messageProcessor=BrokenProcessor(), notificationClients=[RecordingNotificationClient()], shouldNotifyRetriedFailures=shouldNotifyRetriedFailures)
150
+ await workerA.send_message(message=_message())
151
+ for expectedNotificationCount in expectedNotificationCounts:
152
+ assert await processor.execute(longPollSeconds=0)
153
+ assert len(notifications) == expectedNotificationCount
154
+ await _make_visible(queue=workerA, messageId=(await _rows(queue=workerA))[0]['id'])
155
+ assert (await _rows(queue=workerA))[0]['status'] == 'failed'
156
+
157
+
135
158
  async def test_concurrent_claims_never_hand_out_the_same_message(workers: tuple[SqlMessageQueue, SqlMessageQueue]):
136
159
  await workers[0].send_messages(messages=[_message(deduplicationId=None, userId=str(index)) for index in range(20)])
137
160
  results = await asyncio.gather(*[worker.get_messages(limit=3) for _ in range(5) for worker in workers])
@@ -257,7 +280,8 @@ async def test_failed_message_backs_off_then_fails_after_max_attempts(workers: t
257
280
  for attempt in range(1, 4):
258
281
  message = await _claim(queue=workerA)
259
282
  assert message.postCount == attempt
260
- await workerA.fail_message(message=message, errorMessage=f'boom {attempt}')
283
+ isRetryScheduled = await workerA.fail_message(message=message, errorMessage=f'boom {attempt}')
284
+ assert isRetryScheduled == (attempt < 3) # noqa: PLR2004
261
285
  row = await _row(queue=workerA, messageId=message.id)
262
286
  if attempt < 3: # noqa: PLR2004
263
287
  assert row['status'] == 'pending'
File without changes