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