blob: e3a87134d64daceb974b7554c28a7301cc15b213 [file]
// 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:io';
import 'dart:math' show max, min;
import 'package:builder/src/result.dart';
import 'package:collection/collection.dart' show IterableExtension;
import 'package:googleapis/firestore/v1.dart';
import 'package:http/http.dart' as http;
import 'firestore_helpers.dart';
export 'firestore_helpers.dart';
class Commit {
final SafeDocument document;
final String hash /*!*/;
Commit(this.hash, this.document);
Commit.fromJson(this.hash, Map<String, dynamic> data)
: document = SafeDocument(Document(fields: taggedMap(data), name: ''));
int get index => document.getInt('index')!;
String? get revertOf => document.getStringOrNull(fRevertOf);
bool get isRevert => document.fields.containsKey(fRevertOf);
int? get review => document.getInt(fReview);
Map<String, Object?> toJson() => untagMap(document.fields);
}
class FirestoreService {
final String project;
final FirestoreApi firestore;
final http.Client client;
int documentsFetched = 0;
int documentsWritten = 0;
final Stopwatch _stopwatch;
FirestoreService(
this.firestore,
this.client, {
this.project = 'dart-ci-staging',
}) : _stopwatch = Stopwatch()..start();
void log(String string) {
final time = _stopwatch.elapsed.toString();
final lines = LineSplitter().convert(string);
for (final line in lines) {
print('$time $line');
}
}
Future<List<SafeDocument>> query({
required String from,
Filter? where,
Order? orderBy,
int? limit,
String? parent,
}) async {
final query = StructuredQuery()
..from = inCollection(from)
..where = where
..orderBy = orderBy != null ? [orderBy] : null
..limit = limit;
final results = await runQuery(query, parent: parent);
return results.map((d) => SafeDocument(d)).toList();
}
Future<List<T>> _query<T>({
required String from,
Filter? where,
Order? orderBy,
int? limit,
String? parent,
}) async {
final query = StructuredQuery()
..from = inCollection(from)
..where = where
..orderBy = orderBy != null ? [orderBy] : null
..limit = limit;
final results = await runQuery(query, parent: parent);
return results.cast<T>();
}
Future<Document> getDocument(String path) async {
try {
final document = await firestore.projects.databases.documents.get(path);
documentsFetched++;
return document;
} on DetailedApiRequestError {
log("Failed to get document '$path'");
rethrow;
}
}
Future<Document?> getDocumentOrNull(String path) async {
try {
final document = await firestore.projects.databases.documents.get(path);
documentsFetched++;
return document;
} on DetailedApiRequestError catch (e) {
if (e.status == 404) {
return null;
} else {
log("Failed to get document '$path'");
rethrow;
}
}
}
String get databaseUri {
return 'https://firestore.googleapis.com/v1/$database';
}
String get database => 'projects/$project/databases/(default)';
String get documents => '$database/documents';
Future<List<Document>> runQuery(
StructuredQuery query, {
String? parent,
}) async {
final request = RunQueryRequest()..structuredQuery = query;
final parentPath = parent == null ? documents : '$documents/$parent';
final queryResponse = await firestore.projects.databases.documents.runQuery(
request,
parentPath,
);
if (queryResponse.first.document == null) return [];
documentsFetched += queryResponse.length;
return [
for (final responseElement in queryResponse) responseElement.document!,
];
}
Future<void> writeDocument(Document document) async {
documentsWritten++;
final request = CommitRequest()..writes = [Write()..update = document];
await firestore.projects.databases.documents.commit(request, database);
}
bool get isStaging => project == 'dart-ci-staging';
Future<bool> hasPatchset(String review, String patchset) {
return documentExists('$documents/reviews/$review/patchsets/$patchset');
}
Commit _commit(SafeDocument document) {
document.fields['hash'] = taggedValue(document.name.split('/').last);
return Commit(document.name.split('/').last, document);
}
Future<Commit?> getCommit(String hash) async {
final document = await getDocumentOrNull('$documents/commits/$hash');
return document != null ? Commit(hash, SafeDocument(document)) : null;
}
Future<Commit> getCommitByIndex(int index) async {
final response = await query(
from: 'commits',
where: fieldEquals('index', index),
);
return _commit(response.first);
}
Future<Commit> getLastCommit() async {
final lastCommit = await query(
from: 'commits',
orderBy: orderBy('index', false),
);
return _commit(lastCommit.first);
}
Future<void> addCommit(String id, Map<String, dynamic> data) async {
try {
final document = Document()..fields = taggedMap(data);
await firestore.projects.databases.documents.createDocument(
document,
documents,
'commits',
documentId: id,
);
documentsWritten++;
log("Added commit $id -> ${data['index']}");
} on DetailedApiRequestError catch (e) {
if (e.status != 409) {
rethrow;
}
// The document exists, we can ignore this error as the data is already
// correct.
}
}
Future<void> updateConfiguration(String configuration, String builder) async {
documentsWritten++;
final record = await getDocumentOrNull(
'$documents/configurations/$configuration',
);
if (record == null) {
final newRecord = Document()..fields = taggedMap({'builder': builder});
await firestore.projects.databases.documents.createDocument(
newRecord,
documents,
'configurations',
documentId: configuration,
);
log('Configuration document $configuration -> $builder created');
} else {
final originalBuilder = SafeDocument(record).getString('builder');
if (originalBuilder != builder) {
record.fields!['builder']!.stringValue = builder;
await updateFields(record, ['builder']);
log(
'Configuration document changed: $configuration -> $builder '
'(was $originalBuilder)',
);
}
}
}
/// Ensures that a build record for this build exists.
///
/// Returns `true` if and only if there is no completed record
/// for this build.
Future<bool> updateBuildInfo(
String builder,
int buildNumber,
int index,
) async {
final record = await getDocumentOrNull('$documents/builds/$builder:$index');
if (record == null) {
final newRecord = Document()
..fields = taggedMap({
'builder': builder,
'build_number': buildNumber,
'index': index,
});
await firestore.projects.databases.documents.createDocument(
newRecord,
documents,
'builds',
documentId: '$builder:$index',
);
documentsWritten++;
return true;
} else {
final data = SafeDocument(record);
final existingIndex = data.getInt('index');
if (existingIndex != index) {
throw ('Build $buildNumber of $builder had commit index '
'$existingIndex, should be $index.');
}
return data.getBool('completed') != true;
}
}
/// Ensures that a build record for this build exists.
///
/// Returns `true` if and only if there is no completed record
/// for this build.
Future<void> recordTryBuild(
TryBuildInfo info,
String buildbucketID,
bool success,
bool truncated,
) async {
final newRecord = Document()
..fields = taggedMap({
'builder': info.builderName,
'build_number': info.buildNumber,
'buildbucket_id': buildbucketID,
'review': info.review,
'patchset': info.patchset,
'success': success,
'completed': true,
if (truncated) 'truncated': true,
});
log(
'creating try-build record for '
'${info.builderName} ${info.buildNumber} ${info.review} ${info.patchset}',
);
await firestore.projects.databases.documents.createDocument(
newRecord,
documents,
'try_builds',
);
documentsWritten++;
}
Future<String?> findResult(
ResultRecord change,
int startIndex,
int endIndex,
) async {
final name = change.name;
final result = change.result;
final previousResult = change.previousResult;
final expected = change.expected;
final snapshot = await _query<ResultRecord>(
from: 'results',
orderBy: orderBy('blamelist_end_index', false),
where: compositeFilter([
fieldEquals('name', name),
fieldEquals('result', result),
fieldEquals('previous_result', previousResult),
fieldEquals('expected', expected),
]),
limit: 5,
);
bool blamelistIncludesChange(ResultRecord document) {
final before = endIndex < document.blamelistStartIndex;
final after = startIndex > document.blamelistEndIndex;
return !before && !after;
}
return snapshot.firstWhereOrNull(blamelistIncludesChange)?.doc.name;
}
Future<Document> storeResult(ResultRecord result) async {
for (var retries = 0; true; retries++) {
try {
final document = result.doc;
final createdDocument = await firestore.projects.databases.documents
.createDocument(document, documents, 'results');
documentsWritten++;
return createdDocument;
} catch (e) {
log('Failed creating document at path $documents/results.');
log('Retrying in 1000 ms.');
log('Document contents: ${jsonEncode(result.toJson())}\n');
log('$e');
if (retries > 2) {
rethrow;
}
await Future.delayed(Duration(milliseconds: 1000));
}
}
}
Future<bool> updateResult(
String result,
String configuration,
int startIndex,
int endIndex, {
required bool failure,
}) async {
late bool approved;
await retryCommit(() async {
final document = await getDocument(result);
final data = ResultRecord(document);
approved = data.approved;
// Add the new configuration and narrow the blamelist.
final newStart = max(startIndex, data.blamelistStartIndex);
final newEnd = min(endIndex, data.blamelistEndIndex);
// TODO(karlklose): check for pinned, and remove the pin if the new range
// doesn't include it?
final updates = [
'blamelist_start_index',
'blamelist_end_index',
if (failure) 'active',
];
document.fields!['blamelist_start_index'] = taggedValue(newStart);
document.fields!['blamelist_end_index'] = taggedValue(newEnd);
if (failure) {
document.fields!['active'] = taggedValue(true);
}
final addConfiguration = ArrayValue()
..values = [taggedValue(configuration)];
final write = Write()
..currentDocument = (Precondition()..updateTime = document.updateTime)
..update = document
..updateMask = (DocumentMask()..fieldPaths = updates)
..updateTransforms = [
FieldTransform()
..fieldPath = 'configurations'
..appendMissingElements = addConfiguration,
if (failure)
FieldTransform()
..fieldPath = 'active_configurations'
..appendMissingElements = addConfiguration,
];
documentsWritten++;
return write;
});
return approved;
}
Future<void> retryCommit(Future<Write> Function() request) async {
for (var retries = 0; true; retries++) {
final write = await request();
try {
// Use commit instead of write to check for the precondition on the
// document
final request = CommitRequest()..writes = [write];
documentsWritten++;
await firestore.projects.databases.documents.commit(request, database);
return;
} catch (e) {
if (retries > 10) {
rethrow;
}
log('Error while writing data: $e, retrying.');
sleep(Duration(milliseconds: 100));
}
}
}
/// Returns all results which are either pinned to or have a range that is
/// this single index. // TODO: rename this function
Future<List<ResultRecord>> findRevertedChanges(int index) async {
final results = await _query<ResultRecord>(
from: 'results',
where: fieldEquals('pinned_index', index),
);
final unpinnedResults = await _query<ResultRecord>(
from: 'results',
where: fieldEquals('blamelist_end_index', index),
);
for (final data in unpinnedResults) {
if (data.blamelistStartIndex == index && data.pinnedIndex == null) {
results.add(data);
}
}
return results;
}
Future<bool> storeTryChange(
ChangeRecord change,
int review,
int patchset,
) async {
final name = change.name;
final result = change.result;
final expected = change.expected;
final previousResult = change.previousResult;
final configuration = change.configuration;
// Find an existing result record for this test on this patchset.
final responses = await query(
from: 'try_results',
where: compositeFilter([
fieldEquals('review', review),
fieldEquals('patchset', patchset),
fieldEquals('name', name),
fieldEquals('result', result),
fieldEquals('previous_result', previousResult),
fieldEquals('expected', expected),
]),
limit: 1,
);
// TODO(karlklose): We could run only this query, and then see if the
// patchset is equal or not. We don't need the separate
// query with equal patchset.
// The test for previous.isNotEmpty below can be replaced
// with responses.patchset != patchset. We need to hit
// the createDocument both if there was a previous response,
// or there is no response at all.
if (responses.isEmpty) {
// Is the previous result for this test on this review approved?
final previous = await query(
from: 'try_results',
where: compositeFilter([
fieldEquals('review', review),
fieldEquals('name', name),
fieldEquals('result', result),
fieldEquals('previous_result', previousResult),
fieldEquals('expected', expected),
]),
orderBy: orderBy('patchset', false),
limit: 1,
);
final approved =
previous.isNotEmpty && previous.first.getBool('approved') == true;
final document = Document()
..fields = taggedMap({
'name': name,
'result': result,
'previous_result': previousResult,
'expected': expected,
'review': review,
'patchset': patchset,
'configurations': <String>[configuration],
'approved': approved,
});
await firestore.projects.databases.documents.createDocument(
document,
documents,
'try_results',
mask_fieldPaths: [
'name',
'result',
'previous_result',
'expected',
'review',
'patchset',
'configurations',
'approved',
],
);
documentsWritten++;
return approved;
} else {
final document = responses.first;
// Update the TryResult for this test, adding this configuration.
final values = ArrayValue()..values = [taggedValue(configuration)];
final addConfiguration = FieldTransform()
..fieldPath = 'configurations'
..appendMissingElements = values;
await _executeWrite([
Write()
..update = document.toDocument()
..updateTransforms = [addConfiguration],
]);
return document.getBool('approved') == true;
}
}
Future<void> approveResult(Document document) async {
document.fields!['approved'] = taggedValue(true);
await _executeWrite([
Write()
..update = document
..updateMask = (DocumentMask()..fieldPaths = ['approved']),
]);
}
/// Removes [configuration] from the active configurations and marks the
/// active result inactive when we remove the last active config.
Future<void> removeActiveConfiguration(
ResultRecord activeResult,
String configuration,
) async {
final configurations = activeResult.activeConfigurations!;
assert(configurations.contains(configuration));
await removeArrayEntry(
activeResult.doc,
'active_configurations',
taggedValue(configuration),
);
final document = await getDocument(activeResult.doc.name!);
activeResult = ResultRecord(document);
if (activeResult.activeConfigurations?.isEmpty == true) {
activeResult.doc.fields!.remove('active_configurations');
activeResult.doc.fields!.remove('active');
final write = Write()
..update = activeResult.doc
..updateMask = (DocumentMask()
..fieldPaths = ['active', 'active_configurations']);
await _executeWrite([write]);
}
}
Future<void> removeArrayEntry(
Document document,
String fieldName,
Value entry,
) async {
await _executeWrite([
Write()
..transform = (DocumentTransform()
..document = document.name
..fieldTransforms = [
FieldTransform()
..fieldPath = fieldName
..removeAllFromArray = (ArrayValue()..values = [entry]),
]),
]);
}
Future<List<ResultRecord>> findActiveResults(
String name,
String configuration,
) async {
final results = await _query<ResultRecord>(
from: 'results',
where: compositeFilter([
arrayContains('active_configurations', configuration),
fieldEquals('active', true),
fieldEquals('name', name),
]),
);
if (results.length > 1) {
log(
[
'Multiple active results for the same configuration and test',
...results,
].join('\n'),
);
}
return results;
}
Future<List<ResultRecord>> findUnapprovedFailures(
String configuration,
int limit,
) async {
final results = await _query<ResultRecord>(
from: 'results',
where: compositeFilter([
arrayContains('active_configurations', configuration),
fieldEquals('approved', false),
]),
limit: limit,
);
return results;
}
Future<bool> hasReview(String review) async {
return documentExists('$documents/reviews/$review');
}
Future<void> storeReview(String review, Map<String, Value> data) async {
final document = Document()..fields = data;
documentsWritten++;
await firestore.projects.databases.documents.createDocument(
document,
documents,
'reviews',
documentId: review,
);
}
Future<void> deleteDocument(String name) async {
return _executeWrite([Write()..delete = name]);
}
Future<bool> documentExists(String name) async {
return (await getDocumentOrNull(name) != null);
}
Future _executeWrite(List<Write> writes) async {
final request = BatchWriteRequest()..writes = writes;
documentsWritten += writes.length;
return firestore.projects.databases.documents.batchWrite(request, database);
}
Future<void> updateFields(Document document, List<String> fields) async {
await _executeWrite([
Write()
..update = document
..updateMask = (DocumentMask()..fieldPaths = fields),
]);
}
Future<void> storePatchset(
String review,
int patchset,
String kind,
String? description,
int patchsetGroup,
int number,
) async {
final document = Document()
..name = '$documents/reviews/$review/patchsets/$patchset'
..fields = taggedMap({
'kind': kind,
'description': description,
'patchset_group': patchsetGroup,
'number': number,
});
await _executeWrite([Write()..update = document]);
log(
'Stored patchset: $documents/reviews/$review/patchsets/$patchset\n'
'${untagMap(document.fields!)}',
);
documentsWritten++;
}
/// 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) async {
final document = await getDocumentOrNull('$documents/reviews/$review');
if (document == null) {
return true;
}
return document.fields!.containsKey('landed_index');
}
Future<void> linkReviewToCommit(int review, int index) async {
final document = await getDocument('$documents/reviews/$review');
document.fields!['landed_index'] = taggedValue(index);
await updateFields(document, ['landed_index']);
}
Future<void> linkCommentsToCommit(int review, int index) async {
final comments = await query(
from: 'comments',
where: fieldEquals('review', review),
);
if (comments.isEmpty) return;
final writes = <Write>[];
for (final document in comments) {
document.fields['blamelist_start_index'] = taggedValue(index);
document.fields['blamelist_end_index'] = taggedValue(index);
writes.add(
Write()
..update = document.toDocument()
..updateMask = (DocumentMask()
..fieldPaths = ['blamelist_start_index', 'blamelist_end_index']),
);
}
await _executeWrite(writes);
}
Future<List<SafeDocument>> tryApprovals(int review) async {
final patchsets = await query(
from: 'patchsets',
parent: 'reviews/$review',
orderBy: orderBy('number', false),
limit: 1,
);
if (patchsets.isEmpty) {
return [];
}
final lastPatchsetGroup = patchsets.first.getInt('patchset_group');
return query(
from: 'try_results',
where: compositeFilter([
fieldEquals('approved', true),
fieldEquals('review', review),
fieldGreaterThanOrEqual('patchset', lastPatchsetGroup),
]),
);
}
Future<List<SafeDocument>> tryResults(
int review,
String configuration,
) async {
final patchsets = await query(
from: 'patchsets',
parent: 'reviews/$review',
orderBy: orderBy('number', false),
limit: 1,
);
if (patchsets.isEmpty) {
return [];
}
final lastPatchsetGroup = patchsets.first.getInt('patchset_group');
return query(
from: 'try_results',
where: compositeFilter([
fieldEquals('review', review),
arrayContains('configurations', configuration),
fieldGreaterThanOrEqual('patchset', lastPatchsetGroup),
]),
);
}
// TODO(b/210111991): Remove Builder and TryBuilder records completely.
// They were only used to communicate success back to the builder
// when the processing was not on the builder.
Future<void> completeBuilderRecord(
String builder,
int index,
bool success,
) async {
final path = '$documents/builds/$builder:$index';
final document = await getDocument(path);
await _completeBuilderRecord(document, success);
}
Future<void> _completeBuilderRecord(Document document, bool success) async {
await retryCommit(() async {
document.fields!['success'] = taggedValue(success);
final write = Write()
..update = document
..updateMask = (DocumentMask()..fieldPaths = ['success']);
return write;
});
}
}