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
84 changes: 59 additions & 25 deletions lib/features/redis/redis_keys_view.dart
Original file line number Diff line number Diff line change
Expand Up @@ -76,39 +76,55 @@ class _RedisKeysViewState extends material.State<RedisKeysView> {
}

Future<void> _scanBatch() async {
final (nextCursor, keyNames) = await widget.connection.scan(
cursor: _cursor,
match: _matchPattern.isEmpty ? null : _matchPattern,
count: 100,
);
var currentCursor = _cursor;
final accumulatedKeys = <RedisBulkValue>[];
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;
});
}
Expand Down Expand Up @@ -195,7 +211,25 @@ class _RedisKeysViewState extends material.State<RedisKeysView> {
? 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(
Expand Down
102 changes: 102 additions & 0 deletions test/features/redis/redis_keys_view_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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: <String>[]),
// Step 2: found key, next cursor 0
(nextCursor: 0, keys: <String>['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: <String>[]),
(nextCursor: 0, keys: <String>['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 {
Expand All @@ -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<String> keys})> steps;
int scanCallCount = 0;

@override
Future<dynamic> sendCommand(List<dynamic> 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, <String>[]];
}
return super.sendCommand(args);
}
}
Loading