Skip to content

Commit f8dfff9

Browse files
fix(db): statements run one at a time per session; a server cancel keeps the session (#1216)
PostgreSQL: every timeout, including a server-side cancel (SQLSTATE 57014), closed the shared session. A cancel arrives as a PgException and leaves the session healthy; only a bare TimeoutException closes it now. Statements on a session run one at a time in our own queue: the driver started a statement's timeout before the statement got its turn, so a statement waiting behind another one could cancel that one. executeWithTimeout passes its timeout to the statement, which now starts when the statement does. MySQL: statements run one at a time in the same way, so the client's 10 s wait for the previous statement no longer closes the session for every caller. A timed-out statement still closes its session: the client cannot cancel it.
1 parent 9dc42fa commit f8dfff9

4 files changed

Lines changed: 238 additions & 57 deletions

File tree

‎lib/core/database/mysql_connection.dart‎

Lines changed: 55 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -146,6 +146,27 @@ class MysqlConnection {
146146

147147
MySQLConnection? _conn;
148148
Future<void>? _connecting;
149+
150+
/// Statements on this session run one at a time, here. The client would
151+
/// otherwise poll for the previous statement and give up after 10 s, which
152+
/// closed the session for every caller.
153+
Future<void> _statementQueue = Future<void>.value();
154+
155+
/// Test seam: replaces the driver call of [execute].
156+
@visibleForTesting
157+
Future<IResultSet> Function(
158+
String sql,
159+
Map<String, dynamic>? params,
160+
bool iterable,
161+
Duration? timeout,
162+
)? runStatementForTest;
163+
164+
Future<T> _serialized<T>(Future<T> Function() run) {
165+
final previous = _statementQueue;
166+
final turn = Completer<void>();
167+
_statementQueue = turn.future;
168+
return previous.then((_) => run()).whenComplete(() => turn.complete());
169+
}
149170
bool _isConnected = false;
150171
bool _inTransaction = false;
151172

@@ -441,18 +462,36 @@ class MysqlConnection {
441462
String sql, [
442463
Map<String, dynamic>? params,
443464
bool iterable = false,
444-
]) async {
445-
if (!isConnected || _conn == null) {
446-
throw StateError('Not connected to MySQL');
447-
}
448-
try {
449-
final rs = await _conn!.execute(sql, params, iterable);
450-
_noteTransactionSql(sql);
451-
return rs;
452-
} on TimeoutException {
453-
unawaited(forceClose());
454-
rethrow;
455-
}
465+
]) =>
466+
_run(sql, params, iterable, null);
467+
468+
Future<IResultSet> _run(
469+
String sql,
470+
Map<String, dynamic>? params,
471+
bool iterable,
472+
Duration? timeout,
473+
) {
474+
return _serialized(() async {
475+
final seam = runStatementForTest;
476+
final c = _conn;
477+
if (seam == null && (!isConnected || c == null)) {
478+
throw StateError('Not connected to MySQL');
479+
}
480+
try {
481+
final pending = seam != null
482+
? seam(sql, params, iterable, timeout)
483+
: c!.execute(sql, params, iterable);
484+
final rs = timeout == null ? await pending : await pending.timeout(timeout);
485+
_noteTransactionSql(sql);
486+
return rs;
487+
} on TimeoutException {
488+
// The client cannot cancel a running statement, so the session is out of
489+
// step after a timeout and is closed. Statements queued behind this one
490+
// wait in the queue and are not affected.
491+
unawaited(forceClose());
492+
rethrow;
493+
}
494+
});
456495
}
457496

458497
/// Whether this session has an open `START TRANSACTION` / `BEGIN`.
@@ -498,22 +537,15 @@ class MysqlConnection {
498537
}
499538
}
500539

501-
/// Runs [execute] with an application-level [timeout] (driver limits still apply).
540+
/// [execute] with [timeout] as the statement's own limit; it starts when the
541+
/// statement starts, not while the statement waits behind another one.
502542
Future<IResultSet> executeWithTimeout(
503543
String sql, {
504544
Duration? timeout,
505545
Map<String, dynamic>? params,
506546
bool iterable = false,
507-
}) async {
508-
final f = execute(sql, params, iterable);
509-
if (timeout == null) return f;
510-
try {
511-
return await f.timeout(timeout);
512-
} on TimeoutException {
513-
unawaited(forceClose());
514-
rethrow;
515-
}
516-
}
547+
}) =>
548+
_run(sql, params, iterable, timeout);
517549

518550
/// Lists user-visible databases (excludes typical system schemas).
519551
Future<List<String>> listDatabases() async {

‎lib/core/database/postgres_connection.dart‎

Lines changed: 46 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -144,6 +144,22 @@ class PostgresConnection {
144144

145145
Connection? _conn;
146146
Future<void>? _connecting;
147+
148+
/// Statements on this session run one at a time, here and not in the driver.
149+
/// The driver starts a statement's timeout before the statement gets its turn,
150+
/// so a statement that waited behind another one could cancel the other one.
151+
Future<void> _statementQueue = Future<void>.value();
152+
153+
/// Test seam: replaces the driver call of [execute].
154+
@visibleForTesting
155+
Future<Result> Function(String sql, Duration? timeout)? runStatementForTest;
156+
157+
Future<T> _serialized<T>(Future<T> Function() run) {
158+
final previous = _statementQueue;
159+
final turn = Completer<void>();
160+
_statementQueue = turn.future;
161+
return previous.then((_) => run()).whenComplete(() => turn.complete());
162+
}
147163
bool _isConnected = false;
148164
bool _inTransaction = false;
149165

@@ -391,37 +407,41 @@ class PostgresConnection {
391407
}
392408
}
393409

394-
/// Runs SQL on the underlying session. [timeout] overrides
395-
/// [ConnectionSettings.queryTimeout] for this statement (see `postgres`
396-
/// package).
397-
Future<Result> execute(String sql, {Duration? timeout}) async {
398-
if (!isConnected || _conn == null) {
399-
throw StateError('Not connected to PostgreSQL');
400-
}
401-
try {
402-
final result = await _conn!.execute(sql, timeout: timeout);
403-
_inTransaction = applyPostgresTransactionSql(_inTransaction, sql);
404-
return result;
405-
} on TimeoutException {
406-
unawaited(forceClose());
407-
rethrow;
408-
}
410+
/// Runs SQL on the underlying session, one statement at a time. [timeout]
411+
/// overrides [ConnectionSettings.queryTimeout] for this statement and starts
412+
/// when the statement does.
413+
///
414+
/// A server-side cancel (SQLSTATE 57014) arrives as a [PgException], and the
415+
/// session stays usable. Only a bare [TimeoutException] closes it: then the
416+
/// protocol may be out of step.
417+
Future<Result> execute(String sql, {Duration? timeout}) {
418+
return _serialized(() async {
419+
final seam = runStatementForTest;
420+
final c = _conn;
421+
if (seam == null && (!isConnected || c == null)) {
422+
throw StateError('Not connected to PostgreSQL');
423+
}
424+
try {
425+
final result = seam != null
426+
? await seam(sql, timeout)
427+
: await c!.execute(sql, timeout: timeout);
428+
_inTransaction = applyPostgresTransactionSql(_inTransaction, sql);
429+
return result;
430+
} on TimeoutException catch (e) {
431+
if (e is! PgException) unawaited(forceClose());
432+
rethrow;
433+
}
434+
});
409435
}
410436

411-
/// Runs [execute] with an application-level [timeout] (in addition to driver timeout).
437+
/// [execute] with [timeout] as the statement's own limit. The limit starts
438+
/// when the statement starts: time spent waiting behind another statement on
439+
/// this session does not count against it.
412440
Future<Result> executeWithTimeout(
413441
String sql, {
414442
Duration? timeout,
415-
}) async {
416-
final f = execute(sql, timeout: timeout);
417-
if (timeout == null) return f;
418-
try {
419-
return await f.timeout(timeout);
420-
} on TimeoutException {
421-
unawaited(forceClose());
422-
rethrow;
423-
}
424-
}
443+
}) =>
444+
execute(sql, timeout: timeout);
425445

426446
/// Whether the session has an open transaction.
427447
///

‎test/core/database/connection_timeout_protocol_test.dart‎

Lines changed: 4 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,6 @@ import 'package:mysql_client/mysql_client.dart';
44
import 'package:querya_desktop/core/database/mysql_connection.dart';
55
import 'package:querya_desktop/core/database/postgres_connection.dart';
66
import 'package:querya_desktop/core/database/sqlite_connection.dart';
7-
import 'package:postgres/postgres.dart' as pg;
87

98
class FakeSlowMysqlConnection extends MysqlConnection {
109
FakeSlowMysqlConnection({super.id = 1})
@@ -53,12 +52,6 @@ class FakeSlowPostgresConnection extends PostgresConnection {
5352
@override
5453
bool get isConnected => _connected;
5554

56-
@override
57-
Future<pg.Result> execute(String sql, {Duration? timeout}) async {
58-
await Future.delayed(const Duration(seconds: 10));
59-
throw Exception('should not reach here');
60-
}
61-
6255
@override
6356
Future<void> forceClose() async {
6457
forceCloseCount++;
@@ -116,9 +109,12 @@ void main() {
116109
expect(conn.isConnected, isFalse);
117110
});
118111

119-
test('PostgresConnection.executeWithTimeout force-closes on TimeoutException', () async {
112+
test('PostgresConnection.executeWithTimeout force-closes on a bare TimeoutException', () async {
120113
final conn = FakeSlowPostgresConnection();
121114
expect(conn.isConnected, isTrue);
115+
// The driver's own timer fires as a bare TimeoutException.
116+
conn.runStatementForTest = (sql, timeout) =>
117+
Future.error(TimeoutException('driver timer', timeout));
122118

123119
try {
124120
await conn.executeWithTimeout(
Lines changed: 133 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,133 @@
1+
import 'dart:async';
2+
3+
import 'package:flutter_test/flutter_test.dart';
4+
import 'package:mysql_client/mysql_client.dart';
5+
import 'package:postgres/postgres.dart' show PgException;
6+
import 'package:querya_desktop/core/database/mysql_connection.dart';
7+
import 'package:querya_desktop/core/database/postgres_connection.dart';
8+
9+
/// Records force-closes instead of touching a socket.
10+
class _ProbePostgres extends PostgresConnection {
11+
_ProbePostgres()
12+
: super(
13+
name: 'probe',
14+
host: 'localhost',
15+
port: 5432,
16+
database: 'postgres',
17+
);
18+
19+
int forced = 0;
20+
21+
@override
22+
Future<void> forceClose() async {
23+
forced++;
24+
}
25+
}
26+
27+
class _ProbeMysql extends MysqlConnection {
28+
_ProbeMysql()
29+
: super(
30+
name: 'probe',
31+
host: 'localhost',
32+
port: 3306,
33+
database: 'testdb',
34+
);
35+
36+
int forced = 0;
37+
38+
@override
39+
Future<void> forceClose() async {
40+
forced++;
41+
}
42+
}
43+
44+
void main() {
45+
group('PostgreSQL statements on one session (#1216)', () {
46+
test('a server-side cancel is a PgException and keeps the session',
47+
() async {
48+
final conn = _ProbePostgres();
49+
conn.runStatementForTest = (sql, timeout) =>
50+
Future.error(PgException('canceling statement due to user request'));
51+
52+
await expectLater(
53+
conn.execute('SELECT 1'),
54+
throwsA(isA<PgException>()),
55+
);
56+
await Future<void>.delayed(Duration.zero);
57+
expect(conn.forced, 0);
58+
});
59+
60+
test('a bare TimeoutException closes the session', () async {
61+
final conn = _ProbePostgres();
62+
conn.runStatementForTest = (sql, timeout) =>
63+
Future.error(TimeoutException('driver timer', timeout));
64+
65+
await expectLater(
66+
conn.execute('SELECT 1', timeout: const Duration(seconds: 1)),
67+
throwsA(isA<TimeoutException>()),
68+
);
69+
await Future<void>.delayed(Duration.zero);
70+
expect(conn.forced, 1);
71+
});
72+
73+
test('a statement waits for the one before it on the same session',
74+
() async {
75+
final conn = _ProbePostgres();
76+
final log = <String>[];
77+
final gate = Completer<void>();
78+
conn.runStatementForTest = (sql, timeout) async {
79+
log.add('start $sql');
80+
if (sql == 'A') await gate.future;
81+
log.add('end $sql');
82+
throw StateError('no result in this probe');
83+
};
84+
85+
final a = conn.execute('A').then<void>((_) {}, onError: (_) {});
86+
final b = conn.execute('B').then<void>((_) {}, onError: (_) {});
87+
await Future<void>.delayed(Duration.zero);
88+
expect(log, ['start A'], reason: 'B must not start while A runs');
89+
90+
gate.complete();
91+
await Future.wait([a, b]);
92+
expect(log, ['start A', 'end A', 'start B', 'end B']);
93+
});
94+
});
95+
96+
group('MySQL statements on one session (#1216)', () {
97+
test('a statement waits for the one before it on the same session',
98+
() async {
99+
final conn = _ProbeMysql();
100+
final log = <String>[];
101+
final gate = Completer<void>();
102+
conn.runStatementForTest = (sql, params, iterable, timeout) async {
103+
log.add('start $sql');
104+
if (sql == 'A') await gate.future;
105+
log.add('end $sql');
106+
throw StateError('no result in this probe');
107+
};
108+
109+
final a = conn.execute('A').then<void>((_) {}, onError: (_) {});
110+
final b = conn.execute('B').then<void>((_) {}, onError: (_) {});
111+
await Future<void>.delayed(Duration.zero);
112+
expect(log, ['start A']);
113+
114+
gate.complete();
115+
await Future.wait([a, b]);
116+
expect(log, ['start A', 'end A', 'start B', 'end B']);
117+
});
118+
119+
test('a timed-out statement closes the session it ran on', () async {
120+
final conn = _ProbeMysql();
121+
conn.runStatementForTest = (sql, params, iterable, timeout) =>
122+
Completer<IResultSet>().future;
123+
124+
await expectLater(
125+
conn.executeWithTimeout('SELECT SLEEP(100)',
126+
timeout: const Duration(milliseconds: 20)),
127+
throwsA(isA<TimeoutException>()),
128+
);
129+
await Future<void>.delayed(Duration.zero);
130+
expect(conn.forced, 1);
131+
});
132+
});
133+
}

0 commit comments

Comments
 (0)