From 6ed7f5225427bf22000669091c1b5506a474ac88 Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Wed, 23 Sep 2026 23:37:30 +0300 Subject: [PATCH] fix(redis): loop SCAN until keys found or cursor reaches zero to prevent empty result lockup --- lib/features/redis/redis_keys_view.dart | 84 ++++++++++----- test/features/redis/redis_keys_view_test.dart | 102 ++++++++++++++++++ 2 files changed, 161 insertions(+), 25 deletions(-) diff --git a/lib/features/redis/redis_keys_view.dart b/lib/features/redis/redis_keys_view.dart index b311e36..78b624b 100644 --- a/lib/features/redis/redis_keys_view.dart +++ b/lib/features/redis/redis_keys_view.dart @@ -76,39 +76,55 @@ class _RedisKeysViewState extends material.State { } Future _scanBatch() async { - final (nextCursor, keyNames) = await widget.connection.scan( - cursor: _cursor, - match: _matchPattern.isEmpty ? null : _matchPattern, - count: 100, - ); + var currentCursor = _cursor; + final accumulatedKeys = []; + const maxIterations = 10; + var iterations = 0; + + do { + final (nextCursor, keyNames) = await widget.connection.scan( + cursor: currentCursor, + match: _matchPattern.isEmpty ? null : _matchPattern, + count: 100, + ); + currentCursor = nextCursor; + accumulatedKeys.addAll(keyNames); + iterations++; + } while (accumulatedKeys.length < 50 && + currentCursor != 0 && + iterations < maxIterations); // One pipelined burst of TYPE+TTL (not NĂ— Future.wait round-trips). List<_KeyInfo> infos; String? typeTtlError; - 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 (e) { - // Still show keys with unknown type/TTL; surface the failure non-blocking. - infos = [ - for (final name in keyNames) - _KeyInfo(name: name, type: 'unknown', ttl: -1), - ]; - typeTtlError = 'Failed to load key types/TTLs: $e'; + if (accumulatedKeys.isNotEmpty) { + try { + final metas = await widget.connection.typesAndTtls(accumulatedKeys); + infos = [ + for (var i = 0; i < accumulatedKeys.length; i++) + _KeyInfo( + name: accumulatedKeys[i], + type: metas[i].type, + ttl: metas[i].ttl, + ), + ]; + } catch (e) { + // Still show keys with unknown type/TTL; surface the failure non-blocking. + infos = [ + for (final name in accumulatedKeys) + _KeyInfo(name: name, type: 'unknown', ttl: -1), + ]; + typeTtlError = 'Failed to load key types/TTLs: $e'; + } + } else { + infos = []; } if (!mounted) return; setState(() { _keys.addAll(infos); - _cursor = nextCursor; - _hasMore = nextCursor != 0; + _cursor = currentCursor; + _hasMore = currentCursor != 0; _error = typeTtlError; }); } @@ -195,7 +211,25 @@ class _RedisKeysViewState extends material.State { ? material.Center( child: material.Padding( padding: const material.EdgeInsets.all(48), - child: const Text('No keys found').muted(), + child: material.Column( + mainAxisSize: material.MainAxisSize.min, + children: [ + Text(_hasMore + ? 'No keys found in scanned range' + : 'No keys found') + .muted(), + if (_hasMore) ...[ + const Gap(16), + OutlineButton( + onPressed: _loadingMore ? null : _loadMore, + size: ButtonSize.small, + child: _loadingMore + ? const Text('Scanning...') + : const Text('Continue scanning'), + ), + ], + ], + ), ), ) : material.ListView.builder( diff --git a/test/features/redis/redis_keys_view_test.dart b/test/features/redis/redis_keys_view_test.dart index 146da41..968e15d 100644 --- a/test/features/redis/redis_keys_view_test.dart +++ b/test/features/redis/redis_keys_view_test.dart @@ -199,6 +199,83 @@ void main() { expect(find.text('[255, 254]'), findsNothing); await fake.disconnect(); }); + + testWidgets( + 'RedisKeysView loops through empty SCAN batch until keys are found', + (tester) async { + final fake = _MultiStepScanFake( + steps: [ + // Step 1: empty keys, next cursor 10 + (nextCursor: 10, keys: []), + // Step 2: found key, next cursor 0 + (nextCursor: 0, keys: ['target_key']), + ], + dbSizeResult: 1, + ); + await fake.connect(); + + await tester.pumpWidget( + queryaThemeTestShell( + child: material.Scaffold( + body: material.SizedBox( + width: 800, + height: 600, + child: RedisKeysView( + connection: fake, + database: 0, + ), + ), + ), + ), + ); + await tester.pumpAndSettle(); + + expect(find.text('target_key'), findsOneWidget); + expect(fake.scanCallCount, 2); + await fake.disconnect(); + }); + + testWidgets( + 'RedisKeysView shows Continue scanning when empty after iteration limit', + (tester) async { + // 10 empty steps with cursor > 0, then 1 step with keys + final fake = _MultiStepScanFake( + steps: [ + for (var i = 1; i <= 10; i++) (nextCursor: i, keys: []), + (nextCursor: 0, keys: ['late_key']), + ], + dbSizeResult: 1, + ); + await fake.connect(); + + await tester.pumpWidget( + queryaThemeTestShell( + child: material.Scaffold( + body: material.SizedBox( + width: 800, + height: 600, + child: RedisKeysView( + connection: fake, + database: 0, + ), + ), + ), + ), + ); + await tester.pumpAndSettle(); + + expect(find.text('No keys found in scanned range'), findsOneWidget); + expect(find.text('Continue scanning'), findsOneWidget); + expect(fake.scanCallCount, 10); + + // Tap continue scanning + await tester.tap(find.text('Continue scanning')); + await tester.pumpAndSettle(); + + expect(find.text('late_key'), findsOneWidget); + expect(fake.scanCallCount, 11); + await fake.disconnect(); + }); } class _DelTrackingFake extends RedisConnectionTestFake { @@ -212,3 +289,28 @@ class _DelTrackingFake extends RedisConnectionTestFake { return super.del(key); } } + +class _MultiStepScanFake extends RedisConnectionTestFake { + _MultiStepScanFake({ + required this.steps, + super.dbSizeResult, + }); + + final List<({int nextCursor, List keys})> steps; + int scanCallCount = 0; + + @override + Future sendCommand(List args) async { + final op = args.first.toString().toUpperCase(); + if (op == 'SCAN') { + final stepIndex = scanCallCount; + scanCallCount++; + if (stepIndex < steps.length) { + final step = steps[stepIndex]; + return [step.nextCursor, step.keys]; + } + return [0, []]; + } + return super.sendCommand(args); + } +}