| // 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. |
| |
| // This script is used by the bisection mechanism to update the blamelists |
| // of active, non-approved failures which include the commit of the current |
| // bisection build. |
| |
| import 'dart:io'; |
| |
| import 'package:args/args.dart'; |
| import 'package:test_runner/bot_results.dart'; |
| |
| import 'lib/src/firestore.dart'; |
| |
| const newTest = 'new test'; |
| const skippedTest = 'skipped'; |
| |
| const maxAttempts = 20; |
| |
| late FirestoreDatabase database; |
| |
| class ResultRecord { |
| final Map data; |
| |
| ResultRecord(this.data); |
| |
| Map field(String name) => data['fields'][name] /*!*/; |
| |
| int get blamelistStartIndex { |
| return int.parse(field('blamelist_start_index')['integerValue']); |
| } |
| |
| set blamelistStartIndex(int index) { |
| field('blamelist_start_index')['integerValue'] = '$index'; |
| } |
| |
| int get blamelistEndIndex { |
| return int.parse(field('blamelist_end_index')['integerValue']); |
| } |
| |
| String get result => field('result')['stringValue'] /*!*/; |
| |
| String get previousResult => field('previous_result')['stringValue'] /*!*/; |
| |
| String get name => field('name')['stringValue'] /*!*/; |
| |
| String get updateTime => data['updateTime'] /*!*/; |
| } |
| |
| Query unapprovedActiveFailuresQuery(String configuration) { |
| return Query( |
| 'results', |
| CompositeFilter('AND', [ |
| Field('approved').equals(Value.boolean(false)), |
| // TODO(karlklose): also search for inactive failures? |
| Field('active_configurations').contains(Value.string(configuration)), |
| // TODO(karlklose): add index to check for blamelist_start_index < ? |
| ])); |
| } |
| |
| Future<int> getCommitIndex(String commit) async { |
| try { |
| Map document = await database.getDocument('commits', commit); |
| var index = document['fields']['index']; |
| if (index['integerValue'] == null) { |
| throw Exception('Expected an integer, but got "$index"'); |
| } |
| return int.parse(index['integerValue']); |
| } catch (exception) { |
| print('Could not retrieve index for commit "$commit".\n'); |
| rethrow; |
| } |
| } |
| |
| /// Compute if the record should be updated based on the outcomes in the |
| /// result record and the new test result. |
| bool shouldUpdateRecord(ResultRecord resultRecord, Result? testResult) { |
| if (testResult == null || !testResult.matches) { |
| return false; |
| } |
| var baseline = testResult.expectation.toLowerCase(); |
| if (resultRecord.previousResult.toLowerCase() != baseline) { |
| // Currently we only support the case where a bisection run improves the |
| // accuracy of a "Green" -> "Red" result record. |
| return false; |
| } |
| if (resultRecord.result.toLowerCase() == newTest || |
| resultRecord.result.toLowerCase() == skippedTest) { |
| // Skipped tests are often configuration dependent, so it could be wrong |
| // to generalize their effect for the result record to different |
| // configurations. |
| return false; |
| } |
| return true; |
| } |
| |
| void updateBlameLists(String configuration, String commit, |
| Map<String, Map<String, dynamic>> testResults) async { |
| int commitIndex = await getCommitIndex(commit); |
| var query = unapprovedActiveFailuresQuery(configuration); |
| bool needsRetry; |
| int attempts = 0; |
| do { |
| needsRetry = false; |
| var documents = (await database.runQuery(query)) |
| .where((result) => result['document'] != null) |
| .map((result) => result['document']['name']); |
| for (var documentPath in documents) { |
| database.beginTransaction(); |
| var documentName = documentPath.split('/').last; |
| var result = |
| ResultRecord(await database.getDocument('results', documentName)); |
| if (commitIndex < result.blamelistStartIndex || |
| commitIndex >= result.blamelistEndIndex) { |
| continue; |
| } |
| String name = result.name; |
| var testResultData = testResults['$configuration:$name']; |
| var testResult = |
| testResultData != null ? Result.fromMap(testResultData) : null; |
| if (!shouldUpdateRecord(result, testResult)) { |
| continue; |
| } |
| print('Found result record: $configuration:${result.name}: ' |
| '${result.previousResult} -> ${result.result} ' |
| 'in ${result.blamelistStartIndex}..${result.blamelistEndIndex} ' |
| 'to update with ${testResult?.outcome} at $commitIndex.'); |
| // We found a result representation for this test and configuration whose |
| // blamelist includes this results' commit but whose outcome is different |
| // then the outcome in the provided test results. |
| // This means that this commit should not be part of the result |
| // representation and we can update the lower bound of the commit range |
| // and the previous result. |
| var newStartIndex = commitIndex + 1; |
| if (newStartIndex > result.blamelistEndIndex) { |
| print('internal error: inconsistent results; skipping results entry'); |
| continue; |
| } |
| result.blamelistStartIndex = newStartIndex; |
| var updateIndex = Update(['blamelist_start_index'], result.data); |
| if (!await database.commit(writes: [updateIndex])) { |
| // Commiting the change to the database had a conflict, retry. |
| needsRetry = true; |
| if (++attempts == maxAttempts) { |
| throw Exception('Exceeded maximum retry attempts ($maxAttempts).'); |
| } |
| print('Transaction failed, trying again!'); |
| } |
| } |
| } while (needsRetry); |
| } |
| |
| main(List<String> arguments) async { |
| var parser = ArgParser() |
| ..addOption('auth-token', |
| abbr: 'a', |
| help: 'path to a file containing the gcloud auth token (required)') |
| ..addOption('results', |
| abbr: 'r', |
| help: 'path to a file containing the test results (required)') |
| ..addFlag('staging', abbr: 's', help: 'use staging database'); |
| var options = parser.parse(arguments); |
| if (options.rest.isNotEmpty || |
| options['results'] == null || |
| options['auth-token'] == null) { |
| print(parser.usage); |
| exit(1); |
| } |
| var results = await loadResultsMap(options['results']); |
| if (results.isEmpty) { |
| print("No test results provided, nothing to update."); |
| return; |
| } |
| // Pick an arbitrary result entry to find configuration and commit hash. |
| var firstResult = Result.fromMap(results.values.first); |
| var commit = firstResult.commitHash!; |
| var configuration = firstResult.configuration; |
| var project = options['staging'] ? 'dart-ci-staging' : 'dart-ci'; |
| database = FirestoreDatabase( |
| project, await readGcloudAuthToken(options['auth-token'])); |
| updateBlameLists(configuration, commit, results); |
| database.closeClient(); |
| } |