kiba-core 0.5.3.dev65__tar.gz → 0.5.3.dev67__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 (130) hide show
  1. {kiba_core-0.5.3.dev65 → kiba_core-0.5.3.dev67}/.github/workflows/pull-request.yml +13 -2
  2. {kiba_core-0.5.3.dev65 → kiba_core-0.5.3.dev67}/CHANGELOG.md +13 -0
  3. {kiba_core-0.5.3.dev65 → kiba_core-0.5.3.dev67}/PKG-INFO +1 -1
  4. {kiba_core-0.5.3.dev65 → kiba_core-0.5.3.dev67}/core/api/authorizer.py +9 -7
  5. {kiba_core-0.5.3.dev65 → kiba_core-0.5.3.dev67}/core/api/route.py +5 -3
  6. {kiba_core-0.5.3.dev65 → kiba_core-0.5.3.dev67}/core/locks/lock.py +15 -9
  7. {kiba_core-0.5.3.dev65 → kiba_core-0.5.3.dev67}/core/locks/model.py +2 -1
  8. {kiba_core-0.5.3.dev65 → kiba_core-0.5.3.dev67}/core/locks/sql.py +20 -12
  9. {kiba_core-0.5.3.dev65 → kiba_core-0.5.3.dev67}/core/queues/aqs.py +36 -4
  10. {kiba_core-0.5.3.dev65 → kiba_core-0.5.3.dev67}/core/queues/cosmos.py +42 -1
  11. {kiba_core-0.5.3.dev65 → kiba_core-0.5.3.dev67}/core/queues/message_queue.py +13 -1
  12. {kiba_core-0.5.3.dev65 → kiba_core-0.5.3.dev67}/core/queues/message_queue_processor.py +67 -13
  13. kiba_core-0.5.3.dev67/core/queues/sql.py +259 -0
  14. {kiba_core-0.5.3.dev65 → kiba_core-0.5.3.dev67}/core/queues/sqs.py +40 -6
  15. {kiba_core-0.5.3.dev65 → kiba_core-0.5.3.dev67}/core/store/database.py +17 -2
  16. {kiba_core-0.5.3.dev65 → kiba_core-0.5.3.dev67}/kiba_core.egg-info/PKG-INFO +1 -1
  17. {kiba_core-0.5.3.dev65 → kiba_core-0.5.3.dev67}/kiba_core.egg-info/SOURCES.txt +5 -0
  18. {kiba_core-0.5.3.dev65 → kiba_core-0.5.3.dev67}/pyproject.toml +1 -1
  19. {kiba_core-0.5.3.dev65 → kiba_core-0.5.3.dev67}/tests/api/test_authorizer.py +32 -1
  20. {kiba_core-0.5.3.dev65 → kiba_core-0.5.3.dev67}/tests/api/test_route.py +24 -6
  21. {kiba_core-0.5.3.dev65 → kiba_core-0.5.3.dev67}/tests/locks/test_sql_lock.py +20 -5
  22. kiba_core-0.5.3.dev67/tests/queues/conftest.py +12 -0
  23. kiba_core-0.5.3.dev67/tests/queues/test_aqs_queue.py +85 -0
  24. kiba_core-0.5.3.dev67/tests/queues/test_cosmos_queue.py +140 -0
  25. kiba_core-0.5.3.dev67/tests/queues/test_sql_queue.py +353 -0
  26. kiba_core-0.5.3.dev67/tests/queues/test_sqs_queue.py +86 -0
  27. {kiba_core-0.5.3.dev65 → kiba_core-0.5.3.dev67}/tests/test_database.py +17 -0
  28. kiba_core-0.5.3.dev65/core/queues/sql.py +0 -175
  29. {kiba_core-0.5.3.dev65 → kiba_core-0.5.3.dev67}/.github/pull_request_template.md +0 -0
  30. {kiba_core-0.5.3.dev65 → kiba_core-0.5.3.dev67}/.github/workflows/deploy.yml +0 -0
  31. {kiba_core-0.5.3.dev65 → kiba_core-0.5.3.dev67}/.github/workflows/release.yml +0 -0
  32. {kiba_core-0.5.3.dev65 → kiba_core-0.5.3.dev67}/.gitignore +0 -0
  33. {kiba_core-0.5.3.dev65 → kiba_core-0.5.3.dev67}/Dockerfile +0 -0
  34. {kiba_core-0.5.3.dev65 → kiba_core-0.5.3.dev67}/README.md +0 -0
  35. {kiba_core-0.5.3.dev65 → kiba_core-0.5.3.dev67}/core/__init__.py +0 -0
  36. {kiba_core-0.5.3.dev65 → kiba_core-0.5.3.dev67}/core/api/__init__.py +0 -0
  37. {kiba_core-0.5.3.dev65 → kiba_core-0.5.3.dev67}/core/api/api_request.py +0 -0
  38. {kiba_core-0.5.3.dev65 → kiba_core-0.5.3.dev67}/core/api/api_response.py +0 -0
  39. {kiba_core-0.5.3.dev65 → kiba_core-0.5.3.dev67}/core/api/default_routes.py +0 -0
  40. {kiba_core-0.5.3.dev65 → kiba_core-0.5.3.dev67}/core/api/health.py +0 -0
  41. {kiba_core-0.5.3.dev65 → kiba_core-0.5.3.dev67}/core/api/json_route.py +0 -0
  42. {kiba_core-0.5.3.dev65 → kiba_core-0.5.3.dev67}/core/api/middleware/__init__.py +0 -0
  43. {kiba_core-0.5.3.dev65 → kiba_core-0.5.3.dev67}/core/api/middleware/database_connection_middleware.py +0 -0
  44. {kiba_core-0.5.3.dev65 → kiba_core-0.5.3.dev67}/core/api/middleware/exception_handling_middleware.py +0 -0
  45. {kiba_core-0.5.3.dev65 → kiba_core-0.5.3.dev67}/core/api/middleware/logging_middleware.py +0 -0
  46. {kiba_core-0.5.3.dev65 → kiba_core-0.5.3.dev67}/core/api/middleware/origin_ip_middleware.py +0 -0
  47. {kiba_core-0.5.3.dev65 → kiba_core-0.5.3.dev67}/core/api/middleware/server_headers_middleware.py +0 -0
  48. {kiba_core-0.5.3.dev65 → kiba_core-0.5.3.dev67}/core/api/openapi.py +0 -0
  49. {kiba_core-0.5.3.dev65 → kiba_core-0.5.3.dev67}/core/api/rate_limit.py +0 -0
  50. {kiba_core-0.5.3.dev65 → kiba_core-0.5.3.dev67}/core/api/request_context.py +0 -0
  51. {kiba_core-0.5.3.dev65 → kiba_core-0.5.3.dev67}/core/api/route_auth.py +0 -0
  52. {kiba_core-0.5.3.dev65 → kiba_core-0.5.3.dev67}/core/api/route_metadata.py +0 -0
  53. {kiba_core-0.5.3.dev65 → kiba_core-0.5.3.dev67}/core/api/streaming_json_route.py +0 -0
  54. {kiba_core-0.5.3.dev65 → kiba_core-0.5.3.dev67}/core/aws_requester.py +0 -0
  55. {kiba_core-0.5.3.dev65 → kiba_core-0.5.3.dev67}/core/caching/__init__.py +0 -0
  56. {kiba_core-0.5.3.dev65 → kiba_core-0.5.3.dev67}/core/caching/cache.py +0 -0
  57. {kiba_core-0.5.3.dev65 → kiba_core-0.5.3.dev67}/core/caching/dict_cache.py +0 -0
  58. {kiba_core-0.5.3.dev65 → kiba_core-0.5.3.dev67}/core/caching/file_cache.py +0 -0
  59. {kiba_core-0.5.3.dev65 → kiba_core-0.5.3.dev67}/core/exceptions.py +0 -0
  60. {kiba_core-0.5.3.dev65 → kiba_core-0.5.3.dev67}/core/http/__init__.py +0 -0
  61. {kiba_core-0.5.3.dev65 → kiba_core-0.5.3.dev67}/core/http/basic_authentication.py +0 -0
  62. {kiba_core-0.5.3.dev65 → kiba_core-0.5.3.dev67}/core/http/jwt.py +0 -0
  63. {kiba_core-0.5.3.dev65 → kiba_core-0.5.3.dev67}/core/http/rest_method.py +0 -0
  64. {kiba_core-0.5.3.dev65 → kiba_core-0.5.3.dev67}/core/locks/__init__.py +0 -0
  65. {kiba_core-0.5.3.dev65 → kiba_core-0.5.3.dev67}/core/logging.py +0 -0
  66. {kiba_core-0.5.3.dev65 → kiba_core-0.5.3.dev67}/core/notifications/__init__.py +0 -0
  67. {kiba_core-0.5.3.dev65 → kiba_core-0.5.3.dev67}/core/notifications/discord_client.py +0 -0
  68. {kiba_core-0.5.3.dev65 → kiba_core-0.5.3.dev67}/core/notifications/notification_client.py +0 -0
  69. {kiba_core-0.5.3.dev65 → kiba_core-0.5.3.dev67}/core/notifications/slack_client.py +0 -0
  70. {kiba_core-0.5.3.dev65 → kiba_core-0.5.3.dev67}/core/py.typed +0 -0
  71. {kiba_core-0.5.3.dev65 → kiba_core-0.5.3.dev67}/core/queues/__init__.py +0 -0
  72. {kiba_core-0.5.3.dev65 → kiba_core-0.5.3.dev67}/core/queues/model.py +0 -0
  73. {kiba_core-0.5.3.dev65 → kiba_core-0.5.3.dev67}/core/requester/__init__.py +0 -0
  74. {kiba_core-0.5.3.dev65 → kiba_core-0.5.3.dev67}/core/requester/requester.py +0 -0
  75. {kiba_core-0.5.3.dev65 → kiba_core-0.5.3.dev67}/core/s3_manager.py +0 -0
  76. {kiba_core-0.5.3.dev65 → kiba_core-0.5.3.dev67}/core/service_client.py +0 -0
  77. {kiba_core-0.5.3.dev65 → kiba_core-0.5.3.dev67}/core/store/__init__.py +0 -0
  78. {kiba_core-0.5.3.dev65 → kiba_core-0.5.3.dev67}/core/store/retriever.py +0 -0
  79. {kiba_core-0.5.3.dev65 → kiba_core-0.5.3.dev67}/core/store/saver.py +0 -0
  80. {kiba_core-0.5.3.dev65 → kiba_core-0.5.3.dev67}/core/util/__init__.py +0 -0
  81. {kiba_core-0.5.3.dev65 → kiba_core-0.5.3.dev67}/core/util/async_util.py +0 -0
  82. {kiba_core-0.5.3.dev65 → kiba_core-0.5.3.dev67}/core/util/chain_util.py +0 -0
  83. {kiba_core-0.5.3.dev65 → kiba_core-0.5.3.dev67}/core/util/date_util.py +0 -0
  84. {kiba_core-0.5.3.dev65 → kiba_core-0.5.3.dev67}/core/util/dict_util.py +0 -0
  85. {kiba_core-0.5.3.dev65 → kiba_core-0.5.3.dev67}/core/util/file_util.py +0 -0
  86. {kiba_core-0.5.3.dev65 → kiba_core-0.5.3.dev67}/core/util/hashing_util.py +0 -0
  87. {kiba_core-0.5.3.dev65 → kiba_core-0.5.3.dev67}/core/util/http_util.py +0 -0
  88. {kiba_core-0.5.3.dev65 → kiba_core-0.5.3.dev67}/core/util/json_util.py +0 -0
  89. {kiba_core-0.5.3.dev65 → kiba_core-0.5.3.dev67}/core/util/list_util.py +0 -0
  90. {kiba_core-0.5.3.dev65 → kiba_core-0.5.3.dev67}/core/util/process_util.py +0 -0
  91. {kiba_core-0.5.3.dev65 → kiba_core-0.5.3.dev67}/core/util/string_util.py +0 -0
  92. {kiba_core-0.5.3.dev65 → kiba_core-0.5.3.dev67}/core/util/typing_util.py +0 -0
  93. {kiba_core-0.5.3.dev65 → kiba_core-0.5.3.dev67}/core/util/url_util.py +0 -0
  94. {kiba_core-0.5.3.dev65 → kiba_core-0.5.3.dev67}/core/util/value_holder.py +0 -0
  95. {kiba_core-0.5.3.dev65 → kiba_core-0.5.3.dev67}/core/web3/__init__.py +0 -0
  96. {kiba_core-0.5.3.dev65 → kiba_core-0.5.3.dev67}/core/web3/eth_client.py +0 -0
  97. {kiba_core-0.5.3.dev65 → kiba_core-0.5.3.dev67}/core/web3/multicall3.py +0 -0
  98. {kiba_core-0.5.3.dev65 → kiba_core-0.5.3.dev67}/kiba_core.egg-info/dependency_links.txt +0 -0
  99. {kiba_core-0.5.3.dev65 → kiba_core-0.5.3.dev67}/kiba_core.egg-info/requires.txt +0 -0
  100. {kiba_core-0.5.3.dev65 → kiba_core-0.5.3.dev67}/kiba_core.egg-info/top_level.txt +0 -0
  101. {kiba_core-0.5.3.dev65 → kiba_core-0.5.3.dev67}/makefile +0 -0
  102. {kiba_core-0.5.3.dev65 → kiba_core-0.5.3.dev67}/setup.cfg +0 -0
  103. {kiba_core-0.5.3.dev65 → kiba_core-0.5.3.dev67}/tests/__init__.py +0 -0
  104. {kiba_core-0.5.3.dev65 → kiba_core-0.5.3.dev67}/tests/api/test_api_response.py +0 -0
  105. {kiba_core-0.5.3.dev65 → kiba_core-0.5.3.dev67}/tests/api/test_exception_handling_middleware.py +0 -0
  106. {kiba_core-0.5.3.dev65 → kiba_core-0.5.3.dev67}/tests/api/test_json_route.py +0 -0
  107. {kiba_core-0.5.3.dev65 → kiba_core-0.5.3.dev67}/tests/api/test_openapi.py +0 -0
  108. {kiba_core-0.5.3.dev65 → kiba_core-0.5.3.dev67}/tests/api/test_origin_ip_middleware.py +0 -0
  109. {kiba_core-0.5.3.dev65 → kiba_core-0.5.3.dev67}/tests/api/test_rate_limit.py +0 -0
  110. {kiba_core-0.5.3.dev65 → kiba_core-0.5.3.dev67}/tests/api/test_request_context.py +0 -0
  111. {kiba_core-0.5.3.dev65 → kiba_core-0.5.3.dev67}/tests/api/test_streaming_json_route.py +0 -0
  112. {kiba_core-0.5.3.dev65 → kiba_core-0.5.3.dev67}/tests/caching/test_dict_cache.py +0 -0
  113. {kiba_core-0.5.3.dev65 → kiba_core-0.5.3.dev67}/tests/caching/test_file_cache.py +0 -0
  114. {kiba_core-0.5.3.dev65 → kiba_core-0.5.3.dev67}/tests/requester/__init__.py +0 -0
  115. {kiba_core-0.5.3.dev65 → kiba_core-0.5.3.dev67}/tests/requester/test_requester.py +0 -0
  116. {kiba_core-0.5.3.dev65 → kiba_core-0.5.3.dev67}/tests/test_exceptions.py +0 -0
  117. {kiba_core-0.5.3.dev65 → kiba_core-0.5.3.dev67}/tests/util/__init__.py +0 -0
  118. {kiba_core-0.5.3.dev65 → kiba_core-0.5.3.dev67}/tests/util/test_async_util.py +0 -0
  119. {kiba_core-0.5.3.dev65 → kiba_core-0.5.3.dev67}/tests/util/test_chain_util.py +0 -0
  120. {kiba_core-0.5.3.dev65 → kiba_core-0.5.3.dev67}/tests/util/test_date_util.py +0 -0
  121. {kiba_core-0.5.3.dev65 → kiba_core-0.5.3.dev67}/tests/util/test_dict_util.py +0 -0
  122. {kiba_core-0.5.3.dev65 → kiba_core-0.5.3.dev67}/tests/util/test_file_util.py +0 -0
  123. {kiba_core-0.5.3.dev65 → kiba_core-0.5.3.dev67}/tests/util/test_json_util.py +0 -0
  124. {kiba_core-0.5.3.dev65 → kiba_core-0.5.3.dev67}/tests/util/test_list_util.py +0 -0
  125. {kiba_core-0.5.3.dev65 → kiba_core-0.5.3.dev67}/tests/util/test_process_util.py +0 -0
  126. {kiba_core-0.5.3.dev65 → kiba_core-0.5.3.dev67}/tests/util/test_string_util.py +0 -0
  127. {kiba_core-0.5.3.dev65 → kiba_core-0.5.3.dev67}/tests/util/test_url_util.py +0 -0
  128. {kiba_core-0.5.3.dev65 → kiba_core-0.5.3.dev67}/tests/web3/__init__.py +0 -0
  129. {kiba_core-0.5.3.dev65 → kiba_core-0.5.3.dev67}/tests/web3/test_rest_eth_client.py +0 -0
  130. {kiba_core-0.5.3.dev65 → kiba_core-0.5.3.dev67}/uv.lock +0 -0
@@ -45,16 +45,27 @@ jobs:
45
45
  json-file-path: ./type-check-results.json
46
46
  check-name: core-type-check
47
47
  fail-on-error: false
48
- - name: Start postgres
48
+ - name: Start test services
49
49
  run: |
50
50
  docker network create tests
51
51
  docker run --detach --name postgres --network tests --env POSTGRES_PASSWORD=test --env POSTGRES_DB=core postgres:16-alpine
52
+ docker run --detach --name cosmos --network tests mcr.microsoft.com/cosmosdb/linux/azure-cosmos-emulator@sha256:2db1f9e74c506bcf6fc347aa937aea1c00fa756061296a5a9efba530ce86ec02 --protocol http
53
+ docker run --detach --name elasticmq --network tests --publish 9324:9324 softwaremill/elasticmq-native@sha256:e4580ab9ad1bd5cd37b4ba04911bc5ccc8cd2d9ab4de56ece65acee71c24e05c
54
+ docker run --detach --name azurite --network tests --publish 10001:10001 mcr.microsoft.com/azure-storage/azurite@sha256:830430c1da1a2d537e08f3e6764dd1f5ae00cf0346bcaf625b968ec3f0971fd5 azurite-queue --queueHost 0.0.0.0 --skipApiVersionCheck
55
+ echo "Waiting for postgres..."
52
56
  # NOTE(krishan711): check over TCP: during initdb the image runs a temporary server on the unix socket only, which reports ready and then shuts down
53
57
  for i in $(seq 1 60); do docker exec postgres pg_isready --host 127.0.0.1 --username postgres --quiet && break; sleep 1; done
54
58
  docker exec postgres pg_isready --host 127.0.0.1 --username postgres
59
+ echo "Waiting for sqs and aqs emulators..."
60
+ # NOTE(krishan711): any http response (even an error) means the emulator is accepting requests
61
+ for i in $(seq 1 60); do curl --silent --output /dev/null http://localhost:9324/ && curl --silent --output /dev/null http://localhost:10001/ && break; sleep 1; done
62
+ curl --silent --output /dev/null http://localhost:9324/ && curl --silent --output /dev/null http://localhost:10001/
63
+ echo "Waiting for cosmos emulator..."
64
+ for i in $(seq 1 120); do docker exec cosmos curl --silent --fail --output /dev/null http://localhost:8080/ready && break; sleep 1; done
65
+ docker exec cosmos curl --silent --fail --output /dev/null http://localhost:8080/ready
55
66
  - name: Run tests
56
67
  run: |
57
- docker run --name tests --network tests --env CORE_TEST_PSQL_CONNECTION_STRING=postgresql+asyncpg://postgres:test@postgres:5432/core $DOCKER_IMAGE make test-ci
68
+ docker run --name tests --network tests --env CORE_TEST_PSQL_CONNECTION_STRING=postgresql+asyncpg://postgres:test@postgres:5432/core --env CORE_TEST_COSMOS_ENDPOINT=http://cosmos:8081 --env CORE_TEST_SQS_ENDPOINT=http://elasticmq:9324 --env CORE_TEST_AQS_ENDPOINT=http://azurite:10001/devstoreaccount1 $DOCKER_IMAGE make test-ci
58
69
  docker cp tests:/app/test-check-results.json ./test-check-results.json
59
70
  - name: Upload test results
60
71
  uses: kibalabs/github-action-create-annotations@main
@@ -8,6 +8,19 @@ 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 `authorize_bearer_jwt_request` for validating bearer JWTs in `RouteAuthResolver` implementations; it and `authorize_bearer_jwt` now only strip the leading `Bearer ` prefix, let cancellation propagate, and log why a JWT was rejected
12
+ - [MINOR] Made the `route` `authResolver` optional for routes without `auth`; setting `auth` without an `authResolver` raises `ValueError` at registration
13
+ - [MAJOR] Replaced `MessageQueue.delete_message` with `complete_message`, and added abstract `retry_message` (reschedule with a delay) and `fail_message` (apply the queue's failure policy); `MessageQueueProcessor` now reschedules `MessageNeedsReprocessingException` messages with `retry_message`
14
+ - [MINOR] Updated `MessageQueueProcessor` to retry messages that raise `LockedException` up to 3 times (30s linear backoff) before failing them
15
+ - [MINOR] Updated `MessageQueueProcessor` to log instead of raise when a failure notification cannot be sent
16
+ - [MAJOR] Reworked `SqlMessageQueue` to keep message history in `tbl_queue_messages` with a `status` lifecycle (`pending`, `running`, `succeeded`, `failed`, `deduplicated`), `owner`, `lastError`, `startedDate` and `completedDate`; deduplication now only applies against pending messages and keeps the earliest `visibleDate` of the duplicates, every retry, failure retry and lease-expiry reclaim increments `postCount`, failures back off and move to `failed` once `postCount` reaches `maxAttempts`, and `get_message_count` removes finished messages older than `retentionSeconds` (default 7 days) unless `shouldSkipRemovingNonRetained` is set
17
+ - [MINOR] Added `Database.now` to build database-clock timestamps; `SqlMessageQueue` and `SqlLock` now use the database clock instead of the host clock
18
+ - [MINOR] Capped `SqsMessageQueue` delays at SQS's 900 second maximum
19
+ - [MINOR] Updated `CosmosMessageQueue.retry_message` to reschedule the message in place and added emulator-backed tests (run when `CORE_TEST_COSMOS_ENDPOINT` is set)
20
+ - [MINOR] Added `shouldRaiseOnUncommittedWrites` to `Database` to raise instead of log when an isolated context connection is opened after uncommitted writes (for tests and development)
21
+ - [MAJOR] Added abstract `MessageQueue.extend_message_lease` (implemented for SQL, SQS, AQS and Cosmos queues, each returning `False` once the lease is lost; SQS accepts any receipt handle for a message so it can only detect that the message was deleted or is no longer in flight); `MessageQueueProcessor` now keeps a message's lease alive while it is being processed and, if the lease was lost, raises `MessageLeaseLostException` once the job returns (taking priority over any exception from the job, while cancellation still propagates) so the message is neither completed nor retried; `SqsMessageQueue` and `AqsMessageQueue` `retry_message` check the lease is still held before re-sending so a lost lease cannot duplicate the message
22
+ - [MINOR] Added optional `endpointUrl` to `SqsMessageQueue` and `AqsMessageQueue` to target local emulators, and added emulator-backed tests (run when `CORE_TEST_SQS_ENDPOINT` / `CORE_TEST_AQS_ENDPOINT` are set)
23
+ - [MINOR] Added `Lock.ensure_held` to renew a lease and raise `LockedException` if it has been lost, for use before irreversible work; `LockLease` now records its `ttlSeconds`
11
24
  - [MINOR] Added `Lock` with a lease-based `SqlLock` implementation (`tbl_locks`) and a `with_lock` context manager that keeps the lease alive; acquiring raises `LockedException` (naming the current holder) if not acquired within `maxWaitSeconds`, and `get_owner` reports who holds a lock
12
25
  - [MINOR] Added `process_util.get_process_name` to identify the current process (`<containerName>:<containerId>` in docker, `<hostname>:<pid>` otherwise)
13
26
  - [MINOR] Added `Database.create_isolated_context_connection` to run a block in its own committed transaction inside an existing context connection; logs `ISOLATED_CONNECTION_AFTER_UNCOMMITTED_WRITES` when opened while the outer context connection has uncommitted writes
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: kiba-core
3
- Version: 0.5.3.dev65
3
+ Version: 0.5.3.dev67
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
@@ -10,6 +10,7 @@ from core.api.api_request import KibaApiRequest
10
10
  from core.api.route_metadata import OpenApiSecurityScheme
11
11
  from core.api.route_metadata import update_route_metadata
12
12
  from core.exceptions import ForbiddenException
13
+ from core.exceptions import KibaException
13
14
  from core.exceptions import UnauthorizedException
14
15
  from core.http.basic_authentication import BasicAuthentication
15
16
  from core.http.jwt import Jwt
@@ -27,18 +28,19 @@ class SignatureAuthorizer:
27
28
  raise NotImplementedError
28
29
 
29
30
 
30
- async def _authorize_bearer_jwt[ApiRequest: BaseModel](request: KibaApiRequest[ApiRequest], authorizer: Authorizer) -> Jwt:
31
+ async def authorize_bearer_jwt_request[ApiRequest: BaseModel](request: KibaApiRequest[ApiRequest], authorizer: Authorizer) -> Jwt:
31
32
  authorization = request.headers.get('Authorization')
32
33
  if not authorization:
33
34
  raise ForbiddenException(message='AUTH_NOT_PROVIDED')
34
35
  if not authorization.startswith('Bearer '):
35
36
  raise ForbiddenException(message='AUTH_INVALID')
36
- jwtString = authorization.replace('Bearer ', '')
37
37
  try:
38
- jwt = await authorizer.validate_jwt(jwtString=jwtString)
39
- except BaseException: # noqa: BLE001
40
- raise ForbiddenException(message='AUTH_INVALID')
41
- return jwt
38
+ return await authorizer.validate_jwt(jwtString=authorization.removeprefix('Bearer '))
39
+ except Exception as exception:
40
+ # NOTE(krishan711): only log kiba messages as other exception messages (e.g. json decode errors) can contain parts of the token
41
+ reason = exception.message if isinstance(exception, KibaException) else type(exception).__name__
42
+ logging.info(f'Bearer JWT rejected: {reason}')
43
+ raise ForbiddenException(message='AUTH_INVALID') from exception
42
44
 
43
45
 
44
46
  def authorize_bearer_jwt[ApiRequest: BaseModel]( # type: ignore[explicit-any]
@@ -52,7 +54,7 @@ def authorize_bearer_jwt[ApiRequest: BaseModel]( # type: ignore[explicit-any]
52
54
 
53
55
  @functools.wraps(func)
54
56
  async def async_wrapper(request: KibaApiRequest[ApiRequest]) -> typing.Any: # type: ignore[explicit-any, misc]
55
- request.authJwt = await _authorize_bearer_jwt(request=request, authorizer=authorizer)
57
+ request.authJwt = await authorize_bearer_jwt_request(request=request, authorizer=authorizer)
56
58
  result = func(request)
57
59
  # NOTE(krishan711): this is here to support streaming responses which return an async generator
58
60
  if hasattr(result, '__aiter__'):
@@ -42,7 +42,7 @@ def route[ApiRequest: BaseModel, ApiResponse: BaseModel]( # type: ignore[explic
42
42
  requestType: typing.Type[ApiRequest],
43
43
  responseType: typing.Type[ApiResponse],
44
44
  *,
45
- authResolver: RouteAuthResolver,
45
+ authResolver: RouteAuthResolver | None = None,
46
46
  isStreaming: bool = False,
47
47
  operationId: str | None = None,
48
48
  summary: str | None = None,
@@ -52,13 +52,15 @@ def route[ApiRequest: BaseModel, ApiResponse: BaseModel]( # type: ignore[explic
52
52
  rateLimit: RateLimitConfig | None = None,
53
53
  extensions: Mapping[str, object] | None = None,
54
54
  ) -> typing.Callable[[typing.Callable[[KibaApiRequest[ApiRequest]], _AnyReturn]], typing.Callable[[Request], typing.Awaitable[KibaJSONResponse | StreamingResponse]]]:
55
- securitySchemeNames = authResolver.get_route_security_schemes(auth=auth) if auth is not None else []
56
-
57
55
  def decorator(func: typing.Callable[[KibaApiRequest[ApiRequest]], _AnyReturn]) -> typing.Callable[[Request], typing.Awaitable[KibaJSONResponse | StreamingResponse]]: # type: ignore[explicit-any]
58
56
  handler: typing.Callable[[KibaApiRequest[ApiRequest]], _AnyReturn] = func
59
57
  if rateLimit is not None:
60
58
  handler = rate_limit(rateLimit)(handler)
59
+ securitySchemeNames: list[str] = []
61
60
  if auth is not None:
61
+ if authResolver is None:
62
+ raise ValueError(f'Auth policy {auth} requires an authResolver')
63
+ securitySchemeNames = authResolver.get_route_security_schemes(auth=auth)
62
64
  handler = _authorize_route(authResolver, auth)(handler)
63
65
  endpointDecorator = streaming_json_route if isStreaming else json_route
64
66
  endpoint = endpointDecorator(requestType=requestType, responseType=responseType)(handler) # type: ignore[arg-type, ty:invalid-argument-type]
@@ -5,7 +5,8 @@ from abc import ABC
5
5
  from collections.abc import AsyncIterator
6
6
 
7
7
  from core import logging
8
- from core.locks.model import Lease
8
+ from core.exceptions import LockedException
9
+ from core.locks.model import LockLease
9
10
 
10
11
 
11
12
  class Lock(ABC):
@@ -18,26 +19,31 @@ class Lock(ABC):
18
19
  raise NotImplementedError
19
20
 
20
21
  @abc.abstractmethod
21
- async def acquire(self, name: str, ttlSeconds: int = 60, maxWaitSeconds: float = 0) -> Lease:
22
+ async def acquire(self, name: str, ttlSeconds: int = 60, maxWaitSeconds: float = 0) -> LockLease:
22
23
  raise NotImplementedError
23
24
 
24
25
  @abc.abstractmethod
25
- async def extend(self, lease: Lease, ttlSeconds: int = 60) -> bool:
26
+ async def extend(self, lease: LockLease, ttlSeconds: int = 60) -> bool:
26
27
  raise NotImplementedError
27
28
 
28
29
  @abc.abstractmethod
29
- async def release(self, lease: Lease) -> None:
30
+ async def release(self, lease: LockLease) -> None:
30
31
  raise NotImplementedError
31
32
 
32
33
  @abc.abstractmethod
33
34
  async def get_owner(self, name: str) -> str | None:
34
35
  raise NotImplementedError
35
36
 
36
- async def _keep_alive(self, lease: Lease, ttlSeconds: int) -> None:
37
+ async def ensure_held(self, lease: LockLease) -> None:
38
+ if lease.isLost or not await self.extend(lease=lease, ttlSeconds=lease.ttlSeconds):
39
+ lease.isLost = True
40
+ raise LockedException(message=f'LOCK_LOST: {lease.name}')
41
+
42
+ async def _keep_alive(self, lease: LockLease) -> None:
37
43
  while True:
38
- await asyncio.sleep(ttlSeconds / 3)
44
+ await asyncio.sleep(lease.ttlSeconds / 3)
39
45
  try:
40
- isExtended = await self.extend(lease=lease, ttlSeconds=ttlSeconds)
46
+ isExtended = await self.extend(lease=lease, ttlSeconds=lease.ttlSeconds)
41
47
  except Exception as exception: # noqa: BLE001
42
48
  # NOTE(krishan711): a transient failure must not kill the keep-alive; the lease simply lapses at expiryDate if it keeps failing
43
49
  logging.error(f'Failed to extend lease {lease.name}:')
@@ -49,9 +55,9 @@ class Lock(ABC):
49
55
  return
50
56
 
51
57
  @contextlib.asynccontextmanager
52
- async def with_lock(self, name: str, ttlSeconds: int = 60, maxWaitSeconds: float = 0) -> AsyncIterator[Lease]:
58
+ async def with_lock(self, name: str, ttlSeconds: int = 60, maxWaitSeconds: float = 0) -> AsyncIterator[LockLease]:
53
59
  lease = await self.acquire(name=name, ttlSeconds=ttlSeconds, maxWaitSeconds=maxWaitSeconds)
54
- keepAliveTask = asyncio.create_task(self._keep_alive(lease=lease, ttlSeconds=ttlSeconds))
60
+ keepAliveTask = asyncio.create_task(self._keep_alive(lease=lease))
55
61
  try:
56
62
  yield lease
57
63
  finally:
@@ -5,10 +5,11 @@ from pydantic import BaseModel
5
5
  from core.util import date_util
6
6
 
7
7
 
8
- class Lease(BaseModel):
8
+ class LockLease(BaseModel):
9
9
  name: str
10
10
  token: str
11
11
  expiryDate: datetime.datetime
12
+ ttlSeconds: int
12
13
  isLost: bool = False
13
14
 
14
15
  def is_valid(self) -> bool:
@@ -9,7 +9,7 @@ from sqlalchemy.dialects import sqlite as sqlalchemy_sqlite
9
9
  from core.exceptions import InternalServerErrorException
10
10
  from core.exceptions import LockedException
11
11
  from core.locks.lock import Lock
12
- from core.locks.model import Lease
12
+ from core.locks.model import LockLease
13
13
  from core.store.database import Database
14
14
  from core.util import date_util
15
15
  from core.util import process_util
@@ -43,7 +43,7 @@ class SqlLock(Lock):
43
43
  async def disconnect(self) -> None:
44
44
  pass
45
45
 
46
- async def acquire(self, name: str, ttlSeconds: int = 60, maxWaitSeconds: float = 0) -> Lease:
46
+ async def acquire(self, name: str, ttlSeconds: int = 60, maxWaitSeconds: float = 0) -> LockLease:
47
47
  deadline = time.monotonic() + maxWaitSeconds
48
48
  while True:
49
49
  lease = await self._try_acquire(name=name, ttlSeconds=ttlSeconds)
@@ -56,7 +56,7 @@ class SqlLock(Lock):
56
56
 
57
57
  async def get_owner(self, name: str) -> str | None:
58
58
  async with self.database.create_transaction() as connection:
59
- query = sqlalchemy.select(self.table.c.owner).where(self.table.c.name == name).where(self.table.c.expiryDate > date_util.datetime_from_now())
59
+ query = sqlalchemy.select(self.table.c.owner).where(self.table.c.name == name).where(self.table.c.expiryDate > self.database.now())
60
60
  result = await self.database.execute(query=query, connection=connection)
61
61
  return result.scalar_one_or_none()
62
62
 
@@ -67,30 +67,38 @@ class SqlLock(Lock):
67
67
  return sqlalchemy_sqlite.insert(self.table)
68
68
  raise InternalServerErrorException(message=f'SqlLock does not support dialect: {dialectName}')
69
69
 
70
- async def _try_acquire(self, name: str, ttlSeconds: int) -> Lease | None:
71
- now = date_util.datetime_from_now()
72
- lease = Lease(name=name, token=str(uuid.uuid4()), expiryDate=date_util.datetime_from_now(seconds=ttlSeconds))
73
- values = {self.table.c.lockToken: lease.token, self.table.c.acquiredDate: now, self.table.c.expiryDate: lease.expiryDate, self.table.c.owner: self.owner}
70
+ async def _try_acquire(self, name: str, ttlSeconds: int) -> LockLease | None:
71
+ lease = LockLease(name=name, token=str(uuid.uuid4()), expiryDate=date_util.datetime_from_now(seconds=ttlSeconds), ttlSeconds=ttlSeconds)
72
+ values = {self.table.c.lockToken: lease.token, self.table.c.acquiredDate: self.database.now(), self.table.c.expiryDate: self.database.now(seconds=ttlSeconds), self.table.c.owner: self.owner}
74
73
  async with self.database.create_transaction() as connection:
75
74
  upsertQuery = (
76
- self._create_insert(dialectName=connection.dialect.name).values({self.table.c.name: name, **values}).on_conflict_do_update(index_elements=[self.table.c.name], set_=values, where=self.table.c.expiryDate <= now).returning(self.table.c.name)
75
+ self._create_insert(dialectName=connection.dialect.name)
76
+ .values({self.table.c.name: name, **values})
77
+ .on_conflict_do_update(index_elements=[self.table.c.name], set_=values, where=self.table.c.expiryDate <= self.database.now())
78
+ .returning(self.table.c.name)
77
79
  )
78
80
  result = await self.database.execute(query=upsertQuery, connection=connection)
79
81
  isAcquired = result.first() is not None
80
82
  return lease if isAcquired else None
81
83
 
82
- async def extend(self, lease: Lease, ttlSeconds: int = 60) -> bool:
83
- now = date_util.datetime_from_now()
84
+ async def extend(self, lease: LockLease, ttlSeconds: int = 60) -> bool:
84
85
  newExpiryDate = date_util.datetime_from_now(seconds=ttlSeconds)
85
86
  async with self.database.create_transaction() as connection:
86
- updateQuery = sqlalchemy.update(self.table).where(self.table.c.name == lease.name).where(self.table.c.lockToken == lease.token).where(self.table.c.expiryDate > now).values(expiryDate=newExpiryDate).returning(self.table.c.name)
87
+ updateQuery = (
88
+ sqlalchemy.update(self.table)
89
+ .where(self.table.c.name == lease.name)
90
+ .where(self.table.c.lockToken == lease.token)
91
+ .where(self.table.c.expiryDate > self.database.now())
92
+ .values(expiryDate=self.database.now(seconds=ttlSeconds))
93
+ .returning(self.table.c.name)
94
+ )
87
95
  result = await self.database.execute(query=updateQuery, connection=connection)
88
96
  isExtended = result.first() is not None
89
97
  if isExtended:
90
98
  lease.expiryDate = newExpiryDate
91
99
  return isExtended
92
100
 
93
- async def release(self, lease: Lease) -> None:
101
+ async def release(self, lease: LockLease) -> None:
94
102
  async with self.database.create_transaction() as connection:
95
103
  deleteQuery = sqlalchemy.delete(self.table).where(self.table.c.name == lease.name).where(self.table.c.lockToken == lease.token)
96
104
  await self.database.execute(query=deleteQuery, connection=connection)
@@ -3,7 +3,9 @@ from __future__ import annotations
3
3
  import asyncio
4
4
  from collections.abc import Sequence
5
5
 
6
+ from azure.core.exceptions import HttpResponseError
6
7
  from azure.storage.queue import QueueMessage as RawAqsMessage
8
+ from azure.storage.queue import StorageErrorCode
7
9
  from azure.storage.queue.aio import QueueClient
8
10
 
9
11
  from core.exceptions import InternalServerErrorException
@@ -11,6 +13,8 @@ from core.queues.message_queue import MessageQueue
11
13
  from core.queues.model import Message
12
14
  from core.util import list_util
13
15
 
16
+ AQS_RETRY_HOLD_SECONDS = 60
17
+
14
18
 
15
19
  class AqsMessage(Message):
16
20
  aqsId: str
@@ -32,14 +36,18 @@ class AqsMessage(Message):
32
36
 
33
37
 
34
38
  class AqsMessageQueue(MessageQueue[AqsMessage]):
35
- def __init__(self, storageAccountName: str, storageAccountKey: str, queueName: str) -> None:
39
+ def __init__(self, storageAccountName: str, storageAccountKey: str, queueName: str, endpointUrl: str | None = None) -> None:
36
40
  self._storageAccountName = storageAccountName
37
41
  self._storageAccountKey = storageAccountKey
38
42
  self.queueName = queueName
43
+ self.endpointUrl = endpointUrl
39
44
  self._aqsClient: QueueClient | None = None
40
45
 
41
46
  async def connect(self) -> None:
42
- self._aqsClient = QueueClient.from_connection_string(conn_str=f'DefaultEndpointsProtocol=https;AccountName={self._storageAccountName};AccountKey={self._storageAccountKey}', queue_name=self.queueName)
47
+ connectionString = f'DefaultEndpointsProtocol=https;AccountName={self._storageAccountName};AccountKey={self._storageAccountKey}'
48
+ if self.endpointUrl:
49
+ connectionString += f';QueueEndpoint={self.endpointUrl}'
50
+ self._aqsClient = QueueClient.from_connection_string(conn_str=connectionString, queue_name=self.queueName)
43
51
  if not self._aqsClient:
44
52
  raise InternalServerErrorException('Failed to connect to queue')
45
53
  await self._aqsClient.get_queue_properties()
@@ -76,11 +84,35 @@ class AqsMessageQueue(MessageQueue[AqsMessage]):
76
84
  aqsMessages = [AqsMessage.from_aqs_message(aqsMessage=message) async for message in messagesIterator]
77
85
  return aqsMessages
78
86
 
79
- async def delete_message(self, message: AqsMessage) -> None:
87
+ async def complete_message(self, message: AqsMessage) -> None:
80
88
  if not self._aqsClient:
81
- raise InternalServerErrorException('You need to call .connect() before trying to delete messages')
89
+ raise InternalServerErrorException('You need to call .connect() before trying to complete messages')
82
90
  await self._aqsClient.delete_message(message=message.aqsId, pop_receipt=message.popReceipt)
83
91
 
92
+ async def retry_message(self, message: AqsMessage, delaySeconds: int = 0) -> None:
93
+ # NOTE(krishan711): check the lease is still held before re-sending, otherwise another worker owns the message and re-sending would duplicate it
94
+ if not await self.extend_message_lease(message=message, expectedProcessingSeconds=AQS_RETRY_HOLD_SECONDS):
95
+ return
96
+ await self.send_message(message=message, delaySeconds=delaySeconds)
97
+ await self.complete_message(message=message)
98
+
99
+ async def fail_message(self, message: AqsMessage, errorMessage: str | None) -> None:
100
+ # NOTE(krishan711): the message reappears after its visibility timeout
101
+ pass
102
+
103
+ async def extend_message_lease(self, message: AqsMessage, expectedProcessingSeconds: int) -> bool:
104
+ if not self._aqsClient:
105
+ raise InternalServerErrorException('You need to call .connect() before trying to extend message leases')
106
+ try:
107
+ updatedMessage = await self._aqsClient.update_message(message=message.aqsId, pop_receipt=message.popReceipt, visibility_timeout=expectedProcessingSeconds)
108
+ except HttpResponseError as exception:
109
+ if getattr(exception, 'error_code', None) in {StorageErrorCode.POP_RECEIPT_MISMATCH, StorageErrorCode.MESSAGE_NOT_FOUND}:
110
+ return False
111
+ raise
112
+ # NOTE(krishan711): azure invalidates the old pop receipt on every update so later calls must use the new one
113
+ message.popReceipt = updatedMessage.pop_receipt
114
+ return True
115
+
84
116
  async def get_message_count(self) -> int:
85
117
  if not self._aqsClient:
86
118
  raise InternalServerErrorException('You need to call .connect() before trying to count messages')
@@ -13,6 +13,7 @@ from azure.cosmos.aio import ContainerProxy
13
13
 
14
14
  from core.queues.message_queue import MessageQueue
15
15
  from core.queues.model import Message
16
+ from core.util import date_util
16
17
  from core.util.typing_util import JsonObject
17
18
 
18
19
 
@@ -169,7 +170,7 @@ class CosmosMessageQueue(MessageQueue[CosmosMessage]):
169
170
  break
170
171
  return messages
171
172
 
172
- async def delete_message(self, message: CosmosMessage) -> None:
173
+ async def complete_message(self, message: CosmosMessage) -> None:
173
174
  if message.deduplicationId is None:
174
175
  await self.container.delete_item(
175
176
  item=message.id,
@@ -189,6 +190,46 @@ class CosmosMessageQueue(MessageQueue[CosmosMessage]):
189
190
  partition_key=self.queueName,
190
191
  )
191
192
 
193
+ async def retry_message(self, message: CosmosMessage, delaySeconds: int = 0) -> None:
194
+ try:
195
+ await self.container.patch_item(
196
+ item=message.id,
197
+ partition_key=self.queueName,
198
+ patch_operations=[
199
+ {'op': 'set', 'path': '/leaseId', 'value': None},
200
+ {'op': 'set', 'path': '/visibleDate', 'value': time.time() + delaySeconds},
201
+ {'op': 'set', 'path': '/postCount', 'value': (message.postCount or 0) + 1},
202
+ {'op': 'set', 'path': '/postDate', 'value': date_util.datetime_from_now().isoformat()},
203
+ ],
204
+ etag=message.etag,
205
+ match_condition=MatchConditions.IfNotModified,
206
+ )
207
+ except cosmos_exceptions.CosmosHttpResponseError as exception:
208
+ # NOTE(krishan711): 412 means the lease was lost to another worker, which now owns the message
209
+ if exception.status_code != 412: # noqa: PLR2004
210
+ raise
211
+
212
+ async def fail_message(self, message: CosmosMessage, errorMessage: str | None) -> None:
213
+ # NOTE(krishan711): the message reappears once its lease expires
214
+ pass
215
+
216
+ async def extend_message_lease(self, message: CosmosMessage, expectedProcessingSeconds: int) -> bool:
217
+ try:
218
+ updatedItem = await self.container.patch_item(
219
+ item=message.id,
220
+ partition_key=self.queueName,
221
+ patch_operations=[{'op': 'set', 'path': '/visibleDate', 'value': time.time() + expectedProcessingSeconds}],
222
+ etag=message.etag,
223
+ match_condition=MatchConditions.IfNotModified,
224
+ )
225
+ except cosmos_exceptions.CosmosHttpResponseError as exception:
226
+ if exception.status_code == 412: # noqa: PLR2004
227
+ return False
228
+ raise
229
+ # NOTE(krishan711): the patch changes the etag so later calls must use the new one to prove they still hold the lease
230
+ message.etag = str(updatedItem['_etag'])
231
+ return True
232
+
192
233
  async def get_message_count(self) -> int:
193
234
  counts = self.container.query_items(
194
235
  query='SELECT COUNT(1) AS messageCount FROM c WHERE c.itemType = @itemType AND c.queueName = @queueName AND c.visibleDate <= @now',
@@ -31,7 +31,19 @@ class MessageQueue[MessageType: Message](ABC):
31
31
  raise NotImplementedError
32
32
 
33
33
  @abc.abstractmethod
34
- async def delete_message(self, message: MessageType) -> None:
34
+ async def complete_message(self, message: MessageType) -> None:
35
+ raise NotImplementedError
36
+
37
+ @abc.abstractmethod
38
+ async def retry_message(self, message: MessageType, delaySeconds: int = 0) -> None:
39
+ raise NotImplementedError
40
+
41
+ @abc.abstractmethod
42
+ async def fail_message(self, message: MessageType, errorMessage: str | None) -> None:
43
+ raise NotImplementedError
44
+
45
+ @abc.abstractmethod
46
+ async def extend_message_lease(self, message: MessageType, expectedProcessingSeconds: int) -> bool:
35
47
  raise NotImplementedError
36
48
 
37
49
  @abc.abstractmethod
@@ -1,5 +1,7 @@
1
1
  import abc
2
2
  import asyncio
3
+ import contextlib
4
+ import sys
3
5
  import time
4
6
  import urllib.parse as urlparse
5
7
  import uuid
@@ -8,11 +10,15 @@ from abc import ABC
8
10
  from core import logging
9
11
  from core.exceptions import InternalServerErrorException
10
12
  from core.exceptions import KibaException
13
+ from core.exceptions import LockedException
11
14
  from core.notifications.notification_client import NotificationClient
12
15
  from core.queues.message_queue import MessageQueue
13
16
  from core.queues.model import Message
14
17
  from core.util.value_holder import RequestIdHolder
15
18
 
19
+ LOCKED_MAX_RETRY_COUNT = 3
20
+ LOCKED_RETRY_DELAY_SECONDS = 30
21
+
16
22
 
17
23
  class MessageProcessor(ABC):
18
24
  @abc.abstractmethod
@@ -28,6 +34,11 @@ class MessageNeedsReprocessingException(InternalServerErrorException):
28
34
  self.originalException = originalException
29
35
 
30
36
 
37
+ class MessageLeaseLostException(InternalServerErrorException):
38
+ def __init__(self) -> None:
39
+ super().__init__(message='MESSAGE_LEASE_LOST')
40
+
41
+
31
42
  class MessageQueueProcessor[MessageType: Message]:
32
43
  def __init__(self, queue: MessageQueue[MessageType], messageProcessor: MessageProcessor, notificationClients: list[NotificationClient], requestIdHolder: RequestIdHolder | None = None) -> None:
33
44
  self.queue = queue
@@ -35,7 +46,36 @@ class MessageQueueProcessor[MessageType: Message]:
35
46
  self.notificationClients = notificationClients
36
47
  self.requestIdHolder = requestIdHolder
37
48
 
38
- async def _process_message(self, message: MessageType) -> None:
49
+ async def _handle_failure(self, message: MessageType, exception: Exception, requestId: str) -> int:
50
+ logging.error('Caught exception whilst processing message:')
51
+ logging.exception(exception)
52
+ 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.message}```')
57
+ except Exception as notificationException: # noqa: BLE001
58
+ logging.error('Failed to send message failure notification:')
59
+ logging.exception(notificationException)
60
+ return exception.statusCode if isinstance(exception, KibaException) else 500
61
+
62
+ async def _keep_message_alive(self, message: MessageType, expectedProcessingSeconds: int, stopEvent: asyncio.Event) -> bool:
63
+ while True:
64
+ with contextlib.suppress(TimeoutError):
65
+ await asyncio.wait_for(stopEvent.wait(), timeout=expectedProcessingSeconds / 3)
66
+ if stopEvent.is_set():
67
+ return False
68
+ try:
69
+ isExtended = await self.queue.extend_message_lease(message=message, expectedProcessingSeconds=expectedProcessingSeconds)
70
+ except Exception as exception: # noqa: BLE001
71
+ logging.error(f'Failed to extend message lease {message.command}:')
72
+ logging.exception(exception)
73
+ continue
74
+ if not isExtended:
75
+ logging.error(f'Lost message lease for {message.command}')
76
+ return True
77
+
78
+ async def _process_message(self, message: MessageType, expectedProcessingSeconds: int) -> None:
39
79
  requestId = message.requestId or str(uuid.uuid4()).replace('-', '')
40
80
  if self.requestIdHolder:
41
81
  self.requestIdHolder.set_value(value=requestId)
@@ -43,20 +83,34 @@ class MessageQueueProcessor[MessageType: Message]:
43
83
  logging.api(action='MESSAGE', path=message.command, pathPattern=message.command, query=query)
44
84
  startTime = time.time()
45
85
  statusCode = 200
86
+ # NOTE(krishan711): the keep-alive is stopped and awaited (not cancelled) so an in-flight extend finishes before the message is completed, retried or failed.
87
+ # A lost lease is raised once the job returns rather than interrupting it, as cancelling mid-job could abandon work that was already sent.
88
+ # It is raised from the finally so it replaces any exception from the job: the message belongs to another worker, so it must not be retried or completed here.
89
+ # Cancellation (and other non-Exception errors) must still propagate, so those are never replaced.
90
+ stopKeepAliveEvent = asyncio.Event()
91
+ keepAliveTask = asyncio.create_task(self._keep_message_alive(message=message, expectedProcessingSeconds=expectedProcessingSeconds, stopEvent=stopKeepAliveEvent))
46
92
  try:
47
- await self.messageProcessor.process_message(message=message)
48
- await self.queue.delete_message(message=message)
93
+ try:
94
+ await self.messageProcessor.process_message(message=message)
95
+ finally:
96
+ stopKeepAliveEvent.set()
97
+ isLeaseLost = await keepAliveTask
98
+ inFlightException = sys.exception()
99
+ if isLeaseLost and (inFlightException is None or isinstance(inFlightException, Exception)):
100
+ raise MessageLeaseLostException
101
+ await self.queue.complete_message(message=message)
49
102
  except MessageNeedsReprocessingException as exception:
50
103
  logging.info(msg=f'Scheduling reprocessing for message:{message.command} due to: {exception.originalException!s}')
51
- await self.queue.send_message(message=message, delaySeconds=((message.postCount or 0) * exception.delaySeconds))
104
+ await self.queue.retry_message(message=message, delaySeconds=((message.postCount or 0) * exception.delaySeconds))
105
+ except LockedException as exception:
106
+ postCount = message.postCount or 0
107
+ if postCount <= LOCKED_MAX_RETRY_COUNT:
108
+ logging.info(msg=f'Scheduling reprocessing for message:{message.command} due to: {exception!s}')
109
+ await self.queue.retry_message(message=message, delaySeconds=(postCount * LOCKED_RETRY_DELAY_SECONDS))
110
+ else:
111
+ statusCode = await self._handle_failure(message=message, exception=exception, requestId=requestId)
52
112
  except Exception as exception: # noqa: BLE001
53
- statusCode = exception.statusCode if isinstance(exception, KibaException) else 500
54
- logging.error('Caught exception whilst processing message:')
55
- logging.exception(exception)
56
- kibaException = KibaException.from_exception(exception=exception)
57
- for client in self.notificationClients:
58
- await client.post(messageText=f'Error processing message: {message.command}\n```\n{requestId}\n{message.content}\n{kibaException.message}```')
59
- # TODO(krishan711): should possibly reset the visibility timeout
113
+ statusCode = await self._handle_failure(message=message, exception=exception, requestId=requestId)
60
114
  duration = time.time() - startTime
61
115
  logging.api(action='MESSAGE', path=message.command, pathPattern=message.command, query=query, response=statusCode, duration=duration)
62
116
  if self.requestIdHolder:
@@ -66,10 +120,10 @@ class MessageQueueProcessor[MessageType: Message]:
66
120
  logging.info('Retrieving messages...')
67
121
  messages = await self.queue.get_messages(expectedProcessingSeconds=expectedProcessingSeconds, longPollSeconds=longPollSeconds, limit=batchSize)
68
122
  if shouldProcessInParallel:
69
- await asyncio.gather(*[self._process_message(message=message) for message in messages])
123
+ await asyncio.gather(*[self._process_message(message=message, expectedProcessingSeconds=expectedProcessingSeconds) for message in messages])
70
124
  else:
71
125
  for message in messages:
72
- await self._process_message(message=message)
126
+ await self._process_message(message=message, expectedProcessingSeconds=expectedProcessingSeconds)
73
127
  return len(messages)
74
128
 
75
129
  async def execute(self, expectedProcessingSeconds: int = 300, longPollSeconds: int = 20) -> bool: