Skip to content

Commit b81d985

Browse files
Merge pull request #24 from QueryaHub/dev
Dev
2 parents ba58950 + c606f30 commit b81d985

22 files changed

Lines changed: 1044 additions & 272 deletions

lib/app/app.dart

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import 'package:querya_desktop/core/theme/app_theme.dart';
22
import 'package:shadcn_flutter/shadcn_flutter.dart';
33

4+
import 'app_lifecycle_cleanup.dart';
45
import '../features/main_screen/main_screen.dart';
56

67
class QueryaApp extends StatelessWidget {
@@ -18,7 +19,9 @@ class QueryaApp extends StatelessWidget {
1819
enableThemeAnimation: false,
1920
// Avoids scroll interception fighting nested Scrollbars in data views.
2021
enableScrollInterception: false,
21-
home: const MainScreen(),
22+
home: const AppLifecycleCleanup(
23+
child: MainScreen(),
24+
),
2225
);
2326
}
2427
}

lib/app/app_lifecycle_cleanup.dart

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
import 'dart:async';
2+
3+
import 'package:flutter/widgets.dart';
4+
5+
import 'app_shutdown.dart';
6+
7+
/// Closes pooled TCP connections when the app is shutting down.
8+
///
9+
/// Uses [AppLifecycleState.detached] and [dispose] so desktop window close is
10+
/// covered as reliably as the platform allows.
11+
class AppLifecycleCleanup extends StatefulWidget {
12+
const AppLifecycleCleanup({super.key, required this.child});
13+
14+
final Widget child;
15+
16+
@override
17+
State<AppLifecycleCleanup> createState() => _AppLifecycleCleanupState();
18+
}
19+
20+
class _AppLifecycleCleanupState extends State<AppLifecycleCleanup>
21+
with WidgetsBindingObserver {
22+
@override
23+
void initState() {
24+
super.initState();
25+
WidgetsBinding.instance.addObserver(this);
26+
}
27+
28+
@override
29+
void dispose() {
30+
WidgetsBinding.instance.removeObserver(this);
31+
unawaited(disconnectAllExternalServices());
32+
super.dispose();
33+
}
34+
35+
@override
36+
void didChangeAppLifecycleState(AppLifecycleState state) {
37+
if (state == AppLifecycleState.detached) {
38+
unawaited(disconnectAllExternalServices());
39+
}
40+
}
41+
42+
@override
43+
Widget build(BuildContext context) => widget.child;
44+
}

lib/app/app_shutdown.dart

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
import 'package:querya_desktop/core/database/mongodb_service.dart';
2+
import 'package:querya_desktop/core/database/postgres_service.dart';
3+
import 'package:querya_desktop/core/database/redis_service.dart';
4+
5+
/// Disconnects all pooled / cached client connections (PostgreSQL pool, Mongo,
6+
/// Redis). Safe to call when no connections exist.
7+
Future<void> disconnectAllExternalServices() async {
8+
await PostgresService.instance.disconnectAll();
9+
await MongoService.instance.disconnectAll();
10+
await RedisService.instance.disconnectAll();
11+
}

lib/core/database/mongodb_service.dart

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -60,7 +60,7 @@ class MongoService {
6060

6161
/// Disconnects all connections.
6262
Future<void> disconnectAll() async {
63-
for (final connection in _connections.values) {
63+
for (final connection in _connections.values.toList()) {
6464
await disconnect(connection);
6565
}
6666
}

lib/core/database/postgres_connection_pool.dart

Lines changed: 35 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -46,12 +46,18 @@ class PostgresConnectionPool {
4646
PostgresConnectionPool({
4747
required this.createAndConnect,
4848
this.idleDisposeDelay = defaultIdleDisposeDelay,
49+
this.maxEntries = defaultMaxEntries,
4950
});
5051

5152
static const Duration defaultIdleDisposeDelay = Duration(seconds: 8);
5253

54+
/// Max distinct pool keys `(connection id, database, mode)`. When full,
55+
/// least-recently-used **idle** slots (`refs == 0`) are closed first.
56+
static const int defaultMaxEntries = 32;
57+
5358
final PostgresPoolConnectionFactory createAndConnect;
5459
final Duration idleDisposeDelay;
60+
final int maxEntries;
5561

5662
final Map<String, _PoolEntry> _pool = {};
5763

@@ -67,6 +73,7 @@ class PostgresConnectionPool {
6773
final k = keyFor(row.id, database, mode);
6874
var entry = _pool[k];
6975
if (entry != null) {
76+
entry.touch();
7077
entry.idleTimer?.cancel();
7178
entry.idleTimer = null;
7279
entry.refs++;
@@ -77,12 +84,35 @@ class PostgresConnectionPool {
7784
return PgLease._(this, k, entry.connection);
7885
}
7986

87+
_evictIfNeededBeforeNewSlot();
88+
8089
final conn = await createAndConnect(row, database: database, mode: mode);
8190
entry = _PoolEntry(conn)..refs = 1;
8291
_pool[k] = entry;
8392
return PgLease._(this, k, conn);
8493
}
8594

95+
/// Drops idle LRU slots until there is room for one more key.
96+
void _evictIfNeededBeforeNewSlot() {
97+
while (_pool.length >= maxEntries) {
98+
final idle = _pool.entries.where((e) => e.value.refs == 0).toList();
99+
if (idle.isEmpty) {
100+
throw StateError(
101+
'PostgreSQL connection pool exhausted: $maxEntries slots in use.',
102+
);
103+
}
104+
idle.sort((a, b) => a.value.lastUsed.compareTo(b.value.lastUsed));
105+
_removeEntryClosing(idle.first.key);
106+
}
107+
}
108+
109+
void _removeEntryClosing(String k) {
110+
final entry = _pool.remove(k);
111+
if (entry == null) return;
112+
entry.idleTimer?.cancel();
113+
unawaited(entry.connection.forceClose());
114+
}
115+
86116
void _release(String k) {
87117
final entry = _pool[k];
88118
if (entry == null) return;
@@ -106,10 +136,7 @@ class PostgresConnectionPool {
106136
PgSessionMode mode = PgSessionMode.readOnly,
107137
}) {
108138
final k = keyFor(row.id, database, mode);
109-
final entry = _pool.remove(k);
110-
if (entry == null) return;
111-
entry.idleTimer?.cancel();
112-
unawaited(entry.connection.forceClose());
139+
_removeEntryClosing(k);
113140
}
114141

115142
/// Closes all pooled connections (e.g. app shutdown).
@@ -123,9 +150,12 @@ class PostgresConnectionPool {
123150
}
124151

125152
class _PoolEntry {
126-
_PoolEntry(this.connection);
153+
_PoolEntry(this.connection) : lastUsed = DateTime.now();
127154

128155
final PostgresConnection connection;
129156
int refs = 0;
130157
Timer? idleTimer;
158+
DateTime lastUsed;
159+
160+
void touch() => lastUsed = DateTime.now();
131161
}

lib/core/database/postgres_service.dart

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ class PostgresService {
2424
PostgresService._()
2525
: _pool = PostgresConnectionPool(
2626
createAndConnect: _defaultCreateAndConnect,
27+
maxEntries: PostgresConnectionPool.defaultMaxEntries,
2728
);
2829

2930
static final PostgresService instance = PostgresService._();

lib/core/database/redis_service.dart

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,4 +45,11 @@ class RedisService {
4545
await connection.disconnect();
4646
_connections.remove(connection.id);
4747
}
48+
49+
/// Disconnects all Redis connections (e.g. app shutdown).
50+
Future<void> disconnectAll() async {
51+
for (final connection in _connections.values.toList()) {
52+
await disconnect(connection);
53+
}
54+
}
4855
}

lib/core/storage/app_settings.dart

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
import 'local_db.dart';
2+
3+
/// Typed keys for [LocalDb] app_settings.
4+
abstract final class AppSettingsKeys {
5+
static const postgresSqlStmtTimeoutSeconds =
6+
'postgres_sql_stmt_timeout_seconds';
7+
}
8+
9+
/// User preferences backed by [LocalDb] (SQLite).
10+
class AppSettings {
11+
AppSettings._();
12+
static final AppSettings instance = AppSettings._();
13+
14+
/// `null` = use driver / URI default.
15+
Future<int?> getPostgresSqlStmtTimeoutSeconds() async {
16+
final v = await LocalDb.instance.getAppSetting(
17+
AppSettingsKeys.postgresSqlStmtTimeoutSeconds,
18+
);
19+
if (v == null || v.isEmpty) return null;
20+
return int.tryParse(v);
21+
}
22+
23+
Future<void> setPostgresSqlStmtTimeoutSeconds(int? seconds) async {
24+
if (seconds == null) {
25+
await LocalDb.instance.deleteAppSetting(
26+
AppSettingsKeys.postgresSqlStmtTimeoutSeconds,
27+
);
28+
} else {
29+
await LocalDb.instance.setAppSetting(
30+
AppSettingsKeys.postgresSqlStmtTimeoutSeconds,
31+
seconds.toString(),
32+
);
33+
}
34+
}
35+
}

lib/core/storage/local_db.dart

Lines changed: 41 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import 'package:path_provider/path_provider.dart';
55
import 'package:sqflite_common_ffi/sqflite_ffi.dart';
66

77
const _dbName = 'querya.db';
8-
const _dbVersion = 3;
8+
const _dbVersion = 4;
99

1010
/// Local SQLite database for folders and connections.
1111
/// File: [applicationSupport]/querya_desktop/querya.db
@@ -65,6 +65,12 @@ class LocalDb {
6565
created_at TEXT NOT NULL
6666
)
6767
''');
68+
await db.execute('''
69+
CREATE TABLE app_settings (
70+
key TEXT PRIMARY KEY NOT NULL,
71+
value TEXT NOT NULL
72+
)
73+
''');
6874
}
6975

7076
Future<void> _onUpgrade(Database db, int oldVersion, int newVersion) async {
@@ -104,6 +110,40 @@ class LocalDb {
104110
await db.execute('DROP TABLE connections');
105111
await db.execute('ALTER TABLE connections_new RENAME TO connections');
106112
}
113+
if (oldVersion < 4) {
114+
await db.execute('''
115+
CREATE TABLE app_settings (
116+
key TEXT PRIMARY KEY NOT NULL,
117+
value TEXT NOT NULL
118+
)
119+
''');
120+
}
121+
}
122+
123+
Future<String?> getAppSetting(String key) async {
124+
final db = await _open();
125+
final rows = await db.query(
126+
'app_settings',
127+
columns: ['value'],
128+
where: 'key = ?',
129+
whereArgs: [key],
130+
limit: 1,
131+
);
132+
if (rows.isEmpty) return null;
133+
return rows.first['value'] as String?;
134+
}
135+
136+
Future<void> setAppSetting(String key, String value) async {
137+
final db = await _open();
138+
await db.rawInsert(
139+
'INSERT OR REPLACE INTO app_settings (key, value) VALUES (?, ?)',
140+
[key, value],
141+
);
142+
}
143+
144+
Future<void> deleteAppSetting(String key) async {
145+
final db = await _open();
146+
await db.delete('app_settings', where: 'key = ?', whereArgs: [key]);
107147
}
108148

109149
Future<List<String>> getFolders() async {

0 commit comments

Comments
 (0)