Restore script update

This commit is contained in:
2026-05-29 09:04:53 +01:00
parent 0198a20976
commit 30d4e7b624
+320 -36
View File
@@ -19,7 +19,16 @@ param(
[string]$LogPath,
[Parameter(Mandatory=$false)]
[string]$NewName
[string]$NewName,
[Parameter(Mandatory=$false)]
[string]$StopAtTime,
[Parameter(Mandatory=$false)]
[string]$StopAtLSN,
[Parameter(Mandatory=$false)]
[switch]$PreviewRestorePlan
)
# Function to catalog backups
@@ -98,9 +107,23 @@ function Report-Catalog {
# Function to restore database
function Restore-Database {
param([string]$DbName, [hashtable]$Catalog, [string]$Instance, [string]$DataPath, [string]$LogPath, [string]$TargetDbName)
param(
[string]$DbName,
[hashtable]$Catalog,
[string]$Instance,
[string]$DataPath,
[string]$LogPath,
[string]$TargetDbName,
[string]$StopAtTime,
[string]$StopAtLSN,
[bool]$PreviewRestorePlan
)
$restoreDbName = if ($TargetDbName) { $TargetDbName } else { $DbName }
$shouldRenamePhysicalFiles = -not [string]::IsNullOrWhiteSpace($TargetDbName) -and ($TargetDbName -ne $DbName)
$isPointInTimeRestore = ($StopAtTime -or $StopAtLSN)
$targetStopAtTime = $null
$targetStopAtLsn = $null
if (-not $catalog.ContainsKey($DbName)) {
Write-Error "Database $DbName not found in catalog"
@@ -115,13 +138,74 @@ function Restore-Database {
return
}
$latestFullKey = $dbCatalog['FULL'].Keys | Sort-Object -Descending | Select-Object -First 1
$fullFiles = $dbCatalog['FULL'][$latestFullKey] | Sort-Object { $_.Stripe } | ForEach-Object { $_.File }
$fullHeader = Get-BackupInfo -Files $fullFiles -Instance $Instance
if (-not $fullHeader) {
Write-Error "Failed to read FULL backup header for database $DbName"
if ($StopAtTime) {
try {
$targetStopAtTime = [datetime]::Parse($StopAtTime)
} catch {
Write-Error "StopAtTime '$StopAtTime' is not a valid datetime value"
return
}
}
if ($StopAtLSN) {
$lsnForCompare = if ($StopAtLSN -like 'lsn:*') { $StopAtLSN.Substring(4) } else { $StopAtLSN }
try {
$targetStopAtLsn = [decimal]$lsnForCompare
} catch {
Write-Error "StopAtLSN '$StopAtLSN' must be a numeric LSN value"
return
}
}
# Select FULL backup. For StopAtTime, choose latest FULL that finished at/before target time.
$fullCandidates = @()
$fullKeys = $dbCatalog['FULL'].Keys | Sort-Object
foreach ($key in $fullKeys) {
$files = $dbCatalog['FULL'][$key] | Sort-Object { $_.Stripe } | ForEach-Object { $_.File }
$header = Get-BackupInfo -Files $files -Instance $Instance
if (-not $header) {
Write-Warning "Skipping FULL backup $key for $DbName because its header could not be read"
continue
}
$finish = $null
try {
$finish = [datetime]$header.BackupFinishDate
} catch {
Write-Warning "Skipping FULL backup $key for $DbName because BackupFinishDate could not be parsed"
continue
}
if ($targetStopAtTime -and $finish -gt $targetStopAtTime) {
continue
}
$fullCandidates += @{
Key = $key
Files = $files
Header = $header
Finish = $finish
}
}
if ($fullCandidates.Count -eq 0) {
if ($targetStopAtTime) {
Write-Error "No FULL backup found that is at or before StopAtTime '$StopAtTime' for database $DbName"
} else {
Write-Error "No usable FULL backup found for database $DbName"
}
return
}
$selectedFull = if ($targetStopAtTime) {
$fullCandidates | Sort-Object { $_.Finish } -Descending | Select-Object -First 1
} else {
$fullCandidates | Sort-Object { $_.Key } -Descending | Select-Object -First 1
}
$latestFullKey = $selectedFull.Key
$fullFiles = $selectedFull.Files
$fullHeader = $selectedFull.Header
$fullBaseLsn = $fullHeader.CheckpointLSN
if (-not $fullBaseLsn) {
@@ -153,7 +237,7 @@ function Restore-Database {
# When restoring as a different database name, rewrite physical file names
# so they do not clash with the source database files on disk.
if ($TargetDbName) {
if ($shouldRenamePhysicalFiles) {
if ($type -eq 'D') {
$dataFileIndex++
$newBaseName = if ($dataFileIndex -eq 1) { "${restoreDbName}_Data" } else { "${restoreDbName}_Data$dataFileIndex" }
@@ -174,17 +258,13 @@ function Restore-Database {
}
}
# Restore FULL with NORECOVERY
# Build FULL restore query
$withClause = if ($moveClauses) { "WITH $($moveClauses -join ', '), NORECOVERY" } else { "WITH NORECOVERY" }
$restoreQuery = "RESTORE DATABASE [$restoreDbName] FROM $($fileListStr -join ', ') $withClause"
if ($TargetDbName) {
Write-Host "Restoring FULL backup for $DbName as $restoreDbName..."
} else {
Write-Host "Restoring FULL backup for $DbName..."
}
Invoke-Sqlcmd -ServerInstance $Instance -Query $restoreQuery -QueryTimeout 0 -ErrorAction Stop
$fullRestoreQuery = $restoreQuery
# Choose the latest DIFF that is compatible with the selected FULL.
$selectedDiff = $null
$selectedDiffKey = $null
if ($dbCatalog.ContainsKey('DIFF')) {
$compatibleDiffs = @()
@@ -197,30 +277,212 @@ function Restore-Database {
continue
}
if ($diffHeader.DifferentialBaseLSN -eq $fullBaseLsn) {
$compatibleDiffs += @{ Key = $key; Files = $diffFiles }
if ($diffHeader.DifferentialBaseLSN -ne $fullBaseLsn) {
continue
}
$diffFinish = $null
try {
$diffFinish = [datetime]$diffHeader.BackupFinishDate
} catch {
Write-Warning "Skipping DIFF backup $key for $DbName because BackupFinishDate could not be parsed"
continue
}
if ($targetStopAtTime -and $diffFinish -gt $targetStopAtTime) {
continue
}
$compatibleDiffs += @{ Key = $key; Files = $diffFiles; Finish = $diffFinish }
}
if ($compatibleDiffs.Count -gt 0) {
$selectedDiff = $compatibleDiffs | Sort-Object { $_.Key } -Descending | Select-Object -First 1
$selectedDiff = if ($targetStopAtTime) {
$compatibleDiffs | Sort-Object { $_.Finish } -Descending | Select-Object -First 1
} else {
$compatibleDiffs | Sort-Object { $_.Key } -Descending | Select-Object -First 1
}
$selectedDiffKey = $selectedDiff.Key
}
}
$logStartKey = if ($selectedDiffKey) { $selectedDiffKey } else { $latestFullKey }
$logKeys = @()
# Select LOG backups after the latest restored FULL/DIFF point.
if ($dbCatalog.ContainsKey('LOG')) {
$candidateLogKeys = @($dbCatalog['LOG'].Keys | Where-Object { $_ -gt $logStartKey } | Sort-Object)
if ($isPointInTimeRestore -and $candidateLogKeys.Count -eq 0) {
Write-Error "Point-in-time restore requested but no LOG backups are available after the selected FULL/DIFF restore point"
return
}
if ($StopAtTime) {
$cutoffFound = $false
foreach ($key in $candidateLogKeys) {
$logFiles = $dbCatalog['LOG'][$key] | Sort-Object { $_.Stripe } | ForEach-Object { $_.File }
$logHeader = Get-BackupInfo -Files $logFiles -Instance $Instance
if (-not $logHeader) {
continue
}
$logStartTime = $null
$logFinishTime = $null
try {
$logStartTime = [datetime]$logHeader.BackupStartDate
$logFinishTime = [datetime]$logHeader.BackupFinishDate
} catch {
continue
}
if ($logFinishTime -lt $targetStopAtTime) {
$logKeys += $key
continue
}
# A log backup contains all records up to BackupFinishDate. The first log
# whose finish time meets/exceeds StopAtTime is the PITR cutoff log.
if ($logFinishTime -ge $targetStopAtTime) {
$logKeys += $key
$cutoffFound = $true
break
}
}
if (-not $cutoffFound) {
Write-Error "StopAtTime '$StopAtTime' is not covered by available LOG backups after the selected FULL/DIFF restore point"
return
}
} elseif ($StopAtLSN) {
$cutoffFound = $false
foreach ($key in $candidateLogKeys) {
$logFiles = $dbCatalog['LOG'][$key] | Sort-Object { $_.Stripe } | ForEach-Object { $_.File }
$logHeader = Get-BackupInfo -Files $logFiles -Instance $Instance
if (-not $logHeader) {
continue
}
$logFirstLsn = $null
$logLastLsn = $null
try {
$logFirstLsn = [decimal]$logHeader.FirstLSN
$logLastLsn = [decimal]$logHeader.LastLSN
} catch {
continue
}
if ($logLastLsn -lt $targetStopAtLsn) {
$logKeys += $key
continue
}
if ($logFirstLsn -le $targetStopAtLsn -and $targetStopAtLsn -le $logLastLsn) {
$logKeys += $key
$cutoffFound = $true
break
}
if ($logFirstLsn -gt $targetStopAtLsn) {
break
}
}
if (-not $cutoffFound) {
Write-Error "StopAtLSN '$StopAtLSN' is not covered by available LOG backups after the selected FULL/DIFF restore point"
return
}
} else {
$logKeys = $candidateLogKeys
}
if ($isPointInTimeRestore -and $logKeys.Count -eq 0) {
Write-Error "Point-in-time restore requested but no usable LOG backups were selected"
return
}
} elseif ($isPointInTimeRestore) {
Write-Error "Point-in-time restore requested but this database has no LOG backups in the catalog"
return
}
if ($PreviewRestorePlan) {
Write-Host ""
Write-Host "Restore Plan Preview"
Write-Host "===================="
Write-Host "Source Database: $DbName"
Write-Host "Target Database: $restoreDbName"
Write-Host "FULL Backup Key: $latestFullKey"
Write-Host "FULL Backup Files:"
foreach ($f in $fullFiles) {
Write-Host " - $($f.FullName)"
}
if ($selectedDiffKey) {
Write-Host "Selected DIFF Key: $selectedDiffKey"
Write-Host "Selected DIFF Files:"
foreach ($f in $selectedDiff.Files) {
Write-Host " - $($f.FullName)"
}
} else {
Write-Host "Selected DIFF Key: none"
}
if ($logKeys.Count -gt 0) {
Write-Host "LOG Range: $($logKeys[0]) -> $($logKeys[-1]) ($($logKeys.Count) backup sets)"
} else {
Write-Host "LOG Range: none"
}
if ($isPointInTimeRestore) {
if ($StopAtTime) {
Write-Host "PITR Target: STOPAT '$StopAtTime'"
} else {
$previewLsn = if ($StopAtLSN -like 'lsn:*') { $StopAtLSN } else { "lsn:$StopAtLSN" }
Write-Host "PITR Target: STOPATMARK '$previewLsn'"
}
} else {
Write-Host "PITR Target: none (restore to latest available point)"
}
Write-Host "MOVE Clauses:"
foreach ($m in $moveClauses) {
Write-Host " - $m"
}
Write-Host ""
Write-Host "PreviewRestorePlan enabled. Exiting without executing restore operations."
return
}
if ($shouldRenamePhysicalFiles) {
Write-Host "Restoring FULL backup for $DbName as $restoreDbName..."
} else {
Write-Host "Restoring FULL backup for $DbName..."
}
Invoke-Sqlcmd -ServerInstance $Instance -Query $fullRestoreQuery -QueryTimeout 0 -ErrorAction Stop
if ($selectedDiffKey) {
$fileList = $selectedDiff.Files | ForEach-Object { "DISK = '$($_.FullName)'" }
$restoreQuery = "RESTORE DATABASE [$restoreDbName] FROM $($fileList -join ', ') WITH NORECOVERY"
Write-Host "Applying DIFF backup $selectedDiffKey for $restoreDbName..."
Invoke-Sqlcmd -ServerInstance $Instance -Query $restoreQuery -QueryTimeout 0 -ErrorAction Stop
}
}
$logStartKey = if ($selectedDiffKey) { $selectedDiffKey } else { $latestFullKey }
# Apply LOG backups after the latest restored FULL/DIFF point
if ($dbCatalog.ContainsKey('LOG')) {
$logKeys = $dbCatalog['LOG'].Keys | Where-Object { $_ -gt $logStartKey } | Sort-Object
foreach ($key in $logKeys) {
if ($logKeys.Count -gt 0) {
for ($i = 0; $i -lt $logKeys.Count; $i++) {
$key = $logKeys[$i]
$logFiles = $dbCatalog['LOG'][$key] | Sort-Object { $_.Stripe } | ForEach-Object { $_.File }
$fileList = $logFiles | ForEach-Object { "DISK = '$($_.FullName)'" }
$restoreQuery = "RESTORE LOG [$restoreDbName] FROM $($fileList -join ', ') WITH NORECOVERY"
$logWithClause = "WITH NORECOVERY"
# Apply STOPAT/STOPATMARK only on the final LOG restore statement.
if ($isPointInTimeRestore -and $i -eq ($logKeys.Count - 1)) {
if ($StopAtTime) {
$escapedStopAtTime = $StopAtTime.Replace("'", "''")
$logWithClause = "$logWithClause, STOPAT = '$escapedStopAtTime'"
} elseif ($StopAtLSN) {
$normalizedLsn = if ($StopAtLSN -like "lsn:*") { $StopAtLSN } else { "lsn:$StopAtLSN" }
$escapedLsn = $normalizedLsn.Replace("'", "''")
$logWithClause = "$logWithClause, STOPATMARK = '$escapedLsn'"
}
}
$restoreQuery = "RESTORE LOG [$restoreDbName] FROM $($fileList -join ', ') $logWithClause"
Write-Host "Applying LOG backup $key for $restoreDbName..."
Invoke-Sqlcmd -ServerInstance $Instance -Query $restoreQuery -QueryTimeout 0 -ErrorAction Stop
}
@@ -244,8 +506,10 @@ function Print-BackupSummary {
# FULL backups
if ($Headers.ContainsKey('FULL')) {
Write-Host "FULL Backups:"
foreach ($item in $Headers['FULL'] | Sort-Object { $_.Key }) {
$header = $item.Header
$fullItems = @($Headers['FULL'] | Sort-Object { $_.Key })
foreach ($item in $fullItems) {
$header = @($item.Header)[0]
if (-not $header) { continue }
Write-Host " Date: $($header.BackupFinishDate) | LSN Range: $($header.FirstLSN) - $($header.LastLSN)"
}
}
@@ -253,18 +517,20 @@ function Print-BackupSummary {
# DIFF backups
if ($Headers.ContainsKey('DIFF')) {
Write-Host "DIFFERENTIAL Backups:"
foreach ($item in $Headers['DIFF'] | Sort-Object { $_.Key }) {
$header = $item.Header
$diffItems = @($Headers['DIFF'] | Sort-Object { $_.Key })
foreach ($item in $diffItems) {
$header = @($item.Header)[0]
if (-not $header) { continue }
Write-Host " Date: $($header.BackupFinishDate) | Base LSN: $($header.DifferentialBaseLSN) | LSN Range: $($header.FirstLSN) - $($header.LastLSN)"
}
}
# LOG backups
if ($Headers.ContainsKey('LOG')) {
$logItems = $Headers['LOG'] | Sort-Object { $_.Key }
$logItems = @($Headers['LOG'] | Sort-Object { $_.Key })
if ($logItems.Count -gt 0) {
$firstLog = $logItems[0].Header
$lastLog = $logItems[-1].Header
$firstLog = @($logItems[0].Header)[0]
$lastLog = @($logItems[-1].Header)[0]
Write-Host "LOG Backups:"
Write-Host " Point-in-Time Range: $($firstLog.BackupStartDate) to $($lastLog.BackupFinishDate)"
Write-Host " LSN Range: $($firstLog.FirstLSN) - $($lastLog.LastLSN)"
@@ -272,8 +538,11 @@ function Print-BackupSummary {
# Check for gaps
$gaps = @()
for ($i = 1; $i -lt $logItems.Count; $i++) {
$prevLast = $logItems[$i-1].Header.LastLSN
$currFirst = $logItems[$i].Header.FirstLSN
$prevHeader = @($logItems[$i-1].Header)[0]
$currHeader = @($logItems[$i].Header)[0]
if (-not $prevHeader -or -not $currHeader) { continue }
$prevLast = $prevHeader.LastLSN
$currFirst = $currHeader.FirstLSN
if ($prevLast -ne $currFirst) {
$gaps += "Gap between $($logItems[$i-1].Key) (LSN $($prevLast)) and $($logItems[$i].Key) (LSN $($currFirst))"
}
@@ -368,6 +637,21 @@ if ($Action -ne "restore" -and $NewName) {
exit 1
}
if ($StopAtTime -and $StopAtLSN) {
Write-Error "Specify only one of StopAtTime or StopAtLSN"
exit 1
}
if ($Action -ne "restore" -and ($StopAtTime -or $StopAtLSN)) {
Write-Error "StopAtTime and StopAtLSN are only valid with restore action"
exit 1
}
if ($Action -ne "restore" -and $PreviewRestorePlan) {
Write-Error "PreviewRestorePlan is only valid with restore action"
exit 1
}
if ($Action -eq "catalog") {
$catalog = Catalog-Backups -Path $LiveMountRoot
if ($DatabaseName) {
@@ -390,7 +674,7 @@ if ($Action -eq "catalog") {
}
$catalog = Catalog-Backups -Path $LiveMountRoot
Restore-Database -DbName $DatabaseName -Catalog $catalog -Instance $SqlInstance -DataPath $DataPath -LogPath $LogPath -TargetDbName $NewName
Restore-Database -DbName $DatabaseName -Catalog $catalog -Instance $SqlInstance -DataPath $DataPath -LogPath $LogPath -TargetDbName $NewName -StopAtTime $StopAtTime -StopAtLSN $StopAtLSN -PreviewRestorePlan $PreviewRestorePlan
} elseif ($Action -eq "verify") {
$catalog = Catalog-Backups -Path $LiveMountRoot
if ($DatabaseName) {