dbx-tools-postgres 0.6.78__py3-none-any.whl
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.
- dbx_tools/postgres/__init__.py +109 -0
- dbx_tools/postgres/address.py +114 -0
- dbx_tools/postgres/advisory_lock.py +223 -0
- dbx_tools/postgres/engine.py +425 -0
- dbx_tools/postgres/topic_bus.py +380 -0
- dbx_tools_postgres-0.6.78.dist-info/METADATA +188 -0
- dbx_tools_postgres-0.6.78.dist-info/RECORD +8 -0
- dbx_tools_postgres-0.6.78.dist-info/WHEEL +4 -0
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
from .address import (
|
|
2
|
+
SSL_MODES,
|
|
3
|
+
LakebaseConnectionInputs,
|
|
4
|
+
ParsedAddress,
|
|
5
|
+
SslMode,
|
|
6
|
+
parse_address,
|
|
7
|
+
parse_resource_path,
|
|
8
|
+
parseAddress,
|
|
9
|
+
parseResourcePath,
|
|
10
|
+
)
|
|
11
|
+
from .advisory_lock import (
|
|
12
|
+
ExplicitAdvisoryLockId,
|
|
13
|
+
acquire_advisory_lock,
|
|
14
|
+
acquire_advisory_lock_async,
|
|
15
|
+
advisory_lock,
|
|
16
|
+
advisory_lock_async,
|
|
17
|
+
advisory_lock_id,
|
|
18
|
+
advisory_transaction_lock,
|
|
19
|
+
advisory_transaction_lock_async,
|
|
20
|
+
explicit_advisory_lock_id,
|
|
21
|
+
release_advisory_lock,
|
|
22
|
+
release_advisory_lock_async,
|
|
23
|
+
try_advisory_lock,
|
|
24
|
+
try_advisory_lock_async,
|
|
25
|
+
try_advisory_transaction_lock,
|
|
26
|
+
try_advisory_transaction_lock_async,
|
|
27
|
+
)
|
|
28
|
+
from .engine import (
|
|
29
|
+
CredentialProvider,
|
|
30
|
+
PostgresEngineConfig,
|
|
31
|
+
ResolvedPostgresConnection,
|
|
32
|
+
WorkspaceClientLike,
|
|
33
|
+
autoscaling_credential_provider,
|
|
34
|
+
autoscalingCredentialProvider,
|
|
35
|
+
create_async_engine,
|
|
36
|
+
create_engine,
|
|
37
|
+
createAsyncEngine,
|
|
38
|
+
createEngine,
|
|
39
|
+
install_credential_injection,
|
|
40
|
+
installCredentialInjection,
|
|
41
|
+
resolve_postgres_connection,
|
|
42
|
+
resolvePostgresConnection,
|
|
43
|
+
workspace_credential_provider,
|
|
44
|
+
workspaceCredentialProvider,
|
|
45
|
+
)
|
|
46
|
+
from .topic_bus import (
|
|
47
|
+
PostgresTopicBus,
|
|
48
|
+
PostgresTopicBusOptions,
|
|
49
|
+
SerializableValue,
|
|
50
|
+
TopicListener,
|
|
51
|
+
TopicMessage,
|
|
52
|
+
TopicMetadata,
|
|
53
|
+
TopicMetadataProvider,
|
|
54
|
+
TopicPublishInput,
|
|
55
|
+
channel_name,
|
|
56
|
+
channelName,
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
__all__ = [
|
|
60
|
+
"SSL_MODES",
|
|
61
|
+
"CredentialProvider",
|
|
62
|
+
"ExplicitAdvisoryLockId",
|
|
63
|
+
"LakebaseConnectionInputs",
|
|
64
|
+
"ParsedAddress",
|
|
65
|
+
"PostgresEngineConfig",
|
|
66
|
+
"PostgresTopicBus",
|
|
67
|
+
"PostgresTopicBusOptions",
|
|
68
|
+
"ResolvedPostgresConnection",
|
|
69
|
+
"SerializableValue",
|
|
70
|
+
"SslMode",
|
|
71
|
+
"TopicListener",
|
|
72
|
+
"TopicMessage",
|
|
73
|
+
"TopicMetadata",
|
|
74
|
+
"TopicMetadataProvider",
|
|
75
|
+
"TopicPublishInput",
|
|
76
|
+
"WorkspaceClientLike",
|
|
77
|
+
"acquire_advisory_lock",
|
|
78
|
+
"acquire_advisory_lock_async",
|
|
79
|
+
"advisory_lock",
|
|
80
|
+
"advisory_lock_async",
|
|
81
|
+
"advisory_lock_id",
|
|
82
|
+
"advisory_transaction_lock",
|
|
83
|
+
"advisory_transaction_lock_async",
|
|
84
|
+
"autoscalingCredentialProvider",
|
|
85
|
+
"autoscaling_credential_provider",
|
|
86
|
+
"channelName",
|
|
87
|
+
"channel_name",
|
|
88
|
+
"createAsyncEngine",
|
|
89
|
+
"createEngine",
|
|
90
|
+
"create_async_engine",
|
|
91
|
+
"create_engine",
|
|
92
|
+
"explicit_advisory_lock_id",
|
|
93
|
+
"installCredentialInjection",
|
|
94
|
+
"install_credential_injection",
|
|
95
|
+
"parseAddress",
|
|
96
|
+
"parseResourcePath",
|
|
97
|
+
"parse_address",
|
|
98
|
+
"parse_resource_path",
|
|
99
|
+
"release_advisory_lock",
|
|
100
|
+
"release_advisory_lock_async",
|
|
101
|
+
"resolvePostgresConnection",
|
|
102
|
+
"resolve_postgres_connection",
|
|
103
|
+
"try_advisory_lock",
|
|
104
|
+
"try_advisory_lock_async",
|
|
105
|
+
"try_advisory_transaction_lock",
|
|
106
|
+
"try_advisory_transaction_lock_async",
|
|
107
|
+
"workspaceCredentialProvider",
|
|
108
|
+
"workspace_credential_provider",
|
|
109
|
+
]
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
from dataclasses import asdict, dataclass
|
|
5
|
+
from typing import Literal
|
|
6
|
+
from urllib.parse import parse_qs, unquote, urlparse
|
|
7
|
+
|
|
8
|
+
SslMode = Literal["require", "disable", "prefer"]
|
|
9
|
+
SSL_MODES: tuple[SslMode, ...] = ("require", "disable", "prefer")
|
|
10
|
+
|
|
11
|
+
_URL_SCHEME_RE = re.compile(r"^(postgres|postgresql)://", re.IGNORECASE)
|
|
12
|
+
_PROJECT_ID_RE = re.compile(r"^[a-z][a-z0-9-]{0,61}[a-z0-9]$|^[a-z]$")
|
|
13
|
+
_HOSTNAME_HINT_RE = re.compile(r"^[a-z0-9][a-z0-9-]*(\.[a-z0-9][a-z0-9-]*)+$", re.IGNORECASE)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@dataclass(frozen=True, slots=True)
|
|
17
|
+
class LakebaseConnectionInputs:
|
|
18
|
+
project: str | None = None
|
|
19
|
+
branch: str | None = None
|
|
20
|
+
endpoint: str | None = None
|
|
21
|
+
database: str | None = None
|
|
22
|
+
host: str | None = None
|
|
23
|
+
port: int | None = None
|
|
24
|
+
ssl_mode: SslMode | None = None
|
|
25
|
+
|
|
26
|
+
def as_dict(self) -> dict[str, object]:
|
|
27
|
+
return {key: value for key, value in asdict(self).items() if value is not None}
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@dataclass(frozen=True, slots=True)
|
|
31
|
+
class ParsedAddress(LakebaseConnectionInputs):
|
|
32
|
+
endpoint_id: str | None = None
|
|
33
|
+
database_resource_id: str | None = None
|
|
34
|
+
user: str | None = None
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def parse_address(value: str | None) -> ParsedAddress:
|
|
38
|
+
if not value or not (address := value.strip()):
|
|
39
|
+
return ParsedAddress()
|
|
40
|
+
if _URL_SCHEME_RE.match(address):
|
|
41
|
+
return _parse_uri(address)
|
|
42
|
+
if address.startswith("projects/"):
|
|
43
|
+
return _parse_resource_path_segments(address)
|
|
44
|
+
if "." in address and _HOSTNAME_HINT_RE.fullmatch(address):
|
|
45
|
+
return ParsedAddress(host=address)
|
|
46
|
+
if _PROJECT_ID_RE.fullmatch(address):
|
|
47
|
+
return ParsedAddress(project=address)
|
|
48
|
+
return ParsedAddress()
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def parse_resource_path(value: str | None) -> ParsedAddress:
|
|
52
|
+
if not value or not (address := value.strip()).startswith("projects/"):
|
|
53
|
+
return ParsedAddress()
|
|
54
|
+
return _parse_resource_path_segments(address)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _parse_uri(address: str) -> ParsedAddress:
|
|
58
|
+
try:
|
|
59
|
+
parsed = urlparse(address)
|
|
60
|
+
port = parsed.port
|
|
61
|
+
except ValueError:
|
|
62
|
+
return ParsedAddress()
|
|
63
|
+
if parsed.scheme.lower() not in {"postgres", "postgresql"}:
|
|
64
|
+
return ParsedAddress()
|
|
65
|
+
ssl_mode_value = parse_qs(parsed.query).get("sslmode") or parse_qs(parsed.query).get("sslMode")
|
|
66
|
+
ssl_mode = ssl_mode_value[0].lower() if ssl_mode_value else None
|
|
67
|
+
return ParsedAddress(
|
|
68
|
+
host=parsed.hostname or None,
|
|
69
|
+
port=port,
|
|
70
|
+
user=unquote(parsed.username) if parsed.username else None,
|
|
71
|
+
database=unquote(parsed.path.removeprefix("/")) or None,
|
|
72
|
+
ssl_mode=ssl_mode if ssl_mode in SSL_MODES else None,
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _parse_resource_path_segments(address: str) -> ParsedAddress:
|
|
77
|
+
parts = address.split("/")
|
|
78
|
+
if len(parts) < 2 or parts[0] != "projects" or not parts[1]:
|
|
79
|
+
return ParsedAddress()
|
|
80
|
+
project = parts[1]
|
|
81
|
+
if len(parts) == 2:
|
|
82
|
+
return ParsedAddress(project=project)
|
|
83
|
+
if len(parts) == 4 and parts[2] == "branches" and parts[3]:
|
|
84
|
+
return ParsedAddress(project=project, branch=parts[3])
|
|
85
|
+
if (
|
|
86
|
+
len(parts) == 6
|
|
87
|
+
and parts[2] == "branches"
|
|
88
|
+
and parts[3]
|
|
89
|
+
and parts[4] == "endpoints"
|
|
90
|
+
and parts[5]
|
|
91
|
+
):
|
|
92
|
+
return ParsedAddress(
|
|
93
|
+
project=project,
|
|
94
|
+
branch=parts[3],
|
|
95
|
+
endpoint=address,
|
|
96
|
+
endpoint_id=parts[5],
|
|
97
|
+
)
|
|
98
|
+
if (
|
|
99
|
+
len(parts) == 6
|
|
100
|
+
and parts[2] == "branches"
|
|
101
|
+
and parts[3]
|
|
102
|
+
and parts[4] == "databases"
|
|
103
|
+
and parts[5]
|
|
104
|
+
):
|
|
105
|
+
return ParsedAddress(
|
|
106
|
+
project=project,
|
|
107
|
+
branch=parts[3],
|
|
108
|
+
database_resource_id=parts[5],
|
|
109
|
+
)
|
|
110
|
+
return ParsedAddress()
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
parseAddress = parse_address
|
|
114
|
+
parseResourcePath = parse_resource_path
|
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import hashlib
|
|
4
|
+
from collections.abc import AsyncIterator, Iterator
|
|
5
|
+
from contextlib import asynccontextmanager, contextmanager
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from typing import Protocol
|
|
8
|
+
|
|
9
|
+
from dbx_tools.core import to_stable_key
|
|
10
|
+
from sqlalchemy import text
|
|
11
|
+
from sqlalchemy.engine import Connection, Engine
|
|
12
|
+
from sqlalchemy.ext.asyncio import AsyncConnection, AsyncEngine
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@dataclass(frozen=True, slots=True)
|
|
16
|
+
class ExplicitAdvisoryLockId:
|
|
17
|
+
value: int
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class SyncQueryable(Protocol):
|
|
21
|
+
def execute(self, statement: object, parameters: dict[str, object] | None = None) -> object: ...
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class AsyncQueryable(Protocol):
|
|
25
|
+
async def execute(
|
|
26
|
+
self, statement: object, parameters: dict[str, object] | None = None
|
|
27
|
+
) -> object: ...
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def advisory_lock_id(key: object) -> int:
|
|
31
|
+
if isinstance(key, ExplicitAdvisoryLockId):
|
|
32
|
+
return _signed_64(key.value)
|
|
33
|
+
parts = key if isinstance(key, (list, tuple)) else [key]
|
|
34
|
+
canonical = "\0".join(to_stable_key(part) for part in parts)
|
|
35
|
+
return int.from_bytes(hashlib.sha256(canonical.encode()).digest()[:8], "big", signed=True)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def explicit_advisory_lock_id(value: int) -> ExplicitAdvisoryLockId:
|
|
39
|
+
return ExplicitAdvisoryLockId(value)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def acquire_advisory_lock(
|
|
43
|
+
connection: SyncQueryable,
|
|
44
|
+
key: object,
|
|
45
|
+
*,
|
|
46
|
+
transaction: bool = False,
|
|
47
|
+
wait: bool = True,
|
|
48
|
+
) -> bool:
|
|
49
|
+
function = _function_name(transaction=transaction, wait=wait)
|
|
50
|
+
result = connection.execute(
|
|
51
|
+
text(f"SELECT {function}(:lock_id)"), {"lock_id": advisory_lock_id(key)}
|
|
52
|
+
)
|
|
53
|
+
return True if wait else bool(result.scalar_one())
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
async def acquire_advisory_lock_async(
|
|
57
|
+
connection: AsyncQueryable,
|
|
58
|
+
key: object,
|
|
59
|
+
*,
|
|
60
|
+
transaction: bool = False,
|
|
61
|
+
wait: bool = True,
|
|
62
|
+
) -> bool:
|
|
63
|
+
function = _function_name(transaction=transaction, wait=wait)
|
|
64
|
+
result = await connection.execute(
|
|
65
|
+
text(f"SELECT {function}(:lock_id)"), {"lock_id": advisory_lock_id(key)}
|
|
66
|
+
)
|
|
67
|
+
return True if wait else bool(result.scalar_one())
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def release_advisory_lock(connection: SyncQueryable, key: object) -> None:
|
|
71
|
+
lock_id = advisory_lock_id(key)
|
|
72
|
+
result = connection.execute(text("SELECT pg_advisory_unlock(:lock_id)"), {"lock_id": lock_id})
|
|
73
|
+
if result.scalar_one() is not True:
|
|
74
|
+
raise RuntimeError(f"Postgres advisory lock {lock_id} was not held by this connection")
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
async def release_advisory_lock_async(connection: AsyncQueryable, key: object) -> None:
|
|
78
|
+
lock_id = advisory_lock_id(key)
|
|
79
|
+
result = await connection.execute(
|
|
80
|
+
text("SELECT pg_advisory_unlock(:lock_id)"), {"lock_id": lock_id}
|
|
81
|
+
)
|
|
82
|
+
if result.scalar_one() is not True:
|
|
83
|
+
raise RuntimeError(f"Postgres advisory lock {lock_id} was not held by this connection")
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
@contextmanager
|
|
87
|
+
def advisory_lock(engine: Engine, key: object) -> Iterator[Connection]:
|
|
88
|
+
with engine.connect() as connection:
|
|
89
|
+
acquire_advisory_lock(connection, key)
|
|
90
|
+
failure: Exception | None = None
|
|
91
|
+
try:
|
|
92
|
+
yield connection
|
|
93
|
+
except Exception as error: # noqa: BLE001
|
|
94
|
+
failure = error
|
|
95
|
+
unlock_failure = _capture_release(connection, key)
|
|
96
|
+
if failure is not None:
|
|
97
|
+
if unlock_failure is not None:
|
|
98
|
+
failure.add_note(f"Advisory lock release also failed: {unlock_failure}")
|
|
99
|
+
raise failure
|
|
100
|
+
if unlock_failure is not None:
|
|
101
|
+
raise unlock_failure
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
@contextmanager
|
|
105
|
+
def try_advisory_lock(engine: Engine, key: object) -> Iterator[Connection | None]:
|
|
106
|
+
with engine.connect() as connection:
|
|
107
|
+
if not acquire_advisory_lock(connection, key, wait=False):
|
|
108
|
+
yield None
|
|
109
|
+
return
|
|
110
|
+
failure: Exception | None = None
|
|
111
|
+
try:
|
|
112
|
+
yield connection
|
|
113
|
+
except Exception as error: # noqa: BLE001
|
|
114
|
+
failure = error
|
|
115
|
+
unlock_failure = _capture_release(connection, key)
|
|
116
|
+
if failure is not None:
|
|
117
|
+
if unlock_failure is not None:
|
|
118
|
+
failure.add_note(f"Advisory lock release also failed: {unlock_failure}")
|
|
119
|
+
raise failure
|
|
120
|
+
if unlock_failure is not None:
|
|
121
|
+
raise unlock_failure
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
@contextmanager
|
|
125
|
+
def advisory_transaction_lock(engine: Engine, key: object) -> Iterator[Connection]:
|
|
126
|
+
with engine.begin() as connection:
|
|
127
|
+
acquire_advisory_lock(connection, key, transaction=True)
|
|
128
|
+
yield connection
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
@contextmanager
|
|
132
|
+
def try_advisory_transaction_lock(engine: Engine, key: object) -> Iterator[Connection | None]:
|
|
133
|
+
with engine.begin() as connection:
|
|
134
|
+
if not acquire_advisory_lock(connection, key, transaction=True, wait=False):
|
|
135
|
+
yield None
|
|
136
|
+
return
|
|
137
|
+
yield connection
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
@asynccontextmanager
|
|
141
|
+
async def advisory_lock_async(engine: AsyncEngine, key: object) -> AsyncIterator[AsyncConnection]:
|
|
142
|
+
async with engine.connect() as connection:
|
|
143
|
+
await acquire_advisory_lock_async(connection, key)
|
|
144
|
+
failure: Exception | None = None
|
|
145
|
+
try:
|
|
146
|
+
yield connection
|
|
147
|
+
except Exception as error: # noqa: BLE001
|
|
148
|
+
failure = error
|
|
149
|
+
unlock_failure = await _capture_release_async(connection, key)
|
|
150
|
+
if failure is not None:
|
|
151
|
+
if unlock_failure is not None:
|
|
152
|
+
failure.add_note(f"Advisory lock release also failed: {unlock_failure}")
|
|
153
|
+
raise failure
|
|
154
|
+
if unlock_failure is not None:
|
|
155
|
+
raise unlock_failure
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
@asynccontextmanager
|
|
159
|
+
async def try_advisory_lock_async(
|
|
160
|
+
engine: AsyncEngine, key: object
|
|
161
|
+
) -> AsyncIterator[AsyncConnection | None]:
|
|
162
|
+
async with engine.connect() as connection:
|
|
163
|
+
if not await acquire_advisory_lock_async(connection, key, wait=False):
|
|
164
|
+
yield None
|
|
165
|
+
return
|
|
166
|
+
failure: Exception | None = None
|
|
167
|
+
try:
|
|
168
|
+
yield connection
|
|
169
|
+
except Exception as error: # noqa: BLE001
|
|
170
|
+
failure = error
|
|
171
|
+
unlock_failure = await _capture_release_async(connection, key)
|
|
172
|
+
if failure is not None:
|
|
173
|
+
if unlock_failure is not None:
|
|
174
|
+
failure.add_note(f"Advisory lock release also failed: {unlock_failure}")
|
|
175
|
+
raise failure
|
|
176
|
+
if unlock_failure is not None:
|
|
177
|
+
raise unlock_failure
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
@asynccontextmanager
|
|
181
|
+
async def advisory_transaction_lock_async(
|
|
182
|
+
engine: AsyncEngine, key: object
|
|
183
|
+
) -> AsyncIterator[AsyncConnection]:
|
|
184
|
+
async with engine.begin() as connection:
|
|
185
|
+
await acquire_advisory_lock_async(connection, key, transaction=True)
|
|
186
|
+
yield connection
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
@asynccontextmanager
|
|
190
|
+
async def try_advisory_transaction_lock_async(
|
|
191
|
+
engine: AsyncEngine, key: object
|
|
192
|
+
) -> AsyncIterator[AsyncConnection | None]:
|
|
193
|
+
async with engine.begin() as connection:
|
|
194
|
+
if not await acquire_advisory_lock_async(connection, key, transaction=True, wait=False):
|
|
195
|
+
yield None
|
|
196
|
+
return
|
|
197
|
+
yield connection
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def _function_name(*, transaction: bool, wait: bool) -> str:
|
|
201
|
+
if transaction:
|
|
202
|
+
return "pg_advisory_xact_lock" if wait else "pg_try_advisory_xact_lock"
|
|
203
|
+
return "pg_advisory_lock" if wait else "pg_try_advisory_lock"
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def _signed_64(value: int) -> int:
|
|
207
|
+
return ((value + 2**63) % 2**64) - 2**63
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def _capture_release(connection: SyncQueryable, key: object) -> Exception | None:
|
|
211
|
+
try:
|
|
212
|
+
release_advisory_lock(connection, key)
|
|
213
|
+
except Exception as error: # noqa: BLE001
|
|
214
|
+
return error
|
|
215
|
+
return None
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
async def _capture_release_async(connection: AsyncQueryable, key: object) -> Exception | None:
|
|
219
|
+
try:
|
|
220
|
+
await release_advisory_lock_async(connection, key)
|
|
221
|
+
except Exception as error: # noqa: BLE001
|
|
222
|
+
return error
|
|
223
|
+
return None
|