Defer   C
last analyzed

Complexity

Total Complexity 55

Size/Duplication

Total Lines 535
Duplicated Lines 0 %

Importance

Changes 3
Bugs 0 Features 0
Metric Value
eloc 161
c 3
b 0
f 0
dl 0
loc 535
rs 6
wmc 55

21 Methods

Rating   Name   Duplication   Size   Complexity  
A increment() 0 3 1
A sortUpdates() 0 27 3
A decrement() 0 3 1
A deleteMulti() 0 4 2
A touch() 0 13 3
A rollback() 0 22 3
A generateRollback() 0 24 4
A setMulti() 0 4 2
A replace() 0 8 1
A set() 0 8 1
A __destruct() 0 4 2
A clear() 0 4 1
B combineUpdates() 0 72 7
A add() 0 8 1
B doIncrement() 0 47 9
A __construct() 0 3 1
A generateUpdates() 0 13 3
A delete() 0 4 1
A cas() 0 51 5
A commit() 0 21 3
A flush() 0 5 1

How to fix   Complexity   

Complex Class

Complex classes like Defer often do a lot of different things. To break such a class down, we need to identify a cohesive component within that class. A common approach to find such a component is to look for fields/methods that share the same prefixes, or suffixes.

Once you have determined the fields that belong together, you can apply the Extract Class refactoring. If the component makes sense as a sub-class, Extract Subclass is also a candidate, and is often faster.

While breaking up the class, it is a good idea to analyze how other classes use Defer, and based on these observations, apply Extract Interface, too.

1
<?php
2
3
namespace MatthiasMullie\Scrapbook\Buffered\Utils;
4
5
use MatthiasMullie\Scrapbook\Exception\UncommittedTransaction;
6
use MatthiasMullie\Scrapbook\KeyValueStore;
7
8
/**
9
 * This is a helper class for transactions. It will optimize the write going
10
 * out and take care of rolling back.
11
 *
12
 * Optimizations will be:
13
 * * multiple set() values (with the same expiration) will be applied in a
14
 *   single setMulti()
15
 * * for a set() followed by another set() on the same key, only the latter
16
 *   one will be applied
17
 * * same for an replace() followed by an increment(), or whatever operation
18
 *   happens on the same key: if we can pre-calculate the end result, we'll
19
 *   only execute 1 operation with the end result
20
 * * operations before a flush() will not be executed, they'll just be lost
21
 *
22
 * Rollback strategy includes:
23
 * * fetching the original value of operations prone to fail (add, replace &
24
 *   cas) prior to executing them
25
 * * executing said operations before the others, to minimize changes of
26
 *   interfering concurrent writes
27
 * * if the commit fails, said original values will be restored in case the
28
 *   new value had already been stored
29
 *
30
 * This class must never receive invalid data. E.g. a "replace" can never
31
 * follow a "delete" of the same key. This should be guaranteed by whatever
32
 * uses this class: there is no point in re-implementing these checks here.
33
 * The only acceptable conflicts are when cache values have changed outside,
34
 * from another process. Those will be handled by this class.
35
 *
36
 * @author Matthias Mullie <[email protected]>
37
 * @copyright Copyright (c) 2014, Matthias Mullie. All rights reserved
38
 * @license LICENSE MIT
39
 */
40
class Defer
41
{
42
    /**
43
     * Cache to write to.
44
     *
45
     * @var KeyValueStore
46
     */
47
    protected $cache;
48
49
    /**
50
     * All updates will be scheduled by key. If there are multiple updates
51
     * for a key, they can just be folded together.
52
     * E.g. 2 sets, the later will override the former.
53
     * E.g. set + increment, might as well set incremented value immediately.
54
     *
55
     * This is going to be an array that holds horrible arrays of update data,
56
     * being:
57
     * * 0: the operation name (set, add, ...) so we're able to sort them
58
     * * 1: a callable, to apply the update to cache
59
     * * 2: the array of data to supply to the callable
60
     *
61
     * @var array[]
62
     */
63
    protected $keys = array();
64
65
    /**
66
     * Flush is special - it's not specific to (a) key(s), so we can't store
67
     * it to $keys.
68
     *
69
     * @var bool
70
     */
71
    protected $flush = false;
72
73
    public function __construct(KeyValueStore $cache)
74
    {
75
        $this->cache = $cache;
76
    }
77
78
    /**
79
     * @throws UncommittedTransaction
80
     */
81
    public function __destruct()
82
    {
83
        if (!empty($this->keys)) {
84
            throw new UncommittedTransaction('Transaction is about to be destroyed without having been committed or rolled back.');
85
        }
86
    }
87
88
    /**
89
     * @param string $key
90
     * @param mixed  $value
91
     * @param int    $expire
92
     */
93
    public function set($key, $value, $expire)
94
    {
95
        $args = array(
96
            'key' => $key,
97
            'value' => $value,
98
            'expire' => $expire,
99
        );
100
        $this->keys[$key] = array(__FUNCTION__, array($this->cache, __FUNCTION__), $args);
101
    }
102
103
    /**
104
     * @param mixed[] $items
105
     * @param int     $expire
106
     */
107
    public function setMulti(array $items, $expire)
108
    {
109
        foreach ($items as $key => $value) {
110
            $this->set($key, $value, $expire);
111
        }
112
    }
113
114
    /**
115
     * @param string $key
116
     */
117
    public function delete($key)
118
    {
119
        $args = array('key' => $key);
120
        $this->keys[$key] = array(__FUNCTION__, array($this->cache, __FUNCTION__), $args);
121
    }
122
123
    /**
124
     * @param string[] $keys
125
     */
126
    public function deleteMulti(array $keys)
127
    {
128
        foreach ($keys as $key) {
129
            $this->delete($key);
130
        }
131
    }
132
133
    /**
134
     * @param string $key
135
     * @param mixed  $value
136
     * @param int    $expire
137
     */
138
    public function add($key, $value, $expire)
139
    {
140
        $args = array(
141
            'key' => $key,
142
            'value' => $value,
143
            'expire' => $expire,
144
        );
145
        $this->keys[$key] = array(__FUNCTION__, array($this->cache, __FUNCTION__), $args);
146
    }
147
148
    /**
149
     * @param string $key
150
     * @param mixed  $value
151
     * @param int    $expire
152
     */
153
    public function replace($key, $value, $expire)
154
    {
155
        $args = array(
156
            'key' => $key,
157
            'value' => $value,
158
            'expire' => $expire,
159
        );
160
        $this->keys[$key] = array(__FUNCTION__, array($this->cache, __FUNCTION__), $args);
161
    }
162
163
    /**
164
     * @param mixed  $originalValue No real CAS token, but the original value for this key
165
     * @param string $key
166
     * @param mixed  $value
167
     * @param int    $expire
168
     */
169
    public function cas($originalValue, $key, $value, $expire)
170
    {
171
        /*
172
         * If we made it here, we're sure that logically, the CAS applies with
173
         * respect to other operations in this transaction. That means we don't
174
         * have to verify the token here: whatever has already been set/add/
175
         * replace/cas will have taken care of that and we already know this one
176
         * applies on top op that change. We can just fold it in there & update
177
         * the value we set initially.
178
         */
179
        if (isset($this->keys[$key]) && in_array($this->keys[$key][0], array('set', 'add', 'replace', 'cas'))) {
180
            $this->keys[$key][2]['value'] = $value;
181
            $this->keys[$key][2]['expire'] = $expire;
182
183
            return;
184
        }
185
186
        /*
187
         * @param mixed $originalValue
188
         * @param string $key
189
         * @param mixed $value
190
         * @param int $expire
191
         * @return bool
192
         */
193
        $cache = $this->cache;
194
        $callback = function ($originalValue, $key, $value, $expire) use ($cache) {
195
            // check if given (local) CAS token was known
196
            if (null === $originalValue) {
197
                return false;
198
            }
199
200
            // fetch data from real cache, getting new valid CAS token
201
            $current = $cache->get($key, $token);
202
203
            // check if the value we just read from real cache is still the same
204
            // as the one we saved when doing the original fetch
205
            if (serialize($current) === $originalValue) {
206
                // everything still checked out, CAS the value for real now
207
                return $cache->cas($token, $key, $value, $expire);
208
            }
209
210
            return false;
211
        };
212
213
        $args = array(
214
            'originalValue' => $originalValue,
215
            'key' => $key,
216
            'value' => $value,
217
            'expire' => $expire,
218
        );
219
        $this->keys[$key] = array(__FUNCTION__, $callback, $args);
220
    }
221
222
    /**
223
     * @param string $key
224
     * @param int    $offset
225
     * @param int    $initial
226
     * @param int    $expire
227
     */
228
    public function increment($key, $offset, $initial, $expire)
229
    {
230
        $this->doIncrement(__FUNCTION__, $key, $offset, $initial, $expire);
231
    }
232
233
    /**
234
     * @param string $key
235
     * @param int    $offset
236
     * @param int    $initial
237
     * @param int    $expire
238
     */
239
    public function decrement($key, $offset, $initial, $expire)
240
    {
241
        $this->doIncrement(__FUNCTION__, $key, $offset, $initial, $expire);
242
    }
243
244
    /**
245
     * @param string $operation
246
     * @param string $key
247
     * @param int    $offset
248
     * @param int    $initial
249
     * @param int    $expire
250
     */
251
    protected function doIncrement($operation, $key, $offset, $initial, $expire)
252
    {
253
        if (isset($this->keys[$key])) {
254
            if (in_array($this->keys[$key][0], array('set', 'add', 'replace', 'cas'))) {
255
                // we're trying to increment a key that's only just being stored
256
                // in this transaction - might as well combine those
257
                $symbol = 'increment' === $this->keys[$key][1] ? 1 : -1;
258
                $this->keys[$key][2]['value'] += $symbol * $offset;
259
                $this->keys[$key][2]['expire'] = $expire;
260
            } elseif (in_array($this->keys[$key][0], array('increment', 'decrement'))) {
261
                // we're trying to increment a key that's already being incremented
262
                // or decremented in this transaction - might as well combine those
263
264
                // we may be combining an increment with a decrement
265
                // we must carefully figure out how these 2 apply against each other
266
                $symbol = 'increment' === $this->keys[$key][0] ? 1 : -1;
267
                $previous = $symbol * $this->keys[$key][2]['offset'];
268
269
                $symbol = 'increment' === $operation ? 1 : -1;
270
                $current = $symbol * $offset;
271
272
                $offset = $previous + $current;
273
274
                $this->keys[$key][2]['offset'] = abs($offset);
275
                // initial value must also be adjusted to include the new offset
276
                $this->keys[$key][2]['initial'] += $current;
277
                $this->keys[$key][2]['expire'] = $expire;
278
279
                // adjust operation - it might just have switched from increment to
280
                // decrement or vice versa
281
                $operation = $offset >= 0 ? 'increment' : 'decrement';
282
                $this->keys[$key][0] = $operation;
283
                $this->keys[$key][1] = array($this->cache, $operation);
284
            } else {
285
                // touch & delete become useless if incrementing/decrementing after
286
                unset($this->keys[$key]);
287
            }
288
        }
289
290
        if (!isset($this->keys[$key])) {
291
            $args = array(
292
                'key' => $key,
293
                'offset' => $offset,
294
                'initial' => $initial,
295
                'expire' => $expire,
296
            );
297
            $this->keys[$key] = array($operation, array($this->cache, $operation), $args);
298
        }
299
    }
300
301
    /**
302
     * @param string $key
303
     * @param int    $expire
304
     */
305
    public function touch($key, $expire)
306
    {
307
        if (isset($this->keys[$key]) && isset($this->keys[$key][2]['expire'])) {
308
            // changing expiration time of a value we're already storing in
309
            // this transaction - might as well just set new expiration time
310
            // right away
311
            $this->keys[$key][2]['expire'] = $expire;
312
        } else {
313
            $args = array(
314
                'key' => $key,
315
                'expire' => $expire,
316
            );
317
            $this->keys[$key] = array(__FUNCTION__, array($this->cache, __FUNCTION__), $args);
318
        }
319
    }
320
321
    public function flush()
322
    {
323
        // clear all scheduled updates, they'll be wiped out after this anyway
324
        $this->keys = array();
325
        $this->flush = true;
326
    }
327
328
    /**
329
     * Clears all scheduled writes.
330
     */
331
    public function clear()
332
    {
333
        $this->keys = array();
334
        $this->flush = false;
335
    }
336
337
    /**
338
     * Commit all deferred writes to cache.
339
     *
340
     * When the commit fails, no changes in this transaction will be applied
341
     * (and those that had already been applied will be undone). False will
342
     * be returned in that case.
343
     *
344
     * @return bool
345
     */
346
    public function commit()
347
    {
348
        list($old, $new) = $this->generateRollback();
349
        $updates = $this->generateUpdates();
350
        $updates = $this->combineUpdates($updates);
351
        usort($updates, array($this, 'sortUpdates'));
352
353
        foreach ($updates as $update) {
354
            // apply update to cache & receive a simple bool to indicate
355
            // success (true) or failure (false)
356
            $success = call_user_func_array($update[1], $update[2]);
357
            if (false === $success) {
358
                $this->rollback($old, $new);
359
360
                return false;
361
            }
362
        }
363
364
        $this->clear();
365
366
        return true;
367
    }
368
369
    /**
370
     * Roll the cache back to pre-transaction state by comparing the current
371
     * cache values with what we planned to set them to.
372
     */
373
    protected function rollback(array $old, array $new)
374
    {
375
        foreach ($old as $key => $value) {
376
            $current = $this->cache->get($key, $token);
377
378
            /*
379
             * If the value right now equals the one we planned to write, it
380
             * should be restored to what it was before. If it's yet something
381
             * else, another process must've stored it and we should leave it
382
             * alone.
383
             */
384
            if ($current === $new) {
385
                /*
386
                 * CAS the rollback. If it fails, that means another process
387
                 * has stored in the meantime and we can just leave it alone.
388
                 * Note that we can't know the original expiration time!
389
                 */
390
                $this->cas($token, $key, $value, 0);
391
            }
392
        }
393
394
        $this->clear();
395
    }
396
397
    /**
398
     * Since we can't perform true atomic transactions, we'll fake it.
399
     * Most of the operations (set, touch, ...) can't fail. We'll do those last.
400
     * We'll first schedule the operations that can fail (cas, replace, add)
401
     * to minimize chances of another process overwriting those values in the
402
     * meantime.
403
     * But it could still happen, so we should fetch the current values for all
404
     * unsafe operations. If the transaction fails, we can then restore them.
405
     *
406
     * @return array[] Array of 2 [key => value] maps: current & scheduled data
407
     */
408
    protected function generateRollback()
409
    {
410
        $keys = array();
411
        $new = array();
412
413
        foreach ($this->keys as $key => $data) {
414
            $operation = $data[0];
415
416
            // we only need values for cas & replace - recovering from an 'add'
417
            // is just deleting the value...
418
            if (in_array($operation, array('cas', 'replace'))) {
419
                $keys[] = $key;
420
                $new[$key] = $data[2]['value'];
421
            }
422
        }
423
424
        if (empty($keys)) {
425
            return array(array(), array());
426
        }
427
428
        // fetch the existing data & return the planned new data as well
429
        $current = $this->cache->getMulti($keys);
430
431
        return array($current, $new);
432
    }
433
434
    /**
435
     * By storing all updates by key, we've already made sure we don't perform
436
     * redundant operations on a per-key basis. Now we'll turn those into
437
     * actual updates.
438
     *
439
     * @return array
440
     */
441
    protected function generateUpdates()
442
    {
443
        $updates = array();
444
445
        if ($this->flush) {
446
            $updates[] = array('flush', array($this->cache, 'flush'), array());
447
        }
448
449
        foreach ($this->keys as $key => $data) {
450
            $updates[] = $data;
451
        }
452
453
        return $updates;
454
    }
455
456
    /**
457
     * We may have multiple sets & deletes, which can be combined into a single
458
     * setMulti or deleteMulti operation.
459
     *
460
     * @param array $updates
461
     *
462
     * @return array
463
     */
464
    protected function combineUpdates($updates)
465
    {
466
        $setMulti = array();
467
        $deleteMulti = array();
468
469
        foreach ($updates as $i => $update) {
470
            $operation = $update[0];
471
            $args = $update[2];
472
473
            switch ($operation) {
474
                // all set & delete operations can be grouped into setMulti & deleteMulti
475
                case 'set':
476
                    unset($updates[$i]);
477
478
                    // only group sets with same expiration
479
                    $setMulti[$args['expire']][$args['key']] = $args['value'];
480
                    break;
481
                case 'delete':
482
                    unset($updates[$i]);
483
484
                    $deleteMulti[] = $args['key'];
485
                    break;
486
                default:
487
                    break;
488
            }
489
        }
490
491
        if (!empty($setMulti)) {
492
            $cache = $this->cache;
493
494
            /*
495
             * We'll use the return value of all deferred writes to check if they
496
             * should be rolled back.
497
             * commit() expects a single bool, not a per-key array of success bools.
498
             *
499
             * @param mixed[] $items
500
             * @param int $expire
501
             * @return bool
502
             */
503
            $callback = function ($items, $expire) use ($cache) {
504
                $success = $cache->setMulti($items, $expire);
505
506
                return !in_array(false, $success);
507
            };
508
509
            foreach ($setMulti as $expire => $items) {
510
                $updates[] = array('setMulti', $callback, array($items, $expire));
511
            }
512
        }
513
514
        if (!empty($deleteMulti)) {
515
            $cache = $this->cache;
516
517
            /*
518
             * commit() expected a single bool, not an array of success bools.
519
             * Besides, deleteMulti() is never cause for failure here: if the
520
             * key didn't exist because it has been deleted elsewhere already,
521
             * the data isn't corrupt, it's still as we'd expect it.
522
             *
523
             * @param string[] $keys
524
             * @return bool
525
             */
526
            $callback = function ($keys) use ($cache) {
527
                $cache->deleteMulti($keys);
528
529
                return true;
530
            };
531
532
            $updates[] = array('deleteMulti', $callback, array($deleteMulti));
533
        }
534
535
        return $updates;
536
    }
537
538
    /**
539
     * Change the order of the updates in this transaction to ensure we have those
540
     * most likely to fail first. That'll decrease odds of having to roll back, and
541
     * make rolling back easier.
542
     *
543
     * @param array $a Update, where index 0 is the operation name
544
     * @param array $b Update, where index 0 is the operation name
545
     *
546
     * @return int
547
     */
548
    protected function sortUpdates(array $a, array $b)
549
    {
550
        $updateOrder = array(
551
            // there's no point in applying this after doing the below updates
552
            // we also shouldn't really worry about cas/replace failing after this,
553
            // there won't be any after cache having been flushed
554
            'flush',
555
556
            // prone to fail: they depend on certain conditions (token must match
557
            // or value must (not) exist)
558
            'cas',
559
            'replace',
560
            'add',
561
562
            // unlikely/impossible to fail, assuming the input is valid
563
            'touch',
564
            'increment',
565
            'decrement',
566
            'set', 'setMulti',
567
            'delete', 'deleteMulti',
568
        );
569
570
        if ($a[0] === $b[0]) {
571
            return 0;
572
        }
573
574
        return array_search($a[0], $updateOrder) < array_search($b[0], $updateOrder) ? -1 : 1;
575
    }
576
}
577