Skip to content

Decode fully-resident rows through a buffered two-pass path #263

Description

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:738remove_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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementNew feature or request

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions