File Coverage

blib/lib/Test/DB/Shared/mysqld.pm
Criterion Covered Total %
statement 35 171 20.4
branch 0 26 0.0
condition n/a
subroutine 13 31 41.9
pod 2 6 33.3
total 50 234 21.3


line stmt bran cond sub pod time code
1             package Test::DB::Shared::mysqld;
2             $Test::DB::Shared::mysqld::VERSION = '0.004';
3              
4             =head1 NAME
5              
6             Test::DB::Shared::mysqld - Replaces (and decorate) Test::mysqld to share the MySQL instance between your tests
7              
8             =head1 SYNOPSIS
9              
10             If in your test you use L, this acts as a replacement for Test::mysqld:
11              
12             my $mysqld = Test::DB::Shared::mysqld->new(
13             test_namespace => 'myapp',
14             # Then it's plain Test::mysqld config
15             my_cnf => {
16             'skip-networking' => '', # no TCP socket
17             }
18             );
19              
20             # and use like Test::mysqld:
21             my $dbh = DBI->connect(
22             $mysqld->dsn(), undef, ''
23             );
24              
25             And that's it. No special code to write, no restructuring of your tests, and using as
26             a prove plugin is optional.
27              
28             =head1 STATEMENT
29              
30             What you need is a test database, not a test mysqld instance.
31              
32             =head1 HOW TO USE IT
33              
34             See synopsis for the change to your test code. For the rest, you need to use C
35             to benefit from it.
36              
37             If not all your test use the test db, best results will be obtained by using C
38              
39             =head2 Using it as a prove Plugin
40              
41             To speed things even further, you can use that as a prove plugin, with an optional config file:
42              
43             prove -PTest::DB::Shared::mysqld
44              
45             Or
46              
47             prove -PTest::DB::Shared::mysqld=./testmysqld.json
48              
49             The ./testmysqld.json file can contain the arguments to Test::DB::Shared::mysqld in a json format (see SYNOPSIS). They
50             will be used to build one instance for the whole test suite.
51              
52             If no such file is given, the default configuration is the one specified in the SYNOPSIS, but with a randomly generated test_namespace.
53              
54             Note that using this plugin will result in all your Test::DB::Shared::mysqld instances in your t/ files using the same configuration,
55             regardless of what configuration you give in this or this test.
56              
57             =head1 LIMITATIONS
58              
59             Do NOT use that if your test involves doing anything outside a test database. Tests that manage databases
60             will probably break this.
61              
62             Not all mysqld methods are available. Calls like 'start', 'stop', 'setup', 'read_log' .. are not implemented.
63              
64             =head1 WHAT THIS DOES
65              
66             The first time this is used, it will create a Test::mysqld instance in the current process. Then concurrent processes
67             that use the same module (with the same parameters) will be given a new Database in this already running instance, instead
68             of a new MySQL instance.
69              
70             When this goes out of scope, the test database is destroy, and the last process to destroy the last database will tear
71             down the MySQL instance.
72              
73             =head1 BUGS, DIAGNOSTICS and TROUBLESHOOTING
74              
75             There are probably some. To diagnose them, you can run your test in verbose mode ( prove -v ). If that doesn't help,
76             you can 'use Log::Any::Adapter qw/Stderr/' at the top of your test to get some very verbose tracing.
77              
78             If you SIGKILL your whole test suite, bad things will happen. Running in verbose mode
79             will most probably tell you which files you should clean up on your filesystem to get back to a working state.
80              
81             =head1 METHODS
82              
83             =cut
84              
85 5     5   167046 use Moo;
  5         46803  
  5         21  
86 5     5   5510 use Carp qw/confess/;
  5         10  
  5         189  
87 5     5   1870 use Log::Any qw/$log/;
  5         35201  
  5         21  
88              
89 5     5   8272 use DBI;
  5         11  
  5         138  
90              
91 5     5   2316 use JSON;
  5         35533  
  5         22  
92 5     5   2289 use Test::mysqld;
  5         129589  
  5         139  
93              
94 5     5   1772 use File::Slurp;
  5         12329  
  5         316  
95 5     5   35 use File::Spec;
  5         11  
  5         99  
96 5     5   1925 use File::Flock::Tiny;
  5         4000  
  5         168  
97              
98 5     5   39 use POSIX qw(SIGTERM WNOHANG);
  5         10  
  5         26  
99              
100 5     5   248 use Test::More qw//;
  5         10  
  5         7910  
101              
102             # Settings
103             has 'test_namespace' => ( is => 'ro', default => 'test_db_shared' );
104              
105             # Public facing stuff
106             has 'dsn' => ( is => 'lazy' );
107              
108              
109             # Internal cuisine
110              
111             has '_lock_file' => ( is => 'lazy' );
112             has '_mysqld_file' => ( is => 'lazy' );
113              
114             sub _build__lock_file{
115 0     0   0 my ($self) = @_;
116 0         0 return File::Spec->catfile( File::Spec->tmpdir() , $self->_namespace().'.lock' ).'';
117             }
118             sub _build__mysqld_file{
119 0     0   0 my ($self) = @_;
120 0         0 return File::Spec->catfile( File::Spec->tmpdir() , $self->_namespace().'.mysqld' ).'';
121             }
122              
123             has '_testmysqld_args' => ( is => 'ro', required => 1);
124             has '_temp_db_name' => ( is => 'lazy' );
125             has '_shared_mysqld' => ( is => 'lazy' );
126             has '_instance_pid' => ( is => 'ro', required => 1);
127             has '_holds_mysqld' => ( is => 'rw' );
128              
129             my $PROCESS_INSTANCES = {};
130              
131             around BUILDARGS => sub {
132             my ($orig, $class, @rest ) = @_;
133              
134             my $hash_args = $class->$orig(@rest);
135             my $test_namespace = delete $hash_args->{test_namespace};
136              
137             return {
138             _testmysqld_args => $hash_args,
139             _instance_pid => $$,
140             ( $test_namespace ? ( test_namespace => $test_namespace ) : () ),
141             ( $ENV{TEST_DB_SHARED_NAMESPACE} ? ( test_namespace => $ENV{TEST_DB_SHARED_NAMESPACE} ) : () ),
142             }
143             };
144              
145             sub BUILD{
146 0     0 0 0 my ($self) = @_;
147 0         0 my $wself = \$self;
148 0         0 Scalar::Util::weaken( $self );
149 0         0 $PROCESS_INSTANCES->{$self.''} = $wself;
150 0         0 return $self;
151             }
152              
153             =head2 load
154              
155             L plugin implementation. Do NOT use that yourself.
156              
157             =cut
158              
159             {
160             my $plugin_instance;
161             sub load{
162 0     0 1 0 my ($class, $prove) = @_;
163 0 0       0 my @args = @{$prove->{args} || []};
  0         0  
164 0         0 my $config = {
165             test_namespace => 'plugin'.$$.int( rand(1000) ),
166             my_cnf => {
167             'skip-networking' => '', # no TCP socket
168             }
169             };
170 0         0 my $config_file = $args[0];
171 0 0       0 unless( $config_file ){
172 0         0 Test::More::diag( __PACKAGE__." PID $$ config file is not given. Using default config" );
173             }else{
174 0 0       0 if( ! -e $config_file ){
175 0         0 confess("Cannot find config file $config_file");
176             }else{
177 0         0 $config = JSON::decode_json( scalar( File::Slurp::read_file( $config_file, { binmode => ':raw' } ) ) );
178             }
179             }
180 0         0 $plugin_instance = $class->new( $config );
181             ## Just in case.
182 0         0 unlink( $plugin_instance->_mysqld_file() );
183 0         0 Test::More::diag( __PACKAGE__." PID $$ plugin instance mysqld lives at ".$plugin_instance->dsn() );
184 0         0 Test::More::diag( __PACKAGE__." PID $$ plugin instance mysqld descriptor is ".$plugin_instance->_mysqld_file() );
185             # This will inform all the other instances to reuse the namespace (see BUILDARGS).
186 0         0 $ENV{TEST_DB_SHARED_NAMESPACE} = $plugin_instance->test_namespace();
187 0         0 return 1;
188             }
189 0     0 0 0 sub plugin_instance{ return $plugin_instance; }
190 5     5 0 27 sub tear_down_plugin_instance{ $plugin_instance = undef; }
191              
192             # For the plugin to 'just work'
193             # on unloading this code.
194             sub END{
195 5     5   1990 __PACKAGE__->tear_down_plugin_instance();
196             }
197             }
198              
199              
200              
201             sub _namespace{
202 0     0     my ($self) = @_;
203 0           return 'tdbs49C7_'.$self->test_namespace();
204             }
205              
206             # Build a temp DB name according to this pid.
207             # Note it only works because the instance of the DB will run locally.
208             sub _build__temp_db_name{
209 0     0     my ($self) = @_;
210 0           return $self->_namespace().( $self + $$ );
211             }
212              
213             sub _build__shared_mysqld{
214 0     0     my ($self) = @_;
215             # Two cases here.
216             # Either the test mysqld is there and we returned the already built dsn
217              
218             # Or it's not there and we need to build it IN A MUTEX way.
219             # For a start, let's assume it's not there
220             return $self->_monitor(sub{
221 0     0     my $saved_mysqld;
222 0 0         if( ! -e $self->_mysqld_file() ){
223 0           Test::More::note( "PID $$ Creating new Test::mysqld instance" );
224 0           $log->info("PID $$ Creating new Test::mysqld instance");
225 0 0         my $mysqld = Test::mysqld->new( %{$self->_testmysqld_args()} ) or confess
  0            
226             $Test::mysqld::errstr;
227 0           $log->trace("PID $$ Saving all $mysqld public properties");
228              
229 0           $saved_mysqld = {};
230 0           foreach my $property ( 'dsn', 'pid' ){
231 0           $saved_mysqld->{$property} = $mysqld->$property().''
232             }
233 0           $saved_mysqld->{pid_file} = $mysqld->my_cnf()->{'pid-file'};
234             # DO NOT LET mysql think it can manage its mysqld PID
235 0           $mysqld->pid( undef );
236              
237 0           $self->_holds_mysqld( $mysqld );
238              
239             # Create the pid_registry container.
240 0           $log->trace("PID $$ creating pid_registry table in instance");
241             $self->_with_shared_dbh( $saved_mysqld->{dsn},
242             sub{
243 0           my ($dbh) = @_;
244 0           $dbh->do('CREATE TABLE pid_registry(pid INTEGER NOT NULL, instance BIGINT NOT NULL, PRIMARY KEY(pid, instance))');
245 0           });
246 0           my $json_mysqld = JSON::encode_json( $saved_mysqld );
247 0           $log->trace("PID $$ Saving ".$json_mysqld );
248 0           File::Slurp::write_file( $self->_mysqld_file() , {binmode => ':raw'},
249             $json_mysqld );
250             } else {
251 0           Test::More::note("PID $$ Reusing Test::mysqld from ".$self->_mysqld_file());
252 0           $log->info("PID $$ file ".$self->_mysqld_file()." is there. Reusing cluster");
253 0           $saved_mysqld = JSON::decode_json(
254             scalar( File::Slurp::read_file( $self->_mysqld_file() , {binmode => ':raw'} ) )
255             );
256             }
257              
258             $self->_with_shared_dbh( $saved_mysqld->{dsn},
259             sub{
260 0           my $dbh = shift;
261 0           $dbh->do('INSERT INTO pid_registry( pid, instance ) VALUES (?,?)' , {},
262             $self->_instance_pid(), ( $self + $self->_instance_pid() )
263             );
264 0           });
265 0           return $saved_mysqld;
266 0           });
267             }
268              
269             sub _build_dsn{
270 0     0     my ($self) = @_;
271 0 0         if( $$ != $self->_instance_pid() ){
272 0           confess("Do not build the dsn in a subprocess of this instance creator");
273             }
274              
275 0           my $dsn = $self->_shared_mysqld()->{dsn};
276             return $self->_with_shared_dbh( $dsn, sub{
277 0     0     my $dbh = shift;
278 0           my $temp_db_name = $self->_temp_db_name();
279 0           $log->info("PID $$ creating temporary database '$temp_db_name' on $dsn");
280 0           $dbh->do('CREATE DATABASE '.$temp_db_name);
281 0           $dsn =~ s/dbname=([^;])+/dbname=$temp_db_name/;
282 0           $log->info("PID $$ local dsn is '$dsn'");
283 0           return $dsn;
284 0           });
285             }
286              
287             sub _teardown{
288 0     0     my ($self) = @_;
289 0           my $dsn = $self->_shared_mysqld()->{dsn};
290             $self->_with_shared_dbh( $dsn,
291             sub{
292 0     0     my $dbh = shift;
293 0           $dbh->do('DELETE FROM pid_registry WHERE pid = ? AND instance = ? ',{}, $self->_instance_pid() , ( $self + $self->_instance_pid() ) );
294 0           my ( $count_row ) = $dbh->selectrow_array('SELECT COUNT(*) FROM pid_registry');
295 0 0         if( $count_row ){
296 0           $log->info("PID $$ Some PIDs,Instances are still registered as using this DB. Not tearing down");
297 0           return;
298             }
299 0           $log->info("PID $$ no pids anymore in the DB. Tearing down");
300 0           $log->info("PID $$ unlinking ".$self->_mysqld_file());
301 0           unlink $self->_mysqld_file();
302 0           Test::More::note("PID $$ terminating mysqld instance (sending SIGTERM to ".$self->pid().")");
303 0           $log->info("PID $$ terminating mysqld instance (sending SIGTERM to ".$self->pid().")");
304 0           kill SIGTERM, $self->pid();
305 0           });
306             }
307              
308             sub DEMOLISH{
309 0     0 0   my ($self) = @_;
310 0 0         if( $$ != $self->_instance_pid() ){
311             # Do NOT let subprocesses that forked
312             # after the creation of this to have an impact.
313 0           return;
314             }
315              
316 0           delete $PROCESS_INSTANCES->{$self.''};
317              
318              
319             $self->_monitor(sub{
320             # We always want to drop the local process database.
321 0     0     my $dsn = $self->_shared_mysqld()->{dsn};
322 0           $log->info("PID $$ Will drop database on dsn = $dsn");
323             $self->_with_shared_dbh($dsn, sub{
324 0           my $dbh = shift;
325 0           my $temp_db_name = $self->_temp_db_name();
326 0           $log->info("PID $$ dropping temporary database $temp_db_name");
327 0           $dbh->do("DROP DATABASE ".$temp_db_name);
328 0           });
329 0           $self->_teardown();
330 0           });
331              
332 0 0         if( my @other_instances = keys( %{$PROCESS_INSTANCES} ) ){
  0            
333             # Other instances are still alive (in the same PID). Pass on the test mysqld to them
334             # if we have one.
335 0 0         if( my $test_mysqld = $self->_holds_mysqld() ){
336 0           $log->info("PID $$ instance $self giving mysqld to other living instance ".$other_instances[0]);
337 0           ${$PROCESS_INSTANCES->{$other_instances[0]}}->_holds_mysqld( $self->_holds_mysqld() );
  0            
338 0           $self->_holds_mysqld( undef );
339             }
340             }
341              
342 0 0         if( my $test_mysqld = $self->_holds_mysqld() ){
343             # This is the mysqld holder process. Need to wait for it
344             # before exiting
345 0           Test::More::note("PID $$ mysqld holder process waiting for mysqld termination");
346 0           $log->info("PID $$ mysqld holder process waiting for mysqld termination");
347 0           while( waitpid( $self->pid() , 0 ) <= 0 ){
348 0           $log->info("PID $$ db pid = ".$self->pid()." not down yet. Waiting 2 seconds");
349 0           sleep(2);
350             }
351 0           my $pid_file = $self->_shared_mysqld()->{pid_file};
352 0           $log->trace("PID $$ unlinking mysql pidfile $pid_file. Just in case");
353 0           unlink( $pid_file );
354 0           $log->info("PID $$ Ok, mysqld is gone");
355             }
356             }
357              
358             =head1 dsn
359              
360             Returns the dsn to connect to the test database. Note that the user is root and the password
361             is the empty string.
362              
363             =cut
364              
365             =head2 pid
366              
367             See L
368              
369             =cut
370              
371             sub pid{
372 0     0 1   my ($self) = @_;
373 0           return $self->_shared_mysqld()->{pid};
374             }
375              
376             my $in_monitor = {};
377             sub _monitor{
378 0     0     my ($self, $sub) = @_;
379              
380 0 0         if( $in_monitor->{$self} ){
381 0           $log->warn("PID $$ Re-entrant monitor. Will execute sub without locking for deadlock protection");
382 0           return $sub->();
383             }
384 0           $log->trace("PID $$ locking file ".$self->_lock_file());
385 0           $in_monitor->{$self} = 1;
386 0           my $lock = File::Flock::Tiny->lock( $self->_lock_file() );
387 0           my $res = eval{ $sub->(); };
  0            
388 0           my $err = $@;
389 0           delete $in_monitor->{$self};
390 0           $lock->release();
391 0 0         if( $err ){
392 0           confess($err);
393             }
394 0           return $res;
395             }
396              
397             sub _with_shared_dbh{
398 0     0     my ($self, $dsn, $code) = @_;
399 0           my $dbh = DBI->connect_cached( $dsn, 'root', '' , { RaiseError => 1 });
400 0           return $code->($dbh);
401             }
402              
403             __PACKAGE__->meta->make_immutable();