Skip to content

Add OracleConnection.cancel() for server-side break#118

Open
iliasaz wants to merge 1 commit into
lovetodream:mainfrom
iliasaz:upstream/connection-cancel
Open

Add OracleConnection.cancel() for server-side break#118
iliasaz wants to merge 1 commit into
lovetodream:mainfrom
iliasaz:upstream/connection-cancel

Conversation

@iliasaz

@iliasaz iliasaz commented May 4, 2026

Copy link
Copy Markdown

Summary

Adds a public OracleConnection.cancel() API that asynchronously interrupts an in-flight statement on the server — the OCI OCIBreak() / python-oracledb Connection.cancel() equivalent.

let task = Task {
    try await conn.execute("BEGIN dbms_session.sleep(60); END;", logger: logger)
}
// elsewhere…
conn.cancel()  // server raises ORA-01013, the awaiting Task throws .statementCancelled

Without this, Task.cancel() on the awaited promise doesn't propagate to the wire, so the server keeps running and the next execute(...) queues behind it.

Wire-level handshake

Mirrors python-oracledb's _break_external + _reset pair:

  1. Client sends TNS_MARKER_TYPE_INTERRUPT.
  2. Server replies with a BREAK acknowledgment marker.
  3. The existing markerReceived toggle echoes a TNS_MARKER_TYPE_RESET back.
  4. Server raises ORA-01013 on the in-flight statement.
  5. StatementStateMachine.errorReceived(_:) routes through the cancellation branch — extended in this PR to also fail the awaiting execute promise from the .initialized, .describeInfoReceived, and .rowCountsReceived substates (the existing .streaming path was already wired).

Mid-fetch cancels delegate to the existing iterator-drop path (.forwardStreamError(clientCancelled: true) followed by sendMarker(RESET)), so the wire bytes for await stream.cancel() style cancellation are unchanged.

Why this also benefits non-cancel call sites

The PR makes the existing cancelStatementStream path slightly more robust:

  • markerState is reset to .noMarkerSent in triggerBreak() so a cancel that follows another cancel doesn't get stuck on a stale toggle when the server elides one of the marker echoes.
  • errorReceived(_:) now resolves the original execute promise on ORA-01013 from any pre-streaming substate, where previously only .streaming produced a usable failure.

Code changes

File What
Sources/OracleNIO/Connection/OracleConnection.swift Public cancel()
Sources/OracleNIO/OracleChannelHandler.swift triggerBreak() entry point + .sendBreak action handler
Sources/OracleNIO/ConnectionStateMachine/ConnectionStateMachine.swift New triggerBreak() and .sendBreak case
Sources/OracleNIO/ConnectionStateMachine/StatementStateMachine.swift New markCancelledForBreak() returning a BreakOutcome; widened ORA-01013 handling
Sources/OracleNIO/Messages/Coding/OracleFrontendMessageEncoder.swift marker(type:) overload
Sources/OracleNIO/Constants.swift TNS_MARKER_TYPE_BREAK / _INTERRUPT typed as UInt8 for the encoder

Test coverage

Unit (5 new, all 363 existing still pass)

Tests/OracleNIOTests/ConnectionStateMachine/StatementStateMachineTests.swift:

  • breakCancellationFromInitialized — the canonical dbms_session.sleep repro, statement is in .initialized.
  • breakCancellationFromDescribeInfoReceived — between describeInfo and rowHeader.
  • breakCancellationFromStreaming — mid-stream, asserts the existing forwardStreamError + sendMarker(read: true) path is still produced.
  • breakOnIdleConnectionIsNoOp.readyForStatement produces .wait.
  • breakIsIdempotent — second triggerBreak() before the server responds is a no-op.

Integration (5 new, against a live DB)

Tests/IntegrationTests/CancelTests.swift:

  • cancelDuringKernelSleepBEGIN dbms_session.sleep(10); END;, cancel after 1s; follow-up SELECT runs in well under 8s (the kernel sleep checks for breaks at finite intervals so this is order-of-seconds, not instant).
  • cancelDuringCPUBoundExecute — cancel a CPU-bound SELECT COUNT(*) while it's executing; follow-up runs under 2s.
  • cancelMidFetch — cancel during a 10M-row stream; iterator throws .statementCancelled, follow-up immediate.
  • cancelOnIdleConnection — no statement in flight, follow-up unaffected.
  • doubleCancelIsIdempotent — two cancels in quick succession leave the connection reusable.

All 5 pass against the local Oracle Free 23ai test container at the standard ORA_HOSTNAME / ORA_PORT / ORA_SERVICE_NAME / ORA_USERNAME / ORA_PASSWORD env.

Notes

  • scripts/soundness.sh passes (license headers, no unacceptable language, swift-format clean).
  • DCO: commit is Signed-off-by:.
  • API addition only — no breaking changes; all existing tests continue to pass.

Inspired by issue iliasaz/macintora#15 (Oracle SQL IDE) where the missing cancel was particularly visible.

Implements OCIBreak / python-oracledb Connection.cancel() equivalent
on top of the existing TNS marker plumbing. A new public
`OracleConnection.cancel()` triggers a wire-level handshake that
interrupts the in-flight statement on the server, so the awaiting
`execute(...)` promise (or row iterator) fails with
`.statementCancelled` and the connection is reusable for the next
statement.

Wire-level handshake mirrors python-oracledb's `_break_external` /
`_reset` pair: client sends a TNS INTERRUPT marker, server replies
with a BREAK acknowledgment, the existing markerReceived toggle
echoes a RESET back, then the server raises ORA-01013 which the
statement state machine routes through the cancellation branch
(now extended to fail the awaiting promise from `.initialized`,
`.describeInfoReceived`, and `.rowCountsReceived` substates).

Mid-fetch cancels delegate to the existing iterator-drop path
(forwardStreamError + sendMarker(RESET, read: true)) — same
end state, same wire bytes.

Test coverage:
- 5 unit tests in `StatementStateMachineTests` covering each
  substate (`.initialized`, `.describeInfoReceived`, `.streaming`,
  idle, idempotent re-cancel).
- 5 integration tests in `CancelTests` against a live database:
  cancelDuringKernelSleep, cancelDuringCPUBoundExecute,
  cancelMidFetch, cancelOnIdleConnection, doubleCancelIsIdempotent.

Signed-off-by: Ilia Sazonov <ilia_saz@yahoo.com>
@iliasaz
iliasaz requested a review from lovetodream as a code owner May 4, 2026 02:04
@lovetodream

Copy link
Copy Markdown
Owner

This is a great addition. What I don't like about it is that it breaks the concept of structured concurrency with the addition of the cancel method. It should work via Task.cancel instead.

let task = Task {
   try await conn.execute("BEGIN dbms_session.sleep(60); END;", logger: logger)
}
task.cancel()

I didn't have time to review the internals yet. Do you want me to take it over?

@iliasaz

iliasaz commented May 8, 2026

Copy link
Copy Markdown
Author

It should work via Task.cancel instead.

That's a very good point! I didn't think of it. I thought of making it async but that alone doesn't get us any benefits.
Feel free to take it over :-)

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants