File Coverage

blib/lib/File/Write/Rotate.pm
Criterion Covered Total %
statement 217 235 92.3
branch 97 132 73.4
condition 35 43 81.4
subroutine 21 23 91.3
pod 8 8 100.0
total 378 441 85.7


line stmt bran cond sub pod time code
1             ## no critic: InputOutput::ProhibitOneArgSelect
2              
3             package File::Write::Rotate;
4              
5             our $DATE = '2019-06-27'; # DATE
6             our $VERSION = '0.320'; # VERSION
7              
8 3     3   144582 use 5.010001;
  3         33  
9 3     3   13 use strict;
  3         3  
  3         63  
10 3     3   11 use warnings;
  3         5  
  3         92  
11              
12             # we must not use Log::Any, looping if we are used as log output
13             #use Log::Any '$log';
14              
15 3     3   14 use File::Spec;
  3         5  
  3         77  
16 3     3   1367 use IO::Compress::Gzip qw(gzip $GzipError);
  3         83704  
  3         318  
17 3     3   25 use Scalar::Util qw(weaken);
  3         5  
  3         129  
18             #use Taint::Runtime qw(untaint is_tainted);
19 3     3   1266 use Time::HiRes 'time';
  3         3392  
  3         11  
20              
21             our $Debug;
22              
23             sub new {
24 278     278 1 116077 my $class = shift;
25 278         772 my %args0 = @_;
26              
27 278         466 my %args;
28              
29             defined($args{dir} = delete $args0{dir})
30 278 50       952 or die "Please specify dir";
31             defined($args{prefix} = delete $args0{prefix})
32 278 50       636 or die "Please specify prefix";
33 278   100     895 $args{suffix} = delete($args0{suffix}) // "";
34              
35 278   100     565 $args{size} = delete($args0{size}) // 0;
36              
37 278         378 $args{period} = delete($args0{period});
38 278 100       499 if ($args{period}) {
39 39 50       204 $args{period} =~ /\A(daily|day|month|monthly|year|yearly)\z/
40             or die "Invalid period, please use daily/monthly/yearly";
41             }
42              
43 278         526 for (map {"hook_$_"} qw(before_rotate after_rotate after_create
  1390         2456  
44             before_write a)) {
45 1390 100       2202 next unless $args0{$_};
46 17         29 $args{$_} = delete($args0{$_});
47             die "Invalid $_, please supply a coderef"
48 17 50       42 unless ref($args{$_}) eq 'CODE';
49             }
50              
51 278 100 100     856 if (!$args{period} && !$args{size}) {
52 21         33 $args{size} = 10 * 1024 * 1024;
53             }
54              
55 278   100     638 $args{histories} = delete($args0{histories}) // 10;
56              
57 278         391 $args{binmode} = delete($args0{binmode});
58              
59 278         505 $args{buffer_size} = delete($args0{buffer_size});
60              
61 278   100     658 $args{lock_mode} = delete($args0{lock_mode}) // 'write';
62 278 50       1084 $args{lock_mode} =~ /\A(none|write|exclusive)\z/
63             or die "Invalid lock_mode, please use none/write/exclusive";
64              
65 278         410 $args{rotate_probability} = delete($args0{rotate_probability});
66 278 50       459 if (defined $args{rotate_probability}) {
67 0 0 0     0 $args{rotate_probability} > 0 && $args{rotate_probability} < 1.0
68             or die "Invalid rotate_probability, must be 0 < x < 1";
69             }
70              
71 278 50       515 if (keys %args0) {
72 0         0 die "Unknown arguments to new(): ".join(", ", sort keys %args0);
73             }
74              
75 278         444 $args{_buffer} = [];
76              
77 278         465 my $self = bless \%args, $class;
78              
79             $self->{_exclusive_lock} = $self->_get_lock
80 278 100       560 if $self->{lock_mode} eq 'exclusive';
81              
82 278         701 $self;
83             }
84              
85             sub buffer_size {
86 2     2 1 9 my $self = shift;
87 2 100       6 if (@_) {
88 1         2 my $old = $self->{buffer_size};
89 1         2 $self->{buffer_size} = $_[0];
90 1         2 return $old;
91             } else {
92 1         4 return $self->{buffer_size};
93             }
94             }
95              
96             sub handle {
97 1     1 1 5 my $self = shift;
98 1         3 $self->{_fh};
99             }
100              
101             sub path {
102 0     0 1 0 my $self = shift;
103 0         0 $self->{_fp};
104             }
105              
106             # file path, without the rotate suffix
107             sub _file_path {
108 801     801   2840 my ($self) = @_;
109              
110             # _now is calculated every time we access this method
111 801         1644 $self->{_now} = time();
112              
113 801         3822 my @lt = localtime($self->{_now});
114 801         1518 $lt[5] += 1900;
115 801         856 $lt[4]++;
116              
117 801         849 my $period;
118 801 100       1336 if ($self->{period}) {
119 110 100       607 if ($self->{period} =~ /year/i) {
    100          
    50          
120 13         49 $period = sprintf("%04d", $lt[5]);
121             } elsif ($self->{period} =~ /month/) {
122 13         53 $period = sprintf("%04d-%02d", $lt[5], $lt[4]);
123             } elsif ($self->{period} =~ /day|daily/) {
124 84         374 $period = sprintf("%04d-%02d-%02d", $lt[5], $lt[4], $lt[3]);
125             }
126             } else {
127 691         871 $period = "";
128             }
129              
130             my $path = join(
131             '',
132             $self->{dir}, '/',
133             $self->{prefix},
134             length($period) ? ".$period" : "",
135             $self->{suffix},
136 801 100       2201 );
137 801 100       1239 if (wantarray) {
138 308         849 return ($path, $period);
139             } else {
140 493         1169 return $path;
141             }
142             }
143              
144             sub lock_file_path {
145 996     996 1 2980 my ($self) = @_;
146 996         16617 return File::Spec->catfile($self->{dir}, $self->{prefix} . '.lck');
147             }
148              
149             sub _get_lock {
150 592     592   775 my ($self) = @_;
151 592 100       1258 return undef if $self->{lock_mode} eq 'none';
152 588 100       1078 return $self->{_weak_lock} if defined($self->{_weak_lock});
153              
154 510         3418 require File::Flock::Retry;
155 510         3845 my $lock = File::Flock::Retry->lock($self->lock_file_path);
156 510         55760 $self->{_weak_lock} = $lock;
157 510         1619 weaken $self->{_weak_lock};
158 510         798 return $lock;
159             }
160              
161             # will return \@files. each entry is [filename without compress suffix,
162             # rotate_suffix (for sorting), period (for sorting), compress suffix (for
163             # renaming back)]
164             sub _get_files {
165 98     98   133 my ($self) = @_;
166              
167 98 50       2077 opendir my ($dh), $self->{dir} or do {
168 0         0 warn "Can't opendir '$self->{dir}': $!";
169 0         0 return;
170             };
171              
172 98         211 my @files;
173 98         1210 while (my $e = readdir($dh)) {
174 463 100       620 my $cs; $cs = $1 if $e =~ s/(\.gz)\z//; # compress suffix
  463         792  
175 463 100       3126 next unless $e =~ /\A\Q$self->{prefix}\E
176             (?:\. (?\d{4}(?:-\d\d(?:-\d\d)?)?) )?
177             \Q$self->{suffix}\E
178             (?:\. (?\d+) )?
179             \z
180             /x;
181             push @files,
182 3   100 3   4363 [ $e, $+{rotate_suffix} // 0, $+{period} // "", $cs // "" ];
  3   100     896  
  3   100     4126  
  152         2138  
183             }
184 98         869 closedir($dh);
185              
186 98 50       575 [ sort { $a->[2] cmp $b->[2] || $b->[1] <=> $a->[1] } @files ];
  123         500  
187             }
188              
189             # rename (increase rotation suffix) and keep only n histories. note: failure in
190             # rotating should not be fatal, we just warn and return.
191             sub _rotate_and_delete {
192 85     85   7098 my ($self, %opts) = @_;
193              
194 85         126 my $delete_only = $opts{delete_only};
195 85         161 my $lock = $self->_get_lock;
196             CASE:
197             {
198 85 50       111 my $files = $self->_get_files or last CASE;
  85         153  
199              
200             # is there a compression process in progress? this is marked by the
201             # existence of -compress.pid PID file.
202             #
203             # XXX check validity of PID file, otherwise a stale PID file will always
204             # prevent rotation to be done
205 85 50       821 if (-f "$self->{dir}/$self->{prefix}-compress.pid") {
206 0         0 warn "Compression is in progress, rotation is postponed";
207 0         0 last CASE;
208             }
209              
210 7         27 $self->{hook_before_rotate}->($self, [map {$_->[0]} @$files])
211 85 100       232 if $self->{hook_before_rotate};
212              
213 85         2633 my @deleted;
214             my @renamed;
215              
216 85         0 my $i;
217 85         155 my $dir = $self->{dir};
218 85 100       201 my $rotating_period = @$files ? $files->[-1][2] : undef;
219 85         161 for my $f (@$files) {
220 122         293 my ($orig, $rs, $period, $cs) = @$f;
221 122         165 $i++;
222              
223             #say "DEBUG: is_tainted \$dir? ".is_tainted($dir);
224             #say "DEBUG: is_tainted \$orig? ".is_tainted($orig);
225             #say "DEBUG: is_tainted \$cs? ".is_tainted($cs);
226              
227             # TODO actually, it's more proper to taint near the source (in this
228             # case, _get_files)
229             #untaint \$orig;
230 122         420 ($orig) = $orig =~ /(.*)/s; # we use this instead, no module needed
231              
232 122 100       309 if ($i <= @$files - $self->{histories}) {
233 22 50       40 say "DEBUG: Deleting old rotated file $dir/$orig$cs ..."
234             if $Debug;
235 22 50       716 if (unlink "$dir/$orig$cs") {
236 22         102 push @deleted, "$orig$cs";
237             } else {
238 0         0 warn "Can't delete $dir/$orig$cs: $!";
239             }
240 22         56 next;
241             }
242 100 100 66     366 if (!$delete_only && defined($rotating_period) && $period eq $rotating_period) {
      100        
243 48         76 my $new = $orig;
244 48 100       88 if ($rs) {
245 14         73 $new =~ s/\.(\d+)\z/"." . ($1+1)/e;
  14         91  
246             } else {
247 34         51 $new .= ".1";
248             }
249 48 50       99 if ($new ne $orig) {
250 48 50       84 say "DEBUG: Renaming rotated file $dir/$orig$cs -> ".
251             "$dir/$new$cs ..." if $Debug;
252 48 50       1247 if (rename "$dir/$orig$cs", "$dir/$new$cs") {
253 48         252 push @renamed, "$new$cs";
254             } else {
255 0         0 warn "Can't rename '$dir/$orig$cs' -> '$dir/$new$cs': $!";
256             }
257             }
258             }
259             }
260              
261             $self->{hook_after_rotate}->($self, \@renamed, \@deleted)
262 85 100       437 if $self->{hook_after_rotate};
263             } # CASE
264             }
265              
266             sub _open {
267 298     298   398 my $self = shift;
268              
269 298         497 my ($fp, $period) = $self->_file_path;
270 298 50       8850 open $self->{_fh}, ">>", $fp or die "Can't open '$fp': $!";
271 298 100       1007 if (defined $self->{binmode}) {
272 2 50       7 if ($self->{binmode} eq "1") {
273 0         0 binmode $self->{_fh};
274             } else {
275             binmode $self->{_fh}, $self->{binmode}
276 2 50       12 or die "Can't set PerlIO layer on '$fp' ".
277             "to '$self->{binmode}': $!";
278             }
279             }
280 298         939 my $oldfh = select $self->{_fh};
281 298         697 $| = 1;
282 298         631 select $oldfh; # set autoflush
283 298         531 $self->{_fp} = $fp;
284 298 100       942 $self->{hook_after_create}->($self) if $self->{hook_after_create};
285             }
286              
287             # (re)open file and optionally rotate if necessary
288             sub _rotate_and_open {
289              
290 493     493   638 my $self = shift;
291 493         704 my ($do_open, $do_rotate) = @_;
292 493         613 my $fp;
293             my %rotate_params;
294              
295             CASE:
296             {
297             # if instructed, only do rotate some of the time to shave overhead
298 493 0 33     679 if ($self->{rotate_probability} && $self->{_fh}) {
  493         1054  
299 0 0       0 last CASE if rand() > $self->{rotate_probability};
300             }
301              
302 493         899 $fp = $self->_file_path;
303 493 100       4978 unless (-e $fp) {
304 50         110 $do_open++;
305 50         57 $do_rotate++;
306 50         104 $rotate_params{delete_only} = 1;
307 50         93 last CASE;
308             }
309              
310             # file is not opened yet, open
311 443 100       1321 unless ($self->{_fh}) {
312 219         459 $self->_open;
313             }
314              
315             # period has changed, rotate
316 443 50       837 if ($self->{_fp} ne $fp) {
317 0         0 $do_rotate++;
318 0         0 $rotate_params{delete_only} = 1;
319 0         0 last CASE;
320             }
321              
322             # check whether size has been exceeded
323 443         514 my $inode;
324              
325 443 100       807 if ($self->{size} > 0) {
326              
327 437         2967 my @st = stat($self->{_fh});
328 437         795 my $size = $st[7];
329 437         513 $inode = $st[1];
330              
331 437 100       869 if ($size >= $self->{size}) {
332 28 50       58 say "DEBUG: Size of $self->{_fp} is $size, exceeds $self->{size}, rotating ..."
333             if $Debug;
334 28         51 $do_rotate++;
335 28         65 last CASE;
336             } else {
337             # stat the current file (not our handle _fp)
338 409         3175 my @st = stat($fp);
339 409 50       895 die "Can't stat '$fp': $!" unless @st;
340 409         602 my $finode = $st[1];
341              
342             # check whether other process has rename/rotate under us (for
343             # example, 'prefix' has been moved to 'prefix.1'), in which case
344             # we need to reopen
345 409 100 66     1794 if (defined($inode) && $finode != $inode) {
346 1         3 $do_open++;
347             }
348             }
349              
350             }
351             } # CASE
352              
353 493 100       930 $self->_rotate_and_delete(%rotate_params) if $do_rotate;
354 493 100 100     3463 $self->_open if $do_rotate || $do_open; # (re)open
355             }
356              
357             sub write {
358 493     493 1 96093 my $self = shift;
359              
360             # the buffering implementation is currently pretty naive. it assume any
361             # die() as a write failure and store the message to buffer.
362              
363             # FYI: if privilege is dropped from superuser, the failure is usually at
364             # locking the lock file (permission denied).
365              
366 493         707 my @msg = (map( {@$_} @{ $self->{_buffer} } ), @_);
  6         15  
  493         1158  
367              
368 493         718 eval {
369 493         881 my $lock = $self->_get_lock;
370              
371 493         1240 $self->_rotate_and_open;
372              
373             $self->{hook_before_write}->($self, \@msg, $self->{_fh})
374 493 100       2846 if $self->{hook_before_write};
375              
376 488         2335 print { $self->{_fh} } @msg;
  488         6729  
377 488         2767 $self->{_buffer} = [];
378              
379             };
380 493         31901 my $err = $@;
381              
382 493 100       1448 if ($err) {
383 5 100 50     14 if (($self->{buffer_size} // 0) > @{ $self->{_buffer} }) {
  5         16  
384             # put message to buffer temporarily
385 4         5 push @{ $self->{_buffer} }, [@_];
  4         29  
386             } else {
387             # buffer is already full, let's dump the buffered + current message
388             # to the die message anyway.
389             die join(
390             "",
391             "Can't write",
392             (
393 1         5 @{ $self->{_buffer} }
394             ? " (buffer is full, "
395 1 50       3 . scalar(@{ $self->{_buffer} })
  1         13  
396             . " message(s))"
397             : ""
398             ),
399             ": $err, message(s)=",
400             @msg
401             );
402             }
403             }
404             }
405              
406       0 1   sub flush {
407             }
408              
409             sub compress {
410 13     13 1 7909 my ($self) = shift;
411              
412 13         36 my $lock = $self->_get_lock;
413 13         47 my $files_ref = $self->_get_files;
414 13         25 my $done_compression = 0;
415              
416 13 50       20 if (@{$files_ref}) {
  13         36  
417 13         621 require Proc::PID::File;
418              
419             my $pid = Proc::PID::File->new(
420             dir => $self->{dir},
421 13         1926 name => "$self->{prefix}-compress",
422             verify => 1,
423             );
424 13         420 my $latest_period = $files_ref->[-1][2];
425              
426 13 50       39 if ($pid->alive) {
427 0         0 warn "Another compression is in progress";
428             } else {
429 13         1417 my @tocompress;
430             #use DD; dd $self;
431 13         19 for my $file_ref (@{$files_ref}) {
  13         33  
432 30         45 my ($orig, $rs, $period, $cs) = @{ $file_ref };
  30         71  
433             #say "D:compress: orig=<$orig> rs=<$rs> period=<$period> cs=<$cs>";
434 30 50       61 next if $cs; # already compressed
435 30 100 100     79 next if !$self->{period} && !$rs; # not old file
436 25 100 100     91 next if $self->{period} && $period eq $latest_period; # not old file
437 17         168 push @tocompress, File::Spec->catfile($self->{dir}, $orig);
438             }
439              
440 13 50       32 if (@tocompress) {
441 13         25 for my $file (@tocompress) {
442             gzip($file => "$file.gz")
443 17 50       79 or do { warn "gzip failed: $GzipError\n"; next };
  0         0  
  0         0  
444 17         34446 unlink $file;
445             }
446 13         100 $done_compression = 1;
447             }
448             }
449             }
450              
451 13         1053 return $done_compression;
452              
453             }
454              
455             sub DESTROY {
456 278     278   102913 my ($self) = @_;
457              
458             # Proc::PID::File's DESTROY seem to create an empty PID file, remove it.
459 278         5845 unlink "$self->{dir}/$self->{prefix}-compress.pid";
460             }
461              
462             1;
463              
464             # ABSTRACT: Write to files that archive/rotate themselves
465              
466             __END__