Skip to content

Commit 1895c42

Browse files
committed
fix: preserve stream and event configuration contracts
1 parent e26984c commit 1895c42

8 files changed

Lines changed: 219 additions & 36 deletions

File tree

docs/changelog.rst

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,18 @@ Unreleased
2121
return result envelopes, object DDL lookups return ``DDLResult`` directly, and
2222
callers should inspect capability or DDL status instead of treating empty
2323
lists as unsupported metadata.
24+
* Standardized event transport configuration on ``notify``, ``notify_queue``,
25+
``poll_queue``, ``aq``, and ``txeventq``. Retired transport names now raise
26+
an explicit configuration error with the canonical replacement.
27+
28+
**Fixed:**
29+
30+
* Event channels now honor adapter ``events_backend`` driver features when no
31+
extension-level backend is configured.
32+
* Early mysql-connector row-stream cleanup now consumes unread results without
33+
reconnecting underneath an active transaction.
34+
* Public row streams continue to clean up duck-typed sources whose ``close()``
35+
method uses the original no-argument contract.
2436

2537
**Docs:**
2638

docs/reference/extensions/events.rst

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,11 @@ The durable queue is the source of truth for ``notify_queue``; native
2222
notifications only prompt consumers to check it. Durable event queues are not
2323
browser fan-out transports.
2424

25+
Set ``extension_config["events"]["backend"]`` to select the transport. The
26+
adapter ``driver_features["events_backend"]`` value is used only when the
27+
extension setting is absent. Retired transport names fail with an explicit
28+
canonical replacement instead of silently changing delivery semantics.
29+
2530
``polling`` is not a SQLSpec backend name. Litestar Queues uses it for the
2631
fallback worker mode where no push wakeup transport is available and the
2732
worker waits for its configured polling interval.

sqlspec/adapters/mysqlconnector/core.py

Lines changed: 14 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -232,16 +232,15 @@ def fetch_chunk(self) -> "list[dict[str, Any]]":
232232
def close(self, error: bool = False) -> None:
233233
cursor = self._cursor
234234
self._cursor = None
235-
if cursor is not None:
236-
with contextlib.suppress(Exception):
237-
cursor.close()
238235
connection = self._driver.connection
239236
raw_connection = getattr(connection, "_cnx", None) or connection
240-
if getattr(raw_connection, "unread_result", False):
241-
with contextlib.suppress(Exception):
242-
raw_connection.shutdown()
243-
raw_connection.unread_result = False
244-
raw_connection.reconnect()
237+
try:
238+
if getattr(raw_connection, "unread_result", False):
239+
raw_connection.consume_results()
240+
finally:
241+
if cursor is not None:
242+
with contextlib.suppress(Exception):
243+
cursor.close()
245244

246245

247246
class MysqlConnectorAsyncStreamSource:
@@ -302,16 +301,15 @@ async def fetch_chunk(self) -> "list[dict[str, Any]]":
302301
async def close(self, error: bool = False) -> None:
303302
cursor = self._cursor
304303
self._cursor = None
305-
if cursor is not None:
306-
with contextlib.suppress(Exception):
307-
await cursor.close()
308304
connection = self._driver.connection
309305
raw_connection = getattr(connection, "_cnx", None) or connection
310-
if getattr(raw_connection, "unread_result", False):
311-
with contextlib.suppress(Exception):
312-
await raw_connection.shutdown()
313-
raw_connection.unread_result = False
314-
await raw_connection.reconnect()
306+
try:
307+
if getattr(raw_connection, "unread_result", False):
308+
await raw_connection.consume_results()
309+
finally:
310+
if cursor is not None:
311+
with contextlib.suppress(Exception):
312+
await cursor.close()
315313

316314

317315
def normalize_execute_many_parameters(parameters: Any) -> Any:

sqlspec/driver/_stream.py

Lines changed: 27 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111

1212
import builtins
1313
import contextlib
14+
import inspect
1415
from typing import TYPE_CHECKING, Any, Generic, Protocol, TypeVar, cast, overload
1516

1617
from typing_extensions import Self
@@ -43,7 +44,7 @@ def start(self) -> None: ...
4344

4445
def fetch_chunk(self) -> "list[dict[str, Any]]": ...
4546

46-
def close(self, error: bool = False) -> None: ...
47+
def close(self) -> None: ...
4748

4849

4950
class AsyncRowSource(Protocol):
@@ -53,7 +54,29 @@ async def start(self) -> None: ...
5354

5455
async def fetch_chunk(self) -> "list[dict[str, Any]]": ...
5556

56-
async def close(self, error: bool = False) -> None: ...
57+
async def close(self) -> None: ...
58+
59+
60+
def _close_sync_source(source: SyncRowSource, error: bool) -> None:
61+
"""Close a source while preserving the original no-argument contract."""
62+
close = source.close
63+
try:
64+
inspect.signature(close).bind(error=error)
65+
except (TypeError, ValueError):
66+
close()
67+
return
68+
cast("Any", close)(error=error)
69+
70+
71+
async def _close_async_source(source: AsyncRowSource, error: bool) -> None:
72+
"""Close an async source while preserving the original no-argument contract."""
73+
close = source.close
74+
try:
75+
inspect.signature(close).bind(error=error)
76+
except (TypeError, ValueError):
77+
await close()
78+
return
79+
await cast("Any", close)(error=error)
5780

5881

5982
def rows_to_dicts(rows: "list[Any]", column_names: "list[str]") -> "list[dict[str, Any]]":
@@ -142,7 +165,7 @@ def _close(self, error: bool = False) -> None:
142165
self._buffer = []
143166
self._buffer_index = 0
144167
with contextlib.suppress(Exception):
145-
self._source.close(error=error)
168+
_close_sync_source(self._source, error)
146169

147170

148171
class AsyncRowStream(Generic[RowT]):
@@ -224,7 +247,7 @@ async def _aclose(self, error: bool = False) -> None:
224247
self._buffer = []
225248
self._buffer_index = 0
226249
with contextlib.suppress(Exception):
227-
await self._source.close(error=error)
250+
await _close_async_source(self._source, error)
228251

229252

230253
class EagerSyncRowSource:

sqlspec/extensions/events/_channel.py

Lines changed: 28 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,12 @@ def _resolve_event_type(payload: "dict[str, Any]", metadata: "dict[str, Any] | N
9292

9393

9494
_POSTGRES_ADAPTERS = frozenset({"asyncpg", "psycopg", "psqlpy"})
95+
_EVENT_BACKENDS = frozenset({"notify", "notify_queue", "poll_queue", "aq", "txeventq"})
96+
_RETIRED_EVENT_BACKENDS = {
97+
"listen_notify": "notify",
98+
"listen_notify_durable": "notify_queue",
99+
"table_queue": "poll_queue",
100+
}
95101

96102

97103
def _get_default_backend(adapter_name: "str | None") -> str:
@@ -101,6 +107,26 @@ def _get_default_backend(adapter_name: "str | None") -> str:
101107
return "poll_queue"
102108

103109

110+
def _resolve_backend_name(config: Any, extension_settings: "dict[str, Any]", adapter_name: "str | None") -> str:
111+
"""Resolve and validate event backend configuration."""
112+
backend_name = extension_settings.get("backend")
113+
if backend_name is None:
114+
driver_features = getattr(config, "driver_features", {})
115+
if isinstance(driver_features, dict):
116+
backend_name = driver_features.get("events_backend")
117+
if backend_name is None:
118+
return _get_default_backend(adapter_name)
119+
if backend_name in _RETIRED_EVENT_BACKENDS:
120+
replacement = _RETIRED_EVENT_BACKENDS[backend_name]
121+
msg = f"Event backend {backend_name!r} was removed; use {replacement!r}"
122+
raise ImproperConfigurationError(msg)
123+
if backend_name not in _EVENT_BACKENDS:
124+
valid = ", ".join(sorted(_EVENT_BACKENDS))
125+
msg = f"Unknown event backend {backend_name!r}; expected one of: {valid}"
126+
raise ImproperConfigurationError(msg)
127+
return cast("str", backend_name)
128+
129+
104130
def load_native_backend(
105131
config: Any, backend_name: str | None, extension_settings: "dict[str, Any]", adapter_name: "str | None" = None
106132
) -> Any | None:
@@ -363,7 +389,7 @@ def __init__(self, config: "SyncDatabaseConfig[Any, Any, Any]") -> None:
363389
hints = get_runtime_hints(self._adapter_name, config)
364390
self._poll_interval_default = float(extension_settings.get("poll_interval") or hints.poll_interval)
365391
queue_backend = build_queue_backend(config, extension_settings, adapter_name=self._adapter_name, hints=hints)
366-
backend_name = extension_settings.get("backend") or _get_default_backend(self._adapter_name)
392+
backend_name = _resolve_backend_name(config, extension_settings, self._adapter_name)
367393
native_backend = load_native_backend(config, backend_name, extension_settings, adapter_name=self._adapter_name)
368394
if native_backend is None:
369395
if backend_name not in {None, "poll_queue"}:
@@ -591,7 +617,7 @@ def __init__(self, config: "AsyncDatabaseConfig[Any, Any, Any]") -> None:
591617
hints = get_runtime_hints(self._adapter_name, config)
592618
self._poll_interval_default = float(extension_settings.get("poll_interval") or hints.poll_interval)
593619
queue_backend = build_queue_backend(config, extension_settings, adapter_name=self._adapter_name, hints=hints)
594-
backend_name = extension_settings.get("backend") or _get_default_backend(self._adapter_name)
620+
backend_name = _resolve_backend_name(config, extension_settings, self._adapter_name)
595621
native_backend = load_native_backend(config, backend_name, extension_settings, adapter_name=self._adapter_name)
596622
if native_backend is None:
597623
if backend_name not in {None, "poll_queue"}:

tests/unit/adapters/test_mysqlconnector/test_config.py

Lines changed: 45 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -289,43 +289,76 @@ async def test_async_dispatch_select_stream_uses_driver_cursor_options(monkeypat
289289
connection.cursor.assert_awaited_once_with(prepared=True, buffered=False)
290290

291291

292-
def test_sync_stream_close_reconnects_instead_of_draining_unread_rows() -> None:
293-
"""Early close discards the dirty protocol session without fetching remaining rows."""
292+
def test_sync_stream_close_consumes_unread_rows_without_reconnecting() -> None:
293+
"""Early close preserves the current session and any active transaction."""
294294
connection = MagicMock()
295295
connection.unread_result = True
296296
connection._cnx = None
297297
cursor = MagicMock()
298-
cursor.close.side_effect = RuntimeError("Unread result found")
299298
driver = MagicMock(connection=connection, driver_features={})
300299
source = MysqlConnectorSyncStreamSource(driver, "SELECT 1", None, 10, set())
301300
source._cursor = cursor
302301

303302
source.close()
304303

305-
cursor.fetchall.assert_not_called()
306-
connection.shutdown.assert_called_once_with()
307-
connection.reconnect.assert_called_once_with()
304+
connection.consume_results.assert_called_once_with()
305+
cursor.close.assert_called_once_with()
306+
connection.shutdown.assert_not_called()
307+
connection.reconnect.assert_not_called()
308+
309+
310+
def test_sync_stream_close_closes_cursor_when_consuming_unread_rows_fails() -> None:
311+
connection = MagicMock(unread_result=True, _cnx=None)
312+
connection.consume_results.side_effect = RuntimeError("consume failed")
313+
cursor = MagicMock()
314+
source = MysqlConnectorSyncStreamSource(MagicMock(connection=connection), "SELECT 1", None, 10, set())
315+
source._cursor = cursor
316+
317+
with pytest.raises(RuntimeError, match="consume failed"):
318+
source.close()
319+
320+
cursor.close.assert_called_once_with()
321+
connection.reconnect.assert_not_called()
308322

309323

310324
@pytest.mark.anyio
311-
async def test_async_stream_close_reconnects_instead_of_draining_unread_rows() -> None:
312-
"""Async early close replaces the dirty protocol session without fetching remaining rows."""
325+
async def test_async_stream_close_consumes_unread_rows_without_reconnecting() -> None:
326+
"""Async early close preserves the current session and active transaction."""
313327
connection = MagicMock()
314328
connection.unread_result = True
315329
connection._cnx = None
330+
connection.consume_results = AsyncMock()
316331
connection.shutdown = AsyncMock()
317332
connection.reconnect = AsyncMock()
318333
cursor = MagicMock()
319-
cursor.close = AsyncMock(side_effect=RuntimeError("Unread result found"))
334+
cursor.close = AsyncMock()
320335
driver = MagicMock(connection=connection, driver_features={})
321336
source = MysqlConnectorAsyncStreamSource(driver, "SELECT 1", None, 10, set())
322337
source._cursor = cursor
323338

324339
await source.close()
325340

326-
cursor.fetchall.assert_not_called()
327-
connection.shutdown.assert_awaited_once_with()
328-
connection.reconnect.assert_awaited_once_with()
341+
connection.consume_results.assert_awaited_once_with()
342+
cursor.close.assert_awaited_once_with()
343+
connection.shutdown.assert_not_awaited()
344+
connection.reconnect.assert_not_awaited()
345+
346+
347+
@pytest.mark.anyio
348+
async def test_async_stream_close_closes_cursor_when_consuming_unread_rows_fails() -> None:
349+
connection = MagicMock(unread_result=True, _cnx=None)
350+
connection.consume_results = AsyncMock(side_effect=RuntimeError("consume failed"))
351+
connection.reconnect = AsyncMock()
352+
cursor = MagicMock()
353+
cursor.close = AsyncMock()
354+
source = MysqlConnectorAsyncStreamSource(MagicMock(connection=connection), "SELECT 1", None, 10, set())
355+
source._cursor = cursor
356+
357+
with pytest.raises(RuntimeError, match="consume failed"):
358+
await source.close()
359+
360+
cursor.close.assert_awaited_once_with()
361+
connection.reconnect.assert_not_awaited()
329362

330363

331364
def test_sync_create_connection_forwards_local_infile_gate(monkeypatch: pytest.MonkeyPatch) -> None:

tests/unit/driver/test_stream.py

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -176,6 +176,26 @@ def close(self, error: bool = False) -> None:
176176
assert source.close_calls == 1
177177

178178

179+
def test_sync_close_supports_legacy_no_argument_source() -> None:
180+
class LegacyCloseSource:
181+
def __init__(self) -> None:
182+
self.close_calls = 0
183+
184+
def start(self) -> None:
185+
pass
186+
187+
def fetch_chunk(self) -> "list[dict[str, Any]]":
188+
return []
189+
190+
def close(self) -> None:
191+
self.close_calls += 1
192+
193+
source = LegacyCloseSource()
194+
_sync_stream(source).close()
195+
196+
assert source.close_calls == 1
197+
198+
179199
# --------------------------------------------------------------------------- #
180200
# AsyncRowStream
181201
# --------------------------------------------------------------------------- #
@@ -279,6 +299,26 @@ async def close(self, error: bool = False) -> None:
279299
assert source.close_calls == 1
280300

281301

302+
async def test_async_close_supports_legacy_no_argument_source() -> None:
303+
class LegacyCloseSource:
304+
def __init__(self) -> None:
305+
self.close_calls = 0
306+
307+
async def start(self) -> None:
308+
pass
309+
310+
async def fetch_chunk(self) -> "list[dict[str, Any]]":
311+
return []
312+
313+
async def close(self) -> None:
314+
self.close_calls += 1
315+
316+
source = LegacyCloseSource()
317+
await _async_stream(source).aclose()
318+
319+
assert source.close_calls == 1
320+
321+
282322
# --------------------------------------------------------------------------- #
283323
# Eager sources
284324
# --------------------------------------------------------------------------- #

0 commit comments

Comments
 (0)