备份前空间预估;manifest 的 archive 字段只在文件真的存在时才写
需求
- 写满盘这件事只做一件事:**备份前预测本次所需大小,并提示用户存储够不够**,
不再往"更复杂的占用控制"方向做。
实现
- Backup.ps1 新增只读的"备份前空间预估",在动手之前按清单顺序模拟一遍:
* 逐个条目枚举源目录得到真实源大小/文件数,并读取现有归档大小;
* 沿用主循环那套"源未更新就跳过"的判断,所以列出来的就是**本次真的会重打**的条目;
* 估算模型:临时归档写完时旧归档还在,那一刻占用 = 当前累计净增量 + 本次预估;
原子替换后净增量 = 预估 − 旧归档大小;峰值取整个过程的最大值;
* 预估归档大小:有历史归档取 min(源大小, 旧归档 × 1.3),没有则按"不压缩"的悲观值;
* 打印:可用空间、要重打的条目数(及跳过/缺源的数量)、逐条目明细(前 15 条)、
预计峰值新增与净增量,最后给一句结论——"空间足够"或"空间可能不够!…差 Z GB"。
* 不够时**只告警、不中断**:真正放不下的条目仍由逐条目守卫跳过。
- 新增 Sync-BaknretManifestArchive(Common.psm1,Backup/Restore 都在写 manifest 前调用):
维持不变式"manifest 里写了 archive 的记录,磁盘上就一定有那个文件",
把指向不存在归档的 archive 字段清空,但保留 source / action / 历史计数。
这样"源不存在的条目"不会再让 Restore 反复报"manifest 记录的归档不存在",
人工删掉归档(例如把它并进别的条目)之后记录也会自我纠正。
整理(本机备份集)
- 按用户确认,删除了已被 scoop.7z 覆盖的 scoop-config.7z 与 scoop-persist.7z
(删除前先 7z t 复验 scoop.7z:233 MB / 29478 项 / 顶层 [persist, scoop]),
并清掉 manifest 里这两条历史记录;24 条记录的 archive 现在全部存在于磁盘上。
测试
- Pester 88 项、零依赖单元 49 项、端到端 23 项,全部通过。
新增覆盖:对象数组名录、两种写法 × :+/-、同名目录拒绝执行、行尾 # 说明、
孤儿审计(含"manifest 里还有历史记录"的说明)、archive 字段一致性、空间预估输出。
This commit is contained in:
1 parent
43fa4e52dd
commit
7173e8ae10
5 files changed
+244
-4
No files matched your search
+117
@@ -396,6 +396,117 @@ function Invoke-BackupItem {
|
||||
}
|
||||
}
|
||||
|
||||
# ============================================================================
|
||||
# 备份前空间预估(只读,不写任何东西)
|
||||
# ============================================================================
|
||||
# 只做一件事:动手之前告诉用户"这次大概要写多少、盘够不够"。
|
||||
# 不做更复杂的占用控制 —— 真正拦住某个条目的是主循环里的逐条目守卫。
|
||||
#
|
||||
# 模型(按清单顺序模拟一遍):
|
||||
# * 每个要重打的条目会先写一份**临时**归档,这时旧归档还在,所以那一刻占用的
|
||||
# 是"当前累计净增量 + 本次预估";
|
||||
# * 原子替换之后,本次净增量 = 预估 - 现有归档大小(换成更小的归档会把空间还回来)。
|
||||
# 于是:峰值新增 = max_i( 第 i 项之前的累计净增量 + 第 i 项的预估大小 )。
|
||||
$spacePlan = @()
|
||||
$spaceSkipped = 0
|
||||
$spaceNoSource = 0
|
||||
|
||||
foreach ($planLine in $lines) {
|
||||
$planItem = ConvertFrom-BackupListLine -Line $planLine
|
||||
if (-not $planItem) { continue }
|
||||
|
||||
$planDisplayPath = $planItem.Path
|
||||
$planResolved = Resolve-BackupEntry -Entry $planItem -CatalogPath $catalogPath -MaxDepth $script:Config.CatalogMaxDepth
|
||||
if (-not $planResolved.BaseName) { continue }
|
||||
if (-not (Test-ItemSelected -DisplayPath $planDisplayPath -BaseName $planResolved.BaseName)) { continue }
|
||||
if ($planResolved.Blocking) { continue }
|
||||
|
||||
$planSources = @($planResolved.Sources | Where-Object { Test-Path -LiteralPath $_.SourcePath })
|
||||
if ($planSources.Count -eq 0) { $spaceNoSource++; continue }
|
||||
|
||||
$planSourceBytes = [int64]0
|
||||
$planSourceFiles = 0
|
||||
$planLatest = $null
|
||||
foreach ($planSource in $planSources) {
|
||||
$planSummary = Get-FolderSummary -FolderPath $planSource.SourcePath
|
||||
$planSourceBytes += [int64]$planSummary.TotalSize
|
||||
$planSourceFiles += [int]$planSummary.FileCount
|
||||
if ($planSummary.LatestModifiedTime -and (-not $planLatest -or $planSummary.LatestModifiedTime -gt $planLatest)) {
|
||||
$planLatest = $planSummary.LatestModifiedTime
|
||||
}
|
||||
}
|
||||
|
||||
$planArchiveName = $planResolved.BaseName + $tool.Extension
|
||||
$planArchivePath = Join-Path $BackupDir $planArchiveName
|
||||
$planExistingItem = if (Test-Path -LiteralPath $planArchivePath) { Get-Item -LiteralPath $planArchivePath } else { $null }
|
||||
$planExistingBytes = if ($planExistingItem) { [int64]$planExistingItem.Length } else { [int64]0 }
|
||||
|
||||
# 与主循环同一套判断:源没更新就不会重打
|
||||
if (-not $Force -and $planExistingItem -and $planLatest -and $planLatest -le $planExistingItem.LastWriteTime) {
|
||||
$spaceSkipped++
|
||||
continue
|
||||
}
|
||||
|
||||
$planEstimate = if ($planExistingBytes -gt 0) {
|
||||
[int64][math]::Min([double]$planSourceBytes, [double]$planExistingBytes * 1.3)
|
||||
} else {
|
||||
# 没有历史归档可比时按"完全不压缩"的悲观值估,宁可报多不报少
|
||||
$planSourceBytes
|
||||
}
|
||||
|
||||
$spacePlan += [pscustomobject]@{
|
||||
Name = $planResolved.BaseName
|
||||
Source = $planDisplayPath
|
||||
Files = $planSourceFiles
|
||||
SourceBytes = $planSourceBytes
|
||||
Existing = $planExistingBytes
|
||||
Estimate = $planEstimate
|
||||
}
|
||||
}
|
||||
|
||||
$freeNowGB = Get-BaknretFreeSpaceGB -Path $BackupDir
|
||||
|
||||
if ($spacePlan.Count -eq 0) {
|
||||
Write-Log '备份前空间预估:本次没有需要重打的条目(源未更新或源不存在),不会写入新归档' -Level INFO
|
||||
} else {
|
||||
$spacePeak = [double]0
|
||||
$spaceCumulative = [double]0
|
||||
foreach ($plan in $spacePlan) {
|
||||
$spacePeak = [math]::Max($spacePeak, $spaceCumulative + $plan.Estimate)
|
||||
$spaceCumulative += ($plan.Estimate - $plan.Existing)
|
||||
}
|
||||
$peakGB = $spacePeak / 1GB
|
||||
$netGB = $spaceCumulative / 1GB
|
||||
$estimateGB = ((($spacePlan | Measure-Object -Property Estimate -Sum).Sum)) / 1GB
|
||||
$existingGB = ((($spacePlan | Measure-Object -Property Existing -Sum).Sum)) / 1GB
|
||||
|
||||
Write-Log '==== 备份前空间预估(只读)====' -Level INFO
|
||||
Write-Log (" 目标卷可用空间:{0} GB" -f $freeNowGB)
|
||||
Write-Log (" 本次要重打 {0} 个条目(另有 {1} 个源未更新会跳过、{2} 个源不存在)" -f $spacePlan.Count, $spaceSkipped, $spaceNoSource)
|
||||
Write-Log (" 新归档合计约 {0} GB;其中会替换掉的旧归档 {1} GB" -f [math]::Round($estimateGB, 2), [math]::Round($existingGB, 2))
|
||||
|
||||
foreach ($plan in ($spacePlan | Sort-Object Estimate -Descending | Select-Object -First 15)) {
|
||||
Write-Log (" - {0,-22} 源 {1,8:N1} MB / {2,6} 文件 现有 {3,7:N1} MB 预估 {4,7:N1} MB" -f `
|
||||
$plan.Name, ($plan.SourceBytes / 1MB), $plan.Files, ($plan.Existing / 1MB), ($plan.Estimate / 1MB))
|
||||
}
|
||||
if ($spacePlan.Count -gt 15) {
|
||||
Write-Log (" …… 另有 {0} 个条目未逐条列出" -f ($spacePlan.Count - 15))
|
||||
}
|
||||
|
||||
Write-Log (" 预计峰值新增占用:{0} GB(全程净增量 {1} GB)" -f [math]::Round($peakGB, 2), [math]::Round($netGB, 2))
|
||||
|
||||
if ($freeNowGB -lt 0) {
|
||||
Write-Log ' 结论:读不到目标卷可用空间,请自行确认是否够用' -Level WARN
|
||||
} elseif ($peakGB -le $freeNowGB) {
|
||||
Write-Log (" 结论:空间足够(预计用 {0} GB / 可用 {1} GB)" -f [math]::Round($peakGB, 2), $freeNowGB) -Level INFO
|
||||
} else {
|
||||
Write-Log (" 结论:空间可能不够!预计需要 {0} GB,可用只有 {1} GB,差 {2} GB" -f `
|
||||
[math]::Round($peakGB, 2), $freeNowGB, [math]::Round($peakGB - $freeNowGB, 2)) -Level WARN
|
||||
Write-Log ' 仍会继续执行:真正放不下的条目会被逐条目守卫跳过。建议先腾空间,或用 -Only / -Skip 分批备份。' -Level WARN
|
||||
}
|
||||
Write-Log '============================' -Level INFO
|
||||
}
|
||||
|
||||
# ============================================================================
|
||||
# 主流程
|
||||
# ============================================================================
|
||||
@@ -662,6 +773,12 @@ foreach ($line in $lines) {
|
||||
if ($DryRun) {
|
||||
Write-Log '试运行:manifest 与归档都不会被写入' -Level INFO
|
||||
} else {
|
||||
# manifest 里写了 archive 的记录,磁盘上就必须真有那个文件
|
||||
$clearedArchiveFields = Sync-BaknretManifestArchive -Manifest $manifest -BackupDir $BackupDir
|
||||
if ($clearedArchiveFields.Count -gt 0) {
|
||||
Write-Log ("已清空 {0} 条记录里指向不存在归档的 archive 字段:{1}" -f $clearedArchiveFields.Count, ($clearedArchiveFields -join '、')) -Level WARN
|
||||
}
|
||||
|
||||
Write-BaknretManifest -Path $manifestPath -Manifest $manifest | Out-Null
|
||||
Write-Log "manifest 已更新:$manifestPath" -Level DEBUG
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user