Files
wjones 3f4e1425a2 Refactor pg_backup.sh to track newest backup files and update deletion logic
- Changed variable names from oldest to newest for clarity.
- Updated logic to find the newest backup file instead of the oldest.
- Adjusted logging to reflect the change in backup file tracking.
- Modified file deletion criteria to use the newest backup timestamp.

Add batch_rsync.ps1 for parallel rsync operations in PowerShell

- Implemented a PowerShell script to launch multiple rsync processes concurrently.
- Added features for auto-tuning worker count based on file size and destination type.
- Included verbose output options and support for dry runs.
- Ensured compatibility with Windows paths and rsync arguments.

Enhance pyrsync.py with recursive file handling and auto-tuning

- Added support for balanced recursive file chunking in rsync operations.
- Implemented auto-tuning of worker count based on average file size.
- Improved output handling and error reporting for rsync tasks.
- Updated command-line argument parsing for enhanced usability.

Create sync_data.ps1 for FreeFileSync job management

- Developed a PowerShell script to manage synchronization jobs using FreeFileSync.
- Implemented logging for synchronization processes and error handling.
- Added functionality to copy HTML reports generated by FreeFileSync.
- Included an option to force shutdown regardless of synchronization success.
2026-06-03 09:12:20 -04:00

537 lines
19 KiB
PowerShell

#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=<tmpfile>
# 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<string>)
# 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=<tmpfile>', $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