Private/Invoke-HDTStepAttempt.ps1
|
function Invoke-HDTStepAttempt { <# .SYNOPSIS Runs one step to its retry limit and reports the outcome in one shape. .DESCRIPTION Between the loop and Invoke-HDTStep sits everything that is true of EVERY step and of no step type in particular: how many attempts it took, how long it took, whether it overran its timeout, and which of the three failure classes its failure belongs to. ATTEMPTS run from 1 to 1 + the step's retry count. The delay before attempt N is fixed DelaySecond exponential DelaySecond * 2^(N-2) so an exponential policy with delaySeconds: 1 waits 1s, 2s, 4s. The wait is taken through the injected IClock, never Start-Sleep, which is what lets a twenty-minute backoff policy be proven in milliseconds. A CONFIGURATION FAILURE IS NEVER RETRIED. Retrying bad authoring spends a deployment's time three times over and buries the message that would have fixed it under two more copies of itself. The class is read from the thrown error AND from the result's Data, because a step that refused to wipe an ambiguous disk returns that refusal as a Failed result carrying an errorId rather than throwing it - the step contract requires a result, not an exception. AN EXCEPTION IS CAUGHT HERE, not by Invoke-HDTStep. The dispatcher deliberately does not catch, because classifying, retrying and honouring continueOnError all belong to the caller that owns the state document. This is that caller's other half: a step that threw becomes a Failed result carrying the message and the class, so the loop branches on Status and nothing else. TIMEOUTS ARE MEASURED, NOT ENFORCED. `timeoutMinutes` is passed to the step - only CommandLine can enforce it, through IProcessService - and this measures the elapsed time afterwards against the same bound. A step that overran becomes a Failed result with TimedOut set even if it returned success, because a step that took an hour when it was given a minute did not do what the sequence asked. HDT DOES NOT PREEMPT A SYNCHRONOUS STEP. One that hangs in-process hangs the sequence, exactly as MDT's does. Running steps in a child runspace to make timeouts pre-emptive is a post-v1 idea, and ForEach-Object -Parallel is not available to an engine that must run under Windows PowerShell 5.1. .PARAMETER Step A flattened step from Import-HDTSequenceDocument. Its Retry and TimeoutMinutes are the policy this function applies. .PARAMETER Context A New-HDTExecutionContext context. Its Attempt is set before each attempt, so a step can tell a retry from a first try. .PARAMETER StepType A pre-built Get-HDTStepType registry, discovered once per run by the loop. .OUTPUTS System.Management.Automation.PSCustomObject: Status, ExitCode, Message, Data, Attempt, DurationMs, TimedOut, FailureClass. .EXAMPLE $outcome = Invoke-HDTStepAttempt -Step $step -Context $context -StepType $registry #> [CmdletBinding()] [OutputType([pscustomobject])] param( [Parameter(Mandatory = $true, Position = 0)] [ValidateNotNull()] [object] $Step, [Parameter(Mandatory = $true, Position = 1)] [ValidateNotNull()] [object] $Context, [Parameter()] [AllowNull()] [object[]] $StepType ) Set-StrictMode -Version Latest $ErrorActionPreference = 'Stop' $clock = $Context.Service.Clock $retryCount = 0 $delaySecond = 0 $backoff = 'fixed' if ($null -ne $Step.Retry) { $retryCount = [int] $Step.Retry.Count $delaySecond = [int] $Step.Retry.DelaySecond $backoff = [string] $Step.Retry.Backoff } $maximumAttempt = 1 + $retryCount $timeoutMillisecond = 0 if ([int] $Step.TimeoutMinutes -gt 0) { $timeoutMillisecond = [long] $Step.TimeoutMinutes * 60000 } $attempt = 0 while ($true) { $attempt++ if ($attempt -gt 1 -and $delaySecond -gt 0) { $wait = $delaySecond if ($backoff -eq 'exponential') { $wait = $delaySecond * [math]::Pow(2, $attempt - 2) } $clock.Sleep([int] ($wait * 1000)) } $Context.Attempt = $attempt Write-HDTLog -Context $Context.Log -Event 'step.start' ` -Message ("step {0} '{1}' ({2}) starting, attempt {3} of {4}" -f $Step.Index, $Step.Name, $Step.Type, $attempt, $maximumAttempt) ` -Data ([ordered] @{ index = [int] $Step.Index name = [string] $Step.Name type = [string] $Step.Type attempt = $attempt }) # THE SCREEN, IMMEDIATELY AFTER THE RECORD THAT CHANGED IT. This is the # point where the step a technician is watching becomes the step that is # running - before the work, not after it - so the name on screen is the # thing taking the time rather than the thing that just finished. # # IT READS THE LOG BACK RATHER THAN BEING TOLD. DESIGN 11.1: one source # of truth, so the screen and the log cannot disagree. # # A RUN WITH NO DISPLAY RETURNS BEFORE READING ANYTHING, and nothing # here can fail a deployment - see Update-HDTProgressDisplay. Update-HDTProgressDisplay -Context $Context $thrown = $null $startedUtc = $clock.GetUtcNow() try { $result = Invoke-HDTStep -Step $Step -Context $Context -StepType $StepType } catch { $thrown = $_ $result = New-HDTStepResult -Status Failed -Message ([string] $_.Exception.Message) } $durationMillisecond = [long] (($clock.GetUtcNow()) - $startedUtc).TotalMilliseconds $timedOut = ($timeoutMillisecond -gt 0 -and $durationMillisecond -gt $timeoutMillisecond) if ($timedOut) { # Reported as a failure even when the step said it succeeded: a step # that took an hour when it was given a minute did not do what the # sequence asked. $result = New-HDTStepResult -Status Failed -ExitCode ([int] $result.ExitCode) -Data $result.Data ` -Message ("step {0} '{1}' timed out: it overran its bound of {2} minute(s), taking {3} ms. HDT does not preempt a synchronous step, so it was allowed to finish and its result is reported as a failure." -f $Step.Index, $Step.Name, $Step.TimeoutMinutes, $durationMillisecond) } $failureClass = $null if ([string] $result.Status -eq 'Failed') { # THE RESULT'S DATA IS READ AS WELL AS THE EXCEPTION. A phase 04 step # never lets a refusal escape as a terminating error - the step # contract requires a result whose Status is in the closed set - so # 'HDTAmbiguousTargetError' arrives here in $result.Data, and without # this argument a refusal to wipe a disk would be Transient and would # be retried by a step declaring retry:. $failureClass = Get-HDTFailureClass -ErrorRecord $thrown -ResultData $result.Data -TimedOut:$timedOut } # READ DEFENSIVELY. The engine's own steps return New-HDTStepResult, which # always carries Reenter - but a third-party step type dot-sourced from # Modules\ may return a bare object, and reading a missing property under # Set-StrictMode -Version Latest throws. $reenter = $false if ($null -ne $result.PSObject.Properties['Reenter']) { $reenter = [bool] $result.Reenter } $outcome = [pscustomobject] ([ordered] @{ Status = [string] $result.Status ExitCode = [int] $result.ExitCode Message = [string] $result.Message Data = $result.Data Attempt = $attempt DurationMs = $durationMillisecond TimedOut = $timedOut FailureClass = $failureClass Reenter = $reenter }) if ([string] $result.Status -ne 'Failed') { return $outcome } if ($failureClass -eq 'Configuration') { return $outcome } if ($attempt -ge $maximumAttempt) { return $outcome } Write-HDTLog -Context $Context.Log -Severity Warning ` -Message ("step {0} '{1}' failed on attempt {2} of {3} ({4}), and will be retried" -f $Step.Index, $Step.Name, $attempt, $maximumAttempt, $failureClass) ` -Data ([ordered] @{ index = [int] $Step.Index; attempt = $attempt; failureClass = $failureClass }) } } |