Hacktoberfest 2026: những issue maintainer đã đánh dấu cho tháng Mười, đang mở và phù hợp người mới. Xem issue Hacktoberfest

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

Đang mở
#623 0 bình luận 0 reaction 0 người được giao Xem trên GitHub

Chưa có ai nhận issue này.

Đánh giá

Độ khó
4/5
Thời gian dự kiến
3-5 ngày
Mức phù hợp với người mới
48/100
Loại issue
Tính năng
Độ rõ ràng
Đặc tả rõ ràng
Mức độ hoạt động
Sôi nổi
Công nghệ
postgresql, python

Hướng nghiên cứu

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.

Do mô hình lập chỉ mục viết ra từ nội dung của issue.

Mô tả

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.

Ngôn ngữ chính
Python
Star
455
Fork
119
Chỉ số merge pull request
Không có pull request nào được merge trong 30 ngày

Chuẩn bị môi trường

Bắt đầu từ đâu

  1. Đọc hết issue, rồi đọc hướng dẫn đóng góp của dự án.
  2. Bình luận trên issue rằng bạn sẽ nhận — tránh hai người làm cùng một việc.
  3. Fork repository và làm thay đổi trên một nhánh.
  4. Mở pull request có tham chiếu số hiệu của issue.

Issue khác của oracle/python-oracledb

Tất cả issue của oracle/python-oracledb

Issue tương tự

Thêm issue về Python

Nhận issue mới trong hộp thư của bạn

Bản tóm tắt ngắn những issue GitHub phù hợp với người mới.