Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
49124d1
Add the connection pool: concurrent operations over one client
alex-clickhouse Aug 15, 2026
bc5664e
Address review: drop M12, close the leased connections, guard the sweep
alex-clickhouse Aug 15, 2026
33d3084
Address re-review: best-effort closes, and abort rather than terminate
alex-clickhouse Aug 15, 2026
b4247dc
Fix the Release build: do not name the experimental client type
alex-clickhouse Aug 15, 2026
c03ec70
Hold MinPoolSize: the sweep tops the pool back up to the floor
alex-clickhouse Aug 15, 2026
1240484
Cover the cap with top-ups competing for permits
alex-clickhouse Aug 15, 2026
a4b54c0
Address review: release the pooled buffers after an abort
alex-clickhouse Aug 15, 2026
2cdeb74
Pin repeated and concurrent disposal of the reader and writer
alex-clickhouse Aug 15, 2026
b8fb029
Address review: count the dials in flight, and cancel them on dispose
alex-clickhouse Aug 15, 2026
44ad716
Cover the two disposal races the drain cannot reach
alex-clickhouse Aug 15, 2026
025e474
Address re-review: hold the slot for the whole checkout
alex-clickhouse Aug 15, 2026
1d430c6
Fix the sweep throwing whenever nothing is idle
alex-clickhouse Aug 15, 2026
69f59a1
Treat idleness as a liveness limit, not just a resource one
alex-clickhouse Aug 16, 2026
0296302
Address the follow-up: sweep allocation, sweep period, and the docs
alex-clickhouse Aug 16, 2026
36af167
Update pooled row inserts for InsertRowsAsync
alex-clickhouse Aug 28, 2026
ee08bc8
Retire TCP connections after server exceptions
alex-clickhouse Aug 30, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,12 @@ public void Defaults_WhenNotOverridden_MatchNativeProtocolConventions()
Assert.That(options.DialTimeout, Is.EqualTo(TimeSpan.FromSeconds(30)));
Assert.That(options.ReadTimeout, Is.EqualTo(TimeSpan.FromSeconds(300)));
Assert.That(options.MaxSendBufferBytes, Is.EqualTo(1024 * 1024));
Assert.That(options.MinPoolSize, Is.Zero);
Assert.That(options.MaxPoolSize, Is.EqualTo(20));
Assert.That(options.PoolTimeout, Is.EqualTo(TimeSpan.FromSeconds(30)));
Assert.That(options.MaxConnectionLifetime, Is.EqualTo(TimeSpan.FromMinutes(30)));
Assert.That(options.IdleTimeout, Is.EqualTo(TimeSpan.FromMinutes(5)));
Assert.That(options.PoolReusePolicy, Is.EqualTo(ClickHouseTcpPoolReusePolicy.Lifo));
});
}

Expand Down Expand Up @@ -49,6 +55,22 @@ public void Validate_EmptyUsername_ThrowsArgumentException()
Assert.Throws<ArgumentException>(() => options.Validate());
}

[Test]
public void Validate_EmptyDatabase_ThrowsArgumentException()
{
var options = new ClickHouseTcpClientOptions { Database = "" };

Assert.Throws<ArgumentException>(() => options.Validate());
}

[Test]
public void Validate_NonPositiveReadTimeout_ThrowsArgumentOutOfRangeException()
{
var options = new ClickHouseTcpClientOptions { ReadTimeout = TimeSpan.Zero };

Assert.Throws<ArgumentOutOfRangeException>(() => options.Validate());
}

[TestCase(0)]
[TestCase(-1)]
[TestCase(65536)]
Expand All @@ -75,6 +97,154 @@ public void Validate_NonPositiveMaxSendBufferBytes_ThrowsArgumentOutOfRangeExcep
Assert.Throws<ArgumentOutOfRangeException>(() => options.Validate());
}

[Test]
public void Validate_NonPositivePoolTimeout_ThrowsArgumentOutOfRangeException()
{
var options = new ClickHouseTcpClientOptions { PoolTimeout = TimeSpan.Zero };

Assert.Throws<ArgumentOutOfRangeException>(() => options.Validate());
}

[TestCase(0)]
[TestCase(-1)]
public void Validate_MaxPoolSizeBelowOne_ThrowsArgumentOutOfRangeException(int maxPoolSize)
{
var options = new ClickHouseTcpClientOptions { MaxPoolSize = maxPoolSize };

Assert.Throws<ArgumentOutOfRangeException>(() => options.Validate());
}

[Test]
public void Validate_NegativeMinPoolSize_ThrowsArgumentOutOfRangeException()
{
var options = new ClickHouseTcpClientOptions { MinPoolSize = -1 };

Assert.Throws<ArgumentOutOfRangeException>(() => options.Validate());
}

[Test]
public void Validate_NoSweepInterval_DoesNotThrow()
{
// Null is not a value out of range but a request to derive the period, so it must pass validation.
var options = new ClickHouseTcpClientOptions { SweepInterval = null };

Assert.Multiple(() =>
{
Assert.DoesNotThrow(() => options.Validate());
Assert.That(new ClickHouseTcpClientOptions().SweepInterval, Is.Null, "deriving the period is the default");
});
}

[TestCase(0)]
[TestCase(-1)]
public void Validate_NonPositiveSweepInterval_ThrowsArgumentOutOfRangeException(int seconds)
{
// Zero does not mean "no sweep" here, unlike the two limits: that is what leaving it null does.
var options = new ClickHouseTcpClientOptions { SweepInterval = TimeSpan.FromSeconds(seconds) };

var thrown = Assert.Throws<ArgumentOutOfRangeException>(() => options.Validate());

Assert.That(thrown.Message, Does.Contain("SweepInterval"));
}

[Test]
public void Validate_SweepIntervalTooLargeToArm_ThrowsArgumentOutOfRangeException()
{
var options = new ClickHouseTcpClientOptions { SweepInterval = TimeSpan.FromDays(30) };

Assert.Throws<ArgumentOutOfRangeException>(() => options.Validate());
}

[Test]
public void Validate_MinPoolSizeAboveMaxPoolSize_ThrowsArgumentOutOfRangeException()
{
var options = new ClickHouseTcpClientOptions { MinPoolSize = 5, MaxPoolSize = 4 };

var thrown = Assert.Throws<ArgumentOutOfRangeException>(() => options.Validate());

Assert.That(thrown.Message, Does.Contain("MaxPoolSize (4)"));
}

[Test]
public void Validate_MinPoolSizeEqualToMaxPoolSize_DoesNotThrow()
{
var options = new ClickHouseTcpClientOptions { MinPoolSize = 4, MaxPoolSize = 4 };

Assert.DoesNotThrow(() => options.Validate());
}

[Test]
public void Validate_NegativeMaxConnectionLifetime_ThrowsArgumentOutOfRangeException()
{
var options = new ClickHouseTcpClientOptions { MaxConnectionLifetime = TimeSpan.FromSeconds(-1) };

Assert.Throws<ArgumentOutOfRangeException>(() => options.Validate());
}

[Test]
public void Validate_NegativeIdleTimeout_ThrowsArgumentOutOfRangeException()
{
var options = new ClickHouseTcpClientOptions { IdleTimeout = TimeSpan.FromSeconds(-1) };

Assert.Throws<ArgumentOutOfRangeException>(() => options.Validate());
}

[Test]
public void Validate_LifetimeAndIdleTimeoutDisabled_DoesNotThrow()
{
var options = new ClickHouseTcpClientOptions { MaxConnectionLifetime = TimeSpan.Zero, IdleTimeout = TimeSpan.Zero };

Assert.DoesNotThrow(() => options.Validate());
}

[Test]
public void Validate_ShortMaxConnectionLifetime_IsAccepted()
{
// Nothing forbids rotating connections quickly; the pool never interrupts a running operation for age,
// so a short lifetime only means a connection is retired sooner once it comes back.
var options = new ClickHouseTcpClientOptions { MaxConnectionLifetime = TimeSpan.FromSeconds(10) };

Assert.DoesNotThrow(() => options.Validate());
}

[TestCase(nameof(ClickHouseTcpClientOptions.DialTimeout))]
[TestCase(nameof(ClickHouseTcpClientOptions.ReadTimeout))]
[TestCase(nameof(ClickHouseTcpClientOptions.PoolTimeout))]
public void Validate_TimeoutBeyondWhatATimerCanHold_ThrowsAtConstructionNotAtEveryOperation(string property)
{
// These feed CancelAfter / SemaphoreSlim.WaitAsync, which take an int millisecond count. Past ~24.8 days
// the failure would otherwise surface from inside every operation instead of here.
var tooLong = TimeSpan.FromMilliseconds(int.MaxValue) + TimeSpan.FromSeconds(1);
var options = property switch
{
nameof(ClickHouseTcpClientOptions.DialTimeout) => new ClickHouseTcpClientOptions { DialTimeout = tooLong },
nameof(ClickHouseTcpClientOptions.ReadTimeout) => new ClickHouseTcpClientOptions { ReadTimeout = tooLong },
_ => new ClickHouseTcpClientOptions { PoolTimeout = tooLong },
};

var thrown = Assert.Throws<ArgumentOutOfRangeException>(() => options.Validate());

Assert.That(thrown.ParamName, Is.EqualTo(property));
}

[Test]
public void Validate_TimeoutAtExactlyWhatATimerCanHold_IsAccepted()
{
// The boundary itself: rejecting it too would be an off-by-one that only shows up at an absurd setting.
var atLimit = TimeSpan.FromMilliseconds(int.MaxValue);
var options = new ClickHouseTcpClientOptions { DialTimeout = atLimit, ReadTimeout = atLimit, PoolTimeout = atLimit };

Assert.DoesNotThrow(() => options.Validate());
}

[Test]
public void Validate_UndefinedPoolReusePolicy_ThrowsArgumentOutOfRangeException()
{
var options = new ClickHouseTcpClientOptions { PoolReusePolicy = (ClickHouseTcpPoolReusePolicy)42 };

Assert.Throws<ArgumentOutOfRangeException>(() => options.Validate());
}

[Test]
public void Validate_CustomSettingWithEmptyName_ThrowsArgumentException()
{
Expand Down Expand Up @@ -142,6 +312,13 @@ public void WithOwnedCustomSettings_CopiesEveryPropertyAndSnapshotsTheSettings()
MaxSendBufferBytes = 4096,
DialTimeout = TimeSpan.FromSeconds(3),
ReadTimeout = TimeSpan.FromSeconds(4),
MinPoolSize = 1,
MaxPoolSize = 7,
PoolTimeout = TimeSpan.FromSeconds(5),
MaxConnectionLifetime = TimeSpan.FromSeconds(6),
IdleTimeout = TimeSpan.FromSeconds(7),
SweepInterval = TimeSpan.FromSeconds(8),
PoolReusePolicy = ClickHouseTcpPoolReusePolicy.Fifo,
};
var defaults = new ClickHouseTcpClientOptions();

Expand Down Expand Up @@ -214,6 +391,12 @@ public void With_ChangingOneProperty_CarriesEveryOtherPropertyAcross()
MaxSendBufferBytes = 4096,
DialTimeout = TimeSpan.FromSeconds(3),
ReadTimeout = TimeSpan.FromSeconds(4),
MinPoolSize = 1,
MaxPoolSize = 7,
PoolTimeout = TimeSpan.FromSeconds(5),
MaxConnectionLifetime = TimeSpan.FromSeconds(6),
IdleTimeout = TimeSpan.FromSeconds(7),
PoolReusePolicy = ClickHouseTcpPoolReusePolicy.Fifo,
Comment thread
cursor[bot] marked this conversation as resolved.
};

var derived = original with { Port = 9440 };
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -155,6 +155,112 @@ public void TypedSetters_ReadBackOnSameInstance_ReturnValuesNotDefaults()
});
}

[Test]
public void ToOptions_PoolKeys_ParsesEachValue()
{
var builder = new ClickHouseTcpConnectionStringBuilder(
"Host=h;MinPoolSize=2;MaxPoolSize=8;PoolTimeout=15;MaxConnectionLifetime=600;IdleTimeout=120;SweepInterval=5;PoolReusePolicy=Fifo");

var options = builder.ToOptions();

Assert.Multiple(() =>
{
Assert.That(options.MinPoolSize, Is.EqualTo(2));
Assert.That(options.MaxPoolSize, Is.EqualTo(8));
Assert.That(options.PoolTimeout, Is.EqualTo(TimeSpan.FromSeconds(15)));
Assert.That(options.MaxConnectionLifetime, Is.EqualTo(TimeSpan.FromMinutes(10)));
Assert.That(options.IdleTimeout, Is.EqualTo(TimeSpan.FromMinutes(2)));
Assert.That(options.SweepInterval, Is.EqualTo(TimeSpan.FromSeconds(5)));
Assert.That(options.PoolReusePolicy, Is.EqualTo(ClickHouseTcpPoolReusePolicy.Fifo));
});
}

[Test]
public void ToOptions_MissingPoolKeys_AppliesDefaults()
{
var options = new ClickHouseTcpConnectionStringBuilder("Host=h").ToOptions();

Assert.Multiple(() =>
{
Assert.That(options.MinPoolSize, Is.Zero);
Assert.That(options.MaxPoolSize, Is.EqualTo(20));
Assert.That(options.PoolTimeout, Is.EqualTo(TimeSpan.FromSeconds(30)));
Assert.That(options.MaxConnectionLifetime, Is.EqualTo(TimeSpan.FromMinutes(30)));
Assert.That(options.IdleTimeout, Is.EqualTo(TimeSpan.FromMinutes(5)));
Assert.That(options.SweepInterval, Is.Null, "an absent key means the period is derived");
Assert.That(options.PoolReusePolicy, Is.EqualTo(ClickHouseTcpPoolReusePolicy.Lifo));
});
}

[Test]
public void ToOptions_PoolReusePolicyInAnyCase_IsAccepted()
{
var options = new ClickHouseTcpConnectionStringBuilder("Host=h;PoolReusePolicy=fifo").ToOptions();

Assert.That(options.PoolReusePolicy, Is.EqualTo(ClickHouseTcpPoolReusePolicy.Fifo));
}

[Test]
public void ToOptions_UnknownPoolReusePolicy_ThrowsNamingTheAcceptedValues()
{
var builder = new ClickHouseTcpConnectionStringBuilder("Host=h;PoolReusePolicy=Random");

var thrown = Assert.Throws<ArgumentException>(() => builder.ToOptions());

Assert.That(thrown.Message, Does.Contain("Lifo").And.Contains("Fifo"));
}

[Test]
public void PoolTypedSetters_ReadBackOnSameInstance_ReturnValuesNotDefaults()
{
var builder = new ClickHouseTcpConnectionStringBuilder
{
MinPoolSize = 3,
MaxPoolSize = 9,
PoolTimeout = TimeSpan.FromSeconds(11),
MaxConnectionLifetime = TimeSpan.FromMinutes(20),
IdleTimeout = TimeSpan.FromMinutes(3),
SweepInterval = TimeSpan.FromSeconds(4),
PoolReusePolicy = ClickHouseTcpPoolReusePolicy.Fifo,
};

Assert.Multiple(() =>
{
Assert.That(builder.MinPoolSize, Is.EqualTo(3));
Assert.That(builder.MaxPoolSize, Is.EqualTo(9));
Assert.That(builder.PoolTimeout, Is.EqualTo(TimeSpan.FromSeconds(11)));
Assert.That(builder.MaxConnectionLifetime, Is.EqualTo(TimeSpan.FromMinutes(20)));
Assert.That(builder.IdleTimeout, Is.EqualTo(TimeSpan.FromMinutes(3)));
Assert.That(builder.SweepInterval, Is.EqualTo(TimeSpan.FromSeconds(4)));
Assert.That(builder.PoolReusePolicy, Is.EqualTo(ClickHouseTcpPoolReusePolicy.Fifo));
});
}

[Test]
public void SweepInterval_SetToNull_RemovesTheKeyRatherThanStoringAValue()
{
// Null is how a caller goes back to deriving the period, so it must leave no key behind: a stored zero
// would fail validation, and any stored number would keep overriding the derivation.
var builder = new ClickHouseTcpConnectionStringBuilder { SweepInterval = TimeSpan.FromSeconds(4) };

builder.SweepInterval = null;

Assert.Multiple(() =>
{
Assert.That(builder.SweepInterval, Is.Null);
Assert.That(builder.ConnectionString, Does.Not.Contain("SweepInterval").IgnoreCase);
Assert.That(builder.ToOptions().SweepInterval, Is.Null);
});
}

[Test]
public void SweepInterval_Unparseable_ReadsAsAbsentSoThePeriodIsDerived()
{
var builder = new ClickHouseTcpConnectionStringBuilder("Host=h;SweepInterval=soon");

Assert.That(builder.SweepInterval, Is.Null);
}

[Test]
public void Setters_RoundTripThroughConnectionString()
{
Expand Down
Loading
Loading