blob: 3c55cd3e20a3f2d8c4320704ad2c34007d207a81 [file] [edit]
// Copyright 2014 The Flutter Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
// Regenerates `dev/bots/tool_test_durations.json` from the per-test
// durations that recent postsubmit builds reported to ResultDB.
// setUpAll/tearDownAll time is not reported, so files with heavy fixtures read
// a little low.
//
// dart dev/bots/update_tool_test_durations.dart [--builds=N]
import 'dart:convert';
import 'dart:io';
import 'package:args/args.dart';
import 'package:http/http.dart' as http;
import 'package:path/path.dart' as path;
import 'utils.dart';
/// Whole seconds per test file, keyed by path relative to `packages/flutter_tools`.
typedef DurationsByFile = Map<String, int>;
/// [DurationsByFile] keyed by `Platform.operatingSystem`.
typedef DurationsByHost = Map<String, DurationsByFile>;
const String _buildbucket = 'https://cr-buildbucket.appspot.com/prpc/buildbucket.v2.';
const String _resultdb = 'https://results.api.luci.app/prpc/luci.resultdb.v1.';
/// `Platform.operatingSystem` value for each `.ci.yaml` platform prefix.
const Map<String, String> _hostByPrefix = <String, String>{
'Linux': 'linux',
'Mac': 'macos',
'Mac_arm64': 'macos',
'Windows': 'windows',
};
/// A `packages/flutter_tools/test/<name>.shard` directory and the prod
/// builders that run it.
typedef ToolShard = ({String name, RegExp builderName});
RegExp _builderName(String pattern) => RegExp('^(${_hostByPrefix.keys.join('|')}) $pattern\$');
final List<ToolShard> _shards = <ToolShard>[
(name: 'integration', builderName: _builderName(r'tool_integration_tests_\d+(_\d+)?')),
(name: 'commands', builderName: _builderName(r'tool_tests_commands(_\d+_\d+)?')),
(name: 'web', builderName: _builderName(r'web_tool_tests(_\d+_\d+)?')),
];
final http.Client _client = http.Client();
Future<void> main(List<String> arguments) async {
final parser = ArgParser()
..addOption('builds', defaultsTo: '3', help: 'Successful builds to sample per subshard.')
..addFlag('help', abbr: 'h', negatable: false);
final ArgResults args = parser.parse(arguments);
if (args.flag('help')) {
print(parser.usage);
return;
}
final int builds = int.parse(args.option('builds')!);
final output = File(path.join(flutterRoot, 'dev', 'bots', 'tool_test_durations.json'));
final DurationsByHost previous = _readExisting(output);
final List<String> prodBuilders = await _listProdBuilders();
final result = <String, DurationsByFile>{};
for (final ToolShard shard in _shards) {
final sampled = <String, DurationsByFile>{};
for (final MapEntry(key: host, value: builders) in _buildersByHost(
shard,
prodBuilders,
).entries) {
if (builders.isEmpty) {
throw StateError('No ${shard.name} builders found for $host');
}
sampled[host] = await _sampleDurations(shard, builders, builds);
}
// Reconcile per shard so unsampled files get their own shard's median.
for (final MapEntry(key: host, value: durations) in _reconcile(
sampled,
_localTestFiles(shard),
previous,
).entries) {
(result[host] ??= <String, int>{}).addAll(durations);
}
}
_client.close();
final sorted = <String, DurationsByFile>{
for (final MapEntry(key: host, value: durations) in result.entries)
host: <String, int>{
for (final file in durations.keys.toList()..sort()) file: durations[file]!,
},
};
output.writeAsStringSync('${const JsonEncoder.withIndent(' ').convert(sorted)}\n');
stderr.writeln('Wrote ${output.path} (${sorted.values.first.length} files)');
}
/// The [prodBuilders] that run [shard], by host.
Map<String, List<String>> _buildersByHost(ToolShard shard, List<String> prodBuilders) {
final buildersByHost = <String, List<String>>{
for (final String host in _hostByPrefix.values) host: <String>[],
};
for (final builder in prodBuilders) {
final Match? m = shard.builderName.firstMatch(builder);
if (m != null) {
buildersByHost[_hostByPrefix[m.group(1)]!]!.add(builder);
}
}
return buildersByHost;
}
/// Returns the median duration per file over the last [builds] successful builds of [builders].
Future<DurationsByFile> _sampleDurations(ToolShard shard, List<String> builders, int builds) async {
final samples = <String, List<int>>{};
for (final builder in builders..sort()) {
stderr.writeln('Fetching $builds builds of $builder');
for (final String invocation in await _recentInvocations(builder, builds)) {
final DurationsByFile fileDurations = await _fileDurations(shard, invocation);
for (final MapEntry(key: file, value: seconds) in fileDurations.entries) {
samples.putIfAbsent(file, () => <int>[]).add(seconds);
}
}
}
return <String, int>{
for (final MapEntry(key: file, value: seconds) in samples.entries) file: _median(seconds),
};
}
/// Test files of [shard] in the tree, relative to `packages/flutter_tools`.
List<String> _localTestFiles(ToolShard shard) {
final String toolsRoot = path.join(flutterRoot, 'packages', 'flutter_tools');
return Directory(path.join(toolsRoot, 'test', '${shard.name}.shard'))
.listSync(recursive: true)
.whereType<File>()
.map((File f) => path.posix.joinAll(path.split(path.relative(f.path, from: toolsRoot))))
.where((String f) => f.endsWith('_test.dart'))
.toList()
..sort();
}
/// Emits exactly [localFiles] per host, filling in unsampled files.
DurationsByHost _reconcile(
DurationsByHost sampled,
List<String> localFiles,
DurationsByHost previous,
) {
final sampledAnywhere = <String>{for (final DurationsByFile m in sampled.values) ...m.keys};
final result = <String, DurationsByFile>{};
for (final MapEntry(key: host, value: durations) in sampled.entries) {
final int median = _median(durations.values.toList());
result[host] = <String, int>{
for (final String f in localFiles)
f: durations[f] ?? (sampledAnywhere.contains(f) ? 0 : previous[host]?[f] ?? median),
};
}
return <String, DurationsByFile>{
for (final String host in result.keys.toList()..sort()) host: result[host]!,
};
}
DurationsByHost _readExisting(File file) {
if (!file.existsSync()) {
return const <String, DurationsByFile>{};
}
final hosts = json.decode(file.readAsStringSync()) as Map<String, Object?>;
return hosts.map(
(String host, Object? files) => MapEntry<String, DurationsByFile>(
host,
Map<String, int>.from(files! as Map<String, Object?>),
),
);
}
/// Names of all builders in the `flutter/prod` bucket.
///
/// The local .ci.yaml may not match prod, so ask Buildbucket.
Future<List<String>> _listProdBuilders() async {
final names = <String>[];
String? pageToken;
do {
final Object? response = await _rpc(_buildbucket, 'Builders/ListBuilders', <String, Object?>{
'project': 'flutter',
'bucket': 'prod',
'pageSize': 1000,
if (pageToken != null) 'pageToken': pageToken,
});
if (response case {'builders': final List<Object?> builders}) {
for (final builder in builders) {
if (builder case {'id': {'builder': final String name}}) {
names.add(name);
}
}
}
pageToken = switch (response) {
{'nextPageToken': final String token} => token,
_ => null,
};
} while (pageToken != null);
return names;
}
Future<Object?> _rpc(String service, String method, Map<String, Object?> body) async {
final http.Response response = await _client.post(
Uri.parse('$service$method'),
headers: const <String, String>{
'Content-Type': 'application/json',
'Accept': 'application/json',
},
body: json.encode(body),
);
if (response.statusCode != 200) {
throw StateError('$method failed: ${response.statusCode} ${response.body}');
}
// pRPC prefixes JSON responses with an XSSI guard.
const guard = ")]}'";
final String text = response.body.startsWith(guard)
? response.body.substring(guard.length)
: response.body;
return json.decode(text);
}
/// ResultDB invocations of the last [count] successful builds of [builder].
Future<List<String>> _recentInvocations(String builder, int count) async {
final Object? response = await _rpc(_buildbucket, 'Builds/SearchBuilds', <String, Object?>{
'predicate': <String, Object?>{
'builder': <String, String>{'project': 'flutter', 'bucket': 'prod', 'builder': builder},
'status': 'SUCCESS',
},
'pageSize': count,
'fields': 'builds.*.infra.resultdb.invocation',
});
return <String>[
if (response case {'builds': final List<Object?> builds})
for (final build in builds)
if (build case {'infra': {'resultdb': {'invocation': final String invocation}}}) invocation,
];
}
/// Whole seconds per [shard] file in [invocation].
Future<DurationsByFile> _fileDurations(ToolShard shard, String invocation) async {
final prefix = 'packages/flutter_tools/test/${shard.name}.shard/';
final seconds = <String, double>{};
String? pageToken;
do {
final Object? response = await _rpc(_resultdb, 'ResultDB/QueryTestResults', <String, Object?>{
'invocations': <String>[invocation],
'pageSize': 1000,
'readMask': 'testIdStructured,duration',
if (pageToken != null) 'pageToken': pageToken,
});
if (response case {'testResults': final List<Object?> results}) {
for (final result in results) {
if (result
case {
'testIdStructured': {'moduleName': final String file},
'duration': final String duration,
}
when file.startsWith(prefix)) {
final String key = file.substring('packages/flutter_tools/'.length);
seconds[key] =
(seconds[key] ?? 0) + double.parse(duration.substring(0, duration.length - 1));
}
}
}
pageToken = switch (response) {
{'nextPageToken': final String token} => token,
_ => null,
};
} while (pageToken != null);
return seconds.map((String file, double s) => MapEntry<String, int>(file, s.round()));
}
int _median(List<int> values) {
if (values.isEmpty) {
return 0;
}
final List<int> sorted = values.toList()..sort();
return sorted[sorted.length ~/ 2];
}