Hacktoberfest 2026: los issues que los mantenedores marcaron para octubre, abiertos y aptos para principiantes. Explorar issues de Hacktoberfest

Async Thin: bounded cancellation while BREAK/RESET waits on an open silent socket

Abierto
#623 0 comentarios 0 reacciones 0 asignados Ver en GitHub

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

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.version prints 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 main checked at c92c8420874157ce169b28b60b7f139a73f59ee2 (2026-10-07, #610). Its protocol.pyx is byte-identical to the released 26.0.1 file. Runtime results below are for released 26.0.1, not a build of main.
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_message applies wait_for to 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 main wait_for.
  • _reset waits 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, _close clears 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 standalone ping() 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 RuntimeError diagnostics, 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

Primeros pasos

  1. Lee el issue completo y luego la guía de contribución del proyecto.
  2. Comenta en el issue que vas a ocuparte — evita que dos personas hagan lo mismo.
  3. Haz un fork del repositorio y trabaja en una rama.
  4. Abre un pull request que haga referencia al número del issue.

Más de oracle/python-oracledb

Todos los issues de oracle/python-oracledb

Issues similares

Más issues de Python

Recibe los nuevos issues en tu correo

Un resumen breve de issues de GitHub para principiantes.