#Requires -Version 7.0 # ============================================================================= # batch_rsync.ps1 — Parallel rsync launcher for Windows PowerShell # ============================================================================= # # KEY IMPLEMENTATION NOTES # ------------------------ # # Parallelism # Uses RunspacePool + [PowerShell]::Create() so multiple rsync processes # run concurrently without blocking the main thread. All runspace script # blocks are self-contained (no closures over outer variables). # # As-completed result collection # WaitHandle.WaitAny() polls the async handles so results are printed as # soon as each task finishes, matching the behaviour of Python's as_completed(). # # --files-from (recursive-files mode) # rsync is fed a NUL-delimited temp file via --from0 --files-from= # rather than stdin, because stdin is not reliably available inside a # runspace. The temp file is always cleaned up in a finally block. # # Path handling # All source paths are converted from Windows backslashes to forward slashes # before being passed to rsync, which is required for MSYS2/Cygwin rsync # to interpret them correctly (e.g. C:/data/... not C:\data\...). # # Argument passing to rsync # Uses ProcessStartInfo.ArgumentList (System.Collections.Generic.List) # rather than a single argument string. This avoids quoting/escaping issues # with paths that contain spaces or special characters. # Requires .NET 5+ — hence the #Requires -Version 7.0 constraint. # # Auto-tune worker selection # Workers are derived from average file size and destination type (local vs # remote). Smaller files favour higher concurrency; large files and remote # destinations are throttled to avoid saturating the link or dest I/O. # The -Workers value is always treated as an upper bound. # # Size-balanced chunk partitioning (recursive-files mode) # Files are sorted largest-first and distributed across worker buckets with # a greedy least-loaded algorithm to minimise the slowest-worker stall. # # Remote destination detection # Heuristics check for rsync://, :: (daemon), Windows drive letters, UNC # paths, and user@host:/path patterns to decide whether the destination is # local or remote without requiring an actual connection. # # Verbose output # Uses PowerShell's built-in -Verbose switch (CmdletBinding) so there is no # custom flag. Pass -Verbose on the command line to enable detailed output. # # Requirements # - PowerShell 7.0+ (for ProcessStartInfo.ArgumentList via .NET 5+) # - rsync on PATH (MSYS2, Cygwin, or WSL interop on Windows) # # ============================================================================= <# .SYNOPSIS Parallel rsync launcher for Windows PowerShell. .DESCRIPTION Runs multiple rsync invocations in parallel using either top-level entries or balanced recursive file chunks. Requires rsync on PATH (e.g. via MSYS2 or Cygwin on Windows). Paths are converted to forward-slash notation for rsync compatibility. MSYS2 rsync accepts Windows absolute paths using forward slashes (C:/path/...). .EXAMPLE # Basic sync (recursive-files mode, auto worker count): .\batch_rsync.ps1 C:\data user@host:/dest/ .EXAMPLE # Auto-tune workers based on file size and local vs remote destination: .\batch_rsync.ps1 C:\data user@host:/dest/ -AutoTune .EXAMPLE # Limit to a specific number of parallel workers: .\batch_rsync.ps1 C:\data D:\backup -Workers 8 .EXAMPLE # Use top-level mode (one rsync process per immediate source entry): .\batch_rsync.ps1 C:\data D:\backup -Mode top-level .EXAMPLE # Dry run (show what would be transferred without doing it): .\batch_rsync.ps1 C:\data user@host:/dest/ -DryRun .EXAMPLE # Verbose output (command templates, task previews, elapsed time): .\batch_rsync.ps1 C:\data D:\backup -Verbose .EXAMPLE # Show rsync output for all tasks: .\batch_rsync.ps1 C:\data D:\backup -ShowRsyncOutput .EXAMPLE # Show rsync output only when a task fails: .\batch_rsync.ps1 C:\data user@host:/dest/ -ShowRsyncOutputOnFailure .EXAMPLE # Full-featured (auto-tune, verbose, show on failure): .\batch_rsync.ps1 C:\data user@host:/dest/ -AutoTune -Verbose -ShowRsyncOutputOnFailure .EXAMPLE # Override rsync flags entirely: .\batch_rsync.ps1 C:\data D:\backup -RsyncArgs '-az --checksum' #> [CmdletBinding()] param ( [Parameter(Mandatory, Position = 0)] [string]$Src, [Parameter(Mandatory, Position = 1)] [string]$Dest, [ValidateRange(1, [int]::MaxValue)] [int]$Workers = [Math]::Min(8, [Environment]::ProcessorCount), [ValidateSet('recursive-files', 'top-level')] [string]$Mode = 'recursive-files', [string]$RsyncArgs = '-a --partial --info=stats2', [switch]$AutoTune, [switch]$ShowRsyncOutput, [switch]$ShowRsyncOutputOnFailure, [switch]$DryRun ) Set-StrictMode -Version Latest $ErrorActionPreference = 'Stop' #region Helper functions function Format-Bytes { param([double]$Bytes) $units = @('B', 'KB', 'MB', 'GB', 'TB') $value = $Bytes foreach ($unit in $units) { if ($value -lt 1024 -or $unit -eq 'TB') { return '{0:F1}{1}' -f $value, $unit } $value /= 1024 } return '{0:F1}B' -f $Bytes } function Test-RemoteDestination { param([string]$Destination) if ($Destination -match '^rsync://') { return $true } if ($Destination -match '::') { return $true } # Windows drive letters and UNC paths are local if ($Destination -match '^[a-zA-Z]:[/\\]') { return $false } if ($Destination -match '^\\\\') { return $false } # user@host:/path or host:/path patterns are remote return $Destination -match '^[^:/\\]+@?[^:/\\]*:[^/]' } function Get-AutoTunedWorkers { param( [int] $TotalUnits, [double]$AvgSizeBytes, [bool] $IsRemote, [int] $CpuCount, [int] $MaxWorkers ) $cpu = [Math]::Max(1, $CpuCount) $MB1 = 1 * 1024 * 1024 $MB32 = 32 * 1024 * 1024 $MB256 = 256 * 1024 * 1024 if ($AvgSizeBytes -lt $MB1) { $base = [Math]::Min(32, $cpu * 4) } elseif ($AvgSizeBytes -lt $MB32) { $base = [Math]::Min(16, $cpu * 2) } elseif ($AvgSizeBytes -lt $MB256) { $base = [Math]::Min(8, $cpu) } else { $base = [Math]::Min(4, [Math]::Max(2, [int]($cpu / 2))) } if ($IsRemote) { $base = [Math]::Max(2, [Math]::Min($base, 8)) } else { $base = [Math]::Min($base + 2, 24) } $tuned = [Math]::Max(1, [Math]::Min($base, $TotalUnits)) return [Math]::Min($tuned, $MaxWorkers) } function Get-RecursiveFileList { param([string]$SrcDir) $root = $SrcDir.TrimEnd('\', '/') Get-ChildItem -LiteralPath $root -Recurse -File | ForEach-Object { $_.FullName.Substring($root.Length).TrimStart('\', '/') } } function Get-SizedFiles { param([string]$SrcDir, [string[]]$RelFiles) foreach ($rel in $RelFiles) { $abs = Join-Path $SrcDir $rel try { $size = (Get-Item -LiteralPath $abs -ErrorAction Stop).Length } catch { $size = 0 } [PSCustomObject]@{ Size = [long]$size; RelPath = $rel } } } function Get-BalancedChunks { param($SizedFiles, [int]$Workers) $buckets = @(1..$Workers | ForEach-Object { [System.Collections.Generic.List[string]]::new() }) $bucketSizes = [long[]]::new($Workers) foreach ($entry in ($SizedFiles | Sort-Object -Property Size -Descending)) { $minIdx = 0 for ($i = 1; $i -lt $Workers; $i++) { if ($bucketSizes[$i] -lt $bucketSizes[$minIdx]) { $minIdx = $i } } $buckets[$minIdx].Add($entry.RelPath) $bucketSizes[$minIdx] += $entry.Size } $buckets | Where-Object { $_.Count -gt 0 } } #endregion #region Runspace script blocks # These blocks run inside isolated runspaces — all needed logic must be self-contained. $rsyncChunkScriptBlock = { param( [string] $SrcDir, [string[]]$RelFiles, [string] $Dest, [string[]]$RsyncArgsList, [bool] $IsDryRun ) $filesFromTmp = [System.IO.Path]::GetTempFileName() try { # Build a NUL-delimited file list using forward slashes for rsync $lines = $RelFiles | ForEach-Object { $_ -replace '\\', '/' } $allBytes = [System.Collections.Generic.List[byte]]::new() foreach ($line in $lines) { $allBytes.AddRange([System.Text.Encoding]::UTF8.GetBytes($line)) $allBytes.Add([byte]0) } [System.IO.File]::WriteAllBytes($filesFromTmp, $allBytes.ToArray()) $srcRoot = ($SrcDir.TrimEnd('\', '/') -replace '\\', '/') + '/' $cmdArgs = [System.Collections.Generic.List[string]]::new($RsyncArgsList) if ($IsDryRun) { $cmdArgs.Insert(0, '--dry-run') } $cmdArgs.AddRange([string[]]@('--from0', "--files-from=$filesFromTmp", $srcRoot, $Dest)) $psi = [System.Diagnostics.ProcessStartInfo]::new('rsync') $psi.RedirectStandardOutput = $true $psi.RedirectStandardError = $true $psi.UseShellExecute = $false $psi.CreateNoWindow = $true foreach ($arg in $cmdArgs) { $psi.ArgumentList.Add($arg) } try { $proc = [System.Diagnostics.Process]::Start($psi) $stdout = $proc.StandardOutput.ReadToEnd() $stderr = $proc.StandardError.ReadToEnd() $proc.WaitForExit() $exitCode = $proc.ExitCode } catch { $stdout = '' $stderr = "rsync not found on PATH: $($_.Exception.Message)" $exitCode = 127 } return [PSCustomObject]@{ Label = "chunk($($RelFiles.Count) files)" ExitCode = $exitCode Stdout = $stdout Stderr = $stderr } } finally { Remove-Item -LiteralPath $filesFromTmp -Force -ErrorAction SilentlyContinue } } $rsyncItemScriptBlock = { param( [string] $Item, [string] $Dest, [string[]]$RsyncArgsList, [bool] $IsDryRun ) $srcPath = $Item -replace '\\', '/' $cmdArgs = [System.Collections.Generic.List[string]]::new($RsyncArgsList) if ($IsDryRun) { $cmdArgs.Insert(0, '--dry-run') } $cmdArgs.Add($srcPath) $cmdArgs.Add($Dest) $psi = [System.Diagnostics.ProcessStartInfo]::new('rsync') $psi.RedirectStandardOutput = $true $psi.RedirectStandardError = $true $psi.UseShellExecute = $false $psi.CreateNoWindow = $true foreach ($arg in $cmdArgs) { $psi.ArgumentList.Add($arg) } try { $proc = [System.Diagnostics.Process]::Start($psi) $stdout = $proc.StandardOutput.ReadToEnd() $stderr = $proc.StandardError.ReadToEnd() $proc.WaitForExit() $exitCode = $proc.ExitCode } catch { $stdout = '' $stderr = "rsync not found on PATH: $($_.Exception.Message)" $exitCode = 127 } return [PSCustomObject]@{ Label = $Item ExitCode = $exitCode Stdout = $stdout Stderr = $stderr } } #endregion #region Parallel job runner (as-completed using WaitHandle.WaitAny) function Invoke-ParallelJobs { param( [object[]] $Jobs, # PSCustomObjects with .PS and .Handle [bool] $ShowOutput, [bool] $ShowOnFailOnly ) $results = [System.Collections.Generic.List[object]]::new() $pending = [System.Collections.Generic.List[object]]::new() foreach ($j in $Jobs) { $pending.Add($j) } while ($pending.Count -gt 0) { $handles = @($pending | ForEach-Object { $_.Handle.AsyncWaitHandle }) $idx = [System.Threading.WaitHandle]::WaitAny($handles) $job = $pending[$idx] $pending.RemoveAt($idx) $r = $job.PS.EndInvoke($job.Handle)[0] $job.PS.Dispose() $results.Add($r) $status = if ($r.ExitCode -eq 0) { 'OK' } else { "ERR($($r.ExitCode))" } Write-Host "[$status] $($r.Label)" $printOutput = $ShowOutput -and (-not $ShowOnFailOnly -or $r.ExitCode -ne 0) if ($printOutput) { if ($r.Stdout.Trim()) { Write-Host "--- stdout: $($r.Label) ---" Write-Host $r.Stdout.TrimEnd() } if ($r.Stderr.Trim()) { Write-Warning "--- stderr: $($r.Label) ---`n$($r.Stderr.TrimEnd())" } } } return $results } #endregion #region Main $timer = [System.Diagnostics.Stopwatch]::StartNew() $rsyncArgsList = $RsyncArgs -split '\s+' | Where-Object { $_ -ne '' } $srcPath = $Src.TrimEnd('\', '/') $isRemote = Test-RemoteDestination -Destination $Dest $cpuCount = [Environment]::ProcessorCount $showOutput = $ShowRsyncOutput -or $ShowRsyncOutputOnFailure $showOnFail = $ShowRsyncOutputOnFailure -and -not $ShowRsyncOutput if ($ShowRsyncOutput -and $ShowRsyncOutputOnFailure) { Write-Warning 'Both -ShowRsyncOutput and -ShowRsyncOutputOnFailure set; showing output for all tasks.' } $allResults = $null if ($Mode -eq 'recursive-files' -and (Test-Path -LiteralPath $srcPath -PathType Container)) { $relFiles = @(Get-RecursiveFileList -SrcDir $srcPath) if (-not $relFiles -or $relFiles.Count -eq 0) { Write-Error "No files found for: $Src" exit 2 } $sizedFiles = @(Get-SizedFiles -SrcDir $srcPath -RelFiles $relFiles) $totalBytes = ($sizedFiles | Measure-Object -Property Size -Sum).Sum $avgSize = if ($sizedFiles.Count -gt 0) { $totalBytes / $sizedFiles.Count } else { 0 } if ($AutoTune) { $effectiveWorkers = Get-AutoTunedWorkers -TotalUnits $sizedFiles.Count -AvgSizeBytes $avgSize ` -IsRemote $isRemote -CpuCount $cpuCount -MaxWorkers $Workers $destKind = if ($isRemote) { 'remote' } else { 'local' } Write-Host "Auto-tuned workers=$effectiveWorkers ($destKind dest, avg file $(Format-Bytes $avgSize))" } else { $effectiveWorkers = [Math]::Min($Workers, $sizedFiles.Count) } $chunks = @(Get-BalancedChunks -SizedFiles $sizedFiles -Workers $effectiveWorkers) Write-Host "Starting $($chunks.Count) rsync chunk task(s) with $effectiveWorkers worker(s)..." if ($VerbosePreference -ne 'SilentlyContinue') { $srcRoot = ($srcPath -replace '\\', '/') + '/' $previewArgs = (@('rsync') + $rsyncArgsList + @('--from0', '--files-from=', $srcRoot, $Dest)) if ($DryRun) { $previewArgs = @('rsync', '--dry-run') + $previewArgs[1..($previewArgs.Count-1)] } Write-Verbose "Command template: $($previewArgs -join ' ')" } $pool = [RunspaceFactory]::CreateRunspacePool(1, $effectiveWorkers) $pool.Open() $jobs = foreach ($chunk in $chunks) { $ps = [PowerShell]::Create() $ps.RunspacePool = $pool [void]$ps.AddScript($rsyncChunkScriptBlock) [void]$ps.AddParameter('SrcDir', $srcPath) [void]$ps.AddParameter('RelFiles', [string[]]$chunk) [void]$ps.AddParameter('Dest', $Dest) [void]$ps.AddParameter('RsyncArgsList', [string[]]$rsyncArgsList) [void]$ps.AddParameter('IsDryRun', $DryRun.IsPresent) [PSCustomObject]@{ PS = $ps; Handle = $ps.BeginInvoke() } } $allResults = Invoke-ParallelJobs -Jobs $jobs -ShowOutput $showOutput -ShowOnFailOnly $showOnFail $pool.Close() $pool.Dispose() } else { if (Test-Path -LiteralPath $srcPath -PathType Container) { $items = @(Get-ChildItem -LiteralPath $srcPath | ForEach-Object { $_.FullName }) } elseif ($srcPath -match '[*?]') { $items = @(Resolve-Path -Path $srcPath | ForEach-Object { $_.ProviderPath }) } else { $items = @($srcPath) } if (-not $items -or $items.Count -eq 0) { Write-Error "No files found for: $Src" exit 2 } $sizeSamples = foreach ($item in $items) { try { $fi = Get-Item -LiteralPath $item -ErrorAction Stop if ($fi.PSIsContainer) { [long](64 * 1024 * 1024) } else { $fi.Length } } catch { [long]0 } } $totalBytes = ($sizeSamples | Measure-Object -Sum).Sum $avgSize = if ($items.Count -gt 0) { $totalBytes / $items.Count } else { 0 } if ($AutoTune) { $effectiveWorkers = Get-AutoTunedWorkers -TotalUnits $items.Count -AvgSizeBytes $avgSize ` -IsRemote $isRemote -CpuCount $cpuCount -MaxWorkers $Workers $destKind = if ($isRemote) { 'remote' } else { 'local' } Write-Host "Auto-tuned workers=$effectiveWorkers ($destKind dest, avg unit $(Format-Bytes $avgSize))" } else { $effectiveWorkers = [Math]::Min($Workers, $items.Count) } Write-Host "Starting $($items.Count) rsync task(s) with $effectiveWorkers worker(s)..." if ($VerbosePreference -ne 'SilentlyContinue') { Write-Verbose 'Task list preview:' $previewItems = $items | Select-Object -First 10 for ($i = 0; $i -lt $previewItems.Count; $i++) { Write-Verbose (' {0}. {1}' -f ($i + 1), $previewItems[$i]) } if ($items.Count -gt 10) { Write-Verbose " ... and $($items.Count - 10) more" } } $pool = [RunspaceFactory]::CreateRunspacePool(1, $effectiveWorkers) $pool.Open() $jobs = foreach ($item in $items) { if ($VerbosePreference -ne 'SilentlyContinue') { $previewArgs = (@('rsync') + $rsyncArgsList + @(($item -replace '\\', '/'), $Dest)) if ($DryRun) { $previewArgs = @('rsync', '--dry-run') + $previewArgs[1..($previewArgs.Count-1)] } Write-Verbose "Scheduling: $($previewArgs -join ' ')" } $ps = [PowerShell]::Create() $ps.RunspacePool = $pool [void]$ps.AddScript($rsyncItemScriptBlock) [void]$ps.AddParameter('Item', $item) [void]$ps.AddParameter('Dest', $Dest) [void]$ps.AddParameter('RsyncArgsList', [string[]]$rsyncArgsList) [void]$ps.AddParameter('IsDryRun', $DryRun.IsPresent) [PSCustomObject]@{ PS = $ps; Handle = $ps.BeginInvoke() } } $allResults = Invoke-ParallelJobs -Jobs $jobs -ShowOutput $showOutput -ShowOnFailOnly $showOnFail $pool.Close() $pool.Dispose() } #endregion #region Summary $failed = @($allResults | Where-Object { $_.ExitCode -ne 0 }) $elapsed = [Math]::Round($timer.Elapsed.TotalSeconds, 2) Write-Host "`nCompleted: $($allResults.Count) tasks, $($failed.Count) failed in ${elapsed}s." if ($failed.Count -gt 0) { Write-Host 'Failures detail:' foreach ($r in $failed) { Write-Host "--- $($r.Label) (exit $($r.ExitCode)) ---" if ($r.Stdout.Trim()) { Write-Host $r.Stdout.TrimEnd() } if ($r.Stderr.Trim()) { Write-Host $r.Stderr.TrimEnd() } } exit 1 } exit 0 #endregion