File Coverage

blib/lib/Test/DB/Shared/mysqld.pm
Criterion Covered Total %
statement 79 154 51.3
branch 13 22 59.0
condition n/a
subroutine 22 29 75.8
pod 2 5 40.0
total 116 210 55.2


line stmt bran cond sub pod time code
1             package Test::DB::Shared::mysqld;
2             $Test::DB::Shared::mysqld::VERSION = '0.002';
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<Test::mysqld>, 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<prove -j number>
35             to benefit from it.
36              
37             If not all your test use the test db, best results will be obtained by using C<prove -s -j number>
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   359585 use Moose;
  9         3487157  
  9         65  
86 9     9   63512 use Log::Any qw/$log/;
  9         54159  
  9         45  
87              
88 9     9   15385 use DBI;
  9         26  
  9         324  
89              
90 9     9   4382 use JSON;
  9         67496  
  9         50  
91 9     9   4537 use Test::mysqld;
  9         191479  
  9         268  
92              
93 9     9   2220 use File::Slurp;
  9         17419  
  9         4070  
94 9     9   960 use File::Spec;
  9         24  
  9         155  
95 9     9   3506 use File::Flock::Tiny;
  9         6848  
  9         257  
96              
97 9     9   48 use POSIX qw(SIGTERM WNOHANG);
  9         20  
  9         41  
98              
99 9     9   469 use Test::More qw//;
  9         18  
  9         13210  
100              
101             # Settings
102             has 'test_namespace' => ( is => 'ro', isa => 'Str', default => 'test_db_shared' );
103              
104             # Public facing stuff
105             has 'dsn' => ( is => 'ro', isa => 'Str', lazy_build => 1 );
106              
107              
108             # Internal cuisine
109              
110             has '_lock_file' => ( is => 'ro', isa => 'Str', lazy_build => 1 );
111             has '_mysqld_file' => ( is => 'ro', isa => 'Str', lazy_build => 1 );
112              
113             sub _build__lock_file{
114 7     7   24 my ($self) = @_;
115 7         363 return File::Spec->catfile( File::Spec->tmpdir() , $self->_namespace().'.lock' ).'';
116             }
117             sub _build__mysqld_file{
118 7     7   30 my ($self) = @_;
119 7         421 return File::Spec->catfile( File::Spec->tmpdir() , $self->_namespace().'.mysqld' ).'';
120             }
121              
122             has '_testmysqld_args' => ( is => 'ro', isa => 'HashRef', required => 1);
123             has '_temp_db_name' => ( is => 'ro', isa => 'Str', lazy_build => 1 );
124             has '_shared_mysqld' => ( is => 'ro', isa => 'HashRef', lazy_build => 1 );
125             has '_instance_pid' => ( is => 'ro', isa => 'Int', required => 1);
126             has '_holds_mysqld' => ( is => 'rw', isa => 'Maybe[Test::mysqld]', default => undef);
127              
128             around BUILDARGS => sub {
129             my ($orig, $class, @rest ) = @_;
130              
131             my $hash_args = $class->$orig(@rest);
132             my $test_namespace = delete $hash_args->{test_namespace};
133              
134             return {
135             _testmysqld_args => $hash_args,
136             _instance_pid => $$,
137             ( $test_namespace ? ( test_namespace => $test_namespace ) : () ),
138             ( $ENV{TEST_DB_SHARED_NAMESPACE} ? ( test_namespace => $ENV{TEST_DB_SHARED_NAMESPACE} ) : () ),
139             }
140             };
141              
142              
143             =head2 load
144              
145             L<App::Prove> plugin implementation. Do NOT use that yourself.
146              
147             =cut
148              
149             {
150             my $plugin_instance;
151             sub load{
152 2     2 1 1260 my ($class, $prove) = @_;
153 2 100       23 my @args = @{$prove->{args} || []};
  2         105  
154 2         177 my $config = {
155             test_namespace => 'plugin'.$$.int( rand(1000) ),
156             my_cnf => {
157             'skip-networking' => '', # no TCP socket
158             }
159             };
160 2         7 my $config_file = $args[0];
161 2 100       25 unless( $config_file ){
162 1         116 Test::More::diag( __PACKAGE__." PID $$ config file is not given. Using default config" );
163             }else{
164 1 50       13 if( ! -e $config_file ){
165 0         0 confess("Cannot find config file $config_file");
166             }else{
167 1         6 $config = JSON::decode_json( scalar( File::Slurp::read_file( $config_file, { binmode => ':raw' } ) ) );
168             }
169             }
170 2         955 $plugin_instance = $class->new( $config );
171             ## Just in case.
172 2         64 unlink( $plugin_instance->_mysqld_file() );
173 2         67 Test::More::diag( __PACKAGE__." PID $$ plugin instance mysqld lives at ".$plugin_instance->dsn() );
174 0         0 Test::More::diag( __PACKAGE__." PID $$ plugin instance mysqld descriptor is ".$plugin_instance->_mysqld_file() );
175             # This will inform all the other instances to reuse the namespace (see BUILDARGS).
176 0         0 $ENV{TEST_DB_SHARED_NAMESPACE} = $plugin_instance->test_namespace();
177 0         0 return 1;
178             }
179 0     0 0 0 sub plugin_instance{ return $plugin_instance; }
180 9     9 0 222 sub tear_down_plugin_instance{ $plugin_instance = undef; }
181              
182             # For the plugin to 'just work'
183             # on unloading this code.
184             sub END{
185 9     9   915579 __PACKAGE__->tear_down_plugin_instance();
186             }
187             }
188              
189              
190              
191             sub _namespace{
192 14     14   47 my ($self) = @_;
193 14         454 return 'tdbs49C7_'.$self->test_namespace();
194             }
195              
196             # Build a temp DB name according to this pid.
197             # Note it only works because the instance of the DB will run locally.
198             sub _build__temp_db_name{
199 0     0   0 my ($self) = @_;
200 0         0 return $self->_namespace().( $self + 0 );
201             }
202              
203             sub _build__shared_mysqld{
204 14     14   45 my ($self) = @_;
205             # Two cases here.
206             # Either the test mysqld is there and we returned the already built dsn
207              
208             # Or it's not there and we need to build it IN A MUTEX way.
209             # For a start, let's assume it's not there
210             return $self->_monitor(sub{
211 14     14   55 my $saved_mysqld;
212 14 50       609 if( ! -e $self->_mysqld_file() ){
213 14         215 Test::More::note( "PID $$ Creating new Test::mysqld instance" );
214 14         3817 $log->info("PID $$ Creating new Test::mysqld instance");
215 14 50       70 my $mysqld = Test::mysqld->new( %{$self->_testmysqld_args()} ) or confess
  14         706  
216             $Test::mysqld::errstr;
217 0         0 $log->trace("PID $$ Saving all $mysqld public properties");
218              
219 0         0 $saved_mysqld = {};
220 0         0 foreach my $property ( 'dsn', 'pid' ){
221 0         0 $saved_mysqld->{$property} = $mysqld->$property().''
222             }
223 0         0 $saved_mysqld->{pid_file} = $mysqld->my_cnf()->{'pid-file'};
224             # DO NOT LET mysql think it can manage its mysqld PID
225 0         0 $mysqld->pid( undef );
226              
227 0         0 $self->_holds_mysqld( $mysqld );
228              
229             # Create the pid_registry container.
230 0         0 $log->trace("PID $$ creating pid_registry table in instance");
231             $self->_with_shared_dbh( $saved_mysqld->{dsn},
232             sub{
233 0         0 my ($dbh) = @_;
234 0         0 $dbh->do('CREATE TABLE pid_registry(pid INTEGER NOT NULL, instance BIGINT NOT NULL, PRIMARY KEY(pid, instance))');
235 0         0 });
236 0         0 my $json_mysqld = JSON::encode_json( $saved_mysqld );
237 0         0 $log->trace("PID $$ Saving ".$json_mysqld );
238 0         0 File::Slurp::write_file( $self->_mysqld_file() , {binmode => ':raw'},
239             $json_mysqld );
240             } else {
241 0         0 Test::More::note("PID $$ Reusing Test::mysqld from ".$self->_mysqld_file());
242 0         0 $log->info("PID $$ file ".$self->_mysqld_file()." is there. Reusing cluster");
243 0         0 $saved_mysqld = JSON::decode_json(
244             scalar( File::Slurp::read_file( $self->_mysqld_file() , {binmode => ':raw'} ) )
245             );
246             }
247              
248             $self->_with_shared_dbh( $saved_mysqld->{dsn},
249             sub{
250 0         0 my $dbh = shift;
251 0         0 $dbh->do('INSERT INTO pid_registry( pid, instance ) VALUES (?,?)' , {},
252             $self->_instance_pid(), ( $self + 0 )
253             );
254 0         0 });
255 0         0 return $saved_mysqld;
256 14         294 });
257             }
258              
259             sub _build_dsn{
260 7     7   24 my ($self) = @_;
261 7 50       225 if( $$ != $self->_instance_pid() ){
262 0         0 confess("Do not build the dsn in a subprocess of this instance creator");
263             }
264              
265 7         237 my $dsn = $self->_shared_mysqld()->{dsn};
266             return $self->_with_shared_dbh( $dsn, sub{
267 0     0   0 my $dbh = shift;
268 0         0 my $temp_db_name = $self->_temp_db_name();
269 0         0 $log->info("PID $$ creating temporary database '$temp_db_name' on $dsn");
270 0         0 $dbh->do('CREATE DATABASE '.$temp_db_name);
271 0         0 $dsn =~ s/dbname=([^;])+/dbname=$temp_db_name/;
272 0         0 $log->info("PID $$ local dsn is '$dsn'");
273 0         0 return $dsn;
274 0         0 });
275             }
276              
277              
278             sub _teardown{
279 0     0   0 my ($self) = @_;
280 0         0 my $dsn = $self->_shared_mysqld()->{dsn};
281             $self->_with_shared_dbh( $dsn,
282             sub{
283 0     0   0 my $dbh = shift;
284 0         0 $dbh->do('DELETE FROM pid_registry WHERE pid = ? AND instance = ? ',{}, $self->_instance_pid() , ( $self + 0 ) );
285 0         0 my ( $count_row ) = $dbh->selectrow_array('SELECT COUNT(*) FROM pid_registry');
286 0 0       0 if( $count_row ){
287 0         0 $log->info("PID $$ Some PIDs,Instances are still registered as using this DB. Not tearing down");
288 0         0 return;
289             }
290 0         0 $log->info("PID $$ no pids anymore in the DB. Tearing down");
291 0         0 $log->info("PID $$ unlinking ".$self->_mysqld_file());
292 0         0 unlink $self->_mysqld_file();
293 0         0 Test::More::note("PID $$ terminating mysqld instance (sending SIGTERM to ".$self->pid().")");
294 0         0 $log->info("PID $$ terminating mysqld instance (sending SIGTERM to ".$self->pid().")");
295 0         0 kill SIGTERM, $self->pid();
296 0         0 });
297             }
298              
299             sub DEMOLISH{
300 7     7 0 31 my ($self) = @_;
301 7 50       218 if( $$ != $self->_instance_pid() ){
302             # Do NOT let subprocesses that forked
303             # after the creation of this to have an impact.
304 0         0 return;
305             }
306              
307             $self->_monitor(sub{
308             # We always want to drop the local process database.
309 7     7   362 my $dsn = $self->_shared_mysqld()->{dsn};
310 0         0 $log->info("PID $$ Will drop database on dsn = $dsn");
311             $self->_with_shared_dbh($dsn, sub{
312 0         0 my $dbh = shift;
313 0         0 my $temp_db_name = $self->_temp_db_name();
314 0         0 $log->info("PID $$ dropping temporary database $temp_db_name");
315 0         0 $dbh->do("DROP DATABASE ".$temp_db_name);
316 0         0 });
317 0         0 $self->_teardown();
318 7         98 });
319              
320 0 0       0 if( my $test_mysqld = $self->_holds_mysqld() ){
321             # This is the mysqld holder process. Need to wait for it
322             # before exiting
323 0         0 Test::More::note("PID $$ mysqld holder process waiting for mysqld termination");
324 0         0 $log->info("PID $$ mysqld holder process waiting for mysqld termination");
325 0         0 while( waitpid( $self->pid() , 0 ) <= 0 ){
326 0         0 $log->info("PID $$ db pid = ".$self->pid()." not down yet. Waiting 2 seconds");
327 0         0 sleep(2);
328             }
329 0         0 my $pid_file = $self->_shared_mysqld()->{pid_file};
330 0         0 $log->trace("PID $$ unlinking mysql pidfile $pid_file. Just in case");
331 0         0 unlink( $pid_file );
332 0         0 $log->info("PID $$ Ok, mysqld is gone");
333             }
334             }
335              
336             =head1 dsn
337              
338             Returns the dsn to connect to the test database. Note that the user is root and the password
339             is the empty string.
340              
341             =cut
342              
343             =head2 pid
344              
345             See L<Test::mysqld>
346              
347             =cut
348              
349             sub pid{
350 0     0 1 0 my ($self) = @_;
351 0         0 return $self->_shared_mysqld()->{pid};
352             }
353              
354             my $in_monitor = {};
355             sub _monitor{
356 23     23   86 my ($self, $sub) = @_;
357              
358 23 100       105 if( $in_monitor->{$self} ){
359 8         129 $log->warn("PID $$ Re-entrant monitor. Will execute sub without locking for deadlock protection");
360 8         51 return $sub->();
361             }
362 15         548 $log->trace("PID $$ locking file ".$self->_lock_file());
363 15         114 $in_monitor->{$self} = 1;
364 15         382 my $lock = File::Flock::Tiny->lock( $self->_lock_file() );
365 15         114019 my $res = eval{ $sub->(); };
  15         66  
366 15         136903 my $err = $@;
367 15         134 delete $in_monitor->{$self};
368 15 100       125 if( $err ){
369 14         1585 confess($err);
370             }
371 1         6 return $res;
372             }
373              
374             sub _with_shared_dbh{
375 0     0     my ($self, $dsn, $code) = @_;
376 0           my $dbh = DBI->connect_cached( $dsn, 'root', '' , { RaiseError => 1 });
377 0           return $code->($dbh);
378             }
379              
380             __PACKAGE__->meta->make_immutable();