root/trunk/omnipitr/lib/OmniPITR/Program/Backup/Slave.pm

Revision 226, 18.3 kB (checked in by depesz, 3 years ago)

fix bug with pidfile handling

Line 
1 package OmniPITR::Program::Backup::Slave;
2 use strict;
3 use warnings;
4
5 use base qw( OmniPITR::Program::Backup );
6
7 use File::Spec;
8 use File::Basename;
9 use English qw( -no_match_vars );
10 use File::Copy;
11 use File::Path;
12 use Getopt::Long;
13 use Carp;
14 use POSIX qw( strftime );
15 use Sys::Hostname;
16 use OmniPITR::Tools qw( run_command ext_for_compression );
17
18 =head1 make_data_archive()
19
20 Wraps all work necessary to make local .tar files (optionally compressed)
21 with content of PGDATA
22
23 =cut
24
25 sub make_data_archive {
26     my $self = shift;
27     $self->pause_xlog_removal();
28     $self->{ 'CONTROL' }->{ 'initial' } = $self->get_control_data();
29     $self->compress_pgdata();
30     return;
31 }
32
33 =head1 make_xlog_archive()
34
35 Wraps all work necessary to make local .tar files (optionally compressed)
36 with xlogs required to start PostgreSQL from backup.
37
38 =cut
39
40 sub make_xlog_archive {
41     my $self = shift;
42     $self->wait_for_checkpoint_location_change();
43     $self->compress_xlogs();
44     $self->unpause_xlog_removal();
45     return;
46 }
47
48 =head1 compress_xlogs()
49
50 Wrapper function which encapsulates all work required to compress xlog
51 segments that accumulated during backup of data directory.
52
53 =cut
54
55 sub compress_xlogs {
56     my $self = shift;
57
58     $self->make_dot_backup_file();
59     $self->uncompress_wal_archive_segments();
60
61     $self->log->time_start( 'Compressing xlogs' ) if $self->verbose;
62     $self->start_writers( 'xlog' );
63
64     my $source_transform_from = basename( $self->{ 'source' }->{ 'path' } );
65     $source_transform_from =~ s{^/*}{};
66     $source_transform_from =~ s{/*$}{};
67
68     my $dot_backup_transform_from = $self->{ 'temp-dir' };
69     $dot_backup_transform_from =~ s{^/*}{};
70     $dot_backup_transform_from =~ s{/*$}{};
71
72     my $transform_to = basename( $self->{ 'data-dir' } ) . '/pg_xlog';
73     my $transform_command = sprintf 's#^\(%s\|%s\)#%s#', $source_transform_from, $dot_backup_transform_from, $transform_to;
74
75     $self->tar_and_compress(
76         'work_dir'  => dirname( $self->{ 'source' }->{ 'path' } ),
77         'tar_dir'   => [ basename( $self->{ 'source' }->{ 'path' } ), File::Spec->catfile( $self->{ 'temp-dir' }, $self->{ 'dot_backup_filename' } ), ],
78         'transform' => $transform_command,
79     );
80
81     $self->log->time_finish( 'Compressing xlogs' ) if $self->verbose;
82
83     return;
84 }
85
86 =head1 uncompress_wal_archive_segments()
87
88 In case walarchive (--source option) is compressed, L<omnipitr-backup-slave>
89 needs to uncompress files to temp directory before making archive - so that
90 the archive will be easier to use.
91
92 This work is being done in this function.
93
94 =cut
95
96 sub uncompress_wal_archive_segments {
97     my $self = shift;
98     return if 'none' eq $self->{ 'source' }->{ 'compression' };
99
100     my $old_source = $self->{ 'source' }->{ 'path' };
101     my $new_source = File::Spec->catfile( $self->{ 'temp-dir' }, 'uncompresses_pg_xlogs' );
102     $self->{ 'source' }->{ 'path' } = $new_source;
103
104     mkpath( [ $new_source ], 0, oct( "755" ) );
105
106     opendir my $dir, $old_source or $self->log->fatal( 'Cannot open wal-archive (%s) : %s', $old_source, $OS_ERROR );
107     my $extension = ext_for_compression( $self->{ 'source' }->{ 'compression' } );
108     my @wal_segments = sort grep { -f File::Spec->catfile( $old_source, $_ ) && /\Q$extension\E\z/ } readdir( $dir );
109     close $dir;
110
111     $self->log->log( '%s wal segments have to be uncompressed', scalar @wal_segments );
112
113     for my $segment ( @wal_segments ) {
114         my $old_file = File::Spec->catfile( $old_source, $segment );
115         my $new_file = File::Spec->catfile( $new_source, $segment );
116         copy( $old_file, $new_file ) or $self->log->fatal( 'Cannot copy %s to %s: %s', $old_file, $new_file, $OS_ERROR );
117         $self->log->log( 'File copied: %s -> %s', $old_file, $new_file );
118         my @uncompress = ( $self->{ $self->{ 'source' }->{ 'compression' } . '-path' }, '-d', $new_file );
119         unshift @uncompress, $self->{ 'nice-path' } unless $self->{ 'not-nice' };
120         my $response = run_command( $self->{ 'temp-dir' }, @uncompress );
121         if ( $response->{ 'error_code' } ) {
122             $self->log->fatal( 'Error while uncompressing wal segment %s: %s', $new_file, $response );
123         }
124     }
125     return;
126 }
127
128 =head make_dot_backup_file()
129
130 Make I<SEGMENT>.I<OFFSET>.backup file that will be included in xlog archive.
131
132 This file contains vital information like start and end position of WAL
133 reply that is required to get consistent state.
134
135 =cut
136
137 sub make_dot_backup_file {
138     my $self = shift;
139
140     my $redo_location = $self->{ 'CONTROL' }->{ 'initial' }->{ "Latest checkpoint's REDO location" };
141     my $timeline      = $self->{ 'CONTROL' }->{ 'initial' }->{ "Latest checkpoint's TimeLineID" };
142
143     my $final_location = $self->{ 'CONTROL' }->{ 'final' }->{ "Latest checkpoint location" };
144     if (   ( defined $self->{ 'CONTROL' }->{ 'final' }->{ 'Minimum recovery ending location' } )
145         && ( $self->{ 'CONTROL' }->{ 'final' }->{ 'Minimum recovery ending location' } =~ m{\A[a-f0-9]+/[a-f0-9]+\z}i )
146         && ( '0/0' ne $self->{ 'CONTROL' }->{ 'final' }->{ 'Minimum recovery ending location' } ) )
147     {
148         $final_location = $self->{ 'CONTROL' }->{ 'final' }->{ 'Minimum recovery ending location' };
149     }
150     my $final_wal_filename = $self->convert_wal_location_and_timeline_to_filename( $final_location, $timeline );
151
152     my $final_wal_filename_re = qr{\A$final_wal_filename};
153     $self->wait_for_file( $self->{ 'source' }->{ 'path' }, $final_wal_filename_re );
154
155     my $offset = $redo_location;
156     $offset =~ s#.*/##;
157     $offset =~ s/^.*?(.{0,6})$/$1/;
158
159     my $output_filename = sprintf '%s.%08s.backup', $self->convert_wal_location_and_timeline_to_filename( $redo_location, $timeline ), $offset;
160
161     my @content_lines = @{ $self->{ 'backup_file_data' } };
162     splice( @content_lines, 1, 0, sprintf 'STOP WAL LOCATION: %s (file %s)', $final_location, $final_wal_filename );
163     splice( @content_lines, 4, 0, sprintf 'START TIME: %s', strftime( '%Y-%m-%d %H:%M:%S %Z', localtime time ) );
164
165     my $content = join( "\n", @content_lines ) . "\n";
166
167     my $filename = File::Spec->catfile( $self->{ 'temp-dir' }, $output_filename );
168     if ( open my $fh, '>', $filename ) {
169         print $fh $content;
170         close $fh;
171         $self->{ 'dot_backup_filename' } = $output_filename;
172         return;
173     }
174     $self->log->fatal( 'Cannot write .backup file file %s : %s', $output_filename, $OS_ERROR );
175 }
176
177 =head1 wait_for_checkpoint_location_change()
178
179 Just like the name suggests - this function periodically (every 5 seconds,
180 hardcoded, as there is not much sense in parametrizing it) checks
181 pg_controldata of PGDATA, and finishes if value in B<Latest checkpoint
182 location> will change.
183
184 =cut
185
186 sub wait_for_checkpoint_location_change {
187     my $self     = shift;
188     my $pre_wait = $self->get_control_data()->{ 'Latest checkpoint location' };
189     $self->log->log( 'Waiting for checkpoint' ) if $self->verbose;
190     while ( 1 ) {
191         sleep 5;
192         $self->{ 'CONTROL' }->{ 'final' } = $self->get_control_data();
193         last if $self->{ 'CONTROL' }->{ 'final' }->{ 'Latest checkpoint location' } ne $pre_wait;
194     }
195     $self->log->log( 'Checkpoint .' ) if $self->verbose;
196     return;
197 }
198
199 =head1 make_backup_label_temp_file()
200
201 Normal hot backup contains file named 'backup_label' in PGDATA archive.
202
203 Since this is not normal hot backup - PostgreSQL will not create this file,
204 and it has to be created separately by I<omnipitr-backup-slave>.
205
206 This file is created in temp directory (it is B<not> created in PGDATA), and
207 is included in tar in such a way that, on uncompressing, it will get to
208 unarchived PGDATA.
209
210 =cut
211
212 sub make_backup_label_temp_file {
213     my $self = shift;
214
215     my $redo_location = $self->{ 'CONTROL' }->{ 'initial' }->{ "Latest checkpoint's REDO location" };
216     my $last_location = $self->{ 'CONTROL' }->{ 'initial' }->{ "Latest checkpoint location" };
217     my $timeline      = $self->{ 'CONTROL' }->{ 'initial' }->{ "Latest checkpoint's TimeLineID" };
218
219     my @content_lines = ();
220     push @content_lines, sprintf 'START WAL LOCATION: %s (file %s)', $redo_location, $self->convert_wal_location_and_timeline_to_filename( $redo_location, $timeline );
221     push @content_lines, sprintf 'CHECKPOINT LOCATION: %s', $last_location;
222     push @content_lines, sprintf 'START TIME: %s', strftime( '%Y-%m-%d %H:%M:%S %Z', localtime time );
223     push @content_lines, 'LABEL: OmniPITR_Slave_Hot_Backup';
224
225     $self->{ 'backup_file_data' } = \@content_lines;
226     my $content = join( "\n", @content_lines ) . "\n";
227
228     my $filename = File::Spec->catfile( $self->{ 'temp-dir' }, 'backup_label' );
229     if ( open my $fh, '>', $filename ) {
230         print $fh $content;
231         close $fh;
232         return;
233     }
234     $self->log->fatal( 'Cannot write backup_label file %s : %s', $filename, $OS_ERROR );
235 }
236
237 =head1 convert_wal_location_and_timeline_to_filename()
238
239 Helper function which converts WAL location and timeline number into
240 filename that given location will be in.
241
242 =cut
243
244 sub convert_wal_location_and_timeline_to_filename {
245     my $self = shift;
246     my ( $location, $timeline ) = @_;
247
248     my ( $series, $offset ) = split m{/}, $location;
249
250     $offset =~ s/.{0,6}$//;
251
252     my $location_filename = sprintf '%08s%08s%08s', $timeline, $series, $offset;
253
254     return $location_filename;
255 }
256
257 =head1 compress_pgdata()
258
259 Wrapper function which encapsulates all work required to compress data
260 directory.
261
262 =cut
263
264 sub compress_pgdata {
265     my $self = shift;
266
267     $self->make_backup_label_temp_file();
268
269     $self->log->time_start( 'Compressing $PGDATA' ) if $self->verbose;
270     $self->start_writers( 'data' );
271
272     my $transform_from = $self->{ 'temp-dir' };
273     $transform_from =~ s{^/*}{};
274     $transform_from =~ s{/*$}{};
275     my $transform_to = basename( $self->{ 'data-dir' } );
276     my $transform_command = sprintf 's#^%s/#%s/#', $transform_from, $transform_to;
277
278     my @excludes = qw( pg_log/* pg_xlog/0* pg_xlog/archive_status/* recovery.conf postmaster.pid );
279     for my $dir ( qw( pg_log pg_xlog ) ) {
280         push @excludes, $dir if -l File::Spec->catfile( $self->{ 'data-dir' }, $dir );
281     }
282
283     my ( $tablespaces, $transforms ) = $self->get_tablespaces_and_transforms();
284     push @{ $tablespaces }, basename( $self->{ 'data-dir' } ), File::Spec->catfile( $self->{ 'temp-dir' }, 'backup_label' );
285     push @{ $transforms }, $transform_command;
286
287     $self->tar_and_compress(
288         'work_dir'  => dirname( $self->{ 'data-dir' } ),
289         'tar_dir'   => $tablespaces,
290         'excludes'  => [ map { sprintf( '%s/%s', basename( $self->{ 'data-dir' } ), $_ ) } @excludes ],
291         'transform' => $transforms,
292     );
293
294     $self->log->time_finish( 'Compressing $PGDATA' ) if $self->verbose;
295     return;
296 }
297
298 =head1 pause_xlog_removal()
299
300 Creates trigger file that will pause removal of old segments by
301 I<omnipitr-restore>.
302
303 =cut
304
305 sub pause_xlog_removal {
306     my $self = shift;
307
308     if ( open my $fh, '>', $self->{ 'removal-pause-trigger' } ) {
309         print $fh $PROCESS_ID, "\n";
310         close $fh;
311         $self->{ 'removal-pause-trigger-created' } = 1;
312         return;
313     }
314     $self->log->fatal(
315         'Cannot create/write to removal pause trigger (%s) : %S',
316         $self->{ 'removal-pause-trigger' },
317         $OS_ERROR
318     );
319 }
320
321 =head1 unpause_xlog_removal()
322
323 Removed trigger file, effectively unpausing removal of old, obsolete log
324 segments in I<omnipitr-restore>.
325
326 =cut
327
328 sub unpause_xlog_removal {
329     my $self = shift;
330     unlink( $self->{ 'removal-pause-trigger' } );
331     delete $self->{ 'removal-pause-trigger-created' };
332     return;
333 }
334
335 =head1 DESTROY()
336
337 Destructor for object - removes created pause trigger;
338
339 =cut
340
341 sub DESTROY {
342     my $self = shift;
343     unlink( $self->{ 'removal-pause-trigger' } ) if $self->{ 'removal-pause-trigger-created' };
344     $self->SUPER::DESTROY();
345     return;
346 }
347
348 =head1 read_args()
349
350 Function which does all the parsing, and transformation of command line
351 arguments.
352
353 =cut
354
355 sub read_args {
356     my $self = shift;
357
358     my @argv_copy = @ARGV;
359
360     my %args = (
361         'temp-dir' => $ENV{ 'TMPDIR' } || '/tmp',
362         'gzip-path'          => 'gzip',
363         'bzip2-path'         => 'bzip2',
364         'lzma-path'          => 'lzma',
365         'tar-path'           => 'tar',
366         'nice-path'          => 'nice',
367         'rsync-path'         => 'rsync',
368         'pgcontroldata-path' => 'pg_controldata',
369         'filename-template'  => '__HOSTNAME__-__FILETYPE__-^Y-^m-^d.tar__CEXT__',
370     );
371
372     croak( 'Error while reading command line arguments. Please check documentation in doc/omnipitr-backup-slave.pod' )
373         unless GetOptions(
374         \%args,
375         'data-dir|D=s',
376         'source|s=s',
377         'dst-local|dl=s@',
378         'dst-remote|dr=s@',
379         'temp-dir|t=s',
380         'log|l=s',
381         'filename-template|f=s',
382         'removal-pause-trigger|p=s',
383         'pid-file=s',
384         'verbose|v',
385         'gzip-path|gp=s',
386         'bzip2-path|bp=s',
387         'lzma-path|lp=s',
388         'nice-path|np=s',
389         'tar-path|tp=s',
390         'rsync-path|rp=s',
391         'pgcontroldata-path|pp=s',
392         'not-nice|nn',
393         );
394
395     croak( '--log was not provided - cannot continue.' ) unless $args{ 'log' };
396     for my $key ( qw( log filename-template ) ) {
397         $args{ $key } =~ tr/^/%/;
398     }
399
400     for my $key ( grep { !/^dst-(?:local|remote)$/ } keys %args ) {
401         $self->{ $key } = $args{ $key };
402     }
403
404     for my $type ( qw( local remote ) ) {
405         my $D = [];
406         $self->{ 'destination' }->{ $type } = $D;
407
408         next unless defined $args{ 'dst-' . $type };
409
410         my %temp_for_uniq = ();
411         my @items = grep { !$temp_for_uniq{ $_ }++ } @{ $args{ 'dst-' . $type } };
412
413         for my $item ( @items ) {
414             my $current = { 'compression' => 'none', };
415             if ( $item =~ s/\A(gzip|bzip2|lzma)=// ) {
416                 $current->{ 'compression' } = $1;
417             }
418             $current->{ 'path' } = $item;
419             push @{ $D }, $current;
420         }
421     }
422
423     if ( defined $args{ 'source' } && $args{ 'source' } =~ s/\A(gzip|bzip2|lzma)=// ) {
424         $self->{ 'source' } = {
425             'compression' => $1,
426             'path'        => $args{ 'source' },
427         };
428     }
429     else {
430         $self->{ 'source' } = {
431             'compression' => 'none',
432             'path'        => $args{ 'source' },
433         };
434     }
435
436     $self->{ 'filename-template' } = strftime( $self->{ 'filename-template' }, localtime time() );
437     $self->{ 'filename-template' } =~ s/__HOSTNAME__/hostname()/ge;
438
439     # We do it here so it will actually work for reporing problems in validation
440     $self->{ 'log_template' } = $args{ 'log' };
441     $self->{ 'log' }          = OmniPITR::Log->new( $self->{ 'log_template' } );
442
443     $self->log->log( 'Called with parameters: %s', join( ' ', @argv_copy ) ) if $self->verbose;
444
445     return;
446 }
447
448 =head1 validate_args()
449
450 Does all necessary validation of given command line arguments.
451
452 One exception is for compression programs paths - technically, it could be
453 validated in here, but benefit would be pretty limited, and code to do so
454 relatively complex, as compression program path might, but doesn't have to
455 be actual file path - it might be just program name (without path), which is
456 the default.
457
458 =cut
459
460 sub validate_args {
461     my $self = shift;
462
463     $self->log->fatal( 'Data-dir was not provided!' ) unless defined $self->{ 'data-dir' };
464     $self->log->fatal( 'Provided data-dir (%s) does not exist!',   $self->{ 'data-dir' } ) unless -e $self->{ 'data-dir' };
465     $self->log->fatal( 'Provided data-dir (%s) is not directory!', $self->{ 'data-dir' } ) unless -d $self->{ 'data-dir' };
466     $self->log->fatal( 'Provided data-dir (%s) is not readable!',  $self->{ 'data-dir' } ) unless -r $self->{ 'data-dir' };
467
468     my $dst_count = scalar( @{ $self->{ 'destination' }->{ 'local' } } ) + scalar( @{ $self->{ 'destination' }->{ 'remote' } } );
469     $self->log->fatal( "No --dst-* has been provided!" ) if 0 == $dst_count;
470
471     $self->log->fatal( "Filename template does not contain __FILETYPE__ placeholder!" ) unless $self->{ 'filename-template' } =~ /__FILETYPE__/;
472     $self->log->fatal( "Filename template cannot contain / or \\ characters!" ) if $self->{ 'filename-template' } =~ m{[/\\]};
473
474     $self->log->fatal( 'Source of WAL files was not provided!' ) unless defined $self->{ 'source' }->{ 'path' };
475     $self->log->fatal( 'Provided source of wal files (%s) does not exist!',   $self->{ 'source' }->{ 'path' } ) unless -e $self->{ 'source' }->{ 'path' };
476     $self->log->fatal( 'Provided source of wal files (%s) is not directory!', $self->{ 'source' }->{ 'path' } ) unless -d $self->{ 'source' }->{ 'path' };
477     $self->log->fatal( 'Provided source of wal files (%s) is not readable!',  $self->{ 'source' }->{ 'path' } ) unless -r $self->{ 'source' }->{ 'path' };
478
479     $self->log->fatal( 'Temp-dir was not provided!' ) unless defined $self->{ 'temp-dir' };
480     $self->log->fatal( 'Provided temp-dir (%s) does not exist!',   $self->{ 'temp-dir' } ) unless -e $self->{ 'temp-dir' };
481     $self->log->fatal( 'Provided temp-dir (%s) is not directory!', $self->{ 'temp-dir' } ) unless -d $self->{ 'temp-dir' };
482     $self->log->fatal( 'Provided temp-dir (%s) is not writable!',  $self->{ 'temp-dir' } ) unless -w $self->{ 'temp-dir' };
483     $self->log->fatal( 'Provided temp-dir (%s) contains # character!', $self->{ 'temp-dir' } ) if $self->{ 'temp-dir' } =~ /#/;
484
485     $self->log->fatal( 'Removal pause trigger name was not provided!' ) unless defined $self->{ 'removal-pause-trigger' };
486     $self->log->fatal( 'Provided removal pause trigger file (%s) already exists!', $self->{ 'removal-pause-trigger' } ) if -e $self->{ 'removal-pause-trigger' };
487
488     $self->log->fatal( 'Directory for provided removal pause trigger (%s) does not exist!',   $self->{ 'removal-pause-trigger' } ) unless -e dirname( $self->{ 'removal-pause-trigger' } );
489     $self->log->fatal( 'Directory for provided removal pause trigger (%s) is not directory!', $self->{ 'removal-pause-trigger' } ) unless -d dirname( $self->{ 'removal-pause-trigger' } );
490     $self->log->fatal( 'Directory for provided removal pause trigger (%s) is not writable!',  $self->{ 'removal-pause-trigger' } ) unless -w dirname( $self->{ 'removal-pause-trigger' } );
491
492     return unless $self->{ 'destination' }->{ 'local' };
493
494     for my $d ( @{ $self->{ 'destination' }->{ 'local' } } ) {
495         my $dir = $d->{ 'path' };
496         $self->log->fatal( 'Choosen local destination dir (%s) does not exist. Cannot continue.',   $dir ) unless -e $dir;
497         $self->log->fatal( 'Choosen local destination dir (%s) is not directory. Cannot continue.', $dir ) unless -d $dir;
498         $self->log->fatal( 'Choosen local destination dir (%s) is not writable. Cannot continue.',  $dir ) unless -w $dir;
499     }
500
501     return;
502 }
503
504 1;
Note: See TracBrowser for help on using the browser.