Private/DownloadFilesInParallel.ps1

function DownloadFilesInParallel {
    [CmdletBinding()]
    param (
        [Parameter(Mandatory)]
        [System.Collections.ArrayList]
        $FileInfoList,

        [Parameter(Mandatory)]
        [string]
        $OutputPath,

        [Parameter()]
        [int]
        $MaxSimultaneousTransfers = 4
    )

    $ENDED_BITSJOB_STATES = @("Suspended", "Error", "TransientError", "Transferred", "Canceled")
    $ENDED_JOB_STATES = @("Blocked", "Completed", "Disconnected", "Failed", "Stopped", "Suspended")

    try {
        $startTime = Get-Date
        $minutesTaken = 0
        $currentDownloadNumber = 0
        $downloadsFinished = 0
        $totalUpdateFiles = $FileInfoList.Count
        $activeTransfers = New-Object -TypeName "System.Collections.ArrayList"
        $filesLeft = New-Object -TypeName "System.Collections.Queue"

        $FileInfoList | ForEach-Object { $filesLeft.Enqueue($_) }

        while ($filesLeft.Count -gt 0 -or $activeTransfers.Count -gt 0) {
            $elapsedTime = (Get-Date) - $startTime
            $minutesTakenNew = [int]([Math]::Floor($elapsedTime.TotalMinutes))

            if ($minutesTakenNew -gt $minutesTaken) {
                Write-Verbose "Downloading $totalUpdateFiles update file$( if ($totalUpdateFiles -ne 1) { "s" } ), $downloadsFinished update file$( if ($downloadsFinished -ne 1) { "s" } ) downloaded."
                Write-Verbose "Elapsed time: $minutesTakenNew minute$( if ($minutesTakenNew -ne 1) { "s" } )."
                $minutesTaken = $minutesTakenNew
            }

            # Start another transfer if we're below the max simultaneous transfers threshold
            if ($filesLeft.Count -gt 0 -and $activeTransfers.Count -lt $MaxSimultaneousTransfers) {
                $currentDownloadNumber += 1
                $downloadProgress = "$currentDownloadNumber/$totalUpdateFiles"

                $parsedFileInfo = $filesLeft.Dequeue()
                $fileDownloadUri = $parsedFileInfo["Uri"]
                $fileName = $parsedFileInfo["FileName"]
                $fileDownloadPath = Join-Path $OutputPath $fileName -ErrorAction Stop
                $fileDownloadPathTemp = "$fileDownloadPath.tmp"

                if (Test-Path $fileDownloadPath) {
                    Write-Verbose "Skipping already downloaded update file $fileName ($downloadProgress)."
                    continue
                }

                if (Test-Path $fileDownloadPathTemp) {
                    Write-Verbose "Restarting download for incomplete update file $fileName ($downloadProgress)..."

                    try {
                        Remove-Item -Path $fileDownloadPathTemp -ErrorAction Stop
                    } catch {
                        Write-Error -Message "Could not remove incomplete update file ($fileDownloadPathTemp). Please remove this file manually and try again."
                        return
                    }
                } else {
                    Write-Output "Downloading update file $fileName ($downloadProgress)..."
                }

                try {
                    $transferParams = GetBitsTransferSplatBase -Source $fileDownloadUri
                    $newBitsJob = Start-BitsTransfer @transferParams `
                        -DisplayName "Import-WsusUpdate parallel download for $fileName ($downloadProgress)" `
                        -Destination $fileDownloadPathTemp `
                        -Asynchronous `
                        -ErrorAction Stop
                    [void]($activeTransfers.Add(@{
                        "FileName" = $fileName
                        "TempPath" = $fileDownloadPathTemp
                        "Path" = $fileDownloadPath
                        "BitsJob" = $newBitsJob
                    }))

                    # Immediately restart the loop to queue up another job asap.
                    continue
                } catch {
                    Write-Warning "BITS transfer for update file $fileName failed with the following error: $_"
                    Write-Verbose "Full error info:"
                    Write-Verbose ($_ | Format-List -Force | Out-String)
                    Write-Warning "Retrying download once more with Invoke-WebRequest as a fallback..."

                    try {
                        $webRequestParams = GetWebRequestSplatBase -Uri $fileDownloadUri
                        $webRequestParams["OutFile"] = $fileDownloadPathTemp

                        # TODO: Deserialization in job prevents WebSession from working, although this likely won't matter right now.
                        # "Cannot convert the "Microsoft.PowerShell.Commands.WebRequestSession" value of type "Deserialized.Microsoft.PowerShell.Commands.WebRequestSession" to type "Microsoft.PowerShell.Commands.WebRequestSession".
                        $webRequestParams["SessionVariable"] = $null
                        $webRequestParams["WebSession"] = $null

                        $job = Start-Job -Name "Import-WsusUpdate parallel download fallback for $fileName ($downloadProgress)" -ErrorAction Stop -ScriptBlock {
                            [CmdletBinding()]
                            param ()
                            # Hide progress since this is a parallel background download and it will speed up
                            # Invoke-WebRequest.
                            $ProgressPreference = "SilentlyContinue"

                            $webRequestParams = $using:webRequestParams
                            [void](Invoke-WebRequest @webRequestParams -ErrorAction Stop)
                        }
                        [void]($activeTransfers.Add(@{
                            "FileName" = $fileName
                            "TempPath" = $fileDownloadPathTemp
                            "Path" = $fileDownloadPath
                            "WebRequestJob" = $job
                        }))
                    } catch {
                        Write-Warning "Failed to start fallback download for update file $fileName (URI: $fileDownloadUri) with the following error: $_"
                        throw
                    }
                }
            }

            # We don't need to start another job - track progress of our current transfers
            if ($activeTransfers.Count -gt 0) {
                $transferJustFinished = $false

                for ($index = 0; $index -lt $activeTransfers.Count; $index += 1) {
                    $activeTransfer = $activeTransfers[$index]

                    if ($null -ne $activeTransfer["BitsJob"]) {
                        $updateBitsJob = Get-BitsTransfer -JobId $activeTransfer["BitsJob"].JobId -ErrorAction Stop

                        if ($updateBitsJob.JobState -notin $ENDED_BITSJOB_STATES) {
                            continue
                        }

                        if ($updateBitsJob.JobState -ne "Transferred") {
                            Write-Error -Message "Download for update file $( $activeTransfer["FileName"] ) ended unfinished with state $( $updateBitsJob.JobState )." `
                                -Category OperationStopped `
                                -ErrorId "ParallelBITSTransferFailed"
                            return
                        }

                        Complete-BitsTransfer -BitsJob $activeTransfer["BitsJob"] -ErrorAction Stop
                    } elseif ($null -ne $activeTransfer["WebRequestJob"]) {
                        $jobState = $activeTransfer["WebRequestJob"]

                        if ($jobState.State -notin $ENDED_JOB_STATES) {
                            continue
                        }

                        if ($jobState.State -ne "Completed") {
                            Write-Error -Message "Fallback download for update file $( $activeTransfer["FileName"] ) ended unfinished with state $( $jobState.State )." `
                                -Category OperationStopped `
                                -ErrorId "ParallelInvokeWebRequestTransferFailed"
                            return
                        }

                        Remove-Job -Job $activeTransfer["WebRequestJob"] -ErrorAction SilentlyContinue -Confirm:$false
                    } else {
                        throw "Unexpected error: BitsJob and WebRequestJob were both null."
                    }

                    Write-Verbose "Finished downloading update file $( $activeTransfer["FileName"] )..."
                    Write-Verbose "Moving finished download to its proper path $( $activeTransfer["Path"] )."
                    Move-Item -Path $activeTransfer["TempPath"] -Destination $activeTransfer["Path"] -ErrorAction Stop
                    $downloadsFinished += 1
                    $activeTransfers.RemoveAt($index)
                    $index -= 1
                    $transferJustFinished = $true
                }

                if ($transferJustFinished) {
                    # Skip sleep and start new job immediately
                    continue
                }
            }

            # Check for progress updates every so often
            Start-Sleep -Milliseconds 500
        }
    } finally {
        if ($activeTransfers.Count -gt 0) {
            Write-Verbose "Cleaning up $( $activeTransfers.Count ) active transfer$( if ( $activeTransfers.Count -gt 1 ) { "s" } )."

            while ($activeTransfers.Count -gt 0) {
                if ($null -ne $activeTransfers[0]["BitsJob"]) {
                    Remove-BitsTransfer -BitsJob $activeTransfers[0]["BitsJob"] -ErrorAction SilentlyContinue
                } elseif ($null -ne $activeTransfers[0]["WebRequestJob"]) {
                    [void](Stop-Job -Job $activeTransfers[0]["WebRequestJob"] -ErrorAction SilentlyContinue -Confirm:$false)
                    Remove-Job -Job $activeTransfers[0]["WebRequestJob"] -ErrorAction SilentlyContinue -Confirm:$false
                } else {
                    Write-Verbose "Skipping removal of lingering transfer as both BitsJob and WebRequestJob were null."
                }

                $activeTransfers.RemoveAt(0)
            }
        }
    }
}

# Copyright (c) 2023 AJ Tek Corporation. All Rights Reserved.