File Coverage

blib/lib/DBIx/QuickDB/Driver.pm
Criterion Covered Total %
statement 33 226 14.6
branch 0 100 0.0
condition 0 47 0.0
subroutine 11 46 23.9
pod 26 31 83.8
total 70 450 15.5


line stmt bran cond sub pod time code
1             package DBIx::QuickDB::Driver;
2 23     23   174833 use strict;
  23         47  
  23         707  
3 23     23   73 use warnings;
  23         52  
  23         1489  
4              
5             our $VERSION = '0.000045';
6              
7 23     23   119 use Carp qw/croak confess/;
  23         33  
  23         1290  
8 23     23   170 use File::Path qw/remove_tree/;
  23         36  
  23         1050  
9 23     23   2321 use File::Temp qw/tempdir/;
  23         29043  
  23         977  
10 23     23   105 use POSIX ":sys_wait_h";
  23         58  
  23         202  
11 23     23   13633 use Scalar::Util qw/blessed/;
  23         34  
  23         977  
12 23     23   129 use Time::HiRes qw/sleep time/;
  23         44  
  23         231  
13              
14 23     23   4404 use DBIx::QuickDB::Util qw/clone_dir/;
  23         83  
  23         207  
15              
16 23     23   10440 use DBIx::QuickDB::Watcher;
  23         63  
  23         971  
17              
18 23         96 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 23     23   148 };
  23         36  
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 0           my $start = time;
321 0           until (-S $socket) {
322 0           my $waited = time - $start;
323              
324 0 0         if ($waited > 10) {
325 0           my $error_log = $self->read_error_log;
326 0           $watcher->eliminate();
327 0           confess "Timed out waiting for server to start\n$error_log";
328 0           last;
329             }
330              
331 0           sleep 0.01;
332             }
333              
334 0           return;
335             }
336              
337             sub stop {
338 0     0 1   my $self = shift;
339 0           my %params = @_;
340              
341 0 0         my $watcher = delete $self->{+WATCHER} or return;
342              
343             # Flush a durable checkpoint while the server is still running. If shutdown
344             # is slow enough to be SIGKILLed, this ensures the on-disk state is already
345             # consistent so a later clone does not crash-recover and jump SERIAL
346             # sequences forward (PostgreSQL SEQ_LOG_VALS=32), corrupting the clone.
347             # No-op for drivers that do not need it.
348 0           $self->checkpoint;
349              
350             DBI->visit_handles(
351             sub {
352 0     0     my ($driver_handle) = @_;
353              
354             $driver_handle->disconnect
355             if $driver_handle->{Type} && $driver_handle->{Type} eq 'db'
356 0 0 0       && $driver_handle->{Name} && index($driver_handle->{Name}, $self->{+DIR}) >= 0;
      0        
      0        
357              
358 0           return 1;
359             }
360 0           );
361              
362 0           my $server_pid = $watcher->server_pid;
363 0           $watcher->stop();
364              
365 0 0         unless ($params{no_wait}) {
366 0           $watcher->wait();
367              
368             # The watcher reaps the server process before it exits, so once wait()
369             # has returned the server is gone. Confirm via the pid rather than the
370             # unix socket file: a server killed with SIGKILL (e.g. a slow shutdown
371             # that blew the watcher's grace period) never gets to remove its socket,
372             # so waiting for the socket to disappear would hang and then time out.
373 0           my $start = time;
374 0   0       while ($server_pid && kill(0, $server_pid)) {
375 0 0         confess "Timed out waiting for server to stop" if time - $start > 10;
376 0           sleep 0.01;
377             }
378              
379             # Remove a stale unix socket left behind by a hard kill so it does not
380             # confuse callers or a later run that reuses the same directory.
381 0           my $socket = $self->socket;
382 0 0 0       unlink($socket) if $socket && -S $socket;
383             }
384              
385 0           return;
386             }
387              
388             sub shell {
389 0     0 1   my $self = shift;
390 0           my ($db_name) = @_;
391 0 0         $db_name = 'quickdb' unless defined $db_name;
392              
393 0           system($self->shell_command($db_name));
394             }
395              
396             sub DESTROY {
397 0     0     my $self = shift;
398 0 0 0       return unless $self->{+ROOT_PID} && $self->{+ROOT_PID} == $$;
399              
400 0 0         if (my $watcher = delete $self->{+WATCHER}) {
    0          
401 0           my $server_pid = $watcher->server_pid;
402 0           $watcher->eliminate();
403             # Watcher::DESTROY (triggered by $watcher going out of scope) calls
404             # wait(), which blocks until the watcher process exits. After that,
405             # the server should be stopped and the dir cleaned up. But as a
406             # fallback, kill the server directly if it's still alive.
407 0           undef $watcher;
408              
409 0 0 0       if ($server_pid && kill(0, $server_pid)) {
410 0           kill('TERM', $server_pid);
411 0           my $start = time;
412 0           while (kill(0, $server_pid)) {
413 0 0         last if time - $start > 5;
414 0           sleep 0.1;
415             }
416 0 0         kill('KILL', $server_pid) if kill(0, $server_pid);
417             }
418 0 0         $self->cleanup() if $self->should_cleanup;
419             }
420             elsif ($self->should_cleanup) {
421 0           $self->cleanup();
422             }
423              
424 0           return;
425             }
426              
427             1;
428              
429             __END__