blob: 6e6c6f9c983b808651f1d6c15cdf8861f990e6de [file] [log] [blame]
// Copyright (c) 2019, 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:convert';
import 'dart:math' show max, min;
import 'package:firebase_functions_interop/firebase_functions_interop.dart';
import 'package:node_http/node_http.dart' as http;
void info(Object message) {
print("Info: $message");
}
void error(Object message) {
print("Error: $message");
}
void main() {
functions['receiveChanges'] =
functions.pubsub.topic('results').onPublish(receiveChanges);
}
// Cloud functions run the cloud function many times in the same isolate.
// Use static initializer to run global initialization once.
Firestore firestore = createFirestore();
Firestore createFirestore() {
final app = FirebaseAdmin.instance.initializeApp();
return app.firestore()
..settings(FirestoreSettings(timestampsInSnapshots: true));
}
Future<void> receiveChanges(Message message, EventContext context) async {
final results = (message.json as List).cast<Map<String, dynamic>>();
final stats = Statistics();
var buildInfo = await storeBuildInfo(results, stats);
await Future.forEach(results.where(isChangedResult),
(result) => storeChange(result, buildInfo, stats));
stats.report();
}
class Statistics {
int results = 0;
int changes = 0;
int newRecords = 0;
int modifiedRecords = 0;
int commitsFetched = 0;
String builder;
int buildNumber;
void report() {
info("Number of changed results processed: $changes");
info("Number of results processed: $results");
info("Number of firestore records produced: $newRecords");
info("Number of firestore records modified: $modifiedRecords");
info("Number of commits fetched: $commitsFetched");
}
}
Future<Map<String, int>> storeBuildInfo(
List<Map<String, dynamic>> results, Statistics stats) async {
stats.results = results.length;
// Get indices of change. Range includes startIndex and endIndex.
final change = results.first;
final hash = change['commit_hash'] as String;
if (hash.startsWith('refs/changes')) return {}; // No buildInfo for try jobs.
final docRef = await firestore.document('commits/$hash');
var docSnapshot = await docRef.get();
if (!docSnapshot.exists) {
await getMissingCommits(hash, stats);
docSnapshot = await docRef.get();
if (!docSnapshot.exists) {
error('Result received with unknown commit hash $hash');
}
}
final endIndex = docSnapshot.data.getInt('index');
// If this is a new builder, use the current commit as a trivial blamelist.
var startIndex = endIndex;
if (change['previous_commit_hash'] != null) {
final commit = await firestore
.document('commits/${change['previous_commit_hash']}')
.get();
startIndex = commit.data.getInt('index') + 1;
}
final String builder = change['builder_name'];
stats.builder = builder;
final int buildNumber = int.parse(change['build_number']);
stats.buildNumber = buildNumber;
final Set<String> configurations =
results.map((change) => change['configuration'] as String).toSet();
for (final configuration in configurations) {
final record =
await firestore.document('configurations/$configuration').get();
if (!record.exists || record.data.getString('builder') != builder) {
await firestore
.document('configurations/$configuration')
.setData(DocumentData.fromMap({'builder': builder}));
if (!record.exists) {
info('Configuration document $configuration -> $builder created');
} else {
info('Configuration document changed: $configuration -> $builder '
'(was ${record.data.getString("builder")}');
}
}
}
final documentRef = firestore.document('builds/$builder:$endIndex');
final record = await documentRef.get();
if (!record.exists) {
await documentRef.setData(DocumentData.fromMap(
{'builder': builder, 'build_number': buildNumber, 'index': endIndex}));
info('Created build record: '
'builder: $builder, build_number: $buildNumber, index: $endIndex');
} else if (record.data.getInt('index') != endIndex) {
error(
'Build $buildNumber of $builder had commit index ${record.data.getInt('index')},'
'should be $endIndex.');
}
return {"startIndex": startIndex, "endIndex": endIndex};
}
Future<void> storeChange(Map<String, dynamic> change,
Map<String, int> buildInfo, Statistics stats) async {
stats.changes++;
if ((change['commit_hash'] as String).startsWith('refs/changes')) {
return storeTryChange(change);
}
String name = change['name'];
String result = change['result'];
String previousResult = change['previous_result'] ?? 'new test';
QuerySnapshot snapshot = await firestore
.collection('results')
.orderBy('blamelist_end_index', descending: true)
.where('name', isEqualTo: name)
.where('result', isEqualTo: result)
.where('previous_result', isEqualTo: previousResult)
.where('expected', isEqualTo: change['expected'])
.limit(5) // We will pick the right one, probably the latest.
.get();
// Find an existing change group with a blamelist that intersects this change.
final startIndex = buildInfo['startIndex'];
final endIndex = buildInfo['endIndex'];
bool blamelistIncludesChange(DocumentSnapshot groupDocument) {
var group = groupDocument.data;
var groupStart = group.getInt('blamelist_start_index');
var groupEnd = group.getInt('blamelist_end_index');
return startIndex <= groupEnd && endIndex >= groupStart;
}
DocumentSnapshot group = snapshot.documents
.firstWhere(blamelistIncludesChange, orElse: () => null);
if (group == null) {
info("Adding group for $name");
return firestore.collection('results').add(DocumentData.fromMap({
'name': name,
'result': result,
'previous_result': previousResult,
'expected': change['expected'],
'blamelist_start_index': startIndex,
'blamelist_end_index': endIndex,
'trivial_blamelist': startIndex == endIndex,
'configurations': <String>[change['configuration']]
}));
}
// Update the change group in a transaction.
// Add new configuration and narrow the blamelist.
Future<void> updateGroup(Transaction transaction) async {
final DocumentSnapshot groupSnapshot =
await transaction.get(group.reference);
final data = groupSnapshot.data;
final newStart = max(startIndex, data.getInt('blamelist_start_index'));
final newEnd = min(endIndex, data.getInt('blamelist_end_index'));
final updateMap = <String, dynamic>{
'blamelist_start_index':
max(startIndex, data.getInt('blamelist_start_index')),
'blamelist_end_index': min(endIndex, data.getInt('blamelist_end_index'))
};
updateMap['trivial_blamelist'] = (updateMap['blamelist_start_index'] ==
updateMap['blamelist_end_index']);
final update = UpdateData.fromMap({
'blamelist_start_index': newStart,
'blamelist_end_index': newEnd,
'trivial_blamelist': newStart == newEnd
});
update.setFieldValue('configurations',
Firestore.fieldValues.arrayUnion([change['configuration']]));
group.reference.updateData(update);
}
return firestore.runTransaction(updateGroup);
}
Future<void> storeTryChange(Map<String, dynamic> change) async {
String name = change['name'];
String result = change['result'];
String expected = change['expected'];
String patch = change['commit_hash'];
String previousResult = change['previous_result'] ?? 'new test';
QuerySnapshot snapshot = await firestore
.collection('try_results')
.where('patch', isEqualTo: patch)
.where('name', isEqualTo: name)
.where('result', isEqualTo: result)
.where('previous_result', isEqualTo: previousResult)
.where('expected', isEqualTo: expected)
.limit(1)
.get();
if (snapshot.isEmpty) {
info("Adding group for $name");
return firestore.collection('try_results').add(DocumentData.fromMap({
'name': name,
'result': result,
'previous_result': previousResult,
'expected': expected,
'patch': patch,
'configurations': <String>[change['configuration']]
}));
} else {
final update = UpdateData()
..setFieldValue('configurations',
Firestore.fieldValues.arrayUnion([change['configuration']]));
snapshot.documents.first.reference.updateData(update);
}
}
bool isChangedResult(Map<String, dynamic> result) =>
result['changed'] && !result['flaky'] && !result['previous_flaky'];
Future<void> getMissingCommits(String hash, Statistics stats) async {
final client = http.NodeClient();
final QuerySnapshot lastCommit = await firestore
.collection('commits')
.orderBy('index', descending: true)
.limit(1)
.get();
final lastHash = lastCommit.documents.first.documentID;
final lastIndex = lastCommit.documents.first.data.getInt('index');
final logUrl = 'https://dart.googlesource.com/sdk/+log/';
final range = '$lastHash..master';
final parameters = ['format=JSON', 'topo-order', 'n=1000'];
final url = '$logUrl$range?${parameters.join('&')}';
final response = await client.get(url);
final log = response.body;
// Expect XSSI protection.
if (!log.startsWith(")]}'")) {
error('Got response from gerrit API without XSSI protection: ' + log);
throw ('Got response from gerrit API without XSSI protection: ' + log);
}
// Remove first line when XSSI protection is there.
final commits =
jsonDecode(log.substring(log.indexOf('\n') + 1))['log'] as List<dynamic>;
if (commits.isEmpty) {
info('Found no new commits between $lastHash and master');
return;
}
stats.commitsFetched = commits.length;
final first = commits.last as Map<String, dynamic>;
if (first['parents'].first != lastHash) {
error('First new commit ${first['parents'].first} is not'
' a child of last known commit $lastHash when fetching new commits');
throw ('First new commit ${first['parents'].first} is not'
' a child of last known commit $lastHash when fetching new commits');
}
if (!commits.any((commit) => commit['commit'] == hash)) {
info('Did not find commit $hash when fetching new commits');
return;
}
var index = lastIndex + 1;
for (Map<String, dynamic> commit in commits.reversed) {
// Create new commit document for this commit.
final docRef = firestore.document('commits/${commit['commit']}');
final data = DocumentData.fromMap({
'author': commit['author']['email'],
'created': Timestamp.fromDateTime(
parseGitilesDateTime(commit['committer']['time'])),
'index': index,
'title': commit['message'].split('\n').first
});
await docRef.setData(data);
++index;
}
}
const months = const {
'Jan': '01',
'Feb': '02',
'Mar': '03',
'Apr': '04',
'May': '05',
'Jun': '06',
'Jul': '07',
'Aug': '08',
'Sep': '09',
'Oct': '10',
'Nov': '11',
'Dec': '12'
};
DateTime parseGitilesDateTime(String gitiles) {
final parts = gitiles.split(' ');
final year = parts[4];
final month = months[parts[1]];
final day = parts[2].padLeft(2, '0');
return DateTime.parse('$year-$month-$day ${parts[3]} ${parts[5]}');
}