From 58c27c72010187bfa9cfaa9af64533671d8849b7 Mon Sep 17 00:00:00 2001
From: Nick Woolmer <29717167+nwoolmer@users.noreply.github.com>
Date: Fri, 14 Nov 2025 10:40:40 +0000
Subject: [PATCH 01/40] safety
---
src/net-questdb-client/Senders/HttpSender.cs | 88 +++++++++++++++++--
src/net-questdb-client/Utils/SenderOptions.cs | 49 ++++++++++-
2 files changed, 131 insertions(+), 6 deletions(-)
diff --git a/src/net-questdb-client/Senders/HttpSender.cs b/src/net-questdb-client/Senders/HttpSender.cs
index 12ac3ea..d4e369d 100644
--- a/src/net-questdb-client/Senders/HttpSender.cs
+++ b/src/net-questdb-client/Senders/HttpSender.cs
@@ -54,6 +54,11 @@ internal class HttpSender : AbstractSender
///
private SocketsHttpHandler _handler = null!;
+ ///
+ /// Manages round-robin address rotation for failover.
+ ///
+ private AddressProvider _addressProvider = null!;
+
private readonly Func _sendRequestFactory;
private readonly Func _settingRequestFactory;
@@ -91,6 +96,8 @@ public HttpSender(string confStr) : this(new SenderOptions(confStr))
///
private void Build()
{
+ _addressProvider = new AddressProvider(Options.addresses);
+
_handler = new SocketsHttpHandler
{
PooledConnectionIdleTimeout = Options.pool_timeout,
@@ -99,7 +106,7 @@ private void Build()
if (Options.protocol == ProtocolType.https)
{
- _handler.SslOptions.TargetHost = Options.Host;
+ _handler.SslOptions.TargetHost = _addressProvider.CurrentHost;
_handler.SslOptions.EnabledSslProtocols = SslProtocols.Tls12 | SslProtocols.Tls13;
if (Options.tls_verify == TlsVerifyType.unsafe_off)
@@ -145,7 +152,19 @@ private void Build()
_handler.PreAuthenticate = true;
_client = new HttpClient(_handler);
- var uri = new UriBuilder(Options.protocol.ToString(), Options.Host, Options.Port);
+
+ // Set initial address from AddressProvider
+ var port = _addressProvider.CurrentPort;
+ if (port <= 0)
+ {
+ port = Options.protocol switch
+ {
+ ProtocolType.http or ProtocolType.https => 9000,
+ ProtocolType.tcp or ProtocolType.tcps => 9009,
+ _ => 9000
+ };
+ }
+ var uri = new UriBuilder(Options.protocol.ToString(), _addressProvider.CurrentHost, port);
_client.BaseAddress = uri.Uri;
_client.Timeout = Timeout.InfiniteTimeSpan;
@@ -211,6 +230,48 @@ private void Build()
);
}
+ ///
+ /// Recreates the HttpClient with the current address from AddressProvider.
+ /// This is necessary when rotating to a different address during failover.
+ ///
+ private void RecreateHttpClient()
+ {
+ _client.Dispose();
+
+ _client = new HttpClient(_handler);
+
+ // Determine the port to use
+ var port = _addressProvider.CurrentPort;
+ if (port <= 0)
+ {
+ // Use protocol default if no port specified
+ port = Options.protocol switch
+ {
+ ProtocolType.http or ProtocolType.https => 9000,
+ ProtocolType.tcp or ProtocolType.tcps => 9009,
+ _ => 9000
+ };
+ }
+
+ var uri = new UriBuilder(Options.protocol.ToString(), _addressProvider.CurrentHost, port);
+ _client.BaseAddress = uri.Uri;
+ _client.Timeout = Timeout.InfiniteTimeSpan;
+
+ // Reapply authentication headers
+ if (!string.IsNullOrEmpty(Options.username) && !string.IsNullOrEmpty(Options.password))
+ {
+ _client.DefaultRequestHeaders.Authorization
+ = new AuthenticationHeaderValue("Basic",
+ Convert.ToBase64String(
+ Encoding.ASCII.GetBytes(
+ $"{Options.username}:{Options.password}")));
+ }
+ else if (!string.IsNullOrEmpty(Options.token))
+ {
+ _client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", Options.token);
+ }
+ }
+
///
/// Creates an HTTP GET request to the /settings endpoint for querying server capabilities.
///
@@ -394,6 +455,7 @@ public override void Send(CancellationToken ct = default)
///
/// Sends an HTTP request produced by and retries on transient connection or server errors until a successful response is received or elapses.
+ /// When multiple addresses are configured and a retriable error occurs, rotates to the next address and retries.
///
/// Cancellation token used to cancel the overall operation and linked to per-request timeouts.
/// Factory that produces a fresh for each attempt.
@@ -444,6 +506,13 @@ private HttpResponseMessage SendWithRetries(CancellationToken ct, Func
/// The HTTP status code to check.
- /// true if the error is transient and retriable (e.g., 500, 503, 504, 509, 523, 524, 529, 599); otherwise, false.
+ /// true if the error is transient and retriable (e.g., 404, 421, 500, 503, 504, 509, 523, 524, 529, 599); otherwise, false.
// ReSharper disable once IdentifierTypo
private static bool IsRetriableError(HttpStatusCode code)
{
switch (code)
{
+ case HttpStatusCode.NotFound: // 404 - Can happen when instance doesn't have write access
+ case (HttpStatusCode)421: // Misdirected Request - Can indicate wrong server/instance
case HttpStatusCode.InternalServerError:
case HttpStatusCode.ServiceUnavailable:
case HttpStatusCode.GatewayTimeout:
diff --git a/src/net-questdb-client/Utils/SenderOptions.cs b/src/net-questdb-client/Utils/SenderOptions.cs
index 38dcc88..5c92899 100644
--- a/src/net-questdb-client/Utils/SenderOptions.cs
+++ b/src/net-questdb-client/Utils/SenderOptions.cs
@@ -57,6 +57,7 @@ public record SenderOptions
};
private string _addr = "localhost:9000";
+ private List _addresses = new();
private TimeSpan _authTimeout = TimeSpan.FromMilliseconds(15000);
private AutoFlushType _autoFlush = AutoFlushType.on;
private int _autoFlushBytes = int.MaxValue;
@@ -101,6 +102,7 @@ public SenderOptions(string confStr)
ParseEnumWithDefault(nameof(protocol), "http", out _protocol);
ParseEnumWithDefault(nameof(protocol_version), "auto", out _protocol_version);
ParseStringWithDefault(nameof(addr), "localhost:9000", out _addr!);
+ ParseAddresses();
ParseEnumWithDefault(nameof(auto_flush), "on", out _autoFlush);
ParseIntThatMayBeOff(nameof(auto_flush_rows), IsHttp() ? "75000" : "600", out _autoFlushRows);
ParseIntThatMayBeOff(nameof(auto_flush_bytes), int.MaxValue.ToString(), out _autoFlushBytes);
@@ -152,6 +154,7 @@ public ProtocolVersion protocol_version
///
///
/// Used to populate the and fields.
+ /// When multiple addresses are configured, this returns the first one.
///
public string addr
{
@@ -159,6 +162,22 @@ public string addr
set => _addr = value;
}
+ ///
+ /// List of all configured addresses for failover.
+ ///
+ ///
+ /// Contains all addresses specified via multiple `addr` entries in the configuration string.
+ /// The list is never empty; it contains at least the primary address.
+ ///
+ [JsonIgnore]
+ public IReadOnlyList addresses => _addresses.AsReadOnly();
+
+ ///
+ /// Gets the number of configured addresses.
+ ///
+ [JsonIgnore]
+ public int AddressCount => _addresses.Count;
+
///
/// Enables or disables automatic flushing of rows.
/// Defaults to .
@@ -554,10 +573,29 @@ private void ReadConfigStringIntoBuilder(string confStr)
}
var splits = confStr.Split("::");
+ var paramString = splits[1];
+
+ // Parse addresses manually before using DbConnectionStringBuilder
+ // because DbConnectionStringBuilder only keeps the last value for duplicate keys
+ _addresses.Clear();
+ foreach (var param in paramString.Split(';'))
+ {
+ if (string.IsNullOrWhiteSpace(param)) continue;
+
+ var kvp = param.Split('=');
+ if (kvp.Length == 2 && kvp[0].Trim() == "addr")
+ {
+ var addrValue = kvp[1].Trim();
+ if (!string.IsNullOrEmpty(addrValue))
+ {
+ _addresses.Add(addrValue);
+ }
+ }
+ }
_connectionStringBuilder = new DbConnectionStringBuilder
{
- ConnectionString = splits[1],
+ ConnectionString = paramString,
};
VerifyCorrectKeysInConfigString();
@@ -649,6 +687,15 @@ private void VerifyCorrectKeysInConfigString()
}
}
+ private void ParseAddresses()
+ {
+ // If no addresses were parsed from config string, use the primary addr
+ if (_addresses.Count == 0)
+ {
+ _addresses.Add(_addr);
+ }
+ }
+
///
/// Construct a new from the current options.
///
From 97890459e126a841bc9cb939666ff976d5f84cb7 Mon Sep 17 00:00:00 2001
From: Nick Woolmer <29717167+nwoolmer@users.noreply.github.com>
Date: Fri, 14 Nov 2025 10:40:46 +0000
Subject: [PATCH 02/40] safety
---
.../MultiUrlHttpTests.cs | 322 ++++++++++++++++++
.../Utils/AddressProvider.cs | 122 +++++++
2 files changed, 444 insertions(+)
create mode 100644 src/net-questdb-client-tests/MultiUrlHttpTests.cs
create mode 100644 src/net-questdb-client/Utils/AddressProvider.cs
diff --git a/src/net-questdb-client-tests/MultiUrlHttpTests.cs b/src/net-questdb-client-tests/MultiUrlHttpTests.cs
new file mode 100644
index 0000000..eb2c72b
--- /dev/null
+++ b/src/net-questdb-client-tests/MultiUrlHttpTests.cs
@@ -0,0 +1,322 @@
+/*******************************************************************************
+ * ___ _ ____ ____
+ * / _ \ _ _ ___ ___| |_| _ \| __ )
+ * | | | | | | |/ _ \/ __| __| | | | _ \
+ * | |_| | |_| | __/\__ \ |_| |_| | |_) |
+ * \__\_\\__,_|\___||___/\__|____/|____/
+ *
+ * Copyright (c) 2014-2019 Appsicle
+ * Copyright (c) 2019-2024 QuestDB
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ *
+ ******************************************************************************/
+
+using System.Net;
+using dummy_http_server;
+using NUnit.Framework;
+using QuestDB;
+using QuestDB.Utils;
+
+namespace net_questdb_client_tests;
+
+///
+/// Tests for multi-URL support in the HTTP sender with address rotation and failover.
+///
+public class MultiUrlHttpTests
+{
+ private const string Host = "localhost";
+ private const int HttpPort1 = 29475;
+ private const int HttpPort2 = 29476;
+ private const int HttpPort3 = 29477;
+
+ [Test]
+ public void ParseMultipleAddresses_FromConfigString()
+ {
+ // Test parsing multiple addresses from config string
+ var options = new SenderOptions("http::addr=localhost:9000;addr=localhost:9001;addr=localhost:9002;auto_flush=off;");
+
+ Assert.That(options.AddressCount, Is.EqualTo(3));
+ Assert.That(options.addresses[0], Is.EqualTo("localhost:9000"));
+ Assert.That(options.addresses[1], Is.EqualTo("localhost:9001"));
+ Assert.That(options.addresses[2], Is.EqualTo("localhost:9002"));
+ }
+
+ [Test]
+ public void ParseMultipleAddresses_DefaultsToSingleAddress()
+ {
+ // Test that single address is handled correctly
+ var options = new SenderOptions("http::addr=localhost:9000;auto_flush=off;");
+
+ Assert.That(options.AddressCount, Is.EqualTo(1));
+ Assert.That(options.addresses[0], Is.EqualTo("localhost:9000"));
+ }
+
+ [Test]
+ public void ParseMultipleAddresses_NoAddrSpecified()
+ {
+ // Test that default address is used when none specified
+ var options = new SenderOptions("http::auto_flush=off;");
+
+ Assert.That(options.AddressCount, Is.GreaterThan(0));
+ Assert.That(options.addresses[0], Is.EqualTo("localhost:9000"));
+ }
+
+ [Test]
+ public async Task MultipleAddresses_SendToFirstAddress()
+ {
+ // Test sending to first address when it's available
+ using var server1 = new DummyHttpServer(withBasicAuth: false);
+ using var server2 = new DummyHttpServer(withBasicAuth: false);
+
+ await server1.StartAsync(HttpPort1);
+ await server2.StartAsync(HttpPort2);
+
+ var configString = $"http::addr={Host}:{HttpPort1};addr={Host}:{HttpPort2};auto_flush=off;tls_verify=unsafe_off;";
+ using var sender = Sender.New(configString);
+
+ await sender.Table("metrics")
+ .Symbol("tag", "value")
+ .Column("number", 10)
+ .AtAsync(new DateTime(1970, 01, 01, 0, 0, 1));
+
+ await sender.SendAsync();
+
+ // First server should have received the data
+ Assert.That(server1.PrintBuffer(), Contains.Substring("metrics,tag=value number=10i"));
+ // Second server should not have received anything
+ Assert.That(server2.PrintBuffer(), Is.Empty);
+
+ await server1.StopAsync();
+ await server2.StopAsync();
+ }
+
+ [Test]
+ public async Task MultipleAddresses_FailoverOnRetriableError()
+ {
+ // Test failover to second address when first returns a retriable error
+ using var server1 = new DummyHttpServer(withBasicAuth: false, withRetriableError: true);
+ using var server2 = new DummyHttpServer(withBasicAuth: false);
+
+ await server1.StartAsync(HttpPort1);
+ await server2.StartAsync(HttpPort2);
+
+ var configString = $"http::addr={Host}:{HttpPort1};addr={Host}:{HttpPort2};auto_flush=off;tls_verify=unsafe_off;retry_timeout=5000;";
+ using var sender = Sender.New(configString);
+
+ await sender.Table("metrics")
+ .Symbol("tag", "value")
+ .Column("number", 10)
+ .AtAsync(new DateTime(1970, 01, 01, 0, 0, 1));
+
+ await sender.SendAsync();
+
+ // Second server should have received the data after failover
+ Assert.That(server2.PrintBuffer(), Contains.Substring("metrics,tag=value number=10i"));
+
+ await server1.StopAsync();
+ await server2.StopAsync();
+ }
+
+ [Test]
+ public async Task MultipleAddresses_RoundRobinRotation()
+ {
+ // Test round-robin rotation across multiple addresses
+ using var server1 = new DummyHttpServer(withBasicAuth: false);
+ using var server2 = new DummyHttpServer(withBasicAuth: false);
+ using var server3 = new DummyHttpServer(withBasicAuth: false);
+
+ await server1.StartAsync(HttpPort1);
+ await server2.StartAsync(HttpPort2);
+ await server3.StartAsync(HttpPort3);
+
+ var configString = $"http::addr={Host}:{HttpPort1};addr={Host}:{HttpPort2};addr={Host}:{HttpPort3};auto_flush=off;tls_verify=unsafe_off;retry_timeout=5000;";
+
+ // First request succeeds on server 1
+ using var sender1 = Sender.New(configString);
+ await sender1.Table("test1").Column("val", 1).AtAsync(new DateTime(1970, 01, 01, 0, 0, 1));
+ await sender1.SendAsync();
+
+ // All three servers can receive, so data goes to first available (server 1)
+ Assert.That(server1.PrintBuffer(), Contains.Substring("test1"));
+ Assert.That(server2.PrintBuffer(), Is.Empty);
+ Assert.That(server3.PrintBuffer(), Is.Empty);
+
+ await server1.StopAsync();
+ await server2.StopAsync();
+ await server3.StopAsync();
+ }
+
+ [Test]
+ public async Task MultipleAddresses_AllServersUnavailable()
+ {
+ // Test error when all addresses are unavailable
+ var configString = $"http::addr=localhost:29999;addr=localhost:29998;auto_flush=off;tls_verify=unsafe_off;retry_timeout=1000;";
+ using var sender = Sender.New(configString);
+
+ await sender.Table("metrics")
+ .Symbol("tag", "value")
+ .Column("number", 10)
+ .AtAsync(new DateTime(1970, 01, 01, 0, 0, 1));
+
+ // Should throw an error since all servers are unavailable
+ var ex = Assert.ThrowsAsync(async () => await sender.SendAsync());
+ Assert.That(ex?.Message, Does.Contain("Cannot connect"));
+ }
+
+ [Test]
+ public async Task MultipleAddresses_SequentialAddresses()
+ {
+ // Test that we can send data across multiple available addresses
+ using var server1 = new DummyHttpServer(withBasicAuth: false);
+ using var server2 = new DummyHttpServer(withBasicAuth: false);
+
+ await server1.StartAsync(HttpPort1);
+ await server2.StartAsync(HttpPort2);
+
+ // Create senders with different primary addresses
+ var configString1 = $"http::addr={Host}:{HttpPort1};addr={Host}:{HttpPort2};auto_flush=off;tls_verify=unsafe_off;";
+ var configString2 = $"http::addr={Host}:{HttpPort2};addr={Host}:{HttpPort1};auto_flush=off;tls_verify=unsafe_off;";
+
+ using var sender1 = Sender.New(configString1);
+ await sender1.Table("metrics1").Column("number", 30).AtAsync(new DateTime(1970, 01, 01, 0, 0, 1));
+ await sender1.SendAsync();
+
+ using var sender2 = Sender.New(configString2);
+ await sender2.Table("metrics2").Column("number", 40).AtAsync(new DateTime(1970, 01, 01, 0, 0, 1));
+ await sender2.SendAsync();
+
+ // Both servers should have received data
+ Assert.That(server1.PrintBuffer(), Contains.Substring("metrics1"));
+ Assert.That(server2.PrintBuffer(), Contains.Substring("metrics2"));
+
+ await server1.StopAsync();
+ await server2.StopAsync();
+ }
+
+ [Test]
+ public async Task MultipleAddresses_SyncSend()
+ {
+ // Test synchronous send with multiple addresses
+ using var server1 = new DummyHttpServer(withBasicAuth: false);
+ using var server2 = new DummyHttpServer(withBasicAuth: false);
+
+ await server1.StartAsync(HttpPort1);
+ await server2.StartAsync(HttpPort2);
+
+ var configString = $"http::addr={Host}:{HttpPort1};addr={Host}:{HttpPort2};auto_flush=off;tls_verify=unsafe_off;";
+ using var sender = Sender.New(configString);
+
+ sender.Table("metrics")
+ .Symbol("tag", "sync_test")
+ .Column("number", 42)
+ .At(new DateTime(1970, 01, 01, 0, 0, 1));
+
+ sender.Send();
+
+ // First server should have received the data
+ Assert.That(server1.PrintBuffer(), Contains.Substring("metrics,tag=sync_test number=42i"));
+
+ await server1.StopAsync();
+ await server2.StopAsync();
+ }
+
+ [Test]
+ public async Task MultipleAddresses_SuccessfulFirstAttempt()
+ {
+ // Test that no rotation occurs when first address succeeds
+ using var server1 = new DummyHttpServer(withBasicAuth: false);
+ using var server2 = new DummyHttpServer(withBasicAuth: false);
+
+ await server1.StartAsync(HttpPort1);
+ await server2.StartAsync(HttpPort2);
+
+ var configString = $"http::addr={Host}:{HttpPort1};addr={Host}:{HttpPort2};auto_flush=off;tls_verify=unsafe_off;";
+ using var sender = Sender.New(configString);
+
+ await sender.Table("metrics")
+ .Symbol("tag", "success")
+ .Column("number", 100)
+ .AtAsync(new DateTime(1970, 01, 01, 0, 0, 1));
+
+ await sender.SendAsync();
+
+ // Only first server should have received the data
+ Assert.That(server1.PrintBuffer(), Contains.Substring("metrics,tag=success number=100i"));
+ Assert.That(server2.PrintBuffer(), Is.Empty);
+
+ await server1.StopAsync();
+ await server2.StopAsync();
+ }
+
+ [Test]
+ public void AddressProvider_RoundRobinRotation()
+ {
+ // Test AddressProvider round-robin rotation logic
+ var addresses = new[] { "host1:9000", "host2:9001", "host3:9002" };
+ var provider = new AddressProvider(addresses);
+
+ Assert.That(provider.CurrentAddress, Is.EqualTo("host1:9000"));
+ Assert.That(provider.CurrentHost, Is.EqualTo("host1"));
+ Assert.That(provider.CurrentPort, Is.EqualTo(9000));
+ Assert.That(provider.AddressCount, Is.EqualTo(3));
+ Assert.That(provider.HasMultipleAddresses, Is.True);
+
+ // Rotate to next
+ provider.RotateToNextAddress();
+ Assert.That(provider.CurrentAddress, Is.EqualTo("host2:9001"));
+ Assert.That(provider.CurrentHost, Is.EqualTo("host2"));
+ Assert.That(provider.CurrentPort, Is.EqualTo(9001));
+
+ // Rotate to next
+ provider.RotateToNextAddress();
+ Assert.That(provider.CurrentAddress, Is.EqualTo("host3:9002"));
+
+ // Rotate back to first (round-robin)
+ provider.RotateToNextAddress();
+ Assert.That(provider.CurrentAddress, Is.EqualTo("host1:9000"));
+ }
+
+ [Test]
+ public void AddressProvider_ParseHostAndPort()
+ {
+ // Test host and port parsing with various formats
+ var provider1 = new AddressProvider(new[] { "192.168.1.1:9000" });
+ Assert.That(provider1.CurrentHost, Is.EqualTo("192.168.1.1"));
+ Assert.That(provider1.CurrentPort, Is.EqualTo(9000));
+
+ var provider2 = new AddressProvider(new[] { "example.com:8080" });
+ Assert.That(provider2.CurrentHost, Is.EqualTo("example.com"));
+ Assert.That(provider2.CurrentPort, Is.EqualTo(8080));
+
+ // IPv6 addresses with port (format: [ipv6]:port)
+ var provider3 = new AddressProvider(new[] { "[::1]:9000" });
+ Assert.That(provider3.CurrentHost, Is.EqualTo("[::1]"));
+ Assert.That(provider3.CurrentPort, Is.EqualTo(9000));
+ }
+
+ [Test]
+ public void AddressProvider_SingleAddress()
+ {
+ // Test AddressProvider with single address
+ var provider = new AddressProvider(new[] { "localhost:9000" });
+
+ Assert.That(provider.CurrentAddress, Is.EqualTo("localhost:9000"));
+ Assert.That(provider.AddressCount, Is.EqualTo(1));
+ Assert.That(provider.HasMultipleAddresses, Is.False);
+
+ // Rotating with single address should return same address
+ provider.RotateToNextAddress();
+ Assert.That(provider.CurrentAddress, Is.EqualTo("localhost:9000"));
+ }
+}
diff --git a/src/net-questdb-client/Utils/AddressProvider.cs b/src/net-questdb-client/Utils/AddressProvider.cs
new file mode 100644
index 0000000..a428b89
--- /dev/null
+++ b/src/net-questdb-client/Utils/AddressProvider.cs
@@ -0,0 +1,122 @@
+/*******************************************************************************
+ * ___ _ ____ ____
+ * / _ \ _ _ ___ ___| |_| _ \| __ )
+ * | | | | | | |/ _ \/ __| __| | | | _ \
+ * | |_| | |_| | __/\__ \ |_| |_| | |_) |
+ * \__\_\\__,_|\___||___/\__|____/|____/
+ *
+ * Copyright (c) 2014-2019 Appsicle
+ * Copyright (c) 2019-2024 QuestDB
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ *
+ ******************************************************************************/
+
+namespace QuestDB.Utils;
+
+///
+/// Manages round-robin rotation through a list of addresses for failover support.
+///
+public class AddressProvider
+{
+ private readonly List _addresses;
+ private int _currentIndex;
+
+ ///
+ /// Creates a new AddressProvider with the given list of addresses.
+ ///
+ /// List of addresses to rotate through
+ public AddressProvider(IReadOnlyList addresses)
+ {
+ if (addresses.Count == 0)
+ {
+ throw new ArgumentException("At least one address must be provided", nameof(addresses));
+ }
+
+ _addresses = new List(addresses);
+ _currentIndex = 0;
+ }
+
+ ///
+ /// Gets the current address without changing the index.
+ ///
+ public string CurrentAddress => _addresses[_currentIndex];
+
+ ///
+ /// Gets the host from the current address.
+ ///
+ public string CurrentHost => ParseHost(_addresses[_currentIndex]);
+
+ ///
+ /// Gets the port from the current address.
+ ///
+ public int CurrentPort => ParsePort(_addresses[_currentIndex]);
+
+ ///
+ /// Gets the number of addresses.
+ ///
+ public int AddressCount => _addresses.Count;
+
+ ///
+ /// Checks if there are multiple addresses available.
+ ///
+ public bool HasMultipleAddresses => _addresses.Count > 1;
+
+ ///
+ /// Rotates to the next address in round-robin fashion.
+ ///
+ /// The next address
+ public string RotateToNextAddress()
+ {
+ _currentIndex = (_currentIndex + 1) % _addresses.Count;
+ return CurrentAddress;
+ }
+
+ ///
+ /// Parses the host from an address string (host:port format).
+ ///
+ private static string ParseHost(string address)
+ {
+ if (string.IsNullOrEmpty(address))
+ return address;
+
+ var index = address.LastIndexOf(':');
+ if (index > 0)
+ {
+ return address.Substring(0, index);
+ }
+
+ return address;
+ }
+
+ ///
+ /// Parses the port from an address string (host:port format).
+ /// Returns -1 if no port is specified.
+ ///
+ private static int ParsePort(string address)
+ {
+ if (string.IsNullOrEmpty(address))
+ return -1;
+
+ var index = address.LastIndexOf(':');
+ if (index >= 0 && index < address.Length - 1)
+ {
+ if (int.TryParse(address.Substring(index + 1), out var port))
+ {
+ return port;
+ }
+ }
+
+ return -1;
+ }
+}
From baa3beb28f6d156cde7d7c91c4e3559e8602aede Mon Sep 17 00:00:00 2001
From: Nick Woolmer <29717167+nwoolmer@users.noreply.github.com>
Date: Mon, 1 Dec 2025 16:30:59 +0000
Subject: [PATCH 03/40] iterate
---
src/net-questdb-client/Senders/HttpSender.cs | 124 +++++++++++-------
.../Utils/AddressProvider.cs | 4 +-
src/net-questdb-client/Utils/SenderOptions.cs | 1 +
3 files changed, 81 insertions(+), 48 deletions(-)
diff --git a/src/net-questdb-client/Senders/HttpSender.cs b/src/net-questdb-client/Senders/HttpSender.cs
index d4e369d..7115225 100644
--- a/src/net-questdb-client/Senders/HttpSender.cs
+++ b/src/net-questdb-client/Senders/HttpSender.cs
@@ -45,7 +45,13 @@ namespace QuestDB.Senders;
internal class HttpSender : AbstractSender
{
///
- /// Instance-specific for sending data to QuestDB.
+ /// Cache of instances, one per address for multi-URL support.
+ /// Avoids recreating clients on each rotation.
+ ///
+ private readonly Dictionary _clientCache = new();
+
+ ///
+ /// Current reference from the cache.
///
private HttpClient _client = null!;
@@ -151,35 +157,8 @@ private void Build()
_handler.ConnectTimeout = Options.auth_timeout;
_handler.PreAuthenticate = true;
- _client = new HttpClient(_handler);
-
- // Set initial address from AddressProvider
- var port = _addressProvider.CurrentPort;
- if (port <= 0)
- {
- port = Options.protocol switch
- {
- ProtocolType.http or ProtocolType.https => 9000,
- ProtocolType.tcp or ProtocolType.tcps => 9009,
- _ => 9000
- };
- }
- var uri = new UriBuilder(Options.protocol.ToString(), _addressProvider.CurrentHost, port);
- _client.BaseAddress = uri.Uri;
- _client.Timeout = Timeout.InfiniteTimeSpan;
-
- if (!string.IsNullOrEmpty(Options.username) && !string.IsNullOrEmpty(Options.password))
- {
- _client.DefaultRequestHeaders.Authorization
- = new AuthenticationHeaderValue("Basic",
- Convert.ToBase64String(
- Encoding.ASCII.GetBytes(
- $"{Options.username}:{Options.password}")));
- }
- else if (!string.IsNullOrEmpty(Options.token))
- {
- _client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", Options.token);
- }
+ // Create and cache the initial client
+ UseClientForCurrentAddress();
var protocolVersion = Options.protocol_version;
@@ -231,17 +210,16 @@ private void Build()
}
///
- /// Recreates the HttpClient with the current address from AddressProvider.
- /// This is necessary when rotating to a different address during failover.
+ /// Creates a new HttpClient for the specified address with proper configuration.
///
- private void RecreateHttpClient()
+ /// The address to create a client for.
+ /// A configured HttpClient for the given address.
+ private HttpClient CreateClientForAddress(string address)
{
- _client.Dispose();
-
- _client = new HttpClient(_handler);
+ var client = new HttpClient(_handler);
// Determine the port to use
- var port = _addressProvider.CurrentPort;
+ var port = AddressProvider.ParsePort(address);
if (port <= 0)
{
// Use protocol default if no port specified
@@ -253,14 +231,15 @@ private void RecreateHttpClient()
};
}
- var uri = new UriBuilder(Options.protocol.ToString(), _addressProvider.CurrentHost, port);
- _client.BaseAddress = uri.Uri;
- _client.Timeout = Timeout.InfiniteTimeSpan;
+ var host = AddressProvider.ParseHost(address);
+ var uri = new UriBuilder(Options.protocol.ToString(), host, port);
+ client.BaseAddress = uri.Uri;
+ client.Timeout = Timeout.InfiniteTimeSpan;
- // Reapply authentication headers
+ // Apply authentication headers
if (!string.IsNullOrEmpty(Options.username) && !string.IsNullOrEmpty(Options.password))
{
- _client.DefaultRequestHeaders.Authorization
+ client.DefaultRequestHeaders.Authorization
= new AuthenticationHeaderValue("Basic",
Convert.ToBase64String(
Encoding.ASCII.GetBytes(
@@ -268,7 +247,52 @@ private void RecreateHttpClient()
}
else if (!string.IsNullOrEmpty(Options.token))
{
- _client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", Options.token);
+ client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", Options.token);
+ }
+
+ return client;
+ }
+
+ ///
+ /// Gets or creates an HttpClient for the current address, caching it to avoid recreation on subsequent rotations.
+ ///
+ private void UseClientForCurrentAddress()
+ {
+ var address = _addressProvider.CurrentAddress;
+
+ if (!_clientCache.TryGetValue(address, out var client))
+ {
+ // Create and cache a new client for this address
+ client = CreateClientForAddress(address);
+ _clientCache[address] = client;
+ }
+
+ _client = client;
+ }
+
+ ///
+ /// Cleans up all cached HttpClient instances except the one for the current address.
+ /// Called when a successful response is received to avoid holding unnecessary resources.
+ ///
+ private void CleanupUnusedClients()
+ {
+ if (!_addressProvider.HasMultipleAddresses)
+ {
+ return;
+ }
+
+ var currentAddress = _addressProvider.CurrentAddress;
+ var addressesToRemove = _clientCache.Keys
+ .Where(address => address != currentAddress)
+ .ToList();
+
+ foreach (var address in addressesToRemove)
+ {
+ if (_clientCache.TryGetValue(address, out var client))
+ {
+ client.Dispose();
+ _clientCache.Remove(address);
+ }
}
}
@@ -421,6 +445,7 @@ public override void Send(CancellationToken ct = default)
if (response.IsSuccessStatusCode)
{
LastFlush = (response.Headers.Date ?? DateTime.UtcNow).UtcDateTime;
+ CleanupUnusedClients();
success = true;
return;
}
@@ -510,7 +535,7 @@ private HttpResponseMessage SendWithRetries(CancellationToken ct, Func
public override void Dispose()
{
- _client.Dispose();
+ // Dispose all cached clients
+ foreach (var client in _clientCache.Values)
+ {
+ client.Dispose();
+ }
+ _clientCache.Clear();
+
_handler.Dispose();
Buffer.Clear();
Buffer.TrimExcessBuffers();
diff --git a/src/net-questdb-client/Utils/AddressProvider.cs b/src/net-questdb-client/Utils/AddressProvider.cs
index a428b89..3f7e2d8 100644
--- a/src/net-questdb-client/Utils/AddressProvider.cs
+++ b/src/net-questdb-client/Utils/AddressProvider.cs
@@ -85,7 +85,7 @@ public string RotateToNextAddress()
///
/// Parses the host from an address string (host:port format).
///
- private static string ParseHost(string address)
+ public static string ParseHost(string address)
{
if (string.IsNullOrEmpty(address))
return address;
@@ -103,7 +103,7 @@ private static string ParseHost(string address)
/// Parses the port from an address string (host:port format).
/// Returns -1 if no port is specified.
///
- private static int ParsePort(string address)
+ public static int ParsePort(string address)
{
if (string.IsNullOrEmpty(address))
return -1;
diff --git a/src/net-questdb-client/Utils/SenderOptions.cs b/src/net-questdb-client/Utils/SenderOptions.cs
index 5c92899..1483cb7 100644
--- a/src/net-questdb-client/Utils/SenderOptions.cs
+++ b/src/net-questdb-client/Utils/SenderOptions.cs
@@ -90,6 +90,7 @@ public record SenderOptions
///
public SenderOptions()
{
+ ParseAddresses();
}
///
From 3e9a8fe80d09ea606a6ed53d715db32e5e88610a Mon Sep 17 00:00:00 2001
From: Nick Woolmer <29717167+nwoolmer@users.noreply.github.com>
Date: Mon, 1 Dec 2025 18:37:01 +0000
Subject: [PATCH 04/40] iterate
---
src/net-questdb-client/Senders/HttpSender.cs | 70 ++++++++++++-------
.../Utils/AddressProvider.cs | 3 +-
2 files changed, 44 insertions(+), 29 deletions(-)
diff --git a/src/net-questdb-client/Senders/HttpSender.cs b/src/net-questdb-client/Senders/HttpSender.cs
index 7115225..8a1de21 100644
--- a/src/net-questdb-client/Senders/HttpSender.cs
+++ b/src/net-questdb-client/Senders/HttpSender.cs
@@ -158,7 +158,7 @@ private void Build()
_handler.PreAuthenticate = true;
// Create and cache the initial client
- UseClientForCurrentAddress();
+ _client = GetClientForCurrentAddress();
var protocolVersion = Options.protocol_version;
@@ -166,38 +166,45 @@ private void Build()
{
// We need to select the last version that both client and server support.
// Other clients use 1 second timeout for "/settings", follow same practice here.
- using var response = SendWithRetries(default, _settingRequestFactory, TimeSpan.FromSeconds(1));
- if (!response.IsSuccessStatusCode)
+ try
{
- if (response.StatusCode == HttpStatusCode.NotFound)
+ using var response = SendWithRetries(default, _settingRequestFactory, TimeSpan.FromSeconds(1));
+ if (!response.IsSuccessStatusCode)
{
- protocolVersion = ProtocolVersion.V1;
+ if (response.StatusCode == HttpStatusCode.NotFound)
+ {
+ protocolVersion = ProtocolVersion.V1;
+ }
+ else
+ {
+ protocolVersion = ProtocolVersion.V3;
+ }
}
- else
+
+ if (protocolVersion == ProtocolVersion.Auto)
{
- _client.Dispose();
- // Throw exception.
- response.EnsureSuccessStatusCode();
+ try
+ {
+ var json = response.Content.ReadFromJsonAsync().Result!;
+ var versions = json.Config?.LineProtoSupportVersions!;
+ protocolVersion = (ProtocolVersion)versions.Where(v => v <= (int)ProtocolVersion.V3).Max();
+ }
+ catch
+ {
+ protocolVersion = ProtocolVersion.V3;
+ }
}
}
-
- if (protocolVersion == ProtocolVersion.Auto)
+ catch
{
- try
- {
- var json = response.Content.ReadFromJsonAsync().Result!;
- var versions = json.Config?.LineProtoSupportVersions!;
- protocolVersion = (ProtocolVersion)versions.Where(v => v <= (int)ProtocolVersion.V3).Max();
- }
- catch
- {
- protocolVersion = ProtocolVersion.V1;
- }
+ // If /settings probing fails (connection error, timeout, etc.),
+ // default to V3 and allow actual sends to attempt connection.
+ protocolVersion = ProtocolVersion.V3;
}
if (protocolVersion == ProtocolVersion.Auto)
{
- protocolVersion = ProtocolVersion.V1;
+ protocolVersion = ProtocolVersion.V3;
}
}
@@ -236,6 +243,12 @@ private HttpClient CreateClientForAddress(string address)
client.BaseAddress = uri.Uri;
client.Timeout = Timeout.InfiniteTimeSpan;
+ // Update handler's TLS target host if using HTTPS and host changed
+ if (Options.protocol == ProtocolType.https && _handler.SslOptions.TargetHost != host)
+ {
+ _handler.SslOptions.TargetHost = host;
+ }
+
// Apply authentication headers
if (!string.IsNullOrEmpty(Options.username) && !string.IsNullOrEmpty(Options.password))
{
@@ -256,7 +269,7 @@ private HttpClient CreateClientForAddress(string address)
///
/// Gets or creates an HttpClient for the current address, caching it to avoid recreation on subsequent rotations.
///
- private void UseClientForCurrentAddress()
+ private HttpClient GetClientForCurrentAddress()
{
var address = _addressProvider.CurrentAddress;
@@ -268,6 +281,7 @@ private void UseClientForCurrentAddress()
}
_client = client;
+ return client;
}
///
@@ -535,7 +549,6 @@ private HttpResponseMessage SendWithRetries(CancellationToken ct, Func addresses)
/// Rotates to the next address in round-robin fashion.
///
/// The next address
- public string RotateToNextAddress()
+ public void RotateToNextAddress()
{
_currentIndex = (_currentIndex + 1) % _addresses.Count;
- return CurrentAddress;
}
///
From 86ff7751f3e2a7e1da028ae50fd56ffc351d38d0 Mon Sep 17 00:00:00 2001
From: Nick Woolmer <29717167+nwoolmer@users.noreply.github.com>
Date: Mon, 1 Dec 2025 19:05:20 +0000
Subject: [PATCH 05/40] iterate
---
src/dummy-http-server/DummyHttpServer.cs | 5 ++---
src/net-questdb-client/Senders/HttpSender.cs | 9 +++++++++
src/net-questdb-client/Utils/AddressProvider.cs | 10 ++++++++++
3 files changed, 21 insertions(+), 3 deletions(-)
diff --git a/src/dummy-http-server/DummyHttpServer.cs b/src/dummy-http-server/DummyHttpServer.cs
index 91a21bc..982c5a4 100644
--- a/src/dummy-http-server/DummyHttpServer.cs
+++ b/src/dummy-http-server/DummyHttpServer.cs
@@ -91,9 +91,8 @@ public DummyHttpServer(bool withTokenAuth = false, bool withBasicAuth = false, b
}
o.Limits.MaxRequestBodySize = 1073741824;
- o.ListenLocalhost(29474,
- options => { options.UseHttps(); });
- o.ListenLocalhost(29473);
+ // Note: These internal ports will be set dynamically in StartAsync based on the main port
+ // to avoid conflicts when multiple DummyHttpServer instances are created
});
_app = bld.Build();
diff --git a/src/net-questdb-client/Senders/HttpSender.cs b/src/net-questdb-client/Senders/HttpSender.cs
index 8a1de21..9e14668 100644
--- a/src/net-questdb-client/Senders/HttpSender.cs
+++ b/src/net-questdb-client/Senders/HttpSender.cs
@@ -166,6 +166,8 @@ private void Build()
{
// We need to select the last version that both client and server support.
// Other clients use 1 second timeout for "/settings", follow same practice here.
+ // Save the current address index to restore after probing (SendWithRetries may rotate)
+ var initialAddressIndex = _addressProvider.CurrentIndex;
try
{
using var response = SendWithRetries(default, _settingRequestFactory, TimeSpan.FromSeconds(1));
@@ -201,6 +203,13 @@ private void Build()
// default to V3 and allow actual sends to attempt connection.
protocolVersion = ProtocolVersion.V3;
}
+ finally
+ {
+ // Restore the address index to avoid probe rotating the address
+ _addressProvider.CurrentIndex = initialAddressIndex;
+ // Update the client reference to match the restored address
+ _client = GetClientForCurrentAddress();
+ }
if (protocolVersion == ProtocolVersion.Auto)
{
diff --git a/src/net-questdb-client/Utils/AddressProvider.cs b/src/net-questdb-client/Utils/AddressProvider.cs
index a64d915..52214db 100644
--- a/src/net-questdb-client/Utils/AddressProvider.cs
+++ b/src/net-questdb-client/Utils/AddressProvider.cs
@@ -62,6 +62,16 @@ public AddressProvider(IReadOnlyList addresses)
///
public int CurrentPort => ParsePort(_addresses[_currentIndex]);
+ ///
+ /// Gets or sets the current address index.
+ /// Used internally to save/restore state during operations like /settings probing.
+ ///
+ internal int CurrentIndex
+ {
+ get => _currentIndex;
+ set => _currentIndex = value;
+ }
+
///
/// Gets the number of addresses.
///
From f52e685aa30185649e70d867eca09153209beb01 Mon Sep 17 00:00:00 2001
From: Nick Woolmer <29717167+nwoolmer@users.noreply.github.com>
Date: Mon, 1 Dec 2025 19:42:13 +0000
Subject: [PATCH 06/40] test fixes
---
src/dummy-http-server/DummyHttpServer.cs | 42 +++++--
src/dummy-http-server/IlpEndpoint.cs | 142 +++++++++++++++++++++--
2 files changed, 165 insertions(+), 19 deletions(-)
diff --git a/src/dummy-http-server/DummyHttpServer.cs b/src/dummy-http-server/DummyHttpServer.cs
index 982c5a4..45df75c 100644
--- a/src/dummy-http-server/DummyHttpServer.cs
+++ b/src/dummy-http-server/DummyHttpServer.cs
@@ -41,6 +41,10 @@ public class DummyHttpServer : IDisposable
private readonly WebApplication _app;
private int _port = 29743;
private readonly TimeSpan? _withStartDelay;
+ private readonly bool _withTokenAuth;
+ private readonly bool _withBasicAuth;
+ private readonly bool _withRetriableError;
+ private readonly bool _withErrorMessage;
///
/// Initializes a configurable in-process dummy HTTP server used for testing endpoints.
@@ -63,11 +67,19 @@ public DummyHttpServer(bool withTokenAuth = false, bool withBasicAuth = false, b
.AddConsole();
});
+ // Store configuration in instance fields instead of static fields
+ // to avoid interference between multiple concurrent servers
+ _withTokenAuth = withTokenAuth;
+ _withBasicAuth = withBasicAuth;
+ _withRetriableError = withRetriableError;
+ _withErrorMessage = withErrorMessage;
+ _withStartDelay = withStartDelay;
+
+ // Also set static flags for backwards compatibility
IlpEndpoint.WithTokenAuth = withTokenAuth;
IlpEndpoint.WithBasicAuth = withBasicAuth;
IlpEndpoint.WithRetriableError = withRetriableError;
IlpEndpoint.WithErrorMessage = withErrorMessage;
- _withStartDelay = withStartDelay;
if (withTokenAuth)
{
@@ -100,6 +112,13 @@ public DummyHttpServer(bool withTokenAuth = false, bool withBasicAuth = false, b
_app.MapHealthChecks("/ping");
_app.UseDefaultExceptionHandler();
+ // Add middleware to set X-Server-Port header so endpoints know which port they're running on
+ _app.Use(async (context, next) =>
+ {
+ context.Request.Headers["X-Server-Port"] = _port.ToString();
+ await next();
+ });
+
if (withTokenAuth)
{
_app
@@ -125,10 +144,7 @@ public void Dispose()
///
public void Clear()
{
- IlpEndpoint.ReceiveBuffer.Clear();
- IlpEndpoint.ReceiveBytes.Clear();
- IlpEndpoint.LastError = null;
- IlpEndpoint.Counter = 0;
+ IlpEndpoint.ClearPort(_port);
}
///
@@ -147,6 +163,14 @@ public async Task StartAsync(int port = 29743, int[]? versions = null)
versions ??= new[] { 1, 2, 3, };
SettingsEndpoint.Versions = versions;
_port = port;
+
+ // Store configuration flags keyed by port so multiple servers don't interfere
+ IlpEndpoint.SetPortConfig(port,
+ tokenAuth: _withTokenAuth,
+ basicAuth: _withBasicAuth,
+ retriableError: _withRetriableError,
+ errorMessage: _withErrorMessage);
+
_ = _app.RunAsync($"http://localhost:{port}");
}
@@ -169,7 +193,7 @@ public async Task StopAsync()
/// The mutable containing the accumulated received text; modifying it updates the server's buffer.
public StringBuilder GetReceiveBuffer()
{
- return IlpEndpoint.ReceiveBuffer;
+ return IlpEndpoint.GetReceiveBuffer(_port);
}
///
@@ -178,12 +202,12 @@ public StringBuilder GetReceiveBuffer()
/// The mutable list of bytes received by the endpoint.
public List GetReceivedBytes()
{
- return IlpEndpoint.ReceiveBytes;
+ return IlpEndpoint.GetReceiveBytes(_port);
}
public Exception? GetLastError()
{
- return IlpEndpoint.LastError;
+ return IlpEndpoint.GetLastError(_port);
}
public async Task Healthcheck()
@@ -214,7 +238,7 @@ public async Task Healthcheck()
public int GetCounter()
{
- return IlpEndpoint.Counter;
+ return IlpEndpoint.GetCounter(_port);
}
///
diff --git a/src/dummy-http-server/IlpEndpoint.cs b/src/dummy-http-server/IlpEndpoint.cs
index fc4e5fe..ddfee0c 100644
--- a/src/dummy-http-server/IlpEndpoint.cs
+++ b/src/dummy-http-server/IlpEndpoint.cs
@@ -73,14 +73,117 @@ public class IlpEndpoint : Endpoint
{
private const string Username = "admin";
private const string Password = "quest";
- public static readonly StringBuilder ReceiveBuffer = new();
- public static readonly List ReceiveBytes = new();
- public static Exception? LastError = new();
+
+ // Port-keyed storage to support multiple concurrent DummyHttpServer instances
+ private static readonly Dictionary Bytes, Exception? Error, int Counter)>
+ PortData = new();
+
+ // Port-keyed configuration to support multiple concurrent DummyHttpServer instances
+ private static readonly Dictionary
+ PortConfig = new();
+
+ // Configuration flags (global, apply to all servers) - kept for backwards compatibility
public static bool WithTokenAuth = false;
public static bool WithBasicAuth = false;
public static bool WithRetriableError = false;
public static bool WithErrorMessage = false;
- public static int Counter;
+
+ // Get the port from request headers (set by DummyHttpServer)
+ private static int GetPortKey(HttpContext context)
+ {
+ if (context?.Request.Headers.TryGetValue("X-Server-Port", out var portHeader) == true
+ && int.TryParse(portHeader.ToString(), out var port))
+ {
+ return port;
+ }
+ return context?.Connection?.LocalPort ?? 0;
+ }
+
+ private static (StringBuilder Buffer, List Bytes, Exception? Error, int Counter) GetOrCreatePortData(int port)
+ {
+ lock (PortData)
+ {
+ if (!PortData.TryGetValue(port, out var data))
+ {
+ data = (new StringBuilder(), new List(), null, 0);
+ PortData[port] = data;
+ }
+ return data;
+ }
+ }
+
+
+ // Public methods for accessing port-specific data (used by DummyHttpServer)
+ public static StringBuilder GetReceiveBuffer(int port) => GetOrCreatePortData(port).Buffer;
+ public static List GetReceiveBytes(int port) => GetOrCreatePortData(port).Bytes;
+
+ public static Exception? GetLastError(int port)
+ {
+ lock (PortData)
+ {
+ return GetOrCreatePortData(port).Error;
+ }
+ }
+
+ public static void SetLastError(int port, Exception? error)
+ {
+ lock (PortData)
+ {
+ var data = GetOrCreatePortData(port);
+ PortData[port] = (data.Buffer, data.Bytes, error, data.Counter);
+ }
+ }
+
+ public static int GetCounter(int port)
+ {
+ lock (PortData)
+ {
+ return GetOrCreatePortData(port).Counter;
+ }
+ }
+
+ public static void SetCounter(int port, int value)
+ {
+ lock (PortData)
+ {
+ var data = GetOrCreatePortData(port);
+ PortData[port] = (data.Buffer, data.Bytes, data.Error, value);
+ }
+ }
+
+ public static void ClearPort(int port)
+ {
+ lock (PortData)
+ {
+ if (PortData.TryGetValue(port, out var data))
+ {
+ data.Buffer.Clear();
+ data.Bytes.Clear();
+ PortData[port] = (data.Buffer, data.Bytes, null, 0);
+ }
+ }
+ }
+
+ public static void SetPortConfig(int port, bool tokenAuth, bool basicAuth, bool retriableError, bool errorMessage)
+ {
+ lock (PortConfig)
+ {
+ PortConfig[port] = (tokenAuth, basicAuth, retriableError, errorMessage);
+ }
+ }
+
+ private static (bool TokenAuth, bool BasicAuth, bool RetriableError, bool ErrorMessage) GetPortConfig(int port)
+ {
+ lock (PortConfig)
+ {
+ if (PortConfig.TryGetValue(port, out var config))
+ {
+ return config;
+ }
+ // Return static flags as defaults for backwards compatibility
+ return (WithTokenAuth, WithBasicAuth, WithRetriableError, WithErrorMessage);
+ }
+ }
public override void Configure()
{
@@ -101,14 +204,24 @@ public override void Configure()
public override async Task HandleAsync(Request req, CancellationToken ct)
{
- Counter++;
- if (WithRetriableError)
+ int port = GetPortKey(HttpContext);
+ var data = GetOrCreatePortData(port);
+ var config = GetPortConfig(port);
+
+ lock (PortData)
+ {
+ // Increment counter for this port
+ data = GetOrCreatePortData(port);
+ PortData[port] = (data.Buffer, data.Bytes, data.Error, data.Counter + 1);
+ }
+
+ if (config.RetriableError)
{
await SendAsync(null, 500, ct);
return;
}
- if (WithErrorMessage)
+ if (config.ErrorMessage)
{
await SendAsync(new JsonErrorResponse
{ code = "code", errorId = "errorid", line = 1, message = "message", }, 400, ct);
@@ -117,13 +230,22 @@ await SendAsync(new JsonErrorResponse
try
{
- ReceiveBuffer.Append(req.StringContent);
- ReceiveBytes.AddRange(req.ByteContent);
+ lock (PortData)
+ {
+ data = GetOrCreatePortData(port);
+ data.Buffer.Append(req.StringContent);
+ data.Bytes.AddRange(req.ByteContent);
+ PortData[port] = data;
+ }
await SendNoContentAsync(ct);
}
catch (Exception ex)
{
- LastError = ex;
+ lock (PortData)
+ {
+ data = GetOrCreatePortData(port);
+ PortData[port] = (data.Buffer, data.Bytes, ex, data.Counter);
+ }
throw;
}
}
From 6a8461b71bf525b690fdf2da60f5b7c14e915b2b Mon Sep 17 00:00:00 2001
From: Nick Woolmer <29717167+nwoolmer@users.noreply.github.com>
Date: Mon, 1 Dec 2025 20:59:19 +0000
Subject: [PATCH 07/40] iterate
---
.../QuestDbIntegrationTests.cs | 293 +++++++++++++
.../QuestDbManager.cs | 398 ++++++++++++++++++
2 files changed, 691 insertions(+)
create mode 100644 src/net-questdb-client-tests/QuestDbIntegrationTests.cs
create mode 100644 src/net-questdb-client-tests/QuestDbManager.cs
diff --git a/src/net-questdb-client-tests/QuestDbIntegrationTests.cs b/src/net-questdb-client-tests/QuestDbIntegrationTests.cs
new file mode 100644
index 0000000..4481d5e
--- /dev/null
+++ b/src/net-questdb-client-tests/QuestDbIntegrationTests.cs
@@ -0,0 +1,293 @@
+/*******************************************************************************
+ * ___ _ ____ ____
+ * / _ \ _ _ ___ ___| |_| _ \| __ )
+ * | | | | | | |/ _ \/ __| __| | | | _ \
+ * | |_| | |_| | __/\__ \ |_| |_| | |_) |
+ * \__\_\\__,_|\___||___/\__|____/|____/
+ *
+ * Copyright (c) 2014-2019 Appsicle
+ * Copyright (c) 2019-2024 QuestDB
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ *
+ ******************************************************************************/
+
+using System;
+using System.Collections.Generic;
+using System.Diagnostics;
+using System.Net.Http;
+using System.Net.Http.Json;
+using System.Text.Json;
+using System.Threading.Tasks;
+using NUnit.Framework;
+using QuestDB;
+
+namespace QuestDB.Client.Tests;
+
+///
+/// Integration tests against a real QuestDB instance.
+/// Requires QuestDB to be downloaded and running.
+///
+[TestFixture]
+public class QuestDbIntegrationTests
+{
+ private QuestDbManager? _questDb;
+ private const int IlpPort = 19009;
+ private const int HttpPort = 19000;
+
+ [OneTimeSetUp]
+ public async Task SetUpFixture()
+ {
+ _questDb = new QuestDbManager(IlpPort, HttpPort);
+ await _questDb.EnsureDownloadedAsync();
+ await _questDb.StartAsync();
+ await Task.Delay(1000); // Give QuestDB a moment to fully initialize
+ }
+
+ [OneTimeTearDown]
+ public async Task TearDownFixture()
+ {
+ if (_questDb != null)
+ {
+ await _questDb.StopAsync();
+ await _questDb.DisposeAsync();
+ }
+ }
+
+ [Test]
+ public async Task CanSendDataOverHttp()
+ {
+ var httpEndpoint = _questDb!.GetHttpEndpoint();
+ using var sender = Sender.New($"http::addr={httpEndpoint};auto_flush=off;");
+
+ // Send test data
+ await sender
+ .Table("test_http")
+ .Symbol("tag", "test")
+ .Column("value", 42L)
+ .AtAsync(DateTime.UtcNow);
+ await sender.SendAsync();
+
+ // Verify data was written
+ await VerifyTableHasDataAsync("test_http");
+ }
+
+ [Test]
+ public async Task CanSendDataOverIlp()
+ {
+ var ilpEndpoint = _questDb!.GetIlpEndpoint();
+ using var sender = Sender.New($"tcp::addr={ilpEndpoint};auto_flush=off;");
+
+ // Send test data
+ await sender
+ .Table("test_ilp")
+ .Symbol("tag", "test")
+ .Column("value", 123L)
+ .AtAsync(DateTime.UtcNow);
+ await sender.SendAsync();
+
+ // Verify data was written
+ await VerifyTableHasDataAsync("test_ilp");
+ }
+
+ [Test]
+ public async Task CanSendMultipleRows()
+ {
+ var httpEndpoint = _questDb!.GetHttpEndpoint();
+ using var sender = Sender.New($"http::addr={httpEndpoint};auto_flush=off;");
+
+ // Send multiple rows
+ for (int i = 0; i < 10; i++)
+ {
+ await sender
+ .Table("test_multiple_rows")
+ .Symbol("tag", $"test_{i}")
+ .Column("value", (long)(i * 10))
+ .AtAsync(DateTime.UtcNow);
+ }
+
+ await sender.SendAsync();
+
+ // Verify all rows were written
+ var rowCount = await GetTableRowCountAsync("test_multiple_rows");
+ Assert.That(rowCount, Is.GreaterThanOrEqualTo(10), "Expected at least 10 rows");
+ }
+
+ [Test]
+ public async Task CanSendDifferentDataTypes()
+ {
+ var httpEndpoint = _questDb!.GetHttpEndpoint();
+ using var sender = Sender.New($"http::addr={httpEndpoint};auto_flush=off;");
+
+ var now = DateTime.UtcNow;
+
+ // Send different data types
+ await sender
+ .Table("test_data_types")
+ .Symbol("symbol_col", "test")
+ .Column("long_col", 42L)
+ .Column("double_col", 3.14)
+ .Column("string_col", "hello world")
+ .Column("bool_col", true)
+ .AtAsync(now);
+
+ await sender.SendAsync();
+
+ // Verify the row exists
+ var rowCount = await GetTableRowCountAsync("test_data_types");
+ Assert.That(rowCount, Is.GreaterThanOrEqualTo(1));
+ }
+
+ [Test]
+ public async Task MultiUrlFallback()
+ {
+ // Test that the client properly handles multiple URLs with fallback
+ var httpEndpoint = _questDb!.GetHttpEndpoint();
+ var badEndpoint = "http://localhost:19001"; // Non-existent endpoint
+
+ // The client should try the bad endpoint first, then fallback to the good one
+ using var sender = Sender.New(
+ $"http::addr={badEndpoint},{httpEndpoint};auto_flush=off;");
+
+ await sender
+ .Table("test_multi_url")
+ .Symbol("tag", "fallback")
+ .Column("value", 999L)
+ .AtAsync(DateTime.UtcNow);
+
+ await sender.SendAsync();
+
+ // Verify data was written despite the bad endpoint
+ await VerifyTableHasDataAsync("test_multi_url");
+ }
+
+ [Test]
+ public async Task CanAutoFlush()
+ {
+ var httpEndpoint = _questDb!.GetHttpEndpoint();
+ using var sender = Sender.New(
+ $"http::addr={httpEndpoint};auto_flush=on;auto_flush_rows=1;");
+
+ // Send data - should auto-flush due to auto_flush_rows=1
+ await sender
+ .Table("test_auto_flush")
+ .Symbol("tag", "test")
+ .Column("value", 777L)
+ .AtAsync(DateTime.UtcNow);
+
+ // Give it a moment to flush
+ await Task.Delay(100);
+
+ // Verify data was written
+ await VerifyTableHasDataAsync("test_auto_flush");
+ }
+
+ [Test]
+ public async Task HealthCheckEndpoint()
+ {
+ var httpEndpoint = _questDb!.GetHttpEndpoint();
+
+ using var client = new HttpClient();
+ var response = await client.GetAsync($"{httpEndpoint}/api/v1/health");
+
+ Assert.That(response.IsSuccessStatusCode, "Health check should succeed");
+
+ var content = await response.Content.ReadAsStringAsync();
+ var json = JsonDocument.Parse(content);
+ var status = json.RootElement.GetProperty("status").GetString();
+
+ Assert.That(status, Is.EqualTo("ok"), "Status should be 'ok'");
+ }
+
+ [Test]
+ public async Task TablesEndpoint()
+ {
+ var httpEndpoint = _questDb!.GetHttpEndpoint();
+
+ using var client = new HttpClient();
+ var response = await client.GetAsync($"{httpEndpoint}/api/v1/tables");
+
+ Assert.That(response.IsSuccessStatusCode, "Tables endpoint should succeed");
+
+ var content = await response.Content.ReadAsStringAsync();
+ var json = JsonDocument.Parse(content);
+ Assert.That(json.RootElement.TryGetProperty("tables", out _), "Response should contain tables property");
+ }
+
+ private async Task VerifyTableHasDataAsync(string tableName)
+ {
+ var httpEndpoint = _questDb!.GetHttpEndpoint();
+ using var client = new HttpClient { Timeout = TimeSpan.FromSeconds(10) };
+
+ // Retry a few times to allow for write latency
+ var attempts = 0;
+ const int maxAttempts = 10;
+
+ while (attempts < maxAttempts)
+ {
+ try
+ {
+ var response = await client.GetAsync(
+ $"{httpEndpoint}/api/v1/query?query=select count(*) as cnt from {tableName}");
+
+ if (response.IsSuccessStatusCode)
+ {
+ var content = await response.Content.ReadAsStringAsync();
+ var json = JsonDocument.Parse(content);
+ if (json.RootElement.TryGetProperty("dataset", out var dataset) &&
+ dataset.TryGetProperty("count", out var count))
+ {
+ var rowCount = count.GetInt64();
+ if (rowCount > 0)
+ {
+ return;
+ }
+ }
+ }
+ }
+ catch
+ {
+ // Retry
+ }
+
+ await Task.Delay(100);
+ attempts++;
+ }
+
+ Assert.Fail($"Table {tableName} has no data after {maxAttempts} attempts");
+ }
+
+ private async Task GetTableRowCountAsync(string tableName)
+ {
+ var httpEndpoint = _questDb!.GetHttpEndpoint();
+ using var client = new HttpClient { Timeout = TimeSpan.FromSeconds(10) };
+
+ var response = await client.GetAsync(
+ $"{httpEndpoint}/api/v1/query?query=select count(*) as cnt from {tableName}");
+
+ if (!response.IsSuccessStatusCode)
+ {
+ return 0;
+ }
+
+ var content = await response.Content.ReadAsStringAsync();
+ var json = JsonDocument.Parse(content);
+ if (json.RootElement.TryGetProperty("dataset", out var dataset) &&
+ dataset.TryGetProperty("count", out var count))
+ {
+ return count.GetInt64();
+ }
+
+ return 0;
+ }
+}
diff --git a/src/net-questdb-client-tests/QuestDbManager.cs b/src/net-questdb-client-tests/QuestDbManager.cs
new file mode 100644
index 0000000..c9a7826
--- /dev/null
+++ b/src/net-questdb-client-tests/QuestDbManager.cs
@@ -0,0 +1,398 @@
+using System;
+using System.Diagnostics;
+using System.IO;
+using System.IO.Compression;
+using System.Net.Http;
+using System.Runtime.InteropServices;
+using System.Text.Json;
+using System.Threading;
+using System.Threading.Tasks;
+
+namespace QuestDB.Client.Tests;
+
+///
+/// Manages QuestDB server lifecycle for integration tests.
+/// Handles downloading, starting, and stopping QuestDB instances.
+///
+public class QuestDbManager : IAsyncDisposable
+{
+ private readonly string _projectRoot;
+ private readonly string _questdbDir;
+ private readonly int _port;
+ private readonly int _httpPort;
+ private Process? _process;
+ private readonly HttpClient _httpClient;
+
+ public string QuestDbPath { get; private set; } = string.Empty;
+ public bool IsRunning { get; private set; }
+
+ ///
+ /// Initializes a new instance of the QuestDbManager.
+ ///
+ /// ILP port (default: 9009)
+ /// HTTP port (default: 9000)
+ public QuestDbManager(int port = 9009, int httpPort = 9000)
+ {
+ _port = port;
+ _httpPort = httpPort;
+ _projectRoot = FindProjectRoot();
+ _questdbDir = Path.Combine(_projectRoot, ".questdb");
+ _httpClient = new HttpClient { Timeout = TimeSpan.FromSeconds(5) };
+ }
+
+ ///
+ /// Downloads QuestDB binary if not already present.
+ ///
+ public async Task EnsureDownloadedAsync()
+ {
+ if (IsQuestDbDownloaded())
+ {
+ QuestDbPath = GetLatestQuestDbPath();
+ return;
+ }
+
+ var platform = DetectPlatform();
+ var version = await GetLatestVersionAsync();
+
+ Console.WriteLine($"Platform: {platform}");
+ Console.WriteLine($"Latest version: {version}");
+ Console.WriteLine("Downloading QuestDB...");
+
+ await DownloadAndExtractAsync(platform, version);
+
+ QuestDbPath = GetLatestQuestDbPath();
+ if (!Directory.Exists(QuestDbPath))
+ {
+ throw new InvalidOperationException($"QuestDB path does not exist: {QuestDbPath}");
+ }
+
+ Console.WriteLine($"QuestDB extracted to: {QuestDbPath}");
+ }
+
+ ///
+ /// Starts the QuestDB server.
+ ///
+ public async Task StartAsync()
+ {
+ if (IsRunning)
+ {
+ Console.WriteLine("QuestDB is already running");
+ return;
+ }
+
+ await EnsureDownloadedAsync();
+
+ Console.WriteLine($"Starting QuestDB from {QuestDbPath}");
+
+ var questdbExe = Path.Combine(QuestDbPath, "bin", GetExecutableName("questdb"));
+ if (!File.Exists(questdbExe))
+ {
+ throw new FileNotFoundException($"QuestDB executable not found at {questdbExe}");
+ }
+
+ var dataDir = Path.Combine(_questdbDir, "data");
+ Directory.CreateDirectory(dataDir);
+
+ var startInfo = new ProcessStartInfo
+ {
+ FileName = questdbExe,
+ Arguments = $"-d \"{dataDir}\"",
+ UseShellExecute = false,
+ CreateNoWindow = true,
+ RedirectStandardOutput = true,
+ RedirectStandardError = true,
+ EnvironmentVariables =
+ {
+ { "QDB_ROOT", dataDir }
+ }
+ };
+
+ _process = Process.Start(startInfo);
+ if (_process == null)
+ {
+ throw new InvalidOperationException("Failed to start QuestDB process");
+ }
+
+ Console.WriteLine($"QuestDB started with PID {_process.Id}");
+ IsRunning = true;
+
+ // Wait for QuestDB to be ready
+ await WaitForQuestDbAsync();
+ }
+
+ ///
+ /// Stops the QuestDB server.
+ ///
+ public async Task StopAsync()
+ {
+ if (!IsRunning || _process == null)
+ {
+ return;
+ }
+
+ Console.WriteLine($"Stopping QuestDB (PID: {_process.Id})");
+
+ try
+ {
+ _process.Kill();
+ await _process.WaitForExitAsync().ConfigureAwait(false);
+ }
+ catch (InvalidOperationException)
+ {
+ // Process already exited
+ }
+
+ _process?.Dispose();
+ _process = null;
+ IsRunning = false;
+ Console.WriteLine("QuestDB stopped");
+ }
+
+ ///
+ /// Gets the HTTP endpoint for QuestDB.
+ ///
+ public string GetHttpEndpoint() => $"http://localhost:{_httpPort}";
+
+ ///
+ /// Gets the ILP endpoint for QuestDB.
+ ///
+ public string GetIlpEndpoint() => $"localhost:{_port}";
+
+ ///
+ /// Waits for QuestDB to be ready.
+ ///
+ private async Task WaitForQuestDbAsync()
+ {
+ const int maxAttempts = 30;
+ var attempts = 0;
+
+ while (attempts < maxAttempts)
+ {
+ try
+ {
+ var response = await _httpClient.GetAsync($"{GetHttpEndpoint()}/api/v1/health");
+ if (response.IsSuccessStatusCode)
+ {
+ Console.WriteLine("QuestDB is ready");
+ return;
+ }
+ }
+ catch
+ {
+ // Ignore and retry
+ }
+
+ await Task.Delay(1000);
+ attempts++;
+ }
+
+ throw new TimeoutException($"QuestDB failed to start within {maxAttempts} seconds");
+ }
+
+ ///
+ /// Cleanup resources.
+ ///
+ public async ValueTask DisposeAsync()
+ {
+ await StopAsync();
+ _httpClient?.Dispose();
+ _process?.Dispose();
+ }
+
+ private bool IsQuestDbDownloaded()
+ {
+ if (!Directory.Exists(_questdbDir))
+ {
+ return false;
+ }
+
+ var questdbDirs = Directory.GetDirectories(_questdbDir, "questdb-*");
+ return questdbDirs.Length > 0;
+ }
+
+ private string GetLatestQuestDbPath()
+ {
+ var questdbDirs = Directory.GetDirectories(_questdbDir, "questdb-*");
+ if (questdbDirs.Length == 0)
+ {
+ throw new InvalidOperationException("No QuestDB installation found");
+ }
+
+ // Return the most recently modified directory
+ var latest = questdbDirs[0];
+ var latestTime = Directory.GetCreationTime(latest);
+
+ foreach (var dir in questdbDirs)
+ {
+ var time = Directory.GetCreationTime(dir);
+ if (time > latestTime)
+ {
+ latest = dir;
+ latestTime = time;
+ }
+ }
+
+ return latest;
+ }
+
+ private string FindProjectRoot()
+ {
+ var current = Directory.GetCurrentDirectory();
+ while (current != null)
+ {
+ if (File.Exists(Path.Combine(current, "net-questdb-client.sln")))
+ {
+ return current;
+ }
+
+ current = Directory.GetParent(current)?.FullName;
+ }
+
+ throw new InvalidOperationException("Could not find project root (net-questdb-client.sln)");
+ }
+
+ private string DetectPlatform()
+ {
+ var os = OperatingSystem.IsWindows() ? "windows" :
+ OperatingSystem.IsMacOS() ? "macos" :
+ OperatingSystem.IsLinux() ? "linux" :
+ throw new NotSupportedException($"Unsupported OS: {RuntimeInformation.OSDescription}");
+
+ var architecture = RuntimeInformation.ProcessArchitecture switch
+ {
+ System.Runtime.InteropServices.Architecture.X64 => "amd64",
+ System.Runtime.InteropServices.Architecture.Arm64 => "arm64",
+ _ => throw new NotSupportedException($"Unsupported architecture: {RuntimeInformation.ProcessArchitecture}")
+ };
+
+ return $"{os}-{architecture}";
+ }
+
+ private async Task GetLatestVersionAsync()
+ {
+ var latestUrl = "https://api.github.com/repos/questdb/questdb/releases/latest";
+ var response = await _httpClient.GetAsync(latestUrl);
+
+ if (!response.IsSuccessStatusCode)
+ {
+ throw new InvalidOperationException($"Failed to fetch latest QuestDB version from {latestUrl}");
+ }
+
+ var content = await response.Content.ReadAsStringAsync();
+ var json = JsonDocument.Parse(content);
+ var tagName = json.RootElement.GetProperty("tag_name").GetString();
+
+ if (string.IsNullOrEmpty(tagName))
+ {
+ throw new InvalidOperationException("Could not parse version from GitHub API response");
+ }
+
+ // Remove 'v' prefix if present
+ return tagName.StartsWith("v") ? tagName.Substring(1) : tagName;
+ }
+
+ private async Task DownloadAndExtractAsync(string platform, string version)
+ {
+ var downloadUrl = $"https://github.com/questdb/questdb/releases/download/v{version}/questdb-{version}-{platform}.tar.gz";
+ var tarFile = Path.Combine(_questdbDir, $"questdb-{version}-{platform}.tar.gz");
+ var extractDir = Path.Combine(_questdbDir, $"questdb-{version}");
+
+ // Check if already extracted
+ if (Directory.Exists(extractDir))
+ {
+ Console.WriteLine($"QuestDB {version} already extracted");
+ return;
+ }
+
+ try
+ {
+ // Create directory
+ Directory.CreateDirectory(_questdbDir);
+
+ // Download
+ Console.WriteLine($"Downloading from {downloadUrl}...");
+ var response = await _httpClient.GetAsync(downloadUrl);
+ if (!response.IsSuccessStatusCode)
+ {
+ throw new InvalidOperationException(
+ $"Failed to download QuestDB from {downloadUrl}: HTTP {response.StatusCode}");
+ }
+
+ await using var downloadStream = await response.Content.ReadAsStreamAsync();
+ await using var fileStream = File.Create(tarFile);
+ await downloadStream.CopyToAsync(fileStream);
+
+ // Extract using tar command (available on Unix and Windows 10+)
+ Console.WriteLine("Extracting archive...");
+ await ExtractTarGzAsync(tarFile, _questdbDir);
+
+ // Make binaries executable
+ if (!OperatingSystem.IsWindows())
+ {
+ MakeExecutable(Path.Combine(extractDir, "bin"));
+ }
+ }
+ finally
+ {
+ // Clean up tar file
+ if (File.Exists(tarFile))
+ {
+ File.Delete(tarFile);
+ }
+ }
+ }
+
+ private async Task ExtractTarGzAsync(string tarGzFile, string extractPath)
+ {
+ // Use system tar command for reliable extraction across platforms
+ var startInfo = new ProcessStartInfo
+ {
+ FileName = OperatingSystem.IsWindows() ? "tar" : "/usr/bin/tar",
+ Arguments = $"-xzf \"{tarGzFile}\" -C \"{extractPath}\"",
+ UseShellExecute = false,
+ RedirectStandardOutput = true,
+ RedirectStandardError = true,
+ CreateNoWindow = true
+ };
+
+ var process = Process.Start(startInfo);
+ if (process == null)
+ {
+ throw new InvalidOperationException("Failed to start tar extraction process");
+ }
+
+ var error = await process.StandardError.ReadToEndAsync();
+ await process.WaitForExitAsync();
+
+ if (process.ExitCode != 0)
+ {
+ throw new InvalidOperationException($"tar extraction failed: {error}");
+ }
+ }
+
+ private void MakeExecutable(string directoryPath)
+ {
+ if (!Directory.Exists(directoryPath))
+ {
+ return;
+ }
+
+ var startInfo = new ProcessStartInfo
+ {
+ FileName = "/bin/chmod",
+ Arguments = $"+x \"{directoryPath}\"/*",
+ UseShellExecute = false,
+ RedirectStandardOutput = true,
+ RedirectStandardError = true,
+ CreateNoWindow = true
+ };
+
+ var process = Process.Start(startInfo);
+ process?.WaitForExit();
+ }
+
+ private static string GetExecutableName(string baseName)
+ {
+ return OperatingSystem.IsWindows() ? $"{baseName}.exe" : baseName;
+ }
+}
From 9524a5e580f3ad0970978166e1f7a90db973b53a Mon Sep 17 00:00:00 2001
From: Nick Woolmer <29717167+nwoolmer@users.noreply.github.com>
Date: Tue, 2 Dec 2025 14:36:33 +0000
Subject: [PATCH 08/40] fix tests
---
.../QuestDbIntegrationTests.cs | 189 ++++------
.../QuestDbManager.cs | 344 ++++++------------
src/net-questdb-client/Senders/HttpSender.cs | 2 +-
3 files changed, 183 insertions(+), 352 deletions(-)
diff --git a/src/net-questdb-client-tests/QuestDbIntegrationTests.cs b/src/net-questdb-client-tests/QuestDbIntegrationTests.cs
index 4481d5e..e12512a 100644
--- a/src/net-questdb-client-tests/QuestDbIntegrationTests.cs
+++ b/src/net-questdb-client-tests/QuestDbIntegrationTests.cs
@@ -22,36 +22,23 @@
*
******************************************************************************/
-using System;
-using System.Collections.Generic;
-using System.Diagnostics;
-using System.Net.Http;
-using System.Net.Http.Json;
using System.Text.Json;
-using System.Threading.Tasks;
using NUnit.Framework;
-using QuestDB;
namespace QuestDB.Client.Tests;
///
-/// Integration tests against a real QuestDB instance.
-/// Requires QuestDB to be downloaded and running.
+/// Integration tests against a real QuestDB instance running in Docker.
+/// Requires Docker to be installed and running.
///
[TestFixture]
public class QuestDbIntegrationTests
{
- private QuestDbManager? _questDb;
- private const int IlpPort = 19009;
- private const int HttpPort = 19000;
-
[OneTimeSetUp]
public async Task SetUpFixture()
{
_questDb = new QuestDbManager(IlpPort, HttpPort);
- await _questDb.EnsureDownloadedAsync();
await _questDb.StartAsync();
- await Task.Delay(1000); // Give QuestDB a moment to fully initialize
}
[OneTimeTearDown]
@@ -64,18 +51,22 @@ public async Task TearDownFixture()
}
}
+ private QuestDbManager? _questDb;
+ private const int IlpPort = 19009;
+ private const int HttpPort = 19000;
+
[Test]
public async Task CanSendDataOverHttp()
{
- var httpEndpoint = _questDb!.GetHttpEndpoint();
- using var sender = Sender.New($"http::addr={httpEndpoint};auto_flush=off;");
+ var httpEndpoint = _questDb!.GetHttpEndpoint();
+ using var sender = Sender.New($"http::addr={httpEndpoint};auto_flush=off;");
// Send test data
await sender
- .Table("test_http")
- .Symbol("tag", "test")
- .Column("value", 42L)
- .AtAsync(DateTime.UtcNow);
+ .Table("test_http")
+ .Symbol("tag", "test")
+ .Column("value", 42L)
+ .AtAsync(DateTime.UtcNow);
await sender.SendAsync();
// Verify data was written
@@ -85,15 +76,15 @@ await sender
[Test]
public async Task CanSendDataOverIlp()
{
- var ilpEndpoint = _questDb!.GetIlpEndpoint();
- using var sender = Sender.New($"tcp::addr={ilpEndpoint};auto_flush=off;");
+ var ilpEndpoint = _questDb!.GetIlpEndpoint();
+ using var sender = Sender.New($"tcp::addr={ilpEndpoint};auto_flush=off;");
// Send test data
await sender
- .Table("test_ilp")
- .Symbol("tag", "test")
- .Column("value", 123L)
- .AtAsync(DateTime.UtcNow);
+ .Table("test_ilp")
+ .Symbol("tag", "test")
+ .Column("value", 123L)
+ .AtAsync(DateTime.UtcNow);
await sender.SendAsync();
// Verify data was written
@@ -103,17 +94,17 @@ await sender
[Test]
public async Task CanSendMultipleRows()
{
- var httpEndpoint = _questDb!.GetHttpEndpoint();
- using var sender = Sender.New($"http::addr={httpEndpoint};auto_flush=off;");
+ var httpEndpoint = _questDb!.GetHttpEndpoint();
+ using var sender = Sender.New($"http::addr={httpEndpoint};auto_flush=off;");
// Send multiple rows
- for (int i = 0; i < 10; i++)
+ for (var i = 0; i < 10; i++)
{
await sender
- .Table("test_multiple_rows")
- .Symbol("tag", $"test_{i}")
- .Column("value", (long)(i * 10))
- .AtAsync(DateTime.UtcNow);
+ .Table("test_multiple_rows")
+ .Symbol("tag", $"test_{i}")
+ .Column("value", (long)(i * 10))
+ .AtAsync(DateTime.UtcNow);
}
await sender.SendAsync();
@@ -126,25 +117,38 @@ await sender
[Test]
public async Task CanSendDifferentDataTypes()
{
- var httpEndpoint = _questDb!.GetHttpEndpoint();
- using var sender = Sender.New($"http::addr={httpEndpoint};auto_flush=off;");
+ var httpEndpoint = _questDb!.GetHttpEndpoint();
+ using var sender = Sender.New($"http::addr={httpEndpoint};auto_flush=off;");
var now = DateTime.UtcNow;
// Send different data types
await sender
- .Table("test_data_types")
- .Symbol("symbol_col", "test")
- .Column("long_col", 42L)
- .Column("double_col", 3.14)
- .Column("string_col", "hello world")
- .Column("bool_col", true)
- .AtAsync(now);
+ .Table("test_data_types")
+ .Symbol("symbol_col", "test")
+ .Column("long_col", 42L)
+ .Column("double_col", 3.14)
+ .Column("string_col", "hello world")
+ .Column("bool_col", true)
+ .AtAsync(now);
await sender.SendAsync();
// Verify the row exists
- var rowCount = await GetTableRowCountAsync("test_data_types");
+
+ long rowCount = 0;
+ var retries = 10;
+ for (var i = 0; i < retries; i++)
+ {
+ rowCount = await GetTableRowCountAsync("test_data_types");
+ if (rowCount != 0)
+ {
+ break;
+ }
+
+ await Task.Delay(500);
+ }
+
Assert.That(rowCount, Is.GreaterThanOrEqualTo(1));
}
@@ -153,17 +157,17 @@ public async Task MultiUrlFallback()
{
// Test that the client properly handles multiple URLs with fallback
var httpEndpoint = _questDb!.GetHttpEndpoint();
- var badEndpoint = "http://localhost:19001"; // Non-existent endpoint
+ var badEndpoint = "http://localhost:19001"; // Non-existent endpoint
// The client should try the bad endpoint first, then fallback to the good one
using var sender = Sender.New(
- $"http::addr={badEndpoint},{httpEndpoint};auto_flush=off;");
+ $"http::addr={badEndpoint};addr={httpEndpoint};auto_flush=off;");
await sender
- .Table("test_multi_url")
- .Symbol("tag", "fallback")
- .Column("value", 999L)
- .AtAsync(DateTime.UtcNow);
+ .Table("test_multi_url")
+ .Symbol("tag", "fallback")
+ .Column("value", 999L)
+ .AtAsync(DateTime.UtcNow);
await sender.SendAsync();
@@ -180,10 +184,10 @@ public async Task CanAutoFlush()
// Send data - should auto-flush due to auto_flush_rows=1
await sender
- .Table("test_auto_flush")
- .Symbol("tag", "test")
- .Column("value", 777L)
- .AtAsync(DateTime.UtcNow);
+ .Table("test_auto_flush")
+ .Symbol("tag", "test")
+ .Column("value", 777L)
+ .AtAsync(DateTime.UtcNow);
// Give it a moment to flush
await Task.Delay(100);
@@ -191,46 +195,20 @@ await sender
// Verify data was written
await VerifyTableHasDataAsync("test_auto_flush");
}
-
- [Test]
- public async Task HealthCheckEndpoint()
- {
- var httpEndpoint = _questDb!.GetHttpEndpoint();
-
- using var client = new HttpClient();
- var response = await client.GetAsync($"{httpEndpoint}/api/v1/health");
-
- Assert.That(response.IsSuccessStatusCode, "Health check should succeed");
-
- var content = await response.Content.ReadAsStringAsync();
- var json = JsonDocument.Parse(content);
- var status = json.RootElement.GetProperty("status").GetString();
-
- Assert.That(status, Is.EqualTo("ok"), "Status should be 'ok'");
- }
-
- [Test]
- public async Task TablesEndpoint()
+
+ private async Task VerifyTableHasDataAsync(string tableName)
{
- var httpEndpoint = _questDb!.GetHttpEndpoint();
-
- using var client = new HttpClient();
- var response = await client.GetAsync($"{httpEndpoint}/api/v1/tables");
-
- Assert.That(response.IsSuccessStatusCode, "Tables endpoint should succeed");
-
- var content = await response.Content.ReadAsStringAsync();
- var json = JsonDocument.Parse(content);
- Assert.That(json.RootElement.TryGetProperty("tables", out _), "Response should contain tables property");
+ var value = await GetTableRowCountAsync(tableName);
+ Assert.That(value, Is.GreaterThan(0));
}
- private async Task VerifyTableHasDataAsync(string tableName)
+ private async Task GetTableRowCountAsync(string tableName)
{
- var httpEndpoint = _questDb!.GetHttpEndpoint();
- using var client = new HttpClient { Timeout = TimeSpan.FromSeconds(10) };
+ var httpEndpoint = _questDb!.GetHttpEndpoint();
+ using var client = new HttpClient { Timeout = TimeSpan.FromSeconds(10), };
// Retry a few times to allow for write latency
- var attempts = 0;
+ var attempts = 0;
const int maxAttempts = 10;
while (attempts < maxAttempts)
@@ -238,19 +216,19 @@ private async Task VerifyTableHasDataAsync(string tableName)
try
{
var response = await client.GetAsync(
- $"{httpEndpoint}/api/v1/query?query=select count(*) as cnt from {tableName}");
+ $"{httpEndpoint}/exec?query={tableName}");
if (response.IsSuccessStatusCode)
{
var content = await response.Content.ReadAsStringAsync();
- var json = JsonDocument.Parse(content);
- if (json.RootElement.TryGetProperty("dataset", out var dataset) &&
- dataset.TryGetProperty("count", out var count))
+ var json = JsonDocument.Parse(content);
+ if (
+ json.RootElement.TryGetProperty("count", out var count))
{
var rowCount = count.GetInt64();
if (rowCount > 0)
{
- return;
+ return rowCount;
}
}
}
@@ -265,29 +243,6 @@ private async Task VerifyTableHasDataAsync(string tableName)
}
Assert.Fail($"Table {tableName} has no data after {maxAttempts} attempts");
- }
-
- private async Task GetTableRowCountAsync(string tableName)
- {
- var httpEndpoint = _questDb!.GetHttpEndpoint();
- using var client = new HttpClient { Timeout = TimeSpan.FromSeconds(10) };
-
- var response = await client.GetAsync(
- $"{httpEndpoint}/api/v1/query?query=select count(*) as cnt from {tableName}");
-
- if (!response.IsSuccessStatusCode)
- {
- return 0;
- }
-
- var content = await response.Content.ReadAsStringAsync();
- var json = JsonDocument.Parse(content);
- if (json.RootElement.TryGetProperty("dataset", out var dataset) &&
- dataset.TryGetProperty("count", out var count))
- {
- return count.GetInt64();
- }
-
return 0;
}
-}
+}
\ No newline at end of file
diff --git a/src/net-questdb-client-tests/QuestDbManager.cs b/src/net-questdb-client-tests/QuestDbManager.cs
index c9a7826..1e6c149 100644
--- a/src/net-questdb-client-tests/QuestDbManager.cs
+++ b/src/net-questdb-client-tests/QuestDbManager.cs
@@ -1,29 +1,27 @@
using System;
using System.Diagnostics;
using System.IO;
-using System.IO.Compression;
using System.Net.Http;
-using System.Runtime.InteropServices;
-using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
namespace QuestDB.Client.Tests;
///
-/// Manages QuestDB server lifecycle for integration tests.
-/// Handles downloading, starting, and stopping QuestDB instances.
+/// Manages QuestDB server lifecycle for integration tests using Docker.
+/// Handles pulling, starting, and stopping QuestDB container instances.
///
public class QuestDbManager : IAsyncDisposable
{
- private readonly string _projectRoot;
- private readonly string _questdbDir;
+ private const string DockerImage = "questdb/questdb:latest";
+ private const string ContainerNamePrefix = "questdb-test-";
+
private readonly int _port;
private readonly int _httpPort;
- private Process? _process;
+ private string? _containerId;
private readonly HttpClient _httpClient;
+ private readonly string _containerName;
- public string QuestDbPath { get; private set; } = string.Empty;
public bool IsRunning { get; private set; }
///
@@ -35,42 +33,70 @@ public QuestDbManager(int port = 9009, int httpPort = 9000)
{
_port = port;
_httpPort = httpPort;
- _projectRoot = FindProjectRoot();
- _questdbDir = Path.Combine(_projectRoot, ".questdb");
+ _containerName = $"{ContainerNamePrefix}{port}-{httpPort}-{Guid.NewGuid().ToString().Substring(0, 8)}";
_httpClient = new HttpClient { Timeout = TimeSpan.FromSeconds(5) };
}
///
- /// Downloads QuestDB binary if not already present.
+ /// Ensures Docker is available.
///
- public async Task EnsureDownloadedAsync()
+ public async Task EnsureDockerAvailableAsync()
{
- if (IsQuestDbDownloaded())
+ try
{
- QuestDbPath = GetLatestQuestDbPath();
- return;
+ var (exitCode, output) = await RunDockerCommandAsync("--version");
+ if (exitCode != 0)
+ {
+ throw new InvalidOperationException("Docker is not available or not working properly");
+ }
+ Console.WriteLine($"Docker is available: {output.Trim()}");
}
+ catch (Exception ex)
+ {
+ throw new InvalidOperationException(
+ "Docker is required to run integration tests. " +
+ "Please install Docker from https://docs.docker.com/get-docker/",
+ ex);
+ }
+ }
- var platform = DetectPlatform();
- var version = await GetLatestVersionAsync();
-
- Console.WriteLine($"Platform: {platform}");
- Console.WriteLine($"Latest version: {version}");
- Console.WriteLine("Downloading QuestDB...");
-
- await DownloadAndExtractAsync(platform, version);
+ ///
+ /// Ensures QuestDB Docker image is available (uses local if exists, otherwise pulls latest).
+ ///
+ public async Task PullImageAsync()
+ {
+ // Check if image already exists locally
+ if (await ImageExistsAsync())
+ {
+ Console.WriteLine($"Docker image already exists locally: {DockerImage}");
+ return;
+ }
- QuestDbPath = GetLatestQuestDbPath();
- if (!Directory.Exists(QuestDbPath))
+ Console.WriteLine($"Pulling Docker image {DockerImage}...");
+ var (exitCode, output) = await RunDockerCommandAsync($"pull {DockerImage}");
+ if (exitCode != 0)
{
- throw new InvalidOperationException($"QuestDB path does not exist: {QuestDbPath}");
+ throw new InvalidOperationException($"Failed to pull Docker image: {output}");
}
+ Console.WriteLine("Docker image pulled successfully");
+ }
+
+ ///
+ /// Checks if the QuestDB Docker image exists locally.
+ ///
+ private async Task ImageExistsAsync()
+ {
+ // Use 'docker images' to check if image exists
+ // Format: docker images --filter "reference=questdb/questdb:latest" --quiet
+ var (exitCode, output) = await RunDockerCommandAsync($"images --filter \"reference={DockerImage}\" --quiet");
- Console.WriteLine($"QuestDB extracted to: {QuestDbPath}");
+ // If the image exists, output will contain the image ID
+ // If it doesn't exist, output will be empty
+ return exitCode == 0 && !string.IsNullOrWhiteSpace(output);
}
///
- /// Starts the QuestDB server.
+ /// Starts the QuestDB container.
///
public async Task StartAsync()
{
@@ -80,40 +106,34 @@ public async Task StartAsync()
return;
}
- await EnsureDownloadedAsync();
+ await EnsureDockerAvailableAsync();
- Console.WriteLine($"Starting QuestDB from {QuestDbPath}");
+ // Clean up any existing containers using these ports
+ await CleanupExistingContainersAsync();
- var questdbExe = Path.Combine(QuestDbPath, "bin", GetExecutableName("questdb"));
- if (!File.Exists(questdbExe))
- {
- throw new FileNotFoundException($"QuestDB executable not found at {questdbExe}");
- }
+ await PullImageAsync();
- var dataDir = Path.Combine(_questdbDir, "data");
- Directory.CreateDirectory(dataDir);
+ Console.WriteLine($"Starting QuestDB container: {_containerName}");
+ Console.WriteLine($"HTTP port: {_httpPort}, ILP port: {_port}");
- var startInfo = new ProcessStartInfo
- {
- FileName = questdbExe,
- Arguments = $"-d \"{dataDir}\"",
- UseShellExecute = false,
- CreateNoWindow = true,
- RedirectStandardOutput = true,
- RedirectStandardError = true,
- EnvironmentVariables =
- {
- { "QDB_ROOT", dataDir }
- }
- };
+ // Run container with port mappings
+ // -d: detached mode
+ // -p: port mappings
+ // --name: container name
+ var runArgs = $"run -d " +
+ $"-p {_httpPort}:9000 " +
+ $"-p {_port}:9009 " +
+ $"--name {_containerName} " +
+ DockerImage;
- _process = Process.Start(startInfo);
- if (_process == null)
+ var (exitCode, output) = await RunDockerCommandAsync(runArgs);
+ if (exitCode != 0)
{
- throw new InvalidOperationException("Failed to start QuestDB process");
+ throw new InvalidOperationException($"Failed to start QuestDB container: {output}");
}
- Console.WriteLine($"QuestDB started with PID {_process.Id}");
+ _containerId = output.Trim();
+ Console.WriteLine($"QuestDB container started: {_containerId}");
IsRunning = true;
// Wait for QuestDB to be ready
@@ -121,31 +141,29 @@ public async Task StartAsync()
}
///
- /// Stops the QuestDB server.
+ /// Stops the QuestDB container.
///
public async Task StopAsync()
{
- if (!IsRunning || _process == null)
+ if (!IsRunning || string.IsNullOrEmpty(_containerId))
{
return;
}
- Console.WriteLine($"Stopping QuestDB (PID: {_process.Id})");
+ Console.WriteLine($"Stopping QuestDB container: {_containerName}");
- try
+ // Stop the container (with 10 second timeout)
+ var (exitCode, output) = await RunDockerCommandAsync($"stop -t 10 {_containerName}");
+ if (exitCode != 0)
{
- _process.Kill();
- await _process.WaitForExitAsync().ConfigureAwait(false);
- }
- catch (InvalidOperationException)
- {
- // Process already exited
+ Console.WriteLine($"Warning: Failed to stop container gracefully: {output}");
+ // Try force remove
+ await RunDockerCommandAsync($"rm -f {_containerName}");
}
- _process?.Dispose();
- _process = null;
IsRunning = false;
- Console.WriteLine("QuestDB stopped");
+ _containerId = null;
+ Console.WriteLine("QuestDB container stopped");
}
///
@@ -170,7 +188,7 @@ private async Task WaitForQuestDbAsync()
{
try
{
- var response = await _httpClient.GetAsync($"{GetHttpEndpoint()}/api/v1/health");
+ var response = await _httpClient.GetAsync($"{GetHttpEndpoint()}/settings");
if (response.IsSuccessStatusCode)
{
Console.WriteLine("QuestDB is ready");
@@ -196,159 +214,45 @@ public async ValueTask DisposeAsync()
{
await StopAsync();
_httpClient?.Dispose();
- _process?.Dispose();
}
- private bool IsQuestDbDownloaded()
+ private async Task CleanupExistingContainersAsync()
{
- if (!Directory.Exists(_questdbDir))
- {
- return false;
- }
-
- var questdbDirs = Directory.GetDirectories(_questdbDir, "questdb-*");
- return questdbDirs.Length > 0;
- }
+ Console.WriteLine($"Checking for existing containers on ports {_httpPort}/{_port}...");
- private string GetLatestQuestDbPath()
- {
- var questdbDirs = Directory.GetDirectories(_questdbDir, "questdb-*");
- if (questdbDirs.Length == 0)
+ // Get list of all containers (running and stopped)
+ var (exitCode, output) = await RunDockerCommandAsync("ps -a --format \"{{.Names}}\"");
+ if (exitCode != 0)
{
- throw new InvalidOperationException("No QuestDB installation found");
+ return; // Silently ignore errors listing containers
}
- // Return the most recently modified directory
- var latest = questdbDirs[0];
- var latestTime = Directory.GetCreationTime(latest);
+ var containerNames = output.Split('\n', StringSplitOptions.RemoveEmptyEntries);
- foreach (var dir in questdbDirs)
+ // Stop and remove any QuestDB test containers
+ foreach (var name in containerNames)
{
- var time = Directory.GetCreationTime(dir);
- if (time > latestTime)
+ // Look for containers with matching port pattern: questdb-test-{port}-{httpPort}-*
+ if (name.Contains($"questdb-test-") &&
+ (name.Contains($"-{_port}-{_httpPort}-") || name.Contains($"-{_httpPort}-{_port}-")))
{
- latest = dir;
- latestTime = time;
- }
- }
+ Console.WriteLine($"Cleaning up existing container: {name}");
- return latest;
- }
+ // Stop the container
+ await RunDockerCommandAsync($"stop -t 5 {name}");
- private string FindProjectRoot()
- {
- var current = Directory.GetCurrentDirectory();
- while (current != null)
- {
- if (File.Exists(Path.Combine(current, "net-questdb-client.sln")))
- {
- return current;
+ // Remove the container
+ await RunDockerCommandAsync($"rm {name}");
}
-
- current = Directory.GetParent(current)?.FullName;
}
-
- throw new InvalidOperationException("Could not find project root (net-questdb-client.sln)");
}
- private string DetectPlatform()
+ private async Task<(int ExitCode, string Output)> RunDockerCommandAsync(string arguments)
{
- var os = OperatingSystem.IsWindows() ? "windows" :
- OperatingSystem.IsMacOS() ? "macos" :
- OperatingSystem.IsLinux() ? "linux" :
- throw new NotSupportedException($"Unsupported OS: {RuntimeInformation.OSDescription}");
-
- var architecture = RuntimeInformation.ProcessArchitecture switch
- {
- System.Runtime.InteropServices.Architecture.X64 => "amd64",
- System.Runtime.InteropServices.Architecture.Arm64 => "arm64",
- _ => throw new NotSupportedException($"Unsupported architecture: {RuntimeInformation.ProcessArchitecture}")
- };
-
- return $"{os}-{architecture}";
- }
-
- private async Task GetLatestVersionAsync()
- {
- var latestUrl = "https://api.github.com/repos/questdb/questdb/releases/latest";
- var response = await _httpClient.GetAsync(latestUrl);
-
- if (!response.IsSuccessStatusCode)
- {
- throw new InvalidOperationException($"Failed to fetch latest QuestDB version from {latestUrl}");
- }
-
- var content = await response.Content.ReadAsStringAsync();
- var json = JsonDocument.Parse(content);
- var tagName = json.RootElement.GetProperty("tag_name").GetString();
-
- if (string.IsNullOrEmpty(tagName))
- {
- throw new InvalidOperationException("Could not parse version from GitHub API response");
- }
-
- // Remove 'v' prefix if present
- return tagName.StartsWith("v") ? tagName.Substring(1) : tagName;
- }
-
- private async Task DownloadAndExtractAsync(string platform, string version)
- {
- var downloadUrl = $"https://github.com/questdb/questdb/releases/download/v{version}/questdb-{version}-{platform}.tar.gz";
- var tarFile = Path.Combine(_questdbDir, $"questdb-{version}-{platform}.tar.gz");
- var extractDir = Path.Combine(_questdbDir, $"questdb-{version}");
-
- // Check if already extracted
- if (Directory.Exists(extractDir))
- {
- Console.WriteLine($"QuestDB {version} already extracted");
- return;
- }
-
- try
- {
- // Create directory
- Directory.CreateDirectory(_questdbDir);
-
- // Download
- Console.WriteLine($"Downloading from {downloadUrl}...");
- var response = await _httpClient.GetAsync(downloadUrl);
- if (!response.IsSuccessStatusCode)
- {
- throw new InvalidOperationException(
- $"Failed to download QuestDB from {downloadUrl}: HTTP {response.StatusCode}");
- }
-
- await using var downloadStream = await response.Content.ReadAsStreamAsync();
- await using var fileStream = File.Create(tarFile);
- await downloadStream.CopyToAsync(fileStream);
-
- // Extract using tar command (available on Unix and Windows 10+)
- Console.WriteLine("Extracting archive...");
- await ExtractTarGzAsync(tarFile, _questdbDir);
-
- // Make binaries executable
- if (!OperatingSystem.IsWindows())
- {
- MakeExecutable(Path.Combine(extractDir, "bin"));
- }
- }
- finally
- {
- // Clean up tar file
- if (File.Exists(tarFile))
- {
- File.Delete(tarFile);
- }
- }
- }
-
- private async Task ExtractTarGzAsync(string tarGzFile, string extractPath)
- {
- // Use system tar command for reliable extraction across platforms
var startInfo = new ProcessStartInfo
{
- FileName = OperatingSystem.IsWindows() ? "tar" : "/usr/bin/tar",
- Arguments = $"-xzf \"{tarGzFile}\" -C \"{extractPath}\"",
+ FileName = "docker",
+ Arguments = arguments,
UseShellExecute = false,
RedirectStandardOutput = true,
RedirectStandardError = true,
@@ -358,41 +262,13 @@ private async Task ExtractTarGzAsync(string tarGzFile, string extractPath)
var process = Process.Start(startInfo);
if (process == null)
{
- throw new InvalidOperationException("Failed to start tar extraction process");
+ throw new InvalidOperationException("Failed to start docker command");
}
+ var output = await process.StandardOutput.ReadToEndAsync();
var error = await process.StandardError.ReadToEndAsync();
await process.WaitForExitAsync();
- if (process.ExitCode != 0)
- {
- throw new InvalidOperationException($"tar extraction failed: {error}");
- }
- }
-
- private void MakeExecutable(string directoryPath)
- {
- if (!Directory.Exists(directoryPath))
- {
- return;
- }
-
- var startInfo = new ProcessStartInfo
- {
- FileName = "/bin/chmod",
- Arguments = $"+x \"{directoryPath}\"/*",
- UseShellExecute = false,
- RedirectStandardOutput = true,
- RedirectStandardError = true,
- CreateNoWindow = true
- };
-
- var process = Process.Start(startInfo);
- process?.WaitForExit();
- }
-
- private static string GetExecutableName(string baseName)
- {
- return OperatingSystem.IsWindows() ? $"{baseName}.exe" : baseName;
+ return (process.ExitCode, output + error);
}
}
diff --git a/src/net-questdb-client/Senders/HttpSender.cs b/src/net-questdb-client/Senders/HttpSender.cs
index 9e14668..11f6727 100644
--- a/src/net-questdb-client/Senders/HttpSender.cs
+++ b/src/net-questdb-client/Senders/HttpSender.cs
@@ -247,7 +247,7 @@ private HttpClient CreateClientForAddress(string address)
};
}
- var host = AddressProvider.ParseHost(address);
+ var host = AddressProvider.ParseHost(address).Split("//")[1];
var uri = new UriBuilder(Options.protocol.ToString(), host, port);
client.BaseAddress = uri.Uri;
client.Timeout = Timeout.InfiniteTimeSpan;
From fa23f757aca3c3e1e8462307070c53ef97cfda39 Mon Sep 17 00:00:00 2001
From: Nick Woolmer <29717167+nwoolmer@users.noreply.github.com>
Date: Tue, 2 Dec 2025 15:07:32 +0000
Subject: [PATCH 09/40] tests
---
.../QuestDbIntegrationTests.cs | 143 ++++++++++++++++++
.../QuestDbManager.cs | 15 ++
2 files changed, 158 insertions(+)
diff --git a/src/net-questdb-client-tests/QuestDbIntegrationTests.cs b/src/net-questdb-client-tests/QuestDbIntegrationTests.cs
index e12512a..52c39a8 100644
--- a/src/net-questdb-client-tests/QuestDbIntegrationTests.cs
+++ b/src/net-questdb-client-tests/QuestDbIntegrationTests.cs
@@ -195,6 +195,149 @@ await sender
// Verify data was written
await VerifyTableHasDataAsync("test_auto_flush");
}
+
+ [Test]
+ public async Task SendRowsWhileRestartingDatabase()
+ {
+ const int rowsPerBatch = 10;
+ const int numBatches = 5;
+ const int expectedTotalRows = rowsPerBatch * numBatches;
+
+ // Create a persistent Docker volume for the test database
+ var volumeName = $"questdb-test-vol-{Guid.NewGuid().ToString().Substring(0, 8)}";
+
+ // Use a separate QuestDB instance for this chaos test to avoid conflicts
+ var testDb = new QuestDbManager(port: 29009, httpPort: 29000);
+ testDb.SetVolume(volumeName);
+ try
+ {
+ await testDb.StartAsync();
+
+ var httpEndpoint = testDb.GetHttpEndpoint();
+ using var sender = Sender.New(
+ $"http::addr={httpEndpoint};auto_flush=off;retry_timeout=60000;");
+
+ var batchesSent = 0;
+ var sendLock = new object();
+
+ // Task that restarts the database
+ var restartTask = Task.Run(async () =>
+ {
+ // Allow first batch to be sent while database is up
+ await Task.Delay(600);
+
+ // Perform restart cycles
+ for (var i = 0; i < 2; i++)
+ {
+ TestContext.WriteLine($"Stopping test database (cycle {i + 1})");
+ await testDb.StopAsync();
+
+ // Database is down - sender will retry
+ await Task.Delay(1200);
+
+ TestContext.WriteLine($"Starting test database (cycle {i + 1})");
+ await testDb.StartAsync();
+
+ // Wait for client to detect database is back up
+ await Task.Delay(800);
+ }
+
+ TestContext.WriteLine("Test database restart cycles complete");
+ });
+
+ // Task that sends rows continuously
+ var sendTask = Task.Run(async () =>
+ {
+ for (var batch = 0; batch < numBatches; batch++)
+ {
+ try
+ {
+ // Build batch of rows
+ for (var i = 0; i < rowsPerBatch; i++)
+ {
+ var rowId = batch * rowsPerBatch + i;
+ await sender
+ .Table("test_chaos")
+ .Symbol("batch", $"batch_{batch}")
+ .Column("row_id", (long)rowId)
+ .Column("value", (double)(rowId * 100))
+ .AtAsync(DateTime.UtcNow);
+ }
+
+ // Send the batch
+ TestContext.WriteLine($"Sending batch {batch}");
+ await sender.SendAsync();
+
+ lock (sendLock)
+ {
+ batchesSent++;
+ }
+ TestContext.WriteLine($"Batch {batch} sent successfully");
+
+ // Wait before next batch
+ await Task.Delay(500);
+ }
+ catch (Exception ex)
+ {
+ TestContext.WriteLine($"Error sending batch {batch}: {ex.GetType().Name} - {ex.Message}");
+ throw;
+ }
+ }
+
+ TestContext.WriteLine($"All batches sent. Total: {batchesSent}");
+ });
+
+ // Wait for both tasks to complete
+ await Task.WhenAll(sendTask, restartTask);
+
+ // Wait for final data to be written
+ await Task.Delay(2000);
+
+ // Query the row count, with retries
+ long actualRowCount = 0;
+ var maxAttempts = 20;
+ for (var attempt = 0; attempt < maxAttempts; attempt++)
+ {
+ try
+ {
+ using var client = new HttpClient { Timeout = TimeSpan.FromSeconds(5) };
+ var response = await client.GetAsync($"{httpEndpoint}/exec?query=test_chaos");
+
+ if (response.IsSuccessStatusCode)
+ {
+ var content = await response.Content.ReadAsStringAsync();
+ var json = JsonDocument.Parse(content);
+ if (json.RootElement.TryGetProperty("count", out var countProp))
+ {
+ actualRowCount = countProp.GetInt64();
+ TestContext.WriteLine($"Attempt {attempt + 1}: Found {actualRowCount} rows");
+ if (actualRowCount >= expectedTotalRows)
+ break;
+ }
+ }
+ }
+ catch (Exception ex)
+ {
+ TestContext.WriteLine($"Attempt {attempt + 1}: Query failed - {ex.Message}");
+ }
+
+ await Task.Delay(500);
+ }
+
+ // Assert that all rows made it
+ Assert.That(
+ actualRowCount,
+ Is.GreaterThanOrEqualTo(expectedTotalRows),
+ $"Expected {expectedTotalRows} rows but found {actualRowCount}. " +
+ $"Successfully sent {batchesSent} batches of {rowsPerBatch} rows each");
+ }
+ finally
+ {
+ // Cleanup
+ await testDb.StopAsync();
+ await testDb.DisposeAsync();
+ }
+ }
private async Task VerifyTableHasDataAsync(string tableName)
{
diff --git a/src/net-questdb-client-tests/QuestDbManager.cs b/src/net-questdb-client-tests/QuestDbManager.cs
index 1e6c149..7eb1639 100644
--- a/src/net-questdb-client-tests/QuestDbManager.cs
+++ b/src/net-questdb-client-tests/QuestDbManager.cs
@@ -21,6 +21,7 @@ public class QuestDbManager : IAsyncDisposable
private string? _containerId;
private readonly HttpClient _httpClient;
private readonly string _containerName;
+ private string? _volumeName;
public bool IsRunning { get; private set; }
@@ -37,6 +38,14 @@ public QuestDbManager(int port = 9009, int httpPort = 9000)
_httpClient = new HttpClient { Timeout = TimeSpan.FromSeconds(5) };
}
+ ///
+ /// Sets a Docker volume to be used for persistent storage.
+ ///
+ public void SetVolume(string volumeName)
+ {
+ _volumeName = volumeName;
+ }
+
///
/// Ensures Docker is available.
///
@@ -120,10 +129,16 @@ public async Task StartAsync()
// -d: detached mode
// -p: port mappings
// --name: container name
+ // -v: volume mount (if specified)
+ var volumeArg = string.IsNullOrEmpty(_volumeName)
+ ? string.Empty
+ : $"-v {_volumeName}:/var/lib/questdb ";
+
var runArgs = $"run -d " +
$"-p {_httpPort}:9000 " +
$"-p {_port}:9009 " +
$"--name {_containerName} " +
+ volumeArg +
DockerImage;
var (exitCode, output) = await RunDockerCommandAsync(runArgs);
From c22783e933ffd226e653a98a0121509e328b88a7 Mon Sep 17 00:00:00 2001
From: Nick Woolmer <29717167+nwoolmer@users.noreply.github.com>
Date: Tue, 2 Dec 2025 15:50:33 +0000
Subject: [PATCH 10/40] tests
---
.../QuestDbIntegrationTests.cs | 190 ++++++++++++++++++
1 file changed, 190 insertions(+)
diff --git a/src/net-questdb-client-tests/QuestDbIntegrationTests.cs b/src/net-questdb-client-tests/QuestDbIntegrationTests.cs
index 52c39a8..5ce531d 100644
--- a/src/net-questdb-client-tests/QuestDbIntegrationTests.cs
+++ b/src/net-questdb-client-tests/QuestDbIntegrationTests.cs
@@ -338,6 +338,196 @@ await sender
await testDb.DisposeAsync();
}
}
+
+ [Test]
+ public async Task SendRowsWithMultiDatabaseFailover()
+ {
+ const int rowsPerBatch = 10;
+ const int numBatches = 5;
+ const int expectedTotalRows = rowsPerBatch * numBatches;
+
+ // Create two separate databases with persistent volumes
+ var volume1 = $"questdb-test-vol-db1-{Guid.NewGuid().ToString().Substring(0, 8)}";
+ var volume2 = $"questdb-test-vol-db2-{Guid.NewGuid().ToString().Substring(0, 8)}";
+
+ var testDb1 = new QuestDbManager(port: 29009, httpPort: 29000);
+ var testDb2 = new QuestDbManager(port: 29019, httpPort: 29010);
+ testDb1.SetVolume(volume1);
+ testDb2.SetVolume(volume2);
+
+ try
+ {
+ // Start both databases
+ await testDb1.StartAsync();
+ await testDb2.StartAsync();
+
+ var endpoint1 = testDb1.GetHttpEndpoint();
+ var endpoint2 = testDb2.GetHttpEndpoint();
+
+ // Create a single sender with both endpoints for failover
+ using var sender = Sender.New(
+ $"http::addr={endpoint1};addr={endpoint2};auto_flush=off;retry_timeout=60000;");
+
+ var batchesSent = 0;
+ var sendLock = new object();
+
+ // Task that restarts DB1 after sends complete
+ var restartDb1Task = Task.Run(async () =>
+ {
+ // Wait for all sends to complete (5 batches * 500ms + 100ms buffer)
+ await Task.Delay(2600);
+
+ TestContext.WriteLine("Stopping database 1");
+ await testDb1.StopAsync();
+ await Task.Delay(1000);
+
+ TestContext.WriteLine("Starting database 1");
+ await testDb1.StartAsync();
+
+ TestContext.WriteLine("Database 1 restart complete");
+ });
+
+ // Task that restarts DB2 after DB1 restart completes
+ var restartDb2Task = Task.Run(async () =>
+ {
+ // Wait for DB1 restart to complete before restarting DB2
+ await Task.Delay(4000);
+
+ TestContext.WriteLine("Stopping database 2");
+ await testDb2.StopAsync();
+ await Task.Delay(1000);
+
+ TestContext.WriteLine("Starting database 2");
+ await testDb2.StartAsync();
+
+ TestContext.WriteLine("Database 2 restart complete");
+ });
+
+ // Task that sends rows to both databases via multi-address sender
+ var sendTask = Task.Run(async () =>
+ {
+ for (var batch = 0; batch < numBatches; batch++)
+ {
+ try
+ {
+ // Build batch of rows
+ for (var i = 0; i < rowsPerBatch; i++)
+ {
+ var rowId = batch * rowsPerBatch + i;
+ await sender
+ .Table("test_multi_db")
+ .Symbol("batch", $"batch_{batch}")
+ .Column("row_id", (long)rowId)
+ .Column("value", (double)(rowId * 100))
+ .AtAsync(DateTime.UtcNow);
+ }
+
+ TestContext.WriteLine($"Sending batch {batch}");
+ await sender.SendAsync();
+
+ lock (sendLock)
+ {
+ batchesSent++;
+ }
+ TestContext.WriteLine($"Batch {batch} sent successfully");
+
+ await Task.Delay(500);
+ }
+ catch (Exception ex)
+ {
+ TestContext.WriteLine($"Error sending batch {batch}: {ex.GetType().Name} - {ex.Message}");
+ throw;
+ }
+ }
+
+ TestContext.WriteLine($"All batches sent. Total: {batchesSent}");
+ });
+
+ // Wait for all tasks
+ await Task.WhenAll(sendTask, restartDb1Task, restartDb2Task);
+ await Task.Delay(2000);
+
+ // Query both databases and sum the row counts
+ var maxAttempts = 20;
+ long count1 = 0;
+ long count2 = 0;
+
+ for (var attempt = 0; attempt < maxAttempts; attempt++)
+ {
+ try
+ {
+ using var client = new HttpClient { Timeout = TimeSpan.FromSeconds(5) };
+
+ // Query database 1
+ try
+ {
+ var response1 = await client.GetAsync($"{endpoint1}/exec?query=test_multi_db");
+ if (response1.IsSuccessStatusCode)
+ {
+ var content1 = await response1.Content.ReadAsStringAsync();
+ var json1 = JsonDocument.Parse(content1);
+ if (json1.RootElement.TryGetProperty("count", out var countProp1))
+ {
+ count1 = countProp1.GetInt64();
+ }
+ }
+ }
+ catch (Exception ex)
+ {
+ TestContext.WriteLine($"Attempt {attempt + 1}: Query DB1 failed - {ex.Message}");
+ }
+
+ // Query database 2
+ try
+ {
+ var response2 = await client.GetAsync($"{endpoint2}/exec?query=test_multi_db");
+ if (response2.IsSuccessStatusCode)
+ {
+ var content2 = await response2.Content.ReadAsStringAsync();
+ var json2 = JsonDocument.Parse(content2);
+ if (json2.RootElement.TryGetProperty("count", out var countProp2))
+ {
+ count2 = countProp2.GetInt64();
+ }
+ }
+ }
+ catch (Exception ex)
+ {
+ TestContext.WriteLine($"Attempt {attempt + 1}: Query DB2 failed - {ex.Message}");
+ }
+
+ var totalRowCount = count1 + count2;
+ TestContext.WriteLine($"Attempt {attempt + 1}: DB1={count1}, DB2={count2}, Total={totalRowCount}");
+
+ if (totalRowCount >= expectedTotalRows)
+ break;
+ }
+ catch (Exception ex)
+ {
+ TestContext.WriteLine($"Attempt {attempt + 1}: Error - {ex.Message}");
+ }
+
+ await Task.Delay(500);
+ }
+
+ var totalRowCount2 = count1 + count2;
+
+ // Assert that the sum of both databases equals expected total
+ Assert.That(
+ totalRowCount2,
+ Is.EqualTo(expectedTotalRows),
+ $"Expected {expectedTotalRows} total rows across both databases but found {totalRowCount2}. " +
+ $"Successfully sent {batchesSent} batches of {rowsPerBatch} rows each");
+ }
+ finally
+ {
+ // Cleanup
+ await testDb1.StopAsync();
+ await testDb2.StopAsync();
+ await testDb1.DisposeAsync();
+ await testDb2.DisposeAsync();
+ }
+ }
private async Task VerifyTableHasDataAsync(string tableName)
{
From bd5fceaff70a1a8ea24782270110bafbbc1aa9a8 Mon Sep 17 00:00:00 2001
From: Nick Woolmer <29717167+nwoolmer@users.noreply.github.com>
Date: Tue, 2 Dec 2025 16:28:23 +0000
Subject: [PATCH 11/40] fixes
---
src/net-questdb-client-tests/HttpTests.cs | 529 ++++++++++---------
src/net-questdb-client/Senders/HttpSender.cs | 206 +++++---
2 files changed, 394 insertions(+), 341 deletions(-)
diff --git a/src/net-questdb-client-tests/HttpTests.cs b/src/net-questdb-client-tests/HttpTests.cs
index b2e14c2..f14539b 100644
--- a/src/net-questdb-client-tests/HttpTests.cs
+++ b/src/net-questdb-client-tests/HttpTests.cs
@@ -47,33 +47,33 @@ public async Task BasicArrayDouble()
Sender.New(
$"http::addr={Host}:{HttpPort};username=asdasdada;password=asdadad;tls_verify=unsafe_off;auto_flush=off;");
await sender.Table("metrics")
- .Symbol("tag", "value")
- .Column("number", 10)
- .Column("string", "abc")
- .Column("array", new[]
- {
- 1.2, 2.6,
- 3.1,
- })
- .AtAsync(new DateTime(1970, 01, 01, 0, 0, 1));
+ .Symbol("tag", "value")
+ .Column("number", 10)
+ .Column("string", "abc")
+ .Column("array", new[]
+ {
+ 1.2, 2.6,
+ 3.1,
+ })
+ .AtAsync(new DateTime(1970, 01, 01, 0, 0, 1));
await sender.Table("metrics")
- .Symbol("tag", "value")
- .Column("number", 10)
- .Column("string", "abc")
- .Column("array", (ReadOnlySpan)new[]
- {
- 1.5, 2.1,
- 3.1,
- })
- .AtAsync(new DateTime(1970, 01, 01, 0, 0, 2));
+ .Symbol("tag", "value")
+ .Column("number", 10)
+ .Column("string", "abc")
+ .Column("array", (ReadOnlySpan)new[]
+ {
+ 1.5, 2.1,
+ 3.1,
+ })
+ .AtAsync(new DateTime(1970, 01, 01, 0, 0, 2));
await sender.Table("metrics")
- .Symbol("tag", "value")
- .Column("number", 10)
- .Column("string", "abc")
- .Column("array", (ReadOnlySpan)Array.Empty())
- .AtAsync(new DateTime(1970, 01, 01, 0, 0, 3));
+ .Symbol("tag", "value")
+ .Column("number", 10)
+ .Column("string", "abc")
+ .Column("array", (ReadOnlySpan)Array.Empty())
+ .AtAsync(new DateTime(1970, 01, 01, 0, 0, 3));
await sender.SendAsync();
Assert.That(
@@ -98,13 +98,13 @@ await server.StartAsync(HttpPort, new[]
$"http::addr={Host}:{HttpPort};protocol_version=3;tls_verify=unsafe_off;auto_flush=off;");
await sender.Table("metrics")
- .Symbol("tag", "value")
- .Column("dec_pos", 123.45m)
- .Column("dec_neg", -123.45m)
- .Column("dec_null", (decimal?)null)
- .Column("dec_max", decimal.MaxValue)
- .Column("dec_min", decimal.MinValue)
- .AtAsync(new DateTime(1970, 01, 01, 0, 0, 1));
+ .Symbol("tag", "value")
+ .Column("dec_pos", 123.45m)
+ .Column("dec_neg", -123.45m)
+ .Column("dec_null", (decimal?)null)
+ .Column("dec_max", decimal.MaxValue)
+ .Column("dec_min", decimal.MinValue)
+ .AtAsync(new DateTime(1970, 01, 01, 0, 0, 1));
await sender.SendAsync();
@@ -147,12 +147,12 @@ public async Task SendLongArrayAsSpan()
$"http::addr={Host}:{HttpPort};init_buf_size=256;username=asdasdada;password=asdadad;tls_verify=unsafe_off;auto_flush=off;");
sender.Table("metrics")
- .Symbol("tag", "value")
- .Column("number", 10)
- .Column("string", "abc");
+ .Symbol("tag", "value")
+ .Column("number", 10)
+ .Column("string", "abc");
- var arrayLen = (1024 - sender.Length) / 8 + 1;
- var aray = new double[arrayLen];
+ var arrayLen = (1024 - sender.Length) / 8 + 1;
+ var aray = new double[arrayLen];
var expectedArray = new StringBuilder();
for (var i = 0; i < arrayLen; i++)
{
@@ -166,7 +166,7 @@ public async Task SendLongArrayAsSpan()
}
await sender.Column("array", (ReadOnlySpan)aray.AsSpan())
- .AtAsync(new DateTime(1970, 01, 01, 0, 0, 1));
+ .AtAsync(new DateTime(1970, 01, 01, 0, 0, 1));
await sender.SendAsync();
Assert.That(
@@ -190,9 +190,9 @@ await server.StartAsync(HttpPort, new[]
$"http::addr={Host}:{HttpPort};username=asdasdada;password=asdadad;tls_verify=unsafe_off;auto_flush=off;");
sender.Table("metrics")
- .Symbol("tag", "value")
- .Column("number", 10)
- .Column("string", "abc");
+ .Symbol("tag", "value")
+ .Column("number", 10)
+ .Column("string", "abc");
Assert.That(
() => sender.Column("array", new[]
@@ -215,9 +215,9 @@ await server.StartAsync(HttpPort, new[]
$"http::addr={Host}:{HttpPort};username=asdasdada;password=asdadad;tls_verify=unsafe_off;auto_flush=off;");
sender.Table("metrics")
- .Symbol("tag", "value")
- .Column("number", 10)
- .Column("string", "abc");
+ .Symbol("tag", "value")
+ .Column("number", 10)
+ .Column("string", "abc");
Assert.That(
() => sender.Column("array", new[]
@@ -249,15 +249,15 @@ public async Task ArrayNegotiationConnectionIsRetried()
await delayedStart;
await sender.Table("metrics")
- .Symbol("tag", "value")
- .Column("number", 10)
- .Column("string", "abc")
- .Column("array", new[]
- {
- 1.2, 2.6,
- 3.1,
- })
- .AtAsync(new DateTime(1970, 01, 01, 0, 0, 1));
+ .Symbol("tag", "value")
+ .Column("number", 10)
+ .Column("string", "abc")
+ .Column("array", new[]
+ {
+ 1.2, 2.6,
+ 3.1,
+ })
+ .AtAsync(new DateTime(1970, 01, 01, 0, 0, 1));
await sender.SendAsync();
@@ -279,9 +279,9 @@ public async Task BasicBinaryDouble()
Sender.New(
$"http::addr={Host}:{HttpPort};username=asdasdada;password=asdadad;tls_verify=unsafe_off;auto_flush=off;");
await sender.Table("metrics")
- .Symbol("tag", "value")
- .Column("number", 12.2)
- .AtAsync(new DateTime(1970, 01, 01, 0, 0, 1));
+ .Symbol("tag", "value")
+ .Column("number", 12.2)
+ .AtAsync(new DateTime(1970, 01, 01, 0, 0, 1));
await sender.SendAsync();
Assert.That(
@@ -299,18 +299,18 @@ public async Task BasicShapedEnumerableDouble()
Sender.New(
$"http::addr={Host}:{HttpPort};username=asdasdada;password=asdadad;tls_verify=unsafe_off;auto_flush=off;");
await sender.Table("metrics")
- .Symbol("tag", "value")
- .Column("number", 10)
- .Column("string", "abc")
- .Column("array", new[]
- {
- 1.2, 2.6,
- 3.1, 4.6,
- }.AsEnumerable(), new[]
- {
- 2, 2,
- }.AsEnumerable())
- .AtAsync(new DateTime(1970, 01, 01, 0, 0, 1));
+ .Symbol("tag", "value")
+ .Column("number", 10)
+ .Column("string", "abc")
+ .Column("array", new[]
+ {
+ 1.2, 2.6,
+ 3.1, 4.6,
+ }.AsEnumerable(), new[]
+ {
+ 2, 2,
+ }.AsEnumerable())
+ .AtAsync(new DateTime(1970, 01, 01, 0, 0, 1));
await sender.SendAsync();
Assert.That(
@@ -327,9 +327,9 @@ public void InvalidShapedEnumerableDouble()
$"http::addr={Host}:{HttpPort};username=asdasdada;password=asdadad;tls_verify=unsafe_off;auto_flush=off;protocol_version=2;");
sender.Table("metrics")
- .Symbol("tag", "value")
- .Column("number", 10)
- .Column("string", "abc");
+ .Symbol("tag", "value")
+ .Column("number", 10)
+ .Column("string", "abc");
Assert.That(
() => sender.Column("array", new[]
@@ -365,15 +365,15 @@ public async Task BasicFlatArray()
Sender.New(
$"http::addr={Host}:{HttpPort};username=asdasdada;password=asdadad;tls_verify=unsafe_off;auto_flush=off;");
await sender.Table("metrics")
- .Symbol("tag", "value")
- .Column("number", 10)
- .Column("string", "abc")
- .Column("array", new[]
- {
- 1.2, 2.6,
- 3.1, 4.6,
- })
- .AtAsync(new DateTime(1970, 01, 01, 0, 0, 1));
+ .Symbol("tag", "value")
+ .Column("number", 10)
+ .Column("string", "abc")
+ .Column("array", new[]
+ {
+ 1.2, 2.6,
+ 3.1, 4.6,
+ })
+ .AtAsync(new DateTime(1970, 01, 01, 0, 0, 1));
await sender.SendAsync();
Assert.That(
@@ -391,7 +391,9 @@ public async Task BasicMultidimensionalArrayDouble()
for (var i = 0; i < 2; i++)
for (var j = 0; j < 3; j++)
for (var k = 0; k < 3; k++)
+ {
arr[i, j, k] = (i + 1) * (j + 1) * (k + 1);
+ }
using var server = new DummyHttpServer(withBasicAuth: false);
await server.StartAsync(HttpPort);
@@ -399,11 +401,11 @@ public async Task BasicMultidimensionalArrayDouble()
Sender.New(
$"http::addr={Host}:{HttpPort};username=asdasdada;password=asdadad;tls_verify=unsafe_off;auto_flush=off;protocol_version=2;");
await sender.Table("metrics")
- .Symbol("tag", "value")
- .Column("number", 10)
- .Column("string", "abc")
- .Column("array", arr)
- .AtAsync(new DateTime(1970, 01, 01, 0, 0, 1));
+ .Symbol("tag", "value")
+ .Column("number", 10)
+ .Column("string", "abc")
+ .Column("array", arr)
+ .AtAsync(new DateTime(1970, 01, 01, 0, 0, 1));
await sender.SendAsync();
Assert.That(
@@ -421,12 +423,12 @@ public async Task AuthBasicFailed()
await server.StartAsync(HttpPort);
using var sender =
Sender.New(
- $"https::addr={Host}:{HttpsPort};username=asdasdada;password=asdadad;tls_verify=unsafe_off;auto_flush=off;");
+ $"https::addr={Host}:{HttpPort};username=asdasdada;password=asdadad;tls_verify=unsafe_off;auto_flush=off;");
await sender.Table("metrics")
- .Symbol("tag", "value")
- .Column("number", 10)
- .Column("string", "abc")
- .AtAsync(new DateTime(1970, 01, 01, 0, 0, 1));
+ .Symbol("tag", "value")
+ .Column("number", 10)
+ .Column("string", "abc")
+ .AtAsync(new DateTime(1970, 01, 01, 0, 0, 1));
Assert.That(
async () => await sender.SendAsync(),
@@ -441,12 +443,12 @@ public async Task AuthBasicSuccess()
await server.StartAsync(HttpPort);
using var sender =
Sender.New(
- $"https::addr={Host}:{HttpsPort};username=admin;password=quest;tls_verify=unsafe_off;auto_flush=off;");
+ $"https::addr={Host}:{HttpPort};username=admin;password=quest;tls_verify=unsafe_off;auto_flush=off;");
await sender.Table("metrics")
- .Symbol("tag", "value")
- .Column("number", 10)
- .Column("string", "abc")
- .AtAsync(new DateTime(1970, 01, 01, 0, 0, 1));
+ .Symbol("tag", "value")
+ .Column("number", 10)
+ .Column("string", "abc")
+ .AtAsync(new DateTime(1970, 01, 01, 0, 0, 1));
await sender.SendAsync();
}
@@ -462,11 +464,13 @@ public async Task AuthTokenFailed()
$"https::addr={Host}:{HttpsPort};token=askldaklds;tls_verify=unsafe_off;auto_flush=off;");
for (var i = 0; i < 100; i++)
+ {
await sender
- .Table("test")
- .Symbol("foo", "bah")
- .Column("num", i)
- .AtAsync(DateTime.UtcNow);
+ .Table("test")
+ .Symbol("foo", "bah")
+ .Column("num", i)
+ .AtAsync(DateTime.UtcNow);
+ }
Assert.That(
async () => await sender.SendAsync(),
@@ -487,11 +491,13 @@ public async Task AuthTokenSuccess()
$"https::addr={Host}:{HttpsPort};token={token};tls_verify=unsafe_off;auto_flush=off;");
for (var i = 0; i < 100; i++)
+ {
await sender
- .Table("test")
- .Symbol("foo", "bah")
- .Column("num", i)
- .AtAsync(DateTime.UtcNow);
+ .Table("test")
+ .Symbol("foo", "bah")
+ .Column("num", i)
+ .AtAsync(DateTime.UtcNow);
+ }
await sender.SendAsync();
}
@@ -503,10 +509,10 @@ public async Task BasicSend()
using var server = new DummyHttpServer();
await server.StartAsync(HttpPort);
var sender = Sender.New($"http::addr={Host}:{HttpPort};auto_flush=off;");
- var ts = DateTime.UtcNow;
+ var ts = DateTime.UtcNow;
await sender.Table("name")
- .Column("ts", ts)
- .AtAsync(ts);
+ .Column("ts", ts)
+ .AtAsync(ts);
await sender.SendAsync();
Console.WriteLine(server.GetReceiveBuffer().ToString());
await server.StopAsync();
@@ -520,7 +526,7 @@ public void SendBadSymbol()
{
var sender = Sender.New($"http::addr={Host}:{HttpPort};auto_flush=off;protocol_version=1;");
sender.Table("metric name")
- .Symbol("t ,a g", "v alu, e");
+ .Symbol("t ,a g", "v alu, e");
},
Throws.TypeOf().With.Message.Contains("Column names")
);
@@ -534,7 +540,7 @@ public void SendBadColumn()
{
var sender = Sender.New($"http::addr={Host}:{HttpPort};auto_flush=off;protocol_version=1;");
sender.Table("metric name")
- .Column("t a, g", "v alu e");
+ .Column("t a, g", "v alu e");
},
Throws.TypeOf().With.Message.Contains("Column names")
);
@@ -547,10 +553,10 @@ public async Task SendLine()
await server.StartAsync(HttpPort);
var sender = Sender.New($"http::addr={Host}:{HttpPort};auto_flush=off;");
await sender.Table("metrics")
- .Symbol("tag", "value")
- .Column("number", 10)
- .Column("string", "abc")
- .AtAsync(new DateTime(1970, 01, 01, 0, 0, 1));
+ .Symbol("tag", "value")
+ .Column("number", 10)
+ .Column("string", "abc")
+ .AtAsync(new DateTime(1970, 01, 01, 0, 0, 1));
await sender.SendAsync();
Assert.That(
@@ -575,12 +581,12 @@ public async Task SendLineExceedsBuffer()
for (var i = 0; i < lineCount; i++)
{
await sender.Table("table name")
- .Symbol("t a g", "v alu, e")
- .Column("number", 10)
- .Column("db l", 123.12)
- .Column("string", " -=\"")
- .Column("при вед", "медвед")
- .AtAsync(new DateTime(1970, 01, 01, 0, 0, 1));
+ .Symbol("t a g", "v alu, e")
+ .Column("number", 10)
+ .Column("db l", 123.12)
+ .Column("string", " -=\"")
+ .Column("при вед", "медвед")
+ .AtAsync(new DateTime(1970, 01, 01, 0, 0, 1));
totalExpectedSb.Append(expected);
}
@@ -599,17 +605,19 @@ public async Task SendLineExceedsBufferLimit()
Sender.New($"http::addr={Host}:{HttpPort};init_buf_size=1024;max_buf_size=2048;auto_flush=off;");
Assert.That(async () =>
- {
- for (var i = 0; i < 500; i++)
- await sender.Table("table name")
- .Symbol("t a g", "v alu, e")
- .Column("number", 10)
- .Column("db l", 123.12)
- .Column("string", " -=\"")
- .Column("при вед", "медвед")
- .AtAsync(new DateTime(1970, 01, 01, 0, 0, 1));
- },
- Throws.Exception.With.Message.Contains("maximum buffer size"));
+ {
+ for (var i = 0; i < 500; i++)
+ {
+ await sender.Table("table name")
+ .Symbol("t a g", "v alu, e")
+ .Column("number", 10)
+ .Column("db l", 123.12)
+ .Column("string", " -=\"")
+ .Column("при вед", "медвед")
+ .AtAsync(new DateTime(1970, 01, 01, 0, 0, 1));
+ }
+ },
+ Throws.Exception.With.Message.Contains("maximum buffer size"));
}
[Test]
@@ -627,12 +635,12 @@ public async Task SendLineReusesBuffer()
for (var i = 0; i < lineCount; i++)
{
await sender.Table("table name")
- .Symbol("t a g", "v alu, e")
- .Column("number", 10)
- .Column("db l", 123.12)
- .Column("string", " -=\"")
- .Column("при вед", "медвед")
- .AtAsync(new DateTime(1970, 01, 01, 0, 0, 1));
+ .Symbol("t a g", "v alu, e")
+ .Column("number", 10)
+ .Column("db l", 123.12)
+ .Column("string", " -=\"")
+ .Column("при вед", "медвед")
+ .AtAsync(new DateTime(1970, 01, 01, 0, 0, 1));
totalExpectedSb.Append(expected);
}
@@ -641,12 +649,12 @@ await sender.Table("table name")
for (var i = 0; i < lineCount; i++)
{
await sender.Table("table name")
- .Symbol("t a g", "v alu, e")
- .Column("number", 10)
- .Column("db l", 123.12)
- .Column("string", " -=\"")
- .Column("при вед", "медвед")
- .AtAsync(new DateTime(1970, 01, 01, 0, 0, 1));
+ .Symbol("t a g", "v alu, e")
+ .Column("number", 10)
+ .Column("db l", 123.12)
+ .Column("string", " -=\"")
+ .Column("при вед", "медвед")
+ .AtAsync(new DateTime(1970, 01, 01, 0, 0, 1));
totalExpectedSb.Append(expected);
}
@@ -670,12 +678,12 @@ public async Task SendLineTrimsBuffers()
for (var i = 0; i < lineCount; i++)
{
await sender.Table("table name")
- .Symbol("t a g", "v alu, e")
- .Column("number", 10)
- .Column("db l", 123.12)
- .Column("string", " -=\"")
- .Column("при вед", "медвед")
- .AtAsync(new DateTime(1970, 01, 01, 0, 0, 1));
+ .Symbol("t a g", "v alu, e")
+ .Column("number", 10)
+ .Column("db l", 123.12)
+ .Column("string", " -=\"")
+ .Column("при вед", "медвед")
+ .AtAsync(new DateTime(1970, 01, 01, 0, 0, 1));
totalExpectedSb.Append(expected);
}
@@ -685,12 +693,12 @@ await sender.Table("table name")
for (var i = 0; i < lineCount; i++)
{
await sender.Table("table name")
- .Symbol("t a g", "v alu, e")
- .Column("number", 10)
- .Column("db l", 123.12)
- .Column("string", " -=\"")
- .Column("при вед", "медвед")
- .AtAsync(new DateTime(1970, 01, 01, 0, 0, 1));
+ .Symbol("t a g", "v alu, e")
+ .Column("number", 10)
+ .Column("db l", 123.12)
+ .Column("string", " -=\"")
+ .Column("при вед", "медвед")
+ .AtAsync(new DateTime(1970, 01, 01, 0, 0, 1));
totalExpectedSb.Append(expected);
}
@@ -715,18 +723,18 @@ public async Task ServerDisconnects()
for (var i = 0; i < lineCount; i++)
{
await sender.Table("table name")
- .Symbol("t a g", "v alu, e")
- .Column("number", 10)
- .Column("db l", 123.12)
- .Column("string", " -=\"")
- .Column("при вед", "медвед")
- .AtAsync(new DateTime(1970, 01, 01, 0, 0, 1));
+ .Symbol("t a g", "v alu, e")
+ .Column("number", 10)
+ .Column("db l", 123.12)
+ .Column("string", " -=\"")
+ .Column("при вед", "медвед")
+ .AtAsync(new DateTime(1970, 01, 01, 0, 0, 1));
totalExpectedSb.Append(expected);
if (i > 1)
{
Assert.That(async () => await sender.SendAsync(),
- Throws.TypeOf());
+ Throws.TypeOf());
break;
}
@@ -747,11 +755,11 @@ public async Task SendNegativeLongAndDouble()
using var ls = Sender.New($"http::addr={Host}:{HttpPort};auto_flush=off;");
await ls.Table("neg name")
- .Column("number1", long.MinValue + 1)
- .Column("number2", long.MaxValue)
- .Column("number3", double.MinValue)
- .Column("number4", double.MaxValue)
- .AtAsync(86400000000000);
+ .Column("number1", long.MinValue + 1)
+ .Column("number2", long.MaxValue)
+ .Column("number3", double.MinValue)
+ .Column("number4", double.MaxValue)
+ .AtAsync(86400000000000);
await ls.SendAsync();
var expected =
@@ -769,15 +777,15 @@ public async Task SerialiseDoublesV2()
using var ls = Sender.New($"http::addr={Host}:{HttpPort};auto_flush=off;");
await ls.Table("doubles")
- .Column("d0", 0.0)
- .Column("dm0", -0.0)
- .Column("d1", 1.0)
- .Column("dE100", 1E100)
- .Column("d0000001", 0.000001)
- .Column("dNaN", double.NaN)
- .Column("dInf", double.PositiveInfinity)
- .Column("dNInf", double.NegativeInfinity)
- .AtAsync(86400000000000);
+ .Column("d0", 0.0)
+ .Column("dm0", -0.0)
+ .Column("d1", 1.0)
+ .Column("dE100", 1E100)
+ .Column("d0000001", 0.000001)
+ .Column("dNaN", double.NaN)
+ .Column("dInf", double.PositiveInfinity)
+ .Column("dNInf", double.NegativeInfinity)
+ .AtAsync(86400000000000);
await ls.SendAsync();
var expected =
@@ -794,15 +802,15 @@ public async Task SerialiseDoublesV1()
using var ls = Sender.New($"http::addr={Host}:{HttpPort};auto_flush=off;protocol_version=1;");
await ls.Table("doubles")
- .Column("d0", 0.0)
- .Column("dm0", -0.0)
- .Column("d1", 1.0)
- .Column("dE100", 1E100)
- .Column("d0000001", 0.000001)
- .Column("dNaN", double.NaN)
- .Column("dInf", double.PositiveInfinity)
- .Column("dNInf", double.NegativeInfinity)
- .AtAsync(86400000000000);
+ .Column("d0", 0.0)
+ .Column("dm0", -0.0)
+ .Column("d1", 1.0)
+ .Column("dE100", 1E100)
+ .Column("d0000001", 0.000001)
+ .Column("dNaN", double.NaN)
+ .Column("dInf", double.PositiveInfinity)
+ .Column("dNInf", double.NegativeInfinity)
+ .AtAsync(86400000000000);
await ls.SendAsync();
var expected =
@@ -819,8 +827,8 @@ public async Task SendTimestampColumn()
var ts = new DateTime(2022, 2, 24);
await sender.Table("name")
- .Column("ts", ts)
- .AtAsync(ts);
+ .Column("ts", ts)
+ .AtAsync(ts);
await sender.SendAsync();
@@ -838,8 +846,8 @@ public async Task SendColumnNanos()
const long timestampNanos = 1645660800123456789L;
await sender.Table("name")
- .ColumnNanos("ts", timestampNanos)
- .AtAsync(timestampNanos);
+ .ColumnNanos("ts", timestampNanos)
+ .AtAsync(timestampNanos);
await sender.SendAsync();
@@ -857,8 +865,8 @@ public async Task SendAtNanos()
const long timestampNanos = 1645660800987654321L;
await sender.Table("name")
- .Column("value", 42)
- .AtNanosAsync(timestampNanos);
+ .Column("value", 42)
+ .AtNanosAsync(timestampNanos);
await sender.SendAsync();
@@ -872,8 +880,8 @@ public async Task InvalidState()
{
using var srv = new DummyHttpServer();
await srv.StartAsync(HttpPort);
- using var sender = Sender.New($"http::addr={Host}:{HttpPort};auto_flush=off;");
- string? nullString = null;
+ using var sender = Sender.New($"http::addr={Host}:{HttpPort};auto_flush=off;");
+ string? nullString = null;
Assert.That(
() => sender.Table(nullString),
@@ -973,8 +981,8 @@ public async Task InvalidTableName()
{
using var srv = new DummyHttpServer();
await srv.StartAsync(HttpPort);
- using var sender = Sender.New($"http::addr={Host}:{HttpPort};auto_flush=off;");
- string? nullString = null;
+ using var sender = Sender.New($"http::addr={Host}:{HttpPort};auto_flush=off;");
+ string? nullString = null;
Assert.Throws(() => sender.Table(nullString));
Assert.Throws(() => sender.Column("abc", 123));
@@ -1001,19 +1009,19 @@ public async Task SendMillionAsyncExplicit()
$"http::addr={Host}:{HttpPort};init_buf_size={256 * 1024};auto_flush=off;request_timeout=30000;");
var nowMillisecond = DateTime.Now.Millisecond;
- var metric = "metric_name" + nowMillisecond;
+ var metric = "metric_name" + nowMillisecond;
Assert.True(await srv.Healthcheck());
for (var i = 0; i < 1E6; i++)
{
await sender.Table(metric)
- .Symbol("nopoint", "tag" + i % 100)
- .Column("counter", i * 1111.1)
- .Column("int", i)
- .Column("привед", "мед вед")
- .AtAsync(new DateTime(2021, 1, 1, i / 360 / 1000 % 60, i / 60 / 1000 % 60, i / 1000 % 60,
- i % 1000));
+ .Symbol("nopoint", "tag" + i % 100)
+ .Column("counter", i * 1111.1)
+ .Column("int", i)
+ .Column("привед", "мед вед")
+ .AtAsync(new DateTime(2021, 1, 1, i / 360 / 1000 % 60, i / 60 / 1000 % 60, i / 1000 % 60,
+ i % 1000));
if (i % 100 == 0)
{
@@ -1031,7 +1039,7 @@ public async Task SendMillionFixedBuffer()
await srv.StartAsync(HttpPort);
var nowMillisecond = DateTime.Now.Millisecond;
- var metric = "metric_name" + nowMillisecond;
+ var metric = "metric_name" + nowMillisecond;
Assert.True(await srv.Healthcheck());
@@ -1040,13 +1048,15 @@ public async Task SendMillionFixedBuffer()
$"http::addr={Host}:{HttpPort};init_buf_size={1024 * 1024};auto_flush=on;auto_flush_bytes={1024 * 1024};request_timeout=30000;");
for (var i = 0; i < 1E6; i++)
+ {
await sender.Table(metric)
- .Symbol("nopoint", "tag" + i % 100)
- .Column("counter", i * 1111.1)
- .Column("int", i)
- .Column("привед", "мед вед")
- .AtAsync(new DateTime(2021, 1, 1, i / 360 / 1000 % 60, i / 60 / 1000 % 60, i / 1000 % 60,
- i % 1000));
+ .Symbol("nopoint", "tag" + i % 100)
+ .Column("counter", i * 1111.1)
+ .Column("int", i)
+ .Column("привед", "мед вед")
+ .AtAsync(new DateTime(2021, 1, 1, i / 360 / 1000 % 60, i / 60 / 1000 % 60, i / 1000 % 60,
+ i % 1000));
+ }
await sender.SendAsync();
}
@@ -1062,8 +1072,8 @@ public async Task SendNegativeLongMin()
$"http::addr={Host}:{HttpPort};auto_flush=off;");
Assert.That(
() => sender.Table("name")
- .Column("number1", long.MinValue)
- .AtAsync(DateTime.UtcNow),
+ .Column("number1", long.MinValue)
+ .AtAsync(DateTime.UtcNow),
Throws.TypeOf().With.Message.Contains("Special case")
);
}
@@ -1078,8 +1088,8 @@ public async Task SendSpecialStrings()
Sender.New(
$"http::addr={Host}:{HttpPort};auto_flush=off;");
await sender.Table("neg name")
- .Column("привед", " мед\rве\n д")
- .AtAsync(86400000000000);
+ .Column("привед", " мед\rве\n д")
+ .AtAsync(86400000000000);
await sender.SendAsync();
var expected = "neg\\ name привед=\" мед\\\rве\\\n д\" 86400000000000\n";
@@ -1097,9 +1107,9 @@ public async Task SendTagAfterField()
$"http::addr={Host}:{HttpPort};auto_flush=off;");
Assert.That(
async () => await sender.Table("name")
- .Column("number1", 123)
- .Symbol("nand", "asdfa")
- .AtAsync(DateTime.UtcNow),
+ .Column("number1", 123)
+ .Symbol("nand", "asdfa")
+ .AtAsync(DateTime.UtcNow),
Throws.TypeOf()
);
}
@@ -1117,9 +1127,9 @@ public async Task SendMetricOnce()
Assert.That(
async () =>
await sender.Table("name")
- .Column("number1", 123)
- .Table("nand")
- .AtAsync(DateTime.UtcNow),
+ .Column("number1", 123)
+ .Table("nand")
+ .AtAsync(DateTime.UtcNow),
Throws.TypeOf()
);
}
@@ -1273,7 +1283,7 @@ public async Task TransactionMultipleTypes()
await sender.Column("foo", 123d).AtAsync(86400000000000);
await sender.Column("foo", new DateTime(1970, 1, 1, 0, 0, 0, DateTimeKind.Utc)).AtAsync(86400000000000);
await sender.Column("foo", new DateTimeOffset(new DateTime(1970, 1, 1, 0, 0, 0, DateTimeKind.Utc)))
- .AtAsync(86400000000000);
+ .AtAsync(86400000000000);
await sender.Column("foo", false).AtAsync(86400000000000);
await sender.CommitAsync();
@@ -1340,10 +1350,12 @@ public async Task TransactionShouldNotBeAutoFlushed()
sender.Transaction("tableName");
for (var i = 0; i < 100; i++)
+ {
await sender
- .Symbol("foo", "bah")
- .Column("num", i)
- .AtAsync(DateTime.UtcNow);
+ .Symbol("foo", "bah")
+ .Column("num", i)
+ .AtAsync(DateTime.UtcNow);
+ }
Assert.That(sender.RowCount == 100);
Assert.That(sender.WithinTransaction);
@@ -1366,10 +1378,12 @@ public async Task TransactionRequiresCommitToComplete()
sender.Transaction("tableName");
for (var i = 0; i < 100; i++)
+ {
await sender
- .Symbol("foo", "bah")
- .Column("num", i)
- .AtAsync(DateTime.UtcNow);
+ .Symbol("foo", "bah")
+ .Column("num", i)
+ .AtAsync(DateTime.UtcNow);
+ }
Assert.That(
async () => await sender.SendAsync(),
@@ -1385,10 +1399,12 @@ await sender
sender.Transaction("tableName");
for (var i = 0; i < 100; i++)
+ {
await sender
- .Symbol("foo", "bah")
- .Column("num", i)
- .AtAsync(DateTime.UtcNow);
+ .Symbol("foo", "bah")
+ .Column("num", i)
+ .AtAsync(DateTime.UtcNow);
+ }
sender.Commit();
}
@@ -1554,8 +1570,8 @@ public async Task SendTimestampColumns()
using var sender = Sender.New($"http::addr={Host}:{HttpPort};auto_flush=off;");
sender.Table("foo")
- .Symbol("bah", "baz")
- .Column("ts1", DateTime.UtcNow).Column("ts2", DateTimeOffset.UtcNow);
+ .Symbol("bah", "baz")
+ .Column("ts1", DateTime.UtcNow).Column("ts2", DateTimeOffset.UtcNow);
await sender.SendAsync();
}
@@ -1568,36 +1584,36 @@ public async Task SendVariousAts()
using var sender = Sender.New($"http::addr={Host}:{HttpPort};auto_flush=off;");
await sender.Table("foo")
- .Symbol("bah", "baz")
- .AtAsync(DateTime.UtcNow);
+ .Symbol("bah", "baz")
+ .AtAsync(DateTime.UtcNow);
await sender.Table("foo")
- .Symbol("bah", "baz")
- .AtAsync(DateTime.UtcNow);
+ .Symbol("bah", "baz")
+ .AtAsync(DateTime.UtcNow);
await sender.Table("foo")
- .Symbol("bah", "baz")
- .AtAsync(DateTimeOffset.UtcNow);
+ .Symbol("bah", "baz")
+ .AtAsync(DateTimeOffset.UtcNow);
await sender.Table("foo")
- .Symbol("bah", "baz")
- .AtAsync(DateTime.UtcNow.Ticks / 100);
+ .Symbol("bah", "baz")
+ .AtAsync(DateTime.UtcNow.Ticks / 100);
sender.Table("foo")
- .Symbol("bah", "baz")
- .At(DateTime.UtcNow);
+ .Symbol("bah", "baz")
+ .At(DateTime.UtcNow);
sender.Table("foo")
- .Symbol("bah", "baz")
- .At(DateTime.UtcNow);
+ .Symbol("bah", "baz")
+ .At(DateTime.UtcNow);
sender.Table("foo")
- .Symbol("bah", "baz")
- .At(DateTimeOffset.UtcNow);
+ .Symbol("bah", "baz")
+ .At(DateTimeOffset.UtcNow);
sender.Table("foo")
- .Symbol("bah", "baz")
- .At(DateTime.UtcNow.Ticks / 100);
+ .Symbol("bah", "baz")
+ .At(DateTime.UtcNow.Ticks / 100);
await sender.SendAsync();
}
@@ -1653,16 +1669,19 @@ public async Task SendManyRequests()
for (var i = 0; i < lineCount; i++)
{
await sender.Table("table name")
- .Symbol("t a g", "v alu, e")
- .Column("number", i)
- .Column("db l", 123.12)
- .Column("string", " -=\"")
- .Column("при вед", "медвед")
- .AtAsync(DateTime.UtcNow);
+ .Symbol("t a g", "v alu, e")
+ .Column("number", i)
+ .Column("db l", 123.12)
+ .Column("string", " -=\"")
+ .Column("при вед", "медвед")
+ .AtAsync(DateTime.UtcNow);
var request = sender.SendAsync();
- while (request.Status == TaskStatus.WaitingToRun) await Task.Delay(10);
+ while (request.Status == TaskStatus.WaitingToRun)
+ {
+ await Task.Delay(10);
+ }
if (i == 0)
{
@@ -1693,13 +1712,13 @@ public async Task SendWithCert()
using var server = new DummyHttpServer(requireClientCert: true);
await server.StartAsync(HttpsPort);
using var sender = Sender.Configure($"https::addr=localhost:{HttpsPort};tls_verify=unsafe_off;")
- .WithClientCert(cert)
- .Build();
+ .WithClientCert(cert)
+ .Build();
await sender.Table("metrics")
- .Symbol("tag", "value")
- .Column("number", 12.2)
- .AtAsync(new DateTime(1970, 01, 01, 0, 0, 1));
+ .Symbol("tag", "value")
+ .Column("number", 12.2)
+ .AtAsync(new DateTime(1970, 01, 01, 0, 0, 1));
await sender.SendAsync();
Assert.That(
diff --git a/src/net-questdb-client/Senders/HttpSender.cs b/src/net-questdb-client/Senders/HttpSender.cs
index 11f6727..17d3720 100644
--- a/src/net-questdb-client/Senders/HttpSender.cs
+++ b/src/net-questdb-client/Senders/HttpSender.cs
@@ -50,6 +50,14 @@ internal class HttpSender : AbstractSender
///
private readonly Dictionary _clientCache = new();
+ private readonly Func _sendRequestFactory;
+ private readonly Func _settingRequestFactory;
+
+ ///
+ /// Manages round-robin address rotation for failover.
+ ///
+ private AddressProvider _addressProvider = null!;
+
///
/// Current reference from the cache.
///
@@ -61,20 +69,15 @@ internal class HttpSender : AbstractSender
private SocketsHttpHandler _handler = null!;
///
- /// Manages round-robin address rotation for failover.
- ///
- private AddressProvider _addressProvider = null!;
-
- private readonly Func _sendRequestFactory;
- private readonly Func _settingRequestFactory;
-
- ///
- /// Initializes a new HttpSender configured according to the provided options.
+ /// 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.
+ ///
+ /// Configuration for the sender, including connection endpoint, TLS and certificate settings,
+ /// buffering and protocol parameters, authentication, and timeouts.
+ ///
public HttpSender(SenderOptions options)
{
- _sendRequestFactory = GenerateRequest;
+ _sendRequestFactory = GenerateRequest;
_settingRequestFactory = GenerateSettingsRequest;
Options = options;
@@ -82,7 +85,7 @@ public HttpSender(SenderOptions options)
}
///
- /// Initializes a new instance of by parsing a configuration string.
+ /// Initializes a new instance of by parsing a configuration string.
///
/// Configuration string in QuestDB connection string format.
public HttpSender(string confStr) : this(new SenderOptions(confStr))
@@ -90,15 +93,18 @@ 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.
+ /// 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.
+ /// - 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()
{
@@ -107,12 +113,12 @@ private void Build()
_handler = new SocketsHttpHandler
{
PooledConnectionIdleTimeout = Options.pool_timeout,
- MaxConnectionsPerServer = 1,
+ MaxConnectionsPerServer = 1,
};
if (Options.protocol == ProtocolType.https)
{
- _handler.SslOptions.TargetHost = _addressProvider.CurrentHost;
+ _handler.SslOptions.TargetHost = _addressProvider.CurrentHost;
_handler.SslOptions.EnabledSslProtocols = SslProtocols.Tls12 | SslProtocols.Tls13;
if (Options.tls_verify == TlsVerifyType.unsafe_off)
@@ -154,7 +160,7 @@ private void Build()
}
}
- _handler.ConnectTimeout = Options.auth_timeout;
+ _handler.ConnectTimeout = Options.auth_timeout;
_handler.PreAuthenticate = true;
// Create and cache the initial client
@@ -187,7 +193,7 @@ private void Build()
{
try
{
- var json = response.Content.ReadFromJsonAsync().Result!;
+ var json = response.Content.ReadFromJsonAsync().Result!;
var versions = json.Config?.LineProtoSupportVersions!;
protocolVersion = (ProtocolVersion)versions.Where(v => v <= (int)ProtocolVersion.V3).Max();
}
@@ -213,7 +219,7 @@ private void Build()
if (protocolVersion == ProtocolVersion.Auto)
{
- protocolVersion = ProtocolVersion.V3;
+ protocolVersion = ProtocolVersion.V1;
}
}
@@ -226,7 +232,7 @@ private void Build()
}
///
- /// Creates a new HttpClient for the specified address with proper configuration.
+ /// Creates a new HttpClient for the specified address with proper configuration.
///
/// The address to create a client for.
/// A configured HttpClient for the given address.
@@ -242,15 +248,18 @@ private HttpClient CreateClientForAddress(string address)
port = Options.protocol switch
{
ProtocolType.http or ProtocolType.https => 9000,
- ProtocolType.tcp or ProtocolType.tcps => 9009,
- _ => 9000
+ ProtocolType.tcp or ProtocolType.tcps => 9009,
+ _ => 9000,
};
}
- var host = AddressProvider.ParseHost(address).Split("//")[1];
+ var host = address.Contains("//")
+ ? AddressProvider.ParseHost(address).Split("//")[1]
+ : AddressProvider.ParseHost(address);
+
var uri = new UriBuilder(Options.protocol.ToString(), host, port);
client.BaseAddress = uri.Uri;
- client.Timeout = Timeout.InfiniteTimeSpan;
+ client.Timeout = Timeout.InfiniteTimeSpan;
// Update handler's TLS target host if using HTTPS and host changed
if (Options.protocol == ProtocolType.https && _handler.SslOptions.TargetHost != host)
@@ -263,9 +272,9 @@ private HttpClient CreateClientForAddress(string address)
{
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))
{
@@ -276,7 +285,7 @@ private HttpClient CreateClientForAddress(string address)
}
///
- /// Gets or creates an HttpClient for the current address, caching it to avoid recreation on subsequent rotations.
+ /// Gets or creates an HttpClient for the current address, caching it to avoid recreation on subsequent rotations.
///
private HttpClient GetClientForCurrentAddress()
{
@@ -285,7 +294,7 @@ private HttpClient GetClientForCurrentAddress()
if (!_clientCache.TryGetValue(address, out var client))
{
// Create and cache a new client for this address
- client = CreateClientForAddress(address);
+ client = CreateClientForAddress(address);
_clientCache[address] = client;
}
@@ -294,8 +303,8 @@ private HttpClient GetClientForCurrentAddress()
}
///
- /// Cleans up all cached HttpClient instances except the one for the current address.
- /// Called when a successful response is received to avoid holding unnecessary resources.
+ /// Cleans up all cached HttpClient instances except the one for the current address.
+ /// Called when a successful response is received to avoid holding unnecessary resources.
///
private void CleanupUnusedClients()
{
@@ -306,8 +315,8 @@ private void CleanupUnusedClients()
var currentAddress = _addressProvider.CurrentAddress;
var addressesToRemove = _clientCache.Keys
- .Where(address => address != currentAddress)
- .ToList();
+ .Where(address => address != currentAddress)
+ .ToList();
foreach (var address in addressesToRemove)
{
@@ -320,19 +329,20 @@ private void CleanupUnusedClients()
}
///
- /// Creates an HTTP GET request to the /settings endpoint for querying server capabilities.
+ /// Creates an HTTP GET request to the /settings endpoint for querying server capabilities.
///
- /// A new configured for the /settings endpoint.
+ /// A new configured for the /settings endpoint.
private static HttpRequestMessage GenerateSettingsRequest()
{
return new HttpRequestMessage(HttpMethod.Get, "/settings");
}
///
- /// Creates a new cancellation token source linked to the provided token and configured with the calculated request timeout.
+ /// Creates a new cancellation token source linked to the provided token and configured with the calculated request
+ /// timeout.
///
/// Optional cancellation token to link.
- /// A configured with the request timeout.
+ /// A configured with the request timeout.
private CancellationTokenSource GenerateRequestCts(CancellationToken ct = default)
{
var cts = CancellationTokenSource.CreateLinkedTokenSource(ct);
@@ -341,14 +351,17 @@ private CancellationTokenSource GenerateRequestCts(CancellationToken ct = defaul
}
///
- /// Create an HTTP POST request targeting "/write" with the sender's buffer as the request body.
+ /// 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.
+ ///
+ /// 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), };
- request.Content.Headers.ContentType = new MediaTypeHeaderValue("text/plain") { CharSet = "utf-8", };
+ request.Content.Headers.ContentType = new MediaTypeHeaderValue("text/plain") { CharSet = "utf-8", };
request.Content.Headers.ContentLength = Buffer.Length;
return request;
}
@@ -374,13 +387,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);
@@ -439,14 +452,19 @@ public override void Rollback()
}
///
- /// Sends the current buffer synchronously to the server, applying configured retries and handling server-side errors.
+ /// 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.
+ /// 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.
+ ///
+ /// 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)
@@ -459,7 +477,7 @@ public override void Send(CancellationToken ct = default)
return;
}
- bool success = false;
+ var success = false;
try
{
using var response = SendWithRetries(ct, _sendRequestFactory, Options.retry_timeout);
@@ -502,21 +520,25 @@ public override void Send(CancellationToken ct = default)
}
///
- /// Sends an HTTP request produced by and retries on transient connection or server errors until a successful response is received or elapses.
- /// When multiple addresses are configured and a retriable error occurs, rotates to the next address and retries.
+ /// Sends an HTTP request produced by and retries on transient connection or server
+ /// errors until a successful response is received or elapses.
+ /// When multiple addresses are configured and a retriable error occurs, rotates to the next address and retries.
///
/// Cancellation token used to cancel the overall operation and linked to per-request timeouts.
- /// Factory that produces a fresh for each attempt.
+ /// 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 .///
+ /// 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)
+ TimeSpan retryTimeout)
{
HttpResponseMessage? response = null;
- CancellationTokenSource cts = GenerateRequestCts(ct);
- HttpRequestMessage request = requestFactory();
+ var cts = GenerateRequestCts(ct);
+ var request = requestFactory();
try
{
@@ -532,7 +554,7 @@ private HttpResponseMessage SendWithRetries(CancellationToken ct, Func TimeSpan.Zero)
// 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)))
{
@@ -542,9 +564,9 @@ private HttpResponseMessage SendWithRetries(CancellationToken ct, Func
- /// Reads and deserializes a JSON error response from the HTTP response, then throws an with the error details.
+ /// Reads and deserializes a JSON error response from the HTTP response, then throws an
+ /// with the error details.
///
/// The HTTP response containing a JSON error body.
- /// Always thrown with ; the message combines the response reason phrase with the deserialized JSON error or raw response text.
+ ///
+ /// Always thrown with ; the message combines the
+ /// response reason phrase with the deserialized JSON error or raw response text.
+ ///
private void HandleErrorJson(HttpResponseMessage response)
{
using var respStream = response.Content.ReadAsStream();
@@ -617,10 +643,14 @@ private void HandleErrorJson(HttpResponseMessage response)
}
///
- /// 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.
+ /// 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.
+ ///
+ /// 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();
@@ -632,7 +662,7 @@ private async Task HandleErrorJsonAsync(HttpResponseMessage response)
catch (JsonException)
{
using var strReader = new StreamReader(respStream);
- var errorStr = await strReader.ReadToEndAsync();
+ var errorStr = await strReader.ReadToEndAsync();
throw new IngressError(ErrorCode.ServerFlushError, $"{response.ReasonPhrase}. {errorStr}");
}
}
@@ -650,14 +680,14 @@ public override async Task SendAsync(CancellationToken ct = default)
return;
}
- HttpRequestMessage? request = null;
- CancellationTokenSource? cts = null;
- HttpResponseMessage? response = null;
+ HttpRequestMessage? request = null;
+ CancellationTokenSource? cts = null;
+ HttpResponseMessage? response = null;
try
{
request = GenerateRequest();
- cts = GenerateRequestCts(ct);
+ cts = GenerateRequestCts(ct);
try
{
@@ -671,7 +701,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)))
{
@@ -681,9 +711,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));
@@ -701,7 +731,7 @@ public override async Task SendAsync(CancellationToken ct = default)
}
request = GenerateRequest();
- cts = GenerateRequestCts(ct);
+ cts = GenerateRequestCts(ct);
var jitter = TimeSpan.FromMilliseconds(Random.Shared.Next(0, 10) - 10 / 2.0);
await Task.Delay(retryInterval + jitter, cts.Token);
@@ -711,7 +741,7 @@ public override async Task SendAsync(CancellationToken ct = default)
// Get the client for the current address (may have rotated)
var client = GetClientForCurrentAddress();
response = await client.SendAsync(request, HttpCompletionOption.ResponseHeadersRead,
- cts.Token);
+ cts.Token);
}
catch (HttpRequestException)
{
@@ -725,7 +755,7 @@ public override async Task SendAsync(CancellationToken ct = default)
if (response == null)
{
throw new IngressError(ErrorCode.ServerFlushError,
- $"Cannot connect to `{_addressProvider.CurrentHost}:{_addressProvider.CurrentPort}`");
+ $"Cannot connect to `{_addressProvider.CurrentHost}:{_addressProvider.CurrentPort}`");
}
// return if ok
@@ -764,17 +794,20 @@ public override async Task SendAsync(CancellationToken ct = default)
}
///
- /// Determines whether the specified HTTP status code represents a transient error that should be retried.
+ /// Determines whether the specified HTTP status code represents a transient error that should be retried.
///
/// The HTTP status code to check.
- /// true if the error is transient and retriable (e.g., 404, 421, 500, 503, 504, 509, 523, 524, 529, 599); otherwise, false.
+ ///
+ /// true if the error is transient and retriable (e.g., 404, 421, 500, 503, 504, 509, 523, 524, 529, 599);
+ /// otherwise, false.
+ ///
// ReSharper disable once IdentifierTypo
private static bool IsRetriableError(HttpStatusCode code)
{
switch (code)
{
case HttpStatusCode.NotFound: // 404 - Can happen when instance doesn't have write access
- case (HttpStatusCode)421: // Misdirected Request - Can indicate wrong server/instance
+ case (HttpStatusCode)421: // Misdirected Request - Can indicate wrong server/instance
case HttpStatusCode.InternalServerError:
case HttpStatusCode.ServiceUnavailable:
case HttpStatusCode.GatewayTimeout:
@@ -798,6 +831,7 @@ public override void Dispose()
{
client.Dispose();
}
+
_clientCache.Clear();
_handler.Dispose();
From 4bfc0ea486492b8f8e2e8b627223f9823963bc2f Mon Sep 17 00:00:00 2001
From: Nick Woolmer <29717167+nwoolmer@users.noreply.github.com>
Date: Tue, 2 Dec 2025 17:58:58 +0000
Subject: [PATCH 12/40] Fix authentication tests by simplifying to HTTP
protocol
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The authentication tests (AuthBasicSuccess, AuthBasicFailed, AuthTokenSuccess,
AuthTokenFailed) were failing due to protocol and port mismatches. The dummy
HTTP server was starting on HttpPort without proper HTTPS support, but tests
were trying to connect using HTTPS protocol.
Changes:
- Changed all auth tests from https:// to http:// protocol
- Changed HttpsPort references to HttpPort for consistent port matching
- Removed tls_verify=unsafe_off parameters (not needed with plain HTTP)
- Added missing .StopAsync() calls for proper server cleanup
All 10 authentication tests now pass successfully.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude
---
src/net-questdb-client-tests/HttpTests.cs | 12 ++++++++----
1 file changed, 8 insertions(+), 4 deletions(-)
diff --git a/src/net-questdb-client-tests/HttpTests.cs b/src/net-questdb-client-tests/HttpTests.cs
index f14539b..570b08c 100644
--- a/src/net-questdb-client-tests/HttpTests.cs
+++ b/src/net-questdb-client-tests/HttpTests.cs
@@ -423,7 +423,7 @@ public async Task AuthBasicFailed()
await server.StartAsync(HttpPort);
using var sender =
Sender.New(
- $"https::addr={Host}:{HttpPort};username=asdasdada;password=asdadad;tls_verify=unsafe_off;auto_flush=off;");
+ $"http::addr={Host}:{HttpPort};username=asdasdada;password=asdadad;auto_flush=off;");
await sender.Table("metrics")
.Symbol("tag", "value")
.Column("number", 10)
@@ -434,6 +434,7 @@ await sender.Table("metrics")
async () => await sender.SendAsync(),
Throws.TypeOf().With.Message.Contains("Unauthorized")
);
+ await server.StopAsync();
}
[Test]
@@ -443,7 +444,7 @@ public async Task AuthBasicSuccess()
await server.StartAsync(HttpPort);
using var sender =
Sender.New(
- $"https::addr={Host}:{HttpPort};username=admin;password=quest;tls_verify=unsafe_off;auto_flush=off;");
+ $"http::addr={Host}:{HttpPort};username=admin;password=quest;auto_flush=off;");
await sender.Table("metrics")
.Symbol("tag", "value")
.Column("number", 10)
@@ -451,6 +452,7 @@ await sender.Table("metrics")
.AtAsync(new DateTime(1970, 01, 01, 0, 0, 1));
await sender.SendAsync();
+ await server.StopAsync();
}
[Test]
@@ -461,7 +463,7 @@ public async Task AuthTokenFailed()
using var sender =
Sender.New(
- $"https::addr={Host}:{HttpsPort};token=askldaklds;tls_verify=unsafe_off;auto_flush=off;");
+ $"http::addr={Host}:{HttpPort};token=askldaklds;auto_flush=off;");
for (var i = 0; i < 100; i++)
{
@@ -476,6 +478,7 @@ await sender
async () => await sender.SendAsync(),
Throws.TypeOf().With.Message.Contains("Unauthorized")
);
+ await srv.StopAsync();
}
[Test]
@@ -488,7 +491,7 @@ public async Task AuthTokenSuccess()
using var sender =
Sender.New(
- $"https::addr={Host}:{HttpsPort};token={token};tls_verify=unsafe_off;auto_flush=off;");
+ $"http::addr={Host}:{HttpPort};token={token};auto_flush=off;");
for (var i = 0; i < 100; i++)
{
@@ -500,6 +503,7 @@ await sender
}
await sender.SendAsync();
+ await srv.StopAsync();
}
From fdcdcbbf328d6bf40fc068cedf45f4247a20b5b5 Mon Sep 17 00:00:00 2001
From: Nick Woolmer <29717167+nwoolmer@users.noreply.github.com>
Date: Wed, 3 Dec 2025 15:45:41 +0000
Subject: [PATCH 13/40] iterate
---
src/net-questdb-client-tests/HttpTests.cs | 10 +++++++++-
1 file changed, 9 insertions(+), 1 deletion(-)
diff --git a/src/net-questdb-client-tests/HttpTests.cs b/src/net-questdb-client-tests/HttpTests.cs
index 570b08c..1251252 100644
--- a/src/net-questdb-client-tests/HttpTests.cs
+++ b/src/net-questdb-client-tests/HttpTests.cs
@@ -177,6 +177,7 @@ await sender.Column("array", (ReadOnlySpan)aray.AsSpan())
}
[Test]
+ // [Ignore("Test is broken - arrays are not validated until send. Needs redesign.")]
public async Task BasicArrayDoubleNegotiationVersion2NotSupported()
{
{
@@ -1737,8 +1738,15 @@ public async Task FailsWhenExpectingCert()
using var server = new DummyHttpServer(requireClientCert: true);
await server.StartAsync(HttpsPort);
+ using var sender = Sender.Configure($"https::addr=localhost:{HttpsPort};tls_verify=unsafe_off;").Build();
+
+ await sender.Table("metrics")
+ .Symbol("tag", "value")
+ .Column("number", 12.2)
+ .AtAsync(new DateTime(1970, 01, 01, 0, 0, 1));
+
Assert.That(
- () => Sender.Configure($"https::addr=localhost:{HttpsPort};tls_verify=unsafe_off;").Build(),
+ async () => await sender.SendAsync(),
Throws.TypeOf().With.Message.Contains("ServerFlushError")
);
From 298c172b5735f291fa495a81e09adb8837e4743e Mon Sep 17 00:00:00 2001
From: Nick Woolmer <29717167+nwoolmer@users.noreply.github.com>
Date: Wed, 3 Dec 2025 15:49:12 +0000
Subject: [PATCH 14/40] fix test
---
src/dummy-http-server/DummyHttpServer.cs | 7 ++++++-
src/net-questdb-client/Senders/HttpSender.cs | 11 ++---------
2 files changed, 8 insertions(+), 10 deletions(-)
diff --git a/src/dummy-http-server/DummyHttpServer.cs b/src/dummy-http-server/DummyHttpServer.cs
index 45df75c..d298e98 100644
--- a/src/dummy-http-server/DummyHttpServer.cs
+++ b/src/dummy-http-server/DummyHttpServer.cs
@@ -45,6 +45,7 @@ public class DummyHttpServer : IDisposable
private readonly bool _withBasicAuth;
private readonly bool _withRetriableError;
private readonly bool _withErrorMessage;
+ private readonly bool _requireClientCert;
///
/// Initializes a configurable in-process dummy HTTP server used for testing endpoints.
@@ -74,6 +75,7 @@ public DummyHttpServer(bool withTokenAuth = false, bool withBasicAuth = false, b
_withRetriableError = withRetriableError;
_withErrorMessage = withErrorMessage;
_withStartDelay = withStartDelay;
+ _requireClientCert = requireClientCert;
// Also set static flags for backwards compatibility
IlpEndpoint.WithTokenAuth = withTokenAuth;
@@ -171,7 +173,10 @@ public async Task StartAsync(int port = 29743, int[]? versions = null)
retriableError: _withRetriableError,
errorMessage: _withErrorMessage);
- _ = _app.RunAsync($"http://localhost:{port}");
+ var url = _requireClientCert
+ ? $"https://localhost:{port}"
+ : $"http://localhost:{port}";
+ _ = _app.RunAsync(url);
}
///
diff --git a/src/net-questdb-client/Senders/HttpSender.cs b/src/net-questdb-client/Senders/HttpSender.cs
index 17d3720..12df6a4 100644
--- a/src/net-questdb-client/Senders/HttpSender.cs
+++ b/src/net-questdb-client/Senders/HttpSender.cs
@@ -179,14 +179,7 @@ private void Build()
using var response = SendWithRetries(default, _settingRequestFactory, TimeSpan.FromSeconds(1));
if (!response.IsSuccessStatusCode)
{
- if (response.StatusCode == HttpStatusCode.NotFound)
- {
- protocolVersion = ProtocolVersion.V1;
- }
- else
- {
- protocolVersion = ProtocolVersion.V3;
- }
+ protocolVersion = ProtocolVersion.V1;
}
if (protocolVersion == ProtocolVersion.Auto)
@@ -199,7 +192,7 @@ private void Build()
}
catch
{
- protocolVersion = ProtocolVersion.V3;
+ protocolVersion = ProtocolVersion.V1;
}
}
}
From 0ccafb95f5b87222da6919d0205ad4e2944eec1b Mon Sep 17 00:00:00 2001
From: Nick Woolmer <29717167+nwoolmer@users.noreply.github.com>
Date: Wed, 3 Dec 2025 16:03:41 +0000
Subject: [PATCH 15/40] cleanup api
---
src/net-questdb-client/Senders/ISender.cs | 16 ----------------
1 file changed, 16 deletions(-)
diff --git a/src/net-questdb-client/Senders/ISender.cs b/src/net-questdb-client/Senders/ISender.cs
index b324bda..e2dd141 100644
--- a/src/net-questdb-client/Senders/ISender.cs
+++ b/src/net-questdb-client/Senders/ISender.cs
@@ -273,22 +273,6 @@ public interface ISender : IDisposable
/// The same instance to allow fluent chaining.
public ISender Column(ReadOnlySpan name, ReadOnlySpan value) where T : struct;
- ///
- /// Adds a column with the specified string value to the current row.
- ///
- /// 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)
- {
- if (value is null)
- {
- return this;
- }
-
- 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.
From e2c6adea6059de476bab68fced537828239f4303 Mon Sep 17 00:00:00 2001
From: Nick Woolmer <29717167+nwoolmer@users.noreply.github.com>
Date: Wed, 3 Dec 2025 16:11:12 +0000
Subject: [PATCH 16/40] guid, char
---
src/net-questdb-client-tests/HttpTests.cs | 120 ++++++++++++++++++
src/net-questdb-client/Buffers/IBuffer.cs | 12 ++
.../Senders/AbstractSender.cs | 3 +
src/net-questdb-client/Senders/ISender.cs | 27 +++-
4 files changed, 160 insertions(+), 2 deletions(-)
diff --git a/src/net-questdb-client-tests/HttpTests.cs b/src/net-questdb-client-tests/HttpTests.cs
index 6957095..abc366d 100644
--- a/src/net-questdb-client-tests/HttpTests.cs
+++ b/src/net-questdb-client-tests/HttpTests.cs
@@ -1101,6 +1101,126 @@ await sender.Table("neg name")
Assert.That(srv.PrintBuffer(), Is.EqualTo(expected));
}
+ [Test]
+ public async Task SendGuidColumn()
+ {
+ using var srv = new DummyHttpServer();
+ await srv.StartAsync(HttpPort);
+
+ using var sender = Sender.New($"http::addr={Host}:{HttpPort};auto_flush=off;");
+
+ var guid = new Guid("550e8400-e29b-41d4-a716-446655440000");
+ await sender.Table("metrics")
+ .Symbol("tag", "value")
+ .Column("id", guid)
+ .AtAsync(new DateTime(1970, 01, 01, 0, 0, 1));
+
+ await sender.SendAsync();
+
+ var expected = "metrics,tag=value id=\"550e8400-e29b-41d4-a716-446655440000\" 1000000000\n";
+ Assert.That(srv.PrintBuffer(), Is.EqualTo(expected));
+ }
+
+ [Test]
+ public async Task SendCharColumn()
+ {
+ using var srv = new DummyHttpServer();
+ await srv.StartAsync(HttpPort);
+
+ using var sender = Sender.New($"http::addr={Host}:{HttpPort};auto_flush=off;");
+
+ await sender.Table("metrics")
+ .Symbol("tag", "value")
+ .Column("letter", 'A')
+ .AtAsync(new DateTime(1970, 01, 01, 0, 0, 1));
+
+ await sender.SendAsync();
+
+ var expected = "metrics,tag=value letter=\"A\" 1000000000\n";
+ Assert.That(srv.PrintBuffer(), Is.EqualTo(expected));
+ }
+
+ [Test]
+ public async Task SendMultipleGuidAndCharColumns()
+ {
+ using var srv = new DummyHttpServer();
+ await srv.StartAsync(HttpPort);
+
+ using var sender = Sender.New($"http::addr={Host}:{HttpPort};auto_flush=off;");
+
+ var guid1 = new Guid("550e8400-e29b-41d4-a716-446655440000");
+ var guid2 = new Guid("6ba7b810-9dad-11d1-80b4-00c04fd430c8");
+
+ await sender.Table("metrics")
+ .Symbol("tag", "value")
+ .Column("id1", guid1)
+ .Column("letter1", 'X')
+ .Column("id2", guid2)
+ .Column("letter2", 'Y')
+ .AtAsync(new DateTime(1970, 01, 01, 0, 0, 1));
+
+ await sender.SendAsync();
+
+ var expected = "metrics,tag=value id1=\"550e8400-e29b-41d4-a716-446655440000\",letter1=\"X\",id2=\"6ba7b810-9dad-11d1-80b4-00c04fd430c8\",letter2=\"Y\" 1000000000\n";
+ Assert.That(srv.PrintBuffer(), Is.EqualTo(expected));
+ }
+
+ [Test]
+ public async Task SendNullableGuidColumn()
+ {
+ using var srv = new DummyHttpServer();
+ await srv.StartAsync(HttpPort);
+
+ using var sender = Sender.New($"http::addr={Host}:{HttpPort};auto_flush=off;");
+
+ var guid = new Guid("550e8400-e29b-41d4-a716-446655440000");
+
+ // Send with value
+ await sender.Table("metrics")
+ .Symbol("tag", "value1")
+ .NullableColumn("id", (Guid?)guid)
+ .AtAsync(new DateTime(1970, 01, 01, 0, 0, 1));
+
+ // Send with null
+ await sender.Table("metrics")
+ .Symbol("tag", "value2")
+ .NullableColumn("id", (Guid?)null)
+ .AtAsync(new DateTime(1970, 01, 01, 0, 0, 2));
+
+ await sender.SendAsync();
+
+ var expected = "metrics,tag=value1 id=\"550e8400-e29b-41d4-a716-446655440000\" 1000000000\n" +
+ "metrics,tag=value2 2000000000\n";
+ Assert.That(srv.PrintBuffer(), Is.EqualTo(expected));
+ }
+
+ [Test]
+ public async Task SendNullableCharColumn()
+ {
+ using var srv = new DummyHttpServer();
+ await srv.StartAsync(HttpPort);
+
+ using var sender = Sender.New($"http::addr={Host}:{HttpPort};auto_flush=off;");
+
+ // Send with value
+ await sender.Table("metrics")
+ .Symbol("tag", "value1")
+ .NullableColumn("letter", (char?)'Z')
+ .AtAsync(new DateTime(1970, 01, 01, 0, 0, 1));
+
+ // Send with null
+ await sender.Table("metrics")
+ .Symbol("tag", "value2")
+ .NullableColumn("letter", (char?)null)
+ .AtAsync(new DateTime(1970, 01, 01, 0, 0, 2));
+
+ await sender.SendAsync();
+
+ var expected = "metrics,tag=value1 letter=\"Z\" 1000000000\n" +
+ "metrics,tag=value2 2000000000\n";
+ Assert.That(srv.PrintBuffer(), Is.EqualTo(expected));
+ }
+
[Test]
public async Task SendTagAfterField()
{
diff --git a/src/net-questdb-client/Buffers/IBuffer.cs b/src/net-questdb-client/Buffers/IBuffer.cs
index aa8c1b0..1ea82b2 100644
--- a/src/net-questdb-client/Buffers/IBuffer.cs
+++ b/src/net-questdb-client/Buffers/IBuffer.cs
@@ -297,7 +297,19 @@ public interface IBuffer
///
public IBuffer Column(ReadOnlySpan name, decimal value);
+ ///
+ /// Adds a character column with the specified name and value to the current row.
+ ///
+ /// The column name.
+ /// The character value to store in the column.
+ /// The buffer instance for method chaining.
public IBuffer Column(ReadOnlySpan name, char value);
+ ///
+ /// Adds a GUID column with the specified name and value to the current row.
+ ///
+ /// The column name.
+ /// The GUID value to store in the column.
+ /// The buffer instance for method chaining.
public IBuffer Column(ReadOnlySpan name, Guid 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 9bb4cb5..bbdb8c6 100644
--- a/src/net-questdb-client/Senders/AbstractSender.cs
+++ b/src/net-questdb-client/Senders/AbstractSender.cs
@@ -272,18 +272,21 @@ public void Clear()
/// 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);
return this;
}
+ ///
public ISender Column(ReadOnlySpan name, Guid value)
{
Buffer.Column(name, value);
return this;
}
+ ///
public ISender Column(ReadOnlySpan name, char value)
{
Buffer.Column(name, value);
diff --git a/src/net-questdb-client/Senders/ISender.cs b/src/net-questdb-client/Senders/ISender.cs
index e2dd141..9e6a8d1 100644
--- a/src/net-questdb-client/Senders/ISender.cs
+++ b/src/net-questdb-client/Senders/ISender.cs
@@ -442,12 +442,28 @@ public ISender NullableColumn(ReadOnlySpan name, decimal? value)
return this;
}
+ ///
+ /// Adds a GUID column with the specified name and value to the current row.
+ ///
+ /// The column name.
+ /// The GUID value to store in the column.
+ /// The sender instance for fluent call chaining.
public ISender Column(ReadOnlySpan name, Guid value);
-
+ ///
+ /// Adds a character column with the specified name and value to the current row.
+ ///
+ /// The column name.
+ /// The character value to store in the column.
+ /// The sender instance for fluent call chaining.
public ISender Column(ReadOnlySpan name, char value);
-
+ ///
+ /// Adds a nullable GUID column with the specified name when a value is provided; does nothing if the value is null.
+ ///
+ /// The column name.
+ /// The nullable GUID value to add as a column; if null, the column is not added.
+ /// The sender instance for fluent call chaining.
public ISender NullableColumn(ReadOnlySpan name, Guid? value)
{
if (value != null)
@@ -458,6 +474,13 @@ public ISender NullableColumn(ReadOnlySpan name, Guid? value)
return this;
}
+ ///
+ /// Adds a nullable character column with the specified name when a value is provided; does nothing if the value is
+ /// null.
+ ///
+ /// The column name.
+ /// The nullable character value to add as a column; if null, the column is not added.
+ /// The sender instance for fluent call chaining.
public ISender NullableColumn(ReadOnlySpan name, char? value)
{
if (value != null)
From 758136cebd860a74ca4946509ab7c9c009fbafd2 Mon Sep 17 00:00:00 2001
From: Nick Woolmer <29717167+nwoolmer@users.noreply.github.com>
Date: Wed, 3 Dec 2025 16:37:50 +0000
Subject: [PATCH 17/40] cleanup
---
src/net-questdb-client/Senders/HttpSender.cs | 22 ++++----------------
1 file changed, 4 insertions(+), 18 deletions(-)
diff --git a/src/net-questdb-client/Senders/HttpSender.cs b/src/net-questdb-client/Senders/HttpSender.cs
index e6236c9..b40c093 100644
--- a/src/net-questdb-client/Senders/HttpSender.cs
+++ b/src/net-questdb-client/Senders/HttpSender.cs
@@ -184,23 +184,14 @@ private void Build()
if (protocolVersion == ProtocolVersion.Auto)
{
- try
- {
- var json = response.Content.ReadFromJsonAsync().Result!;
- var versions = json.Config?.LineProtoSupportVersions!;
- protocolVersion = (ProtocolVersion)versions.Where(v => v <= (int)ProtocolVersion.V3).Max();
- }
- catch
- {
- protocolVersion = ProtocolVersion.V1;
- }
+ var json = response.Content.ReadFromJsonAsync().Result!;
+ var versions = json.Config?.LineProtoSupportVersions!;
+ protocolVersion = (ProtocolVersion)versions.Where(v => v <= (int)ProtocolVersion.V3).Max();
}
}
catch
{
- // If /settings probing fails (connection error, timeout, etc.),
- // default to V3 and allow actual sends to attempt connection.
- protocolVersion = ProtocolVersion.V3;
+ protocolVersion = ProtocolVersion.V1;
}
finally
{
@@ -209,11 +200,6 @@ private void Build()
// Update the client reference to match the restored address
_client = GetClientForCurrentAddress();
}
-
- if (protocolVersion == ProtocolVersion.Auto)
- {
- protocolVersion = ProtocolVersion.V1;
- }
}
Buffer = Buffers.Buffer.Create(
From b7c377824715090d1e019449d78813fda076dc82 Mon Sep 17 00:00:00 2001
From: Nick Woolmer <29717167+nwoolmer@users.noreply.github.com>
Date: Thu, 4 Dec 2025 10:18:14 +0000
Subject: [PATCH 18/40] wait all
Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
---
src/net-questdb-client-tests/QuestDbManager.cs | 7 +++++--
1 file changed, 5 insertions(+), 2 deletions(-)
diff --git a/src/net-questdb-client-tests/QuestDbManager.cs b/src/net-questdb-client-tests/QuestDbManager.cs
index 7eb1639..e771c2e 100644
--- a/src/net-questdb-client-tests/QuestDbManager.cs
+++ b/src/net-questdb-client-tests/QuestDbManager.cs
@@ -280,8 +280,11 @@ private async Task CleanupExistingContainersAsync()
throw new InvalidOperationException("Failed to start docker command");
}
- var output = await process.StandardOutput.ReadToEndAsync();
- var error = await process.StandardError.ReadToEndAsync();
+ var outputTask = process.StandardOutput.ReadToEndAsync();
+ var errorTask = process.StandardError.ReadToEndAsync();
+ await Task.WhenAll(outputTask, errorTask);
+ var output = await outputTask;
+ var error = await errorTask;
await process.WaitForExitAsync();
return (process.ExitCode, output + error);
From c0c81b00be88aa4b8f77acda74afb4434602e3d8 Mon Sep 17 00:00:00 2001
From: Nick Woolmer <29717167+nwoolmer@users.noreply.github.com>
Date: Thu, 4 Dec 2025 10:18:28 +0000
Subject: [PATCH 19/40] address comment
---
src/net-questdb-client/Senders/HttpSender.cs | 80 +++++++++++++-------
1 file changed, 51 insertions(+), 29 deletions(-)
diff --git a/src/net-questdb-client/Senders/HttpSender.cs b/src/net-questdb-client/Senders/HttpSender.cs
index b40c093..40bf34c 100644
--- a/src/net-questdb-client/Senders/HttpSender.cs
+++ b/src/net-questdb-client/Senders/HttpSender.cs
@@ -50,6 +50,12 @@ internal class HttpSender : AbstractSender
///
private readonly Dictionary _clientCache = new();
+ ///
+ /// Cache of instances, one per address.
+ /// Each address has its own handler to avoid TLS TargetHost conflicts when rotating addresses.
+ ///
+ private readonly Dictionary _handlerCache = new();
+
private readonly Func _sendRequestFactory;
private readonly Func _settingRequestFactory;
@@ -63,11 +69,6 @@ internal class HttpSender : AbstractSender
///
private HttpClient _client = null!;
- ///
- /// Instance specific for use constructing .
- ///
- private SocketsHttpHandler _handler = null!;
-
///
/// Initializes a new HttpSender configured according to the provided options.
///
@@ -106,11 +107,13 @@ public HttpSender(string confStr) : this(new SenderOptions(confStr))
/// 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()
+ ///
+ /// Creates a configured for a specific host.
+ /// Each handler is isolated to prevent TLS TargetHost conflicts between different addresses.
+ ///
+ private SocketsHttpHandler CreateHandler(string host)
{
- _addressProvider = new AddressProvider(Options.addresses);
-
- _handler = new SocketsHttpHandler
+ var handler = new SocketsHttpHandler
{
PooledConnectionIdleTimeout = Options.pool_timeout,
MaxConnectionsPerServer = 1,
@@ -118,16 +121,16 @@ private void Build()
if (Options.protocol == ProtocolType.https)
{
- _handler.SslOptions.TargetHost = _addressProvider.CurrentHost;
- _handler.SslOptions.EnabledSslProtocols = SslProtocols.Tls12 | SslProtocols.Tls13;
+ handler.SslOptions.TargetHost = host;
+ handler.SslOptions.EnabledSslProtocols = SslProtocols.Tls12 | SslProtocols.Tls13;
if (Options.tls_verify == TlsVerifyType.unsafe_off)
{
- _handler.SslOptions.RemoteCertificateValidationCallback += (_, _, _, _) => true;
+ handler.SslOptions.RemoteCertificateValidationCallback += (_, _, _, _) => true;
}
else
{
- _handler.SslOptions.RemoteCertificateValidationCallback =
+ handler.SslOptions.RemoteCertificateValidationCallback =
(_, certificate, chain, errors) =>
{
if ((errors & ~SslPolicyErrors.RemoteCertificateChainErrors) != 0)
@@ -148,20 +151,26 @@ private void Build()
if (!string.IsNullOrEmpty(Options.tls_roots))
{
- _handler.SslOptions.ClientCertificates ??= new X509Certificate2Collection();
- _handler.SslOptions.ClientCertificates.Add(
+ handler.SslOptions.ClientCertificates ??= new X509Certificate2Collection();
+ handler.SslOptions.ClientCertificates.Add(
X509Certificate2.CreateFromPemFile(Options.tls_roots!, Options.tls_roots_password));
}
if (Options.client_cert is not null)
{
- _handler.SslOptions.ClientCertificates ??= new X509Certificate2Collection();
- _handler.SslOptions.ClientCertificates.Add(Options.client_cert);
+ handler.SslOptions.ClientCertificates ??= new X509Certificate2Collection();
+ handler.SslOptions.ClientCertificates.Add(Options.client_cert);
}
}
- _handler.ConnectTimeout = Options.auth_timeout;
- _handler.PreAuthenticate = true;
+ handler.ConnectTimeout = Options.auth_timeout;
+ handler.PreAuthenticate = true;
+ return handler;
+ }
+
+ private void Build()
+ {
+ _addressProvider = new AddressProvider(Options.addresses);
// Create and cache the initial client
_client = GetClientForCurrentAddress();
@@ -217,8 +226,6 @@ private void Build()
/// A configured HttpClient for the given address.
private HttpClient CreateClientForAddress(string address)
{
- var client = new HttpClient(_handler);
-
// Determine the port to use
var port = AddressProvider.ParsePort(address);
if (port <= 0)
@@ -236,16 +243,19 @@ private HttpClient CreateClientForAddress(string address)
? AddressProvider.ParseHost(address).Split("//")[1]
: AddressProvider.ParseHost(address);
+ // Get or create a handler for this specific address
+ if (!_handlerCache.TryGetValue(address, out var handler))
+ {
+ handler = CreateHandler(host);
+ _handlerCache[address] = handler;
+ }
+
+ var client = new HttpClient(handler);
+
var uri = new UriBuilder(Options.protocol.ToString(), host, port);
client.BaseAddress = uri.Uri;
client.Timeout = Timeout.InfiniteTimeSpan;
- // Update handler's TLS target host if using HTTPS and host changed
- if (Options.protocol == ProtocolType.https && _handler.SslOptions.TargetHost != host)
- {
- _handler.SslOptions.TargetHost = host;
- }
-
// Apply authentication headers
if (!string.IsNullOrEmpty(Options.username) && !string.IsNullOrEmpty(Options.password))
{
@@ -282,7 +292,7 @@ private HttpClient GetClientForCurrentAddress()
}
///
- /// Cleans up all cached HttpClient instances except the one for the current address.
+ /// Cleans up all cached HttpClient and SocketsHttpHandler instances except the ones for the current address.
/// Called when a successful response is received to avoid holding unnecessary resources.
///
private void CleanupUnusedClients()
@@ -304,6 +314,12 @@ private void CleanupUnusedClients()
client.Dispose();
_clientCache.Remove(address);
}
+
+ if (_handlerCache.TryGetValue(address, out var handler))
+ {
+ handler.Dispose();
+ _handlerCache.Remove(address);
+ }
}
}
@@ -825,7 +841,13 @@ public override void Dispose()
_clientCache.Clear();
- _handler.Dispose();
+ // Dispose all cached handlers
+ foreach (var handler in _handlerCache.Values)
+ {
+ handler.Dispose();
+ }
+
+ _handlerCache.Clear();
Buffer.Clear();
Buffer.TrimExcessBuffers();
}
From 97a054bb88702d2c11aadee9a772eef54666206f Mon Sep 17 00:00:00 2001
From: Nick Woolmer <29717167+nwoolmer@users.noreply.github.com>
Date: Thu, 4 Dec 2025 10:20:43 +0000
Subject: [PATCH 20/40] address comment
---
.../QuestDbManager.cs | 108 ++++++++++--------
1 file changed, 59 insertions(+), 49 deletions(-)
diff --git a/src/net-questdb-client-tests/QuestDbManager.cs b/src/net-questdb-client-tests/QuestDbManager.cs
index e771c2e..17978bc 100644
--- a/src/net-questdb-client-tests/QuestDbManager.cs
+++ b/src/net-questdb-client-tests/QuestDbManager.cs
@@ -1,45 +1,56 @@
-using System;
using System.Diagnostics;
-using System.IO;
-using System.Net.Http;
-using System.Threading;
-using System.Threading.Tasks;
namespace QuestDB.Client.Tests;
///
-/// Manages QuestDB server lifecycle for integration tests using Docker.
-/// Handles pulling, starting, and stopping QuestDB container instances.
+/// Manages QuestDB server lifecycle for integration tests using Docker.
+/// Handles pulling, starting, and stopping QuestDB container instances.
///
public class QuestDbManager : IAsyncDisposable
{
private const string DockerImage = "questdb/questdb:latest";
private const string ContainerNamePrefix = "questdb-test-";
+ private readonly string _containerName;
+ private readonly HttpClient _httpClient;
+ private readonly int _httpPort;
private readonly int _port;
- private readonly int _httpPort;
private string? _containerId;
- private readonly HttpClient _httpClient;
- private readonly string _containerName;
private string? _volumeName;
- public bool IsRunning { get; private set; }
-
///
- /// Initializes a new instance of the QuestDbManager.
+ /// Initializes a new instance of the QuestDbManager.
///
/// ILP port (default: 9009)
/// HTTP port (default: 9000)
public QuestDbManager(int port = 9009, int httpPort = 9000)
{
- _port = port;
- _httpPort = httpPort;
+ _port = port;
+ _httpPort = httpPort;
_containerName = $"{ContainerNamePrefix}{port}-{httpPort}-{Guid.NewGuid().ToString().Substring(0, 8)}";
- _httpClient = new HttpClient { Timeout = TimeSpan.FromSeconds(5) };
+ _httpClient = new HttpClient { Timeout = TimeSpan.FromSeconds(5), };
+ }
+
+ public bool IsRunning { get; private set; }
+
+ ///
+ /// Cleanup resources.
+ ///
+ public async ValueTask DisposeAsync()
+ {
+ await StopAsync();
+
+ // Clean up Docker volume if one was used
+ if (!string.IsNullOrEmpty(_volumeName))
+ {
+ await RunDockerCommandAsync($"volume rm {_volumeName}");
+ }
+
+ _httpClient?.Dispose();
}
///
- /// Sets a Docker volume to be used for persistent storage.
+ /// Sets a Docker volume to be used for persistent storage.
///
public void SetVolume(string volumeName)
{
@@ -47,7 +58,7 @@ public void SetVolume(string volumeName)
}
///
- /// Ensures Docker is available.
+ /// Ensures Docker is available.
///
public async Task EnsureDockerAvailableAsync()
{
@@ -58,6 +69,7 @@ public async Task EnsureDockerAvailableAsync()
{
throw new InvalidOperationException("Docker is not available or not working properly");
}
+
Console.WriteLine($"Docker is available: {output.Trim()}");
}
catch (Exception ex)
@@ -70,7 +82,7 @@ public async Task EnsureDockerAvailableAsync()
}
///
- /// Ensures QuestDB Docker image is available (uses local if exists, otherwise pulls latest).
+ /// Ensures QuestDB Docker image is available (uses local if exists, otherwise pulls latest).
///
public async Task PullImageAsync()
{
@@ -87,11 +99,12 @@ public async Task PullImageAsync()
{
throw new InvalidOperationException($"Failed to pull Docker image: {output}");
}
+
Console.WriteLine("Docker image pulled successfully");
}
///
- /// Checks if the QuestDB Docker image exists locally.
+ /// Checks if the QuestDB Docker image exists locally.
///
private async Task ImageExistsAsync()
{
@@ -105,7 +118,7 @@ private async Task ImageExistsAsync()
}
///
- /// Starts the QuestDB container.
+ /// Starts the QuestDB container.
///
public async Task StartAsync()
{
@@ -131,8 +144,8 @@ public async Task StartAsync()
// --name: container name
// -v: volume mount (if specified)
var volumeArg = string.IsNullOrEmpty(_volumeName)
- ? string.Empty
- : $"-v {_volumeName}:/var/lib/questdb ";
+ ? string.Empty
+ : $"-v {_volumeName}:/var/lib/questdb ";
var runArgs = $"run -d " +
$"-p {_httpPort}:9000 " +
@@ -156,7 +169,7 @@ public async Task StartAsync()
}
///
- /// Stops the QuestDB container.
+ /// Stops the QuestDB container.
///
public async Task StopAsync()
{
@@ -176,28 +189,34 @@ public async Task StopAsync()
await RunDockerCommandAsync($"rm -f {_containerName}");
}
- IsRunning = false;
+ IsRunning = false;
_containerId = null;
Console.WriteLine("QuestDB container stopped");
}
///
- /// Gets the HTTP endpoint for QuestDB.
+ /// Gets the HTTP endpoint for QuestDB.
///
- public string GetHttpEndpoint() => $"http://localhost:{_httpPort}";
+ public string GetHttpEndpoint()
+ {
+ return $"http://localhost:{_httpPort}";
+ }
///
- /// Gets the ILP endpoint for QuestDB.
+ /// Gets the ILP endpoint for QuestDB.
///
- public string GetIlpEndpoint() => $"localhost:{_port}";
+ public string GetIlpEndpoint()
+ {
+ return $"localhost:{_port}";
+ }
///
- /// Waits for QuestDB to be ready.
+ /// Waits for QuestDB to be ready.
///
private async Task WaitForQuestDbAsync()
{
const int maxAttempts = 30;
- var attempts = 0;
+ var attempts = 0;
while (attempts < maxAttempts)
{
@@ -222,15 +241,6 @@ private async Task WaitForQuestDbAsync()
throw new TimeoutException($"QuestDB failed to start within {maxAttempts} seconds");
}
- ///
- /// Cleanup resources.
- ///
- public async ValueTask DisposeAsync()
- {
- await StopAsync();
- _httpClient?.Dispose();
- }
-
private async Task CleanupExistingContainersAsync()
{
Console.WriteLine($"Checking for existing containers on ports {_httpPort}/{_port}...");
@@ -248,7 +258,7 @@ private async Task CleanupExistingContainersAsync()
foreach (var name in containerNames)
{
// Look for containers with matching port pattern: questdb-test-{port}-{httpPort}-*
- if (name.Contains($"questdb-test-") &&
+ if (name.Contains("questdb-test-") &&
(name.Contains($"-{_port}-{_httpPort}-") || name.Contains($"-{_httpPort}-{_port}-")))
{
Console.WriteLine($"Cleaning up existing container: {name}");
@@ -266,12 +276,12 @@ private async Task CleanupExistingContainersAsync()
{
var startInfo = new ProcessStartInfo
{
- FileName = "docker",
- Arguments = arguments,
- UseShellExecute = false,
+ FileName = "docker",
+ Arguments = arguments,
+ UseShellExecute = false,
RedirectStandardOutput = true,
- RedirectStandardError = true,
- CreateNoWindow = true
+ RedirectStandardError = true,
+ CreateNoWindow = true,
};
var process = Process.Start(startInfo);
@@ -281,12 +291,12 @@ private async Task CleanupExistingContainersAsync()
}
var outputTask = process.StandardOutput.ReadToEndAsync();
- var errorTask = process.StandardError.ReadToEndAsync();
+ var errorTask = process.StandardError.ReadToEndAsync();
await Task.WhenAll(outputTask, errorTask);
var output = await outputTask;
- var error = await errorTask;
+ var error = await errorTask;
await process.WaitForExitAsync();
return (process.ExitCode, output + error);
}
-}
+}
\ No newline at end of file
From 5798bf072db307c8b8754cf1da16f0bfb159bb61 Mon Sep 17 00:00:00 2001
From: Nick Woolmer <29717167+nwoolmer@users.noreply.github.com>
Date: Thu, 4 Dec 2025 10:26:10 +0000
Subject: [PATCH 21/40] address comments
---
.../MultiUrlHttpTests.cs | 31 +++++++++++
.../Utils/AddressProvider.cs | 51 ++++++++++++++++---
2 files changed, 74 insertions(+), 8 deletions(-)
diff --git a/src/net-questdb-client-tests/MultiUrlHttpTests.cs b/src/net-questdb-client-tests/MultiUrlHttpTests.cs
index eb2c72b..93e719d 100644
--- a/src/net-questdb-client-tests/MultiUrlHttpTests.cs
+++ b/src/net-questdb-client-tests/MultiUrlHttpTests.cs
@@ -305,6 +305,37 @@ public void AddressProvider_ParseHostAndPort()
Assert.That(provider3.CurrentPort, Is.EqualTo(9000));
}
+ [Test]
+ public void AddressProvider_IPv6Parsing()
+ {
+ // Test various IPv6 address formats
+
+ // Simple loopback with port
+ var provider1 = new AddressProvider(new[] { "[::1]:9000" });
+ Assert.That(provider1.CurrentHost, Is.EqualTo("[::1]"));
+ Assert.That(provider1.CurrentPort, Is.EqualTo(9000));
+
+ // Full IPv6 address with port
+ var provider2 = new AddressProvider(new[] { "[2001:db8::1]:9000" });
+ Assert.That(provider2.CurrentHost, Is.EqualTo("[2001:db8::1]"));
+ Assert.That(provider2.CurrentPort, Is.EqualTo(9000));
+
+ // IPv6 with many colons
+ var provider3 = new AddressProvider(new[] { "[fe80::1:2:3:4]:8080" });
+ Assert.That(provider3.CurrentHost, Is.EqualTo("[fe80::1:2:3:4]"));
+ Assert.That(provider3.CurrentPort, Is.EqualTo(8080));
+
+ // IPv6 without port (should return -1 for port)
+ var provider4 = new AddressProvider(new[] { "[::1]" });
+ Assert.That(provider4.CurrentHost, Is.EqualTo("[::1]"));
+ Assert.That(provider4.CurrentPort, Is.EqualTo(-1));
+
+ // IPv6 with different port numbers
+ var provider5 = new AddressProvider(new[] { "[::1]:29000" });
+ Assert.That(provider5.CurrentHost, Is.EqualTo("[::1]"));
+ Assert.That(provider5.CurrentPort, Is.EqualTo(29000));
+ }
+
[Test]
public void AddressProvider_SingleAddress()
{
diff --git a/src/net-questdb-client/Utils/AddressProvider.cs b/src/net-questdb-client/Utils/AddressProvider.cs
index 52214db..d0cc893 100644
--- a/src/net-questdb-client/Utils/AddressProvider.cs
+++ b/src/net-questdb-client/Utils/AddressProvider.cs
@@ -92,24 +92,39 @@ public void RotateToNextAddress()
}
///
- /// Parses the host from an address string (host:port format).
+ /// Parses the host from an address string.
+ /// Supports both regular (host:port) and IPv6 ([ipv6]:port) formats.
+ /// For IPv6 addresses, returns the complete bracketed form including '[' and ']'.
///
public static string ParseHost(string address)
{
if (string.IsNullOrEmpty(address))
return address;
- var index = address.LastIndexOf(':');
- if (index > 0)
+ // Handle IPv6 addresses in bracket notation: [ipv6]:port
+ if (address.StartsWith("["))
{
- return address.Substring(0, index);
+ var closingBracketIndex = address.IndexOf(']');
+ if (closingBracketIndex > 0)
+ {
+ // Return the entire bracketed section as the host
+ return address.Substring(0, closingBracketIndex + 1);
+ }
+ }
+
+ // For non-bracketed addresses, use the last colon to split host and port
+ var colonIndex = address.LastIndexOf(':');
+ if (colonIndex > 0)
+ {
+ return address.Substring(0, colonIndex);
}
return address;
}
///
- /// Parses the port from an address string (host:port format).
+ /// Parses the port from an address string.
+ /// Supports both regular (host:port) and IPv6 ([ipv6]:port) formats.
/// Returns -1 if no port is specified.
///
public static int ParsePort(string address)
@@ -117,10 +132,30 @@ public static int ParsePort(string address)
if (string.IsNullOrEmpty(address))
return -1;
- var index = address.LastIndexOf(':');
- if (index >= 0 && index < address.Length - 1)
+ // Handle IPv6 addresses in bracket notation: [ipv6]:port
+ if (address.StartsWith("["))
+ {
+ var closingBracketIndex = address.IndexOf(']');
+ if (closingBracketIndex > 0 && closingBracketIndex < address.Length - 1)
+ {
+ // Check if there's a colon after the closing bracket
+ if (address[closingBracketIndex + 1] == ':')
+ {
+ var portString = address.Substring(closingBracketIndex + 2);
+ if (int.TryParse(portString, out var port))
+ {
+ return port;
+ }
+ }
+ }
+ return -1;
+ }
+
+ // For non-bracketed addresses, use the last colon to split host and port
+ var colonIndex = address.LastIndexOf(':');
+ if (colonIndex >= 0 && colonIndex < address.Length - 1)
{
- if (int.TryParse(address.Substring(index + 1), out var port))
+ if (int.TryParse(address.Substring(colonIndex + 1), out var port))
{
return port;
}
From 20a85e3c40f56a1d867b14a5362c3d6ff06ee8a1 Mon Sep 17 00:00:00 2001
From: Nick Woolmer <29717167+nwoolmer@users.noreply.github.com>
Date: Thu, 4 Dec 2025 12:27:00 +0000
Subject: [PATCH 22/40] comments
---
src/net-questdb-client/Senders/HttpSender.cs | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/src/net-questdb-client/Senders/HttpSender.cs b/src/net-questdb-client/Senders/HttpSender.cs
index 40bf34c..3370ade 100644
--- a/src/net-questdb-client/Senders/HttpSender.cs
+++ b/src/net-questdb-client/Senders/HttpSender.cs
@@ -246,7 +246,7 @@ private HttpClient CreateClientForAddress(string address)
// Get or create a handler for this specific address
if (!_handlerCache.TryGetValue(address, out var handler))
{
- handler = CreateHandler(host);
+ handler = CreateHandler(host);
_handlerCache[address] = handler;
}
From 413632aba586289961b63075ce6496e911115d2c Mon Sep 17 00:00:00 2001
From: Nick Woolmer <29717167+nwoolmer@users.noreply.github.com>
Date: Thu, 4 Dec 2025 12:52:23 +0000
Subject: [PATCH 23/40] comments
---
src/net-questdb-client-tests/QuestDbManager.cs | 10 +++++++---
1 file changed, 7 insertions(+), 3 deletions(-)
diff --git a/src/net-questdb-client-tests/QuestDbManager.cs b/src/net-questdb-client-tests/QuestDbManager.cs
index 17978bc..e766a5d 100644
--- a/src/net-questdb-client-tests/QuestDbManager.cs
+++ b/src/net-questdb-client-tests/QuestDbManager.cs
@@ -255,11 +255,15 @@ private async Task CleanupExistingContainersAsync()
var containerNames = output.Split('\n', StringSplitOptions.RemoveEmptyEntries);
// Stop and remove any QuestDB test containers
- foreach (var name in containerNames)
+ foreach (var rawName in containerNames)
{
+ // Trim the name to remove trailing \r or whitespace
+ var name = rawName.Trim();
+
// Look for containers with matching port pattern: questdb-test-{port}-{httpPort}-*
- if (name.Contains("questdb-test-") &&
- (name.Contains($"-{_port}-{_httpPort}-") || name.Contains($"-{_httpPort}-{_port}-")))
+ if (name.Contains(ContainerNamePrefix, StringComparison.Ordinal) &&
+ (name.Contains($"-{_port}-{_httpPort}-", StringComparison.Ordinal) ||
+ name.Contains($"-{_httpPort}-{_port}-", StringComparison.Ordinal)))
{
Console.WriteLine($"Cleaning up existing container: {name}");
From 818f09fea420ea793ac5d806968ca062c9fd8104 Mon Sep 17 00:00:00 2001
From: Nick Woolmer <29717167+nwoolmer@users.noreply.github.com>
Date: Thu, 4 Dec 2025 16:30:33 +0000
Subject: [PATCH 24/40] iterate on ci
---
ci/azure-pipelines.yml | 26 ++++++++++++++++++++++++++
1 file changed, 26 insertions(+)
diff --git a/ci/azure-pipelines.yml b/ci/azure-pipelines.yml
index 1248050..c8a1629 100644
--- a/ci/azure-pipelines.yml
+++ b/ci/azure-pipelines.yml
@@ -73,6 +73,32 @@ steps:
projects: 'net-questdb-client.sln'
arguments: '--configuration $(buildConfiguration) --no-restore'
+- script: |
+ sudo apt-get update
+ sudo apt-get install -y docker.io
+ sudo usermod -aG docker $(whoami)
+ sudo systemctl start docker
+ displayName: 'Install Docker Engine (Linux)'
+ condition: eq(variables['osName'], 'Linux')
+
+- script: |
+ brew install colima
+ colima start --runtime docker
+ displayName: 'Install Colima (macOS)'
+ condition: eq(variables['osName'], 'macOS')
+
+- script: |
+ choco install docker-desktop -y
+ Start-Process "C:\Program Files\Docker\Docker\Docker Desktop.exe"
+ for ($i = 1; $i -le 60; $i++) {
+ if (Test-Path "\\.\pipe\docker_engine") {
+ break
+ }
+ Start-Sleep -Seconds 1
+ }
+ displayName: 'Install Docker Desktop (Windows)'
+ condition: eq(variables['osName'], 'Windows')
+
- task: DotNetCoreCLI@2
displayName: 'Run tests on $(osName)'
inputs:
From e0359bba66265407a95c8345902d7079afe66bd3 Mon Sep 17 00:00:00 2001
From: Nick Woolmer <29717167+nwoolmer@users.noreply.github.com>
Date: Thu, 4 Dec 2025 16:57:36 +0000
Subject: [PATCH 25/40] ci iterate
---
ci/azure-pipelines.yml | 18 +++++++++++-------
1 file changed, 11 insertions(+), 7 deletions(-)
diff --git a/ci/azure-pipelines.yml b/ci/azure-pipelines.yml
index c8a1629..cfb4371 100644
--- a/ci/azure-pipelines.yml
+++ b/ci/azure-pipelines.yml
@@ -82,19 +82,23 @@ steps:
condition: eq(variables['osName'], 'Linux')
- script: |
- brew install colima
+ brew install docker colima
colima start --runtime docker
displayName: 'Install Colima (macOS)'
condition: eq(variables['osName'], 'macOS')
-- script: |
+- pwsh: |
choco install docker-desktop -y
- Start-Process "C:\Program Files\Docker\Docker\Docker Desktop.exe"
- for ($i = 1; $i -le 60; $i++) {
- if (Test-Path "\\.\pipe\docker_engine") {
- break
+ $dockerPath = "C:\Program Files\Docker\Docker\Docker Desktop.exe"
+ if (Test-Path $dockerPath) {
+ Start-Process $dockerPath
+ for ($i = 1; $i -le 60; $i++) {
+ if (Test-Path "\\.\pipe\docker_engine") {
+ Write-Host "Docker daemon is ready"
+ break
+ }
+ Start-Sleep -Seconds 1
}
- Start-Sleep -Seconds 1
}
displayName: 'Install Docker Desktop (Windows)'
condition: eq(variables['osName'], 'Windows')
From 608688bcdafc545b6e898cec922245d86fb2443e Mon Sep 17 00:00:00 2001
From: Nick Woolmer <29717167+nwoolmer@users.noreply.github.com>
Date: Thu, 4 Dec 2025 17:27:13 +0000
Subject: [PATCH 26/40] ci
---
ci/azure-pipelines.yml | 33 +++++++++++++++++++++------------
1 file changed, 21 insertions(+), 12 deletions(-)
diff --git a/ci/azure-pipelines.yml b/ci/azure-pipelines.yml
index cfb4371..29d4130 100644
--- a/ci/azure-pipelines.yml
+++ b/ci/azure-pipelines.yml
@@ -73,17 +73,18 @@ steps:
projects: 'net-questdb-client.sln'
arguments: '--configuration $(buildConfiguration) --no-restore'
-- script: |
- sudo apt-get update
- sudo apt-get install -y docker.io
- sudo usermod -aG docker $(whoami)
- sudo systemctl start docker
- displayName: 'Install Docker Engine (Linux)'
- condition: eq(variables['osName'], 'Linux')
-
- script: |
brew install docker colima
colima start --runtime docker
+ # Wait for Colima to be ready
+ for i in {1..60}; do
+ if docker ps >/dev/null 2>&1; then
+ echo "Docker daemon is ready"
+ break
+ fi
+ echo "Waiting for Docker daemon... ($i/60)"
+ sleep 1
+ done
displayName: 'Install Colima (macOS)'
condition: eq(variables['osName'], 'macOS')
@@ -92,11 +93,19 @@ steps:
$dockerPath = "C:\Program Files\Docker\Docker\Docker Desktop.exe"
if (Test-Path $dockerPath) {
Start-Process $dockerPath
- for ($i = 1; $i -le 60; $i++) {
- if (Test-Path "\\.\pipe\docker_engine") {
- Write-Host "Docker daemon is ready"
- break
+ # Wait for Docker daemon to be ready (up to 5 minutes)
+ $maxAttempts = 300
+ for ($i = 1; $i -le $maxAttempts; $i++) {
+ try {
+ $result = docker ps 2>&1
+ if ($LASTEXITCODE -eq 0) {
+ Write-Host "Docker daemon is ready"
+ break
+ }
+ } catch {
+ # Silently continue
}
+ Write-Host "Waiting for Docker daemon... ($i/$maxAttempts)"
Start-Sleep -Seconds 1
}
}
From 20c77344d06409feeed98ab8e2dc6b07238771d9 Mon Sep 17 00:00:00 2001
From: Nick Woolmer <29717167+nwoolmer@users.noreply.github.com>
Date: Thu, 4 Dec 2025 18:13:49 +0000
Subject: [PATCH 27/40] ci
---
ci/azure-pipelines.yml | 30 +++++++++++++++----
.../QuestDbManager.cs | 9 ++++--
2 files changed, 32 insertions(+), 7 deletions(-)
diff --git a/ci/azure-pipelines.yml b/ci/azure-pipelines.yml
index 29d4130..19cdc99 100644
--- a/ci/azure-pipelines.yml
+++ b/ci/azure-pipelines.yml
@@ -90,24 +90,44 @@ steps:
- pwsh: |
choco install docker-desktop -y
+ refreshenv
+
$dockerPath = "C:\Program Files\Docker\Docker\Docker Desktop.exe"
if (Test-Path $dockerPath) {
+ Write-Host "Starting Docker Desktop..."
Start-Process $dockerPath
- # Wait for Docker daemon to be ready (up to 5 minutes)
- $maxAttempts = 300
+
+ # Wait for Docker daemon and WSL2 backend to be fully ready
+ # This can take several minutes on first run
+ Write-Host "Waiting for Docker daemon (WSL2 backend)..."
+ $maxAttempts = 600
+ $ready = $false
+
for ($i = 1; $i -le $maxAttempts; $i++) {
try {
- $result = docker ps 2>&1
+ # Try to pull a small image to verify full daemon readiness
+ $null = & docker pull hello-world:latest 2>&1
if ($LASTEXITCODE -eq 0) {
- Write-Host "Docker daemon is ready"
+ Write-Host "Docker daemon is ready (pulled hello-world successfully)"
+ $ready = $true
break
}
} catch {
# Silently continue
}
- Write-Host "Waiting for Docker daemon... ($i/$maxAttempts)"
+
+ if ($i % 60 -eq 0) {
+ Write-Host "Still waiting... ($i seconds elapsed)"
+ }
+
Start-Sleep -Seconds 1
}
+
+ if ($ready) {
+ Write-Host "Docker initialization complete"
+ } else {
+ Write-Host "Warning: Docker may not be fully initialized after 10 minutes"
+ }
}
displayName: 'Install Docker Desktop (Windows)'
condition: eq(variables['osName'], 'Windows')
diff --git a/src/net-questdb-client-tests/QuestDbManager.cs b/src/net-questdb-client-tests/QuestDbManager.cs
index e766a5d..08f35aa 100644
--- a/src/net-questdb-client-tests/QuestDbManager.cs
+++ b/src/net-questdb-client-tests/QuestDbManager.cs
@@ -215,7 +215,7 @@ public string GetIlpEndpoint()
///
private async Task WaitForQuestDbAsync()
{
- const int maxAttempts = 30;
+ const int maxAttempts = 120; // 2 minutes for CI environments with Colima
var attempts = 0;
while (attempts < maxAttempts)
@@ -225,7 +225,7 @@ private async Task WaitForQuestDbAsync()
var response = await _httpClient.GetAsync($"{GetHttpEndpoint()}/settings");
if (response.IsSuccessStatusCode)
{
- Console.WriteLine("QuestDB is ready");
+ Console.WriteLine($"QuestDB is ready (after {attempts} seconds)");
return;
}
}
@@ -236,6 +236,11 @@ private async Task WaitForQuestDbAsync()
await Task.Delay(1000);
attempts++;
+
+ if (attempts % 30 == 0)
+ {
+ Console.WriteLine($"Still waiting for QuestDB... ({attempts}/{maxAttempts} seconds)");
+ }
}
throw new TimeoutException($"QuestDB failed to start within {maxAttempts} seconds");
From d7b9bf4e7b3a1aa3617b085fdb1215fd8c620035 Mon Sep 17 00:00:00 2001
From: Nick Woolmer <29717167+nwoolmer@users.noreply.github.com>
Date: Thu, 4 Dec 2025 18:59:27 +0000
Subject: [PATCH 28/40] ci
---
ci/azure-pipelines.yml | 99 ++++++++++---------
.../QuestDbManager.cs | 64 +++++++++---
2 files changed, 105 insertions(+), 58 deletions(-)
diff --git a/ci/azure-pipelines.yml b/ci/azure-pipelines.yml
index 19cdc99..fa0161e 100644
--- a/ci/azure-pipelines.yml
+++ b/ci/azure-pipelines.yml
@@ -74,62 +74,73 @@ steps:
arguments: '--configuration $(buildConfiguration) --no-restore'
- script: |
- brew install docker colima
- colima start --runtime docker
- # Wait for Colima to be ready
+ sudo apt-get update
+ sudo apt-get install -y questdb
+ sudo systemctl start questdb
+ sudo systemctl enable questdb
+ # Wait for QuestDB to be ready
for i in {1..60}; do
- if docker ps >/dev/null 2>&1; then
- echo "Docker daemon is ready"
+ if curl -s http://localhost:9000/settings > /dev/null 2>&1; then
+ echo "QuestDB is ready"
break
fi
- echo "Waiting for Docker daemon... ($i/60)"
+ echo "Waiting for QuestDB... ($i/60)"
sleep 1
done
- displayName: 'Install Colima (macOS)'
+ displayName: 'Install QuestDB via systemd (Linux)'
+ condition: eq(variables['osName'], 'Linux')
+
+- script: |
+ brew install questdb
+ questdb start
+ # Wait for QuestDB to be ready
+ for i in {1..60}; do
+ if curl -s http://localhost:9000/settings > /dev/null 2>&1; then
+ echo "QuestDB is ready"
+ break
+ fi
+ echo "Waiting for QuestDB... ($i/60)"
+ sleep 1
+ done
+ displayName: 'Install QuestDB via Homebrew (macOS)'
condition: eq(variables['osName'], 'macOS')
- pwsh: |
- choco install docker-desktop -y
- refreshenv
-
- $dockerPath = "C:\Program Files\Docker\Docker\Docker Desktop.exe"
- if (Test-Path $dockerPath) {
- Write-Host "Starting Docker Desktop..."
- Start-Process $dockerPath
-
- # Wait for Docker daemon and WSL2 backend to be fully ready
- # This can take several minutes on first run
- Write-Host "Waiting for Docker daemon (WSL2 backend)..."
- $maxAttempts = 600
- $ready = $false
-
- for ($i = 1; $i -le $maxAttempts; $i++) {
- try {
- # Try to pull a small image to verify full daemon readiness
- $null = & docker pull hello-world:latest 2>&1
- if ($LASTEXITCODE -eq 0) {
- Write-Host "Docker daemon is ready (pulled hello-world successfully)"
- $ready = $true
- break
- }
- } catch {
- # Silently continue
- }
-
- if ($i % 60 -eq 0) {
- Write-Host "Still waiting... ($i seconds elapsed)"
+ # Download and install QuestDB on Windows
+ Write-Host "Downloading QuestDB..."
+ $url = "https://questdb.io/api/questdb-latest-release"
+ $response = Invoke-WebRequest -Uri $url -UseBasicParsing
+ $downloadUrl = $response | Select-Object -ExpandProperty Content | ConvertFrom-Json | Select-Object -ExpandProperty windows_exe
+
+ $questDbPath = "C:\questdb"
+ New-Item -ItemType Directory -Path $questDbPath -Force | Out-Null
+
+ $exePath = "$questDbPath\questdb.exe"
+ Write-Host "Downloading from: $downloadUrl"
+ Invoke-WebRequest -Uri $downloadUrl -OutFile $exePath -UseBasicParsing
+
+ Write-Host "Starting QuestDB..."
+ & $exePath start
+
+ # Wait for QuestDB to be ready (up to 120 seconds)
+ Write-Host "Waiting for QuestDB to start..."
+ $maxAttempts = 120
+ for ($i = 1; $i -le $maxAttempts; $i++) {
+ try {
+ $response = Invoke-WebRequest -Uri "http://localhost:9000/settings" -UseBasicParsing -ErrorAction SilentlyContinue
+ if ($response.StatusCode -eq 200) {
+ Write-Host "QuestDB is ready"
+ break
}
-
- Start-Sleep -Seconds 1
+ } catch {
+ # Silently continue
}
-
- if ($ready) {
- Write-Host "Docker initialization complete"
- } else {
- Write-Host "Warning: Docker may not be fully initialized after 10 minutes"
+ if ($i % 30 -eq 0) {
+ Write-Host "Still waiting... ($i/$maxAttempts seconds)"
}
+ Start-Sleep -Seconds 1
}
- displayName: 'Install Docker Desktop (Windows)'
+ displayName: 'Install QuestDB via Binary (Windows)'
condition: eq(variables['osName'], 'Windows')
- task: DotNetCoreCLI@2
diff --git a/src/net-questdb-client-tests/QuestDbManager.cs b/src/net-questdb-client-tests/QuestDbManager.cs
index 08f35aa..3ca268d 100644
--- a/src/net-questdb-client-tests/QuestDbManager.cs
+++ b/src/net-questdb-client-tests/QuestDbManager.cs
@@ -40,8 +40,8 @@ public async ValueTask DisposeAsync()
{
await StopAsync();
- // Clean up Docker volume if one was used
- if (!string.IsNullOrEmpty(_volumeName))
+ // Clean up Docker volume if one was used (only if using Docker)
+ if (!string.IsNullOrEmpty(_volumeName) && !IsNativeInstance)
{
await RunDockerCommandAsync($"volume rm {_volumeName}");
}
@@ -58,10 +58,18 @@ public void SetVolume(string volumeName)
}
///
- /// Ensures Docker is available.
+ /// Ensures QuestDB is available (either native or Docker).
///
public async Task EnsureDockerAvailableAsync()
{
+ // First, check if QuestDB is already running natively
+ if (await IsQuestDbNativelyAvailableAsync())
+ {
+ Console.WriteLine("QuestDB is running natively (not using Docker)");
+ return;
+ }
+
+ // Fall back to Docker if native QuestDB is not available
try
{
var (exitCode, output) = await RunDockerCommandAsync("--version");
@@ -75,12 +83,28 @@ public async Task EnsureDockerAvailableAsync()
catch (Exception ex)
{
throw new InvalidOperationException(
- "Docker is required to run integration tests. " +
- "Please install Docker from https://docs.docker.com/get-docker/",
+ "QuestDB must be running (natively or via Docker). " +
+ "Please install QuestDB from https://questdb.io/download/ or Docker from https://docs.docker.com/get-docker/",
ex);
}
}
+ ///
+ /// Checks if QuestDB is already running natively (not in Docker).
+ ///
+ private async Task IsQuestDbNativelyAvailableAsync()
+ {
+ try
+ {
+ var response = await _httpClient.GetAsync($"{GetHttpEndpoint()}/settings");
+ return response.IsSuccessStatusCode;
+ }
+ catch
+ {
+ return false;
+ }
+ }
+
///
/// Ensures QuestDB Docker image is available (uses local if exists, otherwise pulls latest).
///
@@ -118,7 +142,7 @@ private async Task ImageExistsAsync()
}
///
- /// Starts the QuestDB container.
+ /// Starts the QuestDB container or connects to native instance.
///
public async Task StartAsync()
{
@@ -130,6 +154,18 @@ public async Task StartAsync()
await EnsureDockerAvailableAsync();
+ // Check if QuestDB is already running natively
+ if (await IsQuestDbNativelyAvailableAsync())
+ {
+ Console.WriteLine("Connecting to native QuestDB instance");
+ IsRunning = true;
+ // No need to wait, it's already running
+ return;
+ }
+
+ // Use Docker to start QuestDB
+ Console.WriteLine("Starting QuestDB via Docker container");
+
// Clean up any existing containers using these ports
await CleanupExistingContainersAsync();
@@ -169,7 +205,7 @@ public async Task StartAsync()
}
///
- /// Stops the QuestDB container.
+ /// Stops the QuestDB container (only if running in Docker).
///
public async Task StopAsync()
{
@@ -194,6 +230,11 @@ public async Task StopAsync()
Console.WriteLine("QuestDB container stopped");
}
+ ///
+ /// Checks if QuestDB is running as a native instance (not Docker).
+ ///
+ public bool IsNativeInstance => string.IsNullOrEmpty(_containerId) && IsRunning;
+
///
/// Gets the HTTP endpoint for QuestDB.
///
@@ -215,7 +256,7 @@ public string GetIlpEndpoint()
///
private async Task WaitForQuestDbAsync()
{
- const int maxAttempts = 120; // 2 minutes for CI environments with Colima
+ const int maxAttempts = 30;
var attempts = 0;
while (attempts < maxAttempts)
@@ -225,7 +266,7 @@ private async Task WaitForQuestDbAsync()
var response = await _httpClient.GetAsync($"{GetHttpEndpoint()}/settings");
if (response.IsSuccessStatusCode)
{
- Console.WriteLine($"QuestDB is ready (after {attempts} seconds)");
+ Console.WriteLine("QuestDB is ready");
return;
}
}
@@ -236,11 +277,6 @@ private async Task WaitForQuestDbAsync()
await Task.Delay(1000);
attempts++;
-
- if (attempts % 30 == 0)
- {
- Console.WriteLine($"Still waiting for QuestDB... ({attempts}/{maxAttempts} seconds)");
- }
}
throw new TimeoutException($"QuestDB failed to start within {maxAttempts} seconds");
From 2788ddd8a41527476e064cdb80da6a188d1d9993 Mon Sep 17 00:00:00 2001
From: Nick Woolmer <29717167+nwoolmer@users.noreply.github.com>
Date: Thu, 4 Dec 2025 19:20:09 +0000
Subject: [PATCH 29/40] ci
---
ci/azure-pipelines.yml | 18 +++++--------
.../QuestDbManager.cs | 25 ++++++++++++++++---
2 files changed, 27 insertions(+), 16 deletions(-)
diff --git a/ci/azure-pipelines.yml b/ci/azure-pipelines.yml
index fa0161e..3d5edfe 100644
--- a/ci/azure-pipelines.yml
+++ b/ci/azure-pipelines.yml
@@ -106,21 +106,15 @@ steps:
condition: eq(variables['osName'], 'macOS')
- pwsh: |
- # Download and install QuestDB on Windows
Write-Host "Downloading QuestDB..."
- $url = "https://questdb.io/api/questdb-latest-release"
- $response = Invoke-WebRequest -Uri $url -UseBasicParsing
- $downloadUrl = $response | Select-Object -ExpandProperty Content | ConvertFrom-Json | Select-Object -ExpandProperty windows_exe
+ $downloadUrl = "https://github.com/questdb/questdb/releases/download/9.2.2/questdb-9.2.2-rt-windows-x86-64.tar.gz"
+ Invoke-WebRequest -Uri $downloadUrl -OutFile questdb.tar.gz
- $questDbPath = "C:\questdb"
- New-Item -ItemType Directory -Path $questDbPath -Force | Out-Null
-
- $exePath = "$questDbPath\questdb.exe"
- Write-Host "Downloading from: $downloadUrl"
- Invoke-WebRequest -Uri $downloadUrl -OutFile $exePath -UseBasicParsing
+ Write-Host "Extracting QuestDB..."
+ tar -xzf questdb.tar.gz
Write-Host "Starting QuestDB..."
- & $exePath start
+ & .\questdb\bin\questdb.exe start
# Wait for QuestDB to be ready (up to 120 seconds)
Write-Host "Waiting for QuestDB to start..."
@@ -140,7 +134,7 @@ steps:
}
Start-Sleep -Seconds 1
}
- displayName: 'Install QuestDB via Binary (Windows)'
+ displayName: 'Install and Start QuestDB (Windows)'
condition: eq(variables['osName'], 'Windows')
- task: DotNetCoreCLI@2
diff --git a/src/net-questdb-client-tests/QuestDbManager.cs b/src/net-questdb-client-tests/QuestDbManager.cs
index 3ca268d..87d76ee 100644
--- a/src/net-questdb-client-tests/QuestDbManager.cs
+++ b/src/net-questdb-client-tests/QuestDbManager.cs
@@ -65,11 +65,25 @@ public async Task EnsureDockerAvailableAsync()
// First, check if QuestDB is already running natively
if (await IsQuestDbNativelyAvailableAsync())
{
- Console.WriteLine("QuestDB is running natively (not using Docker)");
+ Console.WriteLine("QuestDB is running natively");
return;
}
- // Fall back to Docker if native QuestDB is not available
+ // For CI environments, fail if native QuestDB is not available
+ // (CI pipelines explicitly start native QuestDB instances)
+ var isCI = !string.IsNullOrEmpty(Environment.GetEnvironmentVariable("CI")) ||
+ !string.IsNullOrEmpty(Environment.GetEnvironmentVariable("GITHUB_ACTIONS")) ||
+ !string.IsNullOrEmpty(Environment.GetEnvironmentVariable("SYSTEM_TEAMFOUNDATIONCOLLECTIONURI"));
+
+ if (isCI)
+ {
+ throw new InvalidOperationException(
+ "QuestDB is not running. " +
+ "CI pipeline should have started QuestDB before running tests. " +
+ "Please ensure QuestDB was started correctly.");
+ }
+
+ // Fall back to Docker for local development
try
{
var (exitCode, output) = await RunDockerCommandAsync("--version");
@@ -96,11 +110,14 @@ private async Task IsQuestDbNativelyAvailableAsync()
{
try
{
- var response = await _httpClient.GetAsync($"{GetHttpEndpoint()}/settings");
+ // Add a longer timeout for the initial health check
+ using var cts = new System.Threading.CancellationTokenSource(TimeSpan.FromSeconds(10));
+ var response = await _httpClient.GetAsync($"{GetHttpEndpoint()}/settings", System.Net.Http.HttpCompletionOption.ResponseHeadersRead, cts.Token);
return response.IsSuccessStatusCode;
}
- catch
+ catch (Exception ex)
{
+ Console.WriteLine($"Native QuestDB check failed: {ex.Message}");
return false;
}
}
From 4f291bae019309abff75750e6c2bc57777c62b7d Mon Sep 17 00:00:00 2001
From: Nick Woolmer <29717167+nwoolmer@users.noreply.github.com>
Date: Thu, 4 Dec 2025 19:33:57 +0000
Subject: [PATCH 30/40] ci
---
ci/azure-pipelines.yml | 12 +++++++++-
.../QuestDbManager.cs | 22 ++++---------------
2 files changed, 15 insertions(+), 19 deletions(-)
diff --git a/ci/azure-pipelines.yml b/ci/azure-pipelines.yml
index 3d5edfe..e5e5167 100644
--- a/ci/azure-pipelines.yml
+++ b/ci/azure-pipelines.yml
@@ -113,8 +113,18 @@ steps:
Write-Host "Extracting QuestDB..."
tar -xzf questdb.tar.gz
+ Write-Host "Listing extracted contents..."
+ Get-ChildItem
+
Write-Host "Starting QuestDB..."
- & .\questdb\bin\questdb.exe start
+ $questdbExe = Get-ChildItem -Recurse -Filter "questdb.exe" | Select-Object -First 1
+ if ($questdbExe) {
+ Write-Host "Found questdb.exe at: $($questdbExe.FullName)"
+ & $questdbExe.FullName start
+ } else {
+ Write-Host "ERROR: questdb.exe not found after extraction"
+ exit 1
+ }
# Wait for QuestDB to be ready (up to 120 seconds)
Write-Host "Waiting for QuestDB to start..."
diff --git a/src/net-questdb-client-tests/QuestDbManager.cs b/src/net-questdb-client-tests/QuestDbManager.cs
index 87d76ee..af077a1 100644
--- a/src/net-questdb-client-tests/QuestDbManager.cs
+++ b/src/net-questdb-client-tests/QuestDbManager.cs
@@ -69,21 +69,7 @@ public async Task EnsureDockerAvailableAsync()
return;
}
- // For CI environments, fail if native QuestDB is not available
- // (CI pipelines explicitly start native QuestDB instances)
- var isCI = !string.IsNullOrEmpty(Environment.GetEnvironmentVariable("CI")) ||
- !string.IsNullOrEmpty(Environment.GetEnvironmentVariable("GITHUB_ACTIONS")) ||
- !string.IsNullOrEmpty(Environment.GetEnvironmentVariable("SYSTEM_TEAMFOUNDATIONCOLLECTIONURI"));
-
- if (isCI)
- {
- throw new InvalidOperationException(
- "QuestDB is not running. " +
- "CI pipeline should have started QuestDB before running tests. " +
- "Please ensure QuestDB was started correctly.");
- }
-
- // Fall back to Docker for local development
+ // Fall back to Docker if native QuestDB is not available
try
{
var (exitCode, output) = await RunDockerCommandAsync("--version");
@@ -110,14 +96,14 @@ private async Task IsQuestDbNativelyAvailableAsync()
{
try
{
- // Add a longer timeout for the initial health check
- using var cts = new System.Threading.CancellationTokenSource(TimeSpan.FromSeconds(10));
+ // Try with a longer timeout for native instances that may be slower to initialize
+ using var cts = new System.Threading.CancellationTokenSource(TimeSpan.FromSeconds(30));
var response = await _httpClient.GetAsync($"{GetHttpEndpoint()}/settings", System.Net.Http.HttpCompletionOption.ResponseHeadersRead, cts.Token);
return response.IsSuccessStatusCode;
}
catch (Exception ex)
{
- Console.WriteLine($"Native QuestDB check failed: {ex.Message}");
+ Console.WriteLine($"Native QuestDB availability check failed: {ex.Message}");
return false;
}
}
From bcd64203fd3d1ad1317a9d843892685cc32ee23c Mon Sep 17 00:00:00 2001
From: Nick Woolmer <29717167+nwoolmer@users.noreply.github.com>
Date: Thu, 4 Dec 2025 20:02:49 +0000
Subject: [PATCH 31/40] ci
---
ci/azure-pipelines.yml | 49 +++++++++++++++----
.../QuestDbManager.cs | 19 +++++--
2 files changed, 54 insertions(+), 14 deletions(-)
diff --git a/ci/azure-pipelines.yml b/ci/azure-pipelines.yml
index e5e5167..ce7fa69 100644
--- a/ci/azure-pipelines.yml
+++ b/ci/azure-pipelines.yml
@@ -113,27 +113,46 @@ steps:
Write-Host "Extracting QuestDB..."
tar -xzf questdb.tar.gz
- Write-Host "Listing extracted contents..."
- Get-ChildItem
-
- Write-Host "Starting QuestDB..."
+ Write-Host "Finding questdb.exe..."
$questdbExe = Get-ChildItem -Recurse -Filter "questdb.exe" | Select-Object -First 1
- if ($questdbExe) {
- Write-Host "Found questdb.exe at: $($questdbExe.FullName)"
- & $questdbExe.FullName start
- } else {
+ if (-not $questdbExe) {
Write-Host "ERROR: questdb.exe not found after extraction"
+ Get-ChildItem -Recurse
exit 1
}
+ Write-Host "Found questdb.exe at: $($questdbExe.FullName)"
+ $questdbDir = $questdbExe.Directory
+
+ Write-Host "Starting QuestDB from directory: $questdbDir"
+ Push-Location $questdbDir
+ try {
+ # Run questdb.exe directly and capture any errors
+ Write-Host "Executing: $($questdbExe.FullName) start"
+ $process = Start-Process -FilePath $questdbExe.FullName -ArgumentList "start" -PassThru -NoNewWindow
+ Write-Host "Process started with PID: $($process.Id)"
+ Start-Sleep -Seconds 5
+
+ # Check if process is still running
+ if ($process.HasExited) {
+ Write-Host "WARNING: QuestDB process exited immediately with exit code: $($process.ExitCode)"
+ } else {
+ Write-Host "QuestDB process is running"
+ }
+ } finally {
+ Pop-Location
+ }
+
# Wait for QuestDB to be ready (up to 120 seconds)
- Write-Host "Waiting for QuestDB to start..."
+ Write-Host "Waiting for QuestDB to be ready..."
$maxAttempts = 120
+ $ready = $false
for ($i = 1; $i -le $maxAttempts; $i++) {
try {
$response = Invoke-WebRequest -Uri "http://localhost:9000/settings" -UseBasicParsing -ErrorAction SilentlyContinue
if ($response.StatusCode -eq 200) {
- Write-Host "QuestDB is ready"
+ Write-Host "QuestDB is ready after $i seconds"
+ $ready = $true
break
}
} catch {
@@ -144,6 +163,16 @@ steps:
}
Start-Sleep -Seconds 1
}
+
+ if (-not $ready) {
+ Write-Host "WARNING: QuestDB did not respond to health check"
+ $processes = Get-Process -Name questdb -ErrorAction SilentlyContinue
+ if ($processes) {
+ Write-Host "Found QuestDB processes: $($processes | ForEach-Object { $_.Id })"
+ } else {
+ Write-Host "No QuestDB processes found"
+ }
+ }
displayName: 'Install and Start QuestDB (Windows)'
condition: eq(variables['osName'], 'Windows')
diff --git a/src/net-questdb-client-tests/QuestDbManager.cs b/src/net-questdb-client-tests/QuestDbManager.cs
index af077a1..a22a22e 100644
--- a/src/net-questdb-client-tests/QuestDbManager.cs
+++ b/src/net-questdb-client-tests/QuestDbManager.cs
@@ -63,12 +63,15 @@ public void SetVolume(string volumeName)
public async Task EnsureDockerAvailableAsync()
{
// First, check if QuestDB is already running natively
+ Console.WriteLine("Checking for native QuestDB instance...");
if (await IsQuestDbNativelyAvailableAsync())
{
Console.WriteLine("QuestDB is running natively");
return;
}
+ Console.WriteLine("Native QuestDB not detected, falling back to Docker...");
+
// Fall back to Docker if native QuestDB is not available
try
{
@@ -84,7 +87,8 @@ public async Task EnsureDockerAvailableAsync()
{
throw new InvalidOperationException(
"QuestDB must be running (natively or via Docker). " +
- "Please install QuestDB from https://questdb.io/download/ or Docker from https://docs.docker.com/get-docker/",
+ "Please install QuestDB from https://questdb.io/download/ or Docker from https://docs.docker.com/get-docker/ " +
+ $"(Error: {ex.Message})",
ex);
}
}
@@ -94,16 +98,23 @@ public async Task EnsureDockerAvailableAsync()
///
private async Task IsQuestDbNativelyAvailableAsync()
{
+ Console.WriteLine($"Attempting to connect to native QuestDB at {GetHttpEndpoint()}/settings");
try
{
- // Try with a longer timeout for native instances that may be slower to initialize
- using var cts = new System.Threading.CancellationTokenSource(TimeSpan.FromSeconds(30));
+ // Try to connect with a reasonable timeout
+ using var cts = new System.Threading.CancellationTokenSource(TimeSpan.FromSeconds(60));
var response = await _httpClient.GetAsync($"{GetHttpEndpoint()}/settings", System.Net.Http.HttpCompletionOption.ResponseHeadersRead, cts.Token);
+ Console.WriteLine($"Native QuestDB health check returned status: {response.StatusCode}");
return response.IsSuccessStatusCode;
}
+ catch (TaskCanceledException ex)
+ {
+ Console.WriteLine($"Native QuestDB check timed out after 60 seconds: {ex.Message}");
+ return false;
+ }
catch (Exception ex)
{
- Console.WriteLine($"Native QuestDB availability check failed: {ex.Message}");
+ Console.WriteLine($"Native QuestDB check failed with exception: {ex.GetType().Name}: {ex.Message}");
return false;
}
}
From c6182a0822b2b94897939f4d14f37803d37ea617 Mon Sep 17 00:00:00 2001
From: Nick Woolmer <29717167+nwoolmer@users.noreply.github.com>
Date: Thu, 4 Dec 2025 21:10:49 +0000
Subject: [PATCH 32/40] ci
---
ci/azure-pipelines.yml | 89 +++++--------------
.../QuestDbManager.cs | 69 ++------------
2 files changed, 31 insertions(+), 127 deletions(-)
diff --git a/ci/azure-pipelines.yml b/ci/azure-pipelines.yml
index ce7fa69..34880e6 100644
--- a/ci/azure-pipelines.yml
+++ b/ci/azure-pipelines.yml
@@ -91,89 +91,48 @@ steps:
condition: eq(variables['osName'], 'Linux')
- script: |
- brew install questdb
- questdb start
- # Wait for QuestDB to be ready
- for i in {1..60}; do
- if curl -s http://localhost:9000/settings > /dev/null 2>&1; then
- echo "QuestDB is ready"
+ brew install colima docker
+ colima start --runtime docker
+ # Wait for Docker to be ready
+ for i in {1..120}; do
+ if docker ps >/dev/null 2>&1; then
+ echo "Docker is ready"
break
fi
- echo "Waiting for QuestDB... ($i/60)"
+ echo "Waiting for Docker... ($i/120)"
sleep 1
done
- displayName: 'Install QuestDB via Homebrew (macOS)'
+ displayName: 'Install Docker via Colima (macOS)'
condition: eq(variables['osName'], 'macOS')
- pwsh: |
- Write-Host "Downloading QuestDB..."
- $downloadUrl = "https://github.com/questdb/questdb/releases/download/9.2.2/questdb-9.2.2-rt-windows-x86-64.tar.gz"
- Invoke-WebRequest -Uri $downloadUrl -OutFile questdb.tar.gz
-
- Write-Host "Extracting QuestDB..."
- tar -xzf questdb.tar.gz
-
- Write-Host "Finding questdb.exe..."
- $questdbExe = Get-ChildItem -Recurse -Filter "questdb.exe" | Select-Object -First 1
- if (-not $questdbExe) {
- Write-Host "ERROR: questdb.exe not found after extraction"
- Get-ChildItem -Recurse
- exit 1
- }
+ Write-Host "Installing Docker Desktop..."
+ choco install docker-desktop -y --no-progress
- Write-Host "Found questdb.exe at: $($questdbExe.FullName)"
- $questdbDir = $questdbExe.Directory
-
- Write-Host "Starting QuestDB from directory: $questdbDir"
- Push-Location $questdbDir
- try {
- # Run questdb.exe directly and capture any errors
- Write-Host "Executing: $($questdbExe.FullName) start"
- $process = Start-Process -FilePath $questdbExe.FullName -ArgumentList "start" -PassThru -NoNewWindow
- Write-Host "Process started with PID: $($process.Id)"
- Start-Sleep -Seconds 5
-
- # Check if process is still running
- if ($process.HasExited) {
- Write-Host "WARNING: QuestDB process exited immediately with exit code: $($process.ExitCode)"
- } else {
- Write-Host "QuestDB process is running"
- }
- } finally {
- Pop-Location
- }
+ Write-Host "Waiting for Docker Desktop to be installed..."
+ Start-Sleep -Seconds 10
+
+ Write-Host "Starting Docker Desktop..."
+ Start-Process "C:\Program Files\Docker\Docker\Docker Desktop.exe"
- # Wait for QuestDB to be ready (up to 120 seconds)
- Write-Host "Waiting for QuestDB to be ready..."
- $maxAttempts = 120
- $ready = $false
+ Write-Host "Waiting for Docker daemon..."
+ $maxAttempts = 300
for ($i = 1; $i -le $maxAttempts; $i++) {
try {
- $response = Invoke-WebRequest -Uri "http://localhost:9000/settings" -UseBasicParsing -ErrorAction SilentlyContinue
- if ($response.StatusCode -eq 200) {
- Write-Host "QuestDB is ready after $i seconds"
- $ready = $true
+ $output = docker ps 2>&1
+ if ($LASTEXITCODE -eq 0) {
+ Write-Host "Docker is ready"
break
}
} catch {
- # Silently continue
+ # Continue
}
- if ($i % 30 -eq 0) {
- Write-Host "Still waiting... ($i/$maxAttempts seconds)"
+ if ($i % 60 -eq 0) {
+ Write-Host "Still waiting for Docker... ($i/$maxAttempts seconds)"
}
Start-Sleep -Seconds 1
}
-
- if (-not $ready) {
- Write-Host "WARNING: QuestDB did not respond to health check"
- $processes = Get-Process -Name questdb -ErrorAction SilentlyContinue
- if ($processes) {
- Write-Host "Found QuestDB processes: $($processes | ForEach-Object { $_.Id })"
- } else {
- Write-Host "No QuestDB processes found"
- }
- }
- displayName: 'Install and Start QuestDB (Windows)'
+ displayName: 'Install Docker Desktop (Windows)'
condition: eq(variables['osName'], 'Windows')
- task: DotNetCoreCLI@2
diff --git a/src/net-questdb-client-tests/QuestDbManager.cs b/src/net-questdb-client-tests/QuestDbManager.cs
index a22a22e..e766a5d 100644
--- a/src/net-questdb-client-tests/QuestDbManager.cs
+++ b/src/net-questdb-client-tests/QuestDbManager.cs
@@ -40,8 +40,8 @@ public async ValueTask DisposeAsync()
{
await StopAsync();
- // Clean up Docker volume if one was used (only if using Docker)
- if (!string.IsNullOrEmpty(_volumeName) && !IsNativeInstance)
+ // Clean up Docker volume if one was used
+ if (!string.IsNullOrEmpty(_volumeName))
{
await RunDockerCommandAsync($"volume rm {_volumeName}");
}
@@ -58,21 +58,10 @@ public void SetVolume(string volumeName)
}
///
- /// Ensures QuestDB is available (either native or Docker).
+ /// Ensures Docker is available.
///
public async Task EnsureDockerAvailableAsync()
{
- // First, check if QuestDB is already running natively
- Console.WriteLine("Checking for native QuestDB instance...");
- if (await IsQuestDbNativelyAvailableAsync())
- {
- Console.WriteLine("QuestDB is running natively");
- return;
- }
-
- Console.WriteLine("Native QuestDB not detected, falling back to Docker...");
-
- // Fall back to Docker if native QuestDB is not available
try
{
var (exitCode, output) = await RunDockerCommandAsync("--version");
@@ -86,39 +75,12 @@ public async Task EnsureDockerAvailableAsync()
catch (Exception ex)
{
throw new InvalidOperationException(
- "QuestDB must be running (natively or via Docker). " +
- "Please install QuestDB from https://questdb.io/download/ or Docker from https://docs.docker.com/get-docker/ " +
- $"(Error: {ex.Message})",
+ "Docker is required to run integration tests. " +
+ "Please install Docker from https://docs.docker.com/get-docker/",
ex);
}
}
- ///
- /// Checks if QuestDB is already running natively (not in Docker).
- ///
- private async Task IsQuestDbNativelyAvailableAsync()
- {
- Console.WriteLine($"Attempting to connect to native QuestDB at {GetHttpEndpoint()}/settings");
- try
- {
- // Try to connect with a reasonable timeout
- using var cts = new System.Threading.CancellationTokenSource(TimeSpan.FromSeconds(60));
- var response = await _httpClient.GetAsync($"{GetHttpEndpoint()}/settings", System.Net.Http.HttpCompletionOption.ResponseHeadersRead, cts.Token);
- Console.WriteLine($"Native QuestDB health check returned status: {response.StatusCode}");
- return response.IsSuccessStatusCode;
- }
- catch (TaskCanceledException ex)
- {
- Console.WriteLine($"Native QuestDB check timed out after 60 seconds: {ex.Message}");
- return false;
- }
- catch (Exception ex)
- {
- Console.WriteLine($"Native QuestDB check failed with exception: {ex.GetType().Name}: {ex.Message}");
- return false;
- }
- }
-
///
/// Ensures QuestDB Docker image is available (uses local if exists, otherwise pulls latest).
///
@@ -156,7 +118,7 @@ private async Task ImageExistsAsync()
}
///
- /// Starts the QuestDB container or connects to native instance.
+ /// Starts the QuestDB container.
///
public async Task StartAsync()
{
@@ -168,18 +130,6 @@ public async Task StartAsync()
await EnsureDockerAvailableAsync();
- // Check if QuestDB is already running natively
- if (await IsQuestDbNativelyAvailableAsync())
- {
- Console.WriteLine("Connecting to native QuestDB instance");
- IsRunning = true;
- // No need to wait, it's already running
- return;
- }
-
- // Use Docker to start QuestDB
- Console.WriteLine("Starting QuestDB via Docker container");
-
// Clean up any existing containers using these ports
await CleanupExistingContainersAsync();
@@ -219,7 +169,7 @@ public async Task StartAsync()
}
///
- /// Stops the QuestDB container (only if running in Docker).
+ /// Stops the QuestDB container.
///
public async Task StopAsync()
{
@@ -244,11 +194,6 @@ public async Task StopAsync()
Console.WriteLine("QuestDB container stopped");
}
- ///
- /// Checks if QuestDB is running as a native instance (not Docker).
- ///
- public bool IsNativeInstance => string.IsNullOrEmpty(_containerId) && IsRunning;
-
///
/// Gets the HTTP endpoint for QuestDB.
///
From 4847828a2757a66b9a93ae8c821f6c1bfa6511b2 Mon Sep 17 00:00:00 2001
From: Nick Woolmer <29717167+nwoolmer@users.noreply.github.com>
Date: Thu, 4 Dec 2025 22:12:43 +0000
Subject: [PATCH 33/40] ci
---
ci/azure-pipelines.yml | 49 +++++-------------------------------------
1 file changed, 5 insertions(+), 44 deletions(-)
diff --git a/ci/azure-pipelines.yml b/ci/azure-pipelines.yml
index 34880e6..bd1a3d8 100644
--- a/ci/azure-pipelines.yml
+++ b/ci/azure-pipelines.yml
@@ -90,50 +90,11 @@ steps:
displayName: 'Install QuestDB via systemd (Linux)'
condition: eq(variables['osName'], 'Linux')
-- script: |
- brew install colima docker
- colima start --runtime docker
- # Wait for Docker to be ready
- for i in {1..120}; do
- if docker ps >/dev/null 2>&1; then
- echo "Docker is ready"
- break
- fi
- echo "Waiting for Docker... ($i/120)"
- sleep 1
- done
- displayName: 'Install Docker via Colima (macOS)'
- condition: eq(variables['osName'], 'macOS')
-
-- pwsh: |
- Write-Host "Installing Docker Desktop..."
- choco install docker-desktop -y --no-progress
-
- Write-Host "Waiting for Docker Desktop to be installed..."
- Start-Sleep -Seconds 10
-
- Write-Host "Starting Docker Desktop..."
- Start-Process "C:\Program Files\Docker\Docker\Docker Desktop.exe"
-
- Write-Host "Waiting for Docker daemon..."
- $maxAttempts = 300
- for ($i = 1; $i -le $maxAttempts; $i++) {
- try {
- $output = docker ps 2>&1
- if ($LASTEXITCODE -eq 0) {
- Write-Host "Docker is ready"
- break
- }
- } catch {
- # Continue
- }
- if ($i % 60 -eq 0) {
- Write-Host "Still waiting for Docker... ($i/$maxAttempts seconds)"
- }
- Start-Sleep -Seconds 1
- }
- displayName: 'Install Docker Desktop (Windows)'
- condition: eq(variables['osName'], 'Windows')
+- task: Docker@2
+ displayName: 'Ensure Docker is available (macOS & Windows)'
+ condition: or(eq(variables['osName'], 'macOS'), eq(variables['osName'], 'Windows'))
+ inputs:
+ command: 'version'
- task: DotNetCoreCLI@2
displayName: 'Run tests on $(osName)'
From e49f7d3481d157f9d3f4436e0182a4724dcbe1da Mon Sep 17 00:00:00 2001
From: Nick Woolmer <29717167+nwoolmer@users.noreply.github.com>
Date: Thu, 4 Dec 2025 22:55:25 +0000
Subject: [PATCH 34/40] ci
---
ci/azure-pipelines.yml | 61 ++++++++++++++++++++++++++++++++++++++++++
1 file changed, 61 insertions(+)
diff --git a/ci/azure-pipelines.yml b/ci/azure-pipelines.yml
index bd1a3d8..b68fb7f 100644
--- a/ci/azure-pipelines.yml
+++ b/ci/azure-pipelines.yml
@@ -96,6 +96,67 @@ steps:
inputs:
command: 'version'
+- script: |
+ echo "Waiting for Docker to be fully ready on macOS..."
+ maxAttempts=120
+ for i in $(seq 1 $maxAttempts); do
+ if docker ps >/dev/null 2>&1; then
+ echo "Docker is ready to run containers"
+ exit 0
+ fi
+ echo "Waiting for Docker... ($i/$maxAttempts)"
+ sleep 1
+ done
+ echo "ERROR: Docker did not become ready after $maxAttempts seconds"
+ exit 1
+ displayName: 'Wait for Docker to be ready (macOS)'
+ condition: eq(variables['osName'], 'macOS')
+
+- pwsh: |
+ Write-Host "Checking Docker mode on Windows..."
+ $dockerInfo = docker info --format='{{.OSType}}'
+ Write-Host "Current Docker OS type: $dockerInfo"
+
+ if ($dockerInfo -eq "windows") {
+ Write-Host "Docker is in Windows mode, switching to Linux mode..."
+
+ # Get Docker Desktop config path
+ $configPath = "$env:APPDATA\Docker\settings.json"
+
+ if (Test-Path $configPath) {
+ $config = Get-Content $configPath | ConvertFrom-Json
+ $config.WSLDomain = "Ubuntu"
+ $config.UseWindowsContainers = $false
+ $config | ConvertTo-Json | Set-Content $configPath
+
+ Write-Host "Config updated, restarting Docker..."
+ # Kill Docker processes
+ Get-Process "Docker Desktop" -ErrorAction SilentlyContinue | Stop-Process -Force
+ Start-Sleep -Seconds 5
+
+ # Restart Docker Desktop
+ Start-Process "C:\Program Files\Docker\Docker\Docker Desktop.exe"
+ Start-Sleep -Seconds 30
+
+ Write-Host "Waiting for Docker to be ready in Linux mode..."
+ $maxAttempts = 60
+ for ($i = 1; $i -le $maxAttempts; $i++) {
+ try {
+ $mode = docker info --format='{{.OSType}}'
+ if ($mode -eq "linux") {
+ Write-Host "Docker is now in Linux mode"
+ break
+ }
+ } catch {
+ # Continue
+ }
+ Start-Sleep -Seconds 1
+ }
+ }
+ }
+ displayName: 'Switch Docker to Linux mode (Windows)'
+ condition: eq(variables['osName'], 'Windows')
+
- task: DotNetCoreCLI@2
displayName: 'Run tests on $(osName)'
inputs:
From 0b711e5fa3770b62a36d18f16ea65839047aebcf Mon Sep 17 00:00:00 2001
From: Nick Woolmer <29717167+nwoolmer@users.noreply.github.com>
Date: Thu, 4 Dec 2025 23:19:18 +0000
Subject: [PATCH 35/40] ci
---
ci/azure-pipelines.yml | 70 ++----------------------------------------
1 file changed, 2 insertions(+), 68 deletions(-)
diff --git a/ci/azure-pipelines.yml b/ci/azure-pipelines.yml
index b68fb7f..ec97972 100644
--- a/ci/azure-pipelines.yml
+++ b/ci/azure-pipelines.yml
@@ -90,80 +90,14 @@ steps:
displayName: 'Install QuestDB via systemd (Linux)'
condition: eq(variables['osName'], 'Linux')
-- task: Docker@2
- displayName: 'Ensure Docker is available (macOS & Windows)'
- condition: or(eq(variables['osName'], 'macOS'), eq(variables['osName'], 'Windows'))
- inputs:
- command: 'version'
-
-- script: |
- echo "Waiting for Docker to be fully ready on macOS..."
- maxAttempts=120
- for i in $(seq 1 $maxAttempts); do
- if docker ps >/dev/null 2>&1; then
- echo "Docker is ready to run containers"
- exit 0
- fi
- echo "Waiting for Docker... ($i/$maxAttempts)"
- sleep 1
- done
- echo "ERROR: Docker did not become ready after $maxAttempts seconds"
- exit 1
- displayName: 'Wait for Docker to be ready (macOS)'
- condition: eq(variables['osName'], 'macOS')
-
-- pwsh: |
- Write-Host "Checking Docker mode on Windows..."
- $dockerInfo = docker info --format='{{.OSType}}'
- Write-Host "Current Docker OS type: $dockerInfo"
-
- if ($dockerInfo -eq "windows") {
- Write-Host "Docker is in Windows mode, switching to Linux mode..."
-
- # Get Docker Desktop config path
- $configPath = "$env:APPDATA\Docker\settings.json"
-
- if (Test-Path $configPath) {
- $config = Get-Content $configPath | ConvertFrom-Json
- $config.WSLDomain = "Ubuntu"
- $config.UseWindowsContainers = $false
- $config | ConvertTo-Json | Set-Content $configPath
-
- Write-Host "Config updated, restarting Docker..."
- # Kill Docker processes
- Get-Process "Docker Desktop" -ErrorAction SilentlyContinue | Stop-Process -Force
- Start-Sleep -Seconds 5
-
- # Restart Docker Desktop
- Start-Process "C:\Program Files\Docker\Docker\Docker Desktop.exe"
- Start-Sleep -Seconds 30
-
- Write-Host "Waiting for Docker to be ready in Linux mode..."
- $maxAttempts = 60
- for ($i = 1; $i -le $maxAttempts; $i++) {
- try {
- $mode = docker info --format='{{.OSType}}'
- if ($mode -eq "linux") {
- Write-Host "Docker is now in Linux mode"
- break
- }
- } catch {
- # Continue
- }
- Start-Sleep -Seconds 1
- }
- }
- }
- displayName: 'Switch Docker to Linux mode (Windows)'
- condition: eq(variables['osName'], 'Windows')
-
- task: DotNetCoreCLI@2
- displayName: 'Run tests on $(osName)'
+ displayName: 'Run integration tests on $(osName)'
inputs:
command: 'test'
projects: 'src/net-questdb-client-tests/net-questdb-client-tests.csproj'
arguments: '--configuration $(buildConfiguration) --framework net9.0 --no-build --verbosity normal --logger trx --collect:"XPlat Code Coverage"'
publishTestResults: true
+ condition: eq(variables['osName'], 'Linux')
- task: PublishCodeCoverageResults@2
displayName: 'Publish code coverage'
From bec69ed4868b2824bf17660bea4bad20cc58ca36 Mon Sep 17 00:00:00 2001
From: Nick Woolmer <29717167+nwoolmer@users.noreply.github.com>
Date: Fri, 5 Dec 2025 10:31:27 +0000
Subject: [PATCH 36/40] Update ci/azure-pipelines.yml
---
ci/azure-pipelines.yml | 16 ----------------
1 file changed, 16 deletions(-)
diff --git a/ci/azure-pipelines.yml b/ci/azure-pipelines.yml
index ec97972..cbc12b1 100644
--- a/ci/azure-pipelines.yml
+++ b/ci/azure-pipelines.yml
@@ -73,22 +73,6 @@ steps:
projects: 'net-questdb-client.sln'
arguments: '--configuration $(buildConfiguration) --no-restore'
-- script: |
- sudo apt-get update
- sudo apt-get install -y questdb
- sudo systemctl start questdb
- sudo systemctl enable questdb
- # Wait for QuestDB to be ready
- for i in {1..60}; do
- if curl -s http://localhost:9000/settings > /dev/null 2>&1; then
- echo "QuestDB is ready"
- break
- fi
- echo "Waiting for QuestDB... ($i/60)"
- sleep 1
- done
- displayName: 'Install QuestDB via systemd (Linux)'
- condition: eq(variables['osName'], 'Linux')
- task: DotNetCoreCLI@2
displayName: 'Run integration tests on $(osName)'
From 977042470656b7035f91611990c983ea9370f827 Mon Sep 17 00:00:00 2001
From: Nick Woolmer <29717167+nwoolmer@users.noreply.github.com>
Date: Fri, 5 Dec 2025 10:33:28 +0000
Subject: [PATCH 37/40] Apply suggestions from code review
---
ci/azure-pipelines.yml | 4 +---
1 file changed, 1 insertion(+), 3 deletions(-)
diff --git a/ci/azure-pipelines.yml b/ci/azure-pipelines.yml
index cbc12b1..1248050 100644
--- a/ci/azure-pipelines.yml
+++ b/ci/azure-pipelines.yml
@@ -73,15 +73,13 @@ steps:
projects: 'net-questdb-client.sln'
arguments: '--configuration $(buildConfiguration) --no-restore'
-
- task: DotNetCoreCLI@2
- displayName: 'Run integration tests on $(osName)'
+ displayName: 'Run tests on $(osName)'
inputs:
command: 'test'
projects: 'src/net-questdb-client-tests/net-questdb-client-tests.csproj'
arguments: '--configuration $(buildConfiguration) --framework net9.0 --no-build --verbosity normal --logger trx --collect:"XPlat Code Coverage"'
publishTestResults: true
- condition: eq(variables['osName'], 'Linux')
- task: PublishCodeCoverageResults@2
displayName: 'Publish code coverage'
From 742b07c3a2e8c6257e707552224dfd236e8901c5 Mon Sep 17 00:00:00 2001
From: Nick Woolmer <29717167+nwoolmer@users.noreply.github.com>
Date: Fri, 5 Dec 2025 10:36:30 +0000
Subject: [PATCH 38/40] address
https://github.com/questdb/net-questdb-client/issues/50
---
src/net-questdb-client-tests/HttpTests.cs | 8 +++++---
src/net-questdb-client-tests/TcpTests.cs | 1 +
2 files changed, 6 insertions(+), 3 deletions(-)
diff --git a/src/net-questdb-client-tests/HttpTests.cs b/src/net-questdb-client-tests/HttpTests.cs
index abc366d..1a9fa2b 100644
--- a/src/net-questdb-client-tests/HttpTests.cs
+++ b/src/net-questdb-client-tests/HttpTests.cs
@@ -32,6 +32,7 @@
namespace net_questdb_client_tests;
+[SetCulture("en-us")]
public class HttpTests
{
private const string Host = "localhost";
@@ -1161,7 +1162,8 @@ await sender.Table("metrics")
await sender.SendAsync();
- var expected = "metrics,tag=value id1=\"550e8400-e29b-41d4-a716-446655440000\",letter1=\"X\",id2=\"6ba7b810-9dad-11d1-80b4-00c04fd430c8\",letter2=\"Y\" 1000000000\n";
+ var expected =
+ "metrics,tag=value id1=\"550e8400-e29b-41d4-a716-446655440000\",letter1=\"X\",id2=\"6ba7b810-9dad-11d1-80b4-00c04fd430c8\",letter2=\"Y\" 1000000000\n";
Assert.That(srv.PrintBuffer(), Is.EqualTo(expected));
}
@@ -1178,7 +1180,7 @@ public async Task SendNullableGuidColumn()
// Send with value
await sender.Table("metrics")
.Symbol("tag", "value1")
- .NullableColumn("id", (Guid?)guid)
+ .NullableColumn("id", guid)
.AtAsync(new DateTime(1970, 01, 01, 0, 0, 1));
// Send with null
@@ -1205,7 +1207,7 @@ public async Task SendNullableCharColumn()
// Send with value
await sender.Table("metrics")
.Symbol("tag", "value1")
- .NullableColumn("letter", (char?)'Z')
+ .NullableColumn("letter", 'Z')
.AtAsync(new DateTime(1970, 01, 01, 0, 0, 1));
// Send with null
diff --git a/src/net-questdb-client-tests/TcpTests.cs b/src/net-questdb-client-tests/TcpTests.cs
index 9d4e4df..5a5c5d6 100644
--- a/src/net-questdb-client-tests/TcpTests.cs
+++ b/src/net-questdb-client-tests/TcpTests.cs
@@ -38,6 +38,7 @@
namespace net_questdb_client_tests;
+[SetCulture("en-us")]
public class TcpTests
{
private readonly IPAddress _host = IPAddress.Loopback;
From 9141a8009f07577a79c7df109d05699dec01ae9d Mon Sep 17 00:00:00 2001
From: Nick Woolmer <29717167+nwoolmer@users.noreply.github.com>
Date: Fri, 5 Dec 2025 15:00:15 +0000
Subject: [PATCH 39/40] iterate
---
.../QuestDbIntegrationTests.cs | 49 +++++++++++--------
.../QuestDbManager.cs | 2 +-
2 files changed, 29 insertions(+), 22 deletions(-)
diff --git a/src/net-questdb-client-tests/QuestDbIntegrationTests.cs b/src/net-questdb-client-tests/QuestDbIntegrationTests.cs
index 5ce531d..68f40ed 100644
--- a/src/net-questdb-client-tests/QuestDbIntegrationTests.cs
+++ b/src/net-questdb-client-tests/QuestDbIntegrationTests.cs
@@ -24,8 +24,9 @@
using System.Text.Json;
using NUnit.Framework;
+using QuestDB;
-namespace QuestDB.Client.Tests;
+namespace net_questdb_client_tests;
///
/// Integration tests against a real QuestDB instance running in Docker.
@@ -199,15 +200,15 @@ await sender
[Test]
public async Task SendRowsWhileRestartingDatabase()
{
- const int rowsPerBatch = 10;
- const int numBatches = 5;
+ const int rowsPerBatch = 10;
+ const int numBatches = 5;
const int expectedTotalRows = rowsPerBatch * numBatches;
// Create a persistent Docker volume for the test database
var volumeName = $"questdb-test-vol-{Guid.NewGuid().ToString().Substring(0, 8)}";
// Use a separate QuestDB instance for this chaos test to avoid conflicts
- var testDb = new QuestDbManager(port: 29009, httpPort: 29000);
+ var testDb = new QuestDbManager(29009, 29000);
testDb.SetVolume(volumeName);
try
{
@@ -218,7 +219,7 @@ public async Task SendRowsWhileRestartingDatabase()
$"http::addr={httpEndpoint};auto_flush=off;retry_timeout=60000;");
var batchesSent = 0;
- var sendLock = new object();
+ var sendLock = new object();
// Task that restarts the database
var restartTask = Task.Run(async () =>
@@ -272,6 +273,7 @@ await sender
{
batchesSent++;
}
+
TestContext.WriteLine($"Batch {batch} sent successfully");
// Wait before next batch
@@ -295,24 +297,26 @@ await sender
// Query the row count, with retries
long actualRowCount = 0;
- var maxAttempts = 20;
+ var maxAttempts = 20;
for (var attempt = 0; attempt < maxAttempts; attempt++)
{
try
{
- using var client = new HttpClient { Timeout = TimeSpan.FromSeconds(5) };
- var response = await client.GetAsync($"{httpEndpoint}/exec?query=test_chaos");
+ using var client = new HttpClient { Timeout = TimeSpan.FromSeconds(5), };
+ var response = await client.GetAsync($"{httpEndpoint}/exec?query=test_chaos");
if (response.IsSuccessStatusCode)
{
var content = await response.Content.ReadAsStringAsync();
- var json = JsonDocument.Parse(content);
+ var json = JsonDocument.Parse(content);
if (json.RootElement.TryGetProperty("count", out var countProp))
{
actualRowCount = countProp.GetInt64();
TestContext.WriteLine($"Attempt {attempt + 1}: Found {actualRowCount} rows");
if (actualRowCount >= expectedTotalRows)
+ {
break;
+ }
}
}
}
@@ -342,16 +346,16 @@ await sender
[Test]
public async Task SendRowsWithMultiDatabaseFailover()
{
- const int rowsPerBatch = 10;
- const int numBatches = 5;
+ const int rowsPerBatch = 10;
+ const int numBatches = 5;
const int expectedTotalRows = rowsPerBatch * numBatches;
// Create two separate databases with persistent volumes
var volume1 = $"questdb-test-vol-db1-{Guid.NewGuid().ToString().Substring(0, 8)}";
var volume2 = $"questdb-test-vol-db2-{Guid.NewGuid().ToString().Substring(0, 8)}";
- var testDb1 = new QuestDbManager(port: 29009, httpPort: 29000);
- var testDb2 = new QuestDbManager(port: 29019, httpPort: 29010);
+ var testDb1 = new QuestDbManager(29009, 29000);
+ var testDb2 = new QuestDbManager(29019, 29010);
testDb1.SetVolume(volume1);
testDb2.SetVolume(volume2);
@@ -369,7 +373,7 @@ public async Task SendRowsWithMultiDatabaseFailover()
$"http::addr={endpoint1};addr={endpoint2};auto_flush=off;retry_timeout=60000;");
var batchesSent = 0;
- var sendLock = new object();
+ var sendLock = new object();
// Task that restarts DB1 after sends complete
var restartDb1Task = Task.Run(async () =>
@@ -429,6 +433,7 @@ await sender
{
batchesSent++;
}
+
TestContext.WriteLine($"Batch {batch} sent successfully");
await Task.Delay(500);
@@ -448,15 +453,15 @@ await sender
await Task.Delay(2000);
// Query both databases and sum the row counts
- var maxAttempts = 20;
- long count1 = 0;
- long count2 = 0;
+ var maxAttempts = 20;
+ long count1 = 0;
+ long count2 = 0;
for (var attempt = 0; attempt < maxAttempts; attempt++)
{
try
{
- using var client = new HttpClient { Timeout = TimeSpan.FromSeconds(5) };
+ using var client = new HttpClient { Timeout = TimeSpan.FromSeconds(5), };
// Query database 1
try
@@ -465,7 +470,7 @@ await sender
if (response1.IsSuccessStatusCode)
{
var content1 = await response1.Content.ReadAsStringAsync();
- var json1 = JsonDocument.Parse(content1);
+ var json1 = JsonDocument.Parse(content1);
if (json1.RootElement.TryGetProperty("count", out var countProp1))
{
count1 = countProp1.GetInt64();
@@ -484,7 +489,7 @@ await sender
if (response2.IsSuccessStatusCode)
{
var content2 = await response2.Content.ReadAsStringAsync();
- var json2 = JsonDocument.Parse(content2);
+ var json2 = JsonDocument.Parse(content2);
if (json2.RootElement.TryGetProperty("count", out var countProp2))
{
count2 = countProp2.GetInt64();
@@ -500,7 +505,9 @@ await sender
TestContext.WriteLine($"Attempt {attempt + 1}: DB1={count1}, DB2={count2}, Total={totalRowCount}");
if (totalRowCount >= expectedTotalRows)
+ {
break;
+ }
}
catch (Exception ex)
{
@@ -528,7 +535,7 @@ await sender
await testDb2.DisposeAsync();
}
}
-
+
private async Task VerifyTableHasDataAsync(string tableName)
{
var value = await GetTableRowCountAsync(tableName);
diff --git a/src/net-questdb-client-tests/QuestDbManager.cs b/src/net-questdb-client-tests/QuestDbManager.cs
index e766a5d..fc5ffc8 100644
--- a/src/net-questdb-client-tests/QuestDbManager.cs
+++ b/src/net-questdb-client-tests/QuestDbManager.cs
@@ -1,6 +1,6 @@
using System.Diagnostics;
-namespace QuestDB.Client.Tests;
+namespace net_questdb_client_tests;
///
/// Manages QuestDB server lifecycle for integration tests using Docker.
From b87d24ff35111d620e765609e7d9432907610567 Mon Sep 17 00:00:00 2001
From: Nick Woolmer <29717167+nwoolmer@users.noreply.github.com>
Date: Fri, 5 Dec 2025 15:02:40 +0000
Subject: [PATCH 40/40] ci
---
ci/azure-pipelines.yml | 12 +++++++++++-
1 file changed, 11 insertions(+), 1 deletion(-)
diff --git a/ci/azure-pipelines.yml b/ci/azure-pipelines.yml
index 1248050..9a7045d 100644
--- a/ci/azure-pipelines.yml
+++ b/ci/azure-pipelines.yml
@@ -74,12 +74,22 @@ steps:
arguments: '--configuration $(buildConfiguration) --no-restore'
- task: DotNetCoreCLI@2
- displayName: 'Run tests on $(osName)'
+ displayName: 'Run all tests on $(osName)'
inputs:
command: 'test'
projects: 'src/net-questdb-client-tests/net-questdb-client-tests.csproj'
arguments: '--configuration $(buildConfiguration) --framework net9.0 --no-build --verbosity normal --logger trx --collect:"XPlat Code Coverage"'
publishTestResults: true
+ condition: eq(variables['osName'], 'Linux')
+
+- task: DotNetCoreCLI@2
+ displayName: 'Run tests on $(osName) (excluding integration tests)'
+ inputs:
+ command: 'test'
+ projects: 'src/net-questdb-client-tests/net-questdb-client-tests.csproj'
+ arguments: '--configuration $(buildConfiguration) --framework net9.0 --no-build --verbosity normal --logger trx --collect:"XPlat Code Coverage" --filter "FullyQualifiedName!~QuestDbIntegrationTests"'
+ publishTestResults: true
+ condition: ne(variables['osName'], 'Linux')
- task: PublishCodeCoverageResults@2
displayName: 'Publish code coverage'