Skip to content

Commit 721fb9a

Browse files
committed
feat(events): update event backends to use psqlpy's Listener API and refactor connection configurations
1 parent 99acdf6 commit 721fb9a

File tree

9 files changed

+185
-123
lines changed

9 files changed

+185
-123
lines changed

.claude/agents/docs-vision.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
name: docs-vision
33
description: Documentation excellence, quality gate validation, and workspace cleanup specialist - ensures code quality, comprehensive docs, and clean workspace before completion
44
tools: mcp__context7__resolve-library-id, mcp__context7__get-library-docs, WebSearch, Read, Write, Edit, Glob, Grep, Bash, Find, Task
5-
model: sonnet
5+
model: opus
66
standards_uri: ../AGENTS.md#mandatory-code-quality-standards
77
guides_root: ../docs/guides/
88
workspace_root: ../specs/active/

.claude/agents/expert.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
name: expert
33
description: SQLSpec domain expert with comprehensive knowledge of database adapters, SQL parsing, type system, storage backends, and Litestar integration
44
tools: mcp__context7__resolve-library-id, mcp__context7__get-library-docs, WebSearch, mcp__zen__analyze, mcp__zen__thinkdeep, mcp__zen__debug, Read, Write, Edit, Glob, Grep, Bash, Find, Task
5-
model: sonnet
5+
model: opus
66
standards_uri: ../AGENTS.md#mandatory-code-quality-standards
77
guides_root: ../docs/guides/
88
workspace_root: ../specs/active/

.claude/agents/prd.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
name: prd
33
description: Product Requirements and Design agent for complex SQLSpec development spanning multiple files, adapters, and features
44
tools: mcp__zen__planner, mcp__context7__resolve-library-id, mcp__context7__get-library-docs, WebSearch, mcp__zen__consensus, Read, Write, Edit, Glob, Grep, Bash, Find, Task
5-
model: sonnet
5+
model: opus
66
standards_uri: ../AGENTS.md#mandatory-code-quality-standards
77
guides_root: ../docs/guides/
88
workspace_root: ../specs/active/

.claude/agents/testing.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
name: testing
33
description: Comprehensive testing specialist for SQLSpec - creates unit tests, integration tests, fixtures, and validates test coverage across all database adapters
44
tools: mcp__context7__resolve-library-id, mcp__context7__get-library-docs, Read, Write, Edit, Glob, Grep, Bash, Find, Task
5-
model: sonnet
5+
model: opus
66
standards_uri: ../AGENTS.md#mandatory-code-quality-standards
77
guides_root: ../docs/guides/
88
workspace_root: ../specs/active/

sqlspec/adapters/psqlpy/events/backend.py

Lines changed: 99 additions & 55 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,8 @@
1414
from sqlspec.utils.serializers import from_json, to_json
1515

1616
if TYPE_CHECKING:
17+
from psqlpy import Listener
18+
1719
from sqlspec.adapters.psqlpy.config import PsqlpyConfig
1820

1921

@@ -27,7 +29,11 @@
2729

2830

2931
class PsqlpyEventsBackend:
30-
"""Native LISTEN/NOTIFY backend for psqlpy adapters."""
32+
"""Native LISTEN/NOTIFY backend for psqlpy adapters.
33+
34+
Uses psqlpy's Listener API which provides a dedicated connection for
35+
receiving PostgreSQL NOTIFY messages via callbacks or async iteration.
36+
"""
3137

3238
supports_sync = False
3339
supports_async = True
@@ -39,8 +45,8 @@ def __init__(self, config: "PsqlpyConfig") -> None:
3945
raise ImproperConfigurationError(msg)
4046
self._config = config
4147
self._runtime = config.get_observability_runtime()
42-
self._listen_connection_async: Any | None = None
43-
self._listen_connection_async_cm: Any | None = None
48+
self._listener: Any | None = None
49+
self._listener_started: bool = False
4450

4551
async def publish_async(self, channel: str, payload: "dict[str, Any]", metadata: "dict[str, Any] | None" = None) -> str:
4652
event_id = uuid.uuid4().hex
@@ -56,25 +62,36 @@ def publish(self, *_: Any, **__: Any) -> str:
5662
raise ImproperConfigurationError(msg)
5763

5864
async def dequeue_async(self, channel: str, poll_interval: float) -> EventMessage | None:
59-
connection = await self._ensure_async_listener(channel)
60-
await connection.execute(f"LISTEN {channel}")
61-
future: asyncio.Future[str] = asyncio.get_running_loop().create_future()
65+
listener = await self._ensure_listener(channel)
66+
received_payload: str | None = None
67+
event = asyncio.Event()
6268

63-
def _callback(_conn: Any, _pid: int, notified_channel: str, payload: str) -> None:
64-
if notified_channel != channel or future.done():
65-
return
66-
future.set_result(payload)
69+
async def _callback(
70+
_connection: Any,
71+
payload: str,
72+
notified_channel: str,
73+
_process_id: int,
74+
) -> None:
75+
nonlocal received_payload
76+
if notified_channel == channel and received_payload is None:
77+
received_payload = payload
78+
event.set()
6779

68-
connection.add_listener(channel, _callback)
69-
try:
70-
try:
71-
payload = await asyncio.wait_for(future, timeout=poll_interval)
72-
except asyncio.TimeoutError:
73-
return None
74-
return self._decode_payload(channel, payload)
75-
finally:
76-
with contextlib.suppress(Exception):
77-
connection.remove_listener(channel, _callback)
80+
await listener.add_callback(channel=channel, callback=_callback)
81+
82+
if not self._listener_started:
83+
listener.listen()
84+
self._listener_started = True
85+
await asyncio.sleep(0.05)
86+
87+
with contextlib.suppress(asyncio.TimeoutError):
88+
await asyncio.wait_for(event.wait(), timeout=poll_interval)
89+
90+
await listener.clear_channel_callbacks(channel=channel)
91+
92+
if received_payload is not None:
93+
return self._decode_payload(channel, received_payload)
94+
return None
7895

7996
def dequeue(self, *_: Any, **__: Any) -> EventMessage | None:
8097
msg = "dequeue is not supported for sync Psqlpy backends"
@@ -87,15 +104,21 @@ def ack(self, _event_id: str) -> None:
87104
msg = "ack is not supported for sync Psqlpy backends"
88105
raise ImproperConfigurationError(msg)
89106

90-
async def _ensure_async_listener(self, channel: str) -> Any:
91-
if self._listen_connection_async is None:
92-
self._listen_connection_async_cm = self._config.provide_connection()
93-
self._listen_connection_async = await self._listen_connection_async_cm.__aenter__()
94-
try:
95-
await self._listen_connection_async.set_autocommit(True) # type: ignore[attr-defined]
96-
except Exception:
97-
pass
98-
return self._listen_connection_async
107+
async def _ensure_listener(self, channel: str) -> "Listener":
108+
if self._listener is None:
109+
pool = await self._config.provide_pool()
110+
self._listener = pool.listener()
111+
await self._listener.startup()
112+
return self._listener
113+
114+
async def shutdown(self) -> None:
115+
"""Shutdown the listener and release resources."""
116+
if self._listener is not None:
117+
if self._listener_started:
118+
self._listener.abort_listen()
119+
self._listener_started = False
120+
await self._listener.shutdown()
121+
self._listener = None
99122

100123
@staticmethod
101124
def _encode_payload(event_id: str, payload: "dict[str, Any]", metadata: "dict[str, Any] | None") -> str:
@@ -147,7 +170,11 @@ def _parse_timestamp(value: Any) -> datetime:
147170

148171

149172
class PsqlpyHybridEventsBackend:
150-
"""Durable hybrid backend combining queue storage with LISTEN/NOTIFY wakeups."""
173+
"""Durable hybrid backend combining queue storage with LISTEN/NOTIFY wakeups.
174+
175+
Uses psqlpy's Listener API for real-time notifications while persisting
176+
events to a durable queue table.
177+
"""
151178

152179
supports_sync = False
153180
supports_async = True
@@ -160,6 +187,8 @@ def __init__(self, config: "PsqlpyConfig", queue_backend: QueueEventBackend) ->
160187
self._config = config
161188
self._queue = queue_backend
162189
self._runtime = config.get_observability_runtime()
190+
self._listener: Any | None = None
191+
self._listener_started: bool = False
163192

164193
async def publish_async(self, channel: str, payload: "dict[str, Any]", metadata: "dict[str, Any] | None" = None) -> str:
165194
event_id = uuid.uuid4().hex
@@ -172,31 +201,30 @@ def publish(self, *_: Any, **__: Any) -> str:
172201
raise ImproperConfigurationError(msg)
173202

174203
async def dequeue_async(self, channel: str, poll_interval: float) -> EventMessage | None:
175-
connection_cm = self._config.provide_connection()
176-
connection = await connection_cm.__aenter__()
177-
try:
178-
listener = getattr(connection, "add_listener", None)
179-
if listener is None:
180-
return await self._queue.dequeue_async(channel)
181-
future: asyncio.Future[str] = asyncio.get_running_loop().create_future()
182-
183-
def _callback(_conn: Any, _pid: int, notified_channel: str, payload: str) -> None:
184-
if notified_channel != channel or future.done():
185-
return
186-
future.set_result(payload)
187-
188-
listener(channel, _callback)
189-
try:
190-
await asyncio.wait_for(future, timeout=poll_interval)
191-
except asyncio.TimeoutError:
192-
return await self._queue.dequeue_async(channel)
193-
return await self._queue.dequeue_async(channel)
194-
finally:
195-
with contextlib.suppress(Exception):
196-
remove = getattr(connection, "remove_listener", None)
197-
if remove:
198-
remove(channel, _callback)
199-
await connection_cm.__aexit__(None, None, None)
204+
listener = await self._ensure_listener(channel)
205+
event = asyncio.Event()
206+
207+
async def _callback(
208+
_connection: Any,
209+
_payload: str,
210+
notified_channel: str,
211+
_process_id: int,
212+
) -> None:
213+
if notified_channel == channel:
214+
event.set()
215+
216+
await listener.add_callback(channel=channel, callback=_callback)
217+
218+
if not self._listener_started:
219+
listener.listen()
220+
self._listener_started = True
221+
await asyncio.sleep(0.05)
222+
223+
with contextlib.suppress(asyncio.TimeoutError):
224+
await asyncio.wait_for(event.wait(), timeout=poll_interval)
225+
226+
await listener.clear_channel_callbacks(channel=channel)
227+
return await self._queue.dequeue_async(channel)
200228

201229
async def ack_async(self, event_id: str) -> None:
202230
await self._queue.ack_async(event_id)
@@ -206,6 +234,22 @@ def ack(self, _event_id: str) -> None:
206234
msg = "ack is not supported for sync Psqlpy backends"
207235
raise ImproperConfigurationError(msg)
208236

237+
async def _ensure_listener(self, channel: str) -> "Listener":
238+
if self._listener is None:
239+
pool = await self._config.provide_pool()
240+
self._listener = pool.listener()
241+
await self._listener.startup()
242+
return self._listener
243+
244+
async def shutdown(self) -> None:
245+
"""Shutdown the listener and release resources."""
246+
if self._listener is not None:
247+
if self._listener_started:
248+
self._listener.abort_listen()
249+
self._listener_started = False
250+
await self._listener.shutdown()
251+
self._listener = None
252+
209253
async def _publish_durable_async(
210254
self, channel: str, event_id: str, payload: "dict[str, Any]", metadata: "dict[str, Any] | None"
211255
) -> None:

sqlspec/adapters/psycopg/events/backend.py

Lines changed: 31 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
11
"""Psycopg LISTEN/NOTIFY and hybrid event backends."""
22

3-
import asyncio
43
import contextlib
54
import uuid
65
from datetime import datetime, timezone
@@ -89,21 +88,15 @@ async def _ensure_async_listener(self, channel: str) -> Any:
8988
if self._listen_connection_async is None:
9089
self._listen_connection_async_cm = self._config.provide_connection()
9190
self._listen_connection_async = await self._listen_connection_async_cm.__aenter__()
92-
try:
93-
await self._listen_connection_async.set_autocommit(True) # type: ignore[attr-defined]
94-
except Exception:
95-
pass
91+
await self._listen_connection_async.set_autocommit(True)
9692
await self._listen_connection_async.execute(f"LISTEN {channel}")
9793
return self._listen_connection_async
9894

9995
def _ensure_sync_listener(self, channel: str) -> Any:
10096
if self._listen_connection_sync is None:
10197
self._listen_connection_sync_cm = self._config.provide_connection()
10298
self._listen_connection_sync = self._listen_connection_sync_cm.__enter__()
103-
try:
104-
self._listen_connection_sync.autocommit = True
105-
except Exception:
106-
pass
99+
self._listen_connection_sync.autocommit = True
107100
self._listen_connection_sync.execute(f"LISTEN {channel}")
108101
return self._listen_connection_sync
109102

@@ -170,6 +163,10 @@ def __init__(self, config: "DatabaseConfigProtocol[Any, Any, Any]", queue_backen
170163
self._config = config
171164
self._queue = queue_backend
172165
self._runtime = config.get_observability_runtime()
166+
self._listen_connection_async: Any | None = None
167+
self._listen_connection_async_cm: Any | None = None
168+
self._listen_connection_sync: Any | None = None
169+
self._listen_connection_sync_cm: Any | None = None
173170

174171
async def publish_async(self, channel: str, payload: "dict[str, Any]", metadata: "dict[str, Any] | None" = None) -> str:
175172
event_id = uuid.uuid4().hex
@@ -184,31 +181,33 @@ def publish(self, channel: str, payload: "dict[str, Any]", metadata: "dict[str,
184181
return event_id
185182

186183
async def dequeue_async(self, channel: str, poll_interval: float) -> EventMessage | None:
187-
connection_cm = self._config.provide_connection()
188-
connection = await connection_cm.__aenter__()
189-
try:
190-
await connection.execute(f"LISTEN {channel}")
191-
async for _notify in connection.notifies(timeout=poll_interval, stop_after=1):
192-
break
193-
return await self._queue.dequeue_async(channel)
194-
finally:
195-
with contextlib.suppress(Exception):
196-
await connection.execute(f"UNLISTEN {channel}")
197-
await connection_cm.__aexit__(None, None, None)
184+
connection = await self._ensure_async_listener(channel)
185+
async for _notify in connection.notifies(timeout=poll_interval, stop_after=1):
186+
break
187+
return await self._queue.dequeue_async(channel)
198188

199189
def dequeue(self, channel: str, poll_interval: float) -> EventMessage | None:
200-
connection_cm = self._config.provide_connection()
201-
connection = connection_cm.__enter__()
202-
try:
203-
connection.execute(f"LISTEN {channel}")
204-
notify_iter = connection.notifies(timeout=poll_interval, stop_after=1)
205-
with contextlib.suppress(StopIteration):
206-
next(notify_iter)
207-
return self._queue.dequeue(channel)
208-
finally:
209-
with contextlib.suppress(Exception):
210-
connection.execute(f"UNLISTEN {channel}")
211-
connection_cm.__exit__(None, None, None)
190+
connection = self._ensure_sync_listener(channel)
191+
notify_iter = connection.notifies(timeout=poll_interval, stop_after=1)
192+
with contextlib.suppress(StopIteration):
193+
next(notify_iter)
194+
return self._queue.dequeue(channel)
195+
196+
async def _ensure_async_listener(self, channel: str) -> Any:
197+
if self._listen_connection_async is None:
198+
self._listen_connection_async_cm = self._config.provide_connection()
199+
self._listen_connection_async = await self._listen_connection_async_cm.__aenter__()
200+
await self._listen_connection_async.set_autocommit(True)
201+
await self._listen_connection_async.execute(f"LISTEN {channel}")
202+
return self._listen_connection_async
203+
204+
def _ensure_sync_listener(self, channel: str) -> Any:
205+
if self._listen_connection_sync is None:
206+
self._listen_connection_sync_cm = self._config.provide_connection()
207+
self._listen_connection_sync = self._listen_connection_sync_cm.__enter__()
208+
self._listen_connection_sync.autocommit = True
209+
self._listen_connection_sync.execute(f"LISTEN {channel}")
210+
return self._listen_connection_sync
212211

213212
async def ack_async(self, event_id: str) -> None:
214213
await self._queue.ack_async(event_id)

tests/integration/test_adapters/test_asyncpg/test_extensions/test_events.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ async def test_asyncpg_native_event_channel(postgres_service: PostgresService) -
1515
"""AsyncPG configs surface native LISTEN/NOTIFY events."""
1616

1717
config = AsyncpgConfig(
18-
pool_config={
18+
connection_config={
1919
"host": postgres_service.host,
2020
"port": postgres_service.port,
2121
"user": postgres_service.user,
@@ -33,5 +33,5 @@ async def test_asyncpg_native_event_channel(postgres_service: PostgresService) -
3333
event_id = await channel.publish_async("notifications", {"action": "native"})
3434
await channel.ack_async(event_id)
3535

36-
if config.pool_instance:
36+
if config.connection_instance:
3737
await config.close_pool()

0 commit comments

Comments
 (0)