kiba-core 0.5.3.dev73__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.dev73 → kiba_core-0.5.3.dev74}/CHANGELOG.md +2 -0
  2. {kiba_core-0.5.3.dev73 → kiba_core-0.5.3.dev74}/PKG-INFO +1 -1
  3. {kiba_core-0.5.3.dev73 → kiba_core-0.5.3.dev74}/core/queues/aqs.py +2 -2
  4. {kiba_core-0.5.3.dev73 → kiba_core-0.5.3.dev74}/core/queues/cosmos.py +39 -11
  5. {kiba_core-0.5.3.dev73 → kiba_core-0.5.3.dev74}/core/queues/message_queue.py +2 -1
  6. {kiba_core-0.5.3.dev73 → kiba_core-0.5.3.dev74}/core/queues/message_queue_processor.py +12 -8
  7. {kiba_core-0.5.3.dev73 → kiba_core-0.5.3.dev74}/core/queues/sql.py +3 -2
  8. {kiba_core-0.5.3.dev73 → kiba_core-0.5.3.dev74}/core/queues/sqs.py +2 -2
  9. {kiba_core-0.5.3.dev73 → kiba_core-0.5.3.dev74}/kiba_core.egg-info/PKG-INFO +1 -1
  10. {kiba_core-0.5.3.dev73 → kiba_core-0.5.3.dev74}/pyproject.toml +1 -1
  11. {kiba_core-0.5.3.dev73 → kiba_core-0.5.3.dev74}/tests/queues/test_cosmos_queue.py +40 -5
  12. {kiba_core-0.5.3.dev73 → kiba_core-0.5.3.dev74}/tests/queues/test_sql_queue.py +25 -1
  13. {kiba_core-0.5.3.dev73 → kiba_core-0.5.3.dev74}/.github/pull_request_template.md +0 -0
  14. {kiba_core-0.5.3.dev73 → kiba_core-0.5.3.dev74}/.github/workflows/deploy.yml +0 -0
  15. {kiba_core-0.5.3.dev73 → kiba_core-0.5.3.dev74}/.github/workflows/pull-request.yml +0 -0
  16. {kiba_core-0.5.3.dev73 → kiba_core-0.5.3.dev74}/.github/workflows/release.yml +0 -0
  17. {kiba_core-0.5.3.dev73 → kiba_core-0.5.3.dev74}/.gitignore +0 -0
  18. {kiba_core-0.5.3.dev73 → kiba_core-0.5.3.dev74}/Dockerfile +0 -0
  19. {kiba_core-0.5.3.dev73 → kiba_core-0.5.3.dev74}/README.md +0 -0
  20. {kiba_core-0.5.3.dev73 → kiba_core-0.5.3.dev74}/core/__init__.py +0 -0
  21. {kiba_core-0.5.3.dev73 → kiba_core-0.5.3.dev74}/core/api/__init__.py +0 -0
  22. {kiba_core-0.5.3.dev73 → kiba_core-0.5.3.dev74}/core/api/api_request.py +0 -0
  23. {kiba_core-0.5.3.dev73 → kiba_core-0.5.3.dev74}/core/api/api_response.py +0 -0
  24. {kiba_core-0.5.3.dev73 → kiba_core-0.5.3.dev74}/core/api/authorizer.py +0 -0
  25. {kiba_core-0.5.3.dev73 → kiba_core-0.5.3.dev74}/core/api/default_routes.py +0 -0
  26. {kiba_core-0.5.3.dev73 → kiba_core-0.5.3.dev74}/core/api/health.py +0 -0
  27. {kiba_core-0.5.3.dev73 → kiba_core-0.5.3.dev74}/core/api/json_route.py +0 -0
  28. {kiba_core-0.5.3.dev73 → kiba_core-0.5.3.dev74}/core/api/middleware/__init__.py +0 -0
  29. {kiba_core-0.5.3.dev73 → kiba_core-0.5.3.dev74}/core/api/middleware/database_connection_middleware.py +0 -0
  30. {kiba_core-0.5.3.dev73 → kiba_core-0.5.3.dev74}/core/api/middleware/exception_handling_middleware.py +0 -0
  31. {kiba_core-0.5.3.dev73 → kiba_core-0.5.3.dev74}/core/api/middleware/logging_middleware.py +0 -0
  32. {kiba_core-0.5.3.dev73 → kiba_core-0.5.3.dev74}/core/api/middleware/origin_ip_middleware.py +0 -0
  33. {kiba_core-0.5.3.dev73 → kiba_core-0.5.3.dev74}/core/api/middleware/server_headers_middleware.py +0 -0
  34. {kiba_core-0.5.3.dev73 → kiba_core-0.5.3.dev74}/core/api/openapi.py +0 -0
  35. {kiba_core-0.5.3.dev73 → kiba_core-0.5.3.dev74}/core/api/rate_limit.py +0 -0
  36. {kiba_core-0.5.3.dev73 → kiba_core-0.5.3.dev74}/core/api/request_context.py +0 -0
  37. {kiba_core-0.5.3.dev73 → kiba_core-0.5.3.dev74}/core/api/route.py +0 -0
  38. {kiba_core-0.5.3.dev73 → kiba_core-0.5.3.dev74}/core/api/route_auth.py +0 -0
  39. {kiba_core-0.5.3.dev73 → kiba_core-0.5.3.dev74}/core/api/route_metadata.py +0 -0
  40. {kiba_core-0.5.3.dev73 → kiba_core-0.5.3.dev74}/core/api/streaming_json_route.py +0 -0
  41. {kiba_core-0.5.3.dev73 → kiba_core-0.5.3.dev74}/core/aws_requester.py +0 -0
  42. {kiba_core-0.5.3.dev73 → kiba_core-0.5.3.dev74}/core/caching/__init__.py +0 -0
  43. {kiba_core-0.5.3.dev73 → kiba_core-0.5.3.dev74}/core/caching/cache.py +0 -0
  44. {kiba_core-0.5.3.dev73 → kiba_core-0.5.3.dev74}/core/caching/dict_cache.py +0 -0
  45. {kiba_core-0.5.3.dev73 → kiba_core-0.5.3.dev74}/core/caching/file_cache.py +0 -0
  46. {kiba_core-0.5.3.dev73 → kiba_core-0.5.3.dev74}/core/cron/__init__.py +0 -0
  47. {kiba_core-0.5.3.dev73 → kiba_core-0.5.3.dev74}/core/cron/api_request_cron_job_processor.py +0 -0
  48. {kiba_core-0.5.3.dev73 → kiba_core-0.5.3.dev74}/core/cron/cron_executor.py +0 -0
  49. {kiba_core-0.5.3.dev73 → kiba_core-0.5.3.dev74}/core/cron/cron_job_processor.py +0 -0
  50. {kiba_core-0.5.3.dev73 → kiba_core-0.5.3.dev74}/core/cron/model.py +0 -0
  51. {kiba_core-0.5.3.dev73 → kiba_core-0.5.3.dev74}/core/exceptions.py +0 -0
  52. {kiba_core-0.5.3.dev73 → kiba_core-0.5.3.dev74}/core/http/__init__.py +0 -0
  53. {kiba_core-0.5.3.dev73 → kiba_core-0.5.3.dev74}/core/http/basic_authentication.py +0 -0
  54. {kiba_core-0.5.3.dev73 → kiba_core-0.5.3.dev74}/core/http/jwt.py +0 -0
  55. {kiba_core-0.5.3.dev73 → kiba_core-0.5.3.dev74}/core/http/rest_method.py +0 -0
  56. {kiba_core-0.5.3.dev73 → kiba_core-0.5.3.dev74}/core/locks/__init__.py +0 -0
  57. {kiba_core-0.5.3.dev73 → kiba_core-0.5.3.dev74}/core/locks/lock.py +0 -0
  58. {kiba_core-0.5.3.dev73 → kiba_core-0.5.3.dev74}/core/locks/model.py +0 -0
  59. {kiba_core-0.5.3.dev73 → kiba_core-0.5.3.dev74}/core/locks/sql.py +0 -0
  60. {kiba_core-0.5.3.dev73 → kiba_core-0.5.3.dev74}/core/logging.py +0 -0
  61. {kiba_core-0.5.3.dev73 → kiba_core-0.5.3.dev74}/core/notifications/__init__.py +0 -0
  62. {kiba_core-0.5.3.dev73 → kiba_core-0.5.3.dev74}/core/notifications/discord_client.py +0 -0
  63. {kiba_core-0.5.3.dev73 → kiba_core-0.5.3.dev74}/core/notifications/notification_client.py +0 -0
  64. {kiba_core-0.5.3.dev73 → kiba_core-0.5.3.dev74}/core/notifications/slack_client.py +0 -0
  65. {kiba_core-0.5.3.dev73 → kiba_core-0.5.3.dev74}/core/py.typed +0 -0
  66. {kiba_core-0.5.3.dev73 → kiba_core-0.5.3.dev74}/core/queues/__init__.py +0 -0
  67. {kiba_core-0.5.3.dev73 → kiba_core-0.5.3.dev74}/core/queues/model.py +0 -0
  68. {kiba_core-0.5.3.dev73 → kiba_core-0.5.3.dev74}/core/requester/__init__.py +0 -0
  69. {kiba_core-0.5.3.dev73 → kiba_core-0.5.3.dev74}/core/requester/requester.py +0 -0
  70. {kiba_core-0.5.3.dev73 → kiba_core-0.5.3.dev74}/core/s3_manager.py +0 -0
  71. {kiba_core-0.5.3.dev73 → kiba_core-0.5.3.dev74}/core/service_client.py +0 -0
  72. {kiba_core-0.5.3.dev73 → kiba_core-0.5.3.dev74}/core/store/__init__.py +0 -0
  73. {kiba_core-0.5.3.dev73 → kiba_core-0.5.3.dev74}/core/store/database.py +0 -0
  74. {kiba_core-0.5.3.dev73 → kiba_core-0.5.3.dev74}/core/store/retriever.py +0 -0
  75. {kiba_core-0.5.3.dev73 → kiba_core-0.5.3.dev74}/core/store/saver.py +0 -0
  76. {kiba_core-0.5.3.dev73 → kiba_core-0.5.3.dev74}/core/util/__init__.py +0 -0
  77. {kiba_core-0.5.3.dev73 → kiba_core-0.5.3.dev74}/core/util/async_util.py +0 -0
  78. {kiba_core-0.5.3.dev73 → kiba_core-0.5.3.dev74}/core/util/chain_util.py +0 -0
  79. {kiba_core-0.5.3.dev73 → kiba_core-0.5.3.dev74}/core/util/date_util.py +0 -0
  80. {kiba_core-0.5.3.dev73 → kiba_core-0.5.3.dev74}/core/util/dict_util.py +0 -0
  81. {kiba_core-0.5.3.dev73 → kiba_core-0.5.3.dev74}/core/util/file_util.py +0 -0
  82. {kiba_core-0.5.3.dev73 → kiba_core-0.5.3.dev74}/core/util/hashing_util.py +0 -0
  83. {kiba_core-0.5.3.dev73 → kiba_core-0.5.3.dev74}/core/util/http_util.py +0 -0
  84. {kiba_core-0.5.3.dev73 → kiba_core-0.5.3.dev74}/core/util/json_util.py +0 -0
  85. {kiba_core-0.5.3.dev73 → kiba_core-0.5.3.dev74}/core/util/list_util.py +0 -0
  86. {kiba_core-0.5.3.dev73 → kiba_core-0.5.3.dev74}/core/util/process_util.py +0 -0
  87. {kiba_core-0.5.3.dev73 → kiba_core-0.5.3.dev74}/core/util/string_util.py +0 -0
  88. {kiba_core-0.5.3.dev73 → kiba_core-0.5.3.dev74}/core/util/typing_util.py +0 -0
  89. {kiba_core-0.5.3.dev73 → kiba_core-0.5.3.dev74}/core/util/url_util.py +0 -0
  90. {kiba_core-0.5.3.dev73 → kiba_core-0.5.3.dev74}/core/util/value_holder.py +0 -0
  91. {kiba_core-0.5.3.dev73 → kiba_core-0.5.3.dev74}/core/web3/__init__.py +0 -0
  92. {kiba_core-0.5.3.dev73 → kiba_core-0.5.3.dev74}/core/web3/eth_client.py +0 -0
  93. {kiba_core-0.5.3.dev73 → kiba_core-0.5.3.dev74}/core/web3/eth_client_registry.py +0 -0
  94. {kiba_core-0.5.3.dev73 → kiba_core-0.5.3.dev74}/core/web3/multicall3.py +0 -0
  95. {kiba_core-0.5.3.dev73 → kiba_core-0.5.3.dev74}/kiba_core.egg-info/SOURCES.txt +0 -0
  96. {kiba_core-0.5.3.dev73 → kiba_core-0.5.3.dev74}/kiba_core.egg-info/dependency_links.txt +0 -0
  97. {kiba_core-0.5.3.dev73 → kiba_core-0.5.3.dev74}/kiba_core.egg-info/requires.txt +0 -0
  98. {kiba_core-0.5.3.dev73 → kiba_core-0.5.3.dev74}/kiba_core.egg-info/top_level.txt +0 -0
  99. {kiba_core-0.5.3.dev73 → kiba_core-0.5.3.dev74}/makefile +0 -0
  100. {kiba_core-0.5.3.dev73 → kiba_core-0.5.3.dev74}/setup.cfg +0 -0
  101. {kiba_core-0.5.3.dev73 → kiba_core-0.5.3.dev74}/tests/__init__.py +0 -0
  102. {kiba_core-0.5.3.dev73 → kiba_core-0.5.3.dev74}/tests/api/test_api_response.py +0 -0
  103. {kiba_core-0.5.3.dev73 → kiba_core-0.5.3.dev74}/tests/api/test_authorizer.py +0 -0
  104. {kiba_core-0.5.3.dev73 → kiba_core-0.5.3.dev74}/tests/api/test_exception_handling_middleware.py +0 -0
  105. {kiba_core-0.5.3.dev73 → kiba_core-0.5.3.dev74}/tests/api/test_json_route.py +0 -0
  106. {kiba_core-0.5.3.dev73 → kiba_core-0.5.3.dev74}/tests/api/test_openapi.py +0 -0
  107. {kiba_core-0.5.3.dev73 → kiba_core-0.5.3.dev74}/tests/api/test_origin_ip_middleware.py +0 -0
  108. {kiba_core-0.5.3.dev73 → kiba_core-0.5.3.dev74}/tests/api/test_rate_limit.py +0 -0
  109. {kiba_core-0.5.3.dev73 → kiba_core-0.5.3.dev74}/tests/api/test_request_context.py +0 -0
  110. {kiba_core-0.5.3.dev73 → kiba_core-0.5.3.dev74}/tests/api/test_route.py +0 -0
  111. {kiba_core-0.5.3.dev73 → kiba_core-0.5.3.dev74}/tests/api/test_streaming_json_route.py +0 -0
  112. {kiba_core-0.5.3.dev73 → kiba_core-0.5.3.dev74}/tests/caching/test_dict_cache.py +0 -0
  113. {kiba_core-0.5.3.dev73 → kiba_core-0.5.3.dev74}/tests/caching/test_file_cache.py +0 -0
  114. {kiba_core-0.5.3.dev73 → kiba_core-0.5.3.dev74}/tests/cron/__init__.py +0 -0
  115. {kiba_core-0.5.3.dev73 → kiba_core-0.5.3.dev74}/tests/cron/test_cron_executor.py +0 -0
  116. {kiba_core-0.5.3.dev73 → kiba_core-0.5.3.dev74}/tests/locks/test_sql_lock.py +0 -0
  117. {kiba_core-0.5.3.dev73 → kiba_core-0.5.3.dev74}/tests/queues/conftest.py +0 -0
  118. {kiba_core-0.5.3.dev73 → kiba_core-0.5.3.dev74}/tests/queues/test_aqs_queue.py +0 -0
  119. {kiba_core-0.5.3.dev73 → kiba_core-0.5.3.dev74}/tests/queues/test_message_queue_processor.py +0 -0
  120. {kiba_core-0.5.3.dev73 → kiba_core-0.5.3.dev74}/tests/queues/test_sqs_queue.py +0 -0
  121. {kiba_core-0.5.3.dev73 → kiba_core-0.5.3.dev74}/tests/requester/__init__.py +0 -0
  122. {kiba_core-0.5.3.dev73 → kiba_core-0.5.3.dev74}/tests/requester/test_requester.py +0 -0
  123. {kiba_core-0.5.3.dev73 → kiba_core-0.5.3.dev74}/tests/test_database.py +0 -0
  124. {kiba_core-0.5.3.dev73 → kiba_core-0.5.3.dev74}/tests/test_exceptions.py +0 -0
  125. {kiba_core-0.5.3.dev73 → kiba_core-0.5.3.dev74}/tests/test_logging.py +0 -0
  126. {kiba_core-0.5.3.dev73 → kiba_core-0.5.3.dev74}/tests/util/__init__.py +0 -0
  127. {kiba_core-0.5.3.dev73 → kiba_core-0.5.3.dev74}/tests/util/test_async_util.py +0 -0
  128. {kiba_core-0.5.3.dev73 → kiba_core-0.5.3.dev74}/tests/util/test_chain_util.py +0 -0
  129. {kiba_core-0.5.3.dev73 → kiba_core-0.5.3.dev74}/tests/util/test_date_util.py +0 -0
  130. {kiba_core-0.5.3.dev73 → kiba_core-0.5.3.dev74}/tests/util/test_dict_util.py +0 -0
  131. {kiba_core-0.5.3.dev73 → kiba_core-0.5.3.dev74}/tests/util/test_file_util.py +0 -0
  132. {kiba_core-0.5.3.dev73 → kiba_core-0.5.3.dev74}/tests/util/test_json_util.py +0 -0
  133. {kiba_core-0.5.3.dev73 → kiba_core-0.5.3.dev74}/tests/util/test_list_util.py +0 -0
  134. {kiba_core-0.5.3.dev73 → kiba_core-0.5.3.dev74}/tests/util/test_process_util.py +0 -0
  135. {kiba_core-0.5.3.dev73 → kiba_core-0.5.3.dev74}/tests/util/test_string_util.py +0 -0
  136. {kiba_core-0.5.3.dev73 → kiba_core-0.5.3.dev74}/tests/util/test_url_util.py +0 -0
  137. {kiba_core-0.5.3.dev73 → kiba_core-0.5.3.dev74}/tests/web3/__init__.py +0 -0
  138. {kiba_core-0.5.3.dev73 → kiba_core-0.5.3.dev74}/tests/web3/test_eth_client_registry.py +0 -0
  139. {kiba_core-0.5.3.dev73 → kiba_core-0.5.3.dev74}/tests/web3/test_rest_eth_client.py +0 -0
  140. {kiba_core-0.5.3.dev73 → kiba_core-0.5.3.dev74}/uv.lock +0 -0
@@ -8,6 +8,8 @@ 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
11
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
12
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
13
15
  - [MINOR] Added `RestEthClient.get_code`
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: kiba-core
3
- Version: 0.5.3.dev73
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,25 +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, pollLogIntervalSeconds: float = 600) -> 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
48
  self.pollLogIntervalSeconds = pollLogIntervalSeconds
49
+ self.shouldNotifyRetriedFailures = shouldNotifyRetriedFailures
49
50
  self._lastPollLogTime: float | None = None
50
51
 
51
52
  async def _handle_failure(self, message: MessageType, exception: Exception, requestId: str) -> int:
52
53
  logging.error('Caught exception whilst processing message:')
53
54
  logging.exception(exception)
54
55
  kibaException = KibaException.from_exception(exception=exception)
55
- await self.queue.fail_message(message=message, errorMessage=kibaException.message)
56
- for client in self.notificationClients:
57
- try:
58
- await client.post(messageText=f'Error processing message: {message.command}\n```\n{requestId}\n{message.content}\n{kibaException.exceptionType}: {kibaException.message}```')
59
- except Exception as notificationException: # noqa: BLE001
60
- logging.error('Failed to send message failure notification:')
61
- 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)
62
66
  return exception.statusCode if isinstance(exception, KibaException) else 500
63
67
 
64
68
  async def _keep_message_alive(self, message: MessageType, expectedProcessingSeconds: int, stopEvent: asyncio.Event) -> bool:
@@ -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.dev73
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
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "kiba-core"
3
- version = "0.5.3.dev73"
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
@@ -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