File Coverage

blib/lib/Test/DB/Shared/mysqld.pm
Criterion Covered Total %
statement 89 171 52.0
branch 13 26 50.0
condition n/a
subroutine 24 31 77.4
pod 2 6 33.3
total 128 234 54.7


line stmt bran cond sub pod time code
1             package Test::DB::Shared::mysqld;
2             $Test::DB::Shared::mysqld::VERSION = '0.003';
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 9     9   385479 use Moo;
  9         88894  
  9         48  
86 9     9   10486 use Carp qw/confess/;
  9         24  
  9         372  
87 9     9   3383 use Log::Any qw/$log/;
  9         67943  
  9         42  
88              
89 9     9   15237 use DBI;
  9         19  
  9         264  
90              
91 9     9   4171 use JSON;
  9         66719  
  9         43  
92 9     9   4300 use Test::mysqld;
  9         191451  
  9         292  
93              
94 9     9   2391 use File::Slurp;
  9         15590  
  9         515  
95 9     9   57 use File::Spec;
  9         20  
  9         183  
96 9     9   3630 use File::Flock::Tiny;
  9         7276  
  9         299  
97              
98 9     9   73 use POSIX qw(SIGTERM WNOHANG);
  9         18  
  9         51  
99              
100 9     9   457 use Test::More qw//;
  9         17  
  9         14061  
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 7     7   96 my ($self) = @_;
116 7         336 return File::Spec->catfile( File::Spec->tmpdir() , $self->_namespace().'.lock' ).'';
117             }
118             sub _build__mysqld_file{
119 7     7   85 my ($self) = @_;
120 7         369 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', default => undef);
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 7     7 0 209 my ($self) = @_;
147 7         24 my $wself = \$self;
148 7         45 Scalar::Util::weaken( $self );
149 7         50 $PROCESS_INSTANCES->{$self.''} = $wself;
150 7         54 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 2     2 1 902 my ($class, $prove) = @_;
163 2 100       17 my @args = @{$prove->{args} || []};
  2         94  
164 2         139 my $config = {
165             test_namespace => 'plugin'.$$.int( rand(1000) ),
166             my_cnf => {
167             'skip-networking' => '', # no TCP socket
168             }
169             };
170 2         16 my $config_file = $args[0];
171 2 100       24 unless( $config_file ){
172 1         40 Test::More::diag( __PACKAGE__." PID $$ config file is not given. Using default config" );
173             }else{
174 1 50       12 if( ! -e $config_file ){
175 0         0 confess("Cannot find config file $config_file");
176             }else{
177 1         9 $config = JSON::decode_json( scalar( File::Slurp::read_file( $config_file, { binmode => ':raw' } ) ) );
178             }
179             }
180 2         655 $plugin_instance = $class->new( $config );
181             ## Just in case.
182 2         39 unlink( $plugin_instance->_mysqld_file() );
183 2         52 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 9     9 0 80 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 9     9   554043 __PACKAGE__->tear_down_plugin_instance();
196             }
197             }
198              
199              
200              
201             sub _namespace{
202 14     14   47 my ($self) = @_;
203 14         603 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   0 my ($self) = @_;
210 0         0 return $self->_namespace().( $self + $$ );
211             }
212              
213             sub _build__shared_mysqld{
214 14     14   156 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 14     14   26 my $saved_mysqld;
222 14 50       316 if( ! -e $self->_mysqld_file() ){
223 14         326 Test::More::note( "PID $$ Creating new Test::mysqld instance" );
224 14         3987 $log->info("PID $$ Creating new Test::mysqld instance");
225 14 50       72 my $mysqld = Test::mysqld->new( %{$self->_testmysqld_args()} ) or confess
  14         266  
226             $Test::mysqld::errstr;
227 0         0 $log->trace("PID $$ Saving all $mysqld public properties");
228              
229 0         0 $saved_mysqld = {};
230 0         0 foreach my $property ( 'dsn', 'pid' ){
231 0         0 $saved_mysqld->{$property} = $mysqld->$property().''
232             }
233 0         0 $saved_mysqld->{pid_file} = $mysqld->my_cnf()->{'pid-file'};
234             # DO NOT LET mysql think it can manage its mysqld PID
235 0         0 $mysqld->pid( undef );
236              
237 0         0 $self->_holds_mysqld( $mysqld );
238              
239             # Create the pid_registry container.
240 0         0 $log->trace("PID $$ creating pid_registry table in instance");
241             $self->_with_shared_dbh( $saved_mysqld->{dsn},
242             sub{
243 0         0 my ($dbh) = @_;
244 0         0 $dbh->do('CREATE TABLE pid_registry(pid INTEGER NOT NULL, instance BIGINT NOT NULL, PRIMARY KEY(pid, instance))');
245 0         0 });
246 0         0 my $json_mysqld = JSON::encode_json( $saved_mysqld );
247 0         0 $log->trace("PID $$ Saving ".$json_mysqld );
248 0         0 File::Slurp::write_file( $self->_mysqld_file() , {binmode => ':raw'},
249             $json_mysqld );
250             } else {
251 0         0 Test::More::note("PID $$ Reusing Test::mysqld from ".$self->_mysqld_file());
252 0         0 $log->info("PID $$ file ".$self->_mysqld_file()." is there. Reusing cluster");
253 0         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         0 my $dbh = shift;
261 0         0 $dbh->do('INSERT INTO pid_registry( pid, instance ) VALUES (?,?)' , {},
262             $self->_instance_pid(), ( $self + $self->_instance_pid() )
263             );
264 0         0 });
265 0         0 return $saved_mysqld;
266 14         232 });
267             }
268              
269             sub _build_dsn{
270 7     7   230 my ($self) = @_;
271 7 50       114 if( $$ != $self->_instance_pid() ){
272 0         0 confess("Do not build the dsn in a subprocess of this instance creator");
273             }
274              
275 7         346 my $dsn = $self->_shared_mysqld()->{dsn};
276             return $self->_with_shared_dbh( $dsn, sub{
277 0     0   0 my $dbh = shift;
278 0         0 my $temp_db_name = $self->_temp_db_name();
279 0         0 $log->info("PID $$ creating temporary database '$temp_db_name' on $dsn");
280 0         0 $dbh->do('CREATE DATABASE '.$temp_db_name);
281 0         0 $dsn =~ s/dbname=([^;])+/dbname=$temp_db_name/;
282 0         0 $log->info("PID $$ local dsn is '$dsn'");
283 0         0 return $dsn;
284 0         0 });
285             }
286              
287             sub _teardown{
288 0     0   0 my ($self) = @_;
289 0         0 my $dsn = $self->_shared_mysqld()->{dsn};
290             $self->_with_shared_dbh( $dsn,
291             sub{
292 0     0   0 my $dbh = shift;
293 0         0 $dbh->do('DELETE FROM pid_registry WHERE pid = ? AND instance = ? ',{}, $self->_instance_pid() , ( $self + $self->_instance_pid() ) );
294 0         0 my ( $count_row ) = $dbh->selectrow_array('SELECT COUNT(*) FROM pid_registry');
295 0 0       0 if( $count_row ){
296 0         0 $log->info("PID $$ Some PIDs,Instances are still registered as using this DB. Not tearing down");
297 0         0 return;
298             }
299 0         0 $log->info("PID $$ no pids anymore in the DB. Tearing down");
300 0         0 $log->info("PID $$ unlinking ".$self->_mysqld_file());
301 0         0 unlink $self->_mysqld_file();
302 0         0 Test::More::note("PID $$ terminating mysqld instance (sending SIGTERM to ".$self->pid().")");
303 0         0 $log->info("PID $$ terminating mysqld instance (sending SIGTERM to ".$self->pid().")");
304 0         0 kill SIGTERM, $self->pid();
305 0         0 });
306             }
307              
308             sub DEMOLISH{
309 7     7 0 10242 my ($self) = @_;
310 7 50       155 if( $$ != $self->_instance_pid() ){
311             # Do NOT let subprocesses that forked
312             # after the creation of this to have an impact.
313 0         0 return;
314             }
315              
316 7         79 delete $PROCESS_INSTANCES->{$self.''};
317              
318              
319             $self->_monitor(sub{
320             # We always want to drop the local process database.
321 7     7   206 my $dsn = $self->_shared_mysqld()->{dsn};
322 0         0 $log->info("PID $$ Will drop database on dsn = $dsn");
323             $self->_with_shared_dbh($dsn, sub{
324 0         0 my $dbh = shift;
325 0         0 my $temp_db_name = $self->_temp_db_name();
326 0         0 $log->info("PID $$ dropping temporary database $temp_db_name");
327 0         0 $dbh->do("DROP DATABASE ".$temp_db_name);
328 0         0 });
329 0         0 $self->_teardown();
330 7         103 });
331              
332 0 0       0 if( my @other_instances = keys( %{$PROCESS_INSTANCES} ) ){
  0         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       0 if( my $test_mysqld = $self->_holds_mysqld() ){
336 0         0 $log->info("PID $$ instance $self giving mysqld to other living instance ".$other_instances[0]);
337 0         0 ${$PROCESS_INSTANCES->{$other_instances[0]}}->_holds_mysqld( $self->_holds_mysqld() );
  0         0  
338 0         0 $self->_holds_mysqld( undef );
339             }
340             }
341              
342 0 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         0 Test::More::note("PID $$ mysqld holder process waiting for mysqld termination");
346 0         0 $log->info("PID $$ mysqld holder process waiting for mysqld termination");
347 0         0 while( waitpid( $self->pid() , 0 ) <= 0 ){
348 0         0 $log->info("PID $$ db pid = ".$self->pid()." not down yet. Waiting 2 seconds");
349 0         0 sleep(2);
350             }
351 0         0 my $pid_file = $self->_shared_mysqld()->{pid_file};
352 0         0 $log->trace("PID $$ unlinking mysql pidfile $pid_file. Just in case");
353 0         0 unlink( $pid_file );
354 0         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 0 my ($self) = @_;
373 0         0 return $self->_shared_mysqld()->{pid};
374             }
375              
376             my $in_monitor = {};
377             sub _monitor{
378 23     23   92 my ($self, $sub) = @_;
379              
380 23 100       109 if( $in_monitor->{$self} ){
381 8         108 $log->warn("PID $$ Re-entrant monitor. Will execute sub without locking for deadlock protection");
382 8         47 return $sub->();
383             }
384 15         409 $log->trace("PID $$ locking file ".$self->_lock_file());
385 15         210 $in_monitor->{$self} = 1;
386 15         286 my $lock = File::Flock::Tiny->lock( $self->_lock_file() );
387 15         51293 my $res = eval{ $sub->(); };
  15         52  
388 15         111270 my $err = $@;
389 15         105 delete $in_monitor->{$self};
390 15         193 $lock->release();
391 15 100       1510 if( $err ){
392 14         1451 confess($err);
393             }
394 1         9 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();