Async Thin: bounded cancellation while BREAK/RESET waits on an open silent socket
还没有人认领这个 Issue。
评估
- 难度
- 4/5
- 预计耗时
- 3-5 天
- 新手友好度
- 48/100
- Issue 类型
- 功能
- 描述清晰度
- 描述清楚
- 活跃度
- 活跃
- 技术栈
- postgresql, python
调研方向
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.
由索引模型根据 Issue 内容生成。
描述
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.
- 主要语言
- Python
- 星标
- 455
- 派生
- 119
- PR 合并指标
- 30 天内没有已合并 PR
环境准备
- 没有 Dockerfile 或 Docker Compose 文件
- 有 Pull Request 模板
- 阅读贡献指南
从这里开始
- 先读完整个 Issue,再读项目的贡献指南。
- 在 Issue 下留言说明你要接手 —— 这能避免两个人做同样的事。
- Fork 仓库,在一个分支上完成修改。
- 提交 Pull Request,并在描述里引用这个 Issue 编号。
oracle/python-oracledb 的其他 Issue
-
难度 4/5 3-5 天 新手友好度 55/100
oracle/python-oracledb#622 ·
-
bug
难度 4/5 3-5 天 新手友好度 32/100
oracle/python-oracledb#621 · 3 条评论 ·
-
Client Library or Database
难度 4/5 3-5 天 新手友好度 25/100
oracle/python-oracledb#620 · 4 条评论 ·
-
难度 4/5 3-5 天 新手友好度 42/100
oracle/python-oracledb#619 ·
-
asyncio: DPY-4011 on connect_async() when the listener closes the original socket after a redirect可能已有人在做 @AlexGracas 于 16 天前认领。 未关闭
难度 3/5 1-2 天 新手友好度 35/100
oracle/python-oracledb#616 ·
查看 oracle/python-oracledb 的全部 Issue
相似的 Issue
-
难度 2/5 1-3 小时 新手友好度 62/100
维护者通常 1 天内回复
-
namespace operations
难度 1/5 1 小时以内 新手友好度 72/100
EclipseFdn/open-vsx.org#14623 ·
维护者通常 1 天内回复
-
难度 2/5 1-3 小时 新手友好度 76/100
developmentseed/titiler-cmr#261 ·
维护者通常 1 天内回复
-
难度 2/5 1-3 小时 新手友好度 78/100
andrewyng/openworker#774 ·
维护者通常 1 天内回复
-
aggfunc未关闭
难度 2/5 1-3 小时 新手友好度 68/100
worldbank/blackmarblepy#160 ·