#################################################################################################################################### # EXPIRE MODULE #################################################################################################################################### package pgBackRest::Expire; use strict; use warnings FATAL => qw(all); use Carp qw(confess); use Exporter qw(import); use File::Basename qw(dirname); use Scalar::Util qw(looks_like_number); use pgBackRest::Archive::Common; use pgBackRest::Archive::Get::Get; use pgBackRest::Archive::Info; use pgBackRest::Common::Exception; use pgBackRest::Common::Ini; use pgBackRest::Common::Log; use pgBackRest::Backup::Common; use pgBackRest::Backup::Info; use pgBackRest::Config::Config; use pgBackRest::InfoCommon; use pgBackRest::Manifest; use pgBackRest::Protocol::Helper; use pgBackRest::Protocol::Storage::Helper; #################################################################################################################################### # new #################################################################################################################################### sub new { my $class = shift; # Create the class hash my $self = {}; bless $self, $class; # Assign function parameters, defaults, and log debug info my ($strOperation) = logDebugParam(__PACKAGE__ . '->new'); # Initialize total archive expired $self->{iArchiveExpireTotal} = 0; # Return from function and log return values if any return logDebugReturn ( $strOperation, {name => 'self', value => $self} ); } #################################################################################################################################### # logExpire # # Tracks which archive logs have been removed and provides log messages when needed. #################################################################################################################################### sub logExpire { my $self = shift; my $strArchiveId = shift; my $strArchiveFile = shift; if (defined($strArchiveFile)) { if (!defined($self->{strArchiveExpireStart})) { $self->{strArchiveExpireStart} = $strArchiveFile; $self->{strArchiveExpireStop} = $strArchiveFile; } else { $self->{strArchiveExpireStop} = $strArchiveFile; } $self->{iArchiveExpireTotal}++; } else { if (defined($self->{strArchiveExpireStart})) { &log(DETAIL, "remove archive: archiveId = ${strArchiveId}, start = " . substr($self->{strArchiveExpireStart}, 0, 24) . ", stop = " . substr($self->{strArchiveExpireStop}, 0, 24)); } undef($self->{strArchiveExpireStart}); } } #################################################################################################################################### # process # # Removes expired backups and archive logs from the backup directory. Partial backups are not counted for expiration, so if full # or differential retention is set to 2, there must be three complete backups before the oldest one can be deleted. #################################################################################################################################### sub process { my $self = shift; # Assign function parameters, defaults, and log debug info my ($strOperation) = logDebugParam(__PACKAGE__ . '->process'); my @stryPath; my $oStorageRepo = storageRepo(); my $strBackupClusterPath = $oStorageRepo->pathGet(STORAGE_REPO_BACKUP); my $iFullRetention = cfgOption(CFGOPT_REPO_RETENTION_FULL, false); my $iDifferentialRetention = cfgOption(CFGOPT_REPO_RETENTION_DIFF, false); my $strArchiveRetentionType = cfgOption(CFGOPT_REPO_RETENTION_ARCHIVE_TYPE, false); my $iArchiveRetention = cfgOption(CFGOPT_REPO_RETENTION_ARCHIVE, false); # Load the backup.info my $oBackupInfo = new pgBackRest::Backup::Info($oStorageRepo->pathGet(STORAGE_REPO_BACKUP)); # Find all the expired full backups if (defined($iFullRetention)) { # Make sure iFullRetention is valid if (!looks_like_number($iFullRetention) || $iFullRetention < 1) { confess &log(ERROR, cfgOptionName(CFGOPT_REPO_RETENTION_FULL) . ' must be a number >= 1'); } @stryPath = $oBackupInfo->list(backupRegExpGet(true)); if (@stryPath > $iFullRetention) { # Expire all backups that depend on the full backup for (my $iFullIdx = 0; $iFullIdx < @stryPath - $iFullRetention; $iFullIdx++) { my @stryRemoveList; foreach my $strPath ($oBackupInfo->list('^' . $stryPath[$iFullIdx] . '.*')) { $oStorageRepo->remove(STORAGE_REPO_BACKUP . "/${strPath}/" . FILE_MANIFEST . INI_COPY_EXT); $oStorageRepo->remove(STORAGE_REPO_BACKUP . "/${strPath}/" . FILE_MANIFEST); $oBackupInfo->delete($strPath); if ($strPath ne $stryPath[$iFullIdx]) { push(@stryRemoveList, $strPath); } } &log(INFO, 'expire full backup ' . (@stryRemoveList > 0 ? 'set: ' : '') . $stryPath[$iFullIdx] . (@stryRemoveList > 0 ? ', ' . join(', ', @stryRemoveList) : '')); } } } # Find all the expired differential backups if (defined($iDifferentialRetention)) { # Make sure iDifferentialRetention is valid if (!looks_like_number($iDifferentialRetention) || $iDifferentialRetention < 1) { confess &log(ERROR, cfgOptionName(CFGOPT_REPO_RETENTION_DIFF) . ' must be a number >= 1'); } # Get a list of full and differential backups. Full are considered differential for the purpose of retention. # Example: F1, D1, D2, F2 and repo-retention-diff=2, then F1,D2,F2 will be retained, not D2 and D1 as might be expected. @stryPath = $oBackupInfo->list(backupRegExpGet(true, true)); if (@stryPath > $iDifferentialRetention) { for (my $iDiffIdx = 0; $iDiffIdx < @stryPath - $iDifferentialRetention; $iDiffIdx++) { # Skip if this is a full backup. Full backups only count as differential when deciding which differential backups # to expire. next if ($stryPath[$iDiffIdx] =~ backupRegExpGet(true)); # Get a list of all differential and incremental backups my @stryRemoveList; foreach my $strPath ($oBackupInfo->list(backupRegExpGet(false, true, true))) { logDebugMisc($strOperation, "checking ${strPath} for differential expiration"); # Remove all differential and incremental backups before the oldest valid differential if ($strPath lt $stryPath[$iDiffIdx + 1]) { $oStorageRepo->remove(STORAGE_REPO_BACKUP . "/${strPath}" . FILE_MANIFEST); $oBackupInfo->delete($strPath); if ($strPath ne $stryPath[$iDiffIdx]) { push(@stryRemoveList, $strPath); } } } &log(INFO, 'expire diff backup ' . (@stryRemoveList > 0 ? 'set: ' : '') . $stryPath[$iDiffIdx] . (@stryRemoveList > 0 ? ', ' . join(', ', @stryRemoveList) : '')); } } } $oBackupInfo->save(); # Remove backups from disk foreach my $strBackup ($oStorageRepo->list( STORAGE_REPO_BACKUP, {strExpression => backupRegExpGet(true, true, true), strSortOrder => 'reverse'})) { if (!$oBackupInfo->current($strBackup)) { &log(INFO, "remove expired backup ${strBackup}"); $oStorageRepo->remove("${strBackupClusterPath}/${strBackup}", {bRecurse => true}); } } # If archive retention is still undefined, then ignore archiving if (!defined($iArchiveRetention)) { &log(INFO, "option '" . cfgOptionName(CFGOPT_REPO_RETENTION_ARCHIVE) . "' is not set - archive logs will not be expired"); } else { my @stryGlobalBackupRetention; # Determine which backup type to use for archive retention (full, differential, incremental) and get a list of the # remaining non-expired backups based on the type. if ($strArchiveRetentionType eq CFGOPTVAL_BACKUP_TYPE_FULL) { @stryGlobalBackupRetention = $oBackupInfo->list(backupRegExpGet(true), 'reverse'); } elsif ($strArchiveRetentionType eq CFGOPTVAL_BACKUP_TYPE_DIFF) { @stryGlobalBackupRetention = $oBackupInfo->list(backupRegExpGet(true, true), 'reverse'); } elsif ($strArchiveRetentionType eq CFGOPTVAL_BACKUP_TYPE_INCR) { @stryGlobalBackupRetention = $oBackupInfo->list(backupRegExpGet(true, true, true), 'reverse'); } # If no backups were found then preserve current archive logs - too soon to expire them my $iBackupTotal = scalar @stryGlobalBackupRetention; if ($iBackupTotal > 0) { my $oArchiveInfo = new pgBackRest::Archive::Info($oStorageRepo->pathGet(STORAGE_REPO_ARCHIVE), true); my @stryListArchiveDisk = sort {((split('-', $a))[1] + 0) cmp ((split('-', $b))[1] + 0)} $oStorageRepo->list( STORAGE_REPO_ARCHIVE, {strExpression => REGEX_ARCHIVE_DIR_DB_VERSION, bIgnoreMissing => true}); # Make sure the current database versions match between the two files if (!($oArchiveInfo->test(INFO_ARCHIVE_SECTION_DB, INFO_ARCHIVE_KEY_DB_VERSION, undef, ($oBackupInfo->get(INFO_BACKUP_SECTION_DB, INFO_BACKUP_KEY_DB_VERSION)))) || !($oArchiveInfo->test(INFO_ARCHIVE_SECTION_DB, INFO_ARCHIVE_KEY_DB_SYSTEM_ID, undef, ($oBackupInfo->get(INFO_BACKUP_SECTION_DB, INFO_BACKUP_KEY_SYSTEM_ID))))) { confess &log(ERROR, "archive and backup database versions do not match\n" . "HINT: has a stanza-upgrade been performed?", ERROR_FILE_INVALID); } # Get the list of backups that are part of archive retention my @stryTmp = @stryGlobalBackupRetention; my @stryGlobalBackupArchiveRetention = splice(@stryTmp, 0, $iArchiveRetention); # For each archiveId, remove WAL that are not part of retention foreach my $strArchiveId (@stryListArchiveDisk) { # From the global list of backups to retain, create a list of backups, oldest to newest, associated with this # archiveId (e.g. 9.4-1) my @stryLocalBackupRetention = $oBackupInfo->listByArchiveId($strArchiveId, $oStorageRepo->pathGet(STORAGE_REPO_ARCHIVE), \@stryGlobalBackupRetention, 'reverse'); # If no backup to retain was found if (!@stryLocalBackupRetention) { # Get the backup db-id corresponding to this archiveId my $iDbHistoryId = $oBackupInfo->backupArchiveDbHistoryId( $strArchiveId, $oStorageRepo->pathGet(STORAGE_REPO_ARCHIVE)); # If this is not the current database, then delete the archive directory else do nothing since the current # DB archive directory must not be deleted if (!defined($iDbHistoryId) || !$oBackupInfo->test(INFO_BACKUP_SECTION_DB, INFO_BACKUP_KEY_HISTORY_ID, undef, $iDbHistoryId)) { my $strFullPath = $oStorageRepo->pathGet(STORAGE_REPO_ARCHIVE . "/${strArchiveId}"); $oStorageRepo->remove($strFullPath, {bRecurse => true}); &log(INFO, "remove archive path: ${strFullPath}"); } # Continue to next directory next; } my @stryLocalBackupArchiveRentention; # If the archive retention is less than or equal to the number of all backups, then perform selective expiration if (@stryGlobalBackupArchiveRetention && $iArchiveRetention <= scalar @stryGlobalBackupRetention) { # From the full list of backups in archive retention, find the intersection of local backups to retain foreach my $strGlobalBackupArchiveRetention (@stryGlobalBackupArchiveRetention) { foreach my $strLocalBackupRetention (@stryLocalBackupRetention) { if ($strLocalBackupRetention eq $strGlobalBackupArchiveRetention) { unshift(@stryLocalBackupArchiveRentention, $strLocalBackupRetention); } } } } # Else if there are not enough backups yet globally to start archive expiration then set the archive retention # to the oldest backup so anything prior to that will be removed as it is not needed but everything else is # This is incase there are old archives left around so that they don't stay around forever else { if ($strArchiveRetentionType eq CFGOPTVAL_BACKUP_TYPE_FULL && scalar @stryLocalBackupRetention > 0) { &log(INFO, "full backup total < ${iArchiveRetention} - using oldest full backup for ${strArchiveId} " . "archive retention"); $stryLocalBackupArchiveRentention[0] = $stryLocalBackupRetention[0]; } } # If no local backups were found as part of retention then set the backup archive retention to the newest backup # so that the database is fully recoverable (can be recovered from the last backup through pitr) if (!@stryLocalBackupArchiveRentention) { $stryLocalBackupArchiveRentention[0] = $stryLocalBackupRetention[-1]; } my $strArchiveRetentionBackup = $stryLocalBackupArchiveRentention[0]; # If a backup has been selected for retention then continue if (defined($strArchiveRetentionBackup)) { my $bRemove; # Only expire if the selected backup has archive info - backups performed with --no-online will # not have archive info and cannot be used for expiration. if ($oBackupInfo->test(INFO_BACKUP_SECTION_BACKUP_CURRENT, $strArchiveRetentionBackup, INFO_BACKUP_KEY_ARCHIVE_START)) { # Get archive ranges to preserve. Because archive retention can be less than total retention it is # important to preserve archive that is required to make the older backups consistent even though they # cannot be played any further forward with PITR. my $strArchiveExpireMax; my @oyArchiveRange; my @stryBackupList = $oBackupInfo->list(); # With the full list of backups, loop through only those associated with this archiveId foreach my $strBackup ( $oBackupInfo->listByArchiveId( $strArchiveId, $oStorageRepo->pathGet(STORAGE_REPO_ARCHIVE), \@stryBackupList)) { if ($strBackup le $strArchiveRetentionBackup && $oBackupInfo->test(INFO_BACKUP_SECTION_BACKUP_CURRENT, $strBackup, INFO_BACKUP_KEY_ARCHIVE_START)) { my $oArchiveRange = {}; $$oArchiveRange{start} = $oBackupInfo->get(INFO_BACKUP_SECTION_BACKUP_CURRENT, $strBackup, INFO_BACKUP_KEY_ARCHIVE_START); if ($strBackup ne $strArchiveRetentionBackup) { $$oArchiveRange{stop} = $oBackupInfo->get(INFO_BACKUP_SECTION_BACKUP_CURRENT, $strBackup, INFO_BACKUP_KEY_ARCHIVE_STOP); } else { $strArchiveExpireMax = $$oArchiveRange{start}; } &log(DETAIL, "archive retention on backup ${strBackup}, archiveId = ${strArchiveId}, " . "start = $$oArchiveRange{start}" . (defined($$oArchiveRange{stop}) ? ", stop = $$oArchiveRange{stop}" : '')); push(@oyArchiveRange, $oArchiveRange); } } # Get all major archive paths (timeline and first 32 bits of LSN) foreach my $strPath ($oStorageRepo->list( STORAGE_REPO_ARCHIVE . "/${strArchiveId}", {strExpression => REGEX_ARCHIVE_DIR_WAL})) { logDebugMisc($strOperation, "found major WAL path: ${strPath}"); $bRemove = true; # Keep the path if it falls in the range of any backup in retention foreach my $oArchiveRange (@oyArchiveRange) { if ($strPath ge substr($$oArchiveRange{start}, 0, 16) && (!defined($$oArchiveRange{stop}) || $strPath le substr($$oArchiveRange{stop}, 0, 16))) { $bRemove = false; last; } } # Remove the entire directory if all archive is expired if ($bRemove) { my $strFullPath = $oStorageRepo->pathGet(STORAGE_REPO_ARCHIVE . "/${strArchiveId}") . "/${strPath}"; $oStorageRepo->remove($strFullPath, {bRecurse => true}); # Log expire info logDebugMisc($strOperation, "remove major WAL path: ${strFullPath}"); $self->logExpire($strArchiveId, $strPath); } # Else delete individual files instead if the major path is less than or equal to the most recent # retention backup. This optimization prevents scanning though major paths that could not possibly # have anything to expire. elsif ($strPath le substr($strArchiveExpireMax, 0, 16)) { # Look for files in the archive directory foreach my $strSubPath ($oStorageRepo->list( STORAGE_REPO_ARCHIVE . "/${strArchiveId}/${strPath}", {strExpression => "^[0-F]{24}.*\$"})) { $bRemove = true; # Determine if the individual archive log is used in a backup foreach my $oArchiveRange (@oyArchiveRange) { if (substr($strSubPath, 0, 24) ge $$oArchiveRange{start} && (!defined($$oArchiveRange{stop}) || substr($strSubPath, 0, 24) le $$oArchiveRange{stop})) { $bRemove = false; last; } } # Remove archive log if it is not used in a backup if ($bRemove) { $oStorageRepo->remove(STORAGE_REPO_ARCHIVE . "/${strArchiveId}/${strSubPath}"); logDebugMisc($strOperation, "remove WAL segment: ${strArchiveId}/${strSubPath}"); # Log expire info $self->logExpire($strArchiveId, substr($strSubPath, 0, 24)); } else { # Log that the file was not expired $self->logExpire($strArchiveId); } } } } # Log if no archive was expired if ($self->{iArchiveExpireTotal} == 0) { &log(DETAIL, "no archive to remove, archiveId = ${strArchiveId}"); } } } } } } # Return from function and log return values if any return logDebugReturn($strOperation); } 1;