kiba-core 0.5.3.dev64__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.dev64 → kiba_core-0.5.3.dev65}/.github/workflows/pull-request.yml +8 -1
- {kiba_core-0.5.3.dev64 → kiba_core-0.5.3.dev65}/CHANGELOG.md +3 -0
- {kiba_core-0.5.3.dev64 → 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.dev64 → kiba_core-0.5.3.dev65}/core/queues/sql.py +3 -3
- {kiba_core-0.5.3.dev64 → 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.dev64 → kiba_core-0.5.3.dev65}/kiba_core.egg-info/PKG-INFO +1 -1
- {kiba_core-0.5.3.dev64 → kiba_core-0.5.3.dev65}/kiba_core.egg-info/SOURCES.txt +7 -0
- {kiba_core-0.5.3.dev64 → 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.dev64/tests/test_database.py +0 -115
- {kiba_core-0.5.3.dev64 → kiba_core-0.5.3.dev65}/.github/pull_request_template.md +0 -0
- {kiba_core-0.5.3.dev64 → kiba_core-0.5.3.dev65}/.github/workflows/deploy.yml +0 -0
- {kiba_core-0.5.3.dev64 → kiba_core-0.5.3.dev65}/.github/workflows/release.yml +0 -0
- {kiba_core-0.5.3.dev64 → kiba_core-0.5.3.dev65}/.gitignore +0 -0
- {kiba_core-0.5.3.dev64 → kiba_core-0.5.3.dev65}/Dockerfile +0 -0
- {kiba_core-0.5.3.dev64 → kiba_core-0.5.3.dev65}/README.md +0 -0
- {kiba_core-0.5.3.dev64 → kiba_core-0.5.3.dev65}/core/__init__.py +0 -0
- {kiba_core-0.5.3.dev64 → kiba_core-0.5.3.dev65}/core/api/__init__.py +0 -0
- {kiba_core-0.5.3.dev64 → kiba_core-0.5.3.dev65}/core/api/api_request.py +0 -0
- {kiba_core-0.5.3.dev64 → kiba_core-0.5.3.dev65}/core/api/api_response.py +0 -0
- {kiba_core-0.5.3.dev64 → kiba_core-0.5.3.dev65}/core/api/authorizer.py +0 -0
- {kiba_core-0.5.3.dev64 → kiba_core-0.5.3.dev65}/core/api/default_routes.py +0 -0
- {kiba_core-0.5.3.dev64 → kiba_core-0.5.3.dev65}/core/api/health.py +0 -0
- {kiba_core-0.5.3.dev64 → kiba_core-0.5.3.dev65}/core/api/json_route.py +0 -0
- {kiba_core-0.5.3.dev64 → kiba_core-0.5.3.dev65}/core/api/middleware/__init__.py +0 -0
- {kiba_core-0.5.3.dev64 → kiba_core-0.5.3.dev65}/core/api/middleware/database_connection_middleware.py +0 -0
- {kiba_core-0.5.3.dev64 → kiba_core-0.5.3.dev65}/core/api/middleware/exception_handling_middleware.py +0 -0
- {kiba_core-0.5.3.dev64 → kiba_core-0.5.3.dev65}/core/api/middleware/logging_middleware.py +0 -0
- {kiba_core-0.5.3.dev64 → kiba_core-0.5.3.dev65}/core/api/middleware/origin_ip_middleware.py +0 -0
- {kiba_core-0.5.3.dev64 → kiba_core-0.5.3.dev65}/core/api/middleware/server_headers_middleware.py +0 -0
- {kiba_core-0.5.3.dev64 → kiba_core-0.5.3.dev65}/core/api/openapi.py +0 -0
- {kiba_core-0.5.3.dev64 → kiba_core-0.5.3.dev65}/core/api/rate_limit.py +0 -0
- {kiba_core-0.5.3.dev64 → kiba_core-0.5.3.dev65}/core/api/request_context.py +0 -0
- {kiba_core-0.5.3.dev64 → kiba_core-0.5.3.dev65}/core/api/route.py +0 -0
- {kiba_core-0.5.3.dev64 → kiba_core-0.5.3.dev65}/core/api/route_auth.py +0 -0
- {kiba_core-0.5.3.dev64 → kiba_core-0.5.3.dev65}/core/api/route_metadata.py +0 -0
- {kiba_core-0.5.3.dev64 → kiba_core-0.5.3.dev65}/core/api/streaming_json_route.py +0 -0
- {kiba_core-0.5.3.dev64 → kiba_core-0.5.3.dev65}/core/aws_requester.py +0 -0
- {kiba_core-0.5.3.dev64 → kiba_core-0.5.3.dev65}/core/caching/__init__.py +0 -0
- {kiba_core-0.5.3.dev64 → kiba_core-0.5.3.dev65}/core/caching/cache.py +0 -0
- {kiba_core-0.5.3.dev64 → kiba_core-0.5.3.dev65}/core/caching/dict_cache.py +0 -0
- {kiba_core-0.5.3.dev64 → kiba_core-0.5.3.dev65}/core/caching/file_cache.py +0 -0
- {kiba_core-0.5.3.dev64 → kiba_core-0.5.3.dev65}/core/exceptions.py +0 -0
- {kiba_core-0.5.3.dev64 → kiba_core-0.5.3.dev65}/core/http/__init__.py +0 -0
- {kiba_core-0.5.3.dev64 → kiba_core-0.5.3.dev65}/core/http/basic_authentication.py +0 -0
- {kiba_core-0.5.3.dev64 → kiba_core-0.5.3.dev65}/core/http/jwt.py +0 -0
- {kiba_core-0.5.3.dev64 → kiba_core-0.5.3.dev65}/core/http/rest_method.py +0 -0
- {kiba_core-0.5.3.dev64/core/notifications → kiba_core-0.5.3.dev65/core/locks}/__init__.py +0 -0
- {kiba_core-0.5.3.dev64 → kiba_core-0.5.3.dev65}/core/logging.py +0 -0
- {kiba_core-0.5.3.dev64/core/queues → kiba_core-0.5.3.dev65/core/notifications}/__init__.py +0 -0
- {kiba_core-0.5.3.dev64 → kiba_core-0.5.3.dev65}/core/notifications/discord_client.py +0 -0
- {kiba_core-0.5.3.dev64 → kiba_core-0.5.3.dev65}/core/notifications/notification_client.py +0 -0
- {kiba_core-0.5.3.dev64 → kiba_core-0.5.3.dev65}/core/notifications/slack_client.py +0 -0
- {kiba_core-0.5.3.dev64 → kiba_core-0.5.3.dev65}/core/py.typed +0 -0
- {kiba_core-0.5.3.dev64/core/store → kiba_core-0.5.3.dev65/core/queues}/__init__.py +0 -0
- {kiba_core-0.5.3.dev64 → kiba_core-0.5.3.dev65}/core/queues/aqs.py +0 -0
- {kiba_core-0.5.3.dev64 → kiba_core-0.5.3.dev65}/core/queues/cosmos.py +0 -0
- {kiba_core-0.5.3.dev64 → kiba_core-0.5.3.dev65}/core/queues/message_queue.py +0 -0
- {kiba_core-0.5.3.dev64 → kiba_core-0.5.3.dev65}/core/queues/message_queue_processor.py +0 -0
- {kiba_core-0.5.3.dev64 → kiba_core-0.5.3.dev65}/core/queues/model.py +0 -0
- {kiba_core-0.5.3.dev64 → kiba_core-0.5.3.dev65}/core/queues/sqs.py +0 -0
- {kiba_core-0.5.3.dev64 → kiba_core-0.5.3.dev65}/core/requester/__init__.py +0 -0
- {kiba_core-0.5.3.dev64 → kiba_core-0.5.3.dev65}/core/requester/requester.py +0 -0
- {kiba_core-0.5.3.dev64 → kiba_core-0.5.3.dev65}/core/s3_manager.py +0 -0
- {kiba_core-0.5.3.dev64 → kiba_core-0.5.3.dev65}/core/service_client.py +0 -0
- {kiba_core-0.5.3.dev64/core/util → kiba_core-0.5.3.dev65/core/store}/__init__.py +0 -0
- {kiba_core-0.5.3.dev64 → kiba_core-0.5.3.dev65}/core/store/retriever.py +0 -0
- {kiba_core-0.5.3.dev64 → kiba_core-0.5.3.dev65}/core/store/saver.py +0 -0
- {kiba_core-0.5.3.dev64/core/web3 → kiba_core-0.5.3.dev65/core/util}/__init__.py +0 -0
- {kiba_core-0.5.3.dev64 → kiba_core-0.5.3.dev65}/core/util/async_util.py +0 -0
- {kiba_core-0.5.3.dev64 → kiba_core-0.5.3.dev65}/core/util/chain_util.py +0 -0
- {kiba_core-0.5.3.dev64 → kiba_core-0.5.3.dev65}/core/util/date_util.py +0 -0
- {kiba_core-0.5.3.dev64 → kiba_core-0.5.3.dev65}/core/util/dict_util.py +0 -0
- {kiba_core-0.5.3.dev64 → kiba_core-0.5.3.dev65}/core/util/file_util.py +0 -0
- {kiba_core-0.5.3.dev64 → kiba_core-0.5.3.dev65}/core/util/hashing_util.py +0 -0
- {kiba_core-0.5.3.dev64 → kiba_core-0.5.3.dev65}/core/util/http_util.py +0 -0
- {kiba_core-0.5.3.dev64 → kiba_core-0.5.3.dev65}/core/util/json_util.py +0 -0
- {kiba_core-0.5.3.dev64 → kiba_core-0.5.3.dev65}/core/util/list_util.py +0 -0
- {kiba_core-0.5.3.dev64 → kiba_core-0.5.3.dev65}/core/util/string_util.py +0 -0
- {kiba_core-0.5.3.dev64 → kiba_core-0.5.3.dev65}/core/util/typing_util.py +0 -0
- {kiba_core-0.5.3.dev64 → kiba_core-0.5.3.dev65}/core/util/url_util.py +0 -0
- {kiba_core-0.5.3.dev64 → kiba_core-0.5.3.dev65}/core/util/value_holder.py +0 -0
- {kiba_core-0.5.3.dev64/tests/requester → kiba_core-0.5.3.dev65/core/web3}/__init__.py +0 -0
- {kiba_core-0.5.3.dev64 → kiba_core-0.5.3.dev65}/core/web3/eth_client.py +0 -0
- {kiba_core-0.5.3.dev64 → kiba_core-0.5.3.dev65}/core/web3/multicall3.py +0 -0
- {kiba_core-0.5.3.dev64 → kiba_core-0.5.3.dev65}/kiba_core.egg-info/dependency_links.txt +0 -0
- {kiba_core-0.5.3.dev64 → kiba_core-0.5.3.dev65}/kiba_core.egg-info/requires.txt +0 -0
- {kiba_core-0.5.3.dev64 → kiba_core-0.5.3.dev65}/kiba_core.egg-info/top_level.txt +0 -0
- {kiba_core-0.5.3.dev64 → kiba_core-0.5.3.dev65}/makefile +0 -0
- {kiba_core-0.5.3.dev64 → kiba_core-0.5.3.dev65}/setup.cfg +0 -0
- {kiba_core-0.5.3.dev64 → kiba_core-0.5.3.dev65}/tests/__init__.py +0 -0
- {kiba_core-0.5.3.dev64 → kiba_core-0.5.3.dev65}/tests/api/test_api_response.py +0 -0
- {kiba_core-0.5.3.dev64 → kiba_core-0.5.3.dev65}/tests/api/test_authorizer.py +0 -0
- {kiba_core-0.5.3.dev64 → kiba_core-0.5.3.dev65}/tests/api/test_exception_handling_middleware.py +0 -0
- {kiba_core-0.5.3.dev64 → kiba_core-0.5.3.dev65}/tests/api/test_json_route.py +0 -0
- {kiba_core-0.5.3.dev64 → kiba_core-0.5.3.dev65}/tests/api/test_openapi.py +0 -0
- {kiba_core-0.5.3.dev64 → kiba_core-0.5.3.dev65}/tests/api/test_origin_ip_middleware.py +0 -0
- {kiba_core-0.5.3.dev64 → kiba_core-0.5.3.dev65}/tests/api/test_rate_limit.py +0 -0
- {kiba_core-0.5.3.dev64 → kiba_core-0.5.3.dev65}/tests/api/test_request_context.py +0 -0
- {kiba_core-0.5.3.dev64 → kiba_core-0.5.3.dev65}/tests/api/test_route.py +0 -0
- {kiba_core-0.5.3.dev64 → kiba_core-0.5.3.dev65}/tests/api/test_streaming_json_route.py +0 -0
- {kiba_core-0.5.3.dev64 → kiba_core-0.5.3.dev65}/tests/caching/test_dict_cache.py +0 -0
- {kiba_core-0.5.3.dev64 → kiba_core-0.5.3.dev65}/tests/caching/test_file_cache.py +0 -0
- {kiba_core-0.5.3.dev64/tests/web3 → kiba_core-0.5.3.dev65/tests/requester}/__init__.py +0 -0
- {kiba_core-0.5.3.dev64 → kiba_core-0.5.3.dev65}/tests/requester/test_requester.py +0 -0
- {kiba_core-0.5.3.dev64 → kiba_core-0.5.3.dev65}/tests/test_exceptions.py +0 -0
- {kiba_core-0.5.3.dev64 → kiba_core-0.5.3.dev65}/tests/util/__init__.py +0 -0
- {kiba_core-0.5.3.dev64 → kiba_core-0.5.3.dev65}/tests/util/test_async_util.py +0 -0
- {kiba_core-0.5.3.dev64 → kiba_core-0.5.3.dev65}/tests/util/test_chain_util.py +0 -0
- {kiba_core-0.5.3.dev64 → kiba_core-0.5.3.dev65}/tests/util/test_date_util.py +0 -0
- {kiba_core-0.5.3.dev64 → kiba_core-0.5.3.dev65}/tests/util/test_dict_util.py +0 -0
- {kiba_core-0.5.3.dev64 → kiba_core-0.5.3.dev65}/tests/util/test_file_util.py +0 -0
- {kiba_core-0.5.3.dev64 → kiba_core-0.5.3.dev65}/tests/util/test_json_util.py +0 -0
- {kiba_core-0.5.3.dev64 → kiba_core-0.5.3.dev65}/tests/util/test_list_util.py +0 -0
- {kiba_core-0.5.3.dev64 → kiba_core-0.5.3.dev65}/tests/util/test_string_util.py +0 -0
- {kiba_core-0.5.3.dev64 → kiba_core-0.5.3.dev65}/tests/util/test_url_util.py +0 -0
- {kiba_core-0.5.3.dev64 → kiba_core-0.5.3.dev65}/tests/web3/test_rest_eth_client.py +0 -0
- {kiba_core-0.5.3.dev64 → 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,9 @@ 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
|
|
11
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
|
|
12
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
|
|
13
16
|
- [MAJOR] Reworked `route` to compose JSON or streaming transport, application-resolved authorization, and rate limiting
|
|
@@ -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)
|
|
@@ -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(
|
|
@@ -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
|
|
@@ -0,0 +1,256 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
import os
|
|
3
|
+
import time
|
|
4
|
+
from collections.abc import AsyncIterator
|
|
5
|
+
|
|
6
|
+
import pytest
|
|
7
|
+
import pytest_asyncio
|
|
8
|
+
import sqlalchemy
|
|
9
|
+
|
|
10
|
+
from core.exceptions import LockedException
|
|
11
|
+
from core.locks.model import Lease
|
|
12
|
+
from core.locks.sql import LocksMetadata
|
|
13
|
+
from core.locks.sql import SqlLock
|
|
14
|
+
from core.store.database import Database
|
|
15
|
+
from core.util import date_util
|
|
16
|
+
|
|
17
|
+
PSQL_CONNECTION_STRING = os.environ.get('CORE_TEST_PSQL_CONNECTION_STRING')
|
|
18
|
+
|
|
19
|
+
pytestmark = pytest.mark.asyncio
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
# NOTE: postgres only runs when a connection string is given; skipping it instead breaks kiba-build's test-check report parsing
|
|
23
|
+
@pytest_asyncio.fixture(params=['sqlite', 'postgresql'] if PSQL_CONNECTION_STRING else ['sqlite'])
|
|
24
|
+
async def connectionString(request, tmp_path) -> str:
|
|
25
|
+
connectionString = PSQL_CONNECTION_STRING if request.param == 'postgresql' else Database.create_sqlite_connection_string(str(tmp_path / 'database.sqlite'))
|
|
26
|
+
database = Database(connectionString=connectionString)
|
|
27
|
+
await database.connect(poolSize=1)
|
|
28
|
+
async with database.create_transaction() as connection:
|
|
29
|
+
await connection.run_sync(LocksMetadata.drop_all)
|
|
30
|
+
await connection.run_sync(LocksMetadata.create_all)
|
|
31
|
+
await database.disconnect()
|
|
32
|
+
return connectionString
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@pytest_asyncio.fixture
|
|
36
|
+
async def workers(connectionString: str) -> AsyncIterator[tuple[SqlLock, SqlLock]]:
|
|
37
|
+
# NOTE: each worker has its own engine so they behave like separate processes
|
|
38
|
+
databases = [Database(connectionString=connectionString) for _ in range(2)]
|
|
39
|
+
for database in databases:
|
|
40
|
+
await database.connect(poolSize=20)
|
|
41
|
+
yield SqlLock(database=databases[0], pollIntervalSeconds=0.05, owner='worker-a'), SqlLock(database=databases[1], pollIntervalSeconds=0.05, owner='worker-b')
|
|
42
|
+
for database in databases:
|
|
43
|
+
await database.disconnect()
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
async def _expire_lock_row(lock: SqlLock, name: str) -> None:
|
|
47
|
+
async with lock.database.create_transaction() as connection:
|
|
48
|
+
await lock.database.execute(query=sqlalchemy.update(lock.table).where(lock.table.c.name == name).values(expiryDate=date_util.datetime_from_now(seconds=-1)), connection=connection)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
async def _count_acquired(results: list[Lease | BaseException]) -> int:
|
|
52
|
+
for result in results:
|
|
53
|
+
if isinstance(result, BaseException) and not isinstance(result, LockedException):
|
|
54
|
+
raise result
|
|
55
|
+
return sum(1 for result in results if isinstance(result, Lease))
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
async def test_owner_tracks_the_current_holder(workers: tuple[SqlLock, SqlLock]):
|
|
59
|
+
workerA, workerB = workers
|
|
60
|
+
assert await workerB.get_owner(name='job') is None
|
|
61
|
+
lease = await workerA.acquire(name='job')
|
|
62
|
+
assert await workerB.get_owner(name='job') == 'worker-a'
|
|
63
|
+
await workerA.release(lease=lease)
|
|
64
|
+
assert await workerB.get_owner(name='job') is None
|
|
65
|
+
await workerA.acquire(name='job', ttlSeconds=0)
|
|
66
|
+
assert await workerB.get_owner(name='job') is None
|
|
67
|
+
await workerB.acquire(name='job')
|
|
68
|
+
assert await workerA.get_owner(name='job') == 'worker-b'
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
async def test_locked_exception_names_the_holder(workers: tuple[SqlLock, SqlLock]):
|
|
72
|
+
workerA, workerB = workers
|
|
73
|
+
await workerA.acquire(name='job')
|
|
74
|
+
with pytest.raises(LockedException, match='LOCK_HELD: job by worker-a'):
|
|
75
|
+
await workerB.acquire(name='job')
|
|
76
|
+
with pytest.raises(LockedException, match='LOCK_HELD: job by worker-a'):
|
|
77
|
+
async with workerB.with_lock(name='job'):
|
|
78
|
+
pass
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
async def test_acquire_is_exclusive_per_name_across_workers(workers: tuple[SqlLock, SqlLock]):
|
|
82
|
+
workerA, workerB = workers
|
|
83
|
+
await workerA.acquire(name='job')
|
|
84
|
+
with pytest.raises(LockedException):
|
|
85
|
+
await workerB.acquire(name='job')
|
|
86
|
+
with pytest.raises(LockedException):
|
|
87
|
+
await workerA.acquire(name='job')
|
|
88
|
+
await workerB.acquire(name='other-job')
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
async def test_release_frees_the_lock_for_other_workers(workers: tuple[SqlLock, SqlLock]):
|
|
92
|
+
workerA, workerB = workers
|
|
93
|
+
lease = await workerA.acquire(name='job')
|
|
94
|
+
await workerA.release(lease=lease)
|
|
95
|
+
await workerB.acquire(name='job')
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
async def test_expired_lease_is_taken_over_and_old_holder_can_neither_extend_nor_release(workers: tuple[SqlLock, SqlLock]):
|
|
99
|
+
workerA, workerB = workers
|
|
100
|
+
staleLease = await workerA.acquire(name='job', ttlSeconds=0)
|
|
101
|
+
newLease = await workerB.acquire(name='job')
|
|
102
|
+
assert await workerA.extend(lease=staleLease) is False
|
|
103
|
+
await workerA.release(lease=staleLease)
|
|
104
|
+
with pytest.raises(LockedException):
|
|
105
|
+
await workerA.acquire(name='job')
|
|
106
|
+
assert await workerB.extend(lease=newLease) is True
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
async def test_expired_lease_cannot_be_extended_even_without_a_takeover(workers: tuple[SqlLock, SqlLock]):
|
|
110
|
+
workerA, _ = workers
|
|
111
|
+
lease = await workerA.acquire(name='job', ttlSeconds=0)
|
|
112
|
+
assert await workerA.extend(lease=lease) is False
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
async def test_extend_keeps_the_lock_past_its_original_ttl(workers: tuple[SqlLock, SqlLock]):
|
|
116
|
+
workerA, workerB = workers
|
|
117
|
+
lease = await workerA.acquire(name='job', ttlSeconds=1)
|
|
118
|
+
originalExpiryDate = lease.expiryDate
|
|
119
|
+
await asyncio.sleep(0.5)
|
|
120
|
+
assert await workerA.extend(lease=lease, ttlSeconds=2) is True
|
|
121
|
+
assert lease.expiryDate > originalExpiryDate
|
|
122
|
+
await asyncio.sleep(0.8)
|
|
123
|
+
assert lease.is_valid()
|
|
124
|
+
with pytest.raises(LockedException):
|
|
125
|
+
await workerB.acquire(name='job')
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
async def test_acquire_waits_for_another_worker_to_release(workers: tuple[SqlLock, SqlLock]):
|
|
129
|
+
workerA, workerB = workers
|
|
130
|
+
lease = await workerA.acquire(name='job')
|
|
131
|
+
|
|
132
|
+
async def release_later() -> None:
|
|
133
|
+
await asyncio.sleep(0.2)
|
|
134
|
+
await workerA.release(lease=lease)
|
|
135
|
+
|
|
136
|
+
releaseTask = asyncio.create_task(release_later())
|
|
137
|
+
await workerB.acquire(name='job', maxWaitSeconds=2)
|
|
138
|
+
await releaseTask
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
async def test_acquire_takes_over_a_lease_that_expires_while_waiting(workers: tuple[SqlLock, SqlLock]):
|
|
142
|
+
workerA, workerB = workers
|
|
143
|
+
await workerA.acquire(name='job', ttlSeconds=1)
|
|
144
|
+
startTime = time.monotonic()
|
|
145
|
+
await workerB.acquire(name='job', maxWaitSeconds=3)
|
|
146
|
+
assert 0.9 <= time.monotonic() - startTime < 3
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
async def test_acquire_gives_up_after_max_wait(workers: tuple[SqlLock, SqlLock]):
|
|
150
|
+
workerA, workerB = workers
|
|
151
|
+
await workerA.acquire(name='job')
|
|
152
|
+
startTime = time.monotonic()
|
|
153
|
+
with pytest.raises(LockedException):
|
|
154
|
+
await workerB.acquire(name='job', maxWaitSeconds=0.3)
|
|
155
|
+
assert 0.3 <= time.monotonic() - startTime < 3
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
async def test_concurrent_acquires_have_exactly_one_winner(workers: tuple[SqlLock, SqlLock]):
|
|
159
|
+
results = await asyncio.gather(*[worker.acquire(name='job') for _ in range(10) for worker in workers], return_exceptions=True)
|
|
160
|
+
assert await _count_acquired(results=results) == 1
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
async def test_concurrent_takeovers_of_an_expired_lease_have_exactly_one_winner(workers: tuple[SqlLock, SqlLock]):
|
|
164
|
+
workerA, _ = workers
|
|
165
|
+
await workerA.acquire(name='job', ttlSeconds=0)
|
|
166
|
+
results = await asyncio.gather(*[worker.acquire(name='job') for _ in range(10) for worker in workers], return_exceptions=True)
|
|
167
|
+
assert await _count_acquired(results=results) == 1
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
async def test_with_lock_never_lets_two_workers_in_at_once(workers: tuple[SqlLock, SqlLock]):
|
|
171
|
+
activeCount = 0
|
|
172
|
+
maxActiveCount = 0
|
|
173
|
+
completedCount = 0
|
|
174
|
+
|
|
175
|
+
async def run(worker: SqlLock) -> None:
|
|
176
|
+
nonlocal activeCount, maxActiveCount, completedCount
|
|
177
|
+
async with worker.with_lock(name='job', maxWaitSeconds=20):
|
|
178
|
+
activeCount += 1
|
|
179
|
+
maxActiveCount = max(maxActiveCount, activeCount)
|
|
180
|
+
await asyncio.sleep(0.02)
|
|
181
|
+
activeCount -= 1
|
|
182
|
+
completedCount += 1
|
|
183
|
+
|
|
184
|
+
await asyncio.gather(*[run(worker=worker) for _ in range(5) for worker in workers])
|
|
185
|
+
assert maxActiveCount == 1
|
|
186
|
+
assert completedCount == 10
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
async def test_with_lock_releases_when_the_body_raises(workers: tuple[SqlLock, SqlLock]):
|
|
190
|
+
workerA, workerB = workers
|
|
191
|
+
with pytest.raises(ValueError, match='boom'):
|
|
192
|
+
async with workerA.with_lock(name='job'):
|
|
193
|
+
raise ValueError('boom')
|
|
194
|
+
await workerB.acquire(name='job')
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
async def test_with_lock_raises_when_held_and_leaves_the_holder_untouched(workers: tuple[SqlLock, SqlLock]):
|
|
198
|
+
workerA, workerB = workers
|
|
199
|
+
lease = await workerA.acquire(name='job')
|
|
200
|
+
with pytest.raises(LockedException):
|
|
201
|
+
async with workerB.with_lock(name='job'):
|
|
202
|
+
pytest.fail('body must not run without the lock')
|
|
203
|
+
assert await workerA.extend(lease=lease) is True
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
async def test_with_lock_keeps_the_lease_alive_past_its_ttl(workers: tuple[SqlLock, SqlLock]):
|
|
207
|
+
workerA, workerB = workers
|
|
208
|
+
async with workerA.with_lock(name='job', ttlSeconds=1) as lease:
|
|
209
|
+
await asyncio.sleep(1.5)
|
|
210
|
+
assert lease.is_valid()
|
|
211
|
+
with pytest.raises(LockedException):
|
|
212
|
+
await workerB.acquire(name='job')
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
async def test_with_lock_marks_the_lease_lost_after_a_takeover_and_does_not_release_the_new_holder(workers: tuple[SqlLock, SqlLock]):
|
|
216
|
+
workerA, workerB = workers
|
|
217
|
+
async with workerA.with_lock(name='job', ttlSeconds=1) as lease:
|
|
218
|
+
await _expire_lock_row(lock=workerA, name='job')
|
|
219
|
+
newLease = await workerB.acquire(name='job')
|
|
220
|
+
await asyncio.sleep(1)
|
|
221
|
+
assert lease.isLost
|
|
222
|
+
assert not lease.is_valid()
|
|
223
|
+
assert await workerB.extend(lease=newLease) is True
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
async def test_with_lock_keep_alive_survives_a_transient_extend_failure(workers: tuple[SqlLock, SqlLock]):
|
|
227
|
+
workerA, workerB = workers
|
|
228
|
+
|
|
229
|
+
class FlakyLock(SqlLock):
|
|
230
|
+
extendCallCount = 0
|
|
231
|
+
|
|
232
|
+
async def extend(self, lease: Lease, ttlSeconds: int = 60) -> bool:
|
|
233
|
+
self.extendCallCount += 1
|
|
234
|
+
if self.extendCallCount == 1:
|
|
235
|
+
raise ConnectionError('transient')
|
|
236
|
+
return await super().extend(lease=lease, ttlSeconds=ttlSeconds)
|
|
237
|
+
|
|
238
|
+
flakyLock = FlakyLock(database=workerA.database, pollIntervalSeconds=0.05)
|
|
239
|
+
async with flakyLock.with_lock(name='job', ttlSeconds=1) as lease:
|
|
240
|
+
await asyncio.sleep(1.5)
|
|
241
|
+
assert flakyLock.extendCallCount >= 2
|
|
242
|
+
assert lease.is_valid()
|
|
243
|
+
with pytest.raises(LockedException):
|
|
244
|
+
await workerB.acquire(name='job')
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
async def test_lock_changes_commit_independently_of_the_callers_transaction(workers: tuple[SqlLock, SqlLock]):
|
|
248
|
+
workerA, workerB = workers
|
|
249
|
+
with pytest.raises(ValueError, match='rollback'):
|
|
250
|
+
async with workerA.database.create_context_connection():
|
|
251
|
+
await workerA.acquire(name='job')
|
|
252
|
+
with pytest.raises(LockedException):
|
|
253
|
+
await workerB.acquire(name='job')
|
|
254
|
+
raise ValueError('rollback')
|
|
255
|
+
with pytest.raises(LockedException):
|
|
256
|
+
await workerB.acquire(name='job')
|