File Coverage

blib/lib/DBIx/Connector/Retry/MySQL.pm
Criterion Covered Total %
statement 114 126 90.4
branch 31 42 73.8
condition 14 18 77.7
subroutine 26 29 89.6
pod 4 5 80.0
total 189 220 85.9


line stmt bran cond sub pod time code
1             package DBIx::Connector::Retry::MySQL;
2              
3             # ABSTRACT: MySQL-specific DBIx::Connector with retry support
4 2     2   436520 use version;
  2         3908  
  2         12  
5             our $VERSION = 'v1.0.0'; # VERSION
6              
7 2     2   202 use strict;
  2         4  
  2         40  
8 2     2   11 use warnings;
  2         4  
  2         57  
9              
10 2     2   1184 use Moo;
  2         23536  
  2         12  
11              
12             extends 'DBIx::Connector::Retry';
13              
14 2     2   3159 use Scalar::Util qw( weaken );
  2         6  
  2         131  
15 2     2   1326 use Storable qw( dclone );
  2         6706  
  2         197  
16 2     2   1360 use Types::Standard qw( Bool HashRef InstanceOf ClassName );
  2         149023  
  2         30  
17 2     2   3722 use Types::Common::Numeric qw( PositiveOrZeroNum );
  2         25542  
  2         22  
18 2     2   2376 use Time::HiRes qw( sleep );
  2         3051  
  2         11  
19              
20 2     2   1566 use Algorithm::Backoff::RetryTimeouts;
  2         34521  
  2         85  
21 2     2   1218 use DBIx::ParseError::MySQL;
  2         66824  
  2         74  
22              
23 2     2   19 use namespace::clean; # don't export the above
  2         6  
  2         21  
24              
25             #pod =head1 SYNOPSIS
26             #pod
27             #pod my $conn = DBIx::Connector::Retry::MySQL->new(
28             #pod connect_info => [ 'dbi:Driver:database=foobar', $user, $pass, \%args ],
29             #pod retry_debug => 1,
30             #pod timer_options => {
31             #pod # Default options from Algorithm::Backoff::RetryTimeouts
32             #pod max_attempts => 8,
33             #pod max_actual_duration => 50,
34             #pod jitter_factor => 0.1,
35             #pod timeout_jitter_factor => 0.1,
36             #pod adjust_timeout_factor => 0.5,
37             #pod min_adjust_timeout => 5,
38             #pod # ...among others
39             #pod },
40             #pod );
41             #pod
42             #pod # Keep retrying/reconnecting on errors
43             #pod my ($count) = $conn->run(ping => sub {
44             #pod $_->do('UPDATE foobar SET updated = 1 WHERE active = ?', undef, 'on');
45             #pod $_->selectrow_array('SELECT COUNT(*) FROM foobar WHERE updated = 1');
46             #pod });
47             #pod
48             #pod my ($count) = $conn->txn(fixup => sub {
49             #pod $_->selectrow_array('SELECT COUNT(*) FROM barbaz');
50             #pod });
51             #pod
52             #pod # Plus everything else in DBIx::Connector::Retry and DBIx::Connector
53             #pod
54             #pod =head1 DESCRIPTION
55             #pod
56             #pod DBIx::Connector::Retry::MySQL is a subclass of L that will
57             #pod explicitly retry on MySQL-specific transient error messages, as identified by
58             #pod L, using L as its retry
59             #pod algorithm. This connector should be much better at handling deadlocks, connection
60             #pod errors, and Galera node flips to ensure the transaction always goes through.
61             #pod
62             #pod It is essentially a DBIx::Connector version of L.
63             #pod
64             #pod =head1 INHERITED ATTRIBUTES
65             #pod
66             #pod This inherits all of the attributes of L:
67             #pod
68             #pod =head2 L
69             #pod
70             #pod =head2 L
71             #pod
72             #pod =head2 L
73             #pod
74             #pod =head2 max_attempts
75             #pod
76             #pod Unlike L, this is just an alias to the value in
77             #pod L.
78             #pod
79             #pod As such, it has a slightly adjusted default of 8.
80             #pod
81             #pod =cut
82              
83             sub max_attempts {
84 3     3 1 46 my $self = shift;
85 3         60 my $opts = $self->timer_options;
86              
87 3 50       31 return $opts->{max_attempts} = $_[0] if @_;
88 3   100     31 return $opts->{max_attempts} // 8;
89             }
90              
91             #pod =head2 retry_debug
92             #pod
93             #pod Like L, this turns on debug warnings for
94             #pod retries. But, this module has a bit more detail in the messages.
95             #pod
96             #pod =cut
97              
98             sub _warn_retry_debug {
99 0     0   0 my $self = shift;
100              
101 0         0 my $timer = $self->_timer;
102 0         0 my $current_attempt_count = $self->failed_attempt_count + 1;
103             my $debug_msg = sprintf(
104             'Retrying %s coderef, attempt %u of %u, timer: %.1f / %.1f sec, last exception: %s',
105             $self->execute_method,
106             $current_attempt_count, $self->max_attempts,
107             $timer->{_last_timestamp} - $timer->{_start_timestamp}, $timer->{max_actual_duration},
108 0         0 $self->last_exception
109             );
110              
111 0         0 warn $debug_msg;
112             }
113              
114             #pod =head2 retry_handler
115             #pod
116             #pod Since the whole point of the module is the retry-handling code, this attribute cannot be
117             #pod set.
118             #pod
119             #pod =cut
120              
121 24     24 1 152 sub retry_handler { \&_main_retry_handler };
122              
123             #pod =head2 failed_attempt_count
124             #pod
125             #pod Unlike L, this is just an alias to the
126             #pod value in the internal timer object.
127             #pod
128             #pod =cut
129              
130 46   50 46 1 69510991 sub failed_attempt_count { shift->_timer->{_attempts} // 0 }
131              
132             # This neuters the max_attempts trigger in failed_attempt_count, so that the main check
133             # in _main_retry_handler works as expected.
134       24     sub _set_failed_attempt_count {}
135              
136             #pod =head2 L
137             #pod
138             #pod =head1 NEW ATTRIBUTES
139             #pod
140             #pod =head2 timer_class
141             #pod
142             #pod The class used for delay and timeout setting calculations. By default, it's
143             #pod L, but you can use a sub-class of this, if you so
144             #pod choose, provided that it has a similar interface.
145             #pod
146             #pod =cut
147              
148             has timer_class => (
149             is => 'ro',
150             isa => ClassName,
151             default => 'Algorithm::Backoff::RetryTimeouts',
152             );
153              
154             #pod =head2 timer_options
155             #pod
156             #pod Controls all of the options passed to the timer constructor, using L as the
157             #pod object.
158             #pod
159             #pod =cut
160              
161             has timer_options => (
162             is => 'ro',
163             isa => HashRef,
164             default => sub { {} },
165             lazy => 1,
166             );
167              
168             has _timer => (
169             is => 'rw',
170             isa => InstanceOf['Algorithm::Backoff::RetryTimeouts'],
171             init_arg => undef,
172             builder => '_build_timer',
173             clearer => '_clear_timer',
174             lazy => 1,
175             );
176              
177             sub _build_timer {
178 54     54   203 my $self = shift;
179             return $self->timer_class->new(
180 54         202 %{ dclone $self->timer_options }
  54         1020  
181             );
182             }
183              
184             has _current_timeout => (
185             is => 'rw',
186             isa => PositiveOrZeroNum,
187             init_arg => undef,
188             lazy => 1,
189             );
190              
191             sub _reset_timeout {
192 38     38   82 my $self = shift;
193              
194             # Use a temporary timer to get the first timeout value
195 38         115 my $timeout = $self->_build_timer->timeout;
196 38 100       6653 $timeout = 0 if $timeout == -1;
197 38         820 $self->_current_timeout($timeout);
198             }
199              
200             #pod =head2 aggressive_timeouts
201             #pod
202             #pod Boolean that controls whether to use some of the more aggressive, query-unfriendly
203             #pod timeouts:
204             #pod
205             #pod =over
206             #pod
207             #pod =item mysql_read_timeout
208             #pod
209             #pod Controls the timeout for all read operations. Since SQL queries in the middle of
210             #pod sending its first set of row data are still considered to be in a read operation, those
211             #pod queries could time out during those circumstances.
212             #pod
213             #pod If you're confident that you don't have any SQL statements that would take longer than
214             #pod the timeout settings (or at least returning results before that time), you can turn this
215             #pod option on. Otherwise, you may experience longer-running statements going into a retry
216             #pod death spiral until they hit the final timeout and die.
217             #pod
218             #pod =item wait_timeout
219             #pod
220             #pod Controls how long the MySQL server waits for activity from the connection before timing
221             #pod out. While most applications are going to be using the database connection pretty
222             #pod frequently, the MySQL default (8 hours) is much much longer than the mere seconds this
223             #pod engine would set it to.
224             #pod
225             #pod =back
226             #pod
227             #pod Default is off. Obviously, this setting makes no sense if C
228             #pod within L is disabled.
229             #pod
230             #pod =cut
231              
232             has aggressive_timeouts => (
233             is => 'rw',
234             isa => Bool,
235             required => 0,
236             default => 0,
237             lazy => 1,
238             );
239              
240             #pod =head2 parse_error_class
241             #pod
242             #pod The class used for MySQL error parsing. By default, it's L, but
243             #pod you can use a sub-class of this, if you so choose, provided that it has a similar
244             #pod interface.
245             #pod
246             #pod =cut
247              
248             has parse_error_class => (
249             is => 'ro',
250             isa => ClassName,
251             default => 'DBIx::ParseError::MySQL',
252             );
253              
254             #pod =head2 enable_retry_handler
255             #pod
256             #pod Boolean to enable the retry handler. The default is, of course, on. This can be turned
257             #pod off to temporarily disable the retry handler.
258             #pod
259             #pod =cut
260              
261             has enable_retry_handler => (
262             is => 'rw',
263             isa => Bool,
264             required => 0,
265             default => 1,
266             lazy => 1,
267             );
268              
269             # Alias for backwards-compatibility
270 0     0 1 0 sub clear_retry_handler { shift->enable_retry_handler(0) }
271              
272             ### All the lifecycle and private methods
273              
274             # Force in our retry_handler
275             around BUILDARGS => sub {
276             my ($orig, $class, @args) = @_;
277              
278             my $args = $class->$orig(@args);
279             $args->{retry_handler} = \&_main_retry_handler;
280              
281             return $args;
282             };
283              
284             sub BUILD {
285 23     23 0 9590 my $self = shift;
286 23         98 $self->_reset_timeout;
287 23         1149 $self->_set_dbi_connect_info;
288             }
289              
290             # Return the list of timeout strings to check
291             sub _timeout_set_list {
292 74     74   203 my ($self, $type) = @_;
293              
294 74         127 my @timeout_set;
295 74 100       242 if ($type eq 'dbi') {
    50          
296 48         156 @timeout_set = (qw< connect write >);
297 48 100       757 push @timeout_set, 'read' if $self->aggressive_timeouts;
298              
299 48         848 @timeout_set = map { "mysql_${_}_timeout" } @timeout_set;
  97         354  
300             }
301             elsif ($type eq 'session') {
302 26         80 @timeout_set = (qw< lock_wait innodb_lock_wait net_read net_write >);
303 26 50       380 push @timeout_set, 'wait' if $self->aggressive_timeouts;
304              
305 26         189 @timeout_set = map { "${_}_timeout" } @timeout_set;
  104         239  
306             }
307             else {
308 0         0 die "Unknown mysql timeout set: $type";
309             }
310              
311 74         578 return @timeout_set;
312             }
313              
314             # Set the timeouts for reconnections by inserting them into the default DBI connection
315             # attributes.
316             sub _set_dbi_connect_info {
317 51     51   121 my $self = shift;
318 51 100 100     715 return unless $self->_current_timeout && $self->enable_retry_handler;
319              
320 48         2427 my $timeout = int( $self->_current_timeout + 0.5 );
321 48   100     1032 my $dbi_attr = $self->connect_info->[3] //= {};
322 48 50 33     638 return unless $dbi_attr && ref $dbi_attr eq 'HASH';
323              
324 48         124 $dbi_attr->{mysql_auto_reconnect} = 0; # do not use MySQL's own reconnector
325 48         206 $dbi_attr->{$_} = $timeout for $self->_timeout_set_list('dbi');
326             }
327              
328             # Set session timeouts for post-connection variables
329             after _connect => sub {
330             my $self = shift;
331             $self->_set_retry_session_timeouts;
332             };
333              
334             sub _set_retry_session_timeouts {
335 29     29   59 my $self = shift;
336 29 100 100     446 return unless $self->_current_timeout && $self->enable_retry_handler;
337              
338 26         989 my $timeout = int( $self->_current_timeout + 0.5 );
339              
340             # Ironically, we aren't running our own SET SESSION commands with their own
341             # retry protection, since that may lead to infinite stack recursion. Instead,
342             # put it in a basic eval, and do a quick is_transient check. If it passes,
343             # let the next _run/_retry_loop call handle it.
344              
345 26         163 local $@;
346 26         48 eval {
347 26         49 my $dbh = $self->{_dbh};
348 26 50       88 if ($dbh) {
349 26         83 $dbh->do("SET SESSION $_=$timeout") for $self->_timeout_set_list('session');
350             }
351             };
352 26 50       1042 if (my $error = $@) {
353 0         0 my $parsed_error = $self->parse_error_class->new($error);
354 0 0       0 die unless $parsed_error->is_transient; # bare die for $@ propagation
355 0 0       0 warn "Encountered a recoverable error during SET SESSION timeout commands: $error" if $self->retry_debug;
356             }
357             }
358              
359             # Override fixup methods (pretend we're using no_ping mode with our own retry protections)
360             sub _fixup_run {
361 21     21   575 my ($self, $code) = @_;
362 21         78 return $self->_run($code);
363             }
364              
365             sub _txn_fixup_run {
366 0     0   0 my ($self, $code) = @_;
367 0         0 return $self->_txn_run($code);
368             }
369              
370             # Modifications of the main retry loop
371             around _retry_loop => sub {
372             my $orig = shift;
373             my $self = shift;
374             my $wantarray = $_[4]; # keep it in the parameter list
375              
376             # Start new timer
377             $self->_clear_timer;
378              
379             my $timeout = $self->_timer->timeout;
380             $timeout = 0 if $timeout == -1;
381             $self->_current_timeout($timeout);
382              
383             # Save the result in a context-sensitive manner, but reset timers before we return
384             my @res;
385             unless (defined $wantarray) { $self->$orig(@_) }
386             elsif ($wantarray) { @res = $self->$orig(@_) }
387             else { $res[0] = $self->$orig(@_) }
388              
389             $self->_reset_timers_and_timeouts;
390             return $wantarray ? @res : $res[0];
391             };
392              
393             # Our retry handler
394             sub _main_retry_handler {
395 24     24   75 my $self = shift;
396              
397 24         132 my $last_error = $self->last_exception;
398              
399             # Record the failure in the timer algorithm (prior to any checks)
400 24         886 my ($sleep_time, $new_timeout) = $self->_timer->failure;
401              
402             # Retry handler is disabled?
403 24 100       4021 $self->_reset_and_die('Retry handler disabled') unless $self->enable_retry_handler;
404              
405             # If it's not a retryable error, stop here
406 23         707 my $parsed_error = $self->parse_error_class->new($last_error);
407 23 100       5294 $self->_reset_and_die('Exception not transient') unless $parsed_error->is_transient;
408              
409             # Either stop here (because of timeout or max attempts), sleep, or don't
410 22 100       4700 if ($sleep_time == -1) { $self->_reset_and_die('Out of retries') }
  2 100       10  
411 5         6390883 elsif ($sleep_time) { sleep $sleep_time; }
412              
413 20 50       140 if ($new_timeout > 0) {
414             # Reset the connection timeouts before we connect again
415 20         596 $self->_current_timeout($new_timeout);
416 20         1426 $self->_set_dbi_connect_info;
417             }
418              
419             # Force a disconnect, but only if the connection seems to be in a broken state
420 20 100       337 unless ($parsed_error->error_type eq 'lock') {
421 7         86 local $@;
422 7         14 eval { local $SIG{__DIE__}; $self->disconnect };
  7         47  
  7         50  
423             }
424              
425 20         1060 return 1;
426             }
427              
428             sub _reset_timers_and_timeouts {
429 15     15   41 my $self = shift;
430              
431             # Only reset timeouts if we have to, but check before we clear
432 15   66     73 my $needs_resetting = $self->failed_attempt_count && $self->_current_timeout;
433              
434 15         642 $self->_clear_timer;
435 15         231 $self->_reset_timeout;
436              
437 15 100       1008 if ($needs_resetting) {
438 8         43 $self->_set_dbi_connect_info;
439 8         35 $self->_set_retry_session_timeouts;
440             }
441              
442 15         39 return undef;
443             }
444              
445             sub _reset_and_die {
446 4     4   220 my ($self, $fail_reason) = @_;
447              
448             # First error: just pass it unaltered
449 4 100       14 die $self->last_exception if $self->failed_attempt_count <= 1;
450              
451 3         65 my $timer = $self->_timer;
452             my $error = sprintf(
453             'Failed %s coderef: %s, attempts: %u / %u, timer: %.1f / %.1f sec, last exception: %s',
454             $self->execute_method, $fail_reason,
455             $self->failed_attempt_count, $self->max_attempts,
456             $timer->{_last_timestamp} - $timer->{_start_timestamp}, $timer->{max_actual_duration},
457 3         34 $self->last_exception
458             );
459              
460 3         125 $self->_reset_timers_and_timeouts;
461 3         63 die $error;
462             }
463              
464             #pod =head1 CAVEATS
465             #pod
466             #pod =head2 $dbh settings
467             #pod
468             #pod See L.
469             #pod
470             #pod =head2 Savepoints and nested transactions
471             #pod
472             #pod See L.
473             #pod
474             #pod =head2 Connection modes
475             #pod
476             #pod Due to the caveats of L, C mode is changed to
477             #pod just act like C mode. However, C mode is safer to use in this module
478             #pod because it comes with the same retry protections as the other modes. Certain retries,
479             #pod such as connection/server errors, come with an explicit disconnect to make sure it starts
480             #pod back up with a clean slate.
481             #pod
482             #pod In C mode, the DB will be pinged on the first try. If the retry explicitly
483             #pod disconnected, the connector will simply connect back to the DB and run the code, without
484             #pod a superfluous ping.
485             #pod
486             #pod =cut
487              
488             1;
489              
490             __END__