From 6c510bf9ea0ce444ceee06ad86313a15ce240859 Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Mon, 27 Jul 2026 19:45:15 +0300 Subject: [PATCH] perf(redis): pipeline TYPE/TTL for SCAN batches Enqueue all TYPE then all TTL before awaiting replies (pipe_start/end), so a 100-key batch is one RTT burst instead of ~200 round-trips. Closes #426 --- lib/core/database/redis_connection.dart | 39 +++++++++++++++++ lib/features/redis/redis_keys_view.dart | 29 ++++++++----- .../database/redis_types_and_ttls_test.dart | 43 +++++++++++++++++++ 3 files changed, 100 insertions(+), 11 deletions(-) create mode 100644 test/core/database/redis_types_and_ttls_test.dart diff --git a/lib/core/database/redis_connection.dart b/lib/core/database/redis_connection.dart index ee6b2528..f7f8fbe9 100644 --- a/lib/core/database/redis_connection.dart +++ b/lib/core/database/redis_connection.dart @@ -206,6 +206,45 @@ class RedisConnection { return result is int ? result : int.tryParse(result.toString()) ?? -1; } + /// Pipelined TYPE + TTL for a SCAN batch. + /// + /// Writes all commands before awaiting replies (redis-dart FIFO parse + /// queue + optional Nagle via [Command.pipe_start]), so a batch of N keys + /// costs ~1 RTT instead of ~2N sequential round-trips. + Future> typesAndTtls(List keys) async { + if (keys.isEmpty) return const []; + if (!isConnected) { + throw StateError('Not connected to Redis'); + } + + final cmd = _command; + cmd?.pipe_start(); + try { + final typeFutures = >[ + for (final key in keys) + sendCommand(['TYPE', key]).then( + (v) => v?.toString() ?? 'none', + onError: (_) => 'unknown', + ), + ]; + final ttlFutures = >[ + for (final key in keys) + sendCommand(['TTL', key]).then( + (v) => v is int ? v : int.tryParse(v.toString()) ?? -1, + onError: (_) => -1, + ), + ]; + final types = await Future.wait(typeFutures); + final ttls = await Future.wait(ttlFutures); + return [ + for (var i = 0; i < keys.length; i++) + (type: types[i], ttl: ttls[i]), + ]; + } finally { + cmd?.pipe_end(); + } + } + /// GET (string). Future get(String key) async { final result = await sendCommand(['GET', key]); diff --git a/lib/features/redis/redis_keys_view.dart b/lib/features/redis/redis_keys_view.dart index 1d6cc7ba..acba5dbc 100644 --- a/lib/features/redis/redis_keys_view.dart +++ b/lib/features/redis/redis_keys_view.dart @@ -78,17 +78,24 @@ class _RedisKeysViewState extends material.State { count: 100, ); - // Fetch type and TTL for each key concurrently - final futures = keyNames.map((name) async { - try { - final type = await widget.connection.keyType(name); - final ttl = await widget.connection.ttl(name); - return _KeyInfo(name: name, type: type, ttl: ttl); - } catch (_) { - return _KeyInfo(name: name, type: 'unknown', ttl: -1); - } - }); - final infos = await Future.wait(futures); + // One pipelined burst of TYPE+TTL (not NĂ— Future.wait round-trips). + List<_KeyInfo> infos; + try { + final metas = await widget.connection.typesAndTtls(keyNames); + infos = [ + for (var i = 0; i < keyNames.length; i++) + _KeyInfo( + name: keyNames[i], + type: metas[i].type, + ttl: metas[i].ttl, + ), + ]; + } catch (_) { + infos = [ + for (final name in keyNames) + _KeyInfo(name: name, type: 'unknown', ttl: -1), + ]; + } if (!mounted) return; setState(() { diff --git a/test/core/database/redis_types_and_ttls_test.dart b/test/core/database/redis_types_and_ttls_test.dart new file mode 100644 index 00000000..b7067209 --- /dev/null +++ b/test/core/database/redis_types_and_ttls_test.dart @@ -0,0 +1,43 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:querya_desktop/core/database/redis_connection.dart'; + +/// Counts outbound commands to prove [typesAndTtls] fires TYPE+TTL without +/// awaiting between keys (true pipeline enqueue). +class _CountingRedisFake extends RedisConnectionTestFake { + _CountingRedisFake() : super(firstScanKeys: const []); + + final List ops = []; + + @override + Future sendCommand(List args) async { + ops.add(args.first.toString().toUpperCase()); + // Delay so overlapping awaits would change order if callers awaited per key. + await Future.delayed(Duration.zero); + return super.sendCommand(args); + } +} + +void main() { + test('typesAndTtls enqueues all TYPE then all TTL before settling', () async { + final fake = _CountingRedisFake(); + await fake.connect(); + + final metas = await fake.typesAndTtls(['a', 'b', 'c']); + + expect(metas, hasLength(3)); + expect(metas.map((m) => m.type), everyElement('string')); + expect(metas.map((m) => m.ttl), everyElement(-1)); + + // All TYPE writes precede all TTL writes (single burst, not TYPE+TTL per key). + expect( + fake.ops, + ['TYPE', 'TYPE', 'TYPE', 'TTL', 'TTL', 'TTL'], + ); + }); + + test('typesAndTtls returns empty for empty keys', () async { + final fake = RedisConnectionTestFake(); + await fake.connect(); + expect(await fake.typesAndTtls(const []), isEmpty); + }); +}