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.
- kiba_core-0.5.3.dev66/.github/workflows/pull-request.yml +76 -0
- {kiba_core-0.5.3.dev64 → kiba_core-0.5.3.dev66}/CHANGELOG.md +14 -0
- {kiba_core-0.5.3.dev64 → kiba_core-0.5.3.dev66}/PKG-INFO +1 -1
- kiba_core-0.5.3.dev66/core/locks/lock.py +67 -0
- kiba_core-0.5.3.dev66/core/locks/model.py +16 -0
- kiba_core-0.5.3.dev66/core/locks/sql.py +104 -0
- {kiba_core-0.5.3.dev64 → kiba_core-0.5.3.dev66}/core/queues/aqs.py +36 -4
- {kiba_core-0.5.3.dev64 → kiba_core-0.5.3.dev66}/core/queues/cosmos.py +42 -1
- {kiba_core-0.5.3.dev64 → kiba_core-0.5.3.dev66}/core/queues/message_queue.py +13 -1
- {kiba_core-0.5.3.dev64 → kiba_core-0.5.3.dev66}/core/queues/message_queue_processor.py +67 -13
- kiba_core-0.5.3.dev66/core/queues/sql.py +259 -0
- {kiba_core-0.5.3.dev64 → kiba_core-0.5.3.dev66}/core/queues/sqs.py +40 -6
- {kiba_core-0.5.3.dev64 → kiba_core-0.5.3.dev66}/core/store/database.py +40 -1
- kiba_core-0.5.3.dev66/core/util/process_util.py +28 -0
- {kiba_core-0.5.3.dev64 → kiba_core-0.5.3.dev66}/kiba_core.egg-info/PKG-INFO +1 -1
- {kiba_core-0.5.3.dev64 → kiba_core-0.5.3.dev66}/kiba_core.egg-info/SOURCES.txt +12 -0
- {kiba_core-0.5.3.dev64 → kiba_core-0.5.3.dev66}/pyproject.toml +1 -1
- kiba_core-0.5.3.dev66/tests/locks/test_sql_lock.py +271 -0
- kiba_core-0.5.3.dev66/tests/queues/conftest.py +12 -0
- kiba_core-0.5.3.dev66/tests/queues/test_aqs_queue.py +85 -0
- kiba_core-0.5.3.dev66/tests/queues/test_cosmos_queue.py +140 -0
- kiba_core-0.5.3.dev66/tests/queues/test_sql_queue.py +353 -0
- kiba_core-0.5.3.dev66/tests/queues/test_sqs_queue.py +86 -0
- kiba_core-0.5.3.dev66/tests/test_database.py +231 -0
- kiba_core-0.5.3.dev66/tests/util/test_process_util.py +35 -0
- kiba_core-0.5.3.dev66/tests/web3/__init__.py +0 -0
- kiba_core-0.5.3.dev64/.github/workflows/pull-request.yml +0 -58
- kiba_core-0.5.3.dev64/core/queues/sql.py +0 -175
- kiba_core-0.5.3.dev64/tests/test_database.py +0 -115
- {kiba_core-0.5.3.dev64 → kiba_core-0.5.3.dev66}/.github/pull_request_template.md +0 -0
- {kiba_core-0.5.3.dev64 → kiba_core-0.5.3.dev66}/.github/workflows/deploy.yml +0 -0
- {kiba_core-0.5.3.dev64 → kiba_core-0.5.3.dev66}/.github/workflows/release.yml +0 -0
- {kiba_core-0.5.3.dev64 → kiba_core-0.5.3.dev66}/.gitignore +0 -0
- {kiba_core-0.5.3.dev64 → kiba_core-0.5.3.dev66}/Dockerfile +0 -0
- {kiba_core-0.5.3.dev64 → kiba_core-0.5.3.dev66}/README.md +0 -0
- {kiba_core-0.5.3.dev64 → kiba_core-0.5.3.dev66}/core/__init__.py +0 -0
- {kiba_core-0.5.3.dev64 → kiba_core-0.5.3.dev66}/core/api/__init__.py +0 -0
- {kiba_core-0.5.3.dev64 → kiba_core-0.5.3.dev66}/core/api/api_request.py +0 -0
- {kiba_core-0.5.3.dev64 → kiba_core-0.5.3.dev66}/core/api/api_response.py +0 -0
- {kiba_core-0.5.3.dev64 → kiba_core-0.5.3.dev66}/core/api/authorizer.py +0 -0
- {kiba_core-0.5.3.dev64 → kiba_core-0.5.3.dev66}/core/api/default_routes.py +0 -0
- {kiba_core-0.5.3.dev64 → kiba_core-0.5.3.dev66}/core/api/health.py +0 -0
- {kiba_core-0.5.3.dev64 → kiba_core-0.5.3.dev66}/core/api/json_route.py +0 -0
- {kiba_core-0.5.3.dev64 → kiba_core-0.5.3.dev66}/core/api/middleware/__init__.py +0 -0
- {kiba_core-0.5.3.dev64 → kiba_core-0.5.3.dev66}/core/api/middleware/database_connection_middleware.py +0 -0
- {kiba_core-0.5.3.dev64 → kiba_core-0.5.3.dev66}/core/api/middleware/exception_handling_middleware.py +0 -0
- {kiba_core-0.5.3.dev64 → kiba_core-0.5.3.dev66}/core/api/middleware/logging_middleware.py +0 -0
- {kiba_core-0.5.3.dev64 → kiba_core-0.5.3.dev66}/core/api/middleware/origin_ip_middleware.py +0 -0
- {kiba_core-0.5.3.dev64 → kiba_core-0.5.3.dev66}/core/api/middleware/server_headers_middleware.py +0 -0
- {kiba_core-0.5.3.dev64 → kiba_core-0.5.3.dev66}/core/api/openapi.py +0 -0
- {kiba_core-0.5.3.dev64 → kiba_core-0.5.3.dev66}/core/api/rate_limit.py +0 -0
- {kiba_core-0.5.3.dev64 → kiba_core-0.5.3.dev66}/core/api/request_context.py +0 -0
- {kiba_core-0.5.3.dev64 → kiba_core-0.5.3.dev66}/core/api/route.py +0 -0
- {kiba_core-0.5.3.dev64 → kiba_core-0.5.3.dev66}/core/api/route_auth.py +0 -0
- {kiba_core-0.5.3.dev64 → kiba_core-0.5.3.dev66}/core/api/route_metadata.py +0 -0
- {kiba_core-0.5.3.dev64 → kiba_core-0.5.3.dev66}/core/api/streaming_json_route.py +0 -0
- {kiba_core-0.5.3.dev64 → kiba_core-0.5.3.dev66}/core/aws_requester.py +0 -0
- {kiba_core-0.5.3.dev64 → kiba_core-0.5.3.dev66}/core/caching/__init__.py +0 -0
- {kiba_core-0.5.3.dev64 → kiba_core-0.5.3.dev66}/core/caching/cache.py +0 -0
- {kiba_core-0.5.3.dev64 → kiba_core-0.5.3.dev66}/core/caching/dict_cache.py +0 -0
- {kiba_core-0.5.3.dev64 → kiba_core-0.5.3.dev66}/core/caching/file_cache.py +0 -0
- {kiba_core-0.5.3.dev64 → kiba_core-0.5.3.dev66}/core/exceptions.py +0 -0
- {kiba_core-0.5.3.dev64 → kiba_core-0.5.3.dev66}/core/http/__init__.py +0 -0
- {kiba_core-0.5.3.dev64 → kiba_core-0.5.3.dev66}/core/http/basic_authentication.py +0 -0
- {kiba_core-0.5.3.dev64 → kiba_core-0.5.3.dev66}/core/http/jwt.py +0 -0
- {kiba_core-0.5.3.dev64 → kiba_core-0.5.3.dev66}/core/http/rest_method.py +0 -0
- {kiba_core-0.5.3.dev64/core/notifications → kiba_core-0.5.3.dev66/core/locks}/__init__.py +0 -0
- {kiba_core-0.5.3.dev64 → kiba_core-0.5.3.dev66}/core/logging.py +0 -0
- {kiba_core-0.5.3.dev64/core/queues → kiba_core-0.5.3.dev66/core/notifications}/__init__.py +0 -0
- {kiba_core-0.5.3.dev64 → kiba_core-0.5.3.dev66}/core/notifications/discord_client.py +0 -0
- {kiba_core-0.5.3.dev64 → kiba_core-0.5.3.dev66}/core/notifications/notification_client.py +0 -0
- {kiba_core-0.5.3.dev64 → kiba_core-0.5.3.dev66}/core/notifications/slack_client.py +0 -0
- {kiba_core-0.5.3.dev64 → kiba_core-0.5.3.dev66}/core/py.typed +0 -0
- {kiba_core-0.5.3.dev64/core/store → kiba_core-0.5.3.dev66/core/queues}/__init__.py +0 -0
- {kiba_core-0.5.3.dev64 → kiba_core-0.5.3.dev66}/core/queues/model.py +0 -0
- {kiba_core-0.5.3.dev64 → kiba_core-0.5.3.dev66}/core/requester/__init__.py +0 -0
- {kiba_core-0.5.3.dev64 → kiba_core-0.5.3.dev66}/core/requester/requester.py +0 -0
- {kiba_core-0.5.3.dev64 → kiba_core-0.5.3.dev66}/core/s3_manager.py +0 -0
- {kiba_core-0.5.3.dev64 → kiba_core-0.5.3.dev66}/core/service_client.py +0 -0
- {kiba_core-0.5.3.dev64/core/util → kiba_core-0.5.3.dev66/core/store}/__init__.py +0 -0
- {kiba_core-0.5.3.dev64 → kiba_core-0.5.3.dev66}/core/store/retriever.py +0 -0
- {kiba_core-0.5.3.dev64 → kiba_core-0.5.3.dev66}/core/store/saver.py +0 -0
- {kiba_core-0.5.3.dev64/core/web3 → kiba_core-0.5.3.dev66/core/util}/__init__.py +0 -0
- {kiba_core-0.5.3.dev64 → kiba_core-0.5.3.dev66}/core/util/async_util.py +0 -0
- {kiba_core-0.5.3.dev64 → kiba_core-0.5.3.dev66}/core/util/chain_util.py +0 -0
- {kiba_core-0.5.3.dev64 → kiba_core-0.5.3.dev66}/core/util/date_util.py +0 -0
- {kiba_core-0.5.3.dev64 → kiba_core-0.5.3.dev66}/core/util/dict_util.py +0 -0
- {kiba_core-0.5.3.dev64 → kiba_core-0.5.3.dev66}/core/util/file_util.py +0 -0
- {kiba_core-0.5.3.dev64 → kiba_core-0.5.3.dev66}/core/util/hashing_util.py +0 -0
- {kiba_core-0.5.3.dev64 → kiba_core-0.5.3.dev66}/core/util/http_util.py +0 -0
- {kiba_core-0.5.3.dev64 → kiba_core-0.5.3.dev66}/core/util/json_util.py +0 -0
- {kiba_core-0.5.3.dev64 → kiba_core-0.5.3.dev66}/core/util/list_util.py +0 -0
- {kiba_core-0.5.3.dev64 → kiba_core-0.5.3.dev66}/core/util/string_util.py +0 -0
- {kiba_core-0.5.3.dev64 → kiba_core-0.5.3.dev66}/core/util/typing_util.py +0 -0
- {kiba_core-0.5.3.dev64 → kiba_core-0.5.3.dev66}/core/util/url_util.py +0 -0
- {kiba_core-0.5.3.dev64 → kiba_core-0.5.3.dev66}/core/util/value_holder.py +0 -0
- {kiba_core-0.5.3.dev64/tests/requester → kiba_core-0.5.3.dev66/core/web3}/__init__.py +0 -0
- {kiba_core-0.5.3.dev64 → kiba_core-0.5.3.dev66}/core/web3/eth_client.py +0 -0
- {kiba_core-0.5.3.dev64 → kiba_core-0.5.3.dev66}/core/web3/multicall3.py +0 -0
- {kiba_core-0.5.3.dev64 → kiba_core-0.5.3.dev66}/kiba_core.egg-info/dependency_links.txt +0 -0
- {kiba_core-0.5.3.dev64 → kiba_core-0.5.3.dev66}/kiba_core.egg-info/requires.txt +0 -0
- {kiba_core-0.5.3.dev64 → kiba_core-0.5.3.dev66}/kiba_core.egg-info/top_level.txt +0 -0
- {kiba_core-0.5.3.dev64 → kiba_core-0.5.3.dev66}/makefile +0 -0
- {kiba_core-0.5.3.dev64 → kiba_core-0.5.3.dev66}/setup.cfg +0 -0
- {kiba_core-0.5.3.dev64 → kiba_core-0.5.3.dev66}/tests/__init__.py +0 -0
- {kiba_core-0.5.3.dev64 → kiba_core-0.5.3.dev66}/tests/api/test_api_response.py +0 -0
- {kiba_core-0.5.3.dev64 → kiba_core-0.5.3.dev66}/tests/api/test_authorizer.py +0 -0
- {kiba_core-0.5.3.dev64 → kiba_core-0.5.3.dev66}/tests/api/test_exception_handling_middleware.py +0 -0
- {kiba_core-0.5.3.dev64 → kiba_core-0.5.3.dev66}/tests/api/test_json_route.py +0 -0
- {kiba_core-0.5.3.dev64 → kiba_core-0.5.3.dev66}/tests/api/test_openapi.py +0 -0
- {kiba_core-0.5.3.dev64 → kiba_core-0.5.3.dev66}/tests/api/test_origin_ip_middleware.py +0 -0
- {kiba_core-0.5.3.dev64 → kiba_core-0.5.3.dev66}/tests/api/test_rate_limit.py +0 -0
- {kiba_core-0.5.3.dev64 → kiba_core-0.5.3.dev66}/tests/api/test_request_context.py +0 -0
- {kiba_core-0.5.3.dev64 → kiba_core-0.5.3.dev66}/tests/api/test_route.py +0 -0
- {kiba_core-0.5.3.dev64 → kiba_core-0.5.3.dev66}/tests/api/test_streaming_json_route.py +0 -0
- {kiba_core-0.5.3.dev64 → kiba_core-0.5.3.dev66}/tests/caching/test_dict_cache.py +0 -0
- {kiba_core-0.5.3.dev64 → kiba_core-0.5.3.dev66}/tests/caching/test_file_cache.py +0 -0
- {kiba_core-0.5.3.dev64/tests/web3 → kiba_core-0.5.3.dev66/tests/requester}/__init__.py +0 -0
- {kiba_core-0.5.3.dev64 → kiba_core-0.5.3.dev66}/tests/requester/test_requester.py +0 -0
- {kiba_core-0.5.3.dev64 → kiba_core-0.5.3.dev66}/tests/test_exceptions.py +0 -0
- {kiba_core-0.5.3.dev64 → kiba_core-0.5.3.dev66}/tests/util/__init__.py +0 -0
- {kiba_core-0.5.3.dev64 → kiba_core-0.5.3.dev66}/tests/util/test_async_util.py +0 -0
- {kiba_core-0.5.3.dev64 → kiba_core-0.5.3.dev66}/tests/util/test_chain_util.py +0 -0
- {kiba_core-0.5.3.dev64 → kiba_core-0.5.3.dev66}/tests/util/test_date_util.py +0 -0
- {kiba_core-0.5.3.dev64 → kiba_core-0.5.3.dev66}/tests/util/test_dict_util.py +0 -0
- {kiba_core-0.5.3.dev64 → kiba_core-0.5.3.dev66}/tests/util/test_file_util.py +0 -0
- {kiba_core-0.5.3.dev64 → kiba_core-0.5.3.dev66}/tests/util/test_json_util.py +0 -0
- {kiba_core-0.5.3.dev64 → kiba_core-0.5.3.dev66}/tests/util/test_list_util.py +0 -0
- {kiba_core-0.5.3.dev64 → kiba_core-0.5.3.dev66}/tests/util/test_string_util.py +0 -0
- {kiba_core-0.5.3.dev64 → kiba_core-0.5.3.dev66}/tests/util/test_url_util.py +0 -0
- {kiba_core-0.5.3.dev64 → kiba_core-0.5.3.dev66}/tests/web3/test_rest_eth_client.py +0 -0
- {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
|
|
@@ -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
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
48
|
-
|
|
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.
|
|
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 =
|
|
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:
|