33import asyncio
44import typing
55
6+ import anyio
67import pytest
78from psqlpy .exceptions import ListenerStartError
89
1516TEST_CHANNEL = "test_channel"
1617TEST_PAYLOAD = "test_payload"
1718
19+ # How long helpers wait for an asynchronous condition before giving up.
20+ # These bound every wait in this module so a lost/late NOTIFY surfaces as a
21+ # fast, explicit failure instead of hanging the whole test session forever
22+ # (which is what used to wedge the GitHub runners).
23+ WAIT_TIMEOUT = 10.0
24+ POLL_INTERVAL = 0.05
25+ # Time we allow a notification to be (not) delivered before asserting absence.
26+ SETTLE_TIMEOUT = 1.0
27+
1828
1929async def construct_listener (
2030 psql_pool : ConnectionPool ,
@@ -54,44 +64,79 @@ async def callback(
5464 return callback
5565
5666
67+ async def wait_until_listening (
68+ listener : Listener ,
69+ * channels : str ,
70+ ) -> None :
71+ """Block until the listener's backend session is subscribed to ``channels``.
72+
73+ ``Listener.listen()`` and async iteration issue the ``LISTEN`` statements
74+ lazily from a background task, so a ``NOTIFY`` sent immediately afterwards
75+ can race ahead of the subscription and be lost. Polling
76+ ``pg_listening_channels()`` on the listener's own connection removes that
77+ race deterministically.
78+ """
79+ wanted = set (channels )
80+ with anyio .fail_after (WAIT_TIMEOUT ):
81+ while True :
82+ result = await listener .connection .execute (
83+ "SELECT pg_listening_channels() AS channel" ,
84+ )
85+ active = {row ["channel" ] for row in result .result ()}
86+ if wanted <= active :
87+ return
88+ await asyncio .sleep (POLL_INTERVAL )
89+
90+
5791async def notify (
5892 psql_pool : ConnectionPool ,
5993 channel : str = TEST_CHANNEL ,
60- with_delay : bool = False ,
6194) -> None :
62- if with_delay :
63- await asyncio .sleep (0.5 )
64-
6595 connection = await psql_pool .connection ()
66- await connection .execute (f"NOTIFY { channel } , '{ TEST_PAYLOAD } '" )
67- connection .close ()
96+ try :
97+ await connection .execute (f"NOTIFY { channel } , '{ TEST_PAYLOAD } '" )
98+ finally :
99+ connection .close ()
68100
69101
70- async def check_insert_callback (
102+ async def wait_for_callback (
71103 psql_pool : ConnectionPool ,
72104 listener_table_name : str ,
73- is_insert_exist : bool = True ,
74105 number_of_data : int = 1 ,
75- ) -> None :
76- connection = await psql_pool .connection ()
77- test_data_seq = (
78- await connection .execute (
79- f"SELECT * FROM { listener_table_name } " ,
80- )
81- ).result ()
106+ ) -> list [dict [str , typing .Any ]]:
107+ """Poll the result table until the callback has inserted ``number_of_data`` rows."""
108+ with anyio .fail_after (WAIT_TIMEOUT ):
109+ while True :
110+ rows = await read_test_table (psql_pool , listener_table_name )
111+ if len (rows ) >= number_of_data :
112+ return rows
113+ await asyncio .sleep (POLL_INTERVAL )
82114
83- if is_insert_exist :
84- assert len (test_data_seq ) == number_of_data
85- else :
86- assert not len (test_data_seq )
87- return
88115
89- data_record = test_data_seq [0 ]
116+ async def read_test_table (
117+ psql_pool : ConnectionPool ,
118+ listener_table_name : str ,
119+ ) -> list [dict [str , typing .Any ]]:
120+ connection = await psql_pool .connection ()
121+ try :
122+ return (
123+ await connection .execute (
124+ f"SELECT * FROM { listener_table_name } " ,
125+ )
126+ ).result ()
127+ finally :
128+ connection .close ()
90129
91- assert data_record ["payload" ] == TEST_PAYLOAD
92- assert data_record ["channel" ] == TEST_CHANNEL
93130
94- connection .close ()
131+ async def assert_no_callback (
132+ psql_pool : ConnectionPool ,
133+ listener_table_name : str ,
134+ settle : float = SETTLE_TIMEOUT ,
135+ ) -> None :
136+ """Give a notification time to (not) be delivered, then assert nothing landed."""
137+ await asyncio .sleep (settle )
138+ rows = await read_test_table (psql_pool , listener_table_name )
139+ assert not rows
95140
96141
97142async def clear_test_table (
@@ -118,13 +163,15 @@ async def test_listener_listen(
118163 await listener .startup ()
119164 listener .listen ()
120165
166+ await wait_until_listening (listener , TEST_CHANNEL )
121167 await notify (psql_pool = psql_pool )
122- await asyncio .sleep (0.5 )
123168
124- await check_insert_callback (
169+ rows = await wait_for_callback (
125170 psql_pool = psql_pool ,
126171 listener_table_name = listener_table_name ,
127172 )
173+ assert rows [0 ]["payload" ] == TEST_PAYLOAD
174+ assert rows [0 ]["channel" ] == TEST_CHANNEL
128175
129176 await listener .shutdown ()
130177
@@ -140,17 +187,21 @@ async def test_listener_asynciterator(
140187 )
141188 await listener .startup ()
142189
143- asyncio .create_task ( # noqa: RUF006
144- notify (
145- psql_pool = psql_pool ,
146- with_delay = True ,
147- ),
148- )
149-
150- async for listener_msg in listener :
151- assert listener_msg .channel == TEST_CHANNEL
152- assert listener_msg .payload == TEST_PAYLOAD
153- break
190+ async def trigger () -> None :
191+ # Iteration subscribes lazily inside ``__anext__``; wait for the
192+ # subscription before notifying so the message can't be lost.
193+ await wait_until_listening (listener , TEST_CHANNEL )
194+ await notify (psql_pool = psql_pool )
195+
196+ # Bound the iteration: if the notification is never delivered this fails
197+ # fast instead of blocking the session forever.
198+ with anyio .fail_after (WAIT_TIMEOUT ):
199+ async with anyio .create_task_group () as task_group :
200+ task_group .start_soon (trigger )
201+ async for listener_msg in listener :
202+ assert listener_msg .channel == TEST_CHANNEL
203+ assert listener_msg .payload == TEST_PAYLOAD
204+ break
154205
155206 await listener .shutdown ()
156207
@@ -167,10 +218,10 @@ async def test_listener_abort(
167218 await listener .startup ()
168219 listener .listen ()
169220
221+ await wait_until_listening (listener , TEST_CHANNEL )
170222 await notify (psql_pool = psql_pool )
171- await asyncio .sleep (0.5 )
172223
173- await check_insert_callback (
224+ await wait_for_callback (
174225 psql_pool = psql_pool ,
175226 listener_table_name = listener_table_name ,
176227 )
@@ -183,12 +234,10 @@ async def test_listener_abort(
183234 )
184235
185236 await notify (psql_pool = psql_pool )
186- await asyncio .sleep (0.5 )
187237
188- await check_insert_callback (
238+ await assert_no_callback (
189239 psql_pool = psql_pool ,
190240 listener_table_name = listener_table_name ,
191- is_insert_exist = False ,
192241 )
193242
194243
@@ -215,8 +264,11 @@ async def test_listener_double_start_exc(
215264 )
216265 await listener .startup ()
217266
218- with pytest .raises (expected_exception = ListenerStartError ):
219- await listener .startup ()
267+ try :
268+ with pytest .raises (expected_exception = ListenerStartError ):
269+ await listener .startup ()
270+ finally :
271+ await listener .shutdown ()
220272
221273
222274@pytest .mark .usefixtures ("create_table_for_listener_tests" )
@@ -238,14 +290,14 @@ async def test_listener_more_than_one_callback(
238290 await listener .startup ()
239291 listener .listen ()
240292
293+ await wait_until_listening (listener , TEST_CHANNEL , additional_channel )
241294 for channel in [TEST_CHANNEL , additional_channel ]:
242295 await notify (
243296 psql_pool = psql_pool ,
244297 channel = channel ,
245298 )
246299
247- await asyncio .sleep (0.5 )
248- await check_insert_callback (
300+ await wait_for_callback (
249301 psql_pool = psql_pool ,
250302 listener_table_name = listener_table_name ,
251303 number_of_data = 2 ,
@@ -282,12 +334,10 @@ async def test_listener_clear_callbacks(
282334 )
283335
284336 await notify (psql_pool = psql_pool )
285- await asyncio .sleep (0.5 )
286337
287- await check_insert_callback (
338+ await assert_no_callback (
288339 psql_pool = psql_pool ,
289340 listener_table_name = listener_table_name ,
290- is_insert_exist = False ,
291341 )
292342
293343 await listener .shutdown ()
@@ -309,12 +359,10 @@ async def test_listener_clear_all_callbacks(
309359 await listener .clear_all_channels ()
310360
311361 await notify (psql_pool = psql_pool )
312- await asyncio .sleep (0.5 )
313362
314- await check_insert_callback (
363+ await assert_no_callback (
315364 psql_pool = psql_pool ,
316365 listener_table_name = listener_table_name ,
317- is_insert_exist = False ,
318366 )
319367
320368 await listener .shutdown ()
0 commit comments