From 81ae0f12628258642087908991f570f5295a7524 Mon Sep 17 00:00:00 2001 From: SupraJames Date: Wed, 10 Jun 2026 16:56:46 +0100 Subject: [PATCH] Log handling and date fixes --- RestoreScript.ps1 | 88 ++++++++++++++++++++++++++++++++++------------- 1 file changed, 65 insertions(+), 23 deletions(-) diff --git a/RestoreScript.ps1 b/RestoreScript.ps1 index 12276f0..3ed81f4 100644 --- a/RestoreScript.ps1 +++ b/RestoreScript.ps1 @@ -9,7 +9,7 @@ param( [ValidateSet("catalog", "restore", "verify")] [string]$Action, - [Parameter(Mandatory=$true)] + [Parameter(Mandatory=$false)] [string]$SqlInstance = "sqlfcsql\TESTINST", [Parameter(Mandatory=$false)] @@ -66,7 +66,11 @@ function Catalog-Backups { $timeStr = $parts[$typeIndex + 2] $stripe = 0 if ($parts.Length -gt $typeIndex + 3) { - $stripe = [int]$parts[$typeIndex + 3] + $stripeVal = 0 + if (-not [int]::TryParse($parts[$typeIndex + 3], [ref]$stripeVal)) { + continue + } + $stripe = $stripeVal } $key = "$dateStr$timeStr" @@ -140,9 +144,10 @@ function Restore-Database { if ($StopAtTime) { try { - $targetStopAtTime = [datetime]::Parse($StopAtTime) + # Parse using the current OS locale so users can enter dates in their own format. + $targetStopAtTime = [datetime]::Parse($StopAtTime, [System.Globalization.CultureInfo]::CurrentCulture) } catch { - Write-Error "StopAtTime '$StopAtTime' is not a valid datetime value" + Write-Error "StopAtTime '$StopAtTime' is not a valid datetime value (expected format for your locale: $([System.Globalization.CultureInfo]::CurrentCulture.DateTimeFormat.ShortDatePattern) HH:mm:ss)" return } } @@ -207,10 +212,7 @@ function Restore-Database { $fullFiles = $selectedFull.Files $fullHeader = $selectedFull.Header - $fullBaseLsn = $fullHeader.CheckpointLSN - if (-not $fullBaseLsn) { - $fullBaseLsn = $fullHeader.FirstLSN - } + $fullBaseLsn = if ($fullHeader.CheckpointLSN) { [decimal]$fullHeader.CheckpointLSN } else { [decimal]$fullHeader.FirstLSN } # Create directories if specified if ($DataPath -and -not (Test-Path $DataPath)) { @@ -277,7 +279,7 @@ function Restore-Database { continue } - if ($diffHeader.DifferentialBaseLSN -ne $fullBaseLsn) { + if ([decimal]$diffHeader.DifferentialBaseLSN -ne $fullBaseLsn) { continue } @@ -309,15 +311,52 @@ function Restore-Database { $logStartKey = if ($selectedDiffKey) { $selectedDiffKey } else { $latestFullKey } $logKeys = @() + # Determine the LastLSN of the last backup that will be restored (FULL or DIFF). + $lastRestoredHeader = if ($selectedDiff) { + Get-BackupInfo -Files $selectedDiff.Files -Instance $Instance + } else { + $fullHeader + } + $lastRestoredLsn = if ($lastRestoredHeader) { [decimal]$lastRestoredHeader.LastLSN } else { $null } + # 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 + + # Validate LSN continuity: the first candidate LOG must connect to the end of the FULL/DIFF chain. + # If there is a gap (first LOG's FirstLSN > FULL/DIFF LastLSN), no logs can be applied. + if ($candidateLogKeys.Count -gt 0 -and $lastRestoredLsn) { + $firstLogFiles = $dbCatalog['LOG'][$candidateLogKeys[0]] | Sort-Object { $_.Stripe } | ForEach-Object { $_.File } + $firstLogHeader = Get-BackupInfo -Files $firstLogFiles -Instance $Instance + if ($firstLogHeader -and [decimal]$firstLogHeader.FirstLSN -gt $lastRestoredLsn) { + Write-Warning "LSN gap detected: first available LOG backup ($($candidateLogKeys[0])) starts at LSN $($firstLogHeader.FirstLSN), but the restored FULL/DIFF ends at LSN $lastRestoredLsn. No log backups can be applied." + $candidateLogKeys = @() + } } - if ($StopAtTime) { + $skipLogSelection = $false + if ($isPointInTimeRestore -and $candidateLogKeys.Count -eq 0) { + # Only error if the target point is beyond the last restored backup's finish time. + # If the target is at or before that time, no logs are required and restore can proceed. + $lastRestoredFinish = if ($lastRestoredHeader) { + try { [datetime]$lastRestoredHeader.BackupFinishDate } catch { $null } + } else { $null } + $targetIsBeyondRestored = $true + if ($targetStopAtTime -and $lastRestoredFinish -and $targetStopAtTime -le $lastRestoredFinish) { + $targetIsBeyondRestored = $false + } + if ($targetStopAtLsn -and $lastRestoredLsn -and $targetStopAtLsn -le $lastRestoredLsn) { + $targetIsBeyondRestored = $false + } + if ($targetIsBeyondRestored) { + Write-Error "Point-in-time restore requested but no LOG backups are available after the selected FULL/DIFF restore point" + return + } + # Target is within the FULL/DIFF range; no logs needed. + $skipLogSelection = $true + } + + if (-not $skipLogSelection -and $StopAtTime) { $cutoffFound = $false foreach ($key in $candidateLogKeys) { $logFiles = $dbCatalog['LOG'][$key] | Sort-Object { $_.Stripe } | ForEach-Object { $_.File } @@ -353,7 +392,7 @@ function Restore-Database { Write-Error "StopAtTime '$StopAtTime' is not covered by available LOG backups after the selected FULL/DIFF restore point" return } - } elseif ($StopAtLSN) { + } elseif (-not $skipLogSelection -and $StopAtLSN) { $cutoffFound = $false foreach ($key in $candidateLogKeys) { $logFiles = $dbCatalog['LOG'][$key] | Sort-Object { $_.Stripe } | ForEach-Object { $_.File } @@ -395,7 +434,7 @@ function Restore-Database { $logKeys = $candidateLogKeys } - if ($isPointInTimeRestore -and $logKeys.Count -eq 0) { + if (-not $skipLogSelection -and $isPointInTimeRestore -and $logKeys.Count -eq 0) { Write-Error "Point-in-time restore requested but no usable LOG backups were selected" return } @@ -431,7 +470,7 @@ function Restore-Database { } if ($isPointInTimeRestore) { if ($StopAtTime) { - Write-Host "PITR Target: STOPAT '$StopAtTime'" + Write-Host "PITR Target: STOPAT '$($targetStopAtTime.ToString([System.Globalization.CultureInfo]::CurrentCulture))'" } else { $previewLsn = if ($StopAtLSN -like 'lsn:*') { $StopAtLSN } else { "lsn:$StopAtLSN" } Write-Host "PITR Target: STOPATMARK '$previewLsn'" @@ -473,8 +512,11 @@ function Restore-Database { # 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'" + # Use the parsed datetime formatted as ISO 8601, not the raw input string. + # SQL Server requires a consistent datetime format; the user's input may be + # locale-specific (e.g. dd/MM/yyyy) which SQL Server does not accept. + $sqlStopAt = $targetStopAtTime.ToString("yyyy-MM-dd HH:mm:ss") + $logWithClause = "$logWithClause, STOPAT = '$sqlStopAt'" } elseif ($StopAtLSN) { $normalizedLsn = if ($StopAtLSN -like "lsn:*") { $StopAtLSN } else { "lsn:$StopAtLSN" } $escapedLsn = $normalizedLsn.Replace("'", "''") @@ -510,7 +552,7 @@ function Print-BackupSummary { foreach ($item in $fullItems) { $header = @($item.Header)[0] if (-not $header) { continue } - Write-Host " Date: $($header.BackupFinishDate) | LSN Range: $($header.FirstLSN) - $($header.LastLSN)" + Write-Host " Date: $(([datetime]$header.BackupFinishDate).ToString([System.Globalization.CultureInfo]::CurrentCulture)) | LSN Range: $($header.FirstLSN) - $($header.LastLSN)" } } @@ -521,7 +563,7 @@ function Print-BackupSummary { 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)" + Write-Host " Date: $(([datetime]$header.BackupFinishDate).ToString([System.Globalization.CultureInfo]::CurrentCulture)) | Base LSN: $($header.DifferentialBaseLSN) | LSN Range: $($header.FirstLSN) - $($header.LastLSN)" } } @@ -532,7 +574,7 @@ function Print-BackupSummary { $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 " Point-in-Time Range: $(([datetime]$firstLog.BackupStartDate).ToString([System.Globalization.CultureInfo]::CurrentCulture)) to $(([datetime]$lastLog.BackupFinishDate).ToString([System.Globalization.CultureInfo]::CurrentCulture))" Write-Host " LSN Range: $($firstLog.FirstLSN) - $($lastLog.LastLSN)" # Check for gaps @@ -590,8 +632,8 @@ function Verify-Backups { $header = Get-BackupInfo -Files $files -Instance $Instance if ($header) { Write-Host " Backup Details:" - Write-Host " Start Date: $($header.BackupStartDate)" - Write-Host " Finish Date: $($header.BackupFinishDate)" + Write-Host " Start Date: $(([datetime]$header.BackupStartDate).ToString([System.Globalization.CultureInfo]::CurrentCulture))" + Write-Host " Finish Date: $(([datetime]$header.BackupFinishDate).ToString([System.Globalization.CultureInfo]::CurrentCulture))" Write-Host " First LSN: $($header.FirstLSN)" Write-Host " Last LSN: $($header.LastLSN)" if ($header.DatabaseBackupLSN) {