File Coverage

blib/lib/DBIx/QuickDB/Driver.pm
Criterion Covered Total %
statement 33 214 15.4
branch 0 92 0.0
condition 0 41 0.0
subroutine 11 46 23.9
pod 26 31 83.8
total 70 424 16.5


line stmt bran cond sub pod time code
1             package DBIx::QuickDB::Driver;
2 24     24   175134 use strict;
  24         58  
  24         685  
3 24     24   97 use warnings;
  24         65  
  24         1315  
4              
5             our $VERSION = '0.000048';
6              
7 24     24   98 use Carp qw/croak confess/;
  24         28  
  24         1099  
8 24     24   234 use File::Path qw/remove_tree/;
  24         45  
  24         1100  
9 24     24   2370 use File::Temp qw/tempdir/;
  24         29328  
  24         988  
10 24     24   98 use POSIX ":sys_wait_h";
  24         33  
  24         189  
11 24     24   13903 use Scalar::Util qw/blessed/;
  24         35  
  24         974  
12 24     24   113 use Time::HiRes qw/sleep time/;
  24         53  
  24         259  
13              
14 24     24   4573 use DBIx::QuickDB::Util qw/clone_dir env_timeout/;
  24         89  
  24         249  
15              
16 24     24   10510 use DBIx::QuickDB::Watcher;
  24         60  
  24         871  
17              
18 24         94 use DBIx::QuickDB::Util::HashBase qw{
19             -root_pid
20             -dir
21             -_cleanup
22             -autostop -autostart
23             verbose
24             -_log_id
25             username
26             password
27             env_vars
28            
29            
30 24     24   118 };
  24         35  
31              
32 0     0 1   sub viable { (0, "viable() is not implemented for the " . $_[0]->name . " driver") }
33              
34 0     0 1   sub socket { confess "socket() is not implemented for the " . $_[0]->name . " driver" }
35 0     0 1   sub load_sql { confess "load_sql() is not implemented for the " . $_[0]->name . " driver" }
36 0     0     sub bootstrap { confess "bootstrap() is not implemented for the " . $_[0]->name . " driver" }
37 0     0 1   sub connect_string { confess "connect_string() is not implemented for the " . $_[0]->name . " driver" }
38 0     0 1   sub start_command { confess "start_command() is not implemented for the " . $_[0]->name . " driver" }
39 0     0 1   sub shell_command { confess "shell_command() is not implemented for the " . $_[0]->name . " driver" }
40              
41 0     0 0   sub error_log { undef }
42              
43             sub read_error_log {
44 0     0 0   my $self = shift;
45 0 0         my $log = $self->error_log or return "";
46 0 0         return "" unless -f $log;
47 0 0         open(my $fh, '<', $log) or return "Could not open error log '$log': $!";
48 0           return join "" => <$fh>;
49             }
50              
51 0     0 1   sub list_env_vars { qw/DBI_USER DBI_PASS DBI_DSN/ }
52              
53 0     0 1   sub version_string { 'unknown' }
54              
55 0     0 1   sub stop_sig { 'TERM' }
56              
57       0 1   sub write_config {}
58              
59             sub do_in_env {
60 0     0 1   my $self = shift;
61 0           my ($code) = @_;
62              
63 0           my $old = $self->mask_env_vars;
64              
65 0           my $ok = eval { $code->(); 1 };
  0            
  0            
66 0           my $err = $@;
67              
68 0           $self->unmask_env_vars($old);
69              
70 0 0         die $err unless $ok;
71              
72 0           return;
73             }
74              
75             sub mask_env_vars {
76 0     0 1   my $self = shift;
77              
78 0           my %old;
79              
80 0           for my $var ($self->list_env_vars) {
81 0 0         next unless defined $ENV{$var};
82 0           $old{$var} = delete $ENV{$var};
83             }
84              
85 0   0       my $env_vars = $self->env_vars || {};
86 0           for my $var (keys %$env_vars) {
87 0 0         $old{$var} = delete $ENV{$var} unless defined $old{$var};
88 0           $ENV{$var} = $env_vars->{$var};
89             }
90              
91 0           return \%old;
92             }
93              
94             sub unmask_env_vars {
95 0     0 1   my $self = shift;
96 0           my ($old) = @_;
97              
98 0           for my $var (keys %$old) {
99 0           my $val = $old->{$var};
100              
101 0 0         if (defined $val) {
102 0           $ENV{$var} = $val;
103             }
104             else {
105 0           delete $ENV{$var};
106             }
107             }
108              
109 0           return;
110             }
111              
112             sub name {
113 0     0 1   my $in = shift;
114 0   0       my $type = blessed($in) || $in;
115              
116 0           $type =~ s/^DBIx::QuickDB::Driver:://;
117              
118 0           return $type;
119             }
120              
121             sub init {
122 0     0 1   my $self = shift;
123              
124 0 0         confess "'dir' is a required attribute" unless $self->{+DIR};
125              
126 0           $self->{+ROOT_PID} = $$;
127 0           $self->{+_CLEANUP} = delete $self->{cleanup};
128              
129 0 0         $self->{+USERNAME} = '' unless defined $self->{+USERNAME};
130 0 0         $self->{+PASSWORD} = '' unless defined $self->{+PASSWORD};
131              
132 0   0       $self->{+ENV_VARS} ||= {};
133              
134 0           return;
135             }
136              
137             sub clone_data {
138 0     0 1   my $self = shift;
139              
140             return (
141             USERNAME() => $self->{+USERNAME},
142             PASSWORD() => $self->{+PASSWORD},
143             VERBOSE() => $self->{+VERBOSE},
144             AUTOSTOP() => $self->{+AUTOSTOP},
145             AUTOSTART() => $self->{+AUTOSTART},
146              
147             cleanup => $self->{+_CLEANUP},
148              
149 0           ENV_VARS() => {%{$self->{+ENV_VARS}}},
  0            
150             );
151             }
152              
153             sub resync {
154 0     0 1   my $self = shift;
155              
156 0 0         my $from = $self->{+CLONED_FROM} or croak "No original source to sync from";
157              
158 0           my $started = $self->started;
159 0 0         $self->stop if $started;
160              
161 0 0 0       clone_dir($from, $self->{+DIR}, verbose => (($self->{+VERBOSE} // 0) > 2) ? 1 : 0, checksum => 1);
162              
163 0           $self->write_config();
164              
165 0 0         $self->start if $started;
166             }
167              
168             sub clone {
169 0     0 1   my $self = shift;
170 0           my %params = @_;
171              
172 0 0         confess "Cannot clone a started database, please stop it first."
173             if $self->started;
174              
175 0           my $orig_dir = $self->{+DIR};
176 0 0 0       my $new_dir = delete $params{dir} // tempdir('DB-QUICK-CLONE-XXXXXX', CLEANUP => 0, $ENV{QDB_TMPDIR} ? (DIR => $ENV{QDB_TMPDIR}) : (TMPDIR => 1));
177              
178 0 0 0       clone_dir($orig_dir, $new_dir, verbose => (($self->{+VERBOSE} // 0) > 2) ? 1 : 0);
179              
180 0           my $class = ref($self);
181             my %ok = (
182             cleanup => 1,
183 0           map {$_ => 1} DBIx::QuickDB::Util::HashBase::attr_list($class),
  0            
184             );
185 0           my @bad = grep { !$ok{$_} } keys %params;
  0            
186              
187 0 0         confess "Invalid options to clone(): " . join(', ' => @bad)
188             if @bad;
189              
190 0           my $clone = $class->new(
191             $self->clone_data,
192              
193             %params,
194              
195             DIR() => $new_dir,
196              
197             WATCHER() => undef,
198              
199             CLONED_FROM() => $orig_dir,
200             );
201              
202 0           $clone->write_config();
203 0 0         $clone->start if $clone->{+AUTOSTART};
204              
205 0           return $clone;
206             }
207              
208             sub gen_log {
209 0     0 0   my $self = shift;
210 0 0         return if $self->no_log(@_);
211 0           return $self->{+DIR} . "/cmd-log-$$-" . $self->{+_LOG_ID}++;
212             }
213              
214             sub no_log {
215 0     0 1   my $self = shift;
216 0           my ($params) = @_;
217 0   0       return $self->{+VERBOSE} || $params->{no_log} || $ENV{DB_VERBOSE};
218             }
219              
220             sub run_command {
221 0     0 1   my $self = shift;
222 0           my ($cmd, $params) = @_;
223              
224 0           my $no_log = $self->no_log($params);
225 0   0       my $log_file = $params->{log_file} || ($no_log ? undef : $self->gen_log);
226              
227 0           my $pid = fork();
228 0 0         croak "Could not fork" unless defined $pid;
229              
230 0 0         if ($pid) {
231 0           local $?;
232 0 0         return ($pid, $log_file) if $params->{no_wait};
233 0           my $ret = waitpid($pid, 0);
234 0           my $exit = $?;
235 0 0         die "waitpid returned $ret" unless $ret == $pid;
236              
237 0 0         return unless $exit;
238              
239 0           my $log = "";
240 0 0         unless ($no_log) {
241 0 0         open(my $fh, '<', $log_file) or warn "Failed to open log: $!";
242 0           $log = eval { join "" => <$fh> };
  0            
243             }
244 0           my $error_log = $self->read_error_log;
245 0 0         $log .= "\n=== error log ===\n$error_log" if length $error_log;
246 0           croak "Failed to run command '" . join(' ' => @$cmd) . "' ($exit)\n$log";
247             }
248              
249 0           $self->mask_env_vars;
250              
251 0 0         unless ($no_log) {
252 0 0         open(my $log, '>', $log_file) or die "Could not open log file ($log_file): $!";
253 0           close(STDOUT);
254 0           open(STDOUT, '>&', $log);
255 0           close(STDERR);
256 0           open(STDERR, '>&', $log);
257             }
258              
259 0 0         if (my $file = $params->{stdin}) {
260 0           close(STDIN);
261 0 0         open(STDIN, '<', $file) or die "Could not open new STDIN ($file): $!";
262             }
263              
264 0           exec(@$cmd);
265             }
266              
267 0     0 1   sub should_cleanup { shift->{+_CLEANUP} }
268              
269             # Flush server state durably to disk so a hard kill mid-shutdown cannot leave
270             # the data dir needing crash recovery. Drivers that benefit (e.g. PostgreSQL)
271             # override this; the default is a no-op.
272       0 0   sub checkpoint { }
273              
274             sub cleanup {
275 0     0 1   my $self = shift;
276              
277             # Ignore errors here.
278 0           my $err = [];
279 0 0         remove_tree($self->{+DIR}, {safe => 1, error => \$err}) if -d $self->{+DIR};
280 0           return;
281             }
282              
283             sub connect {
284 0     0 1   my $self = shift;
285 0           my ($db_name, %params) = @_;
286              
287 0 0         %params = (AutoCommit => 1, RaiseError => 1) unless @_ > 1;
288              
289 0           my $dbh;
290             $self->do_in_env(
291             sub {
292 0     0     my $cstring = $self->connect_string($db_name);
293 0           require DBI;
294 0           $dbh = DBI->connect($cstring, $self->username, $self->password, \%params);
295             }
296 0           );
297              
298 0           return $dbh;
299             }
300              
301             sub started {
302 0     0 0   my $self = shift;
303              
304 0           my $socket = $self->socket;
305 0 0 0       return 1 if $self->{+WATCHER} || -S $socket;
306 0           return 0;
307             }
308              
309             sub start {
310 0     0 1   my $self = shift;
311 0           my @args = @_;
312              
313 0           my $dir = $self->{+DIR};
314 0           my $socket = $self->socket;
315              
316 0 0 0       return if $self->{+WATCHER} || -S $socket;
317              
318 0           my $watcher = $self->{+WATCHER} = DBIx::QuickDB::Watcher->new(db => $self, args => \@args);
319              
320             # Defaults to 10s; tunable via QDB_START_TIMEOUT for slow hosts that need
321             # longer to bring a server up (e.g. a clone doing crash recovery).
322 0           my $timeout = env_timeout(QDB_START_TIMEOUT => 10);
323              
324 0           my $start = time;
325 0           until (-S $socket) {
326 0           my $waited = time - $start;
327              
328 0 0         if ($waited > $timeout) {
329 0           my $error_log = $self->read_error_log;
330 0           $watcher->eliminate();
331 0           confess "Timed out waiting for server to start\n$error_log";
332 0           last;
333             }
334              
335 0           sleep 0.01;
336             }
337              
338 0           return;
339             }
340              
341             sub stop {
342 0     0 1   my $self = shift;
343 0           my %params = @_;
344              
345 0 0         my $watcher = delete $self->{+WATCHER} or return;
346              
347             # Flush a durable checkpoint while the server is still running. If shutdown
348             # is slow enough to be SIGKILLed, this ensures the on-disk state is already
349             # consistent so a later clone does not crash-recover and jump SERIAL
350             # sequences forward (PostgreSQL SEQ_LOG_VALS=32), corrupting the clone.
351             # No-op for drivers that do not need it.
352 0           $self->checkpoint;
353              
354             DBI->visit_handles(
355             sub {
356 0     0     my ($driver_handle) = @_;
357              
358             $driver_handle->disconnect
359             if $driver_handle->{Type} && $driver_handle->{Type} eq 'db'
360 0 0 0       && $driver_handle->{Name} && index($driver_handle->{Name}, $self->{+DIR}) >= 0;
      0        
      0        
361              
362 0           return 1;
363             }
364 0           );
365              
366 0           $watcher->stop();
367              
368 0 0         unless ($params{no_wait}) {
369             # wait() blocks until the watcher process exits, and the watcher reaps
370             # the server before it exits -- so once wait() returns the server is
371             # gone. Trust that instead of polling a stored server pid: after the
372             # watcher exits that pid may have been recycled by the OS to an
373             # unrelated process, and polling it could hang/confess on the wrong
374             # process (the same pid-reuse hazard the watcher teardown guards against).
375 0           $watcher->wait();
376              
377             # Remove a stale unix socket left behind by a hard kill so it does not
378             # confuse callers or a later run that reuses the same directory.
379 0           my $socket = $self->socket;
380 0 0 0       unlink($socket) if $socket && -S $socket;
381             }
382              
383 0           return;
384             }
385              
386             sub shell {
387 0     0 1   my $self = shift;
388 0           my ($db_name) = @_;
389 0 0         $db_name = 'quickdb' unless defined $db_name;
390              
391 0           system($self->shell_command($db_name));
392             }
393              
394             sub DESTROY {
395 0     0     my $self = shift;
396 0 0 0       return unless $self->{+ROOT_PID} && $self->{+ROOT_PID} == $$;
397              
398 0 0         if (my $watcher = delete $self->{+WATCHER}) {
    0          
399             # eliminate() signals the watcher to stop the server and delete the data
400             # dir; destroying the watcher then blocks (via Watcher::wait) until the
401             # watcher process has exited, and the watcher reaps the server before it
402             # exits. So once $watcher is gone the server is gone too. We deliberately
403             # do NOT fall back to signalling a stored server pid here: after the
404             # watcher exits that pid may have been recycled to an unrelated process
405             # (the pid-reuse hazard), so a stray TERM/KILL could hit the wrong one.
406 0           $watcher->eliminate();
407 0           undef $watcher;
408              
409 0 0         $self->cleanup() if $self->should_cleanup;
410             }
411             elsif ($self->should_cleanup) {
412 0           $self->cleanup();
413             }
414              
415 0           return;
416             }
417              
418             1;
419              
420             __END__