Arm the per-row request timeout only when the decode suspends - #275
Conversation
Every row decode wrapped its future in `tokio::time::timeout` whenever a request budget was set, even though rows served from an already-buffered packet complete on their first poll and never observe the timer. Poll the future once first and construct the `timeout` only if it returns `Pending`. `Timeout::poll` already polls the inner future before the delay, so this is observationally identical. The condition is suspension, not an exhausted budget: `update_remaining_timeout` saturates to `Duration::ZERO` rather than `None`, so skipping the timer for a zero budget would turn a fetch that must fail with `Elapsed` into an unbounded wait. Implemented as a macro so it expands at the call site; the same logic behind an `async fn` stores the awaited arm's future in its state machine and measures ~4% slower. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
There was a problem hiding this comment.
Pull request overview
Optimizes row decoding by deferring request-timeout creation until decoding suspends.
Changes:
- Adds a lazy timeout macro with behavioral tests.
- Applies it to three row/column decoding paths.
- Registers the timeout module.
Reviewed changes
Copilot reviewed 3 out of 3 changed files in this pull request and generated 2 comments.
| File | Description |
|---|---|
request_timeout.rs |
Adds lazy timeout logic and tests. |
network_transport.rs |
Uses lazy timeouts in hot decode paths. |
transport.rs |
Registers the new internal module. |
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
…en-ready equivalence Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
📊 Code Coverage Report
Diff CoverageDiff: main...HEAD, staged and unstaged changes
Summary
🔗 Quick Links |
The ~60 ns per-call figure came from a micro-benchmark on a trivial inner future. The dominant cost is moving the inner future into Timeout<F> by value, which scales with its size, so that number does not transfer. In situ the inner future is 2360 B and the saving is ~200 ns per row. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 018ae934-86a0-41a7-bd16-a4f904cceabe
read_active_plp_bytes was the one remaining site in network_transport.rs with the same Some(t) => match timeout(*t, cancellable) shape as the three row-path sites already converted. It is byte-for-byte identical to resume_row_into apart from the inner call, and it is a per-chunk call: drain_active_plp loops on it with an 8192-byte buffer, so draining one large VARCHAR(MAX)/VARBINARY(MAX)/XML value enters it once per 8 KB. No benchmark covers it - neither harness has a MAX-type column - so this is converted for consistency with the identical adjacent site rather than on a measurement. Future sizes are unchanged (1128 / 376 / 1408 / 1160), so the 4096 B row_fetch_futures_stay_small guard is unaffected. receive_token is now the only eager site left, deliberately: it runs once per result-set boundary, not per row. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 018ae934-86a0-41a7-bd16-a4f904cceabe
This PR converts read_active_plp_bytes, but the PLP streaming path had no future-size coverage, so add read_active_plp_chunk to row_fetch_futures_stay_small. It measures 216 B against the 4096 B limit. Note the number does not reveal the size of the future handed to timeout inside the transport: TdsTokenStreamReader is #[async_trait] and the client holds Box<dyn TdsTransport>, so async_trait boxes at the trait boundary and the caller's future stores only a pointer. The guard covers what callers pay, which is what #225 was about. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 018ae934-86a0-41a7-bd16-a4f904cceabe
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 89d98991-5563-45e0-be67-c9fc0981af1e
David Engel (David-Engel)
left a comment
There was a problem hiding this comment.
Summary
Replaces eager tokio::time::timeout with a probe-then-arm macro on the four per-row/per-column read paths. The core reasoning is sound and the implementation looks correct to me:
- the probe's
poll_fnalways returnsReady, so it never suspends and no wakeup can be lost; - the waker the inner future registers on the probe is the same task waker
Timeoutuses on its immediate next poll, so there is no gap; Option<Duration>isCopy, so$budgetis evaluated exactly once;- macro-local
fut/budgetare hygienic against the caller's expressions; - cancellation ordering (
run_until_cancellednested inside the budget) is preserved at all four sites.
Keying the arming decision on suspension rather than on Duration::ZERO is the right call, and the rationale is documented well.
No blocking findings. Suggestions are inline; two items that fall on unchanged lines are below.
Verification I ran
Fresh worktree at db45b6b8:
cargo clippy -p mssql-tds --all-targets— cleancargo fmt --all -- --check— clean- all 6 new
request_timeouttests pass - instrumented
row_fetch_futures_stay_smallon this branch and onorigin/mainto compare future sizes (see the inline comment on the doc block) - confirmed
receive_tokenreally is not in the per-row loops (tds_client.rsget_next_row_into/next_row_cursorusereceive_row_into/receive_row_header), so leaving it eager is correctly scoped
Follow-up beyond this PR (relevant to #271)
These land on unchanged lines, so noting them here rather than inline.
tds_client.rs:3631 (get_next_row_into) and tds_client.rs:3696 (next_row_cursor) still take an unconditional Instant::now() per row before every receive_row_* call, and update_remaining_timeout takes a second clock read when a budget is set. When no command timeout is configured, that first read is pure overhead on exactly the hot path this PR is optimizing. Skipping it when remaining_request_timeout.is_none() looks like a cheap next slice. Out of scope here — just flagging it.
Nit on the checklist
cargo btest is the one unchecked box in the description. The ADO validation run is green, so I assume it just needs ticking.
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 89d98991-5563-45e0-be67-c9fc0981af1e
Ticked. The ADO validation run on
Confirmed, and it is worse than "one extra clock read" when no timeout is configured: Note Auto-replied by the GitHub Copilot app. |
David Engel (David-Engel)
left a comment
There was a problem hiding this comment.
Re-review at 1aaf17f5 — looks good to me
All four actionable items are addressed and both declines are well argued. Locally on this head: cargo fmt --all -- --check clean, cargo clippy -p mssql-tds --all-targets clean, all 7 request_timeout tests pass. All PR checks green.
I would approve, but I have been in this thread as a reviewer of your measurements rather than as an owner, so treat this as a non-blocking sign-off.
On the new guard test
I verified ready_path_never_constructs_a_sleep independently rather than taking the claim on trust. Replacing the macro body with plain eager tokio::time::timeout and re-running the module: that test FAILS (panics at the Sleep construction) while the other six still pass. It isolates exactly the property the PR exists for, which is what was missing before. Nice.
Retraction
My "multi-kilobyte" finding was wrong — details in that thread. async_trait boxes the trait method, so the sizes I quoted never bounded the moved future. Re-measured at the call site: receive_row_into_internal is 1,408 B and the CancelHandle::run_until_cancelled future handed to the macro is 4,328 B, matching your numbers. Your doc wording stands.
Follow-up that came out of that measurement (not for this PR)
Decomposing the 3.07x:
| future | size | vs. inner |
|---|---|---|
receive_row_into_internal |
1,408 B | 1.00x |
tokio-util CancellationToken::run_until_cancelled(inner) |
2,904 B | 2.06x |
CancelHandle::run_until_cancelled(None, inner) (core.rs:43) |
4,328 B | 3.07x |
The outer wrapper adds 1,424 B on top of tokio-util's — very close to one whole extra copy of inner. That matches its shape: f is live in two distinct suspend states (the Some arm's combinator and None => f.await), so the state machine reserves room for both. Collapsing it to a single await point, for example resolving to one CancellationToken up front and using a never-cancelled token when cancel_handle is None, should land near 2,904 B for the cost of an Arc bump on the no-handle path.
This is worth more than a memcpy: async_trait expands to Box::pin(async move { ... }) per call, so that 4,328 B is a per-row heap allocation, not just a per-row move. Same hot path as #271 and a larger term than the 254 ns this PR reclaims. Happy to open it as its own issue if you would rather not carry it here.
Filed as #298 so it does not ride along here. Your diagnosis is right and the size prediction is essentially exact: a pin-once prototype (pin The reason I did not carry it as a follow-on PR is that the shrink did not translate into time. Seven fresh processes, 31 paired ABBA pairs each, 20,000 buffered rows per pass: no handle median +12.2 ns/row (0.82% slower), live handle median +6.8 ns/row (0.43% slower), per-process range -42.7 to +75.7 ns/row with the sign flipping across processes, while the old-vs-old A/A control sat at -0.8 ns/row. The harness is tight; the wrapper swing is code layout. One correction on the framing: shrinking the future shrinks the Thanks for re-measuring the size decomposition and for independently verifying Note Auto-replied by the GitHub Copilot app. |
|
Correction to my own follow-up review above — please disregard the cancellation-wrapper section. It is a duplicate. #265 already carries it as "Adjacent finding: ~70% of the row future is cancellation plumbing", down to the same mechanism at And my conclusion was backwards. I said it was a larger term than what this PR reclaims. #265's paired wrapper ladder already priced it:
So the cancellation plumbing is ~70% of the future by size but only ~5% by time, while the per-row For the record, the size does still matter, but for a reason that belongs to #265 rather than the one I gave: at ~1.4 KB the row future would fit the 4096 B No new issue needed — #265 owns this, and #297 now covers the |
|
We crossed by about thirty seconds — #298 was filed while I was posting, so ignore the "no new issue needed" line in my previous comment. #298 is the right home and my comment predates it. Your ABBA run settles it better than my retraction did. The prototype landing at 2,912 B against a predicted ~2,904 B and still measuring +12.2 / +6.8 ns/row — with the sign flipping across processes against a −0.8 ns/row A/A control — is the cleanest statement of the point: an exact model of the wrong quantity. Worth keeping in #298 so the next person does not re-derive the byte table and assume it implies time. Taking your framing correction as well. Shrinking the future does not remove the Nothing changes on the PR — #275 still looks good. |
Description
Rows decoded from an already-buffered TDS packet usually complete on their first poll. The row path still wrapped every decode in
tokio::time::timeout, paying for a clock read,Sleepconstruction, and a larger wrapper future even though no wait occurred.This PR polls the decode future once and creates the timeout only when that poll returns
Pending. Tokio also polls the inner future before its delay, so the ready path keeps the same behavior while avoiding the unused timeout setup.The lazy timeout is used by the four row-data operations in
NetworkTransport:receive_row_intoreceive_row_headerresume_row_intoread_active_plp_bytesConnection, cancellation-drain, and token-boundary timeouts stay eager because they are not paid per row or column. The PLP streaming site is changed for consistency with the adjacent row-resume path; it was not covered by the benchmark.
Timeout behavior
The remaining request budget can be
Some(Duration::ZERO). The implementation therefore keys on whether the decode suspends, not whether the budget is zero:ReadyPendingElapsedwhen the exhausted budget winsTests compare these cases directly with
tokio::time::timeout, including a future that suspends once and then becomes ready.The implementation is an inline macro because the equivalent
async fnhelper measured about 4% slower by growing the per-row state machine.Performance
PR #264 is now in
mainand removes the dominant boxed packet-reader overhead from row decoding. On that optimized path, a paired ABBA benchmark measured this change at about 254 ns/row, or 15% of isolated decode time for a 48-column row; the eager arm was slower in 202 of 205 pairs. See the measurement on #271.This isolates row-decode CPU cost. End-to-end impact will be smaller when network, TLS, or server time dominates.
The future-size guard still passes and now also covers
read_active_plp_chunk.Cancellation handling is unchanged: each operation still uses
CancelHandle::run_until_cancelled.Related Issues
Fixes #271
Checklist
cargo bfmtpassescargo bclippypassescargo btestpasses