Async Thin: bounded cancellation while BREAK/RESET waits on an open silent socket
Nadie ha tomado este issue todavía.
Evaluación
- Dificultad
- 4/5
- Tiempo estimado
- 3-5 días
- Aptitud para principiantes
- 48/100
- Tipo de issue
- Nueva funcionalidad
- Claridad
- Bien especificado
- Estado de actividad
- Activo
- Stack tecnológico
- postgresql, python
- Área
- backend-api-design, databases
Línea de trabajo
Read src/oracledb/impl/thin/protocol.pyx, especially _process_message and _reset, and the AsyncConnection.terminate() documentation in src/oracledb/connection.py. Start by tracing cancellation recovery and its timeout handling. Done means cancellation while traffic is blocked has a bounded outcome or a documented supported way to bound it, with tests covering the behavior.
Escrito por el modelo de indexación a partir del texto del issue.
Descripción
Is the cancellation behavior below intentional, and is there a supported way to bound it when discarding the connection is acceptable? A bounded RESET timeout or an opt-in destructive cancellation API would help applications with a total response/shutdown budget.
1. Versions
- python-oracledb 26.0.1, official unmodified wheel, Thin mode; current latest stable on PyPI when checked on 2026-10-20.
- CPython 3.14.8, macOS 15.8, arm64, 64-bit.
- Oracle Database Free 26ai;
connection.versionprints 23.26.3.0.0. - Plain TCP through an in-process loopback relay. The primary reproduction uses standalone native connections, with no SQLAlchemy and no connection pool.
- Current upstream
mainchecked atc92c8420874157ce169b28b60b7f139a73f59ee2(2026-10-07, #610). Itsprotocol.pyxis byte-identical to the released 26.0.1 file. Runtime results below are for released 26.0.1, not a build ofmain.
2. Error, hang, or crash?
Delayed cancellation while the TCP socket remains open and the relay holds traffic in both directions. With call_timeout=30000, a ping() canceled 1 second after intercepted traffic is still pending at approximately 1.25 seconds. Restoring traffic lets it finish with CancelledError.
This is a finite observation, not evidence of an infinite hang. Source inspection suggests the cancellation-triggered RESET receive has no independent timeout. A caller awaiting an asyncio cancellation/deadline can therefore exceed its intended budget while recovery completes.
The native-timeout-first control works: call_timeout=250 finishes in approximately 0.5 seconds, including recovery, before a generous 2-second outer cancellation timer. A per-round-trip timeout and a total application-flow deadline are different contracts. Native-timeout-first operation and cooperative cleanup are useful supported behavior; this report asks about the remaining cancellation-first case.
3. Observed output
Actual output from the script below; no credentials, account names, hostnames, or connection strings are printed:
platform.platform: macOS-15.8-arm64-arm-64bit-Mach-O
sys.maxsize > 2**32: True
platform.python_version: 3.14.8
oracledb.__version__: 26.0.1 thin: True
connection.version: 23.26.3.0.0
call_timeout=30000ms cancel_after=1.0s observed=1.252s done_before_restore=False cancellation_requests=1
outcome_after_restore: CancelledError
close: success
fresh_connection_recovery: success; relay handlers=0; streams=0
connection.version: 23.26.3.0.0
call_timeout=250ms cancel_after=2.0s observed=0.503s done_before_restore=True cancellation_requests=0
outcome_after_restore: DPY-4011
close: success
fresh_connection_recovery: success; relay handlers=0; streams=0
Both cases close and recover with a fresh standalone native connection. This does not claim same-pool recovery or server-side session disappearance. An additional cleanup check kept the canceled ping pending, then cut only the owned relay sockets: the owner finished with DPY-4011, close succeeded, and relay handlers/streams reached zero.
4. Does the application call init_oracle_client()?
No. This uses asynchronous Thin mode only.
5. Runnable reproduction
Save as repro.py, install oracledb==26.0.1, and run python repro.py (or uv run --script repro.py). Set ORACLE_HOST, ORACLE_PORT, ORACLE_SERVICE, ORACLE_USER, and ORACLE_TEST_ONLY=1 for a dedicated disposable test environment. The password comes from ORACLE_PASSWORD or a silent prompt; do not put it in command arguments. Use the PDB's default service so its uppercase name matches CON_NAME, which the script checks along with the session user before arming the fault. No schema objects, DDL, or special database privileges are required.
The relay is armed after connection establishment and the identity query. Cancellation is scheduled only after actual traffic interception; there are no stage-selection sleeps. Traffic is restored and pending work is joined in finally; if draining fails, only the relay's own sockets are cut before a second bounded join.
Standalone Python script
# /// script
# requires-python = ">=3.14"
# dependencies = ["oracledb==26.0.1"]
# ///
import asyncio
import getpass
import os
import platform
import sys
from time import monotonic
import oracledb
WAIT = 5
class Relay:
def __init__(self, host, port):
self.upstream = host, port
self.forwarding = asyncio.Event()
self.forwarding.set()
self.intercepted = asyncio.Event()
self.writers = set()
self.handlers = set()
async def start(self):
self.server = await asyncio.start_server(self.accept, "127.0.0.1", 0)
self.port = self.server.sockets[0].getsockname()[1]
def accept(self, reader, writer):
self.writers.add(writer)
self.handlers.add(asyncio.create_task(self.serve(reader, writer)))
async def pump(self, reader, writer):
while data := await reader.read(65536):
if not self.forwarding.is_set():
self.intercepted.set()
await self.forwarding.wait()
writer.write(data)
await writer.drain()
async def serve(self, reader, writer):
streams, pumps = [writer], []
try:
remote, remote_writer = await asyncio.wait_for(
asyncio.open_connection(*self.upstream), WAIT
)
streams.append(remote_writer)
self.writers.add(remote_writer)
pumps = [asyncio.create_task(self.pump(reader, remote_writer)),
asyncio.create_task(self.pump(remote, writer))]
await asyncio.wait(pumps, return_when=asyncio.FIRST_COMPLETED)
finally:
for stream in streams:
stream.transport.abort()
self.writers.discard(stream)
for task in pumps:
task.cancel()
results = await asyncio.gather(*pumps, return_exceptions=True)
for result in results:
if isinstance(result, Exception) and not isinstance(result, ConnectionError):
raise result
async def cut(self):
self.forwarding.set()
for writer in tuple(self.writers):
writer.transport.abort()
async def close(self):
self.server.close()
await asyncio.wait_for(self.server.wait_closed(), WAIT)
await self.cut()
if self.handlers:
done, pending = await asyncio.wait(self.handlers, timeout=WAIT)
assert not pending, "Relay handlers did not stop"
for task in done:
task.result()
self.handlers.clear()
assert not self.writers
async def settle(task, relay):
done, _ = await asyncio.wait((task,), timeout=WAIT)
if not done:
# Closing only this relay wakes the driver's pending receive on failure.
await relay.cut()
done, _ = await asyncio.wait((task,), timeout=WAIT)
assert done, "Task did not stop after relay sockets were cut"
try:
task.result()
except asyncio.CancelledError:
return "CancelledError"
except Exception as exc:
return getattr(exc.args[0], "full_code", type(exc).__name__) if exc.args else type(exc).__name__
return "success"
async def case(config, timeout_ms, cancel_after):
relay = Relay(config.pop("host"), config.pop("port"))
await relay.start()
connection = None
task = timer = None
try:
connection = await asyncio.wait_for(oracledb.connect_async(
**config, host="127.0.0.1", port=relay.port), WAIT)
with connection.cursor() as cursor:
await cursor.execute("select sys_context('USERENV','SESSION_USER'), "
"sys_context('USERENV','CON_NAME') from dual")
assert await cursor.fetchone() == (config["user"].upper(), config["service_name"].upper())
print("connection.version:", connection.version)
connection.call_timeout = timeout_ms
relay.forwarding.clear()
task = asyncio.create_task(connection.ping())
await asyncio.wait_for(relay.intercepted.wait(), WAIT)
started = monotonic()
timer = asyncio.get_running_loop().call_later(cancel_after, task.cancel)
done, _ = await asyncio.wait((task,), timeout=cancel_after + 0.25)
print(f"call_timeout={timeout_ms}ms cancel_after={cancel_after}s "
f"observed={monotonic() - started:.3f}s done_before_restore={bool(done)} "
f"cancellation_requests={task.cancelling()}")
relay.forwarding.set()
timer.cancel()
print("outcome_after_restore:", await settle(task, relay))
finally:
if timer:
timer.cancel()
relay.forwarding.set()
try:
if task:
await settle(task, relay)
if connection:
print("close:", await settle(asyncio.create_task(connection.close()), relay))
finally:
await relay.close()
recovery = await asyncio.wait_for(oracledb.connect_async(**config,
host=relay.upstream[0], port=relay.upstream[1]), WAIT)
recovery.call_timeout = 250
try:
await asyncio.wait_for(recovery.ping(), WAIT)
print("fresh_connection_recovery: success; relay handlers=0; streams=0")
finally:
await asyncio.wait_for(recovery.close(), WAIT)
async def main():
# ONLY use a dedicated disposable test account/database; never production.
assert os.environ.get("ORACLE_TEST_ONLY") == "1", "Set ORACLE_TEST_ONLY=1 for a dedicated test environment"
config = {"host": os.environ["ORACLE_HOST"], "port": int(os.environ.get("ORACLE_PORT", "1521")),
"service_name": os.environ["ORACLE_SERVICE"], "user": os.environ["ORACLE_USER"],
"password": os.environ.get("ORACLE_PASSWORD") or getpass.getpass("Test database password: ")}
print("platform.platform:", platform.platform())
print("sys.maxsize > 2**32:", sys.maxsize > 2**32)
print("platform.python_version:", platform.python_version())
print("oracledb.__version__:", oracledb.__version__, "thin:", oracledb.is_thin_mode())
await case(config.copy(), 30_000, 1.0)
await case(config.copy(), 250, 2.0)
if __name__ == "__main__":
asyncio.run(main())
Source analysis and possible approach
In released 26.0.1 (cf7d2aa8909543513d25b0fdbc5daa03031e5b17):
_process_messageapplieswait_forto the main exchange and separately to native-timeout recovery. A general exception, including cancellation, can send BREAK and await_reset()outside those timed exchanges. The MarkerDetected branch and flush-out-bind receive also sit outside the mainwait_for._resetwaits for protocol packets without its own deadline. The same current-main cancellation path remains present.AsyncConnection.terminate()documents use when the event loop is unavailable. I have not treated it as a supported application watchdog API in a running loop. Also,_closeclears the public implementation handle before awaiting native close.
A local proof-of-feasibility prototype added an outer CancelledError guard around the entire async message operation, including RESET/flush branches, re-raised cancellation before the generic BREAK/RESET handler, marked the connection disconnected, and used asynchronous transport abort() rather than close() to discard queued writes. This was a small two-Cython-file experiment (16 additions, 4 deletions), not a proposed finished production patch. Six focused local tests against a temporary build of 26.0.1 covered ping, query, rollback, native close, session close, and cancellation inside marker RESET. All completed cancellation before traffic restoration; ping took 1.00154 seconds with cancellation scheduled at 1 second.
Illustrative local prototype diff against 26.0.1
This sketch makes every cancellation destructive. Preserving the current default would require an explicit opt-in design; the diff alone does not provide that API.
--- a/src/oracledb/impl/thin/protocol.pyx
+++ b/src/oracledb/impl/thin/protocol.pyx
@@ -77,7 +77,7 @@
or self._read_buf._transport._transport is None:
self._transport = None
- cdef int _disconnect(self) except -1:
+ cdef int _disconnect(self, bint abort=False) except -1:
"""
Disconnects from the transport.
"""
@@ -86,7 +86,7 @@
self._transport = None
self._read_buf._transport = None
self._write_buf._transport = None
- transport.disconnect()
+ transport.disconnect(abort)
cdef bint _get_is_healthy(self):
"""
@@ -384,6 +384,13 @@
description)
async def _process_message(self, Message message):
+ try:
+ await self._process_message_owned(message)
+ except asyncio.CancelledError:
+ self._disconnect(True)
+ raise
+
+ async def _process_message_owned(self, Message message):
"""
Sends a message to the server and processes its response.
"""
@@ -393,6 +400,8 @@
try:
coroutine = self._process_message_helper(message)
await asyncio.wait_for(coroutine, timeout_obj)
+ except asyncio.CancelledError:
+ raise
except asyncio.TimeoutError:
try:
coroutine = self._process_timeout_helper(message, timeout)
--- a/src/oracledb/impl/thin/transport.pyx
+++ b/src/oracledb/impl/thin/transport.pyx
@@ -113,7 +113,7 @@
offset += 8
self._print_output("\n".join(output_lines))
- cdef int disconnect(self) except -1:
+ cdef int disconnect(self, bint abort=False) except -1:
"""
Disconnects the transport.
"""
@@ -122,7 +122,10 @@
self._print_output(
self._get_debugging_header("Disconnecting transport")
)
- self._transport.close()
+ if abort and self._is_async:
+ self._transport.abort()
+ else:
+ self._transport.close()
self._transport = None
cdef int create_ssl_context(self, ConnectParamsImpl params,
An opt-in mode may be preferable to changing the existing connection-reuse contract. The prototype has not qualified TLS, backpressure, LOB/pipeline operations, or repeated cancellation. Aborting client I/O cannot establish the server transaction outcome; an interrupted commit can remain unknown and must not be blindly replayed.
Related fixes and scope
- #221 fixed native-pool busy-list handling; this reproduction has no pool.
- #278 covered an
executemany()/IntegrityError BREAK/RESET timing hang, initially fixed in 2.0.1 and later reworked. This reproduction uses a canceled standaloneping()and an intentionally silent relay; I am not claiming that historical defect remains unfixed. - #386 discussed close timing/server sessions after short call timeouts and was closed without the requested follow-up. I am not assuming it delivered a cancellation fix.
- #602 added synchronous termination for loop-unavailable/GC cleanup in 26.0.0. That is related, but differs from supported cancellation of owned live-loop work.
- Separate checks with released SQLAlchemy 2.0.55 and 2.1.5 returned the slot on canceled invalidation, correcting older-version occupied-slot behavior; shielded graceful close could still remain pending until traffic returned. Their native-timeout-first public cleanup controls completed around 0.5 seconds and recovered. These checks do not imply that ordinary application cleanup needs a driver patch. Some native-close/invalidation controls emitted internal
RuntimeErrordiagnostics, so they are not a claim that every measured path was error-free. See SQLAlchemy's cleanup ownership implementation and invalidation check-in fix.
Could you clarify whether waiting for RESET after caller cancellation is intentional, and recommend the supported approach when a caller explicitly accepts discarding the connection? Is an opt-in abort, live-loop termination API, or bounded RESET timeout under consideration? I can adapt the reproduction or prototype to the intended semantics.
- Lenguaje dominante
- Python
- Estrellas
- 455
- Forks
- 119
- Métricas de merge de PR
- Sin PR fusionados en 30 d
Preparar el entorno
- Sin Dockerfile ni archivo de Docker Compose
- Tiene una plantilla de pull request
- Leer la guía de contribución
Primeros pasos
- Lee el issue completo y luego la guía de contribución del proyecto.
- Comenta en el issue que vas a ocuparte — evita que dos personas hagan lo mismo.
- Haz un fork del repositorio y trabaja en una rama.
- Abre un pull request que haga referencia al número del issue.
Más de oracle/python-oracledb
-
Dificultad 4/5 3-5 días Aptitud para principiantes 55/100
oracle/python-oracledb#622 ·
-
bug
Dificultad 4/5 3-5 días Aptitud para principiantes 32/100
oracle/python-oracledb#621 · 3 comentarios ·
-
Client Library or Database
Dificultad 4/5 3-5 días Aptitud para principiantes 25/100
oracle/python-oracledb#620 · 4 comentarios ·
-
Dificultad 4/5 3-5 días Aptitud para principiantes 42/100
oracle/python-oracledb#619 ·
-
asyncio: DPY-4011 on connect_async() when the listener closes the original socket after a redirectPosiblemente ocupada @AlexGracas la tomó hace 16 días. Abierto
Dificultad 3/5 1-2 días Aptitud para principiantes 35/100
oracle/python-oracledb#616 ·
Todos los issues de oracle/python-oracledb
Issues similares
-
[Resource]: glyphcss-diagramsAbiertoresource-submission validation-passed
Dificultad 1/5 Menos de una hora Aptitud para principiantes 88/100
hesreallyhim/awesome-claude-code#3214 · 1 comentario ·
Los mantenedores suelen responder en 1 día
-
room-commons replace accepts an empty old string and silently prepends the new text to the pageAbierto
Dificultad 2/5 1-3 horas Aptitud para principiantes 78/100
Los mantenedores suelen responder en 1 día
-
Dificultad 2/5 1-3 horas Aptitud para principiantes 68/100
doobidoo/mcp-memory-service#1650 ·
Los mantenedores suelen responder en 1 día
-
Dificultad 1/5 1-3 horas Aptitud para principiantes 82/100
-
Dificultad 2/5 1-3 horas Aptitud para principiantes 76/100
ml-explore/mlx-lm#2002 ·
Los mantenedores suelen responder en 2 días