Transaction::set()   A
last analyzed

Complexity

Conditions 2
Paths 2

Size

Total Lines 12
Code Lines 5

Duplication

Lines 0
Ratio 0 %

Importance

Changes 1
Bugs 0 Features 0
Metric Value
cc 2
eloc 5
nc 2
nop 3
dl 0
loc 12
rs 10
c 1
b 0
f 0
1
<?php
2
3
namespace MatthiasMullie\Scrapbook\Buffered\Utils;
4
5
use MatthiasMullie\Scrapbook\Adapters\Collections\MemoryStore as BufferCollection;
0 ignored issues
show
Bug introduced by
This use statement conflicts with another class in this namespace, MatthiasMullie\Scrapbook...\Utils\BufferCollection. Consider defining an alias.

Let?s assume that you have a directory layout like this:

.
|-- OtherDir
|   |-- Bar.php
|   `-- Foo.php
`-- SomeDir
    `-- Foo.php

and let?s assume the following content of Bar.php:

// Bar.php
namespace OtherDir;

use SomeDir\Foo; // This now conflicts the class OtherDir\Foo

If both files OtherDir/Foo.php and SomeDir/Foo.php are loaded in the same runtime, you will see a PHP error such as the following:

PHP Fatal error:  Cannot use SomeDir\Foo as Foo because the name is already in use in OtherDir/Foo.php

However, as OtherDir/Foo.php does not necessarily have to be loaded and the error is only triggered if it is loaded before OtherDir/Bar.php, this problem might go unnoticed for a while. In order to prevent this error from surfacing, you must import the namespace with a different alias:

// Bar.php
namespace OtherDir;

use SomeDir\Foo as SomeDirFoo; // There is no conflict anymore.
Loading history...
6
use MatthiasMullie\Scrapbook\KeyValueStore;
7
8
/**
9
 * This is a helper class for BufferedStore & TransactionalStore, which buffer
10
 * real cache requests in memory.
11
 *
12
 * This class accepts 2 caches: a KeyValueStore object (the real cache) and a
13
 * Buffer instance (to read data from as long as it hasn't been committed)
14
 *
15
 * Every write action will first store the data in the Buffer instance, and
16
 * then pas update along to $defer.
17
 * Once commit() is called, $defer will execute all these updates against the
18
 * real cache. All deferred writes that fail to apply will cause that cache key
19
 * to be deleted, to ensure cache consistency.
20
 * Until commit() is called, all data is read from the temporary Buffer instance.
21
 *
22
 * @author Matthias Mullie <[email protected]>
23
 * @copyright Copyright (c) 2014, Matthias Mullie. All rights reserved
24
 * @license LICENSE MIT
25
 */
26
class Transaction implements KeyValueStore
27
{
28
    /**
29
     * @var KeyValueStore
30
     */
31
    protected $cache;
32
33
    /**
34
     * @var Buffer
35
     */
36
    protected $local;
37
38
    /**
39
     * We'll return stub CAS tokens in order to reliably replay the CAS actions
40
     * to the real cache. This will hold a map of stub token => value, used to
41
     * verify when we do the actual CAS.
42
     *
43
     * @see cas()
44
     *
45
     * @var mixed[]
46
     */
47
    protected $tokens = array();
48
49
    /**
50
     * Deferred updates to be committed to real cache.
51
     *
52
     * @var Defer
53
     */
54
    protected $defer;
55
56
    /**
57
     * Suspend reads from real cache. This is used when a flush is issued but it
58
     * has not yet been committed. In that case, we don't want to fall back to
59
     * real cache values, because they're about to be flushed.
60
     *
61
     * @var bool
62
     */
63
    protected $suspend = false;
64
65
    /**
66
     * @var Transaction[]
67
     */
68
    protected $collections = array();
69
70
    /**
71
     * @param Buffer|BufferCollection $local
72
     */
73
    public function __construct(/* Buffer|BufferCollection */ $local, KeyValueStore $cache)
74
    {
75
        // can't do double typehint, so let's manually check the type
76
        if (!$local instanceof Buffer && !$local instanceof BufferCollection) {
0 ignored issues
show
introduced by
$local is always a sub-type of MatthiasMullie\Scrapbook...Collections\MemoryStore.
Loading history...
77
            $error = 'Invalid class for $local: '.get_class($local);
78
            if (class_exists('\TypeError')) {
79
                throw new \TypeError($error);
80
            }
81
            trigger_error($error, E_USER_ERROR);
82
        }
83
84
        $this->cache = $cache;
85
86
        // (uncommitted) writes must never be evicted (even if that means
87
        // crashing because we run out of memory)
88
        $this->local = $local;
0 ignored issues
show
Documentation Bug introduced by
It seems like $local can also be of type MatthiasMullie\Scrapbook...Collections\MemoryStore. However, the property $local is declared as type MatthiasMullie\Scrapbook\Buffered\Utils\Buffer. Maybe add an additional type check?

Our type inference engine has found a suspicous assignment of a value to a property. This check raises an issue when a value that can be of a mixed type is assigned to a property that is type hinted more strictly.

For example, imagine you have a variable $accountId that can either hold an Id object or false (if there is no account id yet). Your code now assigns that value to the id property of an instance of the Account class. This class holds a proper account, so the id value must no longer be false.

Either this assignment is in error or a type check should be added for that assignment.

class Id
{
    public $id;

    public function __construct($id)
    {
        $this->id = $id;
    }

}

class Account
{
    /** @var  Id $id */
    public $id;
}

$account_id = false;

if (starsAreRight()) {
    $account_id = new Id(42);
}

$account = new Account();
if ($account instanceof Id)
{
    $account->id = $account_id;
}
Loading history...
89
90
        $this->defer = new Defer($this->cache);
91
    }
92
93
    /**
94
     * {@inheritdoc}
95
     */
96
    public function get($key, &$token = null)
97
    {
98
        $value = $this->local->get($key, $token);
99
100
        // short-circuit reading from real cache if we have an uncommitted flush
101
        if ($this->suspend && null === $token) {
102
            // flush hasn't been committed yet, don't read from real cache!
103
            return false;
104
        }
105
106
        if (false === $value) {
107
            if ($this->local->expired($key)) {
108
                /*
109
                 * Item used to exist in local cache, but is now expired. This
110
                 * is used when values are to be deleted: we don't want to reach
111
                 * out to real storage because that would respond with the not-
112
                 * yet-deleted value.
113
                 */
114
115
                return false;
116
            }
117
118
            // unknown in local cache = fetch from source cache
119
            $value = $this->cache->get($key, $token);
120
        }
121
122
        // no value = quit early, don't generate a useless token
123
        if (false === $value) {
124
            return false;
125
        }
126
127
        /*
128
         * $token will be unreliable to the deferred updates so generate
129
         * a custom one and keep the associated value around.
130
         * Read more details in PHPDoc for function cas().
131
         * uniqid is ok here. Doesn't really have to be unique across
132
         * servers, just has to be unique every time it's called in this
133
         * one particular request - which it is.
134
         */
135
        $token = uniqid();
136
        $this->tokens[$token] = serialize($value);
137
138
        return $value;
139
    }
140
141
    /**
142
     * {@inheritdoc}
143
     */
144
    public function getMulti(array $keys, array &$tokens = null)
145
    {
146
        // retrieve all that we can from local cache
147
        $values = $this->local->getMulti($keys);
148
        $tokens = array();
149
150
        // short-circuit reading from real cache if we have an uncommitted flush
151
        if (!$this->suspend) {
152
            // figure out which missing key we need to get from real cache
153
            $keys = array_diff($keys, array_keys($values));
154
            foreach ($keys as $i => $key) {
155
                // don't reach out to real cache for keys that are about to be gone
156
                if ($this->local->expired($key)) {
157
                    unset($keys[$i]);
158
                }
159
            }
160
161
            // fetch missing values from real cache
162
            if ($keys) {
0 ignored issues
show
Bug Best Practice introduced by
The expression $keys of type array 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...
163
                $missing = $this->cache->getMulti($keys);
164
                $values += $missing;
165
            }
166
        }
167
168
        // any tokens we get will be unreliable, so generate some replacements
169
        // (more elaborate explanation in get())
170
        foreach ($values as $key => $value) {
171
            $token = uniqid();
172
            $tokens[$key] = $token;
173
            $this->tokens[$token] = serialize($value);
174
        }
175
176
        return $values;
177
    }
178
179
    /**
180
     * {@inheritdoc}
181
     */
182
    public function set($key, $value, $expire = 0)
183
    {
184
        // store the value in memory, so that when we ask for it again later in
185
        // this same request, we get the value we just set
186
        $success = $this->local->set($key, $value, $expire);
187
        if (false === $success) {
0 ignored issues
show
introduced by
The condition false === $success is always false.
Loading history...
188
            return false;
189
        }
190
191
        $this->defer->set($key, $value, $expire);
192
193
        return true;
194
    }
195
196
    /**
197
     * {@inheritdoc}
198
     */
199
    public function setMulti(array $items, $expire = 0)
200
    {
201
        // store the values in memory, so that when we ask for it again later in
202
        // this same request, we get the value we just set
203
        $success = $this->local->setMulti($items, $expire);
204
205
        // only attempt to store those that we've set successfully to local
206
        $successful = array_intersect_key($items, $success);
207
        if (!empty($successful)) {
208
            $this->defer->setMulti($successful, $expire);
209
        }
210
211
        return $success;
212
    }
213
214
    /**
215
     * {@inheritdoc}
216
     */
217
    public function delete($key)
218
    {
219
        // check the current value to see if it currently exists, so we can
220
        // properly return true/false as would be expected from KeyValueStore
221
        $value = $this->get($key);
222
        if (false === $value) {
223
            return false;
224
        }
225
226
        /*
227
         * To make sure that subsequent get() calls for this key don't return
228
         * a value (it's supposed to be deleted), we'll make it expired in our
229
         * temporary bag (as opposed to deleting it from out bag, in which case
230
         * we'd fall back to fetching it from real store, where the transaction
231
         * might not yet be committed)
232
         */
233
        $this->local->set($key, $value, -1);
234
235
        $this->defer->delete($key);
236
237
        return true;
238
    }
239
240
    /**
241
     * {@inheritdoc}
242
     */
243
    public function deleteMulti(array $keys)
244
    {
245
        // check the current values to see if they currently exists, so we can
246
        // properly return true/false as would be expected from KeyValueStore
247
        $items = $this->getMulti($keys);
248
        $success = array();
249
        foreach ($keys as $key) {
250
            $success[$key] = array_key_exists($key, $items);
251
        }
252
253
        // only attempt to store those that we've deleted successfully to local
254
        $values = array_intersect_key($success, array_flip($keys));
255
        if (empty($values)) {
256
            return array();
257
        }
258
259
        // mark all as expired in local cache (see comment in delete())
260
        $this->local->setMulti($values, -1);
261
262
        $this->defer->deleteMulti(array_keys($values));
263
264
        return $success;
265
    }
266
267
    /**
268
     * {@inheritdoc}
269
     */
270
    public function add($key, $value, $expire = 0)
271
    {
272
        // before adding, make sure the value doesn't yet exist (in real cache,
273
        // nor in memory)
274
        if (false !== $this->get($key)) {
275
            return false;
276
        }
277
278
        // store the value in memory, so that when we ask for it again later
279
        // in this same request, we get the value we just set
280
        $success = $this->local->set($key, $value, $expire);
281
        if (false === $success) {
0 ignored issues
show
introduced by
The condition false === $success is always false.
Loading history...
282
            return false;
283
        }
284
285
        $this->defer->add($key, $value, $expire);
286
287
        return true;
288
    }
289
290
    /**
291
     * {@inheritdoc}
292
     */
293
    public function replace($key, $value, $expire = 0)
294
    {
295
        // before replacing, make sure the value actually exists (in real cache,
296
        // or already created in memory)
297
        if (false === $this->get($key)) {
298
            return false;
299
        }
300
301
        // store the value in memory, so that when we ask for it again later
302
        // in this same request, we get the value we just set
303
        $success = $this->local->set($key, $value, $expire);
304
        if (false === $success) {
0 ignored issues
show
introduced by
The condition false === $success is always false.
Loading history...
305
            return false;
306
        }
307
308
        $this->defer->replace($key, $value, $expire);
309
310
        return true;
311
    }
312
313
    /**
314
     * Since our CAS is deferred, the CAS token we got from our original
315
     * get() will likely not be valid by the time we want to store it to
316
     * the real cache. Imagine this scenario:
317
     * * a value is fetched from (real) cache
318
     * * an new value key is CAS'ed (into temp cache - real CAS is deferred)
319
     * * this key's value is fetched again (this time from temp cache)
320
     * * and a new value is CAS'ed again (into temp cache...).
321
     *
322
     * In this scenario, when we finally want to replay the write actions
323
     * onto the real cache, the first 3 actions would likely work fine.
324
     * The last (second CAS) however would not, since it never got a real
325
     * updated $token from the real cache.
326
     *
327
     * To work around this problem, all get() calls will return a unique
328
     * CAS token and store the value-at-that-time associated with that
329
     * token. All we have to do when we want to write the data to real cache
330
     * is, right before was CAS for real, get the value & (real) cas token
331
     * from storage & compare that value to the one we had stored. If that
332
     * checks out, we can safely resume the CAS with the real token we just
333
     * received.
334
     *
335
     * {@inheritdoc}
336
     */
337
    public function cas($token, $key, $value, $expire = 0)
338
    {
339
        $originalValue = isset($this->tokens[$token]) ? $this->tokens[$token] : null;
340
341
        // value is no longer the same as what we used for token
342
        if (serialize($this->get($key)) !== $originalValue) {
343
            return false;
344
        }
345
346
        // "CAS" value to local cache/memory
347
        $success = $this->local->set($key, $value, $expire);
348
        if (false === $success) {
0 ignored issues
show
introduced by
The condition false === $success is always false.
Loading history...
349
            return false;
350
        }
351
352
        // only schedule the CAS to be performed on real cache if it was OK on
353
        // local cache
354
        $this->defer->cas($originalValue, $key, $value, $expire);
355
356
        return true;
357
    }
358
359
    /**
360
     * {@inheritdoc}
361
     */
362
    public function increment($key, $offset = 1, $initial = 0, $expire = 0)
363
    {
364
        if ($offset <= 0 || $initial < 0) {
365
            return false;
366
        }
367
368
        // get existing value (from real cache or memory) so we know what to
369
        // increment in memory (where we may not have anything yet, so we should
370
        // adjust our initial value to what's already in real cache)
371
        $value = $this->get($key);
372
        if (false === $value) {
373
            $value = $initial - $offset;
374
        }
375
376
        if (!is_numeric($value) || !is_numeric($offset)) {
377
            return false;
378
        }
379
380
        // store the value in memory, so that when we ask for it again later
381
        // in this same request, we get the value we just set
382
        $value = max(0, $value + $offset);
383
        $success = $this->local->set($key, $value, $expire);
384
        if (false === $success) {
0 ignored issues
show
introduced by
The condition false === $success is always false.
Loading history...
385
            return false;
386
        }
387
388
        $this->defer->increment($key, $offset, $initial, $expire);
389
390
        return $value;
391
    }
392
393
    /**
394
     * {@inheritdoc}
395
     */
396
    public function decrement($key, $offset = 1, $initial = 0, $expire = 0)
397
    {
398
        if ($offset <= 0 || $initial < 0) {
399
            return false;
400
        }
401
402
        // get existing value (from real cache or memory) so we know what to
403
        // increment in memory (where we may not have anything yet, so we should
404
        // adjust our initial value to what's already in real cache)
405
        $value = $this->get($key);
406
        if (false === $value) {
407
            $value = $initial + $offset;
408
        }
409
410
        if (!is_numeric($value) || !is_numeric($offset)) {
411
            return false;
412
        }
413
414
        // store the value in memory, so that when we ask for it again later
415
        // in this same request, we get the value we just set
416
        $value = max(0, $value - $offset);
417
        $success = $this->local->set($key, $value, $expire);
418
        if (false === $success) {
0 ignored issues
show
introduced by
The condition false === $success is always false.
Loading history...
419
            return false;
420
        }
421
422
        $this->defer->decrement($key, $offset, $initial, $expire);
423
424
        return $value;
425
    }
426
427
    /**
428
     * {@inheritdoc}
429
     */
430
    public function touch($key, $expire)
431
    {
432
        // grab existing value (from real cache or memory) and re-save (to
433
        // memory) with updated expiration time
434
        $value = $this->get($key);
435
        if (false === $value) {
436
            return false;
437
        }
438
439
        $success = $this->local->set($key, $value, $expire);
440
        if (false === $success) {
0 ignored issues
show
introduced by
The condition false === $success is always false.
Loading history...
441
            return false;
442
        }
443
444
        $this->defer->touch($key, $expire);
445
446
        return true;
447
    }
448
449
    /**
450
     * {@inheritdoc}
451
     */
452
    public function flush()
453
    {
454
        foreach ($this->collections as $collection) {
455
            $collection->flush();
456
        }
457
458
        $success = $this->local->flush();
459
        if (false === $success) {
0 ignored issues
show
introduced by
The condition false === $success is always false.
Loading history...
460
            return false;
461
        }
462
463
        // clear all buffered writes, flush wipes them out anyway
464
        $this->clear();
465
466
        // make sure that reads, from now on until commit, don't read from cache
467
        $this->suspend = true;
468
469
        $this->defer->flush();
470
471
        return true;
472
    }
473
474
    /**
475
     * {@inheritdoc}
476
     */
477
    public function getCollection($name)
478
    {
479
        if (!isset($this->collections[$name])) {
480
            $this->collections[$name] = new static(
481
                $this->local->getCollection($name),
482
                $this->cache->getCollection($name)
483
            );
484
        }
485
486
        return $this->collections[$name];
487
    }
488
489
    /**
490
     * Commits all deferred updates to real cache.
491
     * that had already been written to will be deleted.
492
     *
493
     * @return bool
494
     */
495
    public function commit()
496
    {
497
        $this->clear();
498
499
        return $this->defer->commit();
500
    }
501
502
    /**
503
     * Roll back all scheduled changes.
504
     *
505
     * @return bool
506
     */
507
    public function rollback()
508
    {
509
        $this->clear();
510
        $this->defer->clear();
511
512
        return true;
513
    }
514
515
    /**
516
     * Clears all transaction-related data stored in memory.
517
     */
518
    protected function clear()
519
    {
520
        $this->tokens = array();
521
        $this->suspend = false;
522
    }
523
}
524