diff --git a/src/dummy-http-server/DummyHttpServer.cs b/src/dummy-http-server/DummyHttpServer.cs index d2d5080..fa2109f 100644 --- a/src/dummy-http-server/DummyHttpServer.cs +++ b/src/dummy-http-server/DummyHttpServer.cs @@ -42,6 +42,15 @@ public class DummyHttpServer : IDisposable private int _port = 29743; private readonly TimeSpan? _withStartDelay; + /// + /// Initializes a configurable in-process dummy HTTP server used for testing endpoints. + /// + /// If true, enable JWT bearer authentication and authorization. + /// If true, enable basic authentication behavior in the test endpoint. + /// If true, configure the test endpoint to produce retriable error responses. + /// If true, include error messages in test error responses. + /// Optional delay applied when starting the server. + /// If true, require client TLS certificates for HTTPS connections. public DummyHttpServer(bool withTokenAuth = false, bool withBasicAuth = false, bool withRetriableError = false, bool withErrorMessage = false, TimeSpan? withStartDelay = null, bool requireClientCert = false) { @@ -108,6 +117,13 @@ public void Dispose() _app.StopAsync().Wait(); } + /// + /// Clears the in-memory receive buffers and resets the endpoint error state and counter. + /// + /// + /// Empties IlpEndpoint.ReceiveBuffer and IlpEndpoint.ReceiveBytes, sets IlpEndpoint.LastError to null, + /// and sets IlpEndpoint.Counter to zero. + /// public void Clear() { IlpEndpoint.ReceiveBuffer.Clear(); @@ -116,6 +132,12 @@ public void Clear() IlpEndpoint.Counter = 0; } + /// + /// Starts the HTTP server on the specified port and configures the supported protocol versions. + /// + /// Port to listen on (defaults to 29743). + /// Array of supported protocol versions; defaults to {1, 2, 3} when null. + /// A task that completes after any configured startup delay has elapsed and the server's background run task has been initiated. public async Task StartAsync(int port = 29743, int[]? versions = null) { if (_withStartDelay.HasValue) @@ -128,6 +150,9 @@ public async Task StartAsync(int port = 29743, int[]? versions = null) _ = _app.RunAsync($"http://localhost:{port}"); } + /// + /// Starts the web application and listens for HTTP requests on http://localhost:{_port}. + /// public async Task RunAsync() { await _app.RunAsync($"http://localhost:{_port}"); @@ -138,11 +163,19 @@ public async Task StopAsync() await _app.StopAsync(); } + /// + /// Gets the server's in-memory text buffer of received data. + /// + /// The mutable containing the accumulated received text; modifying it updates the server's buffer. public StringBuilder GetReceiveBuffer() { return IlpEndpoint.ReceiveBuffer; } + /// + /// Gets the in-memory list of bytes received by the ILP endpoint. + /// + /// The mutable list of bytes received by the endpoint. public List GetReceivedBytes() { return IlpEndpoint.ReceiveBytes; @@ -160,6 +193,10 @@ public async Task Healthcheck() } + /// + /// Generates a JWT for the test server when the provided credentials match the server's static username and password. + /// + /// The JWT string when credentials are valid; null otherwise. The issued token is valid for one day. public string? GetJwtToken(string username, string password) { if (username == Username && password == Password) @@ -180,6 +217,11 @@ public int GetCounter() return IlpEndpoint.Counter; } + /// + /// Produces a human-readable string representation of the server's received-bytes buffer, interpreting embedded markers and formatting arrays and numeric values. + /// + /// The formatted textual representation of the received bytes buffer. + /// Thrown when the buffer contains an unsupported type code. public string PrintBuffer() { var bytes = GetReceivedBytes().ToArray(); @@ -263,4 +305,4 @@ public string PrintBuffer() sb.Append(Encoding.UTF8.GetString(bytes, lastAppend, i - lastAppend)); return sb.ToString(); } -} +} \ No newline at end of file diff --git a/src/net-questdb-client-tests/DecimalTestHelpers.cs b/src/net-questdb-client-tests/DecimalTestHelpers.cs index 2f16fcd..77142e4 100644 --- a/src/net-questdb-client-tests/DecimalTestHelpers.cs +++ b/src/net-questdb-client-tests/DecimalTestHelpers.cs @@ -30,6 +30,13 @@ namespace net_questdb_client_tests; internal static class DecimalTestHelpers { + /// + /// Asserts that the buffer contains a decimal field for the specified column with the given scale and mantissa bytes. + /// + /// The encoded row payload to search for the column's decimal field. + /// The name of the column whose decimal payload is expected in the buffer. + /// The expected scale byte of the decimal field. + /// The expected mantissa bytes of the decimal field. internal static void AssertDecimalField(ReadOnlySpan buffer, string columnName, byte expectedScale, @@ -49,6 +56,14 @@ internal static void AssertDecimalField(ReadOnlySpan buffer, $"Mantissa bytes for `{columnName}` did not match expectation."); } + /// + /// Asserts that the buffer contains a null decimal field payload for the specified column. + /// + /// Buffer containing the encoded record(s) to inspect. + /// Name of the column whose decimal payload should be null. + /// + /// Verifies the payload starts with '=' then the DECIMAL type marker, and that both scale and mantissa length are zero. + /// internal static void AssertDecimalNullField(ReadOnlySpan buffer, string columnName) { var payload = ExtractDecimalPayload(buffer, columnName); @@ -61,6 +76,12 @@ internal static void AssertDecimalNullField(ReadOnlySpan buffer, string co Assert.That(payload[3], Is.EqualTo(0), $"Unexpected mantissa length for `{columnName}`."); } + /// + /// Locate and return the payload bytes for a decimal column identified by name. + /// + /// The byte span containing the encoded record payload to search. + /// The column name whose payload prefix ("columnName=") will be located. + /// The slice of immediately after the found "columnName=" prefix. private static ReadOnlySpan ExtractDecimalPayload(ReadOnlySpan buffer, string columnName) { var prefix = Encoding.ASCII.GetBytes($"{columnName}="); diff --git a/src/net-questdb-client-tests/DummyIlpServer.cs b/src/net-questdb-client-tests/DummyIlpServer.cs index f145836..a108f74 100644 --- a/src/net-questdb-client-tests/DummyIlpServer.cs +++ b/src/net-questdb-client-tests/DummyIlpServer.cs @@ -49,6 +49,11 @@ public class DummyIlpServer : IDisposable private string? _publicKeyY; private volatile int _totalReceived; + /// + /// Initializes the dummy ILP server and starts a TCP listener bound to the loopback interface. + /// + /// TCP port to listen on. + /// If true, enables TLS for incoming connections. public DummyIlpServer(int port, bool tls) { _tls = tls; @@ -69,6 +74,12 @@ public void AcceptAsync() Task.Run(AcceptConnections); } + /// + /// Accepts a single incoming connection, optionally negotiates TLS and performs server authentication, then reads and saves data from the client. + /// + /// + /// Handles one client socket from the listener, wraps the connection with TLS if configured, invokes server-auth when credentials are set, and delegates continuous data receipt to the save routine. Socket errors are caught and the client socket is disposed on exit. + /// private async Task AcceptConnections() { Socket? clientSocket = null; @@ -107,6 +118,11 @@ private X509Certificate GetCertificate() return X509Certificate.CreateFromCertFile("certificate.pfx"); } + /// + /// Performs the server-side authentication handshake over the given stream using a challenge-response ECDSA verification. + /// + /// Stream used for the authentication handshake; the method may write to it and will close it if the requested key id mismatches or the signature verification fails. + /// Thrown when the configured public key coordinates are not set. private async Task RunServerAuth(Stream connection) { var receivedLen = await ReceiveUntilEol(connection); @@ -165,6 +181,11 @@ private static string Pad(string text) return text + new string('=', padding); } + /// + /// Decode a Base64 string that may use URL-safe characters and missing padding into its raw byte representation. + /// + /// A Base64-encoded string which may use '-' and '_' instead of '+' and '/' and may omit padding. + /// The decoded bytes represented by the normalized Base64 input. public static byte[] FromBase64String(string encodedPrivateKey) { var replace = encodedPrivateKey @@ -173,6 +194,11 @@ public static byte[] FromBase64String(string encodedPrivateKey) return Convert.FromBase64String(Pad(replace)); } + /// + /// Reads bytes from the provided stream until a newline ('\n') byte is encountered, storing any bytes that follow the newline from the final read into the server's receive buffer. + /// + /// The stream to read incoming bytes from. + /// The index position of the newline byte within the internal read buffer. private async Task ReceiveUntilEol(Stream connection) { var len = 0; @@ -223,16 +249,35 @@ private async Task SaveData(Stream connection, Socket socket) } } + /// + /// Produces a human-readable representation of the data received from the connected client. + /// + /// A formatted string containing the contents of the server's received buffer. public string GetTextReceived() { return PrintBuffer(); } + /// + /// Gets a copy of all bytes received so far. + /// + /// A byte array containing the raw bytes received up to this point. public byte[] GetReceivedBytes() { return _received.ToArray(); } + /// + /// Converts the server's accumulated receive buffer into a human-readable string by decoding UTF-8 text and expanding embedded binary markers into readable representations. + /// + /// + /// The method scans the internal receive buffer for the marker sequence "==". After the marker a type byte determines how the following bytes are interpreted: + /// - type 14: formats a multi-dimensional array of doubles as "ARRAY<dim1,dim2,...>[v1,v2,...]". + /// - type 16: formats a single double value. + /// All bytes outside these marked sections are decoded as UTF-8 text and included verbatim. + /// + /// A formatted string containing the decoded UTF-8 text and expanded representations of any detected binary markers. + /// Thrown when an unknown type marker is encountered after the marker sequence. public string PrintBuffer() { var bytes = _received.ToArray(); @@ -317,6 +362,12 @@ public string PrintBuffer() return sb.ToString(); } + /// + /// Enables server-side authentication by configuring the expected key identifier and the ECDSA public key coordinates. + /// + /// The key identifier expected from the client during authentication. + /// Base64-encoded X coordinate of the ECDSA public key (secp256r1). + /// Base64-encoded Y coordinate of the ECDSA public key (secp256r1). public void WithAuth(string keyId, string publicKeyX, string publicKeyY) { _keyId = keyId; diff --git a/src/net-questdb-client-tests/JsonSpecTestRunner.cs b/src/net-questdb-client-tests/JsonSpecTestRunner.cs index 382a536..0fd46e4 100644 --- a/src/net-questdb-client-tests/JsonSpecTestRunner.cs +++ b/src/net-questdb-client-tests/JsonSpecTestRunner.cs @@ -43,6 +43,12 @@ public class JsonSpecTestRunner private const int HttpPort = 29473; private static readonly TestCase[]? TestCases = ReadTestCases(); + /// + /// Populate the provided sender with the test case's table, symbols, and columns, then send the prepared row. + /// + /// The ISender to configure and use for sending the test case row. + /// The test case containing table name, symbols, and typed columns to write. + /// A task that completes when the prepared row has been sent. private static async Task ExecuteTestCase(ISender sender, TestCase testCase) { sender.Table(testCase.Table); @@ -87,6 +93,10 @@ private static async Task ExecuteTestCase(ISender sender, TestCase testCase) await sender.SendAsync(); } + /// + /// Executes the provided test case by sending its configured table, symbols, and columns to a local TCP listener and asserting the listener's received output against the test case's expected result. + /// + /// The test case to run; provides table, symbols, columns to send and a Result describing the expected validation (Status, Line, AnyLines, or BinaryBase64). [TestCaseSource(nameof(TestCases))] public async Task RunTcp(TestCase testCase) { @@ -143,6 +153,10 @@ public async Task RunTcp(TestCase testCase) } } + /// + /// Executes the provided test case by sending data over HTTP to a dummy server using a QuestDB sender and validates the server's response according to the test case result. + /// + /// The test case describing table, symbols, columns, and expected result (status, line(s), or base64 binary) to execute and validate. [TestCaseSource(nameof(TestCases))] public async Task RunHttp(TestCase testCase) { @@ -259,6 +273,10 @@ public class TestCase [JsonPropertyName("columns")] public TestCaseColumn[] Columns { get; set; } = null!; [JsonPropertyName("result")] public TestCaseResult Result { get; set; } = null!; + /// + /// Provides the test case name for display and logging. + /// + /// The TestName of the test case. public override string ToString() { return TestName; diff --git a/src/net-questdb-client/Buffers/Buffer.cs b/src/net-questdb-client/Buffers/Buffer.cs index b4d8644..9c17b04 100644 --- a/src/net-questdb-client/Buffers/Buffer.cs +++ b/src/net-questdb-client/Buffers/Buffer.cs @@ -32,14 +32,14 @@ namespace QuestDB.Buffers; public static class Buffer { /// - /// Creates an IBuffer instance, based on the provided protocol version. + /// Creates a concrete IBuffer implementation configured for the specified protocol version. /// - /// - /// - /// - /// - /// - /// + /// Size in bytes of each buffer segment. + /// Maximum allowed length for names stored in the buffer. + /// Maximum total buffer capacity. + /// Protocol version that determines which concrete buffer implementation to create. + /// An instance corresponding to the specified protocol version. + /// Thrown when an unsupported protocol version is provided. public static IBuffer Create(int bufferSize, int maxNameLen, int maxBufSize, ProtocolVersion version) { return version switch @@ -51,4 +51,4 @@ public static IBuffer Create(int bufferSize, int maxNameLen, int maxBufSize, Pro _ => throw new NotImplementedException(), }; } -} +} \ No newline at end of file diff --git a/src/net-questdb-client/Buffers/BufferV1.cs b/src/net-questdb-client/Buffers/BufferV1.cs index 922ecd4..7cde1cf 100644 --- a/src/net-questdb-client/Buffers/BufferV1.cs +++ b/src/net-questdb-client/Buffers/BufferV1.cs @@ -132,7 +132,13 @@ public void AtNanos(long timestampNanos) FinishLine(); } - /// + /// + /// Resets the buffer to its initial empty state and clears all written data. + /// + /// + /// Clears lengths of all allocated chunks, resets the active chunk and write position, + /// resets row and total-length counters, exits any transaction state, and clears the current table and line start markers. + /// public void Clear() { _currentBufferIndex = 0; @@ -161,7 +167,13 @@ public void TrimExcessBuffers() } } - /// + /// + /// Reverts the current (in-progress) row to its start position, removing any bytes written for that row. + /// + /// + /// Restores the active buffer index, adjusts the total Length and current Position to the saved line start, + /// and clears the table-set flag for the cancelled row. + /// public void CancelRow() { _currentBufferIndex = _lineStartBufferIndex; @@ -223,7 +235,15 @@ public void WriteToStream(Stream stream, CancellationToken ct = default) stream.Flush(); } - /// + /// + /// Sets the table name for the current row and encodes it into the buffer, beginning a new line context. + /// + /// The table name to write; must meet filesystem length limits and protocol naming rules. + /// This buffer instance to support fluent calls. + /// + /// Thrown with ErrorCode.InvalidApiCall if a transaction is active for a different table or if a table has already been set for the current line. + /// Thrown with ErrorCode.InvalidName if the provided name violates length or character restrictions. + /// public IBuffer Table(ReadOnlySpan name) { GuardFsFileNameLimit(name); @@ -385,7 +405,11 @@ public void Put(ReadOnlySpan chars) EncodeUtf8(chars); } - /// + /// + /// Appends the decimal ASCII representation of the specified 64-bit integer to the buffer. + /// + /// The current buffer instance. + /// Thrown when the value is , which cannot be represented by this method; the error contains an inner . public IBuffer Put(long value) { if (value == long.MinValue) @@ -453,6 +477,10 @@ public IBuffer Put(byte value) } + /// + /// Advance the current buffer write position and the overall length by a given number of bytes. + /// + /// The number of bytes to add to both and . [MethodImpl(MethodImplOptions.AggressiveInlining)] internal void Advance(int by) { @@ -460,6 +488,12 @@ internal void Advance(int by) Length += by; } + /// + /// Sets the buffer's current table to the stored table name when a transaction is active and no table has been set for the current row. + /// + /// + /// Has no effect if not within a transaction or if a table has already been set for the current row. + /// [MethodImpl(MethodImplOptions.AggressiveInlining)] internal void SetTableIfAppropriate() { @@ -469,6 +503,9 @@ internal void SetTableIfAppropriate() } } + /// + /// Finalizes the current row: terminates it with a newline, increments the completed row counter, resets per-row flags, and enforces the buffer size limit. + /// [MethodImpl(MethodImplOptions.AggressiveInlining)] private void FinishLine() { @@ -533,6 +570,12 @@ internal void EnsureCapacity(int additional) } } + /// + /// Encodes the specified character as UTF-8 into the current chunk and advances the write position by the number of bytes written. + /// + /// + /// If the current chunk has fewer than four bytes free, switches to the next buffer before writing. + /// private void PutUtf8(char c) { if (Position + 4 >= Chunk.Length) @@ -780,7 +823,11 @@ private static void GuardInvalidColumnName(ReadOnlySpan columnName) /// Check that the file name is not too long. /// /// - /// + /// + /// Validates that the UTF-8 encoded byte length of the given name is within the configured maximum. + /// + /// The name to validate (measured in UTF-8 bytes). + /// Thrown with if the name exceeds the maximum allowed byte length. private void GuardFsFileNameLimit(ReadOnlySpan name) { if (Encoding.UTF8.GetBytes(name.ToString()).Length > _maxNameLen) @@ -790,6 +837,13 @@ private void GuardFsFileNameLimit(ReadOnlySpan name) } } + /// + /// Attempts to add a DECIMAL column to the current row; DECIMAL types are not supported by Protocol Version V1. + /// + /// The column name to write. + /// The decimal value to write, or null to indicate absence. + /// The buffer instance for fluent chaining. + /// Always thrown with to indicate DECIMAL is unsupported. public virtual IBuffer Column(ReadOnlySpan name, decimal? value) { throw new IngressError(ErrorCode.ProtocolVersionError, "Protocol Version does not support DECIMAL types"); diff --git a/src/net-questdb-client/Buffers/BufferV2.cs b/src/net-questdb-client/Buffers/BufferV2.cs index 3a3734b..c0c5db2 100644 --- a/src/net-questdb-client/Buffers/BufferV2.cs +++ b/src/net-questdb-client/Buffers/BufferV2.cs @@ -90,7 +90,10 @@ private void GuardAgainstNonDoubleTypes(Type t) } } - // ReSharper disable once InconsistentNaming + /// + /// Writes the provided value into the buffer as little-endian raw bytes and advances the buffer position by the value's size. + /// + /// A value whose raw bytes will be written into the buffer in little-endian order. private void PutBinaryLE(T value) where T : struct { var size = Marshal.SizeOf(); @@ -110,7 +113,10 @@ private void PutBinaryBE(T value) where T : struct MemoryMarshal.Cast(slot).Reverse(); } - // ReSharper disable once InconsistentNaming + /// + /// Writes a sequence of values into the buffer in little-endian binary form, handling chunk boundaries and advancing the buffer position. + /// + /// A span of values whose raw bytes will be written as little-endian binary (elements are written whole; partial element writes are not performed). private void PutBinaryManyLE(ReadOnlySpan value) where T : struct { var srcSpan = MemoryMarshal.Cast(value); @@ -172,13 +178,22 @@ private void PutBinary(T value) where T : struct } } - /// + /// + /// Writes a column whose value is the provided span of doubles encoded as a binary double array. + /// + /// The current buffer instance. public override IBuffer Column(ReadOnlySpan name, ReadOnlySpan value) where T : struct { GuardAgainstNonDoubleTypes(typeof(T)); return PutDoubleArray(name, value); } + /// + /// Writes a one-dimensional double array column encoded in the buffer's binary double-array format. + /// + /// The column name. + /// A span of elements representing the array; elements must be of type `double`. + /// The current buffer instance. private IBuffer PutDoubleArray(ReadOnlySpan name, ReadOnlySpan value) where T : struct { SetTableIfAppropriate(); @@ -190,7 +205,13 @@ private IBuffer PutDoubleArray(ReadOnlySpan name, ReadOnlySpan value return this; } - /// + /// + /// Add a column with the given name whose value is provided by the specified double array (1D or multi-dimensional). + /// + /// The column name to write. + /// An array of doubles to write. If null the column is omitted. For a 1D array the values are written as a single-dimension double array; for multi-dimensional arrays the rank and each dimension length are written followed by the elements in row-major order. + /// This buffer instance. + /// Thrown when the array's element type cannot be determined. public override IBuffer Column(ReadOnlySpan name, Array? value) { if (value == null) diff --git a/src/net-questdb-client/Buffers/BufferV3.cs b/src/net-questdb-client/Buffers/BufferV3.cs index 3609637..729e66e 100644 --- a/src/net-questdb-client/Buffers/BufferV3.cs +++ b/src/net-questdb-client/Buffers/BufferV3.cs @@ -31,7 +31,12 @@ namespace QuestDB.Buffers; /// public class BufferV3 : BufferV2 { - /// + /// + /// Initializes a new instance of BufferV3 with the specified buffer and name length limits. + /// + /// Initial size of the internal write buffer, in bytes. + /// Maximum allowed length for column names, in characters. + /// Maximum allowed internal buffer size, in bytes. public BufferV3(int bufferSize, int maxNameLen, int maxBufSize) : base(bufferSize, maxNameLen, maxBufSize) { } @@ -49,7 +54,12 @@ public BufferV3(int bufferSize, int maxNameLen, int maxBufSize) : base(bufferSiz // Number of bits scale is shifted by. private const int ScaleShift = 16; - /// + /// + /// Writes a decimal column in QuestDB's binary column format (scale, length, and two's-complement big-endian unscaled value). + /// + /// Column name to write. + /// Nullable decimal value to encode; when null writes zero scale and zero length. + /// The buffer instance for call chaining. public override IBuffer Column(ReadOnlySpan name, decimal? value) { // # Binary Format @@ -120,4 +130,4 @@ public override IBuffer Column(ReadOnlySpan name, decimal? value) return this; } -} +} \ No newline at end of file diff --git a/src/net-questdb-client/Buffers/IBuffer.cs b/src/net-questdb-client/Buffers/IBuffer.cs index 6074853..628128a 100644 --- a/src/net-questdb-client/Buffers/IBuffer.cs +++ b/src/net-questdb-client/Buffers/IBuffer.cs @@ -228,14 +228,30 @@ public interface IBuffer /// public IBuffer Column(ReadOnlySpan name, ReadOnlySpan value) where T : struct; - /// + /// + /// Writes an array column value for the current row. + /// + /// The column name. + /// The array to write as the column value, or null to record a NULL value. + /// The same buffer instance for fluent chaining. public IBuffer Column(ReadOnlySpan name, Array? value); - /// + /// + /// Writes a column with the specified name using the provided enumerable of values and shape information. + /// + /// The column name. + /// An enumerable of values for the column; elements are of the value type `T`. + /// An enumerable of integers describing the multidimensional shape/length(s) for the values. + /// The same instance for call chaining. public IBuffer Column(ReadOnlySpan name, IEnumerable value, IEnumerable shape) where T : struct; /// /// Records a DECIMAL column value using the ILP binary decimal layout. + /// + /// Writes a DECIMAL column with the specified name using the ILP binary decimal layout. /// + /// The column name. + /// The decimal value to write, or `null` to write a NULL column. + /// The buffer instance for method chaining. public IBuffer Column(ReadOnlySpan name, decimal? value); } \ No newline at end of file diff --git a/src/net-questdb-client/Senders/AbstractSender.cs b/src/net-questdb-client/Senders/AbstractSender.cs index 54a3e26..290c96e 100644 --- a/src/net-questdb-client/Senders/AbstractSender.cs +++ b/src/net-questdb-client/Senders/AbstractSender.cs @@ -94,7 +94,12 @@ public ISender Column(ReadOnlySpan name, long value) return this; } - /// + /// + /// Appends an integer-valued column with the specified name to the current buffered row. + /// + /// The column name. + /// The integer value to append for the column. + /// The same instance to allow fluent chaining. public ISender Column(ReadOnlySpan name, int value) { Buffer.Column(name, value); @@ -311,6 +316,9 @@ private void FlushIfNecessary(CancellationToken ct = default) } } + /// + /// Sets to the current UTC time if it has not been initialized. + /// [MethodImpl(MethodImplOptions.AggressiveInlining)] private void GuardLastFlushNotSet() { @@ -320,6 +328,12 @@ private void GuardLastFlushNotSet() } } + /// + /// Adds a nullable decimal column value to the current row in the buffer. + /// + /// The column name. + /// The decimal value to write, or null to emit a null for the column. + /// The same instance for fluent chaining. public ISender Column(ReadOnlySpan name, decimal? value) { Buffer.Column(name, value); diff --git a/src/net-questdb-client/Senders/HttpSender.cs b/src/net-questdb-client/Senders/HttpSender.cs index d748653..3cb644a 100644 --- a/src/net-questdb-client/Senders/HttpSender.cs +++ b/src/net-questdb-client/Senders/HttpSender.cs @@ -57,6 +57,10 @@ internal class HttpSender : AbstractSender private readonly Func _sendRequestFactory; private readonly Func _settingRequestFactory; + /// + /// Initializes a new HttpSender configured according to the provided options. + /// + /// Configuration for the sender, including connection endpoint, TLS and certificate settings, buffering and protocol parameters, authentication, and timeouts. public HttpSender(SenderOptions options) { _sendRequestFactory = GenerateRequest; @@ -70,6 +74,17 @@ public HttpSender(string confStr) : this(new SenderOptions(confStr)) { } + /// + /// Configure and initialize the SocketsHttpHandler and HttpClient, set TLS and authentication options, determine the Line Protocol version (probing /settings when set to Auto), and create the internal send buffer. + /// + /// + /// - Applies pool and connection settings from Options. + /// - When using HTTPS, configures TLS protocols, optional remote-certificate validation override (when tls_verify is unsafe_off), optional custom root CA installation, and optional client certificates. + /// - Sets connection timeout, PreAuthenticate, BaseAddress, and disables HttpClient timeout. + /// - Adds Basic or Bearer Authorization header when credentials or token are provided. + /// - If protocol_version is Auto, probes the server's /settings with a 1-second retry window to select the highest mutually supported protocol up to V3, falling back to V1 on errors or unexpected responses. + /// - Initializes the Buffer with init_buf_size, max_name_len, max_buf_size, and the chosen protocol version. + /// private void Build() { _handler = new SocketsHttpHandler @@ -134,9 +149,9 @@ private void Build() { _client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Basic", - Convert.ToBase64String( - Encoding.ASCII.GetBytes( - $"{Options.username}:{Options.password}"))); + Convert.ToBase64String( + Encoding.ASCII.GetBytes( + $"{Options.username}:{Options.password}"))); } else if (!string.IsNullOrEmpty(Options.token)) { @@ -209,13 +224,13 @@ private CancellationTokenSource GenerateRequestCts(CancellationToken ct = defaul } /// - /// Creates a new HTTP request with appropriate encoding. + /// Create an HTTP POST request targeting "/write" with the sender's buffer as the request body. /// - /// + /// An configured with the buffer as the request body, Content-Type set to "text/plain" with charset "utf-8", and Content-Length set to the buffer length. private HttpRequestMessage GenerateRequest() { var request = new HttpRequestMessage(HttpMethod.Post, "/write") - { Content = new BufferStreamContent(Buffer), }; + { Content = new BufferStreamContent(Buffer), }; request.Content.Headers.ContentType = new MediaTypeHeaderValue("text/plain") { CharSet = "utf-8", }; request.Content.Headers.ContentLength = Buffer.Length; return request; @@ -242,13 +257,13 @@ public override ISender Transaction(ReadOnlySpan tableName) if (WithinTransaction) { throw new IngressError(ErrorCode.InvalidApiCall, - "Cannot start another transaction - only one allowed at a time."); + "Cannot start another transaction - only one allowed at a time."); } if (Length > 0) { throw new IngressError(ErrorCode.InvalidApiCall, - "Buffer must be clear before you can start a transaction."); + "Buffer must be clear before you can start a transaction."); } Buffer.Transaction(tableName); @@ -256,7 +271,6 @@ public override ISender Transaction(ReadOnlySpan tableName) } /// - /// /> public override void Commit(CancellationToken ct = default) { try @@ -307,7 +321,15 @@ public override void Rollback() Buffer.Clear(); } - /// + /// + /// Sends the current buffer synchronously to the server, applying configured retries and handling server-side errors. + /// + /// + /// Validates that a pending transaction is being committed before sending. If the buffer is empty this method returns immediately. + /// On success updates from the server response date; on failure sets to now. The buffer is always cleared after the operation. + /// + /// Cancellation token to cancel the send operation. + /// Thrown with if a transaction is open but not committing, or with for server/transport errors. public override void Send(CancellationToken ct = default) { if (WithinTransaction && !CommittingTransaction) @@ -361,7 +383,17 @@ public override void Send(CancellationToken ct = default) } } - private HttpResponseMessage SendWithRetries(CancellationToken ct, Func requestFactory, TimeSpan retryTimeout) + /// + /// Sends an HTTP request produced by and retries on transient connection or server errors until a successful response is received or elapses. + /// + /// Cancellation token used to cancel the overall operation and linked to per-request timeouts. + /// Factory that produces a fresh for each attempt. + /// Maximum duration to keep retrying transient failures; retries are skipped if this is zero. + /// The final returned by the server for a successful request. + /// Thrown with when a connection could not be established within the allowed retries. + /// The caller is responsible for disposing the returned ./// + private HttpResponseMessage SendWithRetries(CancellationToken ct, Func requestFactory, + TimeSpan retryTimeout) { HttpResponseMessage? response = null; CancellationTokenSource cts = GenerateRequestCts(ct); @@ -379,9 +411,9 @@ private HttpResponseMessage SendWithRetries(CancellationToken ct, Func TimeSpan.Zero) - // retry if appropriate - error that's retriable, and retries are enabled + // retry if appropriate - error that's retriable, and retries are enabled { - if (response == null // if it was a cannot correct error + if (response == null // if it was a cannot correct error || (!response.IsSuccessStatusCode // or some other http error && IsRetriableError(response.StatusCode))) { @@ -391,9 +423,9 @@ private HttpResponseMessage SendWithRetries(CancellationToken ct, Func + /// Read an error payload from the HTTP response (JSON if possible, otherwise raw text) and throw an IngressError containing the server reason and the parsed error details. + /// + /// The HTTP response containing a JSON or plain-text error body. + /// Always thrown with ; the message contains response.ReasonPhrase followed by the deserialized JSON error or the raw response body. private async Task HandleErrorJsonAsync(HttpResponseMessage response) { await using var respStream = await response.Content.ReadAsStreamAsync(); @@ -502,7 +539,7 @@ public override async Task SendAsync(CancellationToken ct = default) // retry if appropriate - error that's retriable, and retries are enabled if (Options.retry_timeout > TimeSpan.Zero) { - if (response == null // if it was a cannot correct error + if (response == null // if it was a cannot correct error || (!response.IsSuccessStatusCode // or some other http error && IsRetriableError(response.StatusCode))) { @@ -512,9 +549,9 @@ public override async Task SendAsync(CancellationToken ct = default) while (retryTimer.Elapsed < Options.retry_timeout // whilst we can still retry && ( - response == null || // either we can't connect - (!response.IsSuccessStatusCode && // or we have another http error - IsRetriableError(response.StatusCode))) + response == null || // either we can't connect + (!response.IsSuccessStatusCode && // or we have another http error + IsRetriableError(response.StatusCode))) ) { retryInterval = TimeSpan.FromMilliseconds(Math.Min(retryInterval.TotalMilliseconds * 2, 1000)); @@ -534,7 +571,7 @@ public override async Task SendAsync(CancellationToken ct = default) try { response = await _client.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, - cts.Token); + cts.Token); } catch (HttpRequestException) { @@ -548,7 +585,7 @@ public override async Task SendAsync(CancellationToken ct = default) if (response == null) { throw new IngressError(ErrorCode.ServerFlushError, - $"Cannot connect to `{Options.Host}:{Options.Port}`"); + $"Cannot connect to `{Options.Host}:{Options.Port}`"); } // return if ok @@ -618,4 +655,4 @@ public override void Dispose() Buffer.Clear(); Buffer.TrimExcessBuffers(); } -} +} \ No newline at end of file diff --git a/src/net-questdb-client/Senders/ISender.cs b/src/net-questdb-client/Senders/ISender.cs index c7227a5..5735f13 100644 --- a/src/net-questdb-client/Senders/ISender.cs +++ b/src/net-questdb-client/Senders/ISender.cs @@ -22,12 +22,8 @@ * ******************************************************************************/ -// ReSharper disable CommentTypo - using QuestDB.Utils; -// ReSharper disable InconsistentNaming - namespace QuestDB.Senders; /// @@ -46,7 +42,7 @@ public interface ISender : IDisposable public int RowCount { get; } /// - /// Represents whether or not the Sender is in a transactional state. + /// Represents whether the Sender is in a transactional state. /// public bool WithinTransaction { get; } @@ -124,25 +120,55 @@ public interface ISender : IDisposable /// /// The name of the column /// The value for the column - /// Itself + /// + /// Adds a column (field) with the specified string value to the current row. + /// + /// The column name. + /// The column value as a character span. + /// The sender instance for fluent chaining. public ISender Column(ReadOnlySpan name, ReadOnlySpan value); - /// + /// + /// Adds a column with the specified name and 64-bit integer value to the current row. + /// + /// The column (field) name. + /// The 64-bit integer value for the column. + /// The current sender instance for method chaining. public ISender Column(ReadOnlySpan name, long value); /// public ISender Column(ReadOnlySpan name, int value); - /// + /// + /// Adds a boolean field column with the specified name and value to the current row. + /// + /// The column (field) name. + /// The boolean value to store in the column. + /// The same instance to allow fluent chaining. public ISender Column(ReadOnlySpan name, bool value); - /// + /// + /// Adds a double-precision field column to the current row. + /// + /// The column (field) name. + /// The column's double-precision value. + /// The same instance for fluent chaining. public ISender Column(ReadOnlySpan name, double value); - /// + /// + /// Adds a column (field) with the specified DateTime value to the current row. + /// + /// The column name. + /// The DateTime value to add. + /// The same instance for fluent chaining. public ISender Column(ReadOnlySpan name, DateTime value); - /// + /// + /// Adds a column with the specified name and DateTimeOffset value to the current row. + /// + /// The column name. + /// The DateTimeOffset value to store for the column (used as a timestamp value). + /// The sender instance for fluent chaining. public ISender Column(ReadOnlySpan name, DateTimeOffset value); /// @@ -211,38 +237,55 @@ public interface ISender : IDisposable /// /// Clears the sender's buffer. + /// + /// Clears the sender's internal buffer and resets buffer-related state, removing all pending rows. /// public void Clear(); - /// + /// + /// Adds a column to the current row using a sequence of value-type elements and an explicit multidimensional shape. + /// + /// The element value type stored in the column. + /// The column name. + /// A sequence of elements that form the column's data. + /// A sequence of integers describing the dimensions of the array representation; dimension lengths must match the number of elements in when multiplied together. + /// The same instance for fluent chaining. public ISender Column(ReadOnlySpan name, IEnumerable value, IEnumerable shape) where T : struct; /// - /// Adds an ARRAY to the current row. - /// Arrays are n-dimensional non-jagged arrays. + /// Adds a column whose value is provided as a native array; multidimensional (non-jagged) arrays are supported. /// - /// - /// - /// + /// The column name. + /// A native array containing the column data. Multidimensional arrays are treated as shaped data (do not pass jagged arrays). + /// The sender instance for fluent chaining. public ISender Column(ReadOnlySpan name, Array value); - /// + /// + /// Adds a column with the specified name and a sequence of value-type elements from a span to the current row. + /// + /// The column (field) name. + /// A contiguous sequence of value-type elements representing the column data. + /// The same instance to allow fluent chaining. public ISender Column(ReadOnlySpan name, ReadOnlySpan value) where T : struct; /// - /// Adds a column (field) to the current row. + /// Adds a column with the specified string value to the current row. /// - /// The name of the column - /// The value for the column - /// Itself + /// The column name. + /// The column's string value; may be null. + /// The same sender instance for fluent chaining. public ISender Column(ReadOnlySpan name, string? value) { return Column(name, value.AsSpan()); } - /// + /// + /// Adds a column whose value is a sequence of value-type elements with the given multidimensional shape when both and are provided; no action is taken if either is null. + /// + /// The column name. + /// The sequence of elements for the column, or null to skip adding the column. + /// The dimensions describing the array shape, or null to skip adding the column. + /// This sender instance for fluent chaining. public ISender NullableColumn(ReadOnlySpan name, IEnumerable? value, IEnumerable? shape) where T : struct { @@ -254,7 +297,12 @@ public ISender NullableColumn(ReadOnlySpan name, IEnumerable? value, return this; } - /// + /// + /// Adds a column using a native array value when the provided array is non-null. + /// + /// The column name. + /// The array to use as the column value; if null, no column is added. Multidimensional arrays are supported (non-jagged). + /// The same instance for fluent chaining. public ISender NullableColumn(ReadOnlySpan name, Array? value) { if (value != null) @@ -265,7 +313,12 @@ public ISender NullableColumn(ReadOnlySpan name, Array? value) return this; } - /// + /// + /// Adds a string column with the given name when the provided value is not null. + /// + /// The column name. + /// The string value to add; if null, no column is added. + /// The current sender instance for fluent chaining. public ISender NullableColumn(ReadOnlySpan name, string? value) { if (value != null) @@ -276,7 +329,12 @@ public ISender NullableColumn(ReadOnlySpan name, string? value) return this; } - /// + /// + /// Adds a long column with the specified name when the provided nullable value has a value; does nothing when the value is null. + /// + /// The column name. + /// The nullable long value to add as a column; if null the sender is unchanged. + /// The current sender instance for fluent chaining. public ISender NullableColumn(ReadOnlySpan name, long? value) { if (value != null) @@ -287,7 +345,12 @@ public ISender NullableColumn(ReadOnlySpan name, long? value) return this; } - /// + /// + /// Adds a boolean column with the given name when a value is provided; does nothing if the value is null. + /// + /// The column name. + /// The nullable boolean value to add as a column. + /// The current sender instance to allow fluent chaining. public ISender NullableColumn(ReadOnlySpan name, bool? value) { if (value != null) @@ -298,7 +361,12 @@ public ISender NullableColumn(ReadOnlySpan name, bool? value) return this; } - /// + /// + /// Adds a column with the given double value when the value is non-null; otherwise no column is added and the sender is unchanged. + /// + /// The column name. + /// The column value; if non-null, the value is written as a double field. + /// The sender instance after the operation. public ISender NullableColumn(ReadOnlySpan name, double? value) { if (value != null) @@ -309,7 +377,12 @@ public ISender NullableColumn(ReadOnlySpan name, double? value) return this; } - /// + /// + /// Adds a DateTime column with the specified name when a value is provided; no action is taken if the value is null. + /// + /// The column name. + /// The nullable DateTime value to add as a column. + /// The current instance for fluent chaining; unchanged if is null. public ISender NullableColumn(ReadOnlySpan name, DateTime? value) { if (value != null) @@ -320,7 +393,12 @@ public ISender NullableColumn(ReadOnlySpan name, DateTime? value) return this; } - /// + /// + /// Adds a column with the given name and DateTimeOffset value when a value is provided; does nothing if the value is null. + /// + /// The column name. + /// The DateTimeOffset value to add; if null the column is not added. + /// The same instance to allow fluent chaining. public ISender NullableColumn(ReadOnlySpan name, DateTimeOffset? value) { if (value != null) @@ -332,7 +410,10 @@ public ISender NullableColumn(ReadOnlySpan name, DateTimeOffset? value) } /// - /// Adds a DECIMAL column in the binary format. + /// Adds a decimal column in binary format to the current row. /// + /// The column name. + /// The decimal value to add; may be null to represent a NULL field. + /// The sender instance for fluent call chaining. public ISender Column(ReadOnlySpan name, decimal? value); } \ No newline at end of file