[current results] Return query results sorted by name
Change page token to a base64-encoded pair of (name, configuration)
Change-Id: I7860aa7a1c5e031d80e7c545dc43d53dccefb7a0
Reviewed-on: https://dart-review.googlesource.com/c/dart_ci/+/162881
Reviewed-by: Alexander Thomas <athom@google.com>
diff --git a/current_results/lib/src/iterable.dart b/current_results/lib/src/iterable.dart
new file mode 100644
index 0000000..ff458ed
--- /dev/null
+++ b/current_results/lib/src/iterable.dart
@@ -0,0 +1,53 @@
+// Copyright (c) 2020, the Dart project authors. Please see the AUTHORS file
+// for details. All rights reserved. Use of this source code is governed by a
+// BSD-style license that can be found in the LICENSE file.
+
+import 'dart:collection';
+
+extension IterableIterator<T> on Iterator<T> {
+ Iterable<T> get iterable => SingleUseIterable(this);
+}
+
+class SingleUseIterable<T> extends IterableBase<T> {
+ final Iterator<T> _iterator;
+ bool _used = false;
+ SingleUseIterable(this._iterator);
+
+ Iterator<T> get iterator {
+ if (_used) throw StateError('SingleUseIterable.iterator called twice');
+ _used = true;
+ return _iterator;
+ }
+}
+
+Iterable<T> merge<T, S extends Comparable>(
+ Iterable<T> a, Iterable<T> b, S Function(T) orderedBy) sync* {
+ final ita = a.iterator;
+ final itb = b.iterator;
+ if (!ita.moveNext()) {
+ yield* itb.iterable;
+ return;
+ }
+ if (!itb.moveNext()) {
+ yield ita.current;
+ yield* ita.iterable;
+ return;
+ }
+ for (;;) {
+ if (orderedBy(ita.current).compareTo(orderedBy(itb.current)) <= 0) {
+ yield ita.current;
+ if (!ita.moveNext()) {
+ yield itb.current;
+ yield* itb.iterable;
+ return;
+ }
+ } else {
+ yield itb.current;
+ if (!itb.moveNext()) {
+ yield ita.current;
+ yield* ita.iterable;
+ return;
+ }
+ }
+ }
+}
diff --git a/current_results/lib/src/result.dart b/current_results/lib/src/result.dart
index 3831306..52c2ec3 100644
--- a/current_results/lib/src/result.dart
+++ b/current_results/lib/src/result.dart
@@ -27,6 +27,8 @@
unique(other.expected),
Duration(milliseconds: other.timeMs));
+ Result.nameOnly(String name) : this(name, null, null, null, null, null, null);
+
static final uniqueStrings = <String>{};
static String unique(String string) =>
diff --git a/current_results/lib/src/slice.dart b/current_results/lib/src/slice.dart
index 6ddd550..e1baac0 100644
--- a/current_results/lib/src/slice.dart
+++ b/current_results/lib/src/slice.dart
@@ -10,11 +10,29 @@
import 'package:current_results/src/result.dart';
import 'package:current_results/src/generated/query.pb.dart' as query_api;
import 'package:current_results/src/generated/result.pb.dart' as api;
+import 'package:current_results/src/iterable.dart';
int compareNames(Result a, Result b) => a.name.compareTo(b.name);
-int compareNamesPrefixMatchesBelow(Result a, Result b) =>
- a.name.compareTo(b.name) < 0 || a.name.startsWith(b.name) ? -1 : 1;
+/// All result names before or starting with [prefix] are "before" prefix.
+int isAfterPrefix(Result a, Result prefix) {
+ if (a.name.startsWith(prefix.name)) return -1;
+ return compareNames(a, prefix);
+}
+
+/// Returns the range from [sorted] of Result entries that are at or after
+/// [startResult] and begin with [prefixResult].
+Iterable<Result> getResultRange(
+ List<Result> sorted, Result startResult, Result prefixResult) {
+ var start = lowerBound<Result>(sorted, startResult, compare: compareNames);
+ if (start >= sorted.length) return [];
+ if (!sorted[start].name.startsWith(prefixResult.name)) return [];
+ var end = start + 1;
+ if (end < sorted.length && sorted[end].name.startsWith(prefixResult.name)) {
+ end = lowerBound<Result>(sorted, prefixResult, compare: isAfterPrefix);
+ }
+ return sorted.getRange(start, end);
+}
/// Holds the test results for all configurations.
/// Answers queries about the test results.
@@ -92,11 +110,8 @@
query_api.GetResultsResponse results(query_api.GetResultsRequest query) {
final response = query_api.GetResultsResponse();
final limit = min(100000, query.pageSize == 0 ? 100000 : query.pageSize);
- // TODO(whesse): Change the implementation to return results sorted by test name first.
- // This will be less efficient, but it is much better for the clients.
- // The page token will change to a test name and configuration when this is done.
- final skip =
- max(0, query.pageToken.isEmpty ? 0 : int.parse(query.pageToken));
+ final pageStart =
+ query.pageToken.isEmpty ? null : PageStart.parse(query.pageToken);
final filterTerms =
query.filter.split(',').map((s) => s.trim()).where((s) => s.isNotEmpty);
final configurationSet = Set<String>();
@@ -117,59 +132,72 @@
testPrefixes.removeAt(i + 1);
}
}
+ if (testPrefixes.isEmpty) {
+ testPrefixes.add('');
+ }
final configurations =
(configurationSet.isEmpty ? _stored.keys : configurationSet).toList()
..sort();
- if (testPrefixes.isEmpty) {
- response.results.addAll(configurations
- .expand((configuration) => _stored[configuration])
- .map(Result.toApi)
- .skip(skip)
- .take(limit));
- if (response.results.length >= limit) {
- response.nextPageToken = (skip + response.results.length).toString();
+ for (final prefix in testPrefixes) {
+ response.results.addAll(getSortedResults(
+ prefix, configurations, pageStart,
+ needed: limit - response.results.length)
+ .map(Result.toApi));
+ if (response.results.length == limit) {
+ response.nextPageToken = PageStart(
+ response.results.last.name, response.results.last.configuration)
+ .encode();
+ break;
}
- return response;
- }
-
- var skipped = 0;
- for (final configuration in configurations) {
- final sorted = _stored[configuration];
- for (final testNamePrefix in testPrefixes) {
- if (response.results.length >= limit) break;
- final prefixResult =
- Result(testNamePrefix, null, null, null, null, null, null);
- var start =
- lowerBound<Result>(sorted, prefixResult, compare: compareNames);
- if (start < sorted.length &&
- sorted[start].name.startsWith(testNamePrefix)) {
- var end = start + 1;
- if (end < sorted.length &&
- sorted[end].name.startsWith(testNamePrefix)) {
- end = lowerBound<Result>(sorted, prefixResult,
- compare: compareNamesPrefixMatchesBelow);
- }
- if (end - start > skip - skipped) {
- // No-op if skipped == skip
- start += skip - skipped;
- skipped = skip;
- response.results.addAll(sorted
- .getRange(start, end)
- .map(Result.toApi)
- .take(limit - response.results.length));
- } else {
- skipped += end - start;
- }
- }
- }
- }
- if (response.results.length >= limit) {
- response.nextPageToken = (skip + response.results.length).toString();
}
return response;
}
+ /// Returns up to [needed] results from the configurations in [configurations],
+ /// with test names that start with [prefix], sorted by test name.
+ /// If [pageStart] is not null, test names before pageStart.name are
+ /// filtered out.
+ List<Result> getSortedResults(
+ String prefix, List<String> configurations, PageStart pageStart,
+ {int needed}) {
+ final prefixResult = Result.nameOnly(prefix);
+ var startResult;
+ if (pageStart == null || pageStart.test.compareTo(prefixResult.name) <= 0) {
+ startResult = prefixResult;
+ } else if (pageStart.test.startsWith(prefixResult.name)) {
+ startResult = Result.nameOnly(pageStart.test);
+ } else {
+ return [];
+ }
+
+ var results = <Result>[];
+
+ for (final configuration in configurations) {
+ var configurationRange =
+ getResultRange(_stored[configuration], startResult, prefixResult);
+
+ if (configurationRange.isEmpty) continue;
+ if (pageStart != null &&
+ configurationRange.first.name == pageStart.test &&
+ configuration.compareTo(pageStart.configuration) <= 0) {
+ configurationRange = configurationRange.skip(1);
+ if (configurationRange.isEmpty) continue;
+ }
+ if (results.isEmpty ||
+ results.last.name.compareTo(configurationRange.first.name) <= 0) {
+ // Optimization
+ results.addAll(configurationRange.take(needed - results.length));
+ } else {
+ results =
+ merge(results, configurationRange, (Result result) => result.name)
+ .take(needed)
+ .toList();
+ }
+ }
+ return results;
+ }
+
query_api.ListTestsResponse listTests(query_api.ListTestsRequest query) {
var limit = min(query.limit, 100000);
if (limit == 0) limit = 20;
@@ -187,3 +215,22 @@
return response;
}
}
+
+class PageStart {
+ final String test;
+ final String configuration;
+
+ PageStart(this.test, this.configuration);
+
+ factory PageStart.parse(String token) {
+ final decoded = jsonDecode(ascii.decode(base64Decode(token)));
+ return PageStart(decoded['test'], decoded['configuration']);
+ }
+
+ String encode() {
+ return base64UrlEncode(ascii.encode(jsonEncode({
+ 'test': test,
+ 'configuration': configuration,
+ })));
+ }
+}
diff --git a/current_results/pubspec.lock b/current_results/pubspec.lock
index 2f9ebb4..5b84ff6 100644
--- a/current_results/pubspec.lock
+++ b/current_results/pubspec.lock
@@ -36,6 +36,13 @@
url: "https://pub.dartlang.org"
source: hosted
version: "2.4.1"
+ boolean_selector:
+ dependency: transitive
+ description:
+ name: boolean_selector
+ url: "https://pub.dartlang.org"
+ source: hosted
+ version: "2.0.0"
charcode:
dependency: transitive
description:
@@ -57,6 +64,13 @@
url: "https://pub.dartlang.org"
source: hosted
version: "2.1.1"
+ coverage:
+ dependency: transitive
+ description:
+ name: coverage
+ url: "https://pub.dartlang.org"
+ source: hosted
+ version: "0.14.1"
crypto:
dependency: transitive
description:
@@ -141,6 +155,13 @@
url: "https://pub.dartlang.org"
source: hosted
version: "1.0.0"
+ http_multi_server:
+ dependency: transitive
+ description:
+ name: http_multi_server
+ url: "https://pub.dartlang.org"
+ source: hosted
+ version: "2.2.0"
http_parser:
dependency: transitive
description:
@@ -148,6 +169,13 @@
url: "https://pub.dartlang.org"
source: hosted
version: "3.1.4"
+ io:
+ dependency: transitive
+ description:
+ name: io
+ url: "https://pub.dartlang.org"
+ source: hosted
+ version: "0.3.4"
js:
dependency: transitive
description:
@@ -155,6 +183,20 @@
url: "https://pub.dartlang.org"
source: hosted
version: "0.6.2"
+ logging:
+ dependency: transitive
+ description:
+ name: logging
+ url: "https://pub.dartlang.org"
+ source: hosted
+ version: "0.11.4"
+ matcher:
+ dependency: transitive
+ description:
+ name: matcher
+ url: "https://pub.dartlang.org"
+ source: hosted
+ version: "0.12.9"
meta:
dependency: transitive
description:
@@ -162,6 +204,13 @@
url: "https://pub.dartlang.org"
source: hosted
version: "1.1.8"
+ mime:
+ dependency: transitive
+ description:
+ name: mime
+ url: "https://pub.dartlang.org"
+ source: hosted
+ version: "0.9.7"
node_interop:
dependency: transitive
description:
@@ -176,6 +225,13 @@
url: "https://pub.dartlang.org"
source: hosted
version: "1.1.1"
+ node_preamble:
+ dependency: transitive
+ description:
+ name: node_preamble
+ url: "https://pub.dartlang.org"
+ source: hosted
+ version: "1.4.12"
package_config:
dependency: transitive
description:
@@ -232,6 +288,41 @@
url: "https://pub.dartlang.org"
source: hosted
version: "0.7.5"
+ shelf_packages_handler:
+ dependency: transitive
+ description:
+ name: shelf_packages_handler
+ url: "https://pub.dartlang.org"
+ source: hosted
+ version: "2.0.0"
+ shelf_static:
+ dependency: transitive
+ description:
+ name: shelf_static
+ url: "https://pub.dartlang.org"
+ source: hosted
+ version: "0.2.8"
+ shelf_web_socket:
+ dependency: transitive
+ description:
+ name: shelf_web_socket
+ url: "https://pub.dartlang.org"
+ source: hosted
+ version: "0.2.3"
+ source_map_stack_trace:
+ dependency: transitive
+ description:
+ name: source_map_stack_trace
+ url: "https://pub.dartlang.org"
+ source: hosted
+ version: "2.0.0"
+ source_maps:
+ dependency: transitive
+ description:
+ name: source_maps
+ url: "https://pub.dartlang.org"
+ source: hosted
+ version: "0.10.9"
source_span:
dependency: transitive
description:
@@ -267,6 +358,27 @@
url: "https://pub.dartlang.org"
source: hosted
version: "1.1.0"
+ test:
+ dependency: "direct dev"
+ description:
+ name: test
+ url: "https://pub.dartlang.org"
+ source: hosted
+ version: "1.15.4"
+ test_api:
+ dependency: transitive
+ description:
+ name: test_api
+ url: "https://pub.dartlang.org"
+ source: hosted
+ version: "0.2.18"
+ test_core:
+ dependency: transitive
+ description:
+ name: test_core
+ url: "https://pub.dartlang.org"
+ source: hosted
+ version: "0.3.11+1"
typed_data:
dependency: transitive
description:
@@ -274,6 +386,13 @@
url: "https://pub.dartlang.org"
source: hosted
version: "1.1.6"
+ vm_service:
+ dependency: transitive
+ description:
+ name: vm_service
+ url: "https://pub.dartlang.org"
+ source: hosted
+ version: "4.2.0"
watcher:
dependency: transitive
description:
@@ -281,6 +400,20 @@
url: "https://pub.dartlang.org"
source: hosted
version: "0.9.7+15"
+ web_socket_channel:
+ dependency: transitive
+ description:
+ name: web_socket_channel
+ url: "https://pub.dartlang.org"
+ source: hosted
+ version: "1.1.0"
+ webkit_inspection_protocol:
+ dependency: transitive
+ description:
+ name: webkit_inspection_protocol
+ url: "https://pub.dartlang.org"
+ source: hosted
+ version: "0.7.3"
yaml:
dependency: transitive
description:
diff --git a/current_results/pubspec.yaml b/current_results/pubspec.yaml
index df22a24..a3904a5 100644
--- a/current_results/pubspec.yaml
+++ b/current_results/pubspec.yaml
@@ -15,3 +15,4 @@
dev_dependencies:
pedantic: ^1.8.0
protoc_plugin: 19.0.1
+ test: ^1.15.0
diff --git a/current_results/test/iterable_test.dart b/current_results/test/iterable_test.dart
new file mode 100644
index 0000000..974c4b9
--- /dev/null
+++ b/current_results/test/iterable_test.dart
@@ -0,0 +1,99 @@
+// Copyright (c) 2020, the Dart project authors. Please see the AUTHORS file
+// for details. All rights reserved. Use of this source code is governed by a
+// BSD-style license that can be found in the LICENSE file.
+
+import 'package:test/test.dart';
+import 'package:current_results/src/iterable.dart';
+
+int intIdentity(int i) => i;
+
+void main() {
+ test('merge int lists', () {
+ final a = [1, 2, 4];
+ final b = [3];
+ final c = [2, 5];
+ final empty = <int>[];
+ expect(merge(a, b, intIdentity), [1, 2, 3, 4]);
+ expect(merge(a, c, intIdentity), [1, 2, 2, 4, 5]);
+ expect(merge(b, b, intIdentity), [3, 3]);
+ expect(merge(c, c, intIdentity), [2, 2, 5, 5]);
+ expect(merge(empty, empty, intIdentity), []);
+ expect(merge<int, int>([], [], intIdentity), []);
+ expect(merge(a, empty, intIdentity), a);
+ expect(merge(b, empty, intIdentity), b);
+ expect(merge(empty, a, intIdentity), a);
+ expect(merge(empty, b, intIdentity), b);
+ });
+
+ test('merge map lists', () {
+ final a = [
+ {'x': 1, 'y': 9},
+ {'x': 2},
+ {'x': 4}
+ ];
+ final b = [
+ {'x': 3, 'y': 8}
+ ];
+ final c = [
+ {'x': 2, 'y': 7},
+ {'x': 5}
+ ];
+ final empty = <Map<String, int>>[];
+
+ int xField(Map<String, int> map) => map['x'];
+ expect(merge(a, b, xField), [
+ {'x': 1, 'y': 9},
+ {'x': 2},
+ {'x': 3, 'y': 8},
+ {'x': 4},
+ ]);
+ expect(merge(a, c, xField), [
+ {'x': 1, 'y': 9},
+ {'x': 2},
+ {'x': 2, 'y': 7},
+ {'x': 4},
+ {'x': 5},
+ ]);
+ expect(merge(b, b, xField), [
+ {'x': 3, 'y': 8},
+ {'x': 3, 'y': 8},
+ ]);
+ expect(merge(c, c, xField), [
+ {'x': 2, 'y': 7},
+ {'x': 2, 'y': 7},
+ {'x': 5},
+ {'x': 5},
+ ]);
+ expect(merge(empty, empty, xField), []);
+ expect(merge<Map<String, int>, int>([], [], xField), []);
+ expect(merge(a, empty, xField), a);
+ expect(merge(b, empty, xField), b);
+ expect(merge(empty, a, xField), a);
+ expect(merge(empty, b, xField), b);
+ });
+
+ test("Iterable from Iterator", () {
+ final a = [
+ 1,
+ 2,
+ 4,
+ ];
+ expect(a.iterator.iterable, a);
+ expect((a.iterator..moveNext()).iterable, a.skip(1));
+ expect([].iterator.iterable, []);
+ expect(() => a.iterator.iterable..toList()..toList(), throwsStateError);
+ expect(a.getRange(1, 2).iterator.iterable.single, 2);
+ expect(a.iterator.iterable.iterator.iterable, a);
+ final m = {'x': 1, 'y': 2};
+ expect(m.keys.iterator.iterable, ['x', 'y']);
+ Iterable<Map<String, int>> unbounded() sync* {
+ for (;;) yield m;
+ }
+
+ expect(unbounded().iterator.iterable.take(2), [m, m]);
+ expect(a.iterator.iterable.length, 3); // hasLength matcher can't be used.
+ expect((() sync* {
+ yield* a.iterator.iterable;
+ })(), a);
+ });
+}