Modules/businessdev.ALbuild.Core/Public/Move-ALbuildDirectory.ps1
|
function Move-ALbuildDirectory { <# .SYNOPSIS Renames a directory into its final place, retrying the transient Windows lock that makes an otherwise-atomic publish fail at random. .DESCRIPTION ALbuild publishes everything expensive the same way: write into a staging folder on the same volume, then rename it into place, so a reader never sees a half-written result. The rename itself is atomic - but ISSUING it is not reliable on Windows. A file that was written moments ago can still be held briefly by a virus scanner, the search indexer or a lazily-closed handle, and Directory.Move then fails with "Access to the path ... is denied" on the SOURCE. That is not theoretical. Hunting a reported test flake across repeated local runs showed the same failure at three different call sites - Get-BcArtifact ('.staging'), Get-BcArtifactSymbolFolder ('.albsym-*') and Install-ALbuildModuleVersion ('.albuild-staging-*') - roughly once every six runs. On a build agent that is not a flaky test, it is a failed artifact download or a failed module install, from a condition that would have passed a moment later. So the rename is retried with a growing delay. Two cases are deliberately NOT retried: * The destination already exists. That means a concurrent publisher won the race, which is a state the callers already handle (keep theirs, or replace a partial one). Retrying would only delay that decision by the whole backoff budget. * Anything that is not a lock: a missing source, a cross-volume move, an invalid path. Those do not get better by waiting, and hiding them behind seconds of retries makes them harder to diagnose, not easier. When every attempt fails the error names the source, the destination, the number of attempts and the original message, so a build log says what actually happened instead of "access denied". .PARAMETER Path The staging directory to publish. .PARAMETER Destination The final directory name. Must be on the same volume for the rename to be atomic. .PARAMETER RetryCount How many attempts in total (default 5). With the default delay that spans about 3 seconds. .PARAMETER InitialDelayMilliseconds Delay before the second attempt; it doubles each time (default 100 -> 100/200/400/800 ms). .EXAMPLE Move-ALbuildDirectory -Path $staging -Destination $targetFolder #> [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseShouldProcessForStateChangingFunctions', '', Justification = 'Deliberately no ShouldProcess: the callers own that decision and already gate the publish. A -WhatIf leaking in here would silently skip the rename and leave the staging folder to be deleted by the caller''s finally block, losing the result.')] [CmdletBinding()] [OutputType([void])] param( [Parameter(Mandatory, Position = 0)] [ValidateNotNullOrEmpty()] [string] $Path, [Parameter(Mandatory, Position = 1)] [ValidateNotNullOrEmpty()] [string] $Destination, [ValidateRange(1, 20)] [int] $RetryCount = 5, [ValidateRange(1, 10000)] [int] $InitialDelayMilliseconds = 100 ) # Checked up front, because both of these are IOException subclasses and would otherwise be mistaken # for a lock and retried through the whole backoff budget - turning an instant, obvious failure into a # fifteen-second one that says nothing new at the end. if (-not (Test-Path -LiteralPath $Path)) { throw "Cannot publish '$Path' as '$Destination': the source directory does not exist." } $sourceRoot = [System.IO.Path]::GetPathRoot([System.IO.Path]::GetFullPath($Path)) $targetRoot = [System.IO.Path]::GetPathRoot([System.IO.Path]::GetFullPath($Destination)) if ($sourceRoot -and $targetRoot -and $sourceRoot -ne $targetRoot) { throw ("Cannot publish '$Path' as '$Destination': a directory rename cannot cross volumes " + "('$sourceRoot' vs '$targetRoot'). Stage on the SAME volume as the destination - that is what " + 'makes the publish atomic in the first place.') } $delay = $InitialDelayMilliseconds for ($attempt = 1; $attempt -le $RetryCount; $attempt++) { try { [System.IO.Directory]::Move($Path, $Destination) if ($attempt -gt 1) { Write-ALbuildLog -Level Information "Published '$Destination' on attempt $attempt (the first $($attempt - 1) hit a transient lock)." } return } catch { # PowerShell wraps a .NET method exception; the useful one is inside. $inner = $_.Exception while ($inner.InnerException) { $inner = $inner.InnerException } # A destination that already exists is a lost race, not a lock - hand it back immediately. if (Test-Path -LiteralPath $Destination) { throw } # A lock is UnauthorizedAccessException, or a plain IOException ("being used by another # process"). NOT the IOException subclasses that mean "it is not there" - those inherit from # IOException too, and waiting for them is pure delay. $isLock = ($inner -is [System.UnauthorizedAccessException]) -or (($inner -is [System.IO.IOException]) -and -not ($inner -is [System.IO.DirectoryNotFoundException]) -and -not ($inner -is [System.IO.FileNotFoundException]) -and -not ($inner -is [System.IO.PathTooLongException])) if (-not $isLock -or $attempt -ge $RetryCount) { throw ("Could not publish '$Path' as '$Destination' after $attempt attempt(s): " + "$($inner.Message) (a directory rename can fail transiently on Windows while a virus " + 'scanner or indexer still holds a freshly written file; if this persists, the cause is ' + 'not transient).') } Write-ALbuildLog -Level Verbose "Publishing '$Destination' failed on attempt $attempt ($($inner.Message)); retrying in $delay ms." Start-Sleep -Milliseconds $delay $delay *= 2 } } } |