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,380 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import inspect
|
|
5
|
+
import json
|
|
6
|
+
import math
|
|
7
|
+
import os
|
|
8
|
+
import platform
|
|
9
|
+
import re
|
|
10
|
+
import socket
|
|
11
|
+
import uuid
|
|
12
|
+
from collections.abc import Awaitable, Callable, Mapping
|
|
13
|
+
from dataclasses import dataclass, field
|
|
14
|
+
from datetime import datetime, timezone
|
|
15
|
+
from typing import Any, Protocol, TypeAlias
|
|
16
|
+
|
|
17
|
+
from dbx_tools.core import fnv_hash, to_identifier, to_stable_key
|
|
18
|
+
from sqlalchemy import text
|
|
19
|
+
from sqlalchemy.ext.asyncio import AsyncEngine
|
|
20
|
+
|
|
21
|
+
SerializableValue: TypeAlias = (
|
|
22
|
+
str | int | float | bool | None | list["SerializableValue"] | dict[str, "SerializableValue"]
|
|
23
|
+
)
|
|
24
|
+
TopicMetadata: TypeAlias = dict[str, SerializableValue]
|
|
25
|
+
TopicListener: TypeAlias = Callable[["TopicMessage"], Awaitable[None] | None]
|
|
26
|
+
TopicMetadataProvider: TypeAlias = Callable[[], Awaitable[TopicMetadata] | TopicMetadata]
|
|
27
|
+
|
|
28
|
+
_DEFAULT_CHANNEL = "dbx_tools_topic_bus"
|
|
29
|
+
_MAX_CHANNEL_LENGTH = 63
|
|
30
|
+
_CHANNEL_HASH_LENGTH = 6
|
|
31
|
+
_CHANNEL_FALLBACK = "bus"
|
|
32
|
+
_MAX_NOTIFY_BYTES = 7_900
|
|
33
|
+
_MIN_RECONNECT_DELAY = 0.25
|
|
34
|
+
_MAX_RECONNECT_DELAY = 5.0
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class AsyncEngineLike(Protocol):
|
|
38
|
+
def begin(self) -> Any: ...
|
|
39
|
+
|
|
40
|
+
async def raw_connection(self) -> Any: ...
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
@dataclass(frozen=True, slots=True)
|
|
44
|
+
class TopicPublishInput:
|
|
45
|
+
type: str
|
|
46
|
+
body: SerializableValue
|
|
47
|
+
metadata: TopicMetadata = field(default_factory=dict)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
@dataclass(frozen=True, slots=True)
|
|
51
|
+
class TopicMessage:
|
|
52
|
+
id: str
|
|
53
|
+
topic: str
|
|
54
|
+
type: str
|
|
55
|
+
metadata: TopicMetadata
|
|
56
|
+
body: SerializableValue
|
|
57
|
+
published_at: str
|
|
58
|
+
|
|
59
|
+
@property
|
|
60
|
+
def publishedAt(self) -> str:
|
|
61
|
+
return self.published_at
|
|
62
|
+
|
|
63
|
+
def as_dict(self) -> dict[str, SerializableValue]:
|
|
64
|
+
return {
|
|
65
|
+
"id": self.id,
|
|
66
|
+
"topic": self.topic,
|
|
67
|
+
"type": self.type,
|
|
68
|
+
"metadata": self.metadata,
|
|
69
|
+
"body": self.body,
|
|
70
|
+
"publishedAt": self.published_at,
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
@dataclass(frozen=True, slots=True)
|
|
75
|
+
class PostgresTopicBusOptions:
|
|
76
|
+
channel: object = _DEFAULT_CHANNEL
|
|
77
|
+
metadata: TopicMetadata | TopicMetadataProvider | None = None
|
|
78
|
+
on_error: Callable[[BaseException], None] | None = None
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
class PostgresTopicBus:
|
|
82
|
+
def __init__(
|
|
83
|
+
self,
|
|
84
|
+
engine: AsyncEngine | AsyncEngineLike,
|
|
85
|
+
options: PostgresTopicBusOptions | None = None,
|
|
86
|
+
*,
|
|
87
|
+
channel: object | None = None,
|
|
88
|
+
metadata: TopicMetadata | TopicMetadataProvider | None = None,
|
|
89
|
+
on_error: Callable[[BaseException], None] | None = None,
|
|
90
|
+
) -> None:
|
|
91
|
+
configured = options or PostgresTopicBusOptions()
|
|
92
|
+
channel_value = configured.channel if channel is None else channel
|
|
93
|
+
self.engine = engine
|
|
94
|
+
self.channel_name = channel_name(channel_value)
|
|
95
|
+
self.metadata = configured.metadata if metadata is None else metadata
|
|
96
|
+
self.on_error = on_error or configured.on_error or (lambda error: None)
|
|
97
|
+
self._listeners: dict[str, set[TopicListener]] = {}
|
|
98
|
+
self._raw_connection: Any | None = None
|
|
99
|
+
self._driver_connection: Any | None = None
|
|
100
|
+
self._start_lock = asyncio.Lock()
|
|
101
|
+
self._reconnect_task: asyncio.Task[None] | None = None
|
|
102
|
+
self._closed = False
|
|
103
|
+
|
|
104
|
+
@property
|
|
105
|
+
def channelName(self) -> str:
|
|
106
|
+
return self.channel_name
|
|
107
|
+
|
|
108
|
+
async def start(self) -> None:
|
|
109
|
+
if self._driver_connection is not None:
|
|
110
|
+
return
|
|
111
|
+
if self._closed:
|
|
112
|
+
raise RuntimeError("Postgres topic bus is closed")
|
|
113
|
+
async with self._start_lock:
|
|
114
|
+
if self._driver_connection is not None:
|
|
115
|
+
return
|
|
116
|
+
raw_connection = await self.engine.raw_connection()
|
|
117
|
+
driver_connection = raw_connection.driver_connection
|
|
118
|
+
try:
|
|
119
|
+
await driver_connection.add_listener(self.channel_name, self._handle_notification)
|
|
120
|
+
add_termination_listener = getattr(
|
|
121
|
+
driver_connection, "add_termination_listener", None
|
|
122
|
+
)
|
|
123
|
+
if add_termination_listener:
|
|
124
|
+
add_termination_listener(self._handle_termination)
|
|
125
|
+
except BaseException:
|
|
126
|
+
await _maybe_await(raw_connection.close())
|
|
127
|
+
raise
|
|
128
|
+
if self._closed:
|
|
129
|
+
await driver_connection.remove_listener(
|
|
130
|
+
self.channel_name,
|
|
131
|
+
self._handle_notification,
|
|
132
|
+
)
|
|
133
|
+
await _maybe_await(raw_connection.close())
|
|
134
|
+
raise RuntimeError("Postgres topic bus is closed")
|
|
135
|
+
self._raw_connection = raw_connection
|
|
136
|
+
self._driver_connection = driver_connection
|
|
137
|
+
|
|
138
|
+
async def broadcast(
|
|
139
|
+
self,
|
|
140
|
+
topic: str,
|
|
141
|
+
message_input: TopicPublishInput | Mapping[str, Any],
|
|
142
|
+
) -> TopicMessage:
|
|
143
|
+
if not topic.strip():
|
|
144
|
+
raise TypeError("Topic must not be empty")
|
|
145
|
+
if self._closed:
|
|
146
|
+
raise RuntimeError("Postgres topic bus is closed")
|
|
147
|
+
publish = _publish_input(message_input)
|
|
148
|
+
if not publish.type.strip():
|
|
149
|
+
raise TypeError("Message type must not be empty")
|
|
150
|
+
if not _is_serializable(publish.metadata) or not _is_serializable(publish.body):
|
|
151
|
+
raise TypeError("Message metadata and body must be JSON serializable without coercion")
|
|
152
|
+
automatic = await self._resolve_metadata()
|
|
153
|
+
message = TopicMessage(
|
|
154
|
+
id=str(uuid.uuid4()),
|
|
155
|
+
topic=topic,
|
|
156
|
+
type=publish.type,
|
|
157
|
+
metadata={**automatic, **publish.metadata},
|
|
158
|
+
body=publish.body,
|
|
159
|
+
published_at=datetime.now(timezone.utc).isoformat().replace("+00:00", "Z"),
|
|
160
|
+
)
|
|
161
|
+
encoded = json.dumps(message.as_dict(), separators=(",", ":"), allow_nan=False)
|
|
162
|
+
if len(encoded.encode("utf-8")) > _MAX_NOTIFY_BYTES:
|
|
163
|
+
raise ValueError(f"Postgres notification exceeds {_MAX_NOTIFY_BYTES} bytes")
|
|
164
|
+
async with self.engine.begin() as connection:
|
|
165
|
+
await connection.execute(
|
|
166
|
+
text("SELECT pg_notify(:channel, :payload)"),
|
|
167
|
+
{"channel": self.channel_name, "payload": encoded},
|
|
168
|
+
)
|
|
169
|
+
return message
|
|
170
|
+
|
|
171
|
+
async def listen(self, topic: str, listener: TopicListener) -> Callable[[], Awaitable[None]]:
|
|
172
|
+
if not topic.strip():
|
|
173
|
+
raise TypeError("Topic must not be empty")
|
|
174
|
+
await self.start()
|
|
175
|
+
listeners = self._listeners.setdefault(topic, set())
|
|
176
|
+
listeners.add(listener)
|
|
177
|
+
|
|
178
|
+
async def unsubscribe() -> None:
|
|
179
|
+
listeners.discard(listener)
|
|
180
|
+
if not listeners:
|
|
181
|
+
self._listeners.pop(topic, None)
|
|
182
|
+
|
|
183
|
+
return unsubscribe
|
|
184
|
+
|
|
185
|
+
async def close(self) -> None:
|
|
186
|
+
if self._closed:
|
|
187
|
+
return
|
|
188
|
+
self._closed = True
|
|
189
|
+
if self._reconnect_task:
|
|
190
|
+
self._reconnect_task.cancel()
|
|
191
|
+
await asyncio.gather(self._reconnect_task, return_exceptions=True)
|
|
192
|
+
self._reconnect_task = None
|
|
193
|
+
raw_connection = self._raw_connection
|
|
194
|
+
driver_connection = self._driver_connection
|
|
195
|
+
self._raw_connection = None
|
|
196
|
+
self._driver_connection = None
|
|
197
|
+
self._listeners.clear()
|
|
198
|
+
if driver_connection is not None:
|
|
199
|
+
remove_termination_listener = getattr(
|
|
200
|
+
driver_connection,
|
|
201
|
+
"remove_termination_listener",
|
|
202
|
+
None,
|
|
203
|
+
)
|
|
204
|
+
if remove_termination_listener:
|
|
205
|
+
remove_termination_listener(self._handle_termination)
|
|
206
|
+
try:
|
|
207
|
+
await driver_connection.remove_listener(
|
|
208
|
+
self.channel_name,
|
|
209
|
+
self._handle_notification,
|
|
210
|
+
)
|
|
211
|
+
except Exception as error:
|
|
212
|
+
self.on_error(error)
|
|
213
|
+
if raw_connection is not None:
|
|
214
|
+
await _maybe_await(raw_connection.close())
|
|
215
|
+
|
|
216
|
+
async def _resolve_metadata(self) -> TopicMetadata:
|
|
217
|
+
configured = self.metadata() if callable(self.metadata) else (self.metadata or {})
|
|
218
|
+
if inspect.isawaitable(configured):
|
|
219
|
+
configured = await configured
|
|
220
|
+
if not _is_serializable(configured) or not isinstance(configured, dict):
|
|
221
|
+
raise TypeError("Bus metadata must be JSON serializable without coercion")
|
|
222
|
+
return {**_machine_metadata(), **configured}
|
|
223
|
+
|
|
224
|
+
def _handle_notification(
|
|
225
|
+
self,
|
|
226
|
+
connection: object,
|
|
227
|
+
process_id: int,
|
|
228
|
+
channel: str,
|
|
229
|
+
payload: str,
|
|
230
|
+
) -> None:
|
|
231
|
+
del connection, process_id
|
|
232
|
+
if channel != self.channel_name:
|
|
233
|
+
return
|
|
234
|
+
message = _decode(payload)
|
|
235
|
+
if message is None:
|
|
236
|
+
return
|
|
237
|
+
for listener in tuple(self._listeners.get(message.topic, ())):
|
|
238
|
+
asyncio.create_task(self._deliver(listener, message))
|
|
239
|
+
|
|
240
|
+
async def _deliver(self, listener: TopicListener, message: TopicMessage) -> None:
|
|
241
|
+
try:
|
|
242
|
+
result = listener(message)
|
|
243
|
+
if inspect.isawaitable(result):
|
|
244
|
+
await result
|
|
245
|
+
except Exception as error:
|
|
246
|
+
self.on_error(error)
|
|
247
|
+
|
|
248
|
+
def _handle_termination(self, connection: object) -> None:
|
|
249
|
+
del connection
|
|
250
|
+
if self._closed or not self._listeners or self._reconnect_task is not None:
|
|
251
|
+
return
|
|
252
|
+
raw_connection = self._raw_connection
|
|
253
|
+
self._raw_connection = None
|
|
254
|
+
self._driver_connection = None
|
|
255
|
+
self._reconnect_task = asyncio.create_task(self._reconnect(raw_connection))
|
|
256
|
+
|
|
257
|
+
async def _reconnect(self, raw_connection: object | None = None) -> None:
|
|
258
|
+
delay = 0.0
|
|
259
|
+
try:
|
|
260
|
+
if raw_connection is not None:
|
|
261
|
+
try:
|
|
262
|
+
await _maybe_await(raw_connection.close())
|
|
263
|
+
except Exception as error:
|
|
264
|
+
self.on_error(error)
|
|
265
|
+
while not self._closed and self._listeners:
|
|
266
|
+
if delay:
|
|
267
|
+
await asyncio.sleep(delay)
|
|
268
|
+
try:
|
|
269
|
+
await self.start()
|
|
270
|
+
return
|
|
271
|
+
except Exception as error:
|
|
272
|
+
if self._closed:
|
|
273
|
+
return
|
|
274
|
+
self.on_error(error)
|
|
275
|
+
delay = (
|
|
276
|
+
_MIN_RECONNECT_DELAY if delay == 0 else min(delay * 2, _MAX_RECONNECT_DELAY)
|
|
277
|
+
)
|
|
278
|
+
finally:
|
|
279
|
+
self._reconnect_task = None
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
def channel_name(channel: object = _DEFAULT_CHANNEL) -> str:
|
|
283
|
+
parts = list(channel) if isinstance(channel, (list, tuple)) else [channel]
|
|
284
|
+
stable = "\0".join(to_stable_key(part) for part in parts)
|
|
285
|
+
suffix = fnv_hash(stable, length=_CHANNEL_HASH_LENGTH)
|
|
286
|
+
labels = [part for part in parts if isinstance(part, (str, int, float, bool))]
|
|
287
|
+
body = to_identifier(*labels, delimiter="_")[: _MAX_CHANNEL_LENGTH - len(suffix) - 1].rstrip(
|
|
288
|
+
"_"
|
|
289
|
+
)
|
|
290
|
+
prefix = body if re.match(r"^[A-Za-z_]", body) else f"{_CHANNEL_FALLBACK}_{body}"
|
|
291
|
+
return re.sub(r"_+", "_", f"{prefix}_{suffix}")
|
|
292
|
+
|
|
293
|
+
|
|
294
|
+
def _publish_input(value: TopicPublishInput | Mapping[str, Any]) -> TopicPublishInput:
|
|
295
|
+
if isinstance(value, TopicPublishInput):
|
|
296
|
+
return value
|
|
297
|
+
return TopicPublishInput(
|
|
298
|
+
type=value.get("type", ""),
|
|
299
|
+
metadata=dict(value.get("metadata") or {}),
|
|
300
|
+
body=value.get("body"),
|
|
301
|
+
)
|
|
302
|
+
|
|
303
|
+
|
|
304
|
+
def _decode(payload: str) -> TopicMessage | None:
|
|
305
|
+
try:
|
|
306
|
+
value = json.loads(payload)
|
|
307
|
+
except (TypeError, json.JSONDecodeError):
|
|
308
|
+
return None
|
|
309
|
+
if not isinstance(value, dict) or not _is_serializable(value):
|
|
310
|
+
return None
|
|
311
|
+
required = ("id", "topic", "type", "metadata", "body", "publishedAt")
|
|
312
|
+
if not all(key in value for key in required) or not isinstance(value["metadata"], dict):
|
|
313
|
+
return None
|
|
314
|
+
if not all(isinstance(value[key], str) for key in ("id", "topic", "type", "publishedAt")):
|
|
315
|
+
return None
|
|
316
|
+
return TopicMessage(
|
|
317
|
+
id=value["id"],
|
|
318
|
+
topic=value["topic"],
|
|
319
|
+
type=value["type"],
|
|
320
|
+
metadata=value["metadata"],
|
|
321
|
+
body=value["body"],
|
|
322
|
+
published_at=value["publishedAt"],
|
|
323
|
+
)
|
|
324
|
+
|
|
325
|
+
|
|
326
|
+
def _machine_metadata() -> TopicMetadata:
|
|
327
|
+
values: dict[str, SerializableValue | None] = {
|
|
328
|
+
"project": _first_env(
|
|
329
|
+
"DATABRICKS_APP_NAME",
|
|
330
|
+
"DATABRICKS_BUNDLE_NAME",
|
|
331
|
+
"PROJECT_NAME",
|
|
332
|
+
),
|
|
333
|
+
"hostname": socket.gethostname(),
|
|
334
|
+
"platform": platform.system().lower(),
|
|
335
|
+
"environment": _env("PYTHON_ENV") or _env("NODE_ENV"),
|
|
336
|
+
"appName": _env("DATABRICKS_APP_NAME"),
|
|
337
|
+
"deploymentId": _env("DATABRICKS_APP_DEPLOYMENT_ID"),
|
|
338
|
+
"databricksHost": _env("DATABRICKS_HOST"),
|
|
339
|
+
}
|
|
340
|
+
return {key: value for key, value in values.items() if value is not None}
|
|
341
|
+
|
|
342
|
+
|
|
343
|
+
def _env(name: str) -> str | None:
|
|
344
|
+
value = os.environ.get(name)
|
|
345
|
+
return value.strip() if value and value.strip() else None
|
|
346
|
+
|
|
347
|
+
|
|
348
|
+
def _first_env(*names: str) -> str | None:
|
|
349
|
+
return next((value for name in names if (value := _env(name))), None)
|
|
350
|
+
|
|
351
|
+
|
|
352
|
+
def _is_serializable(value: object, seen: set[int] | None = None) -> bool:
|
|
353
|
+
if value is None or isinstance(value, (str, bool, int)):
|
|
354
|
+
return True
|
|
355
|
+
if isinstance(value, float):
|
|
356
|
+
return math.isfinite(value)
|
|
357
|
+
seen = seen or set()
|
|
358
|
+
identity = id(value)
|
|
359
|
+
if identity in seen:
|
|
360
|
+
return False
|
|
361
|
+
if isinstance(value, list):
|
|
362
|
+
seen.add(identity)
|
|
363
|
+
try:
|
|
364
|
+
return all(_is_serializable(item, seen) for item in value)
|
|
365
|
+
finally:
|
|
366
|
+
seen.remove(identity)
|
|
367
|
+
if isinstance(value, dict) and all(isinstance(key, str) for key in value):
|
|
368
|
+
seen.add(identity)
|
|
369
|
+
try:
|
|
370
|
+
return all(_is_serializable(item, seen) for item in value.values())
|
|
371
|
+
finally:
|
|
372
|
+
seen.remove(identity)
|
|
373
|
+
return False
|
|
374
|
+
|
|
375
|
+
|
|
376
|
+
async def _maybe_await(value: object) -> object:
|
|
377
|
+
return await value if inspect.isawaitable(value) else value
|
|
378
|
+
|
|
379
|
+
|
|
380
|
+
channelName = channel_name
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
Metadata-Version: 2.3
|
|
2
|
+
Name: dbx-tools-postgres
|
|
3
|
+
Version: 0.6.78
|
|
4
|
+
Summary: WorkspaceClient-backed Lakebase Postgres resolution, SQLAlchemy engines, advisory locks, and LISTEN/NOTIFY topic bus
|
|
5
|
+
Requires-Dist: asyncpg>=0.30,<1
|
|
6
|
+
Requires-Dist: databricks-sdk>=0.63.0,<1
|
|
7
|
+
Requires-Dist: greenlet>=3.2,<4
|
|
8
|
+
Requires-Dist: dbx-tools-core==0.6.78
|
|
9
|
+
Requires-Dist: psycopg[binary]>=3.2.9,<4
|
|
10
|
+
Requires-Dist: sqlalchemy>=2.0.41,<3
|
|
11
|
+
Requires-Python: >=3.10
|
|
12
|
+
Project-URL: Source, https://github.com/reggie-db/dbx-tools/tree/main/packages/py/postgres
|
|
13
|
+
Description-Content-Type: text/markdown
|
|
14
|
+
|
|
15
|
+
# `dbx-tools-postgres`
|
|
16
|
+
|
|
17
|
+
Python Lakebase/Postgres connection setup, advisory locks, and topic fan-out for
|
|
18
|
+
services that already hold a Databricks `WorkspaceClient`. This package is
|
|
19
|
+
currently an unpublished workspace package, and is the Python counterpart to
|
|
20
|
+
`@dbx-tools/postgres` plus `@dbx-tools/appkit`'s address parsing.
|
|
21
|
+
|
|
22
|
+
Install directly from this monorepo:
|
|
23
|
+
|
|
24
|
+
```bash
|
|
25
|
+
pip install "dbx-tools-postgres @ git+https://github.com/reggie-db/dbx-tools.git@main#subdirectory=packages/py/postgres"
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
Key features:
|
|
29
|
+
|
|
30
|
+
- accepts the same Postgres URI, Lakebase resource path, hostname, and project-id
|
|
31
|
+
address shapes as `@dbx-tools/appkit`;
|
|
32
|
+
- resolves missing autoscaling endpoint fields through
|
|
33
|
+
`WorkspaceClient.api_client`;
|
|
34
|
+
- resolves provisioned Lakebase instance DNS through
|
|
35
|
+
`WorkspaceClient.database`;
|
|
36
|
+
- injects a fresh database credential on SQLAlchemy's `do_connect` event rather
|
|
37
|
+
than storing an expiring password in the engine URL, using the SDK's
|
|
38
|
+
provisioned-instance API or the Autoscaling `/postgres/credentials` endpoint;
|
|
39
|
+
- supports sync psycopg and asyncpg SQLAlchemy engines;
|
|
40
|
+
- derives advisory-lock ids from the same stable structured keys as the Node
|
|
41
|
+
package and holds one checked-out connection for the full critical section;
|
|
42
|
+
- provides blocking and try-lock context managers for session and transaction
|
|
43
|
+
locks, with sync and async SQLAlchemy variants;
|
|
44
|
+
- fans messages out to every process on a channel with `PostgresTopicBus`, using
|
|
45
|
+
the same lifecycle and wire envelope as the Node package.
|
|
46
|
+
|
|
47
|
+
```python
|
|
48
|
+
from databricks.sdk import WorkspaceClient
|
|
49
|
+
from dbx_tools.postgres import PostgresEngineConfig, create_async_engine
|
|
50
|
+
|
|
51
|
+
engine = create_async_engine(
|
|
52
|
+
WorkspaceClient(),
|
|
53
|
+
PostgresEngineConfig(instance_name="my-lakebase", database="databricks_postgres"),
|
|
54
|
+
pool_pre_ping=True,
|
|
55
|
+
pool_recycle=1800,
|
|
56
|
+
)
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
Pass `credential_provider=` to either engine factory to inject credentials from
|
|
60
|
+
another source while retaining the same connect-time rotation behavior.
|
|
61
|
+
|
|
62
|
+
```python
|
|
63
|
+
from dbx_tools.postgres import advisory_transaction_lock
|
|
64
|
+
|
|
65
|
+
with advisory_transaction_lock(engine, ["schema-install", "v2"]) as connection:
|
|
66
|
+
connection.exec_driver_sql("CREATE TABLE IF NOT EXISTS ...")
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
## Topic bus
|
|
70
|
+
|
|
71
|
+
`PostgresTopicBus` is async Postgres topic fan-out built on `LISTEN`/`NOTIFY`. Its
|
|
72
|
+
public lifecycle and wire shape match `@dbx-tools/postgres`'s `PostgresTopicBus`,
|
|
73
|
+
so Node and Python services can share a channel:
|
|
74
|
+
|
|
75
|
+
- `PostgresTopicBus(engine, options)`;
|
|
76
|
+
- `channelName`;
|
|
77
|
+
- `await start()`;
|
|
78
|
+
- `await broadcast(topic, TopicPublishInput(...))`;
|
|
79
|
+
- `await listen(topic, listener)` returning an async unsubscribe function;
|
|
80
|
+
- `await close()`;
|
|
81
|
+
- envelope fields `id`, `topic`, `type`, `metadata`, `body`, and `publishedAt`.
|
|
82
|
+
|
|
83
|
+
Channel derivation ports the Node stable-key and FNV rules, so equivalent channel
|
|
84
|
+
parts resolve to the same PostgreSQL identifier in Python and Node.
|
|
85
|
+
|
|
86
|
+
```python
|
|
87
|
+
from dbx_tools.postgres import PostgresTopicBus, TopicPublishInput
|
|
88
|
+
|
|
89
|
+
bus = PostgresTopicBus(engine, channel=["billing", "production"])
|
|
90
|
+
|
|
91
|
+
unsubscribe = await bus.listen("invoice.updated", handle_invoice)
|
|
92
|
+
await bus.broadcast(
|
|
93
|
+
"invoice.updated",
|
|
94
|
+
TopicPublishInput(type="invoice.updated", body={"invoice_id": "inv-7"}),
|
|
95
|
+
)
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
Delivery is live and unstored, like PostgreSQL `LISTEN`/`NOTIFY` itself. Use a
|
|
99
|
+
table or queue when consumers need replay or acknowledgements.
|
|
100
|
+
|
|
101
|
+
## Databricks notebooks and Spark
|
|
102
|
+
|
|
103
|
+
Verified end to end against a Lakebase endpoint on serverless notebook compute.
|
|
104
|
+
[`packages/example/notebooks/bus-lakebase.py`](../../example/notebooks/bus-lakebase.py)
|
|
105
|
+
is the runnable version of everything below.
|
|
106
|
+
|
|
107
|
+
Two things about the Databricks Python runtime change how the bus is called, and
|
|
108
|
+
neither is a limitation of the bus itself:
|
|
109
|
+
|
|
110
|
+
- **A notebook kernel already runs an event loop**, so `asyncio.run` in a cell
|
|
111
|
+
raises `RuntimeError: asyncio.run() cannot be called from a running event
|
|
112
|
+
loop`. Drive the coroutine on a short-lived thread with its own loop rather
|
|
113
|
+
than reaching for `nest_asyncio` — the bus holds a dedicated `LISTEN`
|
|
114
|
+
connection bound to whichever loop started it, so one loop per bus lifetime is
|
|
115
|
+
the invariant to preserve.
|
|
116
|
+
- **Install with `%pip install`, not `pip install --target`.** A `--target`
|
|
117
|
+
install leaves the runtime's preloaded `typing_extensions` ahead of the new
|
|
118
|
+
one on `sys.path`, and importing `dbx_tools.postgres` then fails with
|
|
119
|
+
`ImportError: cannot import name 'TypeAliasType'`. `%pip` restarts the Python
|
|
120
|
+
process, which resolves it.
|
|
121
|
+
|
|
122
|
+
### Publishing from a Spark UDF
|
|
123
|
+
|
|
124
|
+
Publishing from executors works. Listening from them does not, and should not be
|
|
125
|
+
attempted: a UDF invocation is short-lived, while `listen` keeps a connection
|
|
126
|
+
open until `close`.
|
|
127
|
+
|
|
128
|
+
Executors have no Databricks credentials, so they cannot build a
|
|
129
|
+
`WorkspaceClient`. This is where connect-time credential injection pays off — the
|
|
130
|
+
driver mints the Lakebase token once and the UDF closes over it, so the executor
|
|
131
|
+
builds a plain SQLAlchemy engine and installs the token as its provider:
|
|
132
|
+
|
|
133
|
+
```python
|
|
134
|
+
from sqlalchemy import URL
|
|
135
|
+
from sqlalchemy.ext.asyncio import create_async_engine
|
|
136
|
+
from dbx_tools.postgres import (
|
|
137
|
+
PostgresTopicBus,
|
|
138
|
+
TopicPublishInput,
|
|
139
|
+
install_credential_injection,
|
|
140
|
+
)
|
|
141
|
+
|
|
142
|
+
# driver: resolve once, capture in the closure
|
|
143
|
+
resolved = resolve_postgres_connection(workspace_client, config)
|
|
144
|
+
token = workspace_client.api_client.do(
|
|
145
|
+
"POST",
|
|
146
|
+
"/api/2.0/postgres/credentials",
|
|
147
|
+
body={"endpoint": resolved.endpoint},
|
|
148
|
+
)["token"]
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
@udf(returnType=StringType())
|
|
152
|
+
def publish(key: str) -> str:
|
|
153
|
+
async def run() -> str:
|
|
154
|
+
engine = create_async_engine(
|
|
155
|
+
URL.create(
|
|
156
|
+
"postgresql+asyncpg",
|
|
157
|
+
username=resolved.user,
|
|
158
|
+
host=resolved.host,
|
|
159
|
+
port=resolved.port,
|
|
160
|
+
database=resolved.database,
|
|
161
|
+
query={"ssl": resolved.ssl_mode},
|
|
162
|
+
)
|
|
163
|
+
)
|
|
164
|
+
install_credential_injection(engine.sync_engine, lambda: token)
|
|
165
|
+
bus = PostgresTopicBus(engine, channel="app-events")
|
|
166
|
+
try:
|
|
167
|
+
message = await bus.broadcast(
|
|
168
|
+
"row.processed", TopicPublishInput(type="row.processed", body={"key": key})
|
|
169
|
+
)
|
|
170
|
+
return message.id
|
|
171
|
+
finally:
|
|
172
|
+
await bus.close()
|
|
173
|
+
await engine.dispose()
|
|
174
|
+
|
|
175
|
+
return asyncio.run(run())
|
|
176
|
+
```
|
|
177
|
+
|
|
178
|
+
Constraints worth knowing before this reaches production:
|
|
179
|
+
|
|
180
|
+
- A captured token EXPIRES (about an hour). Re-mint per job run; a long-running
|
|
181
|
+
streaming query needs a provider that refreshes instead of a captured string.
|
|
182
|
+
- Each UDF call opens and closes its own connection, so batch the publish at
|
|
183
|
+
partition scope (`mapInPandas`, `foreachPartition`) rather than per row.
|
|
184
|
+
- `spark.sparkContext.broadcast` is unavailable on serverless (Spark Connect).
|
|
185
|
+
A plain closure over driver-side values serializes with the UDF and is enough.
|
|
186
|
+
- Delivery stays live and unstored: if no listener is connected when the UDF
|
|
187
|
+
publishes, the message is gone. Write to a table when executors produce
|
|
188
|
+
results a consumer must not miss.
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
dbx_tools/postgres/__init__.py,sha256=Qb2fH8vxKBpEfuLL2wlgh6_5qS-LMrK2hYByjDczrf4,2780
|
|
2
|
+
dbx_tools/postgres/address.py,sha256=ulOuhMUzYeb5ZslYqe0rTNXqDfovk8QR5s8U6Aej20k,3729
|
|
3
|
+
dbx_tools/postgres/advisory_lock.py,sha256=1Lfp2UOoQ4vJvLOfg47hsKVJpIrfeRByGoAbyw2WMYA,7655
|
|
4
|
+
dbx_tools/postgres/engine.py,sha256=QqR49JVTTfvW-hoxiW08vSKfptClsyD4WsSkM08hcaA,15290
|
|
5
|
+
dbx_tools/postgres/topic_bus.py,sha256=Zr0mtHXr1Gv-cdOW-j7mVwQMZsqjMcVyM-SysjpyNIk,13731
|
|
6
|
+
dbx_tools_postgres-0.6.78.dist-info/WHEEL,sha256=lrO5MD1WVAWzcbNy_L2BwtfPrcM3KfUFpbKYXLkJX4A,80
|
|
7
|
+
dbx_tools_postgres-0.6.78.dist-info/METADATA,sha256=5IVKuaMhXQas5iHTHvzpktxmRNnlpApXIJqLazALD44,7623
|
|
8
|
+
dbx_tools_postgres-0.6.78.dist-info/RECORD,,
|