// Copyright Epic Games, Inc. All Rights Reserved. using System; using System.Collections.Generic; using System.Diagnostics.CodeAnalysis; using System.Linq; using System.Threading; using System.Threading.Tasks; using EpicGames.Core; using EpicGames.Horde.Acls; using EpicGames.Horde.Agents; using EpicGames.Horde.Agents.Leases; using EpicGames.Horde.Agents.Pools; using EpicGames.Horde.Agents.Sessions; using EpicGames.Horde.Artifacts; using EpicGames.Horde.Commits; using EpicGames.Horde.Jobs.Bisect; using EpicGames.Horde.Jobs.Graphs; using EpicGames.Horde.Jobs.Templates; using EpicGames.Horde.Jobs.Timing; using EpicGames.Horde.Logs; using EpicGames.Horde.Notifications; using EpicGames.Horde.Streams; using EpicGames.Horde.Ugs; using EpicGames.Horde.Users; using Microsoft.Extensions.Logging; #pragma warning disable CA1716 namespace EpicGames.Horde.Jobs { /// /// Document describing a job /// public interface IJob { /// /// Job argument indicating a target that should be built /// public const string TargetArgumentPrefix = "-Target="; /// /// Name of the node which parses the buildgraph script /// public const string SetupNodeName = "Setup Build"; /// /// Identifier for the job. Randomly generated. /// public JobId Id { get; } /// /// The stream that this job belongs to /// public StreamId StreamId { get; } /// /// The template ref id /// public TemplateId TemplateId { get; } /// /// The template that this job was created from /// public ContentHash? TemplateHash { get; } /// /// Hash of the graph definition /// public ContentHash GraphHash { get; } /// /// Graph for this job /// public IGraph Graph { get; } /// /// Id of the user that started this job /// public UserId? StartedByUserId { get; } /// /// Id of the user that aborted this job. Set to null if the job is not aborted. /// public UserId? AbortedByUserId { get; } /// /// Optional reason for why the job was canceled /// public string? CancellationReason { get; } /// /// Identifier of the bisect task that started this job /// public BisectTaskId? StartedByBisectTaskId { get; } /// /// Name of the job. /// public string Name { get; } /// /// The commit to build /// public CommitIdWithOrder CommitId { get; } /// /// The code commit for this build /// public CommitIdWithOrder? CodeCommitId { get; } /// /// The preflight changelist number /// public CommitId? PreflightCommitId { get; } /// /// Description for the shelved change if running a preflight /// public string? PreflightDescription { get; } /// /// Priority of this job /// public Priority Priority { get; } /// /// For preflights, submit the change if the job is successful /// public bool AutoSubmit { get; } /// /// The submitted changelist number /// public int? AutoSubmitChange { get; } /// /// Message produced by trying to auto-submit the change /// public string? AutoSubmitMessage { get; } /// /// Whether to update issues based on the outcome of this job /// public bool UpdateIssues { get; } /// /// Whether to promote issues by default based on the outcome of this job /// public bool PromoteIssuesByDefault { get; } /// /// Time that the job was created (in UTC) /// public DateTime CreateTimeUtc { get; } /// /// Options for executing the job /// public JobOptions? JobOptions { get; } /// /// Claims inherited from the user that started this job /// public IReadOnlyList Claims { get; } /// /// Array of jobstep runs /// public IReadOnlyList Batches { get; } /// /// Parameters for the job /// public IReadOnlyDictionary Parameters { get; } /// /// Optional user-defined properties for this job /// public IReadOnlyList Arguments { get; } /// /// Custom list of targets for the job. If null or empty, the list of targets is determined from the command line. /// public IReadOnlyList? Targets { get; } /// /// Additional arguments for the job, when a set of parameters are applied. /// public IReadOnlyList AdditionalArguments { get; } /// /// Environment variables for the job /// public IReadOnlyDictionary Environment { get; } /// /// Issues associated with this job /// public IReadOnlyList Issues { get; } /// /// Unique id for notifications /// public NotificationTriggerId? NotificationTriggerId { get; } /// /// Whether to show badges in UGS for this job /// public bool ShowUgsBadges { get; } /// /// Whether to show alerts in UGS for this job /// public bool ShowUgsAlerts { get; } /// /// Notification channel for this job. /// public string? NotificationChannel { get; } /// /// Notification channel filter for this job. /// public string? NotificationChannelFilter { get; } /// /// Mapping of label ids to notification trigger ids for notifications /// public IReadOnlyDictionary LabelIdxToTriggerId { get; } /// /// List of reports for this step /// public IReadOnlyList? Reports { get; } /// /// List of downstream job triggers /// public IReadOnlyList ChainedJobs { get; } /// /// The job which spawned this job /// public JobId? ParentJobId { get; } /// /// The job step which spawned this job /// public JobStepId? ParentJobStepId { get; } /// /// The last update time /// public DateTime UpdateTimeUtc { get; } /// /// The job meta data /// public List Metadata { get; } /// /// Update counter for this document. Any updates should compare-and-swap based on the value of this counter, or increment it in the case of server-side updates. /// public int UpdateIndex { get; } /// /// Gets the latest job state /// /// Cancellation token for the operation Task RefreshAsync(CancellationToken cancellationToken = default); /// /// Attempt to get a batch with the given id /// /// The job batch id /// Receives the batch interface on success /// True if the batch was found bool TryGetBatch(JobStepBatchId batchId, [NotNullWhen(true)] out IJobStepBatch? batch); /// /// Attempt to get a step with the given id /// /// The job step id /// Receives the step interface on success /// True if the step was found bool TryGetStep(JobStepId stepId, [NotNullWhen(true)] out IJobStep? step); /// /// Attempt to delete the job /// /// Cancellation token for the operation /// True if the job was deleted. False if the job is not the latest revision. Task TryDeleteAsync(CancellationToken cancellationToken = default); /// /// Removes a job from the dispatch queue. Ignores the state of any batches still remaining to execute. Should only be used to correct for inconsistent state. /// /// Cancellation token for the operation /// Task TryRemoveFromDispatchQueueAsync(CancellationToken cancellationToken = default); /// /// Updates a new job /// /// Name of the job /// Priority of the job /// Automatically submit the job on completion /// Changelist that was automatically submitted /// /// Name of the user that aborted the job /// Id for a notification trigger /// New reports /// New arguments for the job /// New trigger ID for a label in the job /// New downstream job id /// Optional reason why the job was canceled /// Cancellation token for the operation Task TryUpdateJobAsync(string? name = null, Priority? priority = null, bool? autoSubmit = null, int? autoSubmitChange = null, string? autoSubmitMessage = null, UserId? abortedByUserId = null, NotificationTriggerId? notificationTriggerId = null, List? reports = null, List? arguments = null, KeyValuePair? labelIdxToTriggerId = null, KeyValuePair? jobTrigger = null, string? cancellationReason = null, CancellationToken cancellationToken = default); /// /// Updates the state of a batch /// /// Unique id of the batch to update /// The new log file id /// New state of the jobstep /// Error code for the batch /// Cancellation token for the operation /// True if the job was updated, false if it was deleted Task TryUpdateBatchAsync(JobStepBatchId batchId, LogId? newLogId, JobStepBatchState? newState, JobStepBatchError? newError, CancellationToken cancellationToken = default); /// /// Update a jobstep state /// /// Unique id of the batch containing the step /// Unique id of the step to update /// New state of the jobstep /// New outcome of the jobstep /// New error annotation for this jobstep /// New state of request abort /// New name of user that requested the abort /// New log id for the jobstep /// New id for a notification trigger /// Whether the step should be retried /// New priority for this step /// New report documents /// Property changes. Any properties with a null value will be removed. /// The reason the job step was canceled /// JobId of any job this step spawned /// Cancellation token for the operation /// True if the job was updated, false if it was deleted in the meantime Task TryUpdateStepAsync(JobStepBatchId batchId, JobStepId stepId, JobStepState newState = default, JobStepOutcome newOutcome = default, JobStepError? newError = null, bool? newAbortRequested = null, UserId? newAbortByUserId = null, LogId? newLogId = null, NotificationTriggerId? newNotificationTriggerId = null, UserId? newRetryByUserId = null, Priority? newPriority = null, List? newReports = null, Dictionary? newProperties = null, string? newCancellationReason = null, JobId? newSpawnedJob = null, CancellationToken cancellationToken = default); /// /// Attempts to update the node groups to be executed for a job. Fails if another write happens in the meantime. /// /// New graph for this job /// Cancellation token for the operation /// True if the groups were updated to the given list. False if another write happened first. Task TryUpdateGraphAsync(IGraph newGraph, CancellationToken cancellationToken = default); /// /// Marks a job as skipped /// /// Reason for this batch being failed /// Cancellation token for the operation /// Updated version of the job Task TrySkipAllBatchesAsync(JobStepBatchError reason, CancellationToken cancellationToken = default); /// /// Marks a batch as skipped /// /// The batch to mark as skipped /// Reason for this batch being failed /// Cancellation token for the operation /// Updated version of the job Task TrySkipBatchAsync(JobStepBatchId batchId, JobStepBatchError reason, CancellationToken cancellationToken = default); /// /// Abort an agent's lease, and update the payload accordingly /// /// Index of the batch to cancel /// Reason for this batch being failed /// Cancellation token for the operation /// True if the job is updated Task TryFailBatchAsync(int batchIdx, JobStepBatchError reason, CancellationToken cancellationToken = default); /// /// Attempt to assign a lease to execute a batch /// /// Index of the batch /// The pool id /// New agent to execute the batch /// Session of the agent that is to execute the batch /// The lease unique id /// Unique id of the log for the batch /// Cancellation token for the operation /// True if the batch is updated Task TryAssignLeaseAsync(int batchIdx, PoolId poolId, AgentId agentId, SessionId sessionId, LeaseId leaseId, LogId logId, CancellationToken cancellationToken = default); /// /// Cancel a lease reservation on a batch (before it has started) /// /// Index of the batch to cancel /// Cancellation token for the operation /// True if the job is updated Task TryCancelLeaseAsync(int batchIdx, CancellationToken cancellationToken = default); } /// /// Extension methods for jobs /// public static class JobExtensions { /// /// Gets the current job state /// /// The job document /// Job state public static JobState GetState(this IJob job) { bool waiting = false; foreach (IJobStepBatch batch in job.Batches) { foreach (IJobStep step in batch.Steps) { if (step.State == JobStepState.Running) { return JobState.Running; } else if (step.State == JobStepState.Ready || step.State == JobStepState.Waiting) { if (batch.State == JobStepBatchState.Starting || batch.State == JobStepBatchState.Running) { return JobState.Running; } else { waiting = true; } } } } return waiting ? JobState.Waiting : JobState.Complete; } /// /// Gets the outcome for a particular named target. May be an aggregate or node name. /// /// The job to check /// The step outcome public static (JobStepState, JobStepOutcome) GetTargetState(this IJob job) { IReadOnlyDictionary nodeToStep = GetStepForNodeMap(job); return GetTargetState(nodeToStep.Values); } /// /// Gets the outcome for a particular named target. May be an aggregate or node name. /// /// The job to check /// Graph for the job /// Target to find an outcome for /// The step outcome public static (JobStepState, JobStepOutcome)? GetTargetState(this IJob job, IGraph graph, string? target) { if (target == null) { return GetTargetState(job); } NodeRef nodeRef; if (graph.TryFindNode(target, out nodeRef)) { IJobStep? step; if (job.TryGetStepForNode(nodeRef, out step)) { return (step.State, step.Outcome); } else { return null; } } IAggregate? aggregate; if (graph.TryFindAggregate(target, out aggregate)) { IReadOnlyDictionary stepForNode = GetStepForNodeMap(job); List steps = new List(); foreach (NodeRef aggregateNodeRef in aggregate.Nodes) { IJobStep? step; if (!stepForNode.TryGetValue(aggregateNodeRef, out step)) { return null; } steps.Add(step); } return GetTargetState(steps); } return null; } /// /// Gets the outcome for a particular named target. May be an aggregate or node name. /// /// Steps to include /// The step outcome public static (JobStepState, JobStepOutcome) GetTargetState(IEnumerable steps) { bool anySkipped = false; bool anyWarnings = false; bool anyFailed = false; bool anyPending = false; foreach (IJobStep step in steps) { anyPending |= step.IsPending(); anySkipped |= step.State == JobStepState.Aborted || step.State == JobStepState.Skipped; anyFailed |= (step.Outcome == JobStepOutcome.Failure); anyWarnings |= (step.Outcome == JobStepOutcome.Warnings); } JobStepState newState = anyPending ? JobStepState.Running : JobStepState.Completed; JobStepOutcome newOutcome = anyFailed ? JobStepOutcome.Failure : anyWarnings ? JobStepOutcome.Warnings : anySkipped ? JobStepOutcome.Unspecified : JobStepOutcome.Success; return (newState, newOutcome); } /// /// Gets the outcome for a particular named target. May be an aggregate or node name. /// /// The job to check /// Graph for the job /// Target to find an outcome for /// The step outcome public static JobStepOutcome GetTargetOutcome(this IJob job, IGraph graph, string target) { NodeRef nodeRef; if (graph.TryFindNode(target, out nodeRef)) { IJobStep? step; if (job.TryGetStepForNode(nodeRef, out step)) { return step.Outcome; } else { return JobStepOutcome.Unspecified; } } IAggregate? aggregate; if (graph.TryFindAggregate(target, out aggregate)) { IReadOnlyDictionary stepForNode = GetStepForNodeMap(job); bool warnings = false; foreach (NodeRef aggregateNodeRef in aggregate.Nodes) { IJobStep? step; if (!stepForNode.TryGetValue(aggregateNodeRef, out step)) { return JobStepOutcome.Unspecified; } if (step.Outcome == JobStepOutcome.Failure) { return JobStepOutcome.Failure; } warnings |= (step.Outcome == JobStepOutcome.Warnings); } return warnings ? JobStepOutcome.Warnings : JobStepOutcome.Success; } return JobStepOutcome.Unspecified; } /// /// Gets the job step for a particular node /// /// The job to search /// The node ref /// Receives the jobstep on success /// True if the jobstep was founds public static bool TryGetStepForNode(this IJob job, NodeRef nodeRef, [NotNullWhen(true)] out IJobStep? jobStep) { jobStep = null; foreach (IJobStepBatch batch in job.Batches) { if (batch.GroupIdx == nodeRef.GroupIdx) { foreach (IJobStep batchStep in batch.Steps) { if (batchStep.NodeIdx == nodeRef.NodeIdx) { jobStep = batchStep; } } } } return jobStep != null; } /// /// Gets a dictionary that maps objects to their associated /// objects on a . /// /// The job document /// Map of to public static IReadOnlyDictionary GetStepForNodeMap(this IJob job) { Dictionary stepForNode = new Dictionary(); foreach (IJobStepBatch batch in job.Batches) { foreach (IJobStep batchStep in batch.Steps) { NodeRef batchNodeRef = new NodeRef(batch.GroupIdx, batchStep.NodeIdx); stepForNode[batchNodeRef] = batchStep; } } return stepForNode; } /// /// Find the latest step executing the given node /// /// The job being run /// Node to find /// The retried step information public static JobStepRefId? FindLatestStepForNode(this IJob job, NodeRef nodeRef) { for (int batchIdx = job.Batches.Count - 1; batchIdx >= 0; batchIdx--) { IJobStepBatch batch = job.Batches[batchIdx]; if (batch.GroupIdx == nodeRef.GroupIdx) { for (int stepIdx = batch.Steps.Count - 1; stepIdx >= 0; stepIdx--) { IJobStep step = batch.Steps[stepIdx]; if (step.NodeIdx == nodeRef.NodeIdx) { return new JobStepRefId(job.Id, batch.Id, step.Id); } } } } return null; } /// /// Gets the estimated timing info for all nodes in the job /// /// The job document /// Graph for this job /// Job timing information /// Logger for any diagnostic messages /// Map of node to expected timing info public static Dictionary GetTimingInfo(this IJob job, IGraph graph, IJobTiming jobTiming, ILogger logger) { #pragma warning disable IDE0054 // Use compound assignment TimeSpan currentTime = DateTime.UtcNow - job.CreateTimeUtc; Dictionary nodeToTimingInfo = graph.Groups.SelectMany(x => x.Nodes).ToDictionary(x => x, x => new TimingInfo()); foreach (IJobStepBatch batch in job.Batches) { INodeGroup group = graph.Groups[batch.GroupIdx]; // Step through the batch, keeping track of the time that things finish. TimingInfo timingInfo = new TimingInfo(); // Wait for the dependencies for the batch to start HashSet dependencyNodes = batch.GetStartDependencies(graph.Groups); timingInfo.WaitForAll(dependencyNodes.Select(x => nodeToTimingInfo[x])); // If the batch has actually started, correct the expected time to use this instead if (batch.StartTimeUtc != null) { timingInfo.TotalTimeToComplete = batch.StartTimeUtc - job.CreateTimeUtc; } // Get the average times for this batch TimeSpan? averageWaitTime = GetAverageWaitTime(graph, batch, jobTiming, logger); TimeSpan? averageInitTime = GetAverageInitTime(graph, batch, jobTiming, logger); // Update the wait times and initialization times along this path timingInfo.TotalWaitTime = timingInfo.TotalWaitTime + (batch.GetWaitTime() ?? averageWaitTime); timingInfo.TotalInitTime = timingInfo.TotalInitTime + (batch.GetInitTime() ?? averageInitTime); // Update the average wait and initialization times too timingInfo.AverageTotalWaitTime = timingInfo.AverageTotalWaitTime + averageWaitTime; timingInfo.AverageTotalInitTime = timingInfo.AverageTotalInitTime + averageInitTime; // Step through the batch, updating the expected times as we go foreach (IJobStep step in batch.Steps) { INode node = group.Nodes[step.NodeIdx]; // Get the timing for this step IJobStepTiming? stepTimingInfo; jobTiming.TryGetStepTiming(node.Name, logger, out stepTimingInfo); // If the step has already started, update the actual time to reach this point if (step.StartTimeUtc != null) { timingInfo.TotalTimeToComplete = step.StartTimeUtc.Value - job.CreateTimeUtc; } // If the step hasn't started yet, make sure the start time is later than the current time if (step.StartTimeUtc == null && currentTime > timingInfo.TotalTimeToComplete) { timingInfo.TotalTimeToComplete = currentTime; } // Wait for all the node dependencies to complete timingInfo.WaitForAll(graph.GetDependencies(node).Select(x => nodeToTimingInfo[x])); // If the step has actually finished, correct the time to use that instead if (step.FinishTimeUtc != null) { timingInfo.TotalTimeToComplete = step.FinishTimeUtc.Value - job.CreateTimeUtc; } else { timingInfo.TotalTimeToComplete = timingInfo.TotalTimeToComplete + NullableTimeSpanFromSeconds(stepTimingInfo?.AverageDuration); } // If the step hasn't finished yet, make sure the start time is later than the current time if (step.FinishTimeUtc == null && currentTime > timingInfo.TotalTimeToComplete) { timingInfo.TotalTimeToComplete = currentTime; } // Update the average time to complete timingInfo.AverageTotalTimeToComplete = timingInfo.AverageTotalTimeToComplete + NullableTimeSpanFromSeconds(stepTimingInfo?.AverageDuration); // Add it to the lookup TimingInfo nodeTimingInfo = new TimingInfo(timingInfo); nodeTimingInfo.StepTiming = stepTimingInfo; nodeToTimingInfo[node] = nodeTimingInfo; } } return nodeToTimingInfo; #pragma warning restore IDE0054 // Use compound assignment } /// /// Gets the step completion info of the job. /// /// The job document /// The job's step completion info. /// Any steps not in a terminal state (, , ) are excluded in total counts except . public static JobStepsCompletionInfo GetStepCompletionInfo(this IJob job) { JobStepsCompletionInfo jobStepsCompletionInfo = new JobStepsCompletionInfo(); foreach (IJobStepBatch batch in job.Batches) { foreach (IJobStep step in batch.Steps) { jobStepsCompletionInfo.StepTotalCount++; if (step.State != JobStepState.Completed && step.State != JobStepState.Aborted && step.State != JobStepState.Skipped) { continue; } if (step.FinishTimeUtc != null && step.StartTimeUtc != null) { TimeSpan? stepDuration = step.FinishTimeUtc.Value - step.StartTimeUtc; if (stepDuration != null) { jobStepsCompletionInfo.JobStepsTotalTime += (float)stepDuration.Value.TotalSeconds; } } switch (step.Outcome) { case JobStepOutcome.Success: jobStepsCompletionInfo.StepPassCount++; break; case JobStepOutcome.Warnings: jobStepsCompletionInfo.StepWarningCount++; break; case JobStepOutcome.Failure: jobStepsCompletionInfo.StepFailureCount++; break; } } } return jobStepsCompletionInfo; } /// /// Gets the average wait time for this batch /// /// Graph for the job /// The batch to get timing info for /// The job timing information /// Logger for diagnostic info /// Wait time for the batch public static TimeSpan? GetAverageWaitTime(IGraph graph, IJobStepBatch batch, IJobTiming jobTiming, ILogger logger) { TimeSpan? waitTime = null; foreach (IJobStep step in batch.Steps) { INode node = graph.Groups[batch.GroupIdx].Nodes[step.NodeIdx]; if (jobTiming.TryGetStepTiming(node.Name, logger, out IJobStepTiming? timingInfo)) { if (timingInfo.AverageWaitTime != null) { TimeSpan stepWaitTime = TimeSpan.FromSeconds(timingInfo.AverageWaitTime.Value); if (waitTime == null || stepWaitTime > waitTime.Value) { waitTime = stepWaitTime; } } } } return waitTime; } /// /// Gets the average initialization time for this batch /// /// Graph for the job /// The batch to get timing info for /// The job timing information /// Logger for diagnostic messages /// Initialization time for this batch public static TimeSpan? GetAverageInitTime(IGraph graph, IJobStepBatch batch, IJobTiming jobTiming, ILogger logger) { TimeSpan? initTime = null; foreach (IJobStep step in batch.Steps) { INode node = graph.Groups[batch.GroupIdx].Nodes[step.NodeIdx]; if (jobTiming.TryGetStepTiming(node.Name, logger, out IJobStepTiming? timingInfo)) { if (timingInfo.AverageInitTime != null) { TimeSpan stepInitTime = TimeSpan.FromSeconds(timingInfo.AverageInitTime.Value); if (initTime == null || stepInitTime > initTime.Value) { initTime = stepInitTime; } } } } return initTime; } /// /// Creates a nullable timespan from a nullable number of seconds /// /// The number of seconds to construct from /// TimeSpan object static TimeSpan? NullableTimeSpanFromSeconds(float? seconds) { if (seconds == null) { return null; } else { return TimeSpan.FromSeconds(seconds.Value); } } /// /// Attempts to get a batch with the given id /// /// The job document /// The batch id /// The step id /// On success, receives the step object /// True if the batch was found public static bool TryGetStep(this IJob job, JobStepBatchId batchId, JobStepId stepId, [NotNullWhen(true)] out IJobStep? step) { if (!job.TryGetStep(stepId, out step) || step.Batch.Id != batchId) { step = null; return false; } return true; } /// /// Finds the set of nodes affected by a label /// /// The job document /// Graph definition for the job /// Index of the label. -1 or Graph.Labels.Count are treated as referring to the default lable. /// Set of nodes affected by the given label public static HashSet GetNodesForLabel(this IJob job, IGraph graph, int labelIdx) { if (labelIdx != -1 && labelIdx != graph.Labels.Count) { // Return all the nodes included by the label return new HashSet(graph.Labels[labelIdx].IncludedNodes); } else { // Set of nodes which are not covered by an existing label, initially containing everything HashSet unlabeledNodes = new HashSet(); for (int groupIdx = 0; groupIdx < graph.Groups.Count; groupIdx++) { INodeGroup group = graph.Groups[groupIdx]; for (int nodeIdx = 0; nodeIdx < group.Nodes.Count; nodeIdx++) { unlabeledNodes.Add(new NodeRef(groupIdx, nodeIdx)); } } // Remove all the nodes that are part of an active label IReadOnlyDictionary stepForNode = job.GetStepForNodeMap(); foreach (ILabel label in graph.Labels) { if (label.RequiredNodes.Any(x => stepForNode.ContainsKey(x))) { unlabeledNodes.ExceptWith(label.IncludedNodes); } } return unlabeledNodes; } } /// /// Create a list of aggregate responses, combining the graph definitions with the state of the job /// /// The job document /// Graph definition for the job /// List to receive all the responses /// The default label state public static GetDefaultLabelStateResponse? GetLabelStateResponses(this IJob job, IGraph graph, List responses) { // Create a lookup from noderef to step information IReadOnlyDictionary stepForNode = job.GetStepForNodeMap(); // Set of nodes which are not covered by an existing label, initially containing everything HashSet unlabeledNodes = new HashSet(); for (int groupIdx = 0; groupIdx < graph.Groups.Count; groupIdx++) { INodeGroup group = graph.Groups[groupIdx]; for (int nodeIdx = 0; nodeIdx < group.Nodes.Count; nodeIdx++) { unlabeledNodes.Add(new NodeRef(groupIdx, nodeIdx)); } } // Create the responses foreach (ILabel label in graph.Labels) { // Refresh the state for this label LabelState newState = LabelState.Unspecified; foreach (NodeRef requiredNodeRef in label.RequiredNodes) { if (stepForNode.ContainsKey(requiredNodeRef)) { newState = LabelState.Complete; break; } } // Refresh the outcome LabelOutcome newOutcome = LabelOutcome.Success; if (newState == LabelState.Complete) { GetLabelState(label.IncludedNodes, stepForNode, out newState, out newOutcome); unlabeledNodes.ExceptWith(label.IncludedNodes); } // Create the response GetLabelStateResponse response = new GetLabelStateResponse(); response.DashboardName = label.DashboardName; response.DashboardCategory = label.DashboardCategory; response.UgsName = label.UgsName; response.UgsProject = label.UgsProject; response.State = newState; response.Outcome = newOutcome; foreach (NodeRef includedNodeRef in label.IncludedNodes) { IJobStep? includedStep; if (stepForNode.TryGetValue(includedNodeRef, out includedStep)) { response.Steps.Add(includedStep.Id); } } responses.Add(response); } // Remove all the nodes that don't have a step unlabeledNodes.RemoveWhere(x => !stepForNode.ContainsKey(x)); // Remove successful "setup build" nodes from the list if (graph.Groups.Count > 1 && graph.Groups[0].Nodes.Count > 0) { INode node = graph.Groups[0].Nodes[0]; if (node.Name == IJob.SetupNodeName) { NodeRef nodeRef = new NodeRef(0, 0); if (unlabeledNodes.Contains(nodeRef)) { IJobStep step = stepForNode[nodeRef]; if (step.State == JobStepState.Completed && step.Outcome == JobStepOutcome.Success && responses.Count > 0) { unlabeledNodes.Remove(nodeRef); } } } } // Add a response for everything not included elsewhere. GetLabelState(unlabeledNodes, stepForNode, out LabelState otherState, out LabelOutcome otherOutcome); GetDefaultLabelStateResponse defaultResponse = new GetDefaultLabelStateResponse(); defaultResponse.State = otherState; defaultResponse.Outcome = otherOutcome; defaultResponse.Nodes.AddRange(unlabeledNodes.Select(x => graph.GetNode(x).Name)); defaultResponse.Steps.AddRange(unlabeledNodes.Select(x => stepForNode[x].Id)); return defaultResponse; } /// /// Get the states of all labels for this job /// /// The job to get states for /// The graph for this job /// Collection of label states by label index public static IReadOnlyList<(LabelState, LabelOutcome)> GetLabelStates(this IJob job, IGraph graph) { IReadOnlyDictionary stepForNodeRef = job.GetStepForNodeMap(); List<(LabelState, LabelOutcome)> states = new List<(LabelState, LabelOutcome)>(); for (int idx = 0; idx < graph.Labels.Count; idx++) { ILabel label = graph.Labels[idx]; // Default the label to the unspecified state LabelState newState = LabelState.Unspecified; LabelOutcome newOutcome = LabelOutcome.Unspecified; // Check if the label should be included if (label.RequiredNodes.Any(x => stepForNodeRef.ContainsKey(x))) { // Combine the state of the steps contributing towards this label bool anySkipped = false; bool anyWarnings = false; bool anyFailed = false; bool anyPending = false; foreach (NodeRef includedNode in label.IncludedNodes) { IJobStep? step; if (stepForNodeRef.TryGetValue(includedNode, out step)) { anyPending |= step.IsPending(); anySkipped |= step.State == JobStepState.Aborted || step.State == JobStepState.Skipped; anyFailed |= (step.Outcome == JobStepOutcome.Failure); anyWarnings |= (step.Outcome == JobStepOutcome.Warnings); } } // Figure out the overall label state newState = anyPending ? LabelState.Running : LabelState.Complete; newOutcome = anyFailed ? LabelOutcome.Failure : anyWarnings ? LabelOutcome.Warnings : anySkipped ? LabelOutcome.Unspecified : LabelOutcome.Success; } states.Add((newState, newOutcome)); } return states; } /// /// Get the states of all UGS badges for this job /// /// The job to get states for /// The graph for this job /// List of badge states public static Dictionary GetUgsBadgeStates(this IJob job, IGraph graph) { IReadOnlyList<(LabelState, LabelOutcome)> labelStates = GetLabelStates(job, graph); return job.GetUgsBadgeStates(graph, labelStates); } /// /// Get the states of all UGS badges for this job /// /// The job to get states for /// The graph for this job /// The existing label states to get the UGS badge states from /// List of badge states #pragma warning disable IDE0060 // Remove unused parameter public static Dictionary GetUgsBadgeStates(this IJob job, IGraph graph, IReadOnlyList<(LabelState, LabelOutcome)> labelStates) #pragma warning restore IDE0060 // Remove unused parameter { Dictionary ugsBadgeStates = new Dictionary(); for (int labelIdx = 0; labelIdx < labelStates.Count; ++labelIdx) { if (graph.Labels[labelIdx].UgsName == null) { continue; } (LabelState state, LabelOutcome outcome) = labelStates[labelIdx]; switch (state) { case LabelState.Complete: { switch (outcome) { case LabelOutcome.Success: { ugsBadgeStates.Add(labelIdx, UgsBadgeState.Success); break; } case LabelOutcome.Warnings: { ugsBadgeStates.Add(labelIdx, UgsBadgeState.Warning); break; } case LabelOutcome.Failure: { ugsBadgeStates.Add(labelIdx, UgsBadgeState.Failure); break; } case LabelOutcome.Unspecified: { ugsBadgeStates.Add(labelIdx, UgsBadgeState.Skipped); break; } } break; } case LabelState.Running: { ugsBadgeStates.Add(labelIdx, UgsBadgeState.Starting); break; } case LabelState.Unspecified: { ugsBadgeStates.Add(labelIdx, UgsBadgeState.Skipped); break; } } } return ugsBadgeStates; } /// /// Gets the state of a job, as a label that includes all steps /// /// The job to query /// Map from node to step /// Receives the state of the label /// Receives the outcome of the label public static void GetJobState(this IJob job, IReadOnlyDictionary stepForNode, out LabelState newState, out LabelOutcome newOutcome) { List nodes = new List(); foreach (IJobStepBatch batch in job.Batches) { foreach (IJobStep step in batch.Steps) { nodes.Add(new NodeRef(batch.GroupIdx, step.NodeIdx)); } } GetLabelState(nodes, stepForNode, out newState, out newOutcome); } /// /// Gets the state of a label /// /// Nodes to include in this label /// Map from node to step /// Receives the state of the label /// Receives the outcome of the label public static void GetLabelState(IEnumerable includedNodes, IReadOnlyDictionary stepForNode, out LabelState newState, out LabelOutcome newOutcome) { newState = LabelState.Complete; newOutcome = LabelOutcome.Success; foreach (NodeRef includedNodeRef in includedNodes) { IJobStep? includedStep; if (stepForNode.TryGetValue(includedNodeRef, out includedStep)) { // Update the state if (includedStep.State != JobStepState.Completed && includedStep.State != JobStepState.Skipped && includedStep.State != JobStepState.Aborted) { newState = LabelState.Running; } // Update the outcome if (includedStep.State == JobStepState.Skipped || includedStep.State == JobStepState.Aborted || includedStep.Outcome == JobStepOutcome.Failure) { newOutcome = LabelOutcome.Failure; } else if (includedStep.Outcome == JobStepOutcome.Warnings && newOutcome == LabelOutcome.Success) { newOutcome = LabelOutcome.Warnings; } } } } /// /// Gets a key attached to all artifacts produced for a job /// public static string GetArtifactKey(this IJob job) { return $"job:{job.Id}"; } /// /// Gets a key attached to all artifacts produced for a job step /// public static string GetArtifactKey(this IJob job, IJobStep jobStep) { return $"job:{job.Id}/step:{jobStep.Id}"; } /// public static async Task SkipBatchAsync(this IJob job, JobStepBatchId batchId, JobStepBatchError error, CancellationToken cancellationToken) { for (; ; ) { IJob? newJob = await job.TrySkipBatchAsync(batchId, error, cancellationToken); if (newJob != null) { return newJob; } newJob = await job.RefreshAsync(cancellationToken); if (newJob == null) { return null; } job = newJob; } } /// public static async Task SkipAllBatchesAsync(this IJob job, JobStepBatchError reason, CancellationToken cancellationToken) { for (; ; ) { IJob? newJob = await job.TrySkipAllBatchesAsync(reason, cancellationToken); if (newJob != null) { return newJob; } newJob = await job.RefreshAsync(cancellationToken); if (newJob == null) { return null; } job = newJob; } } } /// /// Stores information about a batch of job steps /// public interface IJobStepBatch { /// /// Job that this batch belongs to /// public IJob Job { get; } /// /// Unique id for this group /// public JobStepBatchId Id { get; } /// /// The type of agent to execute this group /// public string AgentType { get; } /// /// The log file id for this batch /// public LogId? LogId { get; } /// /// The node group for this batch /// public INodeGroup Group { get; } /// /// Index of the group being executed /// public int GroupIdx { get; } /// /// The state of this group /// public JobStepBatchState State { get; } /// /// Error associated with this group /// public JobStepBatchError Error { get; } /// /// Steps within this run /// public IReadOnlyList Steps { get; } /// /// The pool that this agent was taken from /// public PoolId? PoolId { get; } /// /// The agent assigned to execute this group /// public AgentId? AgentId { get; } /// /// The agent session that is executing this group /// public SessionId? SessionId { get; } /// /// The lease that's executing this group /// public LeaseId? LeaseId { get; } /// /// The weighted priority of this batch for the scheduler /// public int SchedulePriority { get; } /// /// Time at which the group became ready (UTC). /// public DateTime? ReadyTimeUtc { get; } /// /// Time at which the group started (UTC). /// public DateTime? StartTimeUtc { get; } /// /// Time at which the group finished (UTC) /// public DateTime? FinishTimeUtc { get; } } /// /// Extension methods for IJobStepBatch /// public static class JobStepBatchExtensions { /// /// Attempts to get a step with the given id /// /// The batch to search /// The step id /// On success, receives the step object /// True if the step was found public static bool TryGetStep(this IJobStepBatch batch, JobStepId stepId, [NotNullWhen(true)] out IJobStep? step) { step = batch.Steps.FirstOrDefault(x => x.Id == stepId); return step != null; } /// /// Determines if new steps can be appended to this batch. We do not allow this after the last step has been completed, because the agent is shutting down. /// /// The batch to search /// True if new steps can be appended to this batch public static bool CanBeAppendedTo(this IJobStepBatch batch) { return batch.State <= JobStepBatchState.Running; } /// /// Gets the wait time for this batch /// /// The batch to search /// Wait time for the batch public static TimeSpan? GetWaitTime(this IJobStepBatch batch) { if (batch.StartTimeUtc == null || batch.ReadyTimeUtc == null) { return null; } else { return batch.StartTimeUtc.Value - batch.ReadyTimeUtc.Value; } } /// /// Gets the initialization time for this batch /// /// The batch to search /// Initialization time for this batch public static TimeSpan? GetInitTime(this IJobStepBatch batch) { if (batch.StartTimeUtc != null) { foreach (IJobStep step in batch.Steps) { if (step.StartTimeUtc != null) { return step.StartTimeUtc - batch.StartTimeUtc.Value; } } } return null; } /// /// Get the dependencies required for this batch to start, taking run-early nodes into account /// /// The batch to search /// List of node groups /// Set of nodes that must have completed for this batch to start public static HashSet GetStartDependencies(this IJobStepBatch batch, IReadOnlyList groups) { List batchNodes = batch.Steps.ConvertAll(x => groups[batch.GroupIdx].Nodes[x.NodeIdx]); return GetStartDependencies(batchNodes, groups); } /// /// Get the dependencies required for this batch to start, taking run-early nodes into account /// /// Nodes in the batch to search /// List of node groups /// Set of nodes that must have completed for this batch to start public static HashSet GetStartDependencies(IEnumerable batchNodes, IReadOnlyList groups) { // Find all the nodes that this group will start with. List nodes = new List(batchNodes); if (nodes.Any(x => x.RunEarly)) { nodes.RemoveAll(x => !x.RunEarly); } // Find all their dependencies HashSet dependencies = new HashSet(); foreach (INode node in nodes) { dependencies.UnionWith(node.InputDependencies.Select(x => groups[x.GroupIdx].Nodes[x.NodeIdx])); dependencies.UnionWith(node.OrderDependencies.Select(x => groups[x.GroupIdx].Nodes[x.NodeIdx])); } // Exclude all the dependencies within the same group dependencies.ExceptWith(batchNodes); return dependencies; } } /// /// Embedded jobstep document /// public interface IJobStep { /// /// Job that this step belongs to /// public IJob Job { get; } /// /// Batch that this step belongs to /// public IJobStepBatch Batch { get; } /// /// Unique ID assigned to this jobstep. A new id is generated whenever a jobstep's order is changed. /// public JobStepId Id { get; } /// /// The node for this step /// public INode Node { get; } /// /// Index of the node which this jobstep is to execute /// public int NodeIdx { get; } /// /// The name of this node /// public string Name { get; } /// /// References to inputs for this node /// public IReadOnlyList Inputs { get; } /// /// List of output names /// public IReadOnlyList OutputNames { get; } /// /// Indices of nodes which must have succeeded for this node to run /// public IReadOnlyList InputDependencies { get; } /// /// Indices of nodes which must have completed for this node to run /// public IReadOnlyList OrderDependencies { get; } /// /// Whether this node can be run multiple times /// public bool AllowRetry { get; } /// /// This node can start running early, before dependencies of other nodes in the same group are complete /// public bool RunEarly { get; } /// /// Whether to include warnings in the output (defaults to true) /// public bool Warnings { get; } /// /// List of credentials required for this node. Each entry maps an environment variable name to a credential in the form "CredentialName.PropertyName". /// public IReadOnlyDictionary? Credentials { get; } /// /// Annotations for this node /// public IReadOnlyNodeAnnotations Annotations { get; } /// /// Metadata for this node /// public IReadOnlyList Metadata { get; } /// /// Current state of the job step. This is updated automatically when runs complete. /// public JobStepState State { get; } /// /// Current outcome of the jobstep /// public JobStepOutcome Outcome { get; } /// /// Error from executing this step /// public JobStepError Error { get; } /// /// The log id for this step /// public LogId? LogId { get; } /// /// Unique id for notifications /// public NotificationTriggerId? NotificationTriggerId { get; } /// /// Time at which the batch transitioned to the ready state (UTC). /// public DateTime? ReadyTimeUtc { get; } /// /// Time at which the batch transitioned to the executing state (UTC). /// public DateTime? StartTimeUtc { get; } /// /// Time at which the run finished (UTC) /// public DateTime? FinishTimeUtc { get; } /// /// Override for the priority of this step /// public Priority? Priority { get; } /// /// If a retry is requested, stores the name of the user that requested it /// public UserId? RetriedByUserId { get; } /// /// Signal if a step should be aborted /// public bool AbortRequested { get; } /// /// If an abort is requested, stores the id of the user that requested it /// public UserId? AbortedByUserId { get; } /// /// Optional reason for why the job step was canceled /// public string? CancellationReason { get; } /// /// List of reports for this step /// public IReadOnlyList? Reports { get; } /// /// List of jobs this step has spawned /// public IReadOnlyList? SpawnedJobs { get; } /// /// Reports for this jobstep. /// public IReadOnlyDictionary? Properties { get; } } /// /// Extension methods for job steps /// public static class JobStepExtensions { /// /// Determines if a jobstep state is completed, skipped, or aborted. /// /// True if the step is completed, skipped, or aborted public static bool IsPendingState(JobStepState state) { return state != JobStepState.Aborted && state != JobStepState.Completed && state != JobStepState.Skipped; } /// /// Determines if a jobstep is done by checking to see if it is completed, skipped, or aborted. /// /// True if the step is completed, skipped, or aborted public static bool IsPending(this IJobStep step) => IsPendingState(step.State); /// /// Determine if a step should be timed out /// /// /// /// public static bool HasTimedOut(this IJobStep step, DateTime utcNow) { if (step.State == JobStepState.Running && step.StartTimeUtc != null) { TimeSpan elapsed = utcNow - step.StartTimeUtc.Value; if (elapsed > TimeSpan.FromHours(24.0)) { return true; } } return false; } } /// /// Cumulative timing information to reach a certain point in a job /// public class TimingInfo { /// /// Wait time on the critical path /// public TimeSpan? TotalWaitTime { get; set; } /// /// Sync time on the critical path /// public TimeSpan? TotalInitTime { get; set; } /// /// Duration to this point /// public TimeSpan? TotalTimeToComplete { get; set; } /// /// Average wait time to this point /// public TimeSpan? AverageTotalWaitTime { get; set; } /// /// Average sync time to this point /// public TimeSpan? AverageTotalInitTime { get; set; } /// /// Average duration to this point /// public TimeSpan? AverageTotalTimeToComplete { get; set; } /// /// Individual step timing information /// public IJobStepTiming? StepTiming { get; set; } /// /// Constructor /// public TimingInfo() { TotalWaitTime = TimeSpan.Zero; TotalInitTime = TimeSpan.Zero; TotalTimeToComplete = TimeSpan.Zero; AverageTotalWaitTime = TimeSpan.Zero; AverageTotalInitTime = TimeSpan.Zero; AverageTotalTimeToComplete = TimeSpan.Zero; } /// /// Copy constructor /// /// The timing info object to copy from public TimingInfo(TimingInfo other) { TotalWaitTime = other.TotalWaitTime; TotalInitTime = other.TotalInitTime; TotalTimeToComplete = other.TotalTimeToComplete; AverageTotalWaitTime = other.AverageTotalWaitTime; AverageTotalInitTime = other.AverageTotalInitTime; AverageTotalTimeToComplete = other.AverageTotalTimeToComplete; } /// /// Modifies this timing to wait for another timing /// /// The other node to wait for public void WaitFor(TimingInfo other) { if (TotalTimeToComplete != null) { if (other.TotalTimeToComplete == null || other.TotalTimeToComplete.Value > TotalTimeToComplete.Value) { TotalInitTime = other.TotalInitTime; TotalWaitTime = other.TotalWaitTime; TotalTimeToComplete = other.TotalTimeToComplete; } } if (AverageTotalTimeToComplete != null) { if (other.AverageTotalTimeToComplete == null || other.AverageTotalTimeToComplete.Value > AverageTotalTimeToComplete.Value) { AverageTotalInitTime = other.AverageTotalInitTime; AverageTotalWaitTime = other.AverageTotalWaitTime; AverageTotalTimeToComplete = other.AverageTotalTimeToComplete; } } } /// /// Waits for all the given timing info objects to complete /// /// Other timing info objects to wait for public void WaitForAll(IEnumerable others) { foreach (TimingInfo other in others) { WaitFor(other); } } /// /// Constructs a new TimingInfo object which represents the last TimingInfo to finish /// /// TimingInfo objects to wait for /// New TimingInfo instance public static TimingInfo Max(IEnumerable others) { TimingInfo timingInfo = new TimingInfo(); timingInfo.WaitForAll(others); return timingInfo; } /// /// Copies this info to a repsonse object /// public void CopyToResponse(GetTimingInfoResponse response) { response.TotalWaitTime = (float?)TotalWaitTime?.TotalSeconds; response.TotalInitTime = (float?)TotalInitTime?.TotalSeconds; response.TotalTimeToComplete = (float?)TotalTimeToComplete?.TotalSeconds; response.AverageTotalWaitTime = (float?)AverageTotalWaitTime?.TotalSeconds; response.AverageTotalInitTime = (float?)AverageTotalInitTime?.TotalSeconds; response.AverageTotalTimeToComplete = (float?)AverageTotalTimeToComplete?.TotalSeconds; } } /// /// Step completion information /// public class JobStepsCompletionInfo { /// /// The total count of steps passed within a job. /// public int StepPassCount { get; set; } /// /// The total count of steps with a warning result within a job. /// public int StepWarningCount { get; set; } /// /// The total count of steps with a failure result within a job. /// public int StepFailureCount { get; set; } /// /// The total number of steps within a job. /// public int StepTotalCount { get; set; } /// /// The total step time for the job, in seconds. /// public float JobStepsTotalTime { get; set; } /// /// The pass count as compared to the total step count. /// public float PassRatio => StepTotalCount == 0 ? 0 : StepPassCount / (float)StepTotalCount; /// /// The pass and warning count as compared to the total step count. /// public float PassWithWarningRatio => StepTotalCount == 0 ? 0 : (StepPassCount + StepWarningCount) / (float)StepTotalCount; /// /// The warning count as compared to the total step count. /// public float WarningRatio => StepTotalCount == 0 ? 0 : StepWarningCount / (float)StepTotalCount; /// /// The failure count as compared to the total step count. /// public float FailureRatio => StepTotalCount == 0 ? 0 : StepFailureCount / (float)StepTotalCount; } /// /// Information about a chained job trigger /// public interface IChainedJob { /// /// The target to monitor /// public string Target { get; } /// /// The template to trigger on success /// public TemplateId TemplateRefId { get; } /// /// The triggered job id /// public JobId? JobId { get; } /// /// Whether to run the latest change, or default change for the template, when starting the new job. Uses same change as the triggering job by default. /// public bool UseDefaultChangeForTemplate { get; } } /// /// Report for a job or jobstep /// public interface IJobReport { /// /// Name of the report /// string Name { get; } /// /// Where to render the report /// JobReportPlacement Placement { get; } /// /// The artifact id /// ArtifactId? ArtifactId { get; } /// /// Inline data for the report /// string? Content { get; } } /// /// Implementation of IReport /// public class JobReport : IJobReport { /// public string Name { get; set; } = String.Empty; /// public JobReportPlacement Placement { get; set; } /// public ArtifactId? ArtifactId { get; set; } /// public string? Content { get; set; } } }