Client X I/O #2689
Replies: 5 comments 17 replies
-
Please tag others, who may have been active on the PRs for ClientX |
Beta Was this translation helpful? Give feedback.
-
Thanks for this @saurabh500. I agree with the way the streams are laid out - whether we use streams or pipelines, I think it's pretty much the only sensible way to separate responsibilities. We're pretty much on the same page with problem 1. In an earlier PR, I've commented on Writes: bufferThis sounds like a good idea. Having a method which requests a buffer up to a specific size would be helpful, and would eliminate some of the work in TdsBufferAlloc. Writes: streaming and tiny writesTo speak to the larger design of problem 1, I think part of the underlying issue is that we currently handle multiple sizes of writes identically:
Our current approach is a decent middle ground; I think most third-party libraries will probably handle IO fairly similarly. The earlier suggestion about directly writing to the underlying transport will reduce execution time for medium and large writes. To better handle large writes (and to handle very large writes at all) we'll need to use The way we handle tiny writes is difficult though. Even writing an Int16 could theoretically cross TDS packets, incurring network IO. In the async case, this means that we're forced to await each WriteShortAsync in sequence. Although I'm not sure whether or not ValueTask allocates if it completes synchronously, (I don't think it does) there are potentially still state machines to deal with. This could be unpleasant if we're sending a large number of parameters, sending table-valued parameters (or something else which generates a large number of small writes.) I've alluded to this in the past, but I think we should aim to completely eliminate explicit tiny writes, instead trying to build up a structure in memory and writing that. This could be slightly more complex if we're going to support a table-valued parameter with a varbinary field streamed in from a file (because we're dealing with a combination of in-memory bytes and a Stream) but the net result is that we reduce the number of awaits - ideally, to a single WriteAsync. ReadsI've not got any firm opinions here - I think we're hedged into performing many tiny reads because of the TDS protocol. I'd like to avoid that where possible; if we receive row metadata which indicates that every column in the recordset is fixed-length and non-nullable then can we optimise for a single fixed-length read? Streaming data (whether byte arrays or strings) remains important here too. When presented with a varbinary(N), we might need to return a specific type of unseekable stream to the client which guarantees that we'll read N bytes from the network (but never more than that.) The implications of this would probably be difficult though - if we return a stream to the client, the client retrieves the next field and then reads from the stream, the transport stream will already have moved past the end of the varbinary data. There'd need to be some buffering in that stream instance, but this'd naturally cause memory usage to balloon with large amounts of data. General streaming IOWhen I normally think of streaming data, I usually think about byte arrays! When streaming large varchar/nvarchar/xml columns though, there's the overhead of converting from a byte array to a string. This is purely a reminder: when decoding a string, we should use the relevant |
Beta Was this translation helpful? Give feedback.
-
One idea I had percolating around when I was considering how to handle MARS read streams was to effectively link together byte arrays to allow processing without the need to copy. I wonder if we could utilize something like this to better address one of the sorta problems you mentioned. Eg, when we write an int:
Introducing a slightly more complex data structure, we could have:
The main benefit of this is that instead of copying |
Beta Was this translation helpful? Give feedback.
-
As the PR is now open, can we excercise the above concerns (and maybe mine below, which in part overlap): I still have a few questions (this is generic, I'll just take this token as an example):
Honestly, the amount of awaits here is frightening, we're talking about a protocol parser after all, there should be 0 (maybe 1) awaits in here besides a possible await for VarCharAsync read as you know the minimum bytes required to build this token. |
Beta Was this translation helpful? Give feedback.
-
@edwardneal ref structs are c# 7.2, you might have mixed that up with ref fields, so you can use ref structs since .NET core 2.0. ref fields just made the implementation of Span a lot cleaner/easier. For netfx you'd still need something like the type by Andrew, no way around that. |
Beta Was this translation helpful? Give feedback.
-
VERY VERY LONG POST ALERT
Purpose:
Starting this discussion thread to brainstorm the future of SqlClientX I/O to be performant, while adhering to the various requirements
Transport requirements
TDC protocol basics
Sql server clients and server exchange information as messages.
Messages contain a series of Packets.
Messages are a logical concept, where the last packet of the message has a status field with value of EOM which states that its the last packet of the message.
Message
Outgoing Packet Header
The rest of the bytes in the packet follow the TDS protocol spec and structure is dependant on the message being transmitted.
Incoming packet header
The incoming packet header follows a similar structure, but some unused bytes in outgoing packet, end up being meaningful
Packets are buffers which have a pre-negotiated size with the server. The packet size is 4096 till negotiated at the end of login.
The default packet size is 8000 bytes in connection string
After the login negotiation, all the packets on the connection, except the last packet of a message must be the negotiated size.
For simplicity of discussion we will use a packet size of 8k, but this is modifiable in the connection string. Details of values is https://learn.microsoft.com/en-us/dotnet/api/system.data.sqlclient.sqlconnection.packetsize?view=netframework-4.8.1
Writes
While sending out the data over TDS, the client needs to fill in the payload in the TDS packet, and set the appropriate header bytes and flush the packet to the network.
In case of writes, the Message type, status, length of data, packet number need to be adjusted for every packet.
Reads
In case of reads, the client needs to read the packet, and parse out the header to understand how much data is expected.
Though there is a status bit, it is omitted, and the client rely on the size of data specified in the header to parse the information and parse it according to the TDS protocol.
In case of reading the packets, the client needs to assume that the complete TDS packet may not be available in a single transport read, and needs to account for partial packet being read.
The following scenarios are possible:
Partial packet header read: which means that the number of bytes read are less than 8 bytes. In this case the client needs to atleast have 8 bytes packet header to understand how much more data to expect in the packet.
Full header read, but partial payload read: In this case the client could have enough information to respond to the APIs, but would need to read the rest of the packet eventually.
Streams
TDS Write stream
In the current implementation of ClientX, writing the TDS packet is modeled as a
TdsWriteStream
. This stream ingests the TDS packet payload. It needs the Type of stream being sent out. If the incoming data spans across TDS packets, then the flushes the packet to the network. While writing data spanning multiple TDS packet, the stream calculates the packet number, the status message and the data length, and send out a Packet with 0x04 status. (Cancellation is not yet implemented, which requires other status bytes to be used)When a
Flush
/FlushAsync
is called on the stream, it assumes that no more data needs to be sent in this message and sets the EOM status (0x01) of the packet, computes the packet number, the length and flushes the buffer to the network.TDS Read Stream
In the current implementation of ClientX, the readers of data from the network, calls into the stream to get the data as bytes, the consumers follow the TDS protocol, and are expected to request the right amount of data. They read the complete data of the stream and follow the TDS protocol structure. The stream takes care of making sure that it can account for split packets. If the stream has the data available in the buffer, it returns it, else it will read from the underlying transport and read the requested number of bytes, and return them to the consumer.
The consumer reads the data and uses it or discards it.
Motivation behind streams
The idea of using streams was to make the consumers unaware of the nuances of TDS packet header (for writes) and not having to worry about split packets while reading from the network.
Streams offer a nice layering mechanism too, where the TDS stream can be layered with SSL Stream, which can be layered with SSL over TdsStream (for TDS 7.4), and then the actual transport stream (Network/NamedPipes). This separates the responsibilities of the stream. There are also intentions of adding a layer of MARS stream under the TDS stream, which will be backed by a Muxer and Demuxer which has a 1x1 relationship with the SSL/Transport stream.
Each TDS stream would get its own MARS stream, which will write / read to/from muxer-demuxer which will understand the nuances of MARS over a single physical transport stream.
This was to further separate the concerns and have a layered approach in adding features to ClientX.
Deviation from stream
There are new methods on TdsStream in addition to what
System.IO.Stream
offers like peek byte, read byte async etc, since each of these methods could result in an I/O operation.Problem
While the streams are successful at abstracting away the complexities of TDS packet header, they rely on the stream readers to bring in their own buffer to copy the data into. Or the writers need to buffer the data from CLR types to the byte[], which may need e.g. Consider writing an
int
value to the stream, this needs to be converted to a byte[] before it can be handed down to the stream for writing. This means that some kind of buffer management needs to be incorporated by the writer. Consider writing a string to the TDS stream. This would require that the string be converted to a byte[] first, and then written to the stream. This can either be done with chunked buffers, or this could have been made better by writing the string to the available buffer in the stream itself, flushing it, and repeating with the rest of the string, till the whole string is flushed out, without needing an intermediate buffer.Async: While doing any reads/write operations, due to the nature of the protocol, the readers/writers may have to do network I/O calls, based on the space available in the buffer while writing and data available in the buffer while reading. We started clientX with a "written for async but usable for sync" philosophy. While the streams lend itself well to this philosophy, it almost always causes a statemachine to be created and executed if the code is written with
async
await
patterns. The alternative is to manage ValueTask/Task and check for its completion or setup continuations. Streams also cause 1 more level of depth in the call stack, likely causing another statemachine to be generated for every async operation, where the data may just need to copied over to the buffer in the stream. However this can be mitigated by moving away fromasync
await
patternPotential solution
Expose the buffer for the streams directly. Allow the writers/readers of the CLR types to manipulate/read from the buffer if it has space/data available. When the buffer is full, then use write stream to flush the buffer. Writing to buffer when we dont need it to be flushed, would be lot more efficient.
For the "async first and use for sync" approach, The readers and writers will almost always have to have a return type of
ValueTask
/Task
being exposed. However the readers / writers and their consumers need not always useasync
await
and intelligently manage theValueTask
/Task
being returned from network reads/writes.Fig 1: Current implementation in ClientX
Fig 2: Potential improvement
Pipelines
Pipelines in principle seem great for the usecase of SqlClient. Pipelines let the consumers use the buffers directly, which is an important takeaway from the above discussion post, however we have the following hurdles with pipelines for which solutions are needed.
Ask from community
Based on the above, please ask more clarifying questions/provide suggestions / improvements to the design/alternative ways of looking at the problem statement.
Beta Was this translation helpful? Give feedback.
All reactions