Heap-allocate the PLP drain scratch buffer - #226
Conversation
The 8 KiB scratch array in drain_active_plp is live across an await, so rustc stores it inline in the generated future. That size propagates into every caller in the await chain, making the row-fetch hot path construct and move ~8.5 KB of state for a cleanup path that rarely runs. Fixes #225 Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
There was a problem hiding this comment.
Pull request overview
Heap-allocates the PLP drain buffer to reduce row-fetch future sizes and hot-path overhead.
Changes:
- Replaces the 8 KiB stack buffer with a
Vec. - Documents the allocation rationale.
- Adds regression tests for future sizes.
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
The public doc promised an allocation-free drain, which no longer holds when the drain abandons a partially read PLP column. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
📊 Code Coverage Report
Diff CoverageDiff: main...HEAD, staged and unstaged changes
Summary
mssql-tds/src/connection/tds_client.rs🔗 Quick Links |
David Engel (David-Engel)
left a comment
There was a problem hiding this comment.
Summary
One-line fix ([0u8; 8192] -> vec![0u8; 8192]) that removes an 8 KiB stack array held across an .await, plus a doc update and a future-size regression guard. The change is correct and well-scoped, and the claimed numbers reproduce exactly. No blocking issues; one doc-accuracy suggestion inline.
Verification I ran locally (worktree at aa59253)
| check | result |
|---|---|
row_fetch_futures_stay_small |
passes; measured 928 / 408 / 1208 / 960 B — matches the PR body exactly |
| guard actually catches the regression | reverted the one-liner to [0u8; 8192] -> fails with next_row_cursor future is 8480 B, expected <= 4096 B |
scope claim about win_tls/stream.rs:387 |
correct — it is a stack local in a synchronous poll_read returning Poll, no await spans it |
other large stack arrays held across awaits in mssql-tds / mssql-odbc |
none found; drain_active_plp was the only live instance |
| CI | all checks green (ADO validation across Windows/Linux/macOS/ARM, coverage, CodeQL, Kerberos, cross-repo mssql-python) |
The private rationale comment on drain_active_plp is the right call — it records exactly the thing a future reader would otherwise "optimize" back into a stack array. Keep it as written.
Blocking
None.
Suggestion
One inline comment on the next_row_cursor doc change.
Skipping a non-PLP column already materializes and discards its value, so no allocation claim on the drain path holds. Describe the observable behavior instead of an implementation detail callers cannot rely on. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Build 166546 failed on both Windows jobs when the UsePythonVersion task could not reach the GitHub python-versions registry (socket hang up) and the requested interpreters were absent from the agent tool cache. It cannot be rerun in place: Build Windows already published CoberturaCoverageRust_Windows on its first attempt, and unlike the SQL host sentinels that artifact name carries no $(System.StageAttempt) suffix, so every rerun collides with it. A fresh build is the only way to re-report the check. No code change; the tree is identical to c499c6d. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Description
drain_active_plpheld an 8 KiB scratch array on the stack across an.await, so rustc stored it inline in the generated future and that size propagated into every caller in the await chain. The row-fetch hot path therefore paid to construct and move ~8.5 KB of state per row for a cleanup path that only runs when a caller abandons a partially read PLP column.The fix is one line —
[0u8; 8192]→vec![0u8; 8192]— plus a doc comment ondrain_active_plprecording why the buffer is heap-allocated, so it doesn't get "optimized" back into a stack array later.#225 has the full explanation, the standalone criterion repro, and the measured numbers. Please read it there rather than expecting the analysis restated here.
Measured impact
Per-row future size and cost, from the standalone repro in the issue (5000 rows per sample, drain branch never taken):
Measured in the driver itself:
next_row_cursor8520 B → 928 B,read_row_column8464 B → 432 B. Fetch cost fell 20% on the ODBC path and 33% on the TDS column path.The change is entirely inside
mssql-tds, somssql-js,mssql-py-core,mssql-tds-cliandmssql-odbcall benefit. No API change, no growth in steady-state memory.Regression guard
The issue floated a
size_of_valassertion to keep this from silently regressing. It came out clean, so it's included:row_fetch_futures_stay_smallbuilds each of the four hot-path futures via the existingcreate_test_client()helper and asserts each stays under 4096 B. Constructing anasync fn's future runs none of its body, so the futures are built and dropped unpolled — no I/O, no mock traffic.Measured sizes under the test profile are 928 / 408 / 1208 / 960 B, giving ~3.4× headroom while still catching an 8 KiB regression. Verified it works by temporarily reverting the one-liner: the test fails with
next_row_cursor future is 8480 B, expected <= 4096 B.Doc correction
The public doc on
next_row_cursorpromised an "allocation-free" drain. Copilot flagged that the PLP scratch buffer invalidates it; David Engel (@David-Engel) then pointed out the replacement wording was still wrong, because skipping any non-PLP column already materializes and discards its value (see theTODO(#47154)intoken_stream.rs). The doc now describes observable behavior only and makes no allocation claim.Scope
mssql-tds/src/connection/transport/win_tls/stream.rs:387has an identical-looking[0u8; 8192]and is deliberately left alone — it sits in a synchronouspoll_readreturningPollwith no await spanning it, so it is an ordinary stack local and is correct as written. Perf: 8 KiB stack scratch in drain_active_plp inflates every row-fetch future to ~8.5 KB (8.6x per-row cost) #225 calls this out explicitly;drain_active_plpwas the only live instance.Related Issues
Fixes #225
Checklist
cargo bfmtpassescargo bclippypassescargo btestpasses — see note belownext_row_cursordoc correctedValidation was run via
.\scripts\bfmt.ps1and.\scripts\bclippy.ps1(both also covermssql-py-core, which is excluded from the workspace) and both are green.Tests were scoped with
cargo nextest run --workspace --lib --no-fail-fastbecause integration tests require a.envthat isn't present locally. Result: 2216 passed, 7 failed — the 7 are the pre-existing expired-certificate fixture tests incertificate_validatorandwin_tls::validate. Confirmed identical failures on a stashed, unmodified tree, so they are unrelated to this change.