Skip to content

Commit e61f034

Browse files
authored
Merge pull request #174 from psqlpy-python/fix/listener-ci-hang
Fix intermittent listener test hangs (LISTEN/UNLISTEN deadlock, panic, flaky tests)
2 parents 651a7c5 + 4fe2961 commit e61f034

5 files changed

Lines changed: 202 additions & 97 deletions

File tree

pyproject.toml

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,9 @@ lint = [
4848
"ruff"
4949
]
5050
test = [
51-
"pytest"
51+
"pytest",
52+
"pytest-timeout",
53+
"anyio",
5254
]
5355

5456
[tool.maturin]
@@ -61,6 +63,13 @@ homepage = "https://github.com/psqlpy-python/psqlpy"
6163
repository = "https://github.com/psqlpy-python/psqlpy"
6264
documentation = "https://psqlpy-python.github.io/"
6365

66+
[tool.pytest.ini_options]
67+
# Safety net so a wedged test (e.g. a listener waiting on a notification that
68+
# never arrives) fails fast instead of hanging the whole CI job. The listener
69+
# tests bound their own waits well below this; this is the backstop for anything
70+
# unforeseen.
71+
timeout = 120
72+
6473
[tool.mypy]
6574
strict = true
6675
mypy_path = "python"

python/tests/test_listener.py

Lines changed: 100 additions & 52 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
import asyncio
44
import typing
55

6+
import anyio
67
import pytest
78
from psqlpy.exceptions import ListenerStartError
89

@@ -15,6 +16,15 @@
1516
TEST_CHANNEL = "test_channel"
1617
TEST_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

1929
async 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+
5791
async 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

97142
async 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()

src/driver/common.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -371,7 +371,7 @@ macro_rules! impl_copy_records_method {
371371
/// pass Python values directly (the same conversions used by
372372
/// `execute`). Mirrors `asyncpg.Connection.copy_records_to_table`.
373373
///
374-
/// The encoder follows asyncpg's algorithm: a single BytesMut
374+
/// The encoder follows asyncpg's algorithm: a single `BytesMut`
375375
/// accumulator flushed into 512 KiB (`_COPY_BUFFER_SIZE`) chunks.
376376
/// All rows are encoded during the GIL pass; chunks are sent to the
377377
/// server in a second pass after the GIL is released.

0 commit comments

Comments
 (0)