fix(csharp): tear down in-flight CloudFetch on connection dispose and statement cancel/dispose - #659
fix(csharp): tear down in-flight CloudFetch on connection dispose and statement cancel/dispose#659eric-wang-1990 wants to merge 20 commits into
Conversation
Closing a DatabricksConnection while a CloudFetch query was streaming could hang the reader. The CloudFetch pipeline ran on background tasks driven by a cancellation token that CloudFetchDownloadManager.StartAsync created itself and linked to nothing external. The only thing that cancelled it was disposing the reader — but the reader is owned by the query task, which is blocked awaiting the next chunk. connection.Dispose() disposed the shared HttpClient and closed the session, but never cancelled the pipeline or reached the active reader, so the in-flight download failed on the dead HttpClient and DownloadFileAsync retry-spun for up to the retry timeout (minutes) while the reader stayed parked on DownloadCompletedTask — blowing the 60s limit in CloseConnection_DuringCloudFetch_ShouldNotHang. Intermittent because it only fires when Dispose lands mid-download. Give connection shutdown a way to cancel the pipeline: add a connection-scoped CancellationTokenSource cancelled at the top of Dispose (before the HttpClient is torn down), and have CloudFetchDownloadManager.StartAsync link the caller's token into its own source. On dispose the download loop exits and completes the result queue (unblocking the reader's Take) and any in-flight download faults DownloadCompletedTask (unblocking the reader's await), so the read task ends promptly instead of spinning on retries. Wired on the Thrift path; SEA uses a separate connection and already passed, so its call keeps the default token. Add a deterministic unit test that cancels the StartAsync token and asserts a reader parked in GetNextDownloadedFileAsync unblocks within a short bound (times out without the fix, passes with it). Co-authored-by: Isaac
There was a problem hiding this comment.
Verdict: 1 Low
Looks good — a well-scoped, correctly-implemented fix for the CloudFetch dispose hang. The linked-token approach is sound, the SEA default-token path stays behavior-equivalent (non-breaking), per-query registrations don't accumulate on the connection token (linked CTS is disposed in the manager's Stop/Dispose), and the new regression test is deterministic (the parked read cannot complete without cancellation, so its timing checks aren't flaky). Only one minor, non-blocking ordering note filed inline.
Addresses: - #3876787273 at csharp/src/DatabricksConnection.cs:1241 Signed-off-by: peco-engineer-bot[bot] <peco-engineer-bot[bot]@users.noreply.github.com>
…cade) Extends the connection-level CloudFetch cancellation to the statement level, so the full connection ⊃ statement ⊃ cloudfetch cancel cascade holds: - DatabricksStatement gets a statement-lifetime CancellationTokenSource created linked to the connection's shutdown token, cancelled in Cancel() and Dispose() and disposed in Dispose(). It is distinct from the base HiveServer2Statement._executeTokenSource, which is disposed when ExecuteQuery() returns and so cannot cover the later CloudFetch result-fetch phase. - The Thrift reader factory passes this statement token into the pipeline instead of the raw connection token. Because the statement token is linked to the connection token, connection dispose still cancels every statement's downloads (preserving the prior fix), and cancelling/disposing a single statement now stops just its downloads. Also make the reader observe cancellation so a cancel stops the read promptly even while draining already-buffered chunks, not only when it next blocks for a download: CloudFetchDownloadManager exposes the pipeline token (PipelineToken), and CloudFetchReader links it with the caller's token and checks it at the top of each read-loop iteration. Without this, a statement cancel during a large stream would keep returning buffered rows until the in-memory buffer drained. Add E2E CancelStatement_DuringCloudFetch_ShouldStopPromptly: reads the first CloudFetch batch of a huge RANGE, cancels the statement, and asserts the read ends well within the window (it does not without the fix — the read keeps going until the timeout). Full unit suite (925) and full-read CloudFetch E2E pass. Co-authored-by: Isaac
There was a problem hiding this comment.
Verdict: 1 Low
Looks good — a well-scoped fix for the CloudFetch shutdown hang; the connection⊃statement⊃pipeline linked-CTS cascade is sound, the PipelineToken getter handles the disposed-CTS race, and the reader now observes cancellation while draining buffered chunks. One low-severity consistency issue: the statement's CTS Dispose() is placed after (unguarded by) the telemetry emit, unlike the connection which the author deliberately guarded against exactly that throw.
Addresses: - #3877046084 at csharp/src/DatabricksStatement.cs:1497 Signed-off-by: peco-engineer-bot[bot] <peco-engineer-bot[bot]@users.noreply.github.com>
There was a problem hiding this comment.
Verdict: 1 Medium · 1 Low
Solid, well-motivated fix for the CloudFetch teardown hang, with good tests. One medium concern: the statement-lifetime CloudFetch CTS is never recreated, so Cancel() permanently poisons the CloudFetch path and breaks statement reuse-after-cancel (F1). Also flagging that the connection-dispose cascade is Thrift-only — the SEA reader path is left untokenized (F2).
Addresses: - #3877068667 at csharp/src/DatabricksStatement.cs:1525 - #3877068674 at csharp/src/Reader/CloudFetch/CloudFetchReaderFactory.cs:119 Signed-off-by: peco-engineer-bot[bot] <peco-engineer-bot[bot]@users.noreply.github.com>
There was a problem hiding this comment.
Verdict: 1 Medium · 1 Low
Solid, well-reasoned fix for the CloudFetch teardown hang — the linked-CTS cascade, ODE-guarded PipelineToken, and dispose ordering all check out, and the download manager disposes its linked source so no token registration leaks. Two concerns: Cancel() cancels the pipeline CTS after the throwing base.Cancel() RPC (medium — teardown is skipped in the very failure path the fix targets), and RefreshCloudFetchStatementCts disposing the previous CTS can sever a still-open reader's link to the connection shutdown token on re-execute (low).
Addresses: - #3877106059 at csharp/src/DatabricksStatement.cs:1553 - #3877106064 at csharp/src/DatabricksStatement.cs:170 Signed-off-by: peco-engineer-bot[bot] <peco-engineer-bot[bot]@users.noreply.github.com>
CreateThriftReader is Thrift-only and already hard-casts statement.Connection to DatabricksConnection, so the statement is always a DatabricksStatement there. The `statement is DatabricksStatement ? ... : connection.CloudFetchShutdownToken` fallback was unreachable — replace it with a direct cast, matching the existing connection cast. Co-authored-by: Isaac
There was a problem hiding this comment.
Verdict: 1 Low
Looks good — solid, well-documented fix for the CloudFetch teardown hang; the cancel cascade (connection ⊃ statement ⊃ pipeline), the per-execute CTS refresh, and the reader-level prompt-stop are all sound, and the two documented gaps (open-reader re-execute detaching from the connection cascade; SEA path left unlinked) are honestly called out in the code. One Low thread-safety note on the unsynchronized _cloudFetchStatementCts field swap racing a cross-thread Cancel() is filed inline.
Addresses: - #3882435357 at csharp/src/DatabricksStatement.cs:171 Signed-off-by: peco-engineer-bot[bot] <peco-engineer-bot[bot]@users.noreply.github.com>
There was a problem hiding this comment.
Verdict: 1 Low
Looks good — a clean, well-documented fix for the CloudFetch shutdown hang. The connection ⊃ statement ⊃ cloudfetch cancel cascade is coherent: I confirmed the downloader's retry loop (CloudFetchDownloader.cs:595/:771) actually observes the linked pipeline token, so the cancel truly breaks the retry-spin, and the reader/refresh/lock discipline all hold. Only one minor robustness note: the new CloudFetchStatementToken getter isn't defensive against ObjectDisposedException the way its sibling PipelineToken is (currently unreachable in valid usage).
Addresses: - #3882499934 at csharp/src/DatabricksStatement.cs:134 Signed-off-by: peco-engineer-bot[bot] <peco-engineer-bot[bot]@users.noreply.github.com>
There was a problem hiding this comment.
Verdict: 1 Low
Looks good — a solid, well-documented fix for the CloudFetch shutdown hang with the connection ⊃ statement ⊃ cloudfetch cancel cascade, good test coverage (unit + E2E), and correct linked-CTS lifecycle handling. One low-severity consistency gap: the new CloudFetchShutdownToken getter lacks the post-dispose ObjectDisposedException→None guard that its two sibling token getters intentionally have. The SEA path being left out of the cascade is clearly documented and reasonable to defer.
Addresses: - #3882538923 at csharp/src/DatabricksConnection.cs:137 Signed-off-by: peco-engineer-bot[bot] <peco-engineer-bot[bot]@users.noreply.github.com>
The Thrift path got the connection ⊃ statement ⊃ cloudfetch cancel cascade; the SEA (StatementExecution) path had the same latent gap — statement.Cancel() during CloudFetch streaming was a no-op (its _executeCts is disposed once results stream, and the pipeline was started with no token), so a cancel left downloads running (verified: an E2E cancel test read 67M rows and never stopped on SEA). Mirror the Thrift wiring so both protocols behave identically: - StatementExecutionConnection gets a CloudFetchShutdownToken, cancelled at the top of Dispose() and disposed at the end. - StatementExecutionStatement gets a statement-lifetime CTS linked to that token, refreshed per-execute (ExecuteQueryAsync/ExecuteUpdateAsync) so a reused statement isn't poisoned by a prior Cancel(), cancelled in Cancel() and Dispose() (before the no-statement early-return so the linked registration is always freed). - CreateStatementExecutionReader passes the statement token into StartAsync, replacing the no-token call (and the comment that scoped SEA out). Verified on a real warehouse: SEA CancelStatement_DuringCloudFetch now stops promptly (~17s vs never), SEA CloseConnection still passes (~1s), SEA full-read CloudFetchE2ETest.TestCloudFetch (10 cases) unaffected; Thrift unchanged; unit suite 926 green. Co-authored-by: Isaac
There was a problem hiding this comment.
Verdict: 1 Low
Looks good — a well-constructed connection ⊃ statement ⊃ cloudfetch cancel cascade with sound locking, per-execute CTS refresh, symmetric Thrift/SEA wiring, and defensive post-dispose token guards; the reader's linked-CTS + top-of-loop ThrowIfCancellationRequested correctly delivers the mid-buffer prompt-stop, and normal reads remain unaffected. One low-severity note inline about the narrow catch (ObjectDisposedException) around CancellationTokenSource.Cancel() in the Dispose teardown paths. Nit (not inline): the new E2E test CancelStatement_DuringCloudFetch_ShouldStopPromptly never disposes its ManualResetEventSlim queryStarted.
Addresses: - #3888803204 at csharp/src/DatabricksConnection.cs:1221 Signed-off-by: peco-engineer-bot[bot] <peco-engineer-bot[bot]@users.noreply.github.com>
There was a problem hiding this comment.
Verdict: 1 Low
Looks good — a well-constructed, defensively-documented fix for the CloudFetch shutdown hang. The connection ⊃ statement ⊃ pipeline linked-token cascade is correct: per-execute CTS refresh keeps statements reusable after cancel, teardown cancels are best-effort so HttpClient/session cleanup can't be skipped, read-after-dispose is guarded to CancellationToken.None, and the reader now observes the pipeline token plus passes it into GetNextDownloadedFileAsync (I confirmed the parked DownloadCompletedTask await still unblocks because the downloader faults the task on cancel). Only one minor nit: the SEA connection's shutdown-cancel telemetry tag shape diverges from the sibling paths (see inline).
Addresses: - #3888821600 at csharp/src/StatementExecution/StatementExecutionConnection.cs:1216 Signed-off-by: peco-engineer-bot[bot] <peco-engineer-bot[bot]@users.noreply.github.com>
There was a problem hiding this comment.
Verdict: 1 Low
Looks good — solid, well-documented fix for the CloudFetch shutdown hang. The connection ⊃ statement ⊃ cloudfetch linked-token cascade is correctly wired: the download manager's linked CTS is disposed in StopAsync/Dispose, token getters guard ObjectDisposedException, the per-execute refresh swaps under a lock, and both production StartAsync callers pass the statement token so the cascade is never silently dropped. One low-severity note inline about misleading error telemetry on repeated dispose. Nit (no anchor needed): StartAsync(CancellationToken = default) makes an unlinked pipeline the silent default — fine today since both callers pass a token, but a future caller that omits it would quietly regress the cascade; consider making the token required.
Addresses: - #3888832882 at csharp/src/DatabricksStatement.cs:1538 Signed-off-by: peco-engineer-bot[bot] <peco-engineer-bot[bot]@users.noreply.github.com>
There was a problem hiding this comment.
Verdict: 1 Low
Solid, carefully-implemented fix — the connection ⊃ statement ⊃ cloudfetch cancel cascade is wired correctly (linked CTS in StartAsync, in-flight downloads faulted on cancel, guarded per-execute CTS refresh, unconditional disposal), and I found no correctness or resource-leak defects in the cancellation/disposal paths. One low-severity behavioral note: a statement.Cancel() while the reader is parked waiting for the next file surfaces as a silent clean EOF (partial results, no exception) rather than an OperationCanceledException, so cancellation is surfaced inconsistently depending on reader timing. Nit (not inline): the new E2E CancelStatement_DuringCloudFetch_ShouldStopPromptly has a fairly tight margin (reported 5–20s vs a 30s timeout) on the SEA path, though it's a live-workspace SkippableFact not run in ordinary CI.
Addresses: - #3888848391 at csharp/src/Reader/CloudFetch/CloudFetchReader.cs:163 Signed-off-by: peco-engineer-bot[bot] <peco-engineer-bot[bot]@users.noreply.github.com>
There was a problem hiding this comment.
Verdict: 1 Low
Looks good — a well-structured, heavily-documented fix for the CloudFetch shutdown hang, with a correct connection ⊃ statement ⊃ cloudfetch cancel cascade mirrored on both Thrift and SEA. I verified token-field locking is consistent per class, dispose ordering frees linked-token registrations unconditionally, the reader's cancelled-vs-EOF null distinction is sound, and the ICloudFetchDownloadManager change is backward-compatible (no hand-written test doubles). Only one low-severity note about a newly-introduced concrete cast whose safety rests on the current single caller.
Addresses: - #3888866814 at csharp/src/Reader/CloudFetch/CloudFetchReaderFactory.cs:218 Signed-off-by: peco-engineer-bot[bot] <peco-engineer-bot[bot]@users.noreply.github.com>
There was a problem hiding this comment.
Verdict: 1 Low
Looks good — a well-engineered, carefully-documented fix for the CloudFetch dispose/cancel hang, with one low-severity robustness note. The connection⊃statement⊃cloudfetch cancel cascade, cancel-before-teardown ordering, ObjectDisposedException-tolerant token getters, per-execute CTS refresh, and lock-guarded field swaps all check out on both Thrift and SEA paths; the StartAsync(CancellationToken = default) signature change is backward compatible and the new unit/E2E tests target the real regression.
Addresses: - #3888880902 at csharp/src/Reader/CloudFetch/CloudFetchReader.cs:171 Signed-off-by: peco-engineer-bot[bot] <peco-engineer-bot[bot]@users.noreply.github.com>
There was a problem hiding this comment.
Verdict: 1 Low
Looks good — a well-structured connection ⊃ statement ⊃ cloudfetch cancel cascade with careful locking around the per-statement CTS refresh/swap and correct linked-token teardown; AwaitWithCancellationAsync handles the completed/faulted/cancelled cases correctly and the manager disposes its linked CTS. One low-severity inconsistency: the connection dispose paths catch only generic Exception around the shutdown-CTS Cancel(), so a double-dispose emits a spurious error telemetry event, whereas the statement paths deliberately swallow ObjectDisposedException. Nit: CreateThriftReader casts (DatabricksStatement)statement unconditionally while CreateStatementExecutionReader guards the cast with a clear-message throw — harmless but asymmetric.
Addresses: - #3888896539 at csharp/src/DatabricksConnection.cs:1225 Signed-off-by: peco-engineer-bot[bot] <peco-engineer-bot[bot]@users.noreply.github.com>
There was a problem hiding this comment.
Verdict: 1 Low
Looks good — a solid, thoroughly-documented fix for the CloudFetch dispose/cancel hang. The connection⊃statement⊃cloudfetch token cascade is wired correctly on both protocols (both StartAsync callers pass the statement token; RefreshCloudFetchStatementCts runs on every execute path; token getters defensively map ObjectDisposedException→None), the reader's cancelled-null vs EOF distinction prevents silent truncation, and AwaitWithCancellationAsync correctly avoids leaking the delay timer. One low-severity note: a cross-thread Cancel() racing Dispose() emits a spurious error telemetry event for a benign ObjectDisposedException, since Cancel() (unlike Dispose()) doesn't special-case it.
Addresses: - #3888907934 at csharp/src/DatabricksStatement.cs:1642 Signed-off-by: peco-engineer-bot[bot] <peco-engineer-bot[bot]@users.noreply.github.com>
There was a problem hiding this comment.
Verdict: 1 Low
Solid, carefully-documented fix for the CloudFetch teardown hang — the connection ⊃ statement ⊃ cloudfetch cancel cascade, linked-CTS refresh-per-execute, and the AwaitWithCancellationAsync netstandard shim all look correct, and the lock scoping around the per-statement CTS swap is sound. One low-severity edge case: the cancel path now abandons the in-flight DownloadCompletedTask, which can leave a later download fault unobserved.
Addresses: - #3888924976 at csharp/src/Reader/CloudFetch/CloudFetchReader.cs:262 Signed-off-by: peco-engineer-bot[bot] <peco-engineer-bot[bot]@users.noreply.github.com>
Problem
ConcurrencyStressTests.CloseConnection_DuringCloudFetch_ShouldNotHang(Thrift) flakes with:It bounced PR #654 out of the merge queue. The failure is unrelated to #654 — it's a pre-existing, intermittent hang in the CloudFetch shutdown path on
main.Root cause
The CloudFetch pipeline runs on background tasks driven by a cancellation token that
CloudFetchDownloadManager.StartAsynccreated itself and linked to nothing external. The only thing that cancelled it was disposing the reader — owned by the blocked query task, so it can't dispose until the read loop returns.connection.Dispose()disposes the sharedHttpClientand closes the session but never cancels the pipeline, so the in-flight download fails on the deadHttpClientand the retry loop spins for the retry timeout (minutes) while the reader is parked onDownloadCompletedTask. Intermittent because it only fires whenDispose()lands mid-download.Fix — full
connection ⊃ statement ⊃ cloudfetchcancel cascade, both protocolsDatabricksConnection(Thrift) andStatementExecutionConnection(SEA) each get aCloudFetchShutdownToken(aCancellationTokenSourcecancelled at the top ofDispose()).DatabricksStatementandStatementExecutionStatementeach get a statement-lifetime CTS linked to their connection's shutdown token, cancelled inCancel()andDispose(), and refreshed per-execute (a reusable statement mustn't be poisoned by a priorCancel()). This is distinct from the per-execute token, which is disposed when execution returns and so can't cover the CloudFetch result-fetch phase.CloudFetchDownloadManager.StartAsync(CancellationToken)builds its source as a linked source; both reader factories pass the statement token in.CloudFetchDownloadManagerexposesPipelineToken;CloudFetchReaderlinks it with the caller token and checks it at the top of each read-loop iteration, so a cancel stops the read immediately instead of draining the in-memory buffer.Net: closing a connection, or cancelling/disposing a statement, tears the pipeline down and the reader unblocks in milliseconds. A healthy read that never cancels is unaffected — the tokens only fire on explicit dispose/cancel (no timers). The only wall-clock bound on CloudFetch remains the pre-existing query timeout (
ThriftResultFetcher/ SEA, default ~3h).Testing (real warehouse, both Thrift and SEA)
StartAsync_TokenCancelled_UnblocksReaderWaitingForNextFile: times out (5s) without the fix, ~0.2s with it.CancelStatement_DuringCloudFetch_ShouldStopPromptly: without the statement wiring it never stops (33s timeout on Thrift; read 67M rows on SEA); with it, ~5-20s. Verified as a real guard on both protocols.CloseConnection_DuringCloudFetch_ShouldNotHang: passes repeatedly on Thrift (~2-6s) and SEA (~1s).CloudFetchE2ETest.TestCloudFetch(10 cases) passes on both protocols — no regression to normal reads.This pull request and its description were written by Isaac.