Skip to content

Commit b0dbac6

Browse files
Merge pull request #1220 from QueryaHub/fix/1214-concurrent-connect
fix(db): one connect attempt per pooled connection, one reconnect per dropped slot (#1214)
2 parents f928e6d + 6668607 commit b0dbac6

6 files changed

Lines changed: 133 additions & 17 deletions

File tree

‎lib/core/database/mysql_connection.dart‎

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -145,6 +145,7 @@ class MysqlConnection {
145145
String? get connectionString => _connectionString;
146146

147147
MySQLConnection? _conn;
148+
Future<void>? _connecting;
148149
bool _isConnected = false;
149150
bool _inTransaction = false;
150151

@@ -164,7 +165,21 @@ class MysqlConnection {
164165
return '`${id.replaceAll('`', '``')}`';
165166
}
166167

167-
Future<void> connect({int connectTimeoutMs = 10000}) async {
168+
/// Single-flight: a caller that arrives while an attempt is in progress waits
169+
/// for that attempt. Without this, two callers opened two sockets and two SSH
170+
/// tunnels, and a failing attempt dropped the session the other one had made.
171+
Future<void> connect({int connectTimeoutMs = 10000}) {
172+
if (_isConnected && _conn != null) return Future<void>.value();
173+
final inFlight = _connecting;
174+
if (inFlight != null) return inFlight;
175+
final attempt = _connectOnce(connectTimeoutMs);
176+
_connecting = attempt;
177+
return attempt.whenComplete(() {
178+
if (identical(_connecting, attempt)) _connecting = null;
179+
});
180+
}
181+
182+
Future<void> _connectOnce(int connectTimeoutMs) async {
168183
if (_isConnected && _conn != null) return;
169184

170185
var effectivePassword = _password;

‎lib/core/database/mysql_connection_pool.dart‎

Lines changed: 18 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -77,10 +77,7 @@ class MysqlConnectionPool {
7777
entry.idleTimer?.cancel();
7878
entry.idleTimer = null;
7979
entry.refs++;
80-
if (!entry.connection.isConnected) {
81-
await entry.connection.connect();
82-
await entry.connection.setSessionReadOnly(mode.isReadOnlySession);
83-
}
80+
if (!entry.connection.isConnected) await _reconnect(entry, mode);
8481
return MysqlLease._(this, entry, entry.connection);
8582
}
8683

@@ -113,10 +110,7 @@ class MysqlConnectionPool {
113110
entry.idleTimer?.cancel();
114111
entry.idleTimer = null;
115112
entry.refs++;
116-
if (!entry.connection.isConnected) {
117-
await entry.connection.connect();
118-
await entry.connection.setSessionReadOnly(mode.isReadOnlySession);
119-
}
113+
if (!entry.connection.isConnected) await _reconnect(entry, mode);
120114
return MysqlLease._(this, entry, entry.connection);
121115
}
122116

@@ -143,6 +137,21 @@ class MysqlConnectionPool {
143137
/// Releases one lease on [entry]. A lease taken before an [interrupt] points
144138
/// at an entry that is no longer in the pool: it must not touch the entry
145139
/// that replaced it under the same key, so it does nothing.
140+
/// Brings a dropped entry back with one reconnect and one session setting,
141+
/// even when several callers ask at once; they all wait for the same attempt.
142+
Future<void> _reconnect(_PoolEntry entry, MysqlSessionMode mode) {
143+
final inFlight = entry.reconnecting;
144+
if (inFlight != null) return inFlight;
145+
final attempt = () async {
146+
await entry.connection.connect();
147+
await entry.connection.setSessionReadOnly(mode.isReadOnlySession);
148+
}();
149+
entry.reconnecting = attempt;
150+
return attempt.whenComplete(() {
151+
if (identical(entry.reconnecting, attempt)) entry.reconnecting = null;
152+
});
153+
}
154+
146155
void _release(_PoolEntry entry) {
147156
if (!identical(_pool[entry.key], entry)) return;
148157
entry.refs--;
@@ -203,6 +212,7 @@ class _PoolEntry {
203212

204213
final MysqlConnection connection;
205214
final String key;
215+
Future<void>? reconnecting;
206216
int refs = 0;
207217
Timer? idleTimer;
208218
DateTime lastUsed;

‎lib/core/database/postgres_connection.dart‎

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -143,6 +143,7 @@ class PostgresConnection {
143143
String? get connectionString => _connectionString;
144144

145145
Connection? _conn;
146+
Future<void>? _connecting;
146147
bool _isConnected = false;
147148
bool _inTransaction = false;
148149

@@ -201,7 +202,21 @@ class PostgresConnection {
201202
/// [openFromUrl] already parses `sslmode`, `connect_timeout`, `query_timeout`
202203
/// from the URI. If `sslmode` is omitted, we fall back to [useSSL] so the
203204
/// form checkbox still applies; otherwise libpq-style URLs drive TLS mode.
204-
Future<void> connect() async {
205+
/// Single-flight: a caller that arrives while an attempt is in progress waits
206+
/// for that attempt. Without this, two callers opened two sockets and two SSH
207+
/// tunnels, and a failing attempt dropped the session the other one had made.
208+
Future<void> connect() {
209+
if (_isConnected && _conn != null) return Future<void>.value();
210+
final inFlight = _connecting;
211+
if (inFlight != null) return inFlight;
212+
final attempt = _connectOnce();
213+
_connecting = attempt;
214+
return attempt.whenComplete(() {
215+
if (identical(_connecting, attempt)) _connecting = null;
216+
});
217+
}
218+
219+
Future<void> _connectOnce() async {
205220
if (_isConnected && _conn != null) return;
206221

207222
var effectivePassword = _password;

‎lib/core/database/postgres_connection_pool.dart‎

Lines changed: 17 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -89,10 +89,7 @@ class PostgresConnectionPool {
8989
entry.idleTimer?.cancel();
9090
entry.idleTimer = null;
9191
entry.refs++;
92-
if (!entry.connection.isConnected) {
93-
await entry.connection.connect();
94-
await entry.connection.setSessionReadOnly(mode.isReadOnlySession);
95-
}
92+
if (!entry.connection.isConnected) await _reconnect(entry, mode);
9693
return PgLease._(this, entry, entry.connection);
9794
}
9895

@@ -125,11 +122,23 @@ class PostgresConnectionPool {
125122
entry.idleTimer?.cancel();
126123
entry.idleTimer = null;
127124
entry.refs++;
128-
if (!entry.connection.isConnected) {
125+
if (!entry.connection.isConnected) await _reconnect(entry, mode);
126+
return PgLease._(this, entry, entry.connection);
127+
}
128+
129+
/// Brings a dropped entry back with one reconnect and one session setting,
130+
/// even when several callers ask at once; they all wait for the same attempt.
131+
Future<void> _reconnect(_PoolEntry entry, PgSessionMode mode) {
132+
final inFlight = entry.reconnecting;
133+
if (inFlight != null) return inFlight;
134+
final attempt = () async {
129135
await entry.connection.connect();
130136
await entry.connection.setSessionReadOnly(mode.isReadOnlySession);
131-
}
132-
return PgLease._(this, entry, entry.connection);
137+
}();
138+
entry.reconnecting = attempt;
139+
return attempt.whenComplete(() {
140+
if (identical(entry.reconnecting, attempt)) entry.reconnecting = null;
141+
});
133142
}
134143

135144
/// Drops idle LRU slots until there is room for one more key.
@@ -222,6 +231,7 @@ class _PoolEntry {
222231

223232
final PostgresConnection connection;
224233
final String key;
234+
Future<void>? reconnecting;
225235
int refs = 0;
226236
Timer? idleTimer;
227237
DateTime lastUsed;

‎test/core/database/mysql_connection_pool_test.dart‎

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -234,4 +234,36 @@ void main() {
234234
expect(replacement.disconnectCount, 1);
235235
});
236236
});
237+
238+
group('concurrent reconnect (#1214)', () {
239+
test('two callers on a dropped slot share one reconnect and one setting',
240+
() async {
241+
final pool = MysqlConnectionPool(
242+
createAndConnect: (row, {required database, required mode}) async {
243+
final c = FakeMysqlConnection();
244+
await c.connect();
245+
return c;
246+
},
247+
);
248+
final r = _row();
249+
final first = await pool.acquire(r,
250+
database: 'app', mode: MysqlSessionMode.readOnly);
251+
final conn = first.connection as FakeMysqlConnection;
252+
await conn.forceClose();
253+
254+
final leases = await Future.wait([
255+
pool.acquire(r, database: 'app', mode: MysqlSessionMode.readOnly),
256+
pool.acquire(r, database: 'app', mode: MysqlSessionMode.readOnly),
257+
]);
258+
259+
expect(conn.connectCount, 2, reason: 'initial connect plus one reconnect');
260+
expect(conn.setReadOnlyCount, 1);
261+
expect(identical(leases[0].connection, conn), isTrue);
262+
expect(identical(leases[1].connection, conn), isTrue);
263+
first.release();
264+
for (final l in leases) {
265+
l.release();
266+
}
267+
});
268+
});
237269
}

‎test/core/database/postgres_connection_pool_test.dart‎

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -593,4 +593,38 @@ void main() {
593593
expect(replacement.disconnectCount, 1);
594594
});
595595
});
596+
597+
group('concurrent reconnect (#1214)', () {
598+
test('two callers on a dropped slot share one reconnect and one setting',
599+
() async {
600+
final created = <FakePostgresConnection>[];
601+
final pool = PostgresConnectionPool(
602+
createAndConnect: (row, {required database, required mode}) async {
603+
final c = FakePostgresConnection();
604+
await c.connect();
605+
created.add(c);
606+
return c;
607+
},
608+
);
609+
final r = _row();
610+
final first = await pool.acquire(r,
611+
database: 'postgres', mode: PgSessionMode.readOnly);
612+
final conn = first.connection as FakePostgresConnection;
613+
await conn.forceClose();
614+
615+
final leases = await Future.wait([
616+
pool.acquire(r, database: 'postgres', mode: PgSessionMode.readOnly),
617+
pool.acquire(r, database: 'postgres', mode: PgSessionMode.readOnly),
618+
]);
619+
620+
expect(conn.connectCount, 2, reason: 'initial connect plus one reconnect');
621+
expect(conn.setReadOnlyCount, 1);
622+
expect(identical(leases[0].connection, conn), isTrue);
623+
expect(identical(leases[1].connection, conn), isTrue);
624+
first.release();
625+
for (final l in leases) {
626+
l.release();
627+
}
628+
});
629+
});
596630
}

0 commit comments

Comments
 (0)