kiba-core 0.5.3.dev63__tar.gz → 0.5.3.dev65__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.dev63 → kiba_core-0.5.3.dev65}/.github/workflows/pull-request.yml +8 -1
- {kiba_core-0.5.3.dev63 → kiba_core-0.5.3.dev65}/CHANGELOG.md +5 -0
- {kiba_core-0.5.3.dev63 → kiba_core-0.5.3.dev65}/PKG-INFO +1 -1
- kiba_core-0.5.3.dev65/core/locks/lock.py +61 -0
- kiba_core-0.5.3.dev65/core/locks/model.py +15 -0
- kiba_core-0.5.3.dev65/core/locks/sql.py +96 -0
- {kiba_core-0.5.3.dev63 → kiba_core-0.5.3.dev65}/core/queues/aqs.py +9 -0
- {kiba_core-0.5.3.dev63 → kiba_core-0.5.3.dev65}/core/queues/cosmos.py +24 -0
- {kiba_core-0.5.3.dev63 → kiba_core-0.5.3.dev65}/core/queues/message_queue.py +8 -0
- {kiba_core-0.5.3.dev63 → kiba_core-0.5.3.dev65}/core/queues/sql.py +13 -3
- {kiba_core-0.5.3.dev63 → kiba_core-0.5.3.dev65}/core/queues/sqs.py +12 -0
- {kiba_core-0.5.3.dev63 → kiba_core-0.5.3.dev65}/core/store/database.py +24 -0
- kiba_core-0.5.3.dev65/core/util/process_util.py +28 -0
- {kiba_core-0.5.3.dev63 → kiba_core-0.5.3.dev65}/kiba_core.egg-info/PKG-INFO +1 -1
- {kiba_core-0.5.3.dev63 → kiba_core-0.5.3.dev65}/kiba_core.egg-info/SOURCES.txt +7 -0
- {kiba_core-0.5.3.dev63 → kiba_core-0.5.3.dev65}/pyproject.toml +1 -1
- kiba_core-0.5.3.dev65/tests/locks/test_sql_lock.py +256 -0
- kiba_core-0.5.3.dev65/tests/test_database.py +214 -0
- kiba_core-0.5.3.dev65/tests/util/test_process_util.py +35 -0
- kiba_core-0.5.3.dev65/tests/web3/__init__.py +0 -0
- kiba_core-0.5.3.dev63/tests/test_database.py +0 -115
- {kiba_core-0.5.3.dev63 → kiba_core-0.5.3.dev65}/.github/pull_request_template.md +0 -0
- {kiba_core-0.5.3.dev63 → kiba_core-0.5.3.dev65}/.github/workflows/deploy.yml +0 -0
- {kiba_core-0.5.3.dev63 → kiba_core-0.5.3.dev65}/.github/workflows/release.yml +0 -0
- {kiba_core-0.5.3.dev63 → kiba_core-0.5.3.dev65}/.gitignore +0 -0
- {kiba_core-0.5.3.dev63 → kiba_core-0.5.3.dev65}/Dockerfile +0 -0
- {kiba_core-0.5.3.dev63 → kiba_core-0.5.3.dev65}/README.md +0 -0
- {kiba_core-0.5.3.dev63 → kiba_core-0.5.3.dev65}/core/__init__.py +0 -0
- {kiba_core-0.5.3.dev63 → kiba_core-0.5.3.dev65}/core/api/__init__.py +0 -0
- {kiba_core-0.5.3.dev63 → kiba_core-0.5.3.dev65}/core/api/api_request.py +0 -0
- {kiba_core-0.5.3.dev63 → kiba_core-0.5.3.dev65}/core/api/api_response.py +0 -0
- {kiba_core-0.5.3.dev63 → kiba_core-0.5.3.dev65}/core/api/authorizer.py +0 -0
- {kiba_core-0.5.3.dev63 → kiba_core-0.5.3.dev65}/core/api/default_routes.py +0 -0
- {kiba_core-0.5.3.dev63 → kiba_core-0.5.3.dev65}/core/api/health.py +0 -0
- {kiba_core-0.5.3.dev63 → kiba_core-0.5.3.dev65}/core/api/json_route.py +0 -0
- {kiba_core-0.5.3.dev63 → kiba_core-0.5.3.dev65}/core/api/middleware/__init__.py +0 -0
- {kiba_core-0.5.3.dev63 → kiba_core-0.5.3.dev65}/core/api/middleware/database_connection_middleware.py +0 -0
- {kiba_core-0.5.3.dev63 → kiba_core-0.5.3.dev65}/core/api/middleware/exception_handling_middleware.py +0 -0
- {kiba_core-0.5.3.dev63 → kiba_core-0.5.3.dev65}/core/api/middleware/logging_middleware.py +0 -0
- {kiba_core-0.5.3.dev63 → kiba_core-0.5.3.dev65}/core/api/middleware/origin_ip_middleware.py +0 -0
- {kiba_core-0.5.3.dev63 → kiba_core-0.5.3.dev65}/core/api/middleware/server_headers_middleware.py +0 -0
- {kiba_core-0.5.3.dev63 → kiba_core-0.5.3.dev65}/core/api/openapi.py +0 -0
- {kiba_core-0.5.3.dev63 → kiba_core-0.5.3.dev65}/core/api/rate_limit.py +0 -0
- {kiba_core-0.5.3.dev63 → kiba_core-0.5.3.dev65}/core/api/request_context.py +0 -0
- {kiba_core-0.5.3.dev63 → kiba_core-0.5.3.dev65}/core/api/route.py +0 -0
- {kiba_core-0.5.3.dev63 → kiba_core-0.5.3.dev65}/core/api/route_auth.py +0 -0
- {kiba_core-0.5.3.dev63 → kiba_core-0.5.3.dev65}/core/api/route_metadata.py +0 -0
- {kiba_core-0.5.3.dev63 → kiba_core-0.5.3.dev65}/core/api/streaming_json_route.py +0 -0
- {kiba_core-0.5.3.dev63 → kiba_core-0.5.3.dev65}/core/aws_requester.py +0 -0
- {kiba_core-0.5.3.dev63 → kiba_core-0.5.3.dev65}/core/caching/__init__.py +0 -0
- {kiba_core-0.5.3.dev63 → kiba_core-0.5.3.dev65}/core/caching/cache.py +0 -0
- {kiba_core-0.5.3.dev63 → kiba_core-0.5.3.dev65}/core/caching/dict_cache.py +0 -0
- {kiba_core-0.5.3.dev63 → kiba_core-0.5.3.dev65}/core/caching/file_cache.py +0 -0
- {kiba_core-0.5.3.dev63 → kiba_core-0.5.3.dev65}/core/exceptions.py +0 -0
- {kiba_core-0.5.3.dev63 → kiba_core-0.5.3.dev65}/core/http/__init__.py +0 -0
- {kiba_core-0.5.3.dev63 → kiba_core-0.5.3.dev65}/core/http/basic_authentication.py +0 -0
- {kiba_core-0.5.3.dev63 → kiba_core-0.5.3.dev65}/core/http/jwt.py +0 -0
- {kiba_core-0.5.3.dev63 → kiba_core-0.5.3.dev65}/core/http/rest_method.py +0 -0
- {kiba_core-0.5.3.dev63/core/notifications → kiba_core-0.5.3.dev65/core/locks}/__init__.py +0 -0
- {kiba_core-0.5.3.dev63 → kiba_core-0.5.3.dev65}/core/logging.py +0 -0
- {kiba_core-0.5.3.dev63/core/queues → kiba_core-0.5.3.dev65/core/notifications}/__init__.py +0 -0
- {kiba_core-0.5.3.dev63 → kiba_core-0.5.3.dev65}/core/notifications/discord_client.py +0 -0
- {kiba_core-0.5.3.dev63 → kiba_core-0.5.3.dev65}/core/notifications/notification_client.py +0 -0
- {kiba_core-0.5.3.dev63 → kiba_core-0.5.3.dev65}/core/notifications/slack_client.py +0 -0
- {kiba_core-0.5.3.dev63 → kiba_core-0.5.3.dev65}/core/py.typed +0 -0
- {kiba_core-0.5.3.dev63/core/store → kiba_core-0.5.3.dev65/core/queues}/__init__.py +0 -0
- {kiba_core-0.5.3.dev63 → kiba_core-0.5.3.dev65}/core/queues/message_queue_processor.py +0 -0
- {kiba_core-0.5.3.dev63 → kiba_core-0.5.3.dev65}/core/queues/model.py +0 -0
- {kiba_core-0.5.3.dev63 → kiba_core-0.5.3.dev65}/core/requester/__init__.py +0 -0
- {kiba_core-0.5.3.dev63 → kiba_core-0.5.3.dev65}/core/requester/requester.py +0 -0
- {kiba_core-0.5.3.dev63 → kiba_core-0.5.3.dev65}/core/s3_manager.py +0 -0
- {kiba_core-0.5.3.dev63 → kiba_core-0.5.3.dev65}/core/service_client.py +0 -0
- {kiba_core-0.5.3.dev63/core/util → kiba_core-0.5.3.dev65/core/store}/__init__.py +0 -0
- {kiba_core-0.5.3.dev63 → kiba_core-0.5.3.dev65}/core/store/retriever.py +0 -0
- {kiba_core-0.5.3.dev63 → kiba_core-0.5.3.dev65}/core/store/saver.py +0 -0
- {kiba_core-0.5.3.dev63/core/web3 → kiba_core-0.5.3.dev65/core/util}/__init__.py +0 -0
- {kiba_core-0.5.3.dev63 → kiba_core-0.5.3.dev65}/core/util/async_util.py +0 -0
- {kiba_core-0.5.3.dev63 → kiba_core-0.5.3.dev65}/core/util/chain_util.py +0 -0
- {kiba_core-0.5.3.dev63 → kiba_core-0.5.3.dev65}/core/util/date_util.py +0 -0
- {kiba_core-0.5.3.dev63 → kiba_core-0.5.3.dev65}/core/util/dict_util.py +0 -0
- {kiba_core-0.5.3.dev63 → kiba_core-0.5.3.dev65}/core/util/file_util.py +0 -0
- {kiba_core-0.5.3.dev63 → kiba_core-0.5.3.dev65}/core/util/hashing_util.py +0 -0
- {kiba_core-0.5.3.dev63 → kiba_core-0.5.3.dev65}/core/util/http_util.py +0 -0
- {kiba_core-0.5.3.dev63 → kiba_core-0.5.3.dev65}/core/util/json_util.py +0 -0
- {kiba_core-0.5.3.dev63 → kiba_core-0.5.3.dev65}/core/util/list_util.py +0 -0
- {kiba_core-0.5.3.dev63 → kiba_core-0.5.3.dev65}/core/util/string_util.py +0 -0
- {kiba_core-0.5.3.dev63 → kiba_core-0.5.3.dev65}/core/util/typing_util.py +0 -0
- {kiba_core-0.5.3.dev63 → kiba_core-0.5.3.dev65}/core/util/url_util.py +0 -0
- {kiba_core-0.5.3.dev63 → kiba_core-0.5.3.dev65}/core/util/value_holder.py +0 -0
- {kiba_core-0.5.3.dev63/tests/requester → kiba_core-0.5.3.dev65/core/web3}/__init__.py +0 -0
- {kiba_core-0.5.3.dev63 → kiba_core-0.5.3.dev65}/core/web3/eth_client.py +0 -0
- {kiba_core-0.5.3.dev63 → kiba_core-0.5.3.dev65}/core/web3/multicall3.py +0 -0
- {kiba_core-0.5.3.dev63 → kiba_core-0.5.3.dev65}/kiba_core.egg-info/dependency_links.txt +0 -0
- {kiba_core-0.5.3.dev63 → kiba_core-0.5.3.dev65}/kiba_core.egg-info/requires.txt +0 -0
- {kiba_core-0.5.3.dev63 → kiba_core-0.5.3.dev65}/kiba_core.egg-info/top_level.txt +0 -0
- {kiba_core-0.5.3.dev63 → kiba_core-0.5.3.dev65}/makefile +0 -0
- {kiba_core-0.5.3.dev63 → kiba_core-0.5.3.dev65}/setup.cfg +0 -0
- {kiba_core-0.5.3.dev63 → kiba_core-0.5.3.dev65}/tests/__init__.py +0 -0
- {kiba_core-0.5.3.dev63 → kiba_core-0.5.3.dev65}/tests/api/test_api_response.py +0 -0
- {kiba_core-0.5.3.dev63 → kiba_core-0.5.3.dev65}/tests/api/test_authorizer.py +0 -0
- {kiba_core-0.5.3.dev63 → kiba_core-0.5.3.dev65}/tests/api/test_exception_handling_middleware.py +0 -0
- {kiba_core-0.5.3.dev63 → kiba_core-0.5.3.dev65}/tests/api/test_json_route.py +0 -0
- {kiba_core-0.5.3.dev63 → kiba_core-0.5.3.dev65}/tests/api/test_openapi.py +0 -0
- {kiba_core-0.5.3.dev63 → kiba_core-0.5.3.dev65}/tests/api/test_origin_ip_middleware.py +0 -0
- {kiba_core-0.5.3.dev63 → kiba_core-0.5.3.dev65}/tests/api/test_rate_limit.py +0 -0
- {kiba_core-0.5.3.dev63 → kiba_core-0.5.3.dev65}/tests/api/test_request_context.py +0 -0
- {kiba_core-0.5.3.dev63 → kiba_core-0.5.3.dev65}/tests/api/test_route.py +0 -0
- {kiba_core-0.5.3.dev63 → kiba_core-0.5.3.dev65}/tests/api/test_streaming_json_route.py +0 -0
- {kiba_core-0.5.3.dev63 → kiba_core-0.5.3.dev65}/tests/caching/test_dict_cache.py +0 -0
- {kiba_core-0.5.3.dev63 → kiba_core-0.5.3.dev65}/tests/caching/test_file_cache.py +0 -0
- {kiba_core-0.5.3.dev63/tests/web3 → kiba_core-0.5.3.dev65/tests/requester}/__init__.py +0 -0
- {kiba_core-0.5.3.dev63 → kiba_core-0.5.3.dev65}/tests/requester/test_requester.py +0 -0
- {kiba_core-0.5.3.dev63 → kiba_core-0.5.3.dev65}/tests/test_exceptions.py +0 -0
- {kiba_core-0.5.3.dev63 → kiba_core-0.5.3.dev65}/tests/util/__init__.py +0 -0
- {kiba_core-0.5.3.dev63 → kiba_core-0.5.3.dev65}/tests/util/test_async_util.py +0 -0
- {kiba_core-0.5.3.dev63 → kiba_core-0.5.3.dev65}/tests/util/test_chain_util.py +0 -0
- {kiba_core-0.5.3.dev63 → kiba_core-0.5.3.dev65}/tests/util/test_date_util.py +0 -0
- {kiba_core-0.5.3.dev63 → kiba_core-0.5.3.dev65}/tests/util/test_dict_util.py +0 -0
- {kiba_core-0.5.3.dev63 → kiba_core-0.5.3.dev65}/tests/util/test_file_util.py +0 -0
- {kiba_core-0.5.3.dev63 → kiba_core-0.5.3.dev65}/tests/util/test_json_util.py +0 -0
- {kiba_core-0.5.3.dev63 → kiba_core-0.5.3.dev65}/tests/util/test_list_util.py +0 -0
- {kiba_core-0.5.3.dev63 → kiba_core-0.5.3.dev65}/tests/util/test_string_util.py +0 -0
- {kiba_core-0.5.3.dev63 → kiba_core-0.5.3.dev65}/tests/util/test_url_util.py +0 -0
- {kiba_core-0.5.3.dev63 → kiba_core-0.5.3.dev65}/tests/web3/test_rest_eth_client.py +0 -0
- {kiba_core-0.5.3.dev63 → kiba_core-0.5.3.dev65}/uv.lock +0 -0
|
@@ -45,9 +45,16 @@ 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
|
|
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
|
+
# 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
|
+
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
|
+
docker exec postgres pg_isready --host 127.0.0.1 --username postgres
|
|
48
55
|
- name: Run tests
|
|
49
56
|
run: |
|
|
50
|
-
docker run --name tests $DOCKER_IMAGE make test-ci
|
|
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
|
|
51
58
|
docker cp tests:/app/test-check-results.json ./test-check-results.json
|
|
52
59
|
- name: Upload test results
|
|
53
60
|
uses: kibalabs/github-action-create-annotations@main
|
|
@@ -8,6 +8,11 @@ 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 `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
|
+
- [MINOR] Added `process_util.get_process_name` to identify the current process (`<containerName>:<containerId>` in docker, `<hostname>:<pid>` otherwise)
|
|
13
|
+
- [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
|
|
14
|
+
- [MAJOR] Added abstract `MessageQueue.get_message_count` (implemented for SQS, AQS, SQL and Cosmos queues) for the number of messages waiting to be processed
|
|
15
|
+
- [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
|
|
11
16
|
- [MAJOR] Reworked `route` to compose JSON or streaming transport, application-resolved authorization, and rate limiting
|
|
12
17
|
- [MAJOR] Replaced authorization-decorator sequences with named `RouteAuthResolver` policies
|
|
13
18
|
- [MINOR] Added `authJwt`, `authBasic`, and `originIp` to `KibaApiRequest`
|
|
@@ -0,0 +1,61 @@
|
|
|
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.locks.model import Lease
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class Lock(ABC):
|
|
12
|
+
@abc.abstractmethod
|
|
13
|
+
async def connect(self) -> None:
|
|
14
|
+
raise NotImplementedError
|
|
15
|
+
|
|
16
|
+
@abc.abstractmethod
|
|
17
|
+
async def disconnect(self) -> None:
|
|
18
|
+
raise NotImplementedError
|
|
19
|
+
|
|
20
|
+
@abc.abstractmethod
|
|
21
|
+
async def acquire(self, name: str, ttlSeconds: int = 60, maxWaitSeconds: float = 0) -> Lease:
|
|
22
|
+
raise NotImplementedError
|
|
23
|
+
|
|
24
|
+
@abc.abstractmethod
|
|
25
|
+
async def extend(self, lease: Lease, ttlSeconds: int = 60) -> bool:
|
|
26
|
+
raise NotImplementedError
|
|
27
|
+
|
|
28
|
+
@abc.abstractmethod
|
|
29
|
+
async def release(self, lease: Lease) -> None:
|
|
30
|
+
raise NotImplementedError
|
|
31
|
+
|
|
32
|
+
@abc.abstractmethod
|
|
33
|
+
async def get_owner(self, name: str) -> str | None:
|
|
34
|
+
raise NotImplementedError
|
|
35
|
+
|
|
36
|
+
async def _keep_alive(self, lease: Lease, ttlSeconds: int) -> None:
|
|
37
|
+
while True:
|
|
38
|
+
await asyncio.sleep(ttlSeconds / 3)
|
|
39
|
+
try:
|
|
40
|
+
isExtended = await self.extend(lease=lease, ttlSeconds=ttlSeconds)
|
|
41
|
+
except Exception as exception: # noqa: BLE001
|
|
42
|
+
# NOTE(krishan711): a transient failure must not kill the keep-alive; the lease simply lapses at expiryDate if it keeps failing
|
|
43
|
+
logging.error(f'Failed to extend lease {lease.name}:')
|
|
44
|
+
logging.exception(exception)
|
|
45
|
+
continue
|
|
46
|
+
if not isExtended:
|
|
47
|
+
lease.isLost = True
|
|
48
|
+
logging.error(f'Lost lease {lease.name}')
|
|
49
|
+
return
|
|
50
|
+
|
|
51
|
+
@contextlib.asynccontextmanager
|
|
52
|
+
async def with_lock(self, name: str, ttlSeconds: int = 60, maxWaitSeconds: float = 0) -> AsyncIterator[Lease]:
|
|
53
|
+
lease = await self.acquire(name=name, ttlSeconds=ttlSeconds, maxWaitSeconds=maxWaitSeconds)
|
|
54
|
+
keepAliveTask = asyncio.create_task(self._keep_alive(lease=lease, ttlSeconds=ttlSeconds))
|
|
55
|
+
try:
|
|
56
|
+
yield lease
|
|
57
|
+
finally:
|
|
58
|
+
keepAliveTask.cancel()
|
|
59
|
+
with contextlib.suppress(asyncio.CancelledError):
|
|
60
|
+
await keepAliveTask
|
|
61
|
+
await self.release(lease=lease)
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import datetime
|
|
2
|
+
|
|
3
|
+
from pydantic import BaseModel
|
|
4
|
+
|
|
5
|
+
from core.util import date_util
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class Lease(BaseModel):
|
|
9
|
+
name: str
|
|
10
|
+
token: str
|
|
11
|
+
expiryDate: datetime.datetime
|
|
12
|
+
isLost: bool = False
|
|
13
|
+
|
|
14
|
+
def is_valid(self) -> bool:
|
|
15
|
+
return not self.isLost and date_util.datetime_from_now() < self.expiryDate
|
|
@@ -0,0 +1,96 @@
|
|
|
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 Lease
|
|
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) -> Lease:
|
|
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 > date_util.datetime_from_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) -> 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}
|
|
74
|
+
async with self.database.create_transaction() as connection:
|
|
75
|
+
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)
|
|
77
|
+
)
|
|
78
|
+
result = await self.database.execute(query=upsertQuery, connection=connection)
|
|
79
|
+
isAcquired = result.first() is not None
|
|
80
|
+
return lease if isAcquired else None
|
|
81
|
+
|
|
82
|
+
async def extend(self, lease: Lease, ttlSeconds: int = 60) -> bool:
|
|
83
|
+
now = date_util.datetime_from_now()
|
|
84
|
+
newExpiryDate = date_util.datetime_from_now(seconds=ttlSeconds)
|
|
85
|
+
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
|
+
result = await self.database.execute(query=updateQuery, connection=connection)
|
|
88
|
+
isExtended = result.first() is not None
|
|
89
|
+
if isExtended:
|
|
90
|
+
lease.expiryDate = newExpiryDate
|
|
91
|
+
return isExtended
|
|
92
|
+
|
|
93
|
+
async def release(self, lease: Lease) -> None:
|
|
94
|
+
async with self.database.create_transaction() as connection:
|
|
95
|
+
deleteQuery = sqlalchemy.delete(self.table).where(self.table.c.name == lease.name).where(self.table.c.lockToken == lease.token)
|
|
96
|
+
await self.database.execute(query=deleteQuery, connection=connection)
|
|
@@ -80,3 +80,12 @@ class AqsMessageQueue(MessageQueue[AqsMessage]):
|
|
|
80
80
|
if not self._aqsClient:
|
|
81
81
|
raise InternalServerErrorException('You need to call .connect() before trying to delete messages')
|
|
82
82
|
await self._aqsClient.delete_message(message=message.aqsId, pop_receipt=message.popReceipt)
|
|
83
|
+
|
|
84
|
+
async def get_message_count(self) -> int:
|
|
85
|
+
if not self._aqsClient:
|
|
86
|
+
raise InternalServerErrorException('You need to call .connect() before trying to count messages')
|
|
87
|
+
properties = await self._aqsClient.get_queue_properties()
|
|
88
|
+
return int(properties.approximate_message_count or 0)
|
|
89
|
+
|
|
90
|
+
async def get_inflight_message_count(self) -> int:
|
|
91
|
+
raise NotImplementedError('Azure Storage Queues do not expose an in-flight message count')
|
|
@@ -188,3 +188,27 @@ class CosmosMessageQueue(MessageQueue[CosmosMessage]):
|
|
|
188
188
|
],
|
|
189
189
|
partition_key=self.queueName,
|
|
190
190
|
)
|
|
191
|
+
|
|
192
|
+
async def get_message_count(self) -> int:
|
|
193
|
+
counts = self.container.query_items(
|
|
194
|
+
query='SELECT COUNT(1) AS messageCount FROM c WHERE c.itemType = @itemType AND c.queueName = @queueName AND c.visibleDate <= @now',
|
|
195
|
+
parameters=[
|
|
196
|
+
{'name': '@itemType', 'value': 'message'},
|
|
197
|
+
{'name': '@queueName', 'value': self.queueName},
|
|
198
|
+
{'name': '@now', 'value': time.time()},
|
|
199
|
+
],
|
|
200
|
+
partition_key=self.queueName,
|
|
201
|
+
)
|
|
202
|
+
return sum([int(item['messageCount']) async for item in counts])
|
|
203
|
+
|
|
204
|
+
async def get_inflight_message_count(self) -> int:
|
|
205
|
+
counts = self.container.query_items(
|
|
206
|
+
query='SELECT COUNT(1) AS messageCount FROM c WHERE c.itemType = @itemType AND c.queueName = @queueName AND c.leaseId != null AND c.visibleDate > @now',
|
|
207
|
+
parameters=[
|
|
208
|
+
{'name': '@itemType', 'value': 'message'},
|
|
209
|
+
{'name': '@queueName', 'value': self.queueName},
|
|
210
|
+
{'name': '@now', 'value': time.time()},
|
|
211
|
+
],
|
|
212
|
+
partition_key=self.queueName,
|
|
213
|
+
)
|
|
214
|
+
return sum([int(item['messageCount']) async for item in counts])
|
|
@@ -33,3 +33,11 @@ class MessageQueue[MessageType: Message](ABC):
|
|
|
33
33
|
@abc.abstractmethod
|
|
34
34
|
async def delete_message(self, message: MessageType) -> None:
|
|
35
35
|
raise NotImplementedError
|
|
36
|
+
|
|
37
|
+
@abc.abstractmethod
|
|
38
|
+
async def get_message_count(self) -> int:
|
|
39
|
+
raise NotImplementedError
|
|
40
|
+
|
|
41
|
+
@abc.abstractmethod
|
|
42
|
+
async def get_inflight_message_count(self) -> int:
|
|
43
|
+
raise NotImplementedError
|
|
@@ -16,9 +16,9 @@ from core.queues.model import Message
|
|
|
16
16
|
from core.store.database import Database
|
|
17
17
|
from core.util import date_util
|
|
18
18
|
|
|
19
|
-
# NOTE: this table is owned here (not in the consuming app's schema.py) so it can ship with
|
|
20
|
-
# core-py. To include it in an app's own alembic-tracked metadata (so autogenerate creates the
|
|
21
|
-
#
|
|
19
|
+
# NOTE(krishan711): this table is owned here (not in the consuming app's schema.py) so it can ship with
|
|
20
|
+
# core-py. To include it in an app's own alembic-tracked metadata (so autogenerate creates the table),
|
|
21
|
+
# call `QueueMessagesTable.to_metadata(appMetadata)` from the app's schema module.
|
|
22
22
|
QueueMessagesMetadata = sqlalchemy.MetaData()
|
|
23
23
|
|
|
24
24
|
QueueMessagesTable = sqlalchemy.Table(
|
|
@@ -163,3 +163,13 @@ class SqlMessageQueue(MessageQueue[SqlMessage]):
|
|
|
163
163
|
async with self.database.create_transaction() as connection:
|
|
164
164
|
deleteQuery = sqlalchemy.delete(self.table).where(self.table.c.id == message.id).where(self.table.c.lockToken == message.lockToken)
|
|
165
165
|
await self.database.execute(query=deleteQuery, connection=connection) # type: ignore[arg-type]
|
|
166
|
+
|
|
167
|
+
async def get_message_count(self) -> int:
|
|
168
|
+
countQuery = sqlalchemy.select(sqlalchemy.func.count()).select_from(self.table).where(self.table.c.queueName == self.queueName).where(self.table.c.visibleDate <= date_util.datetime_from_now())
|
|
169
|
+
result = await self.database.execute(query=countQuery)
|
|
170
|
+
return int(result.scalar_one())
|
|
171
|
+
|
|
172
|
+
async def get_inflight_message_count(self) -> int:
|
|
173
|
+
countQuery = sqlalchemy.select(sqlalchemy.func.count()).select_from(self.table).where(self.table.c.queueName == self.queueName).where(self.table.c.lockToken.is_not(None)).where(self.table.c.visibleDate > date_util.datetime_from_now())
|
|
174
|
+
result = await self.database.execute(query=countQuery)
|
|
175
|
+
return int(result.scalar_one())
|
|
@@ -96,3 +96,15 @@ class SqsMessageQueue(MessageQueue[SqsMessage]):
|
|
|
96
96
|
if not self._sqsClient:
|
|
97
97
|
raise InternalServerErrorException('You need to call .connect() before trying to delete messages')
|
|
98
98
|
await self._sqsClient.delete_message(QueueUrl=self.queueUrl, ReceiptHandle=message.receiptHandle)
|
|
99
|
+
|
|
100
|
+
async def get_message_count(self) -> int:
|
|
101
|
+
if not self._sqsClient:
|
|
102
|
+
raise InternalServerErrorException('You need to call .connect() before trying to count messages')
|
|
103
|
+
response = await self._sqsClient.get_queue_attributes(QueueUrl=self.queueUrl, AttributeNames=['ApproximateNumberOfMessages'])
|
|
104
|
+
return int(response['Attributes']['ApproximateNumberOfMessages'])
|
|
105
|
+
|
|
106
|
+
async def get_inflight_message_count(self) -> int:
|
|
107
|
+
if not self._sqsClient:
|
|
108
|
+
raise InternalServerErrorException('You need to call .connect() before trying to count messages')
|
|
109
|
+
response = await self._sqsClient.get_queue_attributes(QueueUrl=self.queueUrl, AttributeNames=['ApproximateNumberOfMessagesNotVisible'])
|
|
110
|
+
return int(response['Attributes']['ApproximateNumberOfMessagesNotVisible'])
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import contextlib
|
|
2
2
|
import contextvars
|
|
3
3
|
import typing
|
|
4
|
+
import weakref
|
|
4
5
|
from collections.abc import AsyncIterator
|
|
5
6
|
from typing import TypeVar
|
|
6
7
|
|
|
@@ -10,6 +11,7 @@ from sqlalchemy.engine import Result
|
|
|
10
11
|
from sqlalchemy.ext.asyncio import AsyncConnection
|
|
11
12
|
from sqlalchemy.ext.asyncio import AsyncEngine
|
|
12
13
|
from sqlalchemy.ext.asyncio import create_async_engine
|
|
14
|
+
from sqlalchemy.sql.dml import UpdateBase
|
|
13
15
|
from sqlalchemy.sql.selectable import TypedReturnsRows
|
|
14
16
|
|
|
15
17
|
from core import logging
|
|
@@ -37,6 +39,7 @@ class Database:
|
|
|
37
39
|
self.connectionString = connectionString
|
|
38
40
|
self._engine: AsyncEngine | None = None
|
|
39
41
|
self._connectionContext = contextvars.ContextVar[DatabaseConnection | None]('_connectionContext')
|
|
42
|
+
self._connectionsWithWrites = weakref.WeakSet[DatabaseConnection]()
|
|
40
43
|
|
|
41
44
|
async def connect(self, poolSize: int = 100) -> None:
|
|
42
45
|
if not self._engine:
|
|
@@ -99,6 +102,25 @@ class Database:
|
|
|
99
102
|
await self.disconnect()
|
|
100
103
|
await self.connect()
|
|
101
104
|
|
|
105
|
+
# NOTE(krishan711): unlike create_context_connection this may be used inside an existing context connection.
|
|
106
|
+
# Everything in the block runs on a new transaction that commits when the block exits, independent of the
|
|
107
|
+
# outer transaction, which becomes the context connection again afterwards. It cannot see the outer
|
|
108
|
+
# transaction's uncommitted writes and will block forever on rows the outer transaction has written, so
|
|
109
|
+
# callers must commit their writes before entering. Violations are logged (with the caller's stack) for now.
|
|
110
|
+
@contextlib.asynccontextmanager
|
|
111
|
+
async def create_isolated_context_connection(self) -> AsyncIterator[DatabaseConnection]:
|
|
112
|
+
if not self._engine:
|
|
113
|
+
raise InternalServerErrorException(message='Engine has not been established. Please called collect() first.')
|
|
114
|
+
outerConnection = self._get_context_connection()
|
|
115
|
+
if outerConnection is not None and outerConnection in self._connectionsWithWrites:
|
|
116
|
+
logging.error('ISOLATED_CONNECTION_AFTER_UNCOMMITTED_WRITES: an isolated context connection was opened while the outer context connection has uncommitted writes', stack_info=True)
|
|
117
|
+
async with self._engine.begin() as connection:
|
|
118
|
+
token = self._connectionContext.set(connection)
|
|
119
|
+
try:
|
|
120
|
+
yield connection
|
|
121
|
+
finally:
|
|
122
|
+
self._connectionContext.reset(token)
|
|
123
|
+
|
|
102
124
|
@typing.overload
|
|
103
125
|
async def execute(self, query: TypedReturnsRows[ResultType], connection: DatabaseConnection | None = None) -> Result[ResultType]: ...
|
|
104
126
|
@typing.overload
|
|
@@ -110,4 +132,6 @@ class Database:
|
|
|
110
132
|
connection = self._get_context_connection()
|
|
111
133
|
if not connection:
|
|
112
134
|
raise InternalServerErrorException(message='No connection found. Please provide a connection or call create_context_connection() for the context.')
|
|
135
|
+
if isinstance(query, UpdateBase):
|
|
136
|
+
self._connectionsWithWrites.add(connection)
|
|
113
137
|
return typing.cast(Result[typing.Any], await connection.execute(statement=query)) # type: ignore[explicit-any]
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import re
|
|
3
|
+
import socket
|
|
4
|
+
|
|
5
|
+
DOCKERENV_PATH = '/.dockerenv'
|
|
6
|
+
MOUNTINFO_PATH = '/proc/self/mountinfo'
|
|
7
|
+
_DOCKER_CONTAINER_ID_PATTERN = re.compile(r'/containers/([0-9a-f]{64})/')
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def is_running_in_docker() -> bool:
|
|
11
|
+
return os.path.exists(DOCKERENV_PATH)
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def get_docker_container_id() -> str:
|
|
15
|
+
try:
|
|
16
|
+
with open(MOUNTINFO_PATH, encoding='utf-8') as mountInfoFile:
|
|
17
|
+
match = _DOCKER_CONTAINER_ID_PATTERN.search(mountInfoFile.read())
|
|
18
|
+
except OSError:
|
|
19
|
+
match = None
|
|
20
|
+
# NOTE(krishan711): docker sets the hostname to the short container id unless --hostname is passed
|
|
21
|
+
return match.group(1)[:12] if match else socket.gethostname()
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def get_process_name(containerName: str | None = None) -> str:
|
|
25
|
+
if is_running_in_docker():
|
|
26
|
+
containerId = get_docker_container_id()
|
|
27
|
+
return f'{containerName}:{containerId}' if containerName else containerId
|
|
28
|
+
return f'{socket.gethostname()}:{os.getpid()}'
|
|
@@ -44,6 +44,10 @@ core/http/__init__.py
|
|
|
44
44
|
core/http/basic_authentication.py
|
|
45
45
|
core/http/jwt.py
|
|
46
46
|
core/http/rest_method.py
|
|
47
|
+
core/locks/__init__.py
|
|
48
|
+
core/locks/lock.py
|
|
49
|
+
core/locks/model.py
|
|
50
|
+
core/locks/sql.py
|
|
47
51
|
core/notifications/__init__.py
|
|
48
52
|
core/notifications/discord_client.py
|
|
49
53
|
core/notifications/notification_client.py
|
|
@@ -72,6 +76,7 @@ core/util/hashing_util.py
|
|
|
72
76
|
core/util/http_util.py
|
|
73
77
|
core/util/json_util.py
|
|
74
78
|
core/util/list_util.py
|
|
79
|
+
core/util/process_util.py
|
|
75
80
|
core/util/string_util.py
|
|
76
81
|
core/util/typing_util.py
|
|
77
82
|
core/util/url_util.py
|
|
@@ -99,6 +104,7 @@ tests/api/test_route.py
|
|
|
99
104
|
tests/api/test_streaming_json_route.py
|
|
100
105
|
tests/caching/test_dict_cache.py
|
|
101
106
|
tests/caching/test_file_cache.py
|
|
107
|
+
tests/locks/test_sql_lock.py
|
|
102
108
|
tests/requester/__init__.py
|
|
103
109
|
tests/requester/test_requester.py
|
|
104
110
|
tests/util/__init__.py
|
|
@@ -109,6 +115,7 @@ tests/util/test_dict_util.py
|
|
|
109
115
|
tests/util/test_file_util.py
|
|
110
116
|
tests/util/test_json_util.py
|
|
111
117
|
tests/util/test_list_util.py
|
|
118
|
+
tests/util/test_process_util.py
|
|
112
119
|
tests/util/test_string_util.py
|
|
113
120
|
tests/util/test_url_util.py
|
|
114
121
|
tests/web3/__init__.py
|