Skip to content

Commit 178ebb0

Browse files
authored
Fix SessionPool blocking indefinitely after a server outage(#62)
1 parent 8214361 commit 178ebb0

8 files changed

Lines changed: 598 additions & 41 deletions

docs/API.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,7 @@ var tablet =
4444
| -------------- | ------------------------- | ------------------------ | ----------------------------- |
4545
| Open | bool | open session | session_pool.Open(false) |
4646
| Close | null | close session | session_pool.Close() |
47-
| IsOpen | null | check if session is open | session_pool.IsOpen() |
47+
| IsOpen | null | check if the pool was opened and not yet closed by the caller. It is a lifecycle flag, **not** a connectivity probe: it stays `true` after the server goes down, because the client keeps no heartbeat and reconnects on demand instead. | session_pool.IsOpen() |
4848
| OpenDebugMode | LoggingConfiguration=null | open debug mode | session_pool.OpenDebugMode() |
4949
| CloseDebugMode | null | close debug mode | session_pool.CloseDebugMode() |
5050
| SetTimeZone | string | set time zone | session_pool.GetTimeZone() |

docs/SessionPool_Exception_Handling.md

Lines changed: 104 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -34,9 +34,9 @@ using System;
3434
try
3535
{
3636
var sessionPool = new SessionPool.Builder()
37-
.Host("127.0.0.1")
38-
.Port(6667)
39-
.PoolSize(4)
37+
.SetHost("127.0.0.1")
38+
.SetPort(6667)
39+
.SetPoolSize(4)
4040
.Build();
4141

4242
await sessionPool.Open();
@@ -54,6 +54,52 @@ catch (SessionPoolDepletedException ex)
5454
}
5555
```
5656

57+
## `IsOpen()` is a lifecycle flag, not a health check
58+
59+
`SessionPool.IsOpen()` reports whether **you** have opened the pool and not yet closed it. It is not a
60+
connectivity probe:
61+
62+
- It becomes `true` after a successful `Open()` and only returns to `false` when you call `Close()`.
63+
- The client runs no heartbeat, so a server that goes down does **not** flip it back to `false`.
64+
Reconnection happens lazily, on the next operation.
65+
66+
This means the following common guard never re-opens the pool, because the flag stays `true` forever:
67+
68+
```csharp
69+
// Anti-pattern: this short-circuits even while every connection is dead
70+
if (_pool != null && _pool.IsOpen()) return;
71+
```
72+
73+
To reason about actual availability, use the health metrics below, or simply let an operation throw
74+
`SessionPoolDepletedException` and handle it.
75+
76+
## Pool Wait Timeout
77+
78+
Two independent timeouts govern a pool operation:
79+
80+
| Setting | Unit | Default | Controls |
81+
| ----------------------------------------- | ---- | ------- | ------------------------------------------------------------------------- |
82+
| `SetConnectionTimeoutInMs(int)` | ms | 500 | Socket-level send/receive timeout of an individual connection |
83+
| `SetPoolWaitTimeoutInMs(int)` | ms | 10000 | How long an operation waits for a free client before the pool gives up |
84+
85+
```csharp
86+
var sessionPool = new SessionPool.Builder()
87+
.SetHost("127.0.0.1")
88+
.SetPort(6667)
89+
.SetPoolSize(8)
90+
.SetConnectionTimeoutInMs(500) // socket timeout
91+
.SetPoolWaitTimeoutInMs(10_000) // give up after 10s of waiting for a free client
92+
.Build();
93+
```
94+
95+
When the wait budget is exhausted, the operation throws `SessionPoolDepletedException` with the reason
96+
`Connection pool is empty and wait time out(...ms)`. Raise `SetPoolWaitTimeoutInMs` if your workload
97+
legitimately queues behind long operations; lower it if you would rather fail fast and retry.
98+
99+
> **Note:** before this setting existed, the wait budget was derived from the connection timeout and then
100+
> misinterpreted as seconds, which turned the 500 ms default into a ~41 minute block. If you are upgrading
101+
> from an older version and relied on that (unintended) long wait, set `SetPoolWaitTimeoutInMs` explicitly.
102+
57103
## Pool Health Metrics
58104

59105
### Monitoring Pool Status
@@ -62,16 +108,17 @@ The `SessionPool` class exposes real-time health metrics that can be used for mo
62108

63109
```csharp
64110
var sessionPool = new SessionPool.Builder()
65-
.Host("127.0.0.1")
66-
.Port(6667)
67-
.PoolSize(8)
111+
.SetHost("127.0.0.1")
112+
.SetPort(6667)
113+
.SetPoolSize(8)
68114
.Build();
69115

70116
await sessionPool.Open();
71117

72118
// Check pool health
73119
Console.WriteLine($"Available Clients: {sessionPool.AvailableClients}");
74120
Console.WriteLine($"Total Pool Size: {sessionPool.TotalPoolSize}");
121+
Console.WriteLine($"Unrealized Capacity: {sessionPool.UnrealizedCapacity}");
75122
Console.WriteLine($"Failed Reconnections: {sessionPool.FailedReconnections}");
76123
```
77124

@@ -81,8 +128,27 @@ Console.WriteLine($"Failed Reconnections: {sessionPool.FailedReconnections}");
81128
| -------------------- | --------------------- | ------------------------------------------------ | --------------------------- |
82129
| Available Clients | `AvailableClients` | Number of idle clients ready for use | Alert if < 25% of pool size |
83130
| Total Pool Size | `TotalPoolSize` | Configured maximum pool size | N/A (constant) |
131+
| Unrealized Capacity | `UnrealizedCapacity` | Configured capacity currently holding no connection, refilled on demand | Not an alert signal on its own - see below |
84132
| Failed Reconnections | `FailedReconnections` | Cumulative count of failed reconnection attempts | Alert if > 0 and increasing |
85133

134+
### Capacity is demand-driven
135+
136+
When an operation fails and reconnection also fails, the dead connection is discarded but its **capacity is
137+
retained** rather than lost. `UnrealizedCapacity` counts the capacity left without a connection, and an
138+
acquisition that finds no idle client materializes one connection before falling back to waiting.
139+
Consequences:
140+
141+
- The pool no longer shrinks by one on every failure, so it cannot reach the state where every caller blocks
142+
on a queue nobody will feed.
143+
- Once the server is reachable again, the pool repopulates itself as load demands it - no `Close()` +
144+
`Open()` cycle is required.
145+
- **Capacity is refilled on demand, not eagerly.** A connection is only created when an acquisition finds the
146+
idle queue empty. Under sequential or light workloads one connection is enough to serve every request, so
147+
`UnrealizedCapacity` legitimately stays above zero long after the server has fully recovered. It measures
148+
how much of the configured pool has not been materialized, not server availability.
149+
- Therefore **do not alert on `UnrealizedCapacity` alone.** Use `FailedReconnections` to reason about server
150+
reachability: it only increases when a reconnection actually fails.
151+
86152
## Failure Scenarios and Recovery Strategies
87153

88154
### Scenario 1: Pool Exhaustion (High Load)
@@ -101,9 +167,9 @@ Console.WriteLine($"Failed Reconnections: {sessionPool.FailedReconnections}");
101167

102168
```csharp
103169
var sessionPool = new SessionPool.Builder()
104-
.Host("127.0.0.1")
105-
.Port(6667)
106-
.PoolSize(16) // Increased from 8
170+
.SetHost("127.0.0.1")
171+
.SetPort(6667)
172+
.SetPoolSize(16) // Increased from 8
107173
.Build();
108174
```
109175

@@ -137,13 +203,25 @@ for (int i = 0; i < maxRetries; i++)
137203
**Symptoms:**
138204

139205
- `SessionPoolDepletedException` with reason "Reconnection failed"
140-
- `AvailableClients` decreases over time
141-
- `FailedReconnections` > 0 and increasing
206+
- `AvailableClients` drops toward 0 while `UnrealizedCapacity` rises
207+
- `FailedReconnections` > 0 and increasing (this, not `UnrealizedCapacity`, is the outage signal)
142208

143209
**Root Cause:** IoTDB server unreachable or network issues
144210

145211
**Recovery Strategies:**
146212

213+
0. **Do nothing but retry.** Capacity is retained and refilled on demand, so once the server comes back a
214+
plain retry succeeds. Reinitialising is only needed if you want to change configuration or drop
215+
accumulated state:
216+
217+
```csharp
218+
catch (SessionPoolDepletedException ex)
219+
{
220+
// Capacity is retained and refilled on demand - just back off and try again
221+
await Task.Delay(2000);
222+
}
223+
```
224+
147225
1. **Reinitialize SessionPool:**
148226

149227
```csharp
@@ -159,9 +237,9 @@ catch (SessionPoolDepletedException ex) when (ex.FailedReconnections > 5)
159237

160238
// Create new pool
161239
sessionPool = new SessionPool.Builder()
162-
.Host("127.0.0.1")
163-
.Port(6667)
164-
.PoolSize(8)
240+
.SetHost("127.0.0.1")
241+
.SetPort(6667)
242+
.SetPoolSize(8)
165243
.Build();
166244

167245
await sessionPool.Open();
@@ -245,9 +323,9 @@ public async Task RateLimitedInsert(string deviceId, RowRecord record)
245323

246324
```csharp
247325
var sessionPool = new SessionPool.Builder()
248-
.Host("127.0.0.1")
249-
.Port(6667)
250-
.Timeout(120) // Increased timeout for slow server
326+
.SetHost("127.0.0.1")
327+
.SetPort(6667)
328+
.SetConnectionTimeoutInMs(5000) // Increased socket timeout for a slow server
251329
.Build();
252330
```
253331

@@ -381,10 +459,10 @@ public class ProductionSessionPoolManager
381459
public async Task Initialize()
382460
{
383461
_pool = new SessionPool.Builder()
384-
.Host("127.0.0.1")
385-
.Port(6667)
386-
.PoolSize(8)
387-
.Timeout(60)
462+
.SetHost("127.0.0.1")
463+
.SetPort(6667)
464+
.SetPoolSize(8)
465+
.SetConnectionTimeoutInMs(5000)
388466
.Build();
389467

390468
await _pool.Open();
@@ -481,7 +559,11 @@ public class ProductionSessionPoolManager
481559
The SessionPool exception handling and health monitoring features provide comprehensive tools for building robust IoTDB applications:
482560

483561
- Use `SessionPoolDepletedException` to understand and react to pool issues
484-
- Monitor `AvailableClients`, `TotalPoolSize`, and `FailedReconnections` metrics
562+
- Treat `IsOpen()` as a lifecycle flag, never as a connectivity check
563+
- Tune `SetPoolWaitTimeoutInMs` separately from `SetConnectionTimeoutInMs`
564+
- Monitor `AvailableClients`, `TotalPoolSize`, and `FailedReconnections`; read `UnrealizedCapacity` as
565+
capacity not yet materialized rather than as an outage signal
566+
- Rely on demand-driven capacity refill for recovery; reinitialise only when you need to change configuration
485567
- Implement appropriate recovery strategies based on failure scenarios
486568
- Set up proactive monitoring and alerting to prevent issues
487569
- Follow best practices for pool sizing and resource management

src/Apache.IoTDB/ConcurrentClientQueue.cs

Lines changed: 33 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -58,26 +58,52 @@ public void Return(Client client)
5858
public void AddRef() => Interlocked.Increment(ref _ref);
5959
public int GetRef() => Volatile.Read(ref _ref);
6060
public void RemoveRef() => Interlocked.Decrement(ref _ref);
61-
public int Timeout { get; set; } = 10;
61+
62+
/// <summary>
63+
/// The maximum time, in milliseconds, that <see cref="Take"/> waits for a client to be
64+
/// returned to the pool before throwing. Defaults to 10000 (10 seconds).
65+
/// </summary>
66+
public int TimeoutInMs { get; set; } = DefaultTimeoutInMs;
67+
68+
internal const int DefaultTimeoutInMs = 10_000;
69+
70+
/// <summary>
71+
/// The wait timeout expressed in seconds. Kept for backward compatibility only; it is a thin
72+
/// wrapper over <see cref="TimeoutInMs"/>. Prefer <see cref="TimeoutInMs"/>, which avoids the
73+
/// unit ambiguity that previously caused millisecond values to be interpreted as seconds.
74+
/// </summary>
75+
[Obsolete("Use TimeoutInMs instead. This property interprets its value as seconds.")]
76+
public int Timeout
77+
{
78+
get => TimeoutInMs / 1000;
79+
set => TimeoutInMs = value * 1000;
80+
}
81+
6282
public Client Take()
6383
{
6484
Client client = null;
85+
// One overall deadline for the whole call. Return() uses PulseAll, so every waiter wakes up
86+
// while only one of them can dequeue the returned client; re-arming the full timeout on each
87+
// wake-up would let an unlucky waiter exceed the configured bound indefinitely under churn.
88+
var budgetMs = TimeoutInMs;
89+
var elapsed = Stopwatch.StartNew();
6590
Monitor.Enter(ClientQueue);
6691
try
6792
{
6893
while (true)
6994
{
70-
bool timeout = false;
71-
if (ClientQueue.IsEmpty)
95+
if (ClientQueue.TryDequeue(out client))
7296
{
73-
timeout = !Monitor.Wait(ClientQueue, TimeSpan.FromSeconds(Timeout));
97+
break;
7498
}
75-
ClientQueue.TryDequeue(out client);
7699

77-
if (client != null || timeout)
100+
var remainingMs = budgetMs - (int)elapsed.ElapsedMilliseconds;
101+
if (remainingMs <= 0)
78102
{
79103
break;
80104
}
105+
106+
Monitor.Wait(ClientQueue, TimeSpan.FromMilliseconds(remainingMs));
81107
}
82108
}
83109
finally
@@ -86,7 +112,7 @@ public Client Take()
86112
}
87113
if (client == null)
88114
{
89-
var reasonPhrase = $"Connection pool is empty and wait time out({Timeout}s)";
115+
var reasonPhrase = $"Connection pool is empty and wait time out({budgetMs}ms)";
90116
if (DiagnosticReporter != null)
91117
{
92118
throw DiagnosticReporter.BuildDepletionException(reasonPhrase);

src/Apache.IoTDB/SessionPool.Builder.cs

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ public class Builder
3434
private int _poolSize = 8;
3535
private bool _enableRpcCompression = false;
3636
private int _connectionTimeoutInMs = 500;
37+
private int _poolWaitTimeoutInMs = DefaultPoolWaitTimeoutInMs;
3738
private bool _useSsl = false;
3839
private string _certificatePath = null;
3940
private string _sqlDialect = IoTDBConstant.TREE_SQL_DIALECT;
@@ -94,6 +95,18 @@ public Builder SetConnectionTimeoutInMs(int timeout)
9495
return this;
9596
}
9697

98+
/// <summary>
99+
/// Sets how long, in milliseconds, an operation waits for a client to become available in the pool
100+
/// before a <see cref="SessionPoolDepletedException"/> is thrown. Defaults to
101+
/// <see cref="DefaultPoolWaitTimeoutInMs"/> (10 seconds). This is independent of
102+
/// <see cref="SetConnectionTimeoutInMs"/>, which controls the socket-level timeout.
103+
/// </summary>
104+
public Builder SetPoolWaitTimeoutInMs(int poolWaitTimeoutInMs)
105+
{
106+
_poolWaitTimeoutInMs = poolWaitTimeoutInMs;
107+
return this;
108+
}
109+
97110
public Builder SetUseSsl(bool useSsl)
98111
{
99112
_useSsl = useSsl;
@@ -135,6 +148,7 @@ public Builder()
135148
_poolSize = 8;
136149
_enableRpcCompression = false;
137150
_connectionTimeoutInMs = 500;
151+
_poolWaitTimeoutInMs = DefaultPoolWaitTimeoutInMs;
138152
_useSsl = false;
139153
_certificatePath = null;
140154
_sqlDialect = IoTDBConstant.TREE_SQL_DIALECT;
@@ -146,9 +160,9 @@ public SessionPool Build()
146160
// if nodeUrls is not empty, use nodeUrls to create session pool
147161
if (_nodeUrls.Count > 0)
148162
{
149-
return new SessionPool(_nodeUrls, _username, _password, _fetchSize, _zoneId, _poolSize, _enableRpcCompression, _connectionTimeoutInMs, _useSsl, _certificatePath, _sqlDialect, _database);
163+
return new SessionPool(_nodeUrls, _username, _password, _fetchSize, _zoneId, _poolSize, _enableRpcCompression, _connectionTimeoutInMs, _useSsl, _certificatePath, _sqlDialect, _database, _poolWaitTimeoutInMs);
150164
}
151-
return new SessionPool(_host, _port, _username, _password, _fetchSize, _zoneId, _poolSize, _enableRpcCompression, _connectionTimeoutInMs, _useSsl, _certificatePath, _sqlDialect, _database);
165+
return new SessionPool(_host, _port, _username, _password, _fetchSize, _zoneId, _poolSize, _enableRpcCompression, _connectionTimeoutInMs, _useSsl, _certificatePath, _sqlDialect, _database, _poolWaitTimeoutInMs);
152166
}
153167
}
154168
}

0 commit comments

Comments
 (0)