Public/Invoke-sqmUserDatabaseBackup.ps1
|
<#
.SYNOPSIS Backs up user databases on a SQL Server instance. .DESCRIPTION Backs up all or selected user databases (no system databases) as FULL, DIFF (differential) or LOG (transaction log) backups, selected via -BackupType. FULL and DIFF backups are written with a ".bak" extension, LOG backups with ".trn". The target path is read from the server properties (BackupDirectory) and must end with "Usr-db". If the SqlInstance parameter is not specified, the current computer name ($env:COMPUTERNAME) is used by default. This rule applies to all future versions. When -UseExcludeTable is set, the function reads the table master.dbo.sqm_BackupExclude (created by Sync-sqmBackupExcludeTable) and skips all databases where IsActive=0 AND IsOrphaned=0 (IsActive=1 means "back this database up", the default for newly discovered databases - same semantics as New-sqmOlaUsrDbBackupJob -UseExcludeTable). If the table does not exist or contains no matching rows, all databases are backed up normally. Availability Group databases are backed up ONLY and ALWAYS on the current primary replica. On a secondary they are skipped (Status 'SkippedAgSecondary'), regardless of the AG backup preference; databases outside an AG are not affected. When -MailTo is specified, a backup report is sent via SQL Server Database Mail after all backups have completed. By default the mail is only sent when there are failures or the job was aborted. Add -MailOnSuccess to also receive a mail on full success. .PARAMETER SqlInstance The target SQL Server instance (e.g. "localhost", "SQL01\INSTANCE"). If not specified, the current computer name is used. .PARAMETER SqlCredential Alternative credentials (PSCredential). If not specified, Windows authentication is used. .PARAMETER Database Name or array of user databases to back up. Ignored when -All is set. .PARAMETER All When set, all user databases on the instance are backed up. .PARAMETER BackupPath Optional direct backup path (overrides the value from server properties). The path must end with "Usr-db". .PARAMETER BackupType Backup type: 'FULL', 'DIFF' (differential), or 'LOG' (transaction log). Default: 'FULL'. Maps to Backup-DbaDatabase -Type Full/Differential/Log. LOG backups require the full or bulk-logged recovery model and a prior full backup; databases that don't qualify fail with the underlying SQL Server error for that database only, the rest of the run continues. .PARAMETER UseExcludeTable When set, reads master.dbo.sqm_BackupExclude and skips databases where IsActive=0 and IsOrphaned=0 (IsActive=1, the default for newly discovered databases, means "back this database up"). .PARAMETER CheckPreferredReplica Obsolete since 1.9.166.0, has no effect. AG databases are always backed up on the primary replica only (see description). Kept so existing scripts calling it keep working. .PARAMETER MailTo Recipient email address for the backup report. When specified, a mail is sent via SQL Server Database Mail after the backup run. By default the mail is only sent on errors or when the job was aborted; add -MailOnSuccess to also send on full success. .PARAMETER MailProfile SQL Server Database Mail profile name to use for sending the report mail. Default: 'Default'. .PARAMETER MailOnSuccess When set together with -MailTo, a report mail is also sent when all backups succeeded (not only on errors or abort). .PARAMETER CleanupTime Optional retention period in HOURS for old backup files of the SAME BackupType in BackupPath (e.g. 48 = 2 days, 336 = 2 weeks, 672 = 4 weeks). Hours is the same unit Ola Hallengren's DatabaseBackup uses for its own @CleanupTime, so the value means the same thing everywhere in this module. When specified, Remove-DbaBackup deletes files with this run's extension (.bak for FULL/DIFF, .trn for LOG) older than this period after the backup completes. Not specified, or 0 (default): no cleanup is performed. Note: FULL and DIFF share the ".bak" extension in the same BackupPath, so a single retention period applied via this parameter affects both - run them with separate -BackupPath values if they need different retention. .PARAMETER EnableException Switch to propagate exceptions immediately (by default errors are logged as warnings). .EXAMPLE # Back up all user databases on the current computer Invoke-sqmUserDatabaseBackup -All .EXAMPLE # Back up specific databases on a remote server Invoke-sqmUserDatabaseBackup -SqlInstance "SQL01" -Database "SalesDB", "InventoryDB" .EXAMPLE # With an alternative path Invoke-sqmUserDatabaseBackup -All -BackupPath "D:\Backup\Usr-db" .EXAMPLE # Transaction log backup of all user databases Invoke-sqmUserDatabaseBackup -All -BackupType LOG .EXAMPLE # Transaction log backup with cleanup of .trn files older than 48 hours Invoke-sqmUserDatabaseBackup -All -BackupType LOG -CleanupTime 48 .EXAMPLE # Back up all user databases, skipping databases listed in sqm_BackupExclude Invoke-sqmUserDatabaseBackup -All -UseExcludeTable .EXAMPLE # Back up with exclude table on a remote instance Invoke-sqmUserDatabaseBackup -SqlInstance "SQL01" -All -UseExcludeTable .EXAMPLE # Back up all databases and send a mail report on errors (uses default mail profile) Invoke-sqmUserDatabaseBackup -All -MailTo "dba@example.com" .EXAMPLE # Back up all databases and always send a mail report (success and failure) Invoke-sqmUserDatabaseBackup -All -MailTo "dba@example.com" -MailProfile "SQLAlerts" -MailOnSuccess .EXAMPLE # Full pipeline: backup with exclude table and mail notification Invoke-sqmUserDatabaseBackup -SqlInstance "SQL01" -All -UseExcludeTable ` -MailTo "dba@example.com" -MailOnSuccess .NOTES Requires the dbatools module and existing Invoke-sqmLogging, Get-sqmServerSetting (for the default backup path) and Invoke-sqmNtfsSetup (used to grant the SQL service account NTFS write access when a new backup directory has to be created) functions. The path must end with 'Usr-db'. Default for SqlInstance: $env:COMPUTERNAME (applies to all future versions). #> function Invoke-sqmUserDatabaseBackup { [CmdletBinding(DefaultParameterSetName = 'Specific', SupportsShouldProcess = $true)] param ( [Parameter(Mandatory = $false, Position = 0)] [string]$SqlInstance, [Parameter(Mandatory = $false)] [System.Management.Automation.PSCredential]$SqlCredential, [Parameter(Mandatory = $false, ParameterSetName = 'Specific')] [string[]]$Database, [Parameter(Mandatory = $false, ParameterSetName = 'All')] [switch]$All, [Parameter(Mandatory = $false)] [string]$BackupPath, [Parameter(Mandatory = $false)] [ValidateSet('FULL', 'DIFF', 'LOG')] [string]$BackupType = 'FULL', [Parameter(Mandatory = $false)] [switch]$UseExcludeTable, [Parameter(Mandatory = $false)] [switch]$CheckPreferredReplica, [Parameter(Mandatory = $false)] [string]$MailTo, [Parameter(Mandatory = $false)] [string]$MailProfile = 'Default', [Parameter(Mandatory = $false)] [switch]$MailOnSuccess, [Parameter(Mandatory = $false)] [ValidateRange(0, 87600)] [int]$CleanupTime, [Parameter(Mandatory = $false)] [switch]$EnableException ) begin { $functionName = $MyInvocation.MyCommand.Name # Default fuer SqlInstance: aktueller Computername if (-not $PSBoundParameters.ContainsKey('SqlInstance') -or [string]::IsNullOrWhiteSpace($SqlInstance)) { $SqlInstance = $env:COMPUTERNAME Write-Verbose "Keine SqlInstance angegeben. Verwende Standard: $SqlInstance" } # Pruefung auf dbatools if (-not (Get-Module -ListAvailable -Name dbatools)) { $errMsg = "dbatools-Modul nicht gefunden. Bitte installieren Sie es mit: Install-Module dbatools" Invoke-sqmLogging -Message $errMsg -FunctionName $functionName -Level "ERROR" throw $errMsg } Invoke-sqmLogging -Message "Starte $functionName auf Instanz: $SqlInstance" -FunctionName $functionName -Level "INFO" # Backup-Pfad ermitteln if (-not $BackupPath) { try { # Korrekte Server-Eigenschaft fuer Backup-Verzeichnis: BackupDirectory $BackupPath = Get-sqmServerSetting -Name "BackupDirectory" -SqlInstance $SqlInstance -SqlCredential $SqlCredential -EnableException:$EnableException if ([string]::IsNullOrWhiteSpace($BackupPath)) { $msg = "Server-Eigenschaft 'BackupDirectory' ist leer oder nicht gesetzt." Invoke-sqmLogging -Message $msg -FunctionName $functionName -Level "ERROR" throw $msg } Invoke-sqmLogging -Message "Backup-Pfad aus Server-Eigenschaften gelesen: $BackupPath" -FunctionName $functionName -Level "INFO" } catch { $errMsg = "Konnte Backup-Pfad nicht aus Server-Eigenschaften lesen: $($_.Exception.Message)" Invoke-sqmLogging -Message $errMsg -FunctionName $functionName -Level "ERROR" throw $errMsg } } # Pruefen, ob der Pfad auf "Usr-db" endet - das ist die tatsaechlich verwendete Konvention # (siehe New-sqmOlaUsrDbBackupJob: legt real "<BackupDirectory>\Usr-db" an). Diese Pruefung # verlangte bisher "User-Db" (mit "e") - eine andere Schreibweise als die ueberall sonst im # Modul tatsaechlich verwendete und angelegte "Usr-db", wodurch ein voellig regelkonformer # Pfad hier faelschlich abgelehnt worden waere. if (-not ($BackupPath -match '\\Usr-db$')) { $errMsg = "Der Backup-Pfad '$BackupPath' endet nicht mit 'Usr-db'. Bitte korrigieren Sie die Server-Eigenschaft BackupDirectory oder geben Sie einen gueltigen Pfad an." Invoke-sqmLogging -Message $errMsg -FunctionName $functionName -Level "ERROR" throw $errMsg } # Sicherstellen, dass der Pfad existiert if (-not (Test-Path $BackupPath)) { try { New-Item -Path $BackupPath -ItemType Directory -Force -ErrorAction Stop | Out-Null Invoke-sqmLogging -Message "Verzeichnis $BackupPath wurde erstellt." -FunctionName $functionName -Level "INFO" # Ein frisch angelegtes Verzeichnis erbt NTFS-Rechte nur, wenn irgendein Vorfahre # bereits eine vererbbare ACE fuer das SQL-Dienstkonto hat. Zeigt BackupDirectory # (Server-Eigenschaft) auf einen bislang nicht existierenden Pfad - z.B. weil er # waehrend eines Instanz-Setups umgestellt, aber der zugehoerige NTFS-Setup-Schritt # nie fuer diesen Pfad ausgefuehrt wurde - fehlt diese Vererbung haeufig. New-Item # legt den Ordner dann unter der aufrufenden Identitaet erfolgreich an, aber # BACKUP DATABASE laeuft als SQL-Dienstkonto und scheitert danach mit "Cannot open # backup device ... Operating system error 5(Access is denied.)", obwohl der Ordner # sichtbar vorhanden ist. Deshalb hier proaktiv dieselben Rechte vergeben, die # Invoke-sqmNtfsSetup fuer die Standardverzeichnisse setzt - nicht fatal, falls das # selbst fehlschlaegt (z.B. Dienstkonto nicht ermittelbar): der Backup-Versuch laeuft # trotzdem weiter und liefert im Fehlerfall die eigentliche SQL-Fehlermeldung. try { $ntfsResult = Invoke-sqmNtfsSetup -SqlInstance $SqlInstance -SqlCredential $SqlCredential ` -Directory $BackupPath -Permission Modify -SkipBackup -Confirm:$false -ErrorAction Stop if ($ntfsResult.Status -in @('Granted', 'WhatIfSkipped')) { Invoke-sqmLogging -Message "NTFS-Rechte fuer SQL-Dienstkonto(s) auf neu angelegtem Verzeichnis $BackupPath vergeben." -FunctionName $functionName -Level "INFO" } else { Invoke-sqmLogging -Message "NTFS-Rechte auf $BackupPath konnten nicht vollstaendig vergeben werden: $($ntfsResult.Message)" -FunctionName $functionName -Level "WARNING" } } catch { Invoke-sqmLogging -Message "Konnte NTFS-Rechte auf neu angelegtem Verzeichnis $BackupPath nicht setzen (Backup wird trotzdem versucht): $($_.Exception.Message)" -FunctionName $functionName -Level "WARNING" } } catch { $errMsg = "Konnte Backup-Verzeichnis nicht erstellen: $($_.Exception.Message)" Invoke-sqmLogging -Message $errMsg -FunctionName $functionName -Level "ERROR" throw $errMsg } } # BackupType auf Backup-DbaDatabase -Type und Dateiendung abbilden $dbaBackupType = switch ($BackupType) { 'FULL' { 'Full' } 'DIFF' { 'Differential' } 'LOG' { 'Log' } } $backupFileExtension = if ($BackupType -eq 'LOG') { 'trn' } else { 'bak' } # Ergebnisliste $results = @() } process { try { if ($CheckPreferredReplica) { Invoke-sqmLogging -Message "-CheckPreferredReplica ist seit 1.9.166.0 ohne Wirkung: AG-Datenbanken werden immer nur auf dem Primary gesichert." -FunctionName $functionName -Level "WARNING" } # Datenbanken ermitteln $dbParams = @{ SqlInstance = $SqlInstance SqlCredential = $SqlCredential ExcludeSystem = $true ErrorAction = 'Stop' } if ($EnableException) { $dbParams.EnableException = $true } if ($All) { Invoke-sqmLogging -Message "Parameter -All erkannt: Es werden ALLE Benutzerdatenbanken gesichert." -FunctionName $functionName -Level "INFO" $databases = Get-DbaDatabase @dbParams | Where-Object { $_.IsAccessible } } elseif ($Database) { Invoke-sqmLogging -Message "Filtere nach angegebenen Datenbanken: $($Database -join ', ')" -FunctionName $functionName -Level "DEBUG" $dbParams.Database = $Database $databases = Get-DbaDatabase @dbParams | Where-Object { $_.IsAccessible } # Pruefen, ob alle angeforderten Datenbanken existieren $foundDbNames = $databases | Select-Object -ExpandProperty Name $missing = $Database | Where-Object { $_ -notin $foundDbNames } if ($missing) { $msg = "Folgende Datenbanken wurden nicht gefunden oder sind nicht zugaenglich: $($missing -join ', ')" Invoke-sqmLogging -Message $msg -FunctionName $functionName -Level "WARNING" $results += [PSCustomObject]@{ SqlInstance = $SqlInstance DatabaseName = $missing -join ',' BackupFile = $null Status = "NotFound" Message = $msg } } } else { # Kein Filter und nicht -All: alle Benutzerdatenbanken (wie -All) Invoke-sqmLogging -Message "Keine Filterung - verarbeite alle Benutzerdatenbanken." -FunctionName $functionName -Level "DEBUG" $databases = Get-DbaDatabase @dbParams | Where-Object { $_.IsAccessible } } if (-not $databases) { $msg = "Keine Benutzerdatenbanken fuer Backup gefunden (oder keine zugaenglich)." Invoke-sqmLogging -Message $msg -FunctionName $functionName -Level "WARNING" $results += [PSCustomObject]@{ SqlInstance = $SqlInstance DatabaseName = $null BackupFile = $null Status = "NoDatabasesFound" Message = $msg } return $results } # Exclude-Tabelle auswerten wenn -UseExcludeTable gesetzt if ($UseExcludeTable) { Invoke-sqmLogging -Message "-UseExcludeTable gesetzt: Pruefe master.dbo.sqm_BackupExclude auf Ausnahmen." -FunctionName $functionName -Level "INFO" try { $excludeCheck = Invoke-DbaQuery -SqlInstance $SqlInstance -SqlCredential $SqlCredential -Database master ` -Query "SELECT 1 FROM sys.objects WHERE object_id = OBJECT_ID(N'master.dbo.sqm_BackupExclude') AND type = 'U'" ` -ErrorAction Stop if ($excludeCheck) { $excludeRows = Invoke-DbaQuery -SqlInstance $SqlInstance -SqlCredential $SqlCredential -Database master ` -Query "SELECT DatabaseName FROM master.dbo.sqm_BackupExclude WHERE IsActive = 0 AND IsOrphaned = 0" ` -ErrorAction Stop if ($excludeRows) { $excludeNames = @($excludeRows | Select-Object -ExpandProperty DatabaseName) foreach ($excludeName in $excludeNames) { Invoke-sqmLogging -Message "Datenbank '$excludeName' ist in sqm_BackupExclude (IsActive=0, IsOrphaned=0) und wird uebersprungen." -FunctionName $functionName -Level "INFO" } $databases = $databases | Where-Object { $_.Name -notin $excludeNames } Invoke-sqmLogging -Message "Nach Exclude-Filter: $($databases.Count) Datenbank(en) verbleiben fuer das Backup." -FunctionName $functionName -Level "INFO" } else { Invoke-sqmLogging -Message "sqm_BackupExclude enthaelt keine Ausschluesse (IsActive=0). Alle Datenbanken werden gesichert." -FunctionName $functionName -Level "INFO" } } else { Invoke-sqmLogging -Message "Tabelle sqm_BackupExclude nicht gefunden. Alle Datenbanken werden gesichert." -FunctionName $functionName -Level "WARNING" } } catch { Invoke-sqmLogging -Message "Konnte sqm_BackupExclude nicht auslesen: $($_.Exception.Message). Alle Datenbanken werden gesichert." -FunctionName $functionName -Level "WARNING" } } # AG-Datenbanken nur und immer auf dem Primary sichern: auf einem Secondary # ueberspringen, unabhaengig von der AG-Backup-Preference. Sonst sichern bei lesbarem # Secondary beide Knoten, und bei gleichem Dateinamen in derselben Sekunde scheitert # einer mit OS-Fehler 32. $agSecondaryQuery = @" SELECT d.name AS DatabaseName FROM sys.databases d JOIN sys.dm_hadr_availability_replica_states ars ON ars.replica_id = d.replica_id WHERE ars.is_local = 1 AND ars.role <> 1 "@ try { $agSecondaryNames = @(Invoke-DbaQuery -SqlInstance $SqlInstance -SqlCredential $SqlCredential ` -Database master -Query $agSecondaryQuery -EnableException | Select-Object -ExpandProperty DatabaseName) } catch { $agSecondaryNames = @() Invoke-sqmLogging -Message "AG-Rollenpruefung fehlgeschlagen: $($_.Exception.Message). Es wird ohne AG-Filter gesichert." -FunctionName $functionName -Level "WARNING" } if ($agSecondaryNames.Count -gt 0) { foreach ($db in @($databases | Where-Object { $_.Name -in $agSecondaryNames })) { $skipMsg = "AG-Datenbank '$($db.Name)': lokales Replikat ist nicht Primary, kein Backup." Invoke-sqmLogging -Message $skipMsg -FunctionName $functionName -Level "INFO" $results += [PSCustomObject]@{ SqlInstance = $SqlInstance DatabaseName = $db.Name BackupFile = $null Status = "SkippedAgSecondary" Message = $skipMsg } } $databases = @($databases | Where-Object { $_.Name -notin $agSecondaryNames }) } # Backup fuer jede Datenbank foreach ($db in $databases) { $dbName = $db.Name $backupFile = Join-Path -Path $BackupPath -ChildPath "${dbName}_$(Get-Date -Format 'yyyyMMdd_HHmmss').$backupFileExtension" $backupParams = @{ SqlInstance = $SqlInstance SqlCredential = $SqlCredential Database = $dbName Path = $backupFile Type = $dbaBackupType BackupFileName = $backupFile ErrorAction = 'Stop' } if ($EnableException) { $backupParams.EnableException = $true } $actionMsg = "Sichere Datenbank '$dbName' ($BackupType) nach '$backupFile'" if ($PSCmdlet.ShouldProcess($dbName, $actionMsg)) { try { Invoke-sqmLogging -Message $actionMsg -FunctionName $functionName -Level "INFO" $backupResult = Backup-DbaDatabase @backupParams $successMsg = "Backup von '$dbName' erfolgreich abgeschlossen. Datei: $backupFile" Invoke-sqmLogging -Message $successMsg -FunctionName $functionName -Level "INFO" $results += [PSCustomObject]@{ SqlInstance = $SqlInstance DatabaseName = $dbName BackupFile = $backupFile Status = "Success" Message = $successMsg } } catch { $errMsg = "Fehler beim Backup von '$dbName': $($_.Exception.Message)" Invoke-sqmLogging -Message $errMsg -FunctionName $functionName -Level "ERROR" if ($EnableException) { throw } $results += [PSCustomObject]@{ SqlInstance = $SqlInstance DatabaseName = $dbName BackupFile = $null Status = "Failed" Message = $errMsg } } } else { $skipMsg = "WhatIf: Backup von '$dbName' uebersprungen." Invoke-sqmLogging -Message $skipMsg -FunctionName $functionName -Level "VERBOSE" $results += [PSCustomObject]@{ SqlInstance = $SqlInstance DatabaseName = $dbName BackupFile = $null Status = "WhatIfSkipped" Message = $skipMsg } } } } catch { $errMsg = "Allgemeiner Fehler: $($_.Exception.Message)" Invoke-sqmLogging -Message $errMsg -FunctionName $functionName -Level "ERROR" if ($EnableException) { throw } $results += [PSCustomObject]@{ SqlInstance = $SqlInstance DatabaseName = $null BackupFile = $null Status = "GlobalError" Message = $errMsg } } } end { Invoke-sqmLogging -Message "$functionName abgeschlossen. $($results.Count) Objekte zurueckgegeben." -FunctionName $functionName -Level "INFO" # Alte Backups aufraeumen wenn -CleanupTime angegeben. Nur die Dateiendung dieses # Laufs (.bak fuer FULL/DIFF, .trn fuer LOG) wird angefasst, damit z.B. ein LOG-Lauf # nicht versehentlich FULL/DIFF-Dateien im selben Verzeichnis loescht. $cleanupRemovedCount = $null if ($CleanupTime) { $cleanupAction = "Entferne .$backupFileExtension-Dateien aelter als $CleanupTime Stunden" if ($PSCmdlet.ShouldProcess($BackupPath, $cleanupAction)) { try { Invoke-sqmLogging -Message "$cleanupAction in '$BackupPath'." -FunctionName $functionName -Level "INFO" $cleanupResult = Remove-DbaBackup -Path $BackupPath -BackupFileExtension $backupFileExtension ` -RetentionPeriod "${CleanupTime}h" -EnableException:$EnableException $cleanupRemovedCount = @($cleanupResult).Count Invoke-sqmLogging -Message "Cleanup abgeschlossen: $cleanupRemovedCount Datei(en) entfernt." -FunctionName $functionName -Level "INFO" } catch { Invoke-sqmLogging -Message "Cleanup fehlgeschlagen: $($_.Exception.Message)" -FunctionName $functionName -Level "WARNING" if ($EnableException) { throw } } } } # Mail-Benachrichtigung if ($MailTo) { $failedCount = ($results | Where-Object { $_.Status -eq 'Failed' }).Count $successCount = ($results | Where-Object { $_.Status -eq 'Success' }).Count $skippedCount = ($results | Where-Object { $_.Status -notin 'Success', 'Failed' }).Count $shouldSend = $failedCount -gt 0 -or $MailOnSuccess if ($shouldSend) { $subject = if ($failedCount -gt 0) { "[$SqlInstance] $BackupType Backup FEHLER - $failedCount fehlgeschlagen" } else { "[$SqlInstance] $BackupType Backup erfolgreich - $successCount Datenbanken" } $bodyLines = @( "Backup-Report: $SqlInstance ($BackupType)" "Zeitpunkt: $(Get-Date -Format 'yyyy-MM-dd HH:mm:ss')" "Erfolgreich : $successCount" "Fehlgeschlagen: $failedCount" "Uebersprungen: $skippedCount" ) if ($null -ne $cleanupRemovedCount) { $bodyLines += "Cleanup (${CleanupTime}h): $cleanupRemovedCount Datei(en) entfernt" } $bodyLines += "" $bodyLines += "Details:" foreach ($r in $results) { $bodyLines += " [$($r.Status)] $($r.DatabaseName) - $($r.Message)" } $body = $bodyLines -join "`n" $mailSql = @" EXEC msdb.dbo.sp_send_dbmail @profile_name = '$($MailProfile.Replace("'","''"))', @recipients = '$($MailTo.Replace("'","''"))', @subject = '$($subject.Replace("'","''"))', @body = '$($body.Replace("'","''"))'; "@ try { Invoke-DbaQuery -SqlInstance $SqlInstance -SqlCredential $SqlCredential ` -Database msdb -Query $mailSql -ErrorAction Stop Invoke-sqmLogging -Message "Backup-Mail gesendet an: $MailTo" -FunctionName $functionName -Level "INFO" } catch { Invoke-sqmLogging -Message "Mail konnte nicht gesendet werden: $($_.Exception.Message)" -FunctionName $functionName -Level "WARNING" } } } return $results } } |