blob: 0c51013bdb28c72a594e86760662e978c0fed4d9 [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:math' show max, min;
import 'package:firebase_admin_interop/firebase_admin_interop.dart';
import 'package:node_interop/node.dart';
import 'package:retry/retry.dart';
import 'firestore.dart';
// 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));
}
class ActiveRequest {
final String type;
final String info;
final DateTime start;
ActiveRequest(this.type, this.info) : start = DateTime.now();
String toString() => 'Request $type of $info started at $start';
}
class FirestoreServiceError {
final ActiveRequest request;
final DateTime errorTime;
final Set<ActiveRequest> activeRequests;
final firestoreError;
FirestoreServiceError(this.request, this.firestoreError, this.activeRequests)
: errorTime = DateTime.now();
String toString() => '''
Error in ${request.type} of ${request.info}:
$firestoreError
Request failed at $errorTime, after running for ${errorTime.difference(request.start)}
Concurrent Firestore requests pending:
${activeRequests.join('\n')}''';
}
class FirestoreServiceImpl implements FirestoreService {
int documentsFetched = 0;
int documentsWritten = 0;
Set<ActiveRequest> activeRequests = {};
Future<T> traceRequest<T>(
String type, String info, Future<T> firestoreCall()) async {
final request = ActiveRequest(type, info);
activeRequests.add(request);
T result;
try {
result = await firestoreCall();
if (result is QuerySnapshot && result.isNotEmpty) {
documentsFetched += result.documents.length;
} else if (result is DocumentSnapshot && result.exists) {
documentsFetched++;
}
} catch (e) {
throw FirestoreServiceError(request, e, activeRequests);
}
activeRequests.remove(request);
return result;
}
Future<DocumentSnapshot> getDocument(DocumentReference reference) =>
traceRequest('get document', reference.path, reference.get);
Future<QuerySnapshot> runQuery(DocumentQuery query, String debugInfo) =>
traceRequest('run query', debugInfo, query.get);
Future<void> setDocument(
DocumentReference reference, Map<String, dynamic> data) =>
traceRequest('set document', reference.path, () {
documentsWritten++;
return reference.setData(
DocumentData.fromMap(data), SetOptions(merge: true));
});
Future<void> updateDocument(
DocumentReference reference, Map<String, dynamic> data) =>
traceRequest('update document', reference.path, () {
documentsWritten++;
return reference.updateData(UpdateData.fromMap(data));
});
// Because we can't read the number of documents written from a
// WriteBatch object, increment documentsWritten where we add writes
// to the write batch at use sites.
Future<void> commitBatch(WriteBatch batch, String info) =>
traceRequest('commit batch', info, batch.commit);
// The update function may be run multiple times, if the transaction retries.
// Increment documentsWritten and documentsRead in the update function body.
// The counts will include reads and attempted writes during retries.
Future<T> runTransaction<T>(
String info, Future<T> update(Transaction t)) =>
traceRequest<T>(
'run transaction', info, () => firestore.runTransaction(update));
Future<void> add(CollectionReference reference, Map<String, dynamic> data) =>
traceRequest('add document', reference.path, () {
documentsWritten++;
return reference.add(DocumentData.fromMap(data));
});
Future<bool> isStaging() =>
runQuery(firestore.collection('staging'), 'staging')
.then((s) => s.isNotEmpty);
Future<bool> hasPatchset(String review, String patchset) =>
getDocument(firestore.document('reviews/$review/patchsets/$patchset'))
.then((s) => s.exists);
Map<String, dynamic> _commit(DocumentSnapshot document) {
if (document.exists) {
return document.data.toMap()..['hash'] = document.documentID;
}
return null;
}
Future<Map<String, dynamic>> getCommit(String hash) =>
getDocument(firestore.document('commits/$hash'))
.then((document) => _commit(document));
Future<Map<String, dynamic>> getCommitByIndex(int index) => runQuery(
firestore.collection('commits').where('index', isEqualTo: index),
'commits where index == $index')
.then((s) => _commit(s.documents.first));
Future<Map<String, dynamic>> getLastCommit() async {
QuerySnapshot lastCommit = await runQuery(
firestore
.collection('commits')
.orderBy('index', descending: true)
.limit(1),
'commits by descending index, limit 1');
return _commit(lastCommit.documents.first);
}
Future<void> addCommit(String id, Map<String, dynamic> data) async {
data['created'] = Timestamp.fromDateTime(data['created']);
await setDocument(firestore.document('commits/$id'), data);
}
Future<List<String>> getConfigurations(String builder) async {
QuerySnapshot snapshot = await runQuery(
firestore
.collection('configurations')
.where('builder', isEqualTo: builder),
'configurations where builder == $builder');
return [for (final document in snapshot.documents) document.documentID];
}
Future<void> updateConfiguration(String configuration, String builder) async {
final record =
await getDocument(firestore.document('configurations/$configuration'));
if (!record.exists || record.data.getString('builder') != builder) {
await setDocument(firestore.document('configurations/$configuration'),
{'builder': builder});
if (!record.exists) {
console
.log('Configuration document $configuration -> $builder created');
} else {
console
.log('Configuration document changed: $configuration -> $builder '
'(was ${record.data.getString("builder")}');
}
}
}
Future<void> updateBuildInfo(
String builder, int buildNumber, int index) async {
final documentRef = firestore.document('builds/$builder:$index');
final record = await getDocument(documentRef);
if (!record.exists) {
await setDocument(documentRef,
{'builder': builder, 'build_number': buildNumber, 'index': index});
console.log('Created build record: '
'builder: $builder, build_number: $buildNumber, index: $index');
} else if (record.data.getInt('index') != index) {
throw ('Build $buildNumber of $builder had commit index ${record.data.getInt('index')},'
'should be $index.');
}
}
Future<String> findResult(
Map<String, dynamic> change, int startIndex, int endIndex) async {
String name = change['name'];
String result = change['result'];
String previousResult = change['previous_result'] ?? 'new test';
QuerySnapshot snapshot = await runQuery(
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.
,
'results by descending blamelist_end_index where name == $name,'
'result == $result, previous_result == $previousResult, '
'expected == ${change['expected']}, limit 5');
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;
}
return snapshot.documents
.firstWhere(blamelistIncludesChange, orElse: () => null)
?.documentID;
}
Future<void> storeResult(Map<String, dynamic> result) =>
firestore.collection('results').add(DocumentData.fromMap(result));
Future<bool> updateResult(
String result, String configuration, int startIndex, int endIndex,
{bool failure}) {
DocumentReference reference = firestore.document('results/$result');
// Update the result in a transaction.
Future<bool> updateResultTransaction(Transaction transaction) =>
transaction.get(reference).then((resultSnapshot) {
documentsFetched++;
final data = resultSnapshot.data;
// Allow missing 'approved' field during transition period.
bool approved = data.getBool('approved') ?? false;
// Add the new configuration and narrow the blamelist.
final newStart =
max(startIndex, data.getInt('blamelist_start_index'));
final newEnd = min(endIndex, data.getInt('blamelist_end_index'));
final update = UpdateData.fromMap({
'blamelist_start_index': newStart,
'blamelist_end_index': newEnd,
});
update.setFieldValue('configurations',
Firestore.fieldValues.arrayUnion([configuration]));
if (failure) {
update.setBool('active', true);
update.setFieldValue('active_configurations',
Firestore.fieldValues.arrayUnion([configuration]));
}
transaction.update(reference, update);
documentsWritten++;
return approved;
});
return runTransaction(
'update result ${reference.path}', updateResultTransaction);
}
Future<List<Map<String, dynamic>>> findRevertedChanges(int index) async {
QuerySnapshot pinned = await runQuery(
firestore.collection('results').where('pinned_index', isEqualTo: index),
"results where pinned_index == $index");
QuerySnapshot unpinned = await runQuery(
firestore
.collection('results')
.where('blamelist_end_index', isEqualTo: index),
'results where blamelist_end_index == $index');
return [
for (final document in pinned.documents) document.data.toMap(),
for (final document in unpinned.documents)
if (document.data.getInt('blamelist_start_index') == index &&
document.data.getInt('pinned_index') == null)
document.data.toMap(),
];
}
Future<bool> storeTryChange(
Map<String, dynamic> change, int review, int patchset) async {
String name = change['name'];
String result = change['result'];
String expected = change['expected'];
String previousResult = change['previous_result'] ?? 'new test';
// Find an existing TryResult for this test on this patchset.
QuerySnapshot snapshot = await runQuery(
firestore
.collection('try_results')
.where('review', isEqualTo: review)
.where('patchset', isEqualTo: patchset)
.where('name', isEqualTo: name)
.where('result', isEqualTo: result)
.where('previous_result', isEqualTo: previousResult)
.where('expected', isEqualTo: expected)
.limit(1),
'try_results where review == $review, patchset == $patchset, '
'name == $name, result == $result, '
'previous_result == ${previousResult}, expected == $expected, '
'limit 1');
if (snapshot.isEmpty) {
// Is the previous result for this test on this review approved?
QuerySnapshot previous = await runQuery(
firestore
.collection('try_results')
.where('review', isEqualTo: review)
.where('name', isEqualTo: name)
.where('result', isEqualTo: result)
.where('previous_result', isEqualTo: previousResult)
.where('expected', isEqualTo: expected)
.orderBy('patchset', descending: true)
.limit(1),
'try_results where review == $review, '
'name == $name, result == $result, '
'previous_result == ${previousResult}, expected == $expected, '
'order by descending patchset, limit 1');
// Create a TryResult for this test on this patchset.
// Allow a missing 'approved' field during a transition period
final approved = previous.isNotEmpty &&
previous.documents.first.data.getBool('approved') == true;
await firestore.collection('try_results').add(DocumentData.fromMap({
'name': name,
'result': result,
'previous_result': previousResult,
'expected': expected,
'review': review,
'patchset': patchset,
'configurations': <String>[change['configuration']],
'approved': approved
}));
return approved;
} else {
// Update the TryResult for this test, adding this configuration.
await updateDocument(snapshot.documents.first.reference, {
'configurations':
Firestore.fieldValues.arrayUnion([change['configuration']])
});
// Return true if this result is approved
return snapshot.documents.first.data.getBool('approved') == true;
}
}
Future<void> updateActiveResult(
Map<String, dynamic> activeResult, String configuration) async {
final document = firestore.document('results/${activeResult['id']}');
if (activeResult['active_configurations'].length > 1) {
await updateDocument(document, {
'active_configurations':
Firestore.fieldValues.arrayRemove([configuration])
});
activeResult = (await getDocument(document)).data.toMap();
if (!activeResult.containsKey('active_configurations') ||
activeResult['active_configurations'].isNotEmpty) return;
}
return updateDocument(document, {
'active_configurations': Firestore.fieldValues.delete(),
'active': Firestore.fieldValues.delete()
});
}
Future<List<Map<String, dynamic>>> findActiveResults(
Map<String, dynamic> change) async {
QuerySnapshot snapshot = await runQuery(
firestore
.collection('results')
.where('active_configurations',
arrayContains: change['configuration'])
.where('active', isEqualTo: true)
.where('name', isEqualTo: change['name']),
'results where active_configurations contains '
'${change['configuration']}, active == true, '
'name == ${change['name']}');
final results = [
for (final document in snapshot.documents)
document.data.toMap()..['id'] = document.documentID
];
if (results.length > 1) {
console.error([
'Multiple active results for the same configuration and test',
...results
].join('\n'));
}
return results;
}
Future<void> storeReview(String review, Map<String, dynamic> data) =>
setDocument(firestore.document('reviews/$review'), data);
Future<void> storePatchset(
String review, int patchset, Map<String, dynamic> data) =>
setDocument(
firestore.document('reviews/$review/patchsets/$patchset'), data);
/// Returns true if a review record in the database has a landed_index field,
/// or if there is no record for the review in the database. Reviews with no
/// test failures have no record, and don't need to be linked when landing.
Future<bool> reviewIsLanded(int review) =>
getDocument(firestore.document('reviews/$review')).then((document) =>
!document.exists || document.data.getInt('landed_index') != null);
Future<void> linkReviewToCommit(int review, int index) => updateDocument(
firestore.document('reviews/$review'), {'landed_index': index});
Future<void> linkCommentsToCommit(int review, int index) async {
QuerySnapshot comments = await firestore
.collection('comments')
.where('review', isEqualTo: review)
.get();
if (comments.isEmpty) return;
final batch = firestore.batch();
for (final comment in comments.documents) {
documentsWritten++;
batch.updateData(
comment.reference,
UpdateData.fromMap(
{'blamelist_start_index': index, 'blamelist_end_index': index}));
}
await commitBatch(batch, 'linkCommentsToCommit');
}
Future<List<Map<String, dynamic>>> tryApprovals(int review) async {
final patchsets = await runQuery(
firestore
.collection('reviews/$review/patchsets')
.orderBy('number', descending: true)
.limit(1),
'reviews/$review/patchsets by descending number limit 1');
if (patchsets.isEmpty) return [];
final lastPatchsetGroup =
patchsets.documents.first.data.getInt('patchset_group');
QuerySnapshot approvals = await runQuery(
firestore
.collection('try_results')
.where('approved', isEqualTo: true)
.where('review', isEqualTo: review)
.where('patchset', isGreaterThanOrEqualTo: lastPatchsetGroup),
'try_results where approved == true, review == $review, '
'patchset >= $lastPatchsetGroup');
return [for (final document in approvals.documents) document.data.toMap()];
}
Future<List<Map<String, dynamic>>> tryResults(
int review, String configuration) async {
final patchsets = await runQuery(
firestore
.collection('reviews/$review/patchsets')
.orderBy('number', descending: true)
.limit(1),
'reviews/$review/patchsets by descending number limit 1');
if (patchsets.isEmpty) return [];
final lastPatchsetGroup =
patchsets.documents.first.data.getInt('patchset_group');
QuerySnapshot approvals = await runQuery(
firestore
.collection('try_results')
.where('review', isEqualTo: review)
.where('configurations', arrayContains: configuration)
.where('patchset', isGreaterThanOrEqualTo: lastPatchsetGroup),
'try_results where review == $review, '
'configurations contains $configuration, '
'patchset >= $lastPatchsetGroup');
return [for (final document in approvals.documents) document.data.toMap()];
}
Future<void> storeChunkStatus(String builder, int index, bool success) async {
final document = firestore.document('builds/$builder:$index');
// Compute activeFailures outside transaction, because it runs queries.
// Because "completed" might be true inside transaction, but not now,
// we must compute activeFailures always, not just on last chunk.
var activeFailures = false;
final configurations = await getConfigurations(builder);
for (final configuration in configurations) {
// Find out if there are any unapproved unfixed failures,
// which we call "active" failures, to give sticky redness.
final snapshot = await runQuery(
firestore
.collection('results')
.where('active_configurations', arrayContains: configuration)
.where('approved', isEqualTo: false)
.limit(1),
'results where active_configurations contains $configuration, '
'approved == false, limit 1');
if (snapshot.isNotEmpty) {
activeFailures = true;
break;
}
}
Future<void> updateStatus(Transaction transaction) async {
final snapshot = await transaction.get(document);
documentsFetched++;
final data = snapshot.data.toMap();
final int chunks = data['num_chunks'];
final int processedChunks = data['processed_chunks'] ?? 0;
final bool completed = chunks == processedChunks + 1;
final update = UpdateData.fromMap({
'processed_chunks': processedChunks + 1,
'success': (data['success'] ?? true) && success,
if (completed) 'completed': true,
if (completed && activeFailures) 'active_failures': true
});
transaction.update(document, update);
documentsWritten++;
}
await retry(
() => runTransaction(
'update build status ${document.path}', updateStatus),
retryIf: (e) {
console.error("Retrying storeChunkStatus failed transaction: $e");
return e.toString().contains('Please try again.');
});
}
Future<void> storeBuildChunkCount(
String builder, int index, int numChunks) async {
return updateDocument(firestore.document('builds/$builder:$index'),
{'num_chunks': numChunks});
}
Future<void> storeTryChunkStatus(String builder, int buildNumber,
String buildbucketID, int review, int patchset, bool success) async {
await _ensureTryBuildRecord(
builder, buildNumber, buildbucketID, review, patchset);
final reference =
firestore.document('try_builds/$builder:$review:$patchset');
Future<void> updateStatus(Transaction transaction) async {
final snapshot = await transaction.get(reference);
documentsFetched++;
final data = snapshot.data.toMap();
final int chunks = data['num_chunks'];
final int processedChunks = data['processed_chunks'] ?? 0;
final bool completed = chunks == processedChunks + 1;
final update = UpdateData.fromMap({
'processed_chunks': processedChunks + 1,
'success': (data['success'] ?? true) && success,
if (completed) 'completed': true
});
transaction.update(reference, update);
documentsWritten++;
}
await retry(
() => runTransaction(
'update try build status ${reference.path}', updateStatus),
retryIf: (e) {
console.error("Retrying storeTryChunkStatus failed transaction: $e");
return e.toString().contains('Please try again.');
});
}
Future<void> storeTryBuildChunkCount(String builder, int buildNumber,
String buildbucketID, int review, int patchset, int numChunks) async {
await _ensureTryBuildRecord(
builder, buildNumber, buildbucketID, review, patchset);
await updateDocument(
firestore.document('try_builds/$builder:$review:$patchset'),
{'num_chunks': numChunks});
}
Future<void> _ensureTryBuildRecord(String builder, int buildNumber,
String buildbucketID, int review, int patchset) async {
final reference =
firestore.document('try_builds/$builder:$review:$patchset');
var snapshot = await getDocument(reference);
if (snapshot.exists && snapshot.data.getInt('build_number') > buildNumber) {
throw ArgumentError("Received chunk from previous build $buildNumber"
" after chunk from a later build");
}
if (snapshot.exists && snapshot.data.getInt('build_number') < buildNumber) {
Future<void> deleteEarlierBuild(Transaction transaction) async {
final snapshot = await transaction.get(reference);
documentsFetched++;
if (snapshot.exists &&
snapshot.data.getInt('build_number') < buildNumber) {
transaction.delete(reference);
documentsWritten++;
}
}
try {
await runTransaction(
'delete earlier build on patchset: ${reference.path}',
deleteEarlierBuild);
} finally {
snapshot = await getDocument(reference);
}
}
if (!snapshot.exists) {
await setDocument(reference, {
'builder': builder,
'build_number': buildNumber,
if (buildbucketID != null) 'buildbucket_id': buildbucketID,
'review': review,
'patchset': patchset,
});
}
}
}