You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
Give RowWriter implementations a way to receive string and binary payloads without mssql-tds allocating for them, so an external consumer can act as a direct sink for wire bytes and place them straight into its own final representation.
The driving consumer is a PostgreSQL FDW (pg_fabric) that queries SQL Server and materialises results as PG datums. It needs the encoded bytes plus the encoding descriptor; where the encoding permits, it stores them verbatim and lets PostgreSQL own transcoding.
Non-goals
DefaultRowWriter is not required to benefit. It may keep its existing owned write_string/write_bytes path unchanged. Unifying the two was an aspiration, not a requirement.
Not a rework of ColumnValues, take_row(), or row-level buffer reuse.
Six methods added to RowWriter, every one with a default implementation, so existing writers compile and behave identically without edits.
/// Writes packet-backed encoded string bytes before the packet buffer is reused.fnwrite_string_ref(&mutself,col:usize,bytes:&[u8],encoding_type:&EncodingType){self.write_string(col,SqlString::new(bytes.to_vec(), encoding_type.clone()));}/// Writes packet-backed binary bytes before the packet buffer is reused.fnwrite_bytes_ref(&mutself,col:usize,bytes:&[u8]){self.write_bytes(col, bytes.to_vec());}/// Returns a destination for a known-length binary value, when the consumer/// can allocate its final representation before the decoder reads the bytes.fnbytes_destination(&mutself,_col:usize,_length:usize) -> Option<&mut[u8]>{None}/// Completes or abandons a destination returned by `bytes_destination`.fnfinish_bytes_destination(&mutself,_col:usize,_success:bool){}/// Returns a destination for a known-length character value, when the consumer/// can allocate its final representation before the decoder reads the bytes.////// `length` counts encoded bytes on the wire, not characters, so a consumer/// whose storage differs in length under `encoding_type` returns `None`.fnstring_destination(&mutself,_col:usize,_length:usize,_encoding_type:&EncodingType,) -> Option<&mut[u8]>{None}/// Completes or abandons a destination returned by `string_destination`.fnfinish_string_destination(&mutself,_col:usize,_success:bool){}
&mut [u8] rather than &mut Vec<u8> is deliberate and load-bearing — see Why the destination is a slice below.
How the decoder selects a path
Three tiers, tried in order, per value:
Tier
Path
Applies when
Cost to consumer
1
*_destination → fill → finish_*_destination
length known before the payload is read, and the writer returns Some
0 copies, 0 mssql-tds allocations
2
write_*_ref(&[u8])
length unknown (PLP_UNKNOWNLEN), encoding needs transcoding, or the writer declined tier 1
1 copy, 0 mssql-tds allocations
3
write_string(SqlString) / write_bytes(Vec<u8>)
writer overrides nothing
unchanged from today
finish_*_destination(col, success) carries the outcome, so a decode error, timeout, or cancellation mid-value lets the consumer release or invalidate the storage it handed out. Only one destination may be outstanding at a time; the col argument lets the consumer assert that.
Why the destination is a slice, not a Vec
The consumer's final storage is not on the Rust heap. The FDW allocates a PostgreSQL varlena, writes the length header, and returns a slice over the payload region:
let varlena = pg_sys::palloc(allocation_size)as*mut pg_sys::varlena;
pgrx::set_varsize_4b(varlena, allocation_size asi32);let data = addr_of_mut!((&mut**varattrib_4b).va_data).cast::<u8>();self.pending_bytes = Some((col,Datum::from(varlena asusize)));Some(std::slice::from_raw_parts_mut(data, length))
Consequences:
palloc returns uninitialised memory, so there is no zero-fill. The objection that a reserve-style API forces a memset applies only to a Vec-backed writer.
The decoder writes into the value's final location. A &mut Vec<u8> would force the consumer to copy Rust heap → palloc afterwards, which defeats the purpose.
The consumer is already in unsafe FFI territory and constructs the slice itself.
Why DefaultRowWriter cannot regress
It overrides none of the six methods, so it is structurally excluded from the new paths rather than merely measured as neutral. Any nonzero delta on DefaultRowWriter indicates accidental coupling elsewhere and should be treated as a defect, not a tradeoff.
This is the decisive difference from the earlier begin_value/commit_value attempt, which changed the path all writers took and regressed DefaultRowWriter by up to +20.9% on small values.
Transcoding
EncodingType is passed to string_destination and write_string_ref so the consumer decides:
Utf8 — accept the bytes verbatim after a validity check; no transcode.
LcidBased — if the payload is ASCII, accept verbatim (below 0x80 every single-byte code page agrees with UTF-8); otherwise transcode.
Utf16 — the UTF-8 output length is not known from the wire length, so tier 1 is declined (None) and the value arrives via write_string_ref.
Prerequisite: borrowed payload slices
Tier 2 requires handing out &[u8] borrowed from the packet buffer. TdsPacketReader currently exposes only scalar reads, read_bytes(&mut [u8]), and read_u8_varbyte() -> Vec<u8> — there is no slice-returning API. That capability has to be added, and it is the highest-risk part of this work: lifetimes against buffer reuse, and values that span packet boundaries.
Tier 1 needs no new reader capability; read_bytes(&mut [u8]) already fills a caller-owned slice and works across packet boundaries.
Scope
In scope: the six RowWriter methods, decoder tier selection for string/binary values, and the packet-reader borrow capability.
Out of scope: next_row_into_unboxed and the token-stream/dispatch changes carried in the reference branches — that axis landed in #264 and measured no additional gain on its own.
Measurement gate
Any implementation must be measured on current main, not on the reference branches. Both are based on dc8c62d1, which predates #264; that change alone took ~61.6% out of this path, and #269 demonstrated a change that looked neutral pre-#264 and cost +21.5% after.
Ship criteria:
The FFI/sink writer gains materially on VARBINARY(MAX) and NVARCHAR(MAX).
DefaultRowWriter measures flat (expected exactly 0%, since it takes no new path).
Non-PLP values do not regress. A prior attempt showed an unexplained +4.1% on the non-PLP FFI control across all rounds, on a path that gained no sink calls. Reproduce or rule this out before proposing a PR.
Both are proofs of concept and not merge candidates.
History
This issue previously proposed a reserve(col, n) -> &mut [u8] accumulator, which was revised at review to begin_value -> Option<&mut Vec<u8>> and closed after that variant regressed DefaultRowWriter. The revision was the error: a Vec destination cannot write into consumer-owned memory, so the measured design was incapable of delivering the goal above. The original slice-based shape was correct. Reopened with the design restated to match the real consumer.
Give
RowWriterimplementations a way to receive string and binary payloads withoutmssql-tdsallocating for them, so an external consumer can act as a direct sink for wire bytes and place them straight into its own final representation.The driving consumer is a PostgreSQL FDW (
pg_fabric) that queries SQL Server and materialises results as PG datums. It needs the encoded bytes plus the encoding descriptor; where the encoding permits, it stores them verbatim and lets PostgreSQL own transcoding.Non-goals
DefaultRowWriteris not required to benefit. It may keep its existing ownedwrite_string/write_bytespath unchanged. Unifying the two was an aspiration, not a requirement.ColumnValues,take_row(), or row-level buffer reuse.API
Six methods added to
RowWriter, every one with a default implementation, so existing writers compile and behave identically without edits.&mut [u8]rather than&mut Vec<u8>is deliberate and load-bearing — see Why the destination is a slice below.How the decoder selects a path
Three tiers, tried in order, per value:
*_destination→ fill →finish_*_destinationSomemssql-tdsallocationswrite_*_ref(&[u8])PLP_UNKNOWNLEN), encoding needs transcoding, or the writer declined tier 1mssql-tdsallocationswrite_string(SqlString)/write_bytes(Vec<u8>)finish_*_destination(col, success)carries the outcome, so a decode error, timeout, or cancellation mid-value lets the consumer release or invalidate the storage it handed out. Only one destination may be outstanding at a time; thecolargument lets the consumer assert that.Why the destination is a slice, not a
VecThe consumer's final storage is not on the Rust heap. The FDW allocates a PostgreSQL
varlena, writes the length header, and returns a slice over the payload region:Consequences:
pallocreturns uninitialised memory, so there is no zero-fill. The objection that a reserve-style API forces amemsetapplies only to aVec-backed writer.&mut Vec<u8>would force the consumer to copy Rust heap →pallocafterwards, which defeats the purpose.unsafeFFI territory and constructs the slice itself.Why
DefaultRowWritercannot regressIt overrides none of the six methods, so it is structurally excluded from the new paths rather than merely measured as neutral. Any nonzero delta on
DefaultRowWriterindicates accidental coupling elsewhere and should be treated as a defect, not a tradeoff.This is the decisive difference from the earlier
begin_value/commit_valueattempt, which changed the path all writers took and regressedDefaultRowWriterby up to +20.9% on small values.Transcoding
EncodingTypeis passed tostring_destinationandwrite_string_refso the consumer decides:Utf8— accept the bytes verbatim after a validity check; no transcode.LcidBased— if the payload is ASCII, accept verbatim (below0x80every single-byte code page agrees with UTF-8); otherwise transcode.Utf16— the UTF-8 output length is not known from the wire length, so tier 1 is declined (None) and the value arrives viawrite_string_ref.Prerequisite: borrowed payload slices
Tier 2 requires handing out
&[u8]borrowed from the packet buffer.TdsPacketReadercurrently exposes only scalar reads,read_bytes(&mut [u8]), andread_u8_varbyte() -> Vec<u8>— there is no slice-returning API. That capability has to be added, and it is the highest-risk part of this work: lifetimes against buffer reuse, and values that span packet boundaries.Tier 1 needs no new reader capability;
read_bytes(&mut [u8])already fills a caller-owned slice and works across packet boundaries.Scope
In scope: the six
RowWritermethods, decoder tier selection for string/binary values, and the packet-reader borrow capability.Out of scope:
next_row_into_unboxedand the token-stream/dispatch changes carried in the reference branches — that axis landed in #264 and measured no additional gain on its own.Measurement gate
Any implementation must be measured on current
main, not on the reference branches. Both are based ondc8c62d1, which predates #264; that change alone took ~61.6% out of this path, and #269 demonstrated a change that looked neutral pre-#264 and cost +21.5% after.Ship criteria:
VARBINARY(MAX)andNVARCHAR(MAX).DefaultRowWritermeasures flat (expected exactly 0%, since it takes no new path).Reference implementations
NivasSA/mssql-rs@poc/tds-row-decode-optimizations(9df51f01), based ondc8c62d1. Aspirational PR: POC: reduce per-row cost in the TDS row decode path #238.pg_fabric@poc/tds-decode-fdw-usage(ee3d434) —src/pg_fabric_fdw/src/fdw/writes.rsimplementsPgDatumWriter.Both are proofs of concept and not merge candidates.
History
This issue previously proposed a
reserve(col, n) -> &mut [u8]accumulator, which was revised at review tobegin_value -> Option<&mut Vec<u8>>and closed after that variant regressedDefaultRowWriter. The revision was the error: aVecdestination cannot write into consumer-owned memory, so the measured design was incapable of delivering the goal above. The original slice-based shape was correct. Reopened with the design restated to match the real consumer.Parent: #247.