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.
Files changed (125) hide show
  1. {kiba_core-0.5.3.dev64 → kiba_core-0.5.3.dev65}/.github/workflows/pull-request.yml +8 -1
  2. {kiba_core-0.5.3.dev64 → kiba_core-0.5.3.dev65}/CHANGELOG.md +3 -0
  3. {kiba_core-0.5.3.dev64 → kiba_core-0.5.3.dev65}/PKG-INFO +1 -1
  4. kiba_core-0.5.3.dev65/core/locks/lock.py +61 -0
  5. kiba_core-0.5.3.dev65/core/locks/model.py +15 -0
  6. kiba_core-0.5.3.dev65/core/locks/sql.py +96 -0
  7. {kiba_core-0.5.3.dev64 → kiba_core-0.5.3.dev65}/core/queues/sql.py +3 -3
  8. {kiba_core-0.5.3.dev64 → kiba_core-0.5.3.dev65}/core/store/database.py +24 -0
  9. kiba_core-0.5.3.dev65/core/util/process_util.py +28 -0
  10. {kiba_core-0.5.3.dev64 → kiba_core-0.5.3.dev65}/kiba_core.egg-info/PKG-INFO +1 -1
  11. {kiba_core-0.5.3.dev64 → kiba_core-0.5.3.dev65}/kiba_core.egg-info/SOURCES.txt +7 -0
  12. {kiba_core-0.5.3.dev64 → kiba_core-0.5.3.dev65}/pyproject.toml +1 -1
  13. kiba_core-0.5.3.dev65/tests/locks/test_sql_lock.py +256 -0
  14. kiba_core-0.5.3.dev65/tests/test_database.py +214 -0
  15. kiba_core-0.5.3.dev65/tests/util/test_process_util.py +35 -0
  16. kiba_core-0.5.3.dev65/tests/web3/__init__.py +0 -0
  17. kiba_core-0.5.3.dev64/tests/test_database.py +0 -115
  18. {kiba_core-0.5.3.dev64 → kiba_core-0.5.3.dev65}/.github/pull_request_template.md +0 -0
  19. {kiba_core-0.5.3.dev64 → kiba_core-0.5.3.dev65}/.github/workflows/deploy.yml +0 -0
  20. {kiba_core-0.5.3.dev64 → kiba_core-0.5.3.dev65}/.github/workflows/release.yml +0 -0
  21. {kiba_core-0.5.3.dev64 → kiba_core-0.5.3.dev65}/.gitignore +0 -0
  22. {kiba_core-0.5.3.dev64 → kiba_core-0.5.3.dev65}/Dockerfile +0 -0
  23. {kiba_core-0.5.3.dev64 → kiba_core-0.5.3.dev65}/README.md +0 -0
  24. {kiba_core-0.5.3.dev64 → kiba_core-0.5.3.dev65}/core/__init__.py +0 -0
  25. {kiba_core-0.5.3.dev64 → kiba_core-0.5.3.dev65}/core/api/__init__.py +0 -0
  26. {kiba_core-0.5.3.dev64 → kiba_core-0.5.3.dev65}/core/api/api_request.py +0 -0
  27. {kiba_core-0.5.3.dev64 → kiba_core-0.5.3.dev65}/core/api/api_response.py +0 -0
  28. {kiba_core-0.5.3.dev64 → kiba_core-0.5.3.dev65}/core/api/authorizer.py +0 -0
  29. {kiba_core-0.5.3.dev64 → kiba_core-0.5.3.dev65}/core/api/default_routes.py +0 -0
  30. {kiba_core-0.5.3.dev64 → kiba_core-0.5.3.dev65}/core/api/health.py +0 -0
  31. {kiba_core-0.5.3.dev64 → kiba_core-0.5.3.dev65}/core/api/json_route.py +0 -0
  32. {kiba_core-0.5.3.dev64 → kiba_core-0.5.3.dev65}/core/api/middleware/__init__.py +0 -0
  33. {kiba_core-0.5.3.dev64 → kiba_core-0.5.3.dev65}/core/api/middleware/database_connection_middleware.py +0 -0
  34. {kiba_core-0.5.3.dev64 → kiba_core-0.5.3.dev65}/core/api/middleware/exception_handling_middleware.py +0 -0
  35. {kiba_core-0.5.3.dev64 → kiba_core-0.5.3.dev65}/core/api/middleware/logging_middleware.py +0 -0
  36. {kiba_core-0.5.3.dev64 → kiba_core-0.5.3.dev65}/core/api/middleware/origin_ip_middleware.py +0 -0
  37. {kiba_core-0.5.3.dev64 → kiba_core-0.5.3.dev65}/core/api/middleware/server_headers_middleware.py +0 -0
  38. {kiba_core-0.5.3.dev64 → kiba_core-0.5.3.dev65}/core/api/openapi.py +0 -0
  39. {kiba_core-0.5.3.dev64 → kiba_core-0.5.3.dev65}/core/api/rate_limit.py +0 -0
  40. {kiba_core-0.5.3.dev64 → kiba_core-0.5.3.dev65}/core/api/request_context.py +0 -0
  41. {kiba_core-0.5.3.dev64 → kiba_core-0.5.3.dev65}/core/api/route.py +0 -0
  42. {kiba_core-0.5.3.dev64 → kiba_core-0.5.3.dev65}/core/api/route_auth.py +0 -0
  43. {kiba_core-0.5.3.dev64 → kiba_core-0.5.3.dev65}/core/api/route_metadata.py +0 -0
  44. {kiba_core-0.5.3.dev64 → kiba_core-0.5.3.dev65}/core/api/streaming_json_route.py +0 -0
  45. {kiba_core-0.5.3.dev64 → kiba_core-0.5.3.dev65}/core/aws_requester.py +0 -0
  46. {kiba_core-0.5.3.dev64 → kiba_core-0.5.3.dev65}/core/caching/__init__.py +0 -0
  47. {kiba_core-0.5.3.dev64 → kiba_core-0.5.3.dev65}/core/caching/cache.py +0 -0
  48. {kiba_core-0.5.3.dev64 → kiba_core-0.5.3.dev65}/core/caching/dict_cache.py +0 -0
  49. {kiba_core-0.5.3.dev64 → kiba_core-0.5.3.dev65}/core/caching/file_cache.py +0 -0
  50. {kiba_core-0.5.3.dev64 → kiba_core-0.5.3.dev65}/core/exceptions.py +0 -0
  51. {kiba_core-0.5.3.dev64 → kiba_core-0.5.3.dev65}/core/http/__init__.py +0 -0
  52. {kiba_core-0.5.3.dev64 → kiba_core-0.5.3.dev65}/core/http/basic_authentication.py +0 -0
  53. {kiba_core-0.5.3.dev64 → kiba_core-0.5.3.dev65}/core/http/jwt.py +0 -0
  54. {kiba_core-0.5.3.dev64 → kiba_core-0.5.3.dev65}/core/http/rest_method.py +0 -0
  55. {kiba_core-0.5.3.dev64/core/notifications → kiba_core-0.5.3.dev65/core/locks}/__init__.py +0 -0
  56. {kiba_core-0.5.3.dev64 → kiba_core-0.5.3.dev65}/core/logging.py +0 -0
  57. {kiba_core-0.5.3.dev64/core/queues → kiba_core-0.5.3.dev65/core/notifications}/__init__.py +0 -0
  58. {kiba_core-0.5.3.dev64 → kiba_core-0.5.3.dev65}/core/notifications/discord_client.py +0 -0
  59. {kiba_core-0.5.3.dev64 → kiba_core-0.5.3.dev65}/core/notifications/notification_client.py +0 -0
  60. {kiba_core-0.5.3.dev64 → kiba_core-0.5.3.dev65}/core/notifications/slack_client.py +0 -0
  61. {kiba_core-0.5.3.dev64 → kiba_core-0.5.3.dev65}/core/py.typed +0 -0
  62. {kiba_core-0.5.3.dev64/core/store → kiba_core-0.5.3.dev65/core/queues}/__init__.py +0 -0
  63. {kiba_core-0.5.3.dev64 → kiba_core-0.5.3.dev65}/core/queues/aqs.py +0 -0
  64. {kiba_core-0.5.3.dev64 → kiba_core-0.5.3.dev65}/core/queues/cosmos.py +0 -0
  65. {kiba_core-0.5.3.dev64 → kiba_core-0.5.3.dev65}/core/queues/message_queue.py +0 -0
  66. {kiba_core-0.5.3.dev64 → kiba_core-0.5.3.dev65}/core/queues/message_queue_processor.py +0 -0
  67. {kiba_core-0.5.3.dev64 → kiba_core-0.5.3.dev65}/core/queues/model.py +0 -0
  68. {kiba_core-0.5.3.dev64 → kiba_core-0.5.3.dev65}/core/queues/sqs.py +0 -0
  69. {kiba_core-0.5.3.dev64 → kiba_core-0.5.3.dev65}/core/requester/__init__.py +0 -0
  70. {kiba_core-0.5.3.dev64 → kiba_core-0.5.3.dev65}/core/requester/requester.py +0 -0
  71. {kiba_core-0.5.3.dev64 → kiba_core-0.5.3.dev65}/core/s3_manager.py +0 -0
  72. {kiba_core-0.5.3.dev64 → kiba_core-0.5.3.dev65}/core/service_client.py +0 -0
  73. {kiba_core-0.5.3.dev64/core/util → kiba_core-0.5.3.dev65/core/store}/__init__.py +0 -0
  74. {kiba_core-0.5.3.dev64 → kiba_core-0.5.3.dev65}/core/store/retriever.py +0 -0
  75. {kiba_core-0.5.3.dev64 → kiba_core-0.5.3.dev65}/core/store/saver.py +0 -0
  76. {kiba_core-0.5.3.dev64/core/web3 → kiba_core-0.5.3.dev65/core/util}/__init__.py +0 -0
  77. {kiba_core-0.5.3.dev64 → kiba_core-0.5.3.dev65}/core/util/async_util.py +0 -0
  78. {kiba_core-0.5.3.dev64 → kiba_core-0.5.3.dev65}/core/util/chain_util.py +0 -0
  79. {kiba_core-0.5.3.dev64 → kiba_core-0.5.3.dev65}/core/util/date_util.py +0 -0
  80. {kiba_core-0.5.3.dev64 → kiba_core-0.5.3.dev65}/core/util/dict_util.py +0 -0
  81. {kiba_core-0.5.3.dev64 → kiba_core-0.5.3.dev65}/core/util/file_util.py +0 -0
  82. {kiba_core-0.5.3.dev64 → kiba_core-0.5.3.dev65}/core/util/hashing_util.py +0 -0
  83. {kiba_core-0.5.3.dev64 → kiba_core-0.5.3.dev65}/core/util/http_util.py +0 -0
  84. {kiba_core-0.5.3.dev64 → kiba_core-0.5.3.dev65}/core/util/json_util.py +0 -0
  85. {kiba_core-0.5.3.dev64 → kiba_core-0.5.3.dev65}/core/util/list_util.py +0 -0
  86. {kiba_core-0.5.3.dev64 → kiba_core-0.5.3.dev65}/core/util/string_util.py +0 -0
  87. {kiba_core-0.5.3.dev64 → kiba_core-0.5.3.dev65}/core/util/typing_util.py +0 -0
  88. {kiba_core-0.5.3.dev64 → kiba_core-0.5.3.dev65}/core/util/url_util.py +0 -0
  89. {kiba_core-0.5.3.dev64 → kiba_core-0.5.3.dev65}/core/util/value_holder.py +0 -0
  90. {kiba_core-0.5.3.dev64/tests/requester → kiba_core-0.5.3.dev65/core/web3}/__init__.py +0 -0
  91. {kiba_core-0.5.3.dev64 → kiba_core-0.5.3.dev65}/core/web3/eth_client.py +0 -0
  92. {kiba_core-0.5.3.dev64 → kiba_core-0.5.3.dev65}/core/web3/multicall3.py +0 -0
  93. {kiba_core-0.5.3.dev64 → kiba_core-0.5.3.dev65}/kiba_core.egg-info/dependency_links.txt +0 -0
  94. {kiba_core-0.5.3.dev64 → kiba_core-0.5.3.dev65}/kiba_core.egg-info/requires.txt +0 -0
  95. {kiba_core-0.5.3.dev64 → kiba_core-0.5.3.dev65}/kiba_core.egg-info/top_level.txt +0 -0
  96. {kiba_core-0.5.3.dev64 → kiba_core-0.5.3.dev65}/makefile +0 -0
  97. {kiba_core-0.5.3.dev64 → kiba_core-0.5.3.dev65}/setup.cfg +0 -0
  98. {kiba_core-0.5.3.dev64 → kiba_core-0.5.3.dev65}/tests/__init__.py +0 -0
  99. {kiba_core-0.5.3.dev64 → kiba_core-0.5.3.dev65}/tests/api/test_api_response.py +0 -0
  100. {kiba_core-0.5.3.dev64 → kiba_core-0.5.3.dev65}/tests/api/test_authorizer.py +0 -0
  101. {kiba_core-0.5.3.dev64 → kiba_core-0.5.3.dev65}/tests/api/test_exception_handling_middleware.py +0 -0
  102. {kiba_core-0.5.3.dev64 → kiba_core-0.5.3.dev65}/tests/api/test_json_route.py +0 -0
  103. {kiba_core-0.5.3.dev64 → kiba_core-0.5.3.dev65}/tests/api/test_openapi.py +0 -0
  104. {kiba_core-0.5.3.dev64 → kiba_core-0.5.3.dev65}/tests/api/test_origin_ip_middleware.py +0 -0
  105. {kiba_core-0.5.3.dev64 → kiba_core-0.5.3.dev65}/tests/api/test_rate_limit.py +0 -0
  106. {kiba_core-0.5.3.dev64 → kiba_core-0.5.3.dev65}/tests/api/test_request_context.py +0 -0
  107. {kiba_core-0.5.3.dev64 → kiba_core-0.5.3.dev65}/tests/api/test_route.py +0 -0
  108. {kiba_core-0.5.3.dev64 → kiba_core-0.5.3.dev65}/tests/api/test_streaming_json_route.py +0 -0
  109. {kiba_core-0.5.3.dev64 → kiba_core-0.5.3.dev65}/tests/caching/test_dict_cache.py +0 -0
  110. {kiba_core-0.5.3.dev64 → kiba_core-0.5.3.dev65}/tests/caching/test_file_cache.py +0 -0
  111. {kiba_core-0.5.3.dev64/tests/web3 → kiba_core-0.5.3.dev65/tests/requester}/__init__.py +0 -0
  112. {kiba_core-0.5.3.dev64 → kiba_core-0.5.3.dev65}/tests/requester/test_requester.py +0 -0
  113. {kiba_core-0.5.3.dev64 → kiba_core-0.5.3.dev65}/tests/test_exceptions.py +0 -0
  114. {kiba_core-0.5.3.dev64 → kiba_core-0.5.3.dev65}/tests/util/__init__.py +0 -0
  115. {kiba_core-0.5.3.dev64 → kiba_core-0.5.3.dev65}/tests/util/test_async_util.py +0 -0
  116. {kiba_core-0.5.3.dev64 → kiba_core-0.5.3.dev65}/tests/util/test_chain_util.py +0 -0
  117. {kiba_core-0.5.3.dev64 → kiba_core-0.5.3.dev65}/tests/util/test_date_util.py +0 -0
  118. {kiba_core-0.5.3.dev64 → kiba_core-0.5.3.dev65}/tests/util/test_dict_util.py +0 -0
  119. {kiba_core-0.5.3.dev64 → kiba_core-0.5.3.dev65}/tests/util/test_file_util.py +0 -0
  120. {kiba_core-0.5.3.dev64 → kiba_core-0.5.3.dev65}/tests/util/test_json_util.py +0 -0
  121. {kiba_core-0.5.3.dev64 → kiba_core-0.5.3.dev65}/tests/util/test_list_util.py +0 -0
  122. {kiba_core-0.5.3.dev64 → kiba_core-0.5.3.dev65}/tests/util/test_string_util.py +0 -0
  123. {kiba_core-0.5.3.dev64 → kiba_core-0.5.3.dev65}/tests/util/test_url_util.py +0 -0
  124. {kiba_core-0.5.3.dev64 → kiba_core-0.5.3.dev65}/tests/web3/test_rest_eth_client.py +0 -0
  125. {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
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: kiba-core
3
- Version: 0.5.3.dev64
3
+ Version: 0.5.3.dev65
4
4
  Summary: Kiba Labs' python utilities
5
5
  Project-URL: repository, https://github.com/kibalabs/core-py
6
6
  Requires-Python: <4,>=3.12
@@ -0,0 +1,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
- # table), call `QueueMessagesTable.to_metadata(appMetadata)` from the app's schema module.
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()}'
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: kiba-core
3
- Version: 0.5.3.dev64
3
+ Version: 0.5.3.dev65
4
4
  Summary: Kiba Labs' python utilities
5
5
  Project-URL: repository, https://github.com/kibalabs/core-py
6
6
  Requires-Python: <4,>=3.12
@@ -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
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "kiba-core"
3
- version = "0.5.3.dev64"
3
+ version = "0.5.3.dev65"
4
4
  description = "Kiba Labs' python utilities"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.12, <4"
@@ -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')