blob: 3bcce62ea66b2e4ca2e159b631f93c7f4aeccbae [file] [log] [blame]
// Copyright 2019 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.
import 'package:gcloud/db.dart';
import 'package:github/github.dart';
import 'package:json_annotation/json_annotation.dart';
import '../../request_handling/exceptions.dart';
import '../../service/datastore.dart';
import '../../service/logging.dart';
import '../ci_yaml/target.dart';
import '../luci/push_message.dart';
import 'commit.dart';
import 'key_converter.dart';
import 'package:cocoon_service/src/model/luci/buildbucket.dart' as bb;
part 'task.g.dart';
/// Class that represents the intersection of a test at a particular [Commit].
///
/// Tasks are tests that have been run N (possibly zero) times against a
/// particular commit.
@JsonSerializable(createFactory: false, ignoreUnannotated: true)
@Kind(name: 'Task')
class Task extends Model<int> {
/// Creates a new [Task].
Task({
Key<int>? key,
this.commitKey,
this.createTimestamp = 0,
this.startTimestamp = 0,
this.endTimestamp = 0,
this.name,
this.attempts = 0,
this.isFlaky = false,
this.isTestFlaky = false,
this.timeoutInMinutes,
this.reason = '',
this.requiredCapabilities,
this.reservedForAgentId = '',
this.stageName,
this.buildNumber,
this.buildNumberList,
this.builderName,
this.luciBucket,
String? status,
}) : _status = status {
if (status != null && !legalStatusValues.contains(status)) {
throw ArgumentError('Invalid state: "$status"');
}
parentKey = key?.parent;
id = key?.id;
}
/// Construct [Task] from a [Target].
factory Task.fromTarget({
required Commit commit,
required Target target,
}) {
return Task(
attempts: 1,
builderName: target.value.name,
commitKey: commit.key,
createTimestamp: commit.timestamp!,
isFlaky: target.value.bringup,
key: commit.key.append(Task),
name: target.value.name,
requiredCapabilities: <String>[target.value.testbed],
stageName: target.value.scheduler.toString(),
status: Task.statusNew,
timeoutInMinutes: target.value.timeout,
);
}
/// Lookup [Task] from Datastore from its parent key and name.
static Future<Task> fromCommitKey({
required DatastoreService datastore,
required Key<String> commitKey,
required String name,
}) async {
if (name.isEmpty) {
throw const BadRequestException('task name is null');
}
final Query<Task> query = datastore.db.query<Task>(ancestorKey: commitKey)..filter('name =', name);
final List<Task> tasks = await query.run().toList();
if (tasks.length != 1) {
log.severe('Found ${tasks.length} entries for builder $name');
throw InternalServerError('Expected to find 1 task for $name, but found ${tasks.length}');
}
return tasks.single;
}
/// Lookup [Task] from its [key].
///
/// This is the fastest way to lookup [Task], but requires [id] to be passed
/// as it is generated from Datastore.
static Future<Task> fromKey({
required DatastoreService datastore,
required Key<String> commitKey,
required int id,
}) {
log.fine('Looking up key...');
final Key<int> key = Key<int>(commitKey, Task, id);
return datastore.lookupByValue<Task>(key);
}
/// Lookup [Task] from Datastore.
///
/// Either name or id must be given to lookup [Task].
///
/// Prefer passing [id] when possible as it is a faster lookup.
static Future<Task> fromDatastore({
required DatastoreService datastore,
required Key<String> commitKey,
String? name,
String? id,
}) {
if (id == null) {
return Task.fromCommitKey(
datastore: datastore,
commitKey: commitKey,
name: name!,
);
}
return Task.fromKey(
datastore: datastore,
commitKey: commitKey,
id: int.parse(id),
);
}
/// Creates a [Task] based on a buildbucket [bb.Build].
static Future<Task> fromBuildbucketBuild(
bb.Build build,
DatastoreService datastore, {
String? customName,
}) async {
log.fine('Creating task from buildbucket result: ${build.toString()}');
// Example: Getting "flutter" from "mirrors/flutter".
final String repository = build.input!.gitilesCommit!.project!.split('/')[1];
log.fine('Repository: $repository');
// Example: Getting "stable" from "refs/heads/stable".
final String branch = build.input!.gitilesCommit!.ref!.split('/')[2];
log.fine('Branch: $branch');
final String hash = build.input!.gitilesCommit!.hash!;
log.fine('Hash: $hash');
final RepositorySlug slug = RepositorySlug('flutter', repository);
log.fine('Slug: ${slug.toString()}');
final int startTime = build.startTime?.millisecondsSinceEpoch ?? 0;
final int endTime = build.endTime?.millisecondsSinceEpoch ?? 0;
log.fine('Start/end time (ms): $startTime, $endTime');
final String id = '${slug.fullName}/$branch/$hash';
final Key<String> commitKey = datastore.db.emptyKey.append<String>(Commit, id: id);
final Commit commit = await datastore.db.lookupValue<Commit>(commitKey);
final task = Task(
attempts: 1,
buildNumber: build.number,
buildNumberList: build.number.toString(),
builderName: build.builderId.builder,
commitKey: commitKey,
createTimestamp: startTime,
endTimestamp: endTime,
luciBucket: build.builderId.bucket,
name: customName ?? build.builderId.builder,
stageName: build.builderId.project,
startTimestamp: startTime,
status: convertBuildbucketStatusToString(build.status!),
key: commit.key.append(Task),
timeoutInMinutes: 0,
reason: '',
requiredCapabilities: [],
reservedForAgentId: '',
);
return task;
}
/// Converts a buildbucket status to a task status.
static String convertBuildbucketStatusToString(bb.Status status) {
switch (status) {
case bb.Status.success:
return statusSucceeded;
case bb.Status.canceled:
return statusCancelled;
case bb.Status.infraFailure:
return statusInfraFailure;
case bb.Status.started:
return statusInProgress;
case bb.Status.scheduled:
return statusNew;
default:
return statusFailed;
}
}
/// The task was cancelled.
static const String statusCancelled = 'Cancelled';
/// The task is yet to be run.
static const String statusNew = 'New';
/// The task failed to run due to an unexpected issue.
static const String statusInfraFailure = 'Infra Failure';
/// The task is currently running.
static const String statusInProgress = 'In Progress';
/// The task was run successfully.
static const String statusSucceeded = 'Succeeded';
/// The task failed to run successfully.
static const String statusFailed = 'Failed';
/// The task was skipped or canceled while running.
///
/// This status is only used by LUCI tasks.
static const String statusSkipped = 'Skipped';
/// The list of legal values for the [status] property.
static const List<String> legalStatusValues = <String>[
statusCancelled,
statusFailed,
statusInfraFailure,
statusInProgress,
statusNew,
statusSkipped,
statusSucceeded,
];
static const List<String> finishedStatusValues = <String>[
statusCancelled,
statusFailed,
statusInfraFailure,
statusSkipped,
statusSucceeded,
];
/// The key of the commit that owns this task.
@ModelKeyProperty(propertyName: 'ChecklistKey')
@JsonKey(name: 'ChecklistKey')
@StringKeyConverter()
Key<String>? commitKey;
/// The timestamp (in milliseconds since the Epoch) that this task was
/// created.
///
/// This is _not_ when the task first started running, as tasks start out in
/// the 'New' state until they've been picked up by an [Agent].
@IntProperty(propertyName: 'CreateTimestamp', required: true)
@JsonKey(name: 'CreateTimestamp')
int? createTimestamp;
/// The timestamp (in milliseconds since the Epoch) that this task started
/// running.
///
/// Tasks may be run more than once. If this task has been run more than
/// once, this timestamp represents when the task was most recently started.
@IntProperty(propertyName: 'StartTimestamp', required: true)
@JsonKey(name: 'StartTimestamp')
int? startTimestamp;
/// The timestamp (in milliseconds since the Epoch) that this task last
/// finished running.
@IntProperty(propertyName: 'EndTimestamp', required: true)
@JsonKey(name: 'EndTimestamp')
int? endTimestamp;
/// The name of the task.
///
/// This is a human-readable name, typically a test name (e.g.
/// "hello_world__memory").
@StringProperty(propertyName: 'Name', required: true)
@JsonKey(name: 'Name')
String? name;
/// The number of attempts that have been made to run this task successfully.
///
/// New tasks that have not yet been picked up by an [Agent] will have zero
/// attempts.
@IntProperty(propertyName: 'Attempts', required: true)
@JsonKey(name: 'Attempts')
int? attempts;
/// Whether this task has been marked flaky by .ci.yaml.
///
/// See also:
///
/// * <https://github.com/flutter/flutter/blob/master/.ci.yaml>
///
/// A flaky (`bringup: true`) task will not block the tree.
@BoolProperty(propertyName: 'Flaky')
@JsonKey(name: 'Flaky')
bool? isFlaky;
/// Whether the test execution of this task shows flake.
///
/// Test runner supports rerun, and this flag tracks if a flake happens.
///
/// See also:
/// * <https://github.com/flutter/flutter/blob/master/dev/devicelab/lib/framework/runner.dart>
@BoolProperty(propertyName: 'TestFlaky')
@JsonKey(name: 'TestFlaky')
bool? isTestFlaky;
/// The timeout of the task, or zero if the task has no timeout.
@IntProperty(propertyName: 'TimeoutInMinutes', required: true)
@JsonKey(name: 'TimeoutInMinutes')
int? timeoutInMinutes;
/// Currently unset and unused.
@StringProperty(propertyName: 'Reason')
@JsonKey(name: 'Reason')
String? reason;
/// The build number of luci build: https://chromium.googlesource.com/infra/luci/luci-go/+/master/buildbucket/proto/build.proto#146
@IntProperty(propertyName: 'BuildNumber')
@JsonKey(name: 'BuildNumber')
int? buildNumber;
/// The build number list of luci builds: comma joined string of
/// different build numbers.
///
/// For the case with single run 123, [buildNumberList] = '123';
/// For the case with multiple reruns 123, 456, 789,
/// [buildNumberList] = '123,456,789'.
@StringProperty(propertyName: 'BuildNumberList')
@JsonKey(name: 'BuildNumberList')
String? buildNumberList;
/// The builder name of luci build.
@StringProperty(propertyName: 'BuilderName')
@JsonKey(name: 'BuilderName')
String? builderName;
/// The luci pool where the luci task runs.
@StringProperty(propertyName: 'LuciBucket')
@JsonKey(name: 'luciBucket')
String? luciBucket;
/// The list of capabilities that agents are required to have to run this
/// task.
///
/// See also:
///
/// * [Agent.capabilities], which list the capabilities of an agent.
@StringListProperty(propertyName: 'RequiredCapabilities')
@JsonKey(name: 'RequiredCapabilities')
List<String>? requiredCapabilities;
/// Set to the ID of the agent that's responsible for running this task.
///
/// This will be null until an agent has reserved this task.
@StringProperty(propertyName: 'ReservedForAgentID')
@JsonKey(name: 'ReservedForAgentID')
String? reservedForAgentId;
/// The name of the [Stage] that groups this task with other tasks that are
/// related to it.
@StringProperty(propertyName: 'StageName', required: true)
@JsonKey(name: 'StageName')
String? stageName;
/// The status of the task.
///
/// Legal values and their meanings are defined in [legalStatusValues].
@StringProperty(propertyName: 'Status', required: true)
@JsonKey(name: 'Status')
String get status => _status ?? statusNew;
String? _status;
set status(String value) {
if (!legalStatusValues.contains(value)) {
throw ArgumentError('Invalid state: "$value"');
}
_status = value;
}
/// Update [Task] fields based on a LUCI [Build].
void updateFromBuild(Build build) {
final List<String>? tags = build.tags;
// Example tag: build_address:luci.flutter.prod/Linux Cocoon/271
final String? buildAddress = tags?.firstWhere((String tag) => tag.contains('build_address'));
if (buildAddress == null) {
log.warning('Tags: $tags');
throw const BadRequestException('build_address does not contain build number');
}
final int currentBuildNumber = int.parse(buildAddress.split('/').last);
if (buildNumber == null || buildNumber! < currentBuildNumber) {
buildNumber = currentBuildNumber;
} else if (currentBuildNumber < buildNumber!) {
log.fine('Skipping message as build number is before the current task');
return;
}
if (buildNumberList == null) {
buildNumberList = '$buildNumber';
} else {
final Set<String> buildNumberSet = buildNumberList!.split(',').toSet();
buildNumberSet.add(buildNumber.toString());
buildNumberList = buildNumberSet.join(',');
}
createTimestamp = build.createdTimestamp?.millisecondsSinceEpoch ?? 0;
startTimestamp = build.startedTimestamp?.millisecondsSinceEpoch ?? 0;
endTimestamp = build.completedTimestamp?.millisecondsSinceEpoch ?? 0;
_setStatusFromLuciStatus(build);
}
/// Updates [Task] based on a Buildbucket [Build].
void updateFromBuildbucketBuild(bb.Build build) {
buildNumber = build.number!;
if (buildNumberList == null) {
buildNumberList = '$buildNumber';
} else {
final Set<String> buildNumberSet = buildNumberList!.split(',').toSet();
buildNumberSet.add(buildNumber.toString());
buildNumberList = buildNumberSet.join(',');
}
createTimestamp = build.startTime?.millisecondsSinceEpoch ?? 0;
startTimestamp = build.startTime?.millisecondsSinceEpoch ?? 0;
endTimestamp = build.endTime?.millisecondsSinceEpoch ?? 0;
attempts = buildNumberList!.split(',').length;
status = convertBuildbucketStatusToString(build.status!);
}
/// Get a [Task] status from a LUCI [Build] status/result.
String _setStatusFromLuciStatus(Build build) {
// Updates can come out of order. Ensure completed statuses are kept.
if (_isStatusCompleted()) {
return status;
}
if (build.status == Status.started) {
return status = statusInProgress;
}
switch (build.result) {
case Result.success:
return status = statusSucceeded;
case Result.canceled:
return status = statusCancelled;
case Result.failure:
// Note that `Result` does not support `infraFailure`:
// https://github.com/luci/luci-go/blob/main/common/api/buildbucket/buildbucket/v1/buildbucket-gen.go#L247-L251
// To determine an infra failure status, we need to combine `Result.failure` and `FailureReason.infraFailure`.
if (build.failureReason == FailureReason.infraFailure) {
return status = statusInfraFailure;
} else {
return status = statusFailed;
}
default:
throw BadRequestException('${build.result} is unknown');
}
}
bool _isStatusCompleted() {
const List<String> completedStatuses = <String>[
statusCancelled,
statusFailed,
statusInfraFailure,
statusSucceeded,
];
return completedStatuses.contains(status);
}
/// Comparator that sorts tasks by fewest attempts first.
static int byAttempts(Task a, Task b) => a.attempts!.compareTo(b.attempts!);
/// Serializes this object to a JSON primitive.
Map<String, dynamic> toJson() => _$TaskToJson(this);
@override
String toString() {
final StringBuffer buf = StringBuffer()
..write('$runtimeType(')
..write('id: $id')
..write(', parentKey: ${parentKey?.id}')
..write(', key: ${parentKey == null ? null : key.id}')
..write(', commitKey: ${commitKey?.id}')
..write(', createTimestamp: $createTimestamp')
..write(', startTimestamp: $startTimestamp')
..write(', endTimestamp: $endTimestamp')
..write(', name: $name')
..write(', attempts: $attempts')
..write(', isFlaky: $isFlaky')
..write(', isTestRunFlaky: $isTestFlaky')
..write(', timeoutInMinutes: $timeoutInMinutes')
..write(', reason: $reason')
..write(', requiredCapabilities: $requiredCapabilities')
..write(', reservedForAgentId: $reservedForAgentId')
..write(', stageName: $stageName')
..write(', status: $status')
..write(', buildNumber: $buildNumber')
..write(', buildNumberList: $buildNumberList')
..write(', builderName: $builderName')
..write(', luciBucket: $luciBucket')
..write(')');
return buf.toString();
}
}
Iterable<Task> targetsToTask(Commit commit, List<Target> targets) =>
targets.map((Target target) => Task.fromTarget(commit: commit, target: target));
/// The serialized representation of a [Task].
// TODO(tvolkert): Directly serialize [Task] once frontends migrate to new serialization format.
@JsonSerializable(createFactory: false)
class SerializableTask {
const SerializableTask(this.task);
@JsonKey(name: 'Task')
final Task task;
@JsonKey(name: 'Key')
@IntKeyConverter()
Key<int> get key => task.key;
/// Serializes this object to a JSON primitive.
Map<String, dynamic> toJson() => _$SerializableTaskToJson(this);
}
/// A [Task], paired with its associated parent [Commit].
///
/// The [Task] model object references its parent [Commit] through the
/// [Task.commitKey] field, but it does not hold a reference to the associated
/// [Commit] object (just the relational mapping). This class exists for those
/// times when the caller has loaded the associated commit from the datastore
/// and would like to pass both the task its commit around.
class FullTask {
/// Creates a new [FullTask].
const FullTask(this.task, this.commit);
/// The [Task] object.
final Task task;
/// The [Commit] object references by this [task]'s [Task.commitKey].
final Commit commit;
}