Issues (70)

src/Buffered/Utils/Transaction.php (1 issue)

1
<?php
2
3
namespace MatthiasMullie\Scrapbook\Buffered\Utils;
4
5
use MatthiasMullie\Scrapbook\Adapters\Collections\MemoryStore as BufferCollection;
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) {
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;
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) {
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) {
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) {
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) {
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) {
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) {
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) {
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) {
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