Passed
Pull Request — development (#3708)
by Martyn
15:25
created

MasterSlaveReplication::reset()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 3
Code Lines 1

Duplication

Lines 0
Ratio 0 %

Importance

Changes 1
Bugs 0 Features 0
Metric Value
cc 1
eloc 1
c 1
b 0
f 0
nc 1
nop 0
dl 0
loc 3
rs 10
1
<?php
2
3
/*
4
 * This file is part of the Predis package.
5
 *
6
 * (c) 2009-2020 Daniele Alessandri
7
 * (c) 2021-2023 Till Krüss
8
 *
9
 * For the full copyright and license information, please view the LICENSE
10
 * file that was distributed with this source code.
11
 */
12
13
namespace Predis\Connection\Replication;
14
15
use InvalidArgumentException;
16
use Predis\ClientException;
17
use Predis\Command\CommandInterface;
18
use Predis\Command\RawCommand;
19
use Predis\Connection\ConnectionException;
20
use Predis\Connection\FactoryInterface;
21
use Predis\Connection\NodeConnectionInterface;
22
use Predis\Replication\MissingMasterException;
23
use Predis\Replication\ReplicationStrategy;
24
use Predis\Response\ErrorInterface as ResponseErrorInterface;
25
26
/**
27
 * Aggregate connection handling replication of Redis nodes configured in a
28
 * single master / multiple slaves setup.
29
 */
30
class MasterSlaveReplication implements ReplicationInterface
31
{
32
    /**
33
     * @var ReplicationStrategy
34
     */
35
    protected $strategy;
36
37
    /**
38
     * @var NodeConnectionInterface
39
     */
40
    protected $master;
41
42
    /**
43
     * @var NodeConnectionInterface[]
44
     */
45
    protected $slaves = [];
46
47
    /**
48
     * @var NodeConnectionInterface[]
49
     */
50
    protected $pool = [];
51
52
    /**
53
     * @var NodeConnectionInterface[]
54
     */
55
    protected $aliases = [];
56
57
    /**
58
     * @var NodeConnectionInterface
59
     */
60
    protected $current;
61
62
    /**
63
     * @var bool
64
     */
65
    protected $autoDiscovery = false;
66
67
    /**
68
     * @var FactoryInterface
69
     */
70
    protected $connectionFactory;
71
72
    /**
73
     * {@inheritdoc}
74
     */
75
    public function __construct(ReplicationStrategy $strategy = null)
76
    {
77
        $this->strategy = $strategy ?: new ReplicationStrategy();
78
    }
79
80
    /**
81
     * Configures the automatic discovery of the replication configuration on failure.
82
     *
83
     * @param bool $value Enable or disable auto discovery.
84
     */
85
    public function setAutoDiscovery($value)
86
    {
87
        if (!$this->connectionFactory) {
88
            throw new ClientException('Automatic discovery requires a connection factory');
89
        }
90
91
        $this->autoDiscovery = (bool) $value;
92
    }
93
94
    /**
95
     * Sets the connection factory used to create the connections by the auto
96
     * discovery procedure.
97
     *
98
     * @param FactoryInterface $connectionFactory Connection factory instance.
99
     */
100
    public function setConnectionFactory(FactoryInterface $connectionFactory)
101
    {
102
        $this->connectionFactory = $connectionFactory;
103
    }
104
105
    /**
106
     * Resets the connection state.
107
     */
108
    protected function reset()
109
    {
110
        $this->current = null;
111
    }
112
113
    /**
114
     * {@inheritdoc}
115
     */
116
    public function add(NodeConnectionInterface $connection)
117
    {
118
        $parameters = $connection->getParameters();
119
120
        if ('master' === $parameters->role) {
0 ignored issues
show
Bug Best Practice introduced by
The property role does not exist on Predis\Connection\ParametersInterface. Since you implemented __get, consider adding a @property annotation.
Loading history...
121
            $this->master = $connection;
122
        } else {
123
            // everything else is considered a slvave.
124
            $this->slaves[] = $connection;
125
        }
126
127
        if (isset($parameters->alias)) {
128
            $this->aliases[$parameters->alias] = $connection;
129
        }
130
131
        $this->pool[(string) $connection] = $connection;
132
133
        $this->reset();
134
    }
135
136
    /**
137
     * {@inheritdoc}
138
     */
139
    public function remove(NodeConnectionInterface $connection)
140
    {
141
        if ($connection === $this->master) {
142
            $this->master = null;
143
        } elseif (false !== $id = array_search($connection, $this->slaves, true)) {
144
            unset($this->slaves[$id]);
145
        } else {
146
            return false;
147
        }
148
149
        unset($this->pool[(string) $connection]);
150
151
        if ($this->aliases && $alias = $connection->getParameters()->alias) {
0 ignored issues
show
Bug Best Practice introduced by
The expression $this->aliases of type Predis\Connection\NodeConnectionInterface[] is implicitly converted to a boolean; are you sure this is intended? If so, consider using ! empty($expr) instead to make it clear that you intend to check for an array without elements.

This check marks implicit conversions of arrays to boolean values in a comparison. While in PHP an empty array is considered to be equal (but not identical) to false, this is not always apparent.

Consider making the comparison explicit by using empty(..) or ! empty(...) instead.

Loading history...
152
            unset($this->aliases[$alias]);
153
        }
154
155
        $this->reset();
156
157
        return true;
158
    }
159
160
    /**
161
     * {@inheritdoc}
162
     */
163
    public function getConnectionByCommand(CommandInterface $command)
164
    {
165
        if (!$this->current) {
166
            if ($this->strategy->isReadOperation($command) && $slave = $this->pickSlave()) {
167
                $this->current = $slave;
168
            } else {
169
                $this->current = $this->getMasterOrDie();
170
            }
171
172
            return $this->current;
173
        }
174
175
        if ($this->current === $master = $this->getMasterOrDie()) {
176
            return $master;
177
        }
178
179
        if (!$this->strategy->isReadOperation($command) || !$this->slaves) {
0 ignored issues
show
Bug Best Practice introduced by
The expression $this->slaves of type Predis\Connection\NodeConnectionInterface[] is implicitly converted to a boolean; are you sure this is intended? If so, consider using empty($expr) instead to make it clear that you intend to check for an array without elements.

This check marks implicit conversions of arrays to boolean values in a comparison. While in PHP an empty array is considered to be equal (but not identical) to false, this is not always apparent.

Consider making the comparison explicit by using empty(..) or ! empty(...) instead.

Loading history...
180
            $this->current = $master;
181
        }
182
183
        return $this->current;
184
    }
185
186
    /**
187
     * {@inheritdoc}
188
     */
189
    public function getConnectionById($id)
190
    {
191
        return $this->pool[$id] ?? null;
192
    }
193
194
    /**
195
     * Returns a connection instance by its alias.
196
     *
197
     * @param string $alias Connection alias.
198
     *
199
     * @return NodeConnectionInterface|null
200
     */
201
    public function getConnectionByAlias($alias)
202
    {
203
        return $this->aliases[$alias] ?? null;
204
    }
205
206
    /**
207
     * Returns a connection by its role.
208
     *
209
     * @param string $role Connection role (`master` or `slave`)
210
     *
211
     * @return NodeConnectionInterface|null
212
     */
213
    public function getConnectionByRole($role)
214
    {
215
        if ($role === 'master') {
216
            return $this->getMaster();
217
        } elseif ($role === 'slave') {
218
            return $this->pickSlave();
219
        }
220
221
        return null;
222
    }
223
224
    /**
225
     * Switches the internal connection in use by the backend.
226
     *
227
     * @param NodeConnectionInterface $connection Connection instance in the pool.
228
     */
229
    public function switchTo(NodeConnectionInterface $connection)
230
    {
231
        if ($connection && $connection === $this->current) {
232
            return;
233
        }
234
235
        if ($connection !== $this->master && !in_array($connection, $this->slaves, true)) {
236
            throw new InvalidArgumentException('Invalid connection or connection not found.');
237
        }
238
239
        $this->current = $connection;
240
    }
241
242
    /**
243
     * {@inheritdoc}
244
     */
245
    public function switchToMaster()
246
    {
247
        if (!$connection = $this->getConnectionByRole('master')) {
248
            throw new InvalidArgumentException('Invalid connection or connection not found.');
249
        }
250
251
        $this->switchTo($connection);
252
    }
253
254
    /**
255
     * {@inheritdoc}
256
     */
257
    public function switchToSlave()
258
    {
259
        if (!$connection = $this->getConnectionByRole('slave')) {
260
            throw new InvalidArgumentException('Invalid connection or connection not found.');
261
        }
262
263
        $this->switchTo($connection);
264
    }
265
266
    /**
267
     * {@inheritdoc}
268
     */
269
    public function getCurrent()
270
    {
271
        return $this->current;
272
    }
273
274
    /**
275
     * {@inheritdoc}
276
     */
277
    public function getMaster()
278
    {
279
        return $this->master;
280
    }
281
282
    /**
283
     * Returns the connection associated to the master server.
284
     *
285
     * @return NodeConnectionInterface
286
     */
287
    private function getMasterOrDie()
288
    {
289
        if (!$connection = $this->getMaster()) {
290
            throw new MissingMasterException('No master server available for replication');
291
        }
292
293
        return $connection;
294
    }
295
296
    /**
297
     * {@inheritdoc}
298
     */
299
    public function getSlaves()
300
    {
301
        return $this->slaves;
302
    }
303
304
    /**
305
     * Returns the underlying replication strategy.
306
     *
307
     * @return ReplicationStrategy
308
     */
309
    public function getReplicationStrategy()
310
    {
311
        return $this->strategy;
312
    }
313
314
    /**
315
     * Returns a random slave.
316
     *
317
     * @return NodeConnectionInterface|null
318
     */
319
    protected function pickSlave()
320
    {
321
        if (!$this->slaves) {
0 ignored issues
show
Bug Best Practice introduced by
The expression $this->slaves of type Predis\Connection\NodeConnectionInterface[] is implicitly converted to a boolean; are you sure this is intended? If so, consider using empty($expr) instead to make it clear that you intend to check for an array without elements.

This check marks implicit conversions of arrays to boolean values in a comparison. While in PHP an empty array is considered to be equal (but not identical) to false, this is not always apparent.

Consider making the comparison explicit by using empty(..) or ! empty(...) instead.

Loading history...
322
            return null;
323
        }
324
325
        return $this->slaves[array_rand($this->slaves)];
326
    }
327
328
    /**
329
     * {@inheritdoc}
330
     */
331
    public function isConnected()
332
    {
333
        return $this->current ? $this->current->isConnected() : false;
334
    }
335
336
    /**
337
     * {@inheritdoc}
338
     */
339
    public function connect()
340
    {
341
        if (!$this->current) {
342
            if (!$this->current = $this->pickSlave()) {
343
                if (!$this->current = $this->getMaster()) {
344
                    throw new ClientException('No available connection for replication');
345
                }
346
            }
347
        }
348
349
        $this->current->connect();
350
    }
351
352
    /**
353
     * {@inheritdoc}
354
     */
355
    public function disconnect()
356
    {
357
        foreach ($this->pool as $connection) {
358
            $connection->disconnect();
359
        }
360
    }
361
362
    /**
363
     * Handles response from INFO.
364
     *
365
     * @param string $response
366
     *
367
     * @return array
368
     */
369
    private function handleInfoResponse($response)
370
    {
371
        $info = [];
372
373
        foreach (preg_split('/\r?\n/', $response) as $row) {
374
            if (strpos($row, ':') === false) {
375
                continue;
376
            }
377
378
            [$k, $v] = explode(':', $row, 2);
379
            $info[$k] = $v;
380
        }
381
382
        return $info;
383
    }
384
385
    /**
386
     * Fetches the replication configuration from one of the servers.
387
     */
388
    public function discover()
389
    {
390
        if (!$this->connectionFactory) {
391
            throw new ClientException('Discovery requires a connection factory');
392
        }
393
394
        while (true) {
395
            try {
396
                if ($connection = $this->getMaster()) {
397
                    $this->discoverFromMaster($connection, $this->connectionFactory);
398
                    break;
399
                } elseif ($connection = $this->pickSlave()) {
400
                    $this->discoverFromSlave($connection, $this->connectionFactory);
401
                    break;
402
                } else {
403
                    throw new ClientException('No connection available for discovery');
404
                }
405
            } catch (ConnectionException $exception) {
406
                $this->remove($connection);
0 ignored issues
show
Bug introduced by
It seems like $connection can also be of type null; however, parameter $connection of Predis\Connection\Replic...veReplication::remove() does only seem to accept Predis\Connection\NodeConnectionInterface, maybe add an additional type check? ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

406
                $this->remove(/** @scrutinizer ignore-type */ $connection);
Loading history...
407
            }
408
        }
409
    }
410
411
    /**
412
     * Discovers the replication configuration by contacting the master node.
413
     *
414
     * @param NodeConnectionInterface $connection        Connection to the master node.
415
     * @param FactoryInterface        $connectionFactory Connection factory instance.
416
     */
417
    protected function discoverFromMaster(NodeConnectionInterface $connection, FactoryInterface $connectionFactory)
418
    {
419
        $response = $connection->executeCommand(RawCommand::create('INFO', 'REPLICATION'));
420
        $replication = $this->handleInfoResponse($response);
421
422
        if ($replication['role'] !== 'master') {
423
            throw new ClientException("Role mismatch (expected master, got slave) [$connection]");
424
        }
425
426
        $this->slaves = [];
427
428
        foreach ($replication as $k => $v) {
429
            $parameters = null;
430
431
            if (strpos($k, 'slave') === 0 && preg_match('/ip=(?P<host>.*),port=(?P<port>\d+)/', $v, $parameters)) {
432
                $slaveConnection = $connectionFactory->create([
433
                    'host' => $parameters['host'],
434
                    'port' => $parameters['port'],
435
                    'role' => 'slave',
436
                ]);
437
438
                $this->add($slaveConnection);
439
            }
440
        }
441
    }
442
443
    /**
444
     * Discovers the replication configuration by contacting one of the slaves.
445
     *
446
     * @param NodeConnectionInterface $connection        Connection to one of the slaves.
447
     * @param FactoryInterface        $connectionFactory Connection factory instance.
448
     */
449
    protected function discoverFromSlave(NodeConnectionInterface $connection, FactoryInterface $connectionFactory)
450
    {
451
        $response = $connection->executeCommand(RawCommand::create('INFO', 'REPLICATION'));
452
        $replication = $this->handleInfoResponse($response);
453
454
        if ($replication['role'] !== 'slave') {
455
            throw new ClientException("Role mismatch (expected slave, got master) [$connection]");
456
        }
457
458
        $masterConnection = $connectionFactory->create([
459
            'host' => $replication['master_host'],
460
            'port' => $replication['master_port'],
461
            'role' => 'master',
462
        ]);
463
464
        $this->add($masterConnection);
465
466
        $this->discoverFromMaster($masterConnection, $connectionFactory);
467
    }
468
469
    /**
470
     * Retries the execution of a command upon slave failure.
471
     *
472
     * @param CommandInterface $command Command instance.
473
     * @param string           $method  Actual method.
474
     *
475
     * @return mixed
476
     */
477
    private function retryCommandOnFailure(CommandInterface $command, $method)
478
    {
479
        while (true) {
480
            try {
481
                $connection = $this->getConnectionByCommand($command);
482
                $response = $connection->$method($command);
483
484
                if ($response instanceof ResponseErrorInterface && $response->getErrorType() === 'LOADING') {
485
                    throw new ConnectionException($connection, "Redis is loading the dataset in memory [$connection]");
486
                }
487
488
                break;
489
            } catch (ConnectionException $exception) {
490
                $connection = $exception->getConnection();
491
                $connection->disconnect();
492
493
                if ($connection === $this->master && !$this->autoDiscovery) {
494
                    // Throw immediately when master connection is failing, even
495
                    // when the command represents a read-only operation, unless
496
                    // automatic discovery has been enabled.
497
                    throw $exception;
498
                } else {
499
                    // Otherwise remove the failing slave and attempt to execute
500
                    // the command again on one of the remaining slaves...
501
                    $this->remove($connection);
502
                }
503
504
                // ... that is, unless we have no more connections to use.
505
                if (!$this->slaves && !$this->master) {
0 ignored issues
show
Bug Best Practice introduced by
The expression $this->slaves of type Predis\Connection\NodeConnectionInterface[] is implicitly converted to a boolean; are you sure this is intended? If so, consider using empty($expr) instead to make it clear that you intend to check for an array without elements.

This check marks implicit conversions of arrays to boolean values in a comparison. While in PHP an empty array is considered to be equal (but not identical) to false, this is not always apparent.

Consider making the comparison explicit by using empty(..) or ! empty(...) instead.

Loading history...
506
                    throw $exception;
507
                } elseif ($this->autoDiscovery) {
508
                    $this->discover();
509
                }
510
            } catch (MissingMasterException $exception) {
511
                if ($this->autoDiscovery) {
512
                    $this->discover();
513
                } else {
514
                    throw $exception;
515
                }
516
            }
517
        }
518
519
        return $response;
0 ignored issues
show
Comprehensibility Best Practice introduced by
The variable $response does not seem to be defined for all execution paths leading up to this point.
Loading history...
520
    }
521
522
    /**
523
     * {@inheritdoc}
524
     */
525
    public function writeRequest(CommandInterface $command)
526
    {
527
        $this->retryCommandOnFailure($command, __FUNCTION__);
528
    }
529
530
    /**
531
     * {@inheritdoc}
532
     */
533
    public function readResponse(CommandInterface $command)
534
    {
535
        return $this->retryCommandOnFailure($command, __FUNCTION__);
536
    }
537
538
    /**
539
     * {@inheritdoc}
540
     */
541
    public function executeCommand(CommandInterface $command)
542
    {
543
        return $this->retryCommandOnFailure($command, __FUNCTION__);
544
    }
545
546
    /**
547
     * {@inheritdoc}
548
     */
549
    public function __sleep()
550
    {
551
        return ['master', 'slaves', 'pool', 'aliases', 'strategy'];
552
    }
553
}
554