Redis::get()   A
last analyzed

Complexity

Conditions 3
Paths 3

Size

Total Lines 27
Code Lines 13

Duplication

Lines 0
Ratio 0 %

Importance

Changes 3
Bugs 0 Features 0
Metric Value
cc 3
eloc 13
c 3
b 0
f 0
nc 3
nop 2
dl 0
loc 27
rs 9.8333
1
<?php
2
3
namespace MatthiasMullie\Scrapbook\Adapters;
4
5
use MatthiasMullie\Scrapbook\Adapters\Collections\Redis as Collection;
6
use MatthiasMullie\Scrapbook\Exception\InvalidCollection;
7
use MatthiasMullie\Scrapbook\KeyValueStore;
8
9
/**
10
 * Redis adapter. Basically just a wrapper over \Redis, but in an exchangeable
11
 * (KeyValueStore) interface.
12
 *
13
 * @author Matthias Mullie <[email protected]>
14
 * @copyright Copyright (c) 2014, Matthias Mullie. All rights reserved
15
 * @license LICENSE MIT
16
 */
17
class Redis implements KeyValueStore
18
{
19
    /**
20
     * @var \Redis
21
     */
22
    protected $client;
23
24
    /**
25
     * @var string|null
26
     */
27
    protected $version;
28
29
    public function __construct(\Redis $client)
30
    {
31
        $this->client = $client;
32
33
        // set a serializer if none is set already
34
        if (\Redis::SERIALIZER_NONE == $this->client->getOption(\Redis::OPT_SERIALIZER)) {
35
            $this->client->setOption(\Redis::OPT_SERIALIZER, \Redis::SERIALIZER_PHP);
36
        }
37
    }
38
39
    /**
40
     * {@inheritdoc}
41
     */
42
    public function get($key, &$token = null)
43
    {
44
        $this->client->multi();
45
46
        $this->client->get($key);
47
        $this->client->exists($key);
48
49
        /** @var array $return */
50
        $return = $this->client->exec();
51
        if (false === $return) {
52
            return false;
53
        }
54
55
        $value = $return[0];
56
        $exists = $return[1];
57
58
        // no value = quit early, don't generate a useless token
59
        if (!$exists) {
60
            $token = null;
61
62
            return false;
63
        }
64
65
        // serializing to make sure we don't pass objects (by-reference) ;)
66
        $token = serialize($value);
67
68
        return $value;
69
    }
70
71
    /**
72
     * {@inheritdoc}
73
     */
74
    public function getMulti(array $keys, array &$tokens = null)
75
    {
76
        $tokens = array();
77
        if (empty($keys)) {
78
            return array();
79
        }
80
81
        $this->client->multi();
82
83
        $this->client->mget($keys);
84
        foreach ($keys as $key) {
85
            $this->client->exists($key);
86
        }
87
88
        /** @var array $return */
89
        $return = $this->client->exec();
90
        if (false === $return) {
91
            return array();
92
        }
93
94
        $values = array_shift($return);
95
        $exists = $return;
96
97
        if (false === $values) {
98
            $values = array_fill_keys($keys, false);
99
        }
100
        $values = array_combine($keys, $values);
101
        $exists = array_combine($keys, $exists);
102
103
        $tokens = array();
104
        foreach ($values as $key => $value) {
105
            // filter out non-existing values
106
            if (!$exists[$key]) {
107
                unset($values[$key]);
108
                continue;
109
            }
110
111
            // serializing to make sure we don't pass objects (by-reference) ;)
112
            $tokens[$key] = serialize($value);
113
        }
114
115
        return $values;
116
    }
117
118
    /**
119
     * {@inheritdoc}
120
     */
121
    public function set($key, $value, $expire = 0)
122
    {
123
        $ttl = $this->ttl($expire);
124
125
        /*
126
         * Negative ttl behavior isn't properly documented & doesn't always
127
         * appear to treat the value as non-existing. Let's play safe and just
128
         * delete it right away!
129
         */
130
        if ($ttl < 0) {
131
            $this->delete($key);
132
133
            return true;
134
        }
135
136
        /*
137
         * phpredis advises: "Calling setex() is preferred if you want a Time To
138
         * Live". It seems that setex it what set will fall back to if you pass
139
         * it a TTL anyway.
140
         * Redis advises: "Note: Since the SET command options can replace
141
         * SETNX, SETEX, PSETEX, it is possible that in future versions of Redis
142
         * these three commands will be deprecated and finally removed."
143
         * I'll just go with set() - it works and seems the desired path for the
144
         * future.
145
         *
146
         * @see https://github.com/ukko/phpredis-phpdoc/blob/master/src/Redis.php#L190
147
         * @see https://github.com/phpredis/phpredis#set
148
         * @see http://redis.io/commands/SET
149
         */
150
        return $this->client->set($key, $value, $ttl);
151
    }
152
153
    /**
154
     * {@inheritdoc}
155
     */
156
    public function setMulti(array $items, $expire = 0)
157
    {
158
        if (empty($items)) {
159
            return array();
160
        }
161
162
        $ttl = $this->ttl($expire);
163
164
        /*
165
         * Negative ttl behavior isn't properly documented & doesn't always
166
         * appear to treat the value as non-existing. Let's play safe and just
167
         * delete it right away!
168
         */
169
        if ($ttl < 0) {
170
            $this->deleteMulti(array_keys($items));
171
172
            return array_fill_keys(array_keys($items), true);
173
        }
174
175
        if (null === $ttl) {
176
            $success = $this->client->mset($items);
177
178
            return array_fill_keys(array_keys($items), $success);
179
        }
180
181
        $this->client->multi();
182
        $this->client->mset($items);
183
184
        // Redis has no convenient multi-expire method
185
        foreach ($items as $key => $value) {
186
            $this->client->expire($key, $ttl);
187
        }
188
189
        /* @var bool[] $return */
190
        $result = (array) $this->client->exec();
191
192
        $return = array();
193
        $keys = array_keys($items);
194
        $success = array_shift($result);
195
        foreach ($result as $i => $value) {
196
            $key = $keys[$i];
197
            $return[$key] = $success && $value;
198
        }
199
200
        return $return;
201
    }
202
203
    /**
204
     * {@inheritdoc}
205
     */
206
    public function delete($key)
207
    {
208
        return (bool) $this->client->del($key);
209
    }
210
211
    /**
212
     * {@inheritdoc}
213
     */
214
    public function deleteMulti(array $keys)
215
    {
216
        if (empty($keys)) {
217
            return array();
218
        }
219
220
        /*
221
         * del will only return the amount of deleted entries, but we also want
222
         * to know which failed. Deletes will only fail for items that don't
223
         * exist, so we'll just ask for those and see which are missing.
224
         */
225
        $items = $this->getMulti($keys);
226
227
        $this->client->del($keys);
228
229
        $return = array();
230
        foreach ($keys as $key) {
231
            $return[$key] = array_key_exists($key, $items);
232
        }
233
234
        return $return;
235
    }
236
237
    /**
238
     * {@inheritdoc}
239
     */
240
    public function add($key, $value, $expire = 0)
241
    {
242
        $ttl = $this->ttl($expire);
243
244
        /*
245
         * Negative ttl behavior isn't properly documented & doesn't always
246
         * appear to treat the value as non-existing. Let's play safe and not
247
         * even create the value (also saving a request)
248
         */
249
        if ($ttl < 0) {
250
            return true;
251
        }
252
253
        if (null === $ttl) {
254
            return $this->client->setnx($key, $value);
255
        }
256
257
        /*
258
         * I could use Redis 2.6.12-style options array:
259
         * $this->client->set($key, $value, array('xx', 'ex' => $ttl));
260
         * However, this one should be pretty fast already, compared to the
261
         * replace-workaround below.
262
         */
263
        $this->client->multi();
264
        $this->client->setnx($key, $value);
265
        $this->client->expire($key, $ttl);
266
267
        /** @var bool[] $return */
268
        $return = (array) $this->client->exec();
269
270
        return !in_array(false, $return);
271
    }
272
273
    /**
274
     * {@inheritdoc}
275
     */
276
    public function replace($key, $value, $expire = 0)
277
    {
278
        $ttl = $this->ttl($expire);
279
280
        /*
281
         * Negative ttl behavior isn't properly documented & doesn't always
282
         * appear to treat the value as non-existing. Let's play safe and just
283
         * delete it right away!
284
         */
285
        if ($ttl < 0) {
286
            return $this->delete($key);
287
        }
288
289
        /*
290
         * Redis supports passing set() an extended options array since >=2.6.12
291
         * which allows for an easy and 1-request way to replace a value.
292
         * That version already comes with Ubuntu 14.04. Ubuntu 12.04 (still
293
         * widely used and in LTS) comes with an older version, however, so I
294
         * want to support that too.
295
         * Supporting both versions comes at a cost.
296
         * I'll optimize for recent versions, which will get (in case of replace
297
         * failure) 1 additional network request (for version info). Older
298
         * versions will get 2 additional network requests: a failed replace
299
         * (because the options are unknown) & a version check.
300
         */
301
        if (null === $this->version || $this->supportsOptionsArray()) {
302
            $options = array('xx');
303
            if ($ttl > 0) {
304
                /*
305
                 * Not adding 0 TTL to options:
306
                 * * HHVM (used to) interpret(s) wrongly & throw an exception
307
                 * * it's not needed anyway, for 0...
308
                 * @see https://github.com/facebook/hhvm/pull/4833
309
                 */
310
                $options['ex'] = $ttl;
311
            }
312
313
            // either we support options array or we haven't yet checked, in
314
            // which case I'll assume a recent server is running
315
            $result = $this->client->set($key, $value, $options);
316
            if (false !== $result) {
317
                return $result;
318
            }
319
320
            if ($this->supportsOptionsArray()) {
321
                // failed execution, but not because our Redis version is too old
322
                return false;
323
            }
324
        }
325
326
        // workaround for old Redis versions
327
        $this->client->watch($key);
328
329
        $exists = $this->client->exists('key');
330
        if (!$exists) {
331
            /*
332
             * HHVM Redis only got unwatch recently
333
             * @see https://github.com/asgrim/hhvm/commit/bf5a259cece5df8a7617133c85043608d1ad5316
334
             */
335
            if (method_exists($this->client, 'unwatch')) {
336
                $this->client->unwatch();
337
            } else {
338
                // this should also kill the watch...
339
                $this->client->multi()->discard();
340
            }
341
342
            return false;
343
        }
344
345
        // since we're watching the key, this will fail should it change in the
346
        // meantime
347
        $this->client->multi();
348
349
        $this->client->set($key, $value, $ttl);
350
351
        /** @var bool[] $return */
352
        $return = (array) $this->client->exec();
353
354
        return !in_array(false, $return);
355
    }
356
357
    /**
358
     * {@inheritdoc}
359
     */
360
    public function cas($token, $key, $value, $expire = 0)
361
    {
362
        $this->client->watch($key);
363
364
        // check if the value still matches CAS token
365
        $comparison = $this->client->get($key);
366
        if (serialize($comparison) !== $token) {
367
            /*
368
             * HHVM Redis only got unwatch recently
369
             * @see https://github.com/asgrim/hhvm/commit/bf5a259cece5df8a7617133c85043608d1ad5316
370
             */
371
            if (method_exists($this->client, 'unwatch')) {
372
                $this->client->unwatch();
373
            } else {
374
                // this should also kill the watch...
375
                $this->client->multi()->discard();
376
            }
377
378
            return false;
379
        }
380
381
        $ttl = $this->ttl($expire);
382
383
        // since we're watching the key, this will fail should it change in the
384
        // meantime
385
        $this->client->multi();
386
387
        /*
388
         * Negative ttl behavior isn't properly documented & doesn't always
389
         * appear to treat the value as non-existing. Let's play safe and just
390
         * delete it right away!
391
         */
392
        if ($ttl < 0) {
393
            $this->client->del($key);
394
        } else {
395
            $this->client->set($key, $value, $ttl);
396
        }
397
398
        /** @var bool[] $return */
399
        $return = (array) $this->client->exec();
400
401
        return !in_array(false, $return);
402
    }
403
404
    /**
405
     * {@inheritdoc}
406
     */
407
    public function increment($key, $offset = 1, $initial = 0, $expire = 0)
408
    {
409
        if ($offset <= 0 || $initial < 0) {
410
            return false;
411
        }
412
413
        // INCRBY initializes (at 0) & immediately increments, whereas we
414
        // only do initialization if the value does not yet exist
415
        if (0 === $initial + $offset && 0 === $expire) {
416
            return $this->client->incrBy($key, $offset);
417
        }
418
419
        return $this->doIncrement($key, $offset, $initial, $expire);
420
    }
421
422
    /**
423
     * {@inheritdoc}
424
     */
425
    public function decrement($key, $offset = 1, $initial = 0, $expire = 0)
426
    {
427
        if ($offset <= 0 || $initial < 0) {
428
            return false;
429
        }
430
431
        // DECRBY can't be used. Not even if we don't need an initial
432
        // value (it auto-initializes at 0) or expire. Problem is it
433
        // will decrement below 0, which is something we don't support.
434
435
        return $this->doIncrement($key, -$offset, $initial, $expire);
436
    }
437
438
    /**
439
     * {@inheritdoc}
440
     */
441
    public function touch($key, $expire)
442
    {
443
        $ttl = $this->ttl($expire);
444
445
        if ($ttl < 0) {
446
            // Redis can't set expired, so just remove in that case ;)
447
            return (bool) $this->client->del($key);
448
        }
449
450
        return $this->client->expire($key, $ttl);
451
    }
452
453
    /**
454
     * {@inheritdoc}
455
     */
456
    public function flush()
457
    {
458
        return $this->client->flushAll();
459
    }
460
461
    /**
462
     * {@inheritdoc}
463
     */
464
    public function getCollection($name)
465
    {
466
        if (!is_numeric($name)) {
467
            throw new InvalidCollection('Redis database names must be numeric. '.serialize($name).' given.');
468
        }
469
470
        // we can't reuse $this->client in a different object, because it'll
471
        // operate on a different database
472
        $client = new \Redis();
473
474
        if (null !== $this->client->getPersistentID()) {
475
            $client->pconnect(
476
                $this->client->getHost(),
477
                $this->client->getPort(),
478
                $this->client->getTimeout()
479
            );
480
        } else {
481
            $client->connect(
482
                $this->client->getHost(),
483
                $this->client->getPort(),
484
                $this->client->getTimeout()
485
            );
486
        }
487
488
        $auth = $this->client->getAuth();
489
        if (null !== $auth) {
490
            $client->auth($auth);
0 ignored issues
show
Bug introduced by
It seems like $auth can also be of type true; however, parameter $password of Redis::auth() does only seem to accept string, 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

490
            $client->auth(/** @scrutinizer ignore-type */ $auth);
Loading history...
491
        }
492
493
        $readTimeout = $this->client->getReadTimeout();
494
        if ($readTimeout) {
495
            $client->setOption(\Redis::OPT_READ_TIMEOUT, $this->client->getReadTimeout());
496
        }
497
498
        return new Collection($client, $name);
499
    }
500
501
    /**
502
     * Redis expects true TTL, not expiration timestamp.
503
     *
504
     * @param int $expire
505
     *
506
     * @return int|null TTL in seconds, or `null` for no expiration
507
     */
508
    protected function ttl($expire)
509
    {
510
        if (0 === $expire) {
511
            return null;
512
        }
513
514
        // relative time in seconds, <30 days
515
        if ($expire > 30 * 24 * 60 * 60) {
516
            return $expire - time();
517
        }
518
519
        return $expire;
520
    }
521
522
    /**
523
     * Shared between increment/decrement: both have mostly the same logic
524
     * (decrement just increments a negative value), but need their validation
525
     * & use of non-ttl native methods split up.
526
     *
527
     * @param string $key
528
     * @param int    $offset
529
     * @param int    $initial
530
     * @param int    $expire
531
     *
532
     * @return int|bool
533
     */
534
    protected function doIncrement($key, $offset, $initial, $expire)
535
    {
536
        $ttl = $this->ttl($expire);
537
538
        $this->client->watch($key);
539
540
        $value = $this->client->get($key);
541
542
        if (false === $value) {
543
            /*
544
             * Negative ttl behavior isn't properly documented & doesn't always
545
             * appear to treat the value as non-existing. Let's play safe and not
546
             * even create the value (also saving a request)
547
             */
548
            if ($ttl < 0) {
549
                return true;
550
            }
551
552
            // value is not yet set, store initial value!
553
            $this->client->multi();
554
            $this->client->set($key, $initial, $ttl);
555
556
            /** @var bool[] $return */
557
            $return = (array) $this->client->exec();
558
559
            return !in_array(false, $return) ? $initial : false;
560
        }
561
562
        // can't increment if a non-numeric value is set
563
        if (!is_numeric($value) || $value < 0) {
564
            /*
565
             * HHVM Redis only got unwatch recently.
566
             * @see https://github.com/asgrim/hhvm/commit/bf5a259cece5df8a7617133c85043608d1ad5316
567
             */
568
            if (method_exists($this->client, 'unwatch')) {
569
                $this->client->unwatch();
570
            } else {
571
                // this should also kill the watch...
572
                $this->client->multi()->discard();
573
            }
574
575
            return false;
576
        }
577
578
        $value += $offset;
579
        // value can never be lower than 0
580
        $value = max(0, $value);
581
582
        $this->client->multi();
583
584
        /*
585
         * Negative ttl behavior isn't properly documented & doesn't always
586
         * appear to treat the value as non-existing. Let's play safe and just
587
         * delete it right away!
588
         */
589
        if ($ttl < 0) {
590
            $this->client->del($key);
591
        } else {
592
            $this->client->set($key, $value, $ttl);
593
        }
594
595
        /** @var bool[] $return */
596
        $return = (array) $this->client->exec();
597
598
        return !in_array(false, $return) ? $value : false;
599
    }
600
601
    /**
602
     * Returns the version of the Redis server we're connecting to.
603
     *
604
     * @return string
605
     */
606
    protected function getVersion()
607
    {
608
        if (null === $this->version) {
609
            $info = $this->client->info();
610
            $this->version = $info['redis_version'];
611
        }
612
613
        return $this->version;
614
    }
615
616
    /**
617
     * Version-based check to test if passing an options array to set() is
618
     * supported.
619
     *
620
     * @return bool
621
     */
622
    protected function supportsOptionsArray()
623
    {
624
        return version_compare($this->getVersion(), '2.6.12') >= 0;
625
    }
626
}
627