Part of #250.
Problem statement
This is the largest single attribution in the #238 PoC: roughly 2.306s, with allocator time falling from 2.36s to 0.05s (−95%).
When a row is already fully present in the receive buffer, decoding it still goes through the async reader a byte at a time — a boxed future per read, plus a per-value allocation. The #238 deck reports that for a ~270-byte row shape, ~99% of rows are fully resident in the TCP receive buffer by the time decode begins, so nearly all of that work is avoidable.
The PoC adds a fast path that decodes such rows in two passes: pass 1 bounds-checks every field to establish the row's extent; pass 2 writes the values.
Proposed solution
Adopt the two-pass approach, with three changes to what the PoC does.
1. Make the decline reason explicit. The PoC returns Option<RowReadResult>, which conflates "this row isn't resident yet" with "this row shape isn't supported by the fast path". Those need different handling:
enum BufferedRow {
Written(RowReadResult),
NeedMore,
Unsupported,
}
2. Add a residency loop with a hard cap. The PoC declines to the async path whenever a row straddles a packet boundary, so the fast path hit rate collapses as rows grow:
| Row size |
Straddle rate at 4 KB packets |
| ~270 B |
~7% |
| 1 KB |
~25% |
| 2 KB |
~50% |
| > 4 KB |
100% |
On NeedMore, grow the buffer and read_tds_packet().await (which appends via network_transport.rs:738 → remove_header_from_packet), then retry. working_buffer is sized packet_size * 2 (buffers.rs:21) so it must be growable.
ROW_RESIDENCY_CAP is a security requirement, not a tuning knob: pass 1 discovers the row's length only by running past its end, so without a cap a corrupt length field can drive unbounded buffer growth. The loop must also terminate cleanly on EOF.
3. Reuse the shared PLP validator rather than the PoC's local validate_plp_chunk, which implements only two of six checks.
The PoC's decline discipline is sound and should be preserved: pass 1 bounds-checks every field and returns before any writer.* call and before consume_buffered (pr238.diff:1284), with the reader advancing only after pass 2 succeeds (:1621). That is what makes declining free of side effects.
Affected crate
mssql-tds
Alternatives considered
- Single pass with rollback. Would require either buffering writer calls or making every
RowWriter implementor support undo. Two passes over an L1-resident row is far cheaper than either.
- Decline to async on any straddle (the PoC's behaviour). Rejected — 0% hit rate for rows larger than a packet, which is the case where the per-row cost is highest.
- Always buffer whole rows before decoding. Removes the fast/slow split but forces the residency cap onto every row and gives up the ability to stream very large values.
Additional context
Primary risk to de-risk first — the pass-1 tax is real and is worst where decoding is cheapest.
Measured with mssql-tds/benches/decode_plan_shape.rs by comparing its two_pass_total group against single_pass_decode:
| Schema |
single pass |
two pass |
pass-1 tax |
| intn48 |
371.0 µs |
510.7 µs |
+37.7% |
| slide5 (39 IntN + 9 varchar) |
1.097 ms |
1.273 ms |
+16.0% |
| varchar48 |
3.962 ms |
4.170 ms |
+5.3% |
Buffering must win this back from eliminated awaits and allocations before it nets anything. A narrow-integer result set is where the design is most at risk — and that is not the shape the #238 benchmark stresses, so the PoC's headline numbers do not cover this case. Benchmark a narrow all-integer schema before committing to the design.
Testing. The PoC shipped zero tests for the buffered path, against a repo gate of 85% diff coverage. The strongest guard is a differential test: a recording RowWriter that asserts the async and buffered paths emit byte-identical call traces for the same input. Both existing bench harnesses already use this technique and can be borrowed from.
Dependencies. Needs the sync reader primitives and the shared PLP validator. Should land after the ColumnDecodeSpec work in #249, since the two-pass path wants the resolved per-column spec rather than re-classifying per cell in both passes.
Part of #250.
Problem statement
This is the largest single attribution in the #238 PoC: roughly 2.306s, with allocator time falling from 2.36s to 0.05s (−95%).
When a row is already fully present in the receive buffer, decoding it still goes through the async reader a byte at a time — a boxed future per read, plus a per-value allocation. The #238 deck reports that for a ~270-byte row shape, ~99% of rows are fully resident in the TCP receive buffer by the time decode begins, so nearly all of that work is avoidable.
The PoC adds a fast path that decodes such rows in two passes: pass 1 bounds-checks every field to establish the row's extent; pass 2 writes the values.
Proposed solution
Adopt the two-pass approach, with three changes to what the PoC does.
1. Make the decline reason explicit. The PoC returns
Option<RowReadResult>, which conflates "this row isn't resident yet" with "this row shape isn't supported by the fast path". Those need different handling:2. Add a residency loop with a hard cap. The PoC declines to the async path whenever a row straddles a packet boundary, so the fast path hit rate collapses as rows grow:
On
NeedMore, grow the buffer andread_tds_packet().await(which appends vianetwork_transport.rs:738→remove_header_from_packet), then retry.working_bufferis sizedpacket_size * 2(buffers.rs:21) so it must be growable.ROW_RESIDENCY_CAPis a security requirement, not a tuning knob: pass 1 discovers the row's length only by running past its end, so without a cap a corrupt length field can drive unbounded buffer growth. The loop must also terminate cleanly on EOF.3. Reuse the shared PLP validator rather than the PoC's local
validate_plp_chunk, which implements only two of six checks.The PoC's decline discipline is sound and should be preserved: pass 1 bounds-checks every field and returns before any
writer.*call and beforeconsume_buffered(pr238.diff:1284), with the reader advancing only after pass 2 succeeds (:1621). That is what makes declining free of side effects.Affected crate
mssql-tds
Alternatives considered
RowWriterimplementor support undo. Two passes over an L1-resident row is far cheaper than either.Additional context
Primary risk to de-risk first — the pass-1 tax is real and is worst where decoding is cheapest.
Measured with
mssql-tds/benches/decode_plan_shape.rsby comparing itstwo_pass_totalgroup againstsingle_pass_decode:Buffering must win this back from eliminated awaits and allocations before it nets anything. A narrow-integer result set is where the design is most at risk — and that is not the shape the #238 benchmark stresses, so the PoC's headline numbers do not cover this case. Benchmark a narrow all-integer schema before committing to the design.
Testing. The PoC shipped zero tests for the buffered path, against a repo gate of 85% diff coverage. The strongest guard is a differential test: a recording
RowWriterthat asserts the async and buffered paths emit byte-identical call traces for the same input. Both existing bench harnesses already use this technique and can be borrowed from.Dependencies. Needs the sync reader primitives and the shared PLP validator. Should land after the
ColumnDecodeSpecwork in #249, since the two-pass path wants the resolved per-column spec rather than re-classifying per cell in both passes.