kiba-core 0.5.3.dev64__tar.gz → 0.5.3.dev66__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 (132) hide show
  1. kiba_core-0.5.3.dev66/.github/workflows/pull-request.yml +76 -0
  2. {kiba_core-0.5.3.dev64 → kiba_core-0.5.3.dev66}/CHANGELOG.md +14 -0
  3. {kiba_core-0.5.3.dev64 → kiba_core-0.5.3.dev66}/PKG-INFO +1 -1
  4. kiba_core-0.5.3.dev66/core/locks/lock.py +67 -0
  5. kiba_core-0.5.3.dev66/core/locks/model.py +16 -0
  6. kiba_core-0.5.3.dev66/core/locks/sql.py +104 -0
  7. {kiba_core-0.5.3.dev64 → kiba_core-0.5.3.dev66}/core/queues/aqs.py +36 -4
  8. {kiba_core-0.5.3.dev64 → kiba_core-0.5.3.dev66}/core/queues/cosmos.py +42 -1
  9. {kiba_core-0.5.3.dev64 → kiba_core-0.5.3.dev66}/core/queues/message_queue.py +13 -1
  10. {kiba_core-0.5.3.dev64 → kiba_core-0.5.3.dev66}/core/queues/message_queue_processor.py +67 -13
  11. kiba_core-0.5.3.dev66/core/queues/sql.py +259 -0
  12. {kiba_core-0.5.3.dev64 → kiba_core-0.5.3.dev66}/core/queues/sqs.py +40 -6
  13. {kiba_core-0.5.3.dev64 → kiba_core-0.5.3.dev66}/core/store/database.py +40 -1
  14. kiba_core-0.5.3.dev66/core/util/process_util.py +28 -0
  15. {kiba_core-0.5.3.dev64 → kiba_core-0.5.3.dev66}/kiba_core.egg-info/PKG-INFO +1 -1
  16. {kiba_core-0.5.3.dev64 → kiba_core-0.5.3.dev66}/kiba_core.egg-info/SOURCES.txt +12 -0
  17. {kiba_core-0.5.3.dev64 → kiba_core-0.5.3.dev66}/pyproject.toml +1 -1
  18. kiba_core-0.5.3.dev66/tests/locks/test_sql_lock.py +271 -0
  19. kiba_core-0.5.3.dev66/tests/queues/conftest.py +12 -0
  20. kiba_core-0.5.3.dev66/tests/queues/test_aqs_queue.py +85 -0
  21. kiba_core-0.5.3.dev66/tests/queues/test_cosmos_queue.py +140 -0
  22. kiba_core-0.5.3.dev66/tests/queues/test_sql_queue.py +353 -0
  23. kiba_core-0.5.3.dev66/tests/queues/test_sqs_queue.py +86 -0
  24. kiba_core-0.5.3.dev66/tests/test_database.py +231 -0
  25. kiba_core-0.5.3.dev66/tests/util/test_process_util.py +35 -0
  26. kiba_core-0.5.3.dev66/tests/web3/__init__.py +0 -0
  27. kiba_core-0.5.3.dev64/.github/workflows/pull-request.yml +0 -58
  28. kiba_core-0.5.3.dev64/core/queues/sql.py +0 -175
  29. kiba_core-0.5.3.dev64/tests/test_database.py +0 -115
  30. {kiba_core-0.5.3.dev64 → kiba_core-0.5.3.dev66}/.github/pull_request_template.md +0 -0
  31. {kiba_core-0.5.3.dev64 → kiba_core-0.5.3.dev66}/.github/workflows/deploy.yml +0 -0
  32. {kiba_core-0.5.3.dev64 → kiba_core-0.5.3.dev66}/.github/workflows/release.yml +0 -0
  33. {kiba_core-0.5.3.dev64 → kiba_core-0.5.3.dev66}/.gitignore +0 -0
  34. {kiba_core-0.5.3.dev64 → kiba_core-0.5.3.dev66}/Dockerfile +0 -0
  35. {kiba_core-0.5.3.dev64 → kiba_core-0.5.3.dev66}/README.md +0 -0
  36. {kiba_core-0.5.3.dev64 → kiba_core-0.5.3.dev66}/core/__init__.py +0 -0
  37. {kiba_core-0.5.3.dev64 → kiba_core-0.5.3.dev66}/core/api/__init__.py +0 -0
  38. {kiba_core-0.5.3.dev64 → kiba_core-0.5.3.dev66}/core/api/api_request.py +0 -0
  39. {kiba_core-0.5.3.dev64 → kiba_core-0.5.3.dev66}/core/api/api_response.py +0 -0
  40. {kiba_core-0.5.3.dev64 → kiba_core-0.5.3.dev66}/core/api/authorizer.py +0 -0
  41. {kiba_core-0.5.3.dev64 → kiba_core-0.5.3.dev66}/core/api/default_routes.py +0 -0
  42. {kiba_core-0.5.3.dev64 → kiba_core-0.5.3.dev66}/core/api/health.py +0 -0
  43. {kiba_core-0.5.3.dev64 → kiba_core-0.5.3.dev66}/core/api/json_route.py +0 -0
  44. {kiba_core-0.5.3.dev64 → kiba_core-0.5.3.dev66}/core/api/middleware/__init__.py +0 -0
  45. {kiba_core-0.5.3.dev64 → kiba_core-0.5.3.dev66}/core/api/middleware/database_connection_middleware.py +0 -0
  46. {kiba_core-0.5.3.dev64 → kiba_core-0.5.3.dev66}/core/api/middleware/exception_handling_middleware.py +0 -0
  47. {kiba_core-0.5.3.dev64 → kiba_core-0.5.3.dev66}/core/api/middleware/logging_middleware.py +0 -0
  48. {kiba_core-0.5.3.dev64 → kiba_core-0.5.3.dev66}/core/api/middleware/origin_ip_middleware.py +0 -0
  49. {kiba_core-0.5.3.dev64 → kiba_core-0.5.3.dev66}/core/api/middleware/server_headers_middleware.py +0 -0
  50. {kiba_core-0.5.3.dev64 → kiba_core-0.5.3.dev66}/core/api/openapi.py +0 -0
  51. {kiba_core-0.5.3.dev64 → kiba_core-0.5.3.dev66}/core/api/rate_limit.py +0 -0
  52. {kiba_core-0.5.3.dev64 → kiba_core-0.5.3.dev66}/core/api/request_context.py +0 -0
  53. {kiba_core-0.5.3.dev64 → kiba_core-0.5.3.dev66}/core/api/route.py +0 -0
  54. {kiba_core-0.5.3.dev64 → kiba_core-0.5.3.dev66}/core/api/route_auth.py +0 -0
  55. {kiba_core-0.5.3.dev64 → kiba_core-0.5.3.dev66}/core/api/route_metadata.py +0 -0
  56. {kiba_core-0.5.3.dev64 → kiba_core-0.5.3.dev66}/core/api/streaming_json_route.py +0 -0
  57. {kiba_core-0.5.3.dev64 → kiba_core-0.5.3.dev66}/core/aws_requester.py +0 -0
  58. {kiba_core-0.5.3.dev64 → kiba_core-0.5.3.dev66}/core/caching/__init__.py +0 -0
  59. {kiba_core-0.5.3.dev64 → kiba_core-0.5.3.dev66}/core/caching/cache.py +0 -0
  60. {kiba_core-0.5.3.dev64 → kiba_core-0.5.3.dev66}/core/caching/dict_cache.py +0 -0
  61. {kiba_core-0.5.3.dev64 → kiba_core-0.5.3.dev66}/core/caching/file_cache.py +0 -0
  62. {kiba_core-0.5.3.dev64 → kiba_core-0.5.3.dev66}/core/exceptions.py +0 -0
  63. {kiba_core-0.5.3.dev64 → kiba_core-0.5.3.dev66}/core/http/__init__.py +0 -0
  64. {kiba_core-0.5.3.dev64 → kiba_core-0.5.3.dev66}/core/http/basic_authentication.py +0 -0
  65. {kiba_core-0.5.3.dev64 → kiba_core-0.5.3.dev66}/core/http/jwt.py +0 -0
  66. {kiba_core-0.5.3.dev64 → kiba_core-0.5.3.dev66}/core/http/rest_method.py +0 -0
  67. {kiba_core-0.5.3.dev64/core/notifications → kiba_core-0.5.3.dev66/core/locks}/__init__.py +0 -0
  68. {kiba_core-0.5.3.dev64 → kiba_core-0.5.3.dev66}/core/logging.py +0 -0
  69. {kiba_core-0.5.3.dev64/core/queues → kiba_core-0.5.3.dev66/core/notifications}/__init__.py +0 -0
  70. {kiba_core-0.5.3.dev64 → kiba_core-0.5.3.dev66}/core/notifications/discord_client.py +0 -0
  71. {kiba_core-0.5.3.dev64 → kiba_core-0.5.3.dev66}/core/notifications/notification_client.py +0 -0
  72. {kiba_core-0.5.3.dev64 → kiba_core-0.5.3.dev66}/core/notifications/slack_client.py +0 -0
  73. {kiba_core-0.5.3.dev64 → kiba_core-0.5.3.dev66}/core/py.typed +0 -0
  74. {kiba_core-0.5.3.dev64/core/store → kiba_core-0.5.3.dev66/core/queues}/__init__.py +0 -0
  75. {kiba_core-0.5.3.dev64 → kiba_core-0.5.3.dev66}/core/queues/model.py +0 -0
  76. {kiba_core-0.5.3.dev64 → kiba_core-0.5.3.dev66}/core/requester/__init__.py +0 -0
  77. {kiba_core-0.5.3.dev64 → kiba_core-0.5.3.dev66}/core/requester/requester.py +0 -0
  78. {kiba_core-0.5.3.dev64 → kiba_core-0.5.3.dev66}/core/s3_manager.py +0 -0
  79. {kiba_core-0.5.3.dev64 → kiba_core-0.5.3.dev66}/core/service_client.py +0 -0
  80. {kiba_core-0.5.3.dev64/core/util → kiba_core-0.5.3.dev66/core/store}/__init__.py +0 -0
  81. {kiba_core-0.5.3.dev64 → kiba_core-0.5.3.dev66}/core/store/retriever.py +0 -0
  82. {kiba_core-0.5.3.dev64 → kiba_core-0.5.3.dev66}/core/store/saver.py +0 -0
  83. {kiba_core-0.5.3.dev64/core/web3 → kiba_core-0.5.3.dev66/core/util}/__init__.py +0 -0
  84. {kiba_core-0.5.3.dev64 → kiba_core-0.5.3.dev66}/core/util/async_util.py +0 -0
  85. {kiba_core-0.5.3.dev64 → kiba_core-0.5.3.dev66}/core/util/chain_util.py +0 -0
  86. {kiba_core-0.5.3.dev64 → kiba_core-0.5.3.dev66}/core/util/date_util.py +0 -0
  87. {kiba_core-0.5.3.dev64 → kiba_core-0.5.3.dev66}/core/util/dict_util.py +0 -0
  88. {kiba_core-0.5.3.dev64 → kiba_core-0.5.3.dev66}/core/util/file_util.py +0 -0
  89. {kiba_core-0.5.3.dev64 → kiba_core-0.5.3.dev66}/core/util/hashing_util.py +0 -0
  90. {kiba_core-0.5.3.dev64 → kiba_core-0.5.3.dev66}/core/util/http_util.py +0 -0
  91. {kiba_core-0.5.3.dev64 → kiba_core-0.5.3.dev66}/core/util/json_util.py +0 -0
  92. {kiba_core-0.5.3.dev64 → kiba_core-0.5.3.dev66}/core/util/list_util.py +0 -0
  93. {kiba_core-0.5.3.dev64 → kiba_core-0.5.3.dev66}/core/util/string_util.py +0 -0
  94. {kiba_core-0.5.3.dev64 → kiba_core-0.5.3.dev66}/core/util/typing_util.py +0 -0
  95. {kiba_core-0.5.3.dev64 → kiba_core-0.5.3.dev66}/core/util/url_util.py +0 -0
  96. {kiba_core-0.5.3.dev64 → kiba_core-0.5.3.dev66}/core/util/value_holder.py +0 -0
  97. {kiba_core-0.5.3.dev64/tests/requester → kiba_core-0.5.3.dev66/core/web3}/__init__.py +0 -0
  98. {kiba_core-0.5.3.dev64 → kiba_core-0.5.3.dev66}/core/web3/eth_client.py +0 -0
  99. {kiba_core-0.5.3.dev64 → kiba_core-0.5.3.dev66}/core/web3/multicall3.py +0 -0
  100. {kiba_core-0.5.3.dev64 → kiba_core-0.5.3.dev66}/kiba_core.egg-info/dependency_links.txt +0 -0
  101. {kiba_core-0.5.3.dev64 → kiba_core-0.5.3.dev66}/kiba_core.egg-info/requires.txt +0 -0
  102. {kiba_core-0.5.3.dev64 → kiba_core-0.5.3.dev66}/kiba_core.egg-info/top_level.txt +0 -0
  103. {kiba_core-0.5.3.dev64 → kiba_core-0.5.3.dev66}/makefile +0 -0
  104. {kiba_core-0.5.3.dev64 → kiba_core-0.5.3.dev66}/setup.cfg +0 -0
  105. {kiba_core-0.5.3.dev64 → kiba_core-0.5.3.dev66}/tests/__init__.py +0 -0
  106. {kiba_core-0.5.3.dev64 → kiba_core-0.5.3.dev66}/tests/api/test_api_response.py +0 -0
  107. {kiba_core-0.5.3.dev64 → kiba_core-0.5.3.dev66}/tests/api/test_authorizer.py +0 -0
  108. {kiba_core-0.5.3.dev64 → kiba_core-0.5.3.dev66}/tests/api/test_exception_handling_middleware.py +0 -0
  109. {kiba_core-0.5.3.dev64 → kiba_core-0.5.3.dev66}/tests/api/test_json_route.py +0 -0
  110. {kiba_core-0.5.3.dev64 → kiba_core-0.5.3.dev66}/tests/api/test_openapi.py +0 -0
  111. {kiba_core-0.5.3.dev64 → kiba_core-0.5.3.dev66}/tests/api/test_origin_ip_middleware.py +0 -0
  112. {kiba_core-0.5.3.dev64 → kiba_core-0.5.3.dev66}/tests/api/test_rate_limit.py +0 -0
  113. {kiba_core-0.5.3.dev64 → kiba_core-0.5.3.dev66}/tests/api/test_request_context.py +0 -0
  114. {kiba_core-0.5.3.dev64 → kiba_core-0.5.3.dev66}/tests/api/test_route.py +0 -0
  115. {kiba_core-0.5.3.dev64 → kiba_core-0.5.3.dev66}/tests/api/test_streaming_json_route.py +0 -0
  116. {kiba_core-0.5.3.dev64 → kiba_core-0.5.3.dev66}/tests/caching/test_dict_cache.py +0 -0
  117. {kiba_core-0.5.3.dev64 → kiba_core-0.5.3.dev66}/tests/caching/test_file_cache.py +0 -0
  118. {kiba_core-0.5.3.dev64/tests/web3 → kiba_core-0.5.3.dev66/tests/requester}/__init__.py +0 -0
  119. {kiba_core-0.5.3.dev64 → kiba_core-0.5.3.dev66}/tests/requester/test_requester.py +0 -0
  120. {kiba_core-0.5.3.dev64 → kiba_core-0.5.3.dev66}/tests/test_exceptions.py +0 -0
  121. {kiba_core-0.5.3.dev64 → kiba_core-0.5.3.dev66}/tests/util/__init__.py +0 -0
  122. {kiba_core-0.5.3.dev64 → kiba_core-0.5.3.dev66}/tests/util/test_async_util.py +0 -0
  123. {kiba_core-0.5.3.dev64 → kiba_core-0.5.3.dev66}/tests/util/test_chain_util.py +0 -0
  124. {kiba_core-0.5.3.dev64 → kiba_core-0.5.3.dev66}/tests/util/test_date_util.py +0 -0
  125. {kiba_core-0.5.3.dev64 → kiba_core-0.5.3.dev66}/tests/util/test_dict_util.py +0 -0
  126. {kiba_core-0.5.3.dev64 → kiba_core-0.5.3.dev66}/tests/util/test_file_util.py +0 -0
  127. {kiba_core-0.5.3.dev64 → kiba_core-0.5.3.dev66}/tests/util/test_json_util.py +0 -0
  128. {kiba_core-0.5.3.dev64 → kiba_core-0.5.3.dev66}/tests/util/test_list_util.py +0 -0
  129. {kiba_core-0.5.3.dev64 → kiba_core-0.5.3.dev66}/tests/util/test_string_util.py +0 -0
  130. {kiba_core-0.5.3.dev64 → kiba_core-0.5.3.dev66}/tests/util/test_url_util.py +0 -0
  131. {kiba_core-0.5.3.dev64 → kiba_core-0.5.3.dev66}/tests/web3/test_rest_eth_client.py +0 -0
  132. {kiba_core-0.5.3.dev64 → kiba_core-0.5.3.dev66}/uv.lock +0 -0
@@ -0,0 +1,76 @@
1
+ name: Build
2
+ on:
3
+ pull_request:
4
+ branches: ["*"]
5
+ concurrency:
6
+ group: ${{ github.workflow }}-${{ github.ref }}-pull-request
7
+ cancel-in-progress: true
8
+ jobs:
9
+ core-build:
10
+ runs-on: ubuntu-latest
11
+ env:
12
+ DOCKER_IMAGE: core
13
+ steps:
14
+ - name: Check out the repo
15
+ uses: actions/checkout@v2
16
+ - name: Set up Docker Buildx
17
+ uses: docker/setup-buildx-action@v1
18
+ - name: Build docker image
19
+ uses: docker/build-push-action@v2
20
+ with:
21
+ context: .
22
+ load: true
23
+ cache-from: type=gha,scope=core
24
+ cache-to: type=gha,mode=max,scope=core
25
+ tags: ${{ env.DOCKER_IMAGE }}
26
+ - name: Run lint-check
27
+ run: |
28
+ docker run --name lint-check $DOCKER_IMAGE make lint-check-ci
29
+ docker cp lint-check:/app/lint-check-results.json ./lint-check-results.json
30
+ - name: Upload lint-check results
31
+ uses: kibalabs/github-action-create-annotations@main
32
+ with:
33
+ github-token: ${{ secrets.GITHUB_TOKEN }}
34
+ json-file-path: ./lint-check-results.json
35
+ check-name: core-lint-check
36
+ fail-on-error: false
37
+ - name: Run type-check
38
+ run: |
39
+ docker run --name type-check $DOCKER_IMAGE make type-check-ci
40
+ docker cp type-check:/app/type-check-results.json ./type-check-results.json
41
+ - name: Upload type-check results
42
+ uses: kibalabs/github-action-create-annotations@main
43
+ with:
44
+ github-token: ${{ secrets.GITHUB_TOKEN }}
45
+ json-file-path: ./type-check-results.json
46
+ check-name: core-type-check
47
+ fail-on-error: false
48
+ - name: Start test services
49
+ run: |
50
+ docker network create tests
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..."
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
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
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
66
+ - name: Run tests
67
+ run: |
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
69
+ docker cp tests:/app/test-check-results.json ./test-check-results.json
70
+ - name: Upload test results
71
+ uses: kibalabs/github-action-create-annotations@main
72
+ with:
73
+ github-token: ${{ secrets.GITHUB_TOKEN }}
74
+ json-file-path: ./test-check-results.json
75
+ check-name: core-tests
76
+ fail-on-error: true
@@ -8,6 +8,20 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/)
8
8
  ## [Unreleased]
9
9
 
10
10
  ### Added
11
+ - [MAJOR] 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`
12
+ - [MINOR] Updated `MessageQueueProcessor` to retry messages that raise `LockedException` up to 3 times (30s linear backoff) before failing them
13
+ - [MINOR] Updated `MessageQueueProcessor` to log instead of raise when a failure notification cannot be sent
14
+ - [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
15
+ - [MINOR] Added `Database.now` to build database-clock timestamps; `SqlMessageQueue` and `SqlLock` now use the database clock instead of the host clock
16
+ - [MINOR] Capped `SqsMessageQueue` delays at SQS's 900 second maximum
17
+ - [MINOR] Updated `CosmosMessageQueue.retry_message` to reschedule the message in place and added emulator-backed tests (run when `CORE_TEST_COSMOS_ENDPOINT` is set)
18
+ - [MINOR] Added `shouldRaiseOnUncommittedWrites` to `Database` to raise instead of log when an isolated context connection is opened after uncommitted writes (for tests and development)
19
+ - [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
20
+ - [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)
21
+ - [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`
22
+ - [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
23
+ - [MINOR] Added `process_util.get_process_name` to identify the current process (`<containerName>:<containerId>` in docker, `<hostname>:<pid>` otherwise)
24
+ - [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
11
25
  - [MAJOR] Added abstract `MessageQueue.get_message_count` (implemented for SQS, AQS, SQL and Cosmos queues) for the number of messages waiting to be processed
12
26
  - [MAJOR] Added abstract `MessageQueue.get_inflight_message_count` (implemented for SQS, SQL and Cosmos queues; AQS raises `NotImplementedError` as Azure does not expose it) for the number of messages currently being processed
13
27
  - [MAJOR] Reworked `route` to compose JSON or streaming transport, application-resolved authorization, and rate limiting
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: kiba-core
3
- Version: 0.5.3.dev64
3
+ Version: 0.5.3.dev66
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
@@ -0,0 +1,67 @@
1
+ import abc
2
+ import asyncio
3
+ import contextlib
4
+ from abc import ABC
5
+ from collections.abc import AsyncIterator
6
+
7
+ from core import logging
8
+ from core.exceptions import LockedException
9
+ from core.locks.model import LockLease
10
+
11
+
12
+ class Lock(ABC):
13
+ @abc.abstractmethod
14
+ async def connect(self) -> None:
15
+ raise NotImplementedError
16
+
17
+ @abc.abstractmethod
18
+ async def disconnect(self) -> None:
19
+ raise NotImplementedError
20
+
21
+ @abc.abstractmethod
22
+ async def acquire(self, name: str, ttlSeconds: int = 60, maxWaitSeconds: float = 0) -> LockLease:
23
+ raise NotImplementedError
24
+
25
+ @abc.abstractmethod
26
+ async def extend(self, lease: LockLease, ttlSeconds: int = 60) -> bool:
27
+ raise NotImplementedError
28
+
29
+ @abc.abstractmethod
30
+ async def release(self, lease: LockLease) -> None:
31
+ raise NotImplementedError
32
+
33
+ @abc.abstractmethod
34
+ async def get_owner(self, name: str) -> str | None:
35
+ raise NotImplementedError
36
+
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:
43
+ while True:
44
+ await asyncio.sleep(lease.ttlSeconds / 3)
45
+ try:
46
+ isExtended = await self.extend(lease=lease, ttlSeconds=lease.ttlSeconds)
47
+ except Exception as exception: # noqa: BLE001
48
+ # NOTE(krishan711): a transient failure must not kill the keep-alive; the lease simply lapses at expiryDate if it keeps failing
49
+ logging.error(f'Failed to extend lease {lease.name}:')
50
+ logging.exception(exception)
51
+ continue
52
+ if not isExtended:
53
+ lease.isLost = True
54
+ logging.error(f'Lost lease {lease.name}')
55
+ return
56
+
57
+ @contextlib.asynccontextmanager
58
+ async def with_lock(self, name: str, ttlSeconds: int = 60, maxWaitSeconds: float = 0) -> AsyncIterator[LockLease]:
59
+ lease = await self.acquire(name=name, ttlSeconds=ttlSeconds, maxWaitSeconds=maxWaitSeconds)
60
+ keepAliveTask = asyncio.create_task(self._keep_alive(lease=lease))
61
+ try:
62
+ yield lease
63
+ finally:
64
+ keepAliveTask.cancel()
65
+ with contextlib.suppress(asyncio.CancelledError):
66
+ await keepAliveTask
67
+ await self.release(lease=lease)
@@ -0,0 +1,16 @@
1
+ import datetime
2
+
3
+ from pydantic import BaseModel
4
+
5
+ from core.util import date_util
6
+
7
+
8
+ class LockLease(BaseModel):
9
+ name: str
10
+ token: str
11
+ expiryDate: datetime.datetime
12
+ ttlSeconds: int
13
+ isLost: bool = False
14
+
15
+ def is_valid(self) -> bool:
16
+ return not self.isLost and date_util.datetime_from_now() < self.expiryDate
@@ -0,0 +1,104 @@
1
+ import asyncio
2
+ import time
3
+ import uuid
4
+
5
+ import sqlalchemy
6
+ from sqlalchemy.dialects import postgresql as sqlalchemy_psql
7
+ from sqlalchemy.dialects import sqlite as sqlalchemy_sqlite
8
+
9
+ from core.exceptions import InternalServerErrorException
10
+ from core.exceptions import LockedException
11
+ from core.locks.lock import Lock
12
+ from core.locks.model import LockLease
13
+ from core.store.database import Database
14
+ from core.util import date_util
15
+ from core.util import process_util
16
+
17
+ # NOTE(krishan711): this table is owned here (not in the consuming app's schema.py) so it can ship with
18
+ # core-py. To include it in an app's own alembic-tracked metadata (so autogenerate creates the table),
19
+ # call `LocksTable.to_metadata(appMetadata)` from the app's schema module.
20
+ LocksMetadata = sqlalchemy.MetaData()
21
+
22
+ LocksTable = sqlalchemy.Table(
23
+ 'tbl_locks',
24
+ LocksMetadata,
25
+ sqlalchemy.Column(key='name', name='name', type_=sqlalchemy.Text, primary_key=True, nullable=False),
26
+ sqlalchemy.Column(key='lockToken', name='lock_token', type_=sqlalchemy.Text, nullable=False),
27
+ sqlalchemy.Column(key='acquiredDate', name='acquired_date', type_=sqlalchemy.DateTime(timezone=True), nullable=False),
28
+ sqlalchemy.Column(key='expiryDate', name='expiry_date', type_=sqlalchemy.DateTime(timezone=True), nullable=False),
29
+ sqlalchemy.Column(key='owner', name='owner', type_=sqlalchemy.Text, nullable=False),
30
+ )
31
+
32
+
33
+ class SqlLock(Lock):
34
+ def __init__(self, database: Database, table: sqlalchemy.Table = LocksTable, pollIntervalSeconds: float = 1.0, owner: str | None = None) -> None:
35
+ self.database = database
36
+ self.table = table
37
+ self.pollIntervalSeconds = pollIntervalSeconds
38
+ self.owner = owner or process_util.get_process_name()
39
+
40
+ async def connect(self) -> None:
41
+ pass
42
+
43
+ async def disconnect(self) -> None:
44
+ pass
45
+
46
+ async def acquire(self, name: str, ttlSeconds: int = 60, maxWaitSeconds: float = 0) -> LockLease:
47
+ deadline = time.monotonic() + maxWaitSeconds
48
+ while True:
49
+ lease = await self._try_acquire(name=name, ttlSeconds=ttlSeconds)
50
+ if lease is not None:
51
+ return lease
52
+ if time.monotonic() >= deadline:
53
+ owner = await self.get_owner(name=name)
54
+ raise LockedException(message=f'LOCK_HELD: {name} by {owner}')
55
+ await asyncio.sleep(min(self.pollIntervalSeconds, max(deadline - time.monotonic(), 0)))
56
+
57
+ async def get_owner(self, name: str) -> str | None:
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 > self.database.now())
60
+ result = await self.database.execute(query=query, connection=connection)
61
+ return result.scalar_one_or_none()
62
+
63
+ def _create_insert(self, dialectName: str) -> sqlalchemy_psql.Insert | sqlalchemy_sqlite.Insert:
64
+ if dialectName == 'postgresql':
65
+ return sqlalchemy_psql.insert(self.table)
66
+ if dialectName == 'sqlite':
67
+ return sqlalchemy_sqlite.insert(self.table)
68
+ raise InternalServerErrorException(message=f'SqlLock does not support dialect: {dialectName}')
69
+
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}
73
+ async with self.database.create_transaction() as connection:
74
+ upsertQuery = (
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)
79
+ )
80
+ result = await self.database.execute(query=upsertQuery, connection=connection)
81
+ isAcquired = result.first() is not None
82
+ return lease if isAcquired else None
83
+
84
+ async def extend(self, lease: LockLease, ttlSeconds: int = 60) -> bool:
85
+ newExpiryDate = date_util.datetime_from_now(seconds=ttlSeconds)
86
+ async with self.database.create_transaction() as connection:
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
+ )
95
+ result = await self.database.execute(query=updateQuery, connection=connection)
96
+ isExtended = result.first() is not None
97
+ if isExtended:
98
+ lease.expiryDate = newExpiryDate
99
+ return isExtended
100
+
101
+ async def release(self, lease: LockLease) -> None:
102
+ async with self.database.create_transaction() as connection:
103
+ deleteQuery = sqlalchemy.delete(self.table).where(self.table.c.name == lease.name).where(self.table.c.lockToken == lease.token)
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: