Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
5 changes: 4 additions & 1 deletion lib/app/app.dart
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import 'package:querya_desktop/core/theme/app_theme.dart';
import 'package:shadcn_flutter/shadcn_flutter.dart';

import 'app_lifecycle_cleanup.dart';
import '../features/main_screen/main_screen.dart';

class QueryaApp extends StatelessWidget {
Expand All @@ -18,7 +19,9 @@ class QueryaApp extends StatelessWidget {
enableThemeAnimation: false,
// Avoids scroll interception fighting nested Scrollbars in data views.
enableScrollInterception: false,
home: const MainScreen(),
home: const AppLifecycleCleanup(
child: MainScreen(),
),
);
}
}
44 changes: 44 additions & 0 deletions lib/app/app_lifecycle_cleanup.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import 'dart:async';

import 'package:flutter/widgets.dart';

import 'app_shutdown.dart';

/// Closes pooled TCP connections when the app is shutting down.
///
/// Uses [AppLifecycleState.detached] and [dispose] so desktop window close is
/// covered as reliably as the platform allows.
class AppLifecycleCleanup extends StatefulWidget {
const AppLifecycleCleanup({super.key, required this.child});

final Widget child;

@override
State<AppLifecycleCleanup> createState() => _AppLifecycleCleanupState();
}

class _AppLifecycleCleanupState extends State<AppLifecycleCleanup>
with WidgetsBindingObserver {
@override
void initState() {
super.initState();
WidgetsBinding.instance.addObserver(this);
}

@override
void dispose() {
WidgetsBinding.instance.removeObserver(this);
unawaited(disconnectAllExternalServices());
super.dispose();
}

@override
void didChangeAppLifecycleState(AppLifecycleState state) {
if (state == AppLifecycleState.detached) {
unawaited(disconnectAllExternalServices());
}
}

@override
Widget build(BuildContext context) => widget.child;
}
11 changes: 11 additions & 0 deletions lib/app/app_shutdown.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import 'package:querya_desktop/core/database/mongodb_service.dart';
import 'package:querya_desktop/core/database/postgres_service.dart';
import 'package:querya_desktop/core/database/redis_service.dart';

/// Disconnects all pooled / cached client connections (PostgreSQL pool, Mongo,
/// Redis). Safe to call when no connections exist.
Future<void> disconnectAllExternalServices() async {
await PostgresService.instance.disconnectAll();
await MongoService.instance.disconnectAll();
await RedisService.instance.disconnectAll();
}
2 changes: 1 addition & 1 deletion lib/core/database/mongodb_service.dart
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,7 @@ class MongoService {

/// Disconnects all connections.
Future<void> disconnectAll() async {
for (final connection in _connections.values) {
for (final connection in _connections.values.toList()) {
await disconnect(connection);
}
}
Expand Down
40 changes: 35 additions & 5 deletions lib/core/database/postgres_connection_pool.dart
Original file line number Diff line number Diff line change
Expand Up @@ -46,12 +46,18 @@ class PostgresConnectionPool {
PostgresConnectionPool({
required this.createAndConnect,
this.idleDisposeDelay = defaultIdleDisposeDelay,
this.maxEntries = defaultMaxEntries,
});

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

/// Max distinct pool keys `(connection id, database, mode)`. When full,
/// least-recently-used **idle** slots (`refs == 0`) are closed first.
static const int defaultMaxEntries = 32;

final PostgresPoolConnectionFactory createAndConnect;
final Duration idleDisposeDelay;
final int maxEntries;

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

Expand All @@ -67,6 +73,7 @@ class PostgresConnectionPool {
final k = keyFor(row.id, database, mode);
var entry = _pool[k];
if (entry != null) {
entry.touch();
entry.idleTimer?.cancel();
entry.idleTimer = null;
entry.refs++;
Expand All @@ -77,12 +84,35 @@ class PostgresConnectionPool {
return PgLease._(this, k, entry.connection);
}

_evictIfNeededBeforeNewSlot();

final conn = await createAndConnect(row, database: database, mode: mode);
entry = _PoolEntry(conn)..refs = 1;
_pool[k] = entry;
return PgLease._(this, k, conn);
}

/// Drops idle LRU slots until there is room for one more key.
void _evictIfNeededBeforeNewSlot() {
while (_pool.length >= maxEntries) {
final idle = _pool.entries.where((e) => e.value.refs == 0).toList();
if (idle.isEmpty) {
throw StateError(
'PostgreSQL connection pool exhausted: $maxEntries slots in use.',
);
}
idle.sort((a, b) => a.value.lastUsed.compareTo(b.value.lastUsed));
_removeEntryClosing(idle.first.key);
}
}

void _removeEntryClosing(String k) {
final entry = _pool.remove(k);
if (entry == null) return;
entry.idleTimer?.cancel();
unawaited(entry.connection.forceClose());
}

void _release(String k) {
final entry = _pool[k];
if (entry == null) return;
Expand All @@ -106,10 +136,7 @@ class PostgresConnectionPool {
PgSessionMode mode = PgSessionMode.readOnly,
}) {
final k = keyFor(row.id, database, mode);
final entry = _pool.remove(k);
if (entry == null) return;
entry.idleTimer?.cancel();
unawaited(entry.connection.forceClose());
_removeEntryClosing(k);
}

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

class _PoolEntry {
_PoolEntry(this.connection);
_PoolEntry(this.connection) : lastUsed = DateTime.now();

final PostgresConnection connection;
int refs = 0;
Timer? idleTimer;
DateTime lastUsed;

void touch() => lastUsed = DateTime.now();
}
1 change: 1 addition & 0 deletions lib/core/database/postgres_service.dart
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ class PostgresService {
PostgresService._()
: _pool = PostgresConnectionPool(
createAndConnect: _defaultCreateAndConnect,
maxEntries: PostgresConnectionPool.defaultMaxEntries,
);

static final PostgresService instance = PostgresService._();
Expand Down
7 changes: 7 additions & 0 deletions lib/core/database/redis_service.dart
Original file line number Diff line number Diff line change
Expand Up @@ -45,4 +45,11 @@ class RedisService {
await connection.disconnect();
_connections.remove(connection.id);
}

/// Disconnects all Redis connections (e.g. app shutdown).
Future<void> disconnectAll() async {
for (final connection in _connections.values.toList()) {
await disconnect(connection);
}
}
}
35 changes: 35 additions & 0 deletions lib/core/storage/app_settings.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import 'local_db.dart';

/// Typed keys for [LocalDb] app_settings.
abstract final class AppSettingsKeys {
static const postgresSqlStmtTimeoutSeconds =
'postgres_sql_stmt_timeout_seconds';
}

/// User preferences backed by [LocalDb] (SQLite).
class AppSettings {
AppSettings._();
static final AppSettings instance = AppSettings._();

/// `null` = use driver / URI default.
Future<int?> getPostgresSqlStmtTimeoutSeconds() async {
final v = await LocalDb.instance.getAppSetting(
AppSettingsKeys.postgresSqlStmtTimeoutSeconds,
);
if (v == null || v.isEmpty) return null;
return int.tryParse(v);
}

Future<void> setPostgresSqlStmtTimeoutSeconds(int? seconds) async {
if (seconds == null) {
await LocalDb.instance.deleteAppSetting(
AppSettingsKeys.postgresSqlStmtTimeoutSeconds,
);
} else {
await LocalDb.instance.setAppSetting(
AppSettingsKeys.postgresSqlStmtTimeoutSeconds,
seconds.toString(),
);
}
}
}
42 changes: 41 additions & 1 deletion lib/core/storage/local_db.dart
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import 'package:path_provider/path_provider.dart';
import 'package:sqflite_common_ffi/sqflite_ffi.dart';

const _dbName = 'querya.db';
const _dbVersion = 3;
const _dbVersion = 4;

/// Local SQLite database for folders and connections.
/// File: [applicationSupport]/querya_desktop/querya.db
Expand Down Expand Up @@ -65,6 +65,12 @@ class LocalDb {
created_at TEXT NOT NULL
)
''');
await db.execute('''
CREATE TABLE app_settings (
key TEXT PRIMARY KEY NOT NULL,
value TEXT NOT NULL
)
''');
}

Future<void> _onUpgrade(Database db, int oldVersion, int newVersion) async {
Expand Down Expand Up @@ -104,6 +110,40 @@ class LocalDb {
await db.execute('DROP TABLE connections');
await db.execute('ALTER TABLE connections_new RENAME TO connections');
}
if (oldVersion < 4) {
await db.execute('''
CREATE TABLE app_settings (
key TEXT PRIMARY KEY NOT NULL,
value TEXT NOT NULL
)
''');
}
}

Future<String?> getAppSetting(String key) async {
final db = await _open();
final rows = await db.query(
'app_settings',
columns: ['value'],
where: 'key = ?',
whereArgs: [key],
limit: 1,
);
if (rows.isEmpty) return null;
return rows.first['value'] as String?;
}

Future<void> setAppSetting(String key, String value) async {
final db = await _open();
await db.rawInsert(
'INSERT OR REPLACE INTO app_settings (key, value) VALUES (?, ?)',
[key, value],
);
}

Future<void> deleteAppSetting(String key) async {
final db = await _open();
await db.delete('app_settings', where: 'key = ?', whereArgs: [key]);
}

Future<List<String>> getFolders() async {
Expand Down
Loading
Loading