Completed
Push — master ( 499be4...9132e2 )
by André
51:01 queued 27:11
created

InMemoryCache::deleteMulti()   A

Complexity

Conditions 4
Paths 4

Size

Total Lines 16

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
cc 4
nc 4
nop 1
dl 0
loc 16
rs 9.7333
c 0
b 0
f 0
1
<?php
2
3
/**
4
 * File containing MetadataCachePool class.
5
 *
6
 * @copyright Copyright (C) eZ Systems AS. All rights reserved.
7
 * @license For full copyright and license information view LICENSE file distributed with this source code.
8
 */
9
declare(strict_types=1);
10
11
namespace eZ\Publish\Core\Persistence\Cache\InMemory;
12
13
/**
14
 * Simple In-Memory Cache Pool.
15
 *
16
 * @internal Only for use in eZ\Publish\Core\Persistence\Cache\AbstractInMemoryHandler, may change depending on needs there.
17
 *
18
 * Goal:
19
 * By nature caches stale objects in memory to avoid round-trips to cache backend over network.
20
 * Thus should only be used for meta data which changes in-frequently, and has limits and short expiry to keep the risk
21
 * of using out-of-date data to a minimum. Besides backend round trips, also aims to keep memory usage to a minimum.
22
 *
23
 * Properties:
24
 * - Limited by amount and time, to make sure to use minimal amount of memory, and reduce risk of stale cache.
25
 * - Secondary indexes, allows for populating memory quickly, and safely able to delete only knowing primary key
26
 *   E.g. I language object is loaded first by id, locale (eng-GB) lookup is also populated. Opposite is also true.
27
 * - On object limits, will first try to vacuum in-frequently used cache items, then by FIFO principle if needed.
28
 */
29
class InMemoryCache
30
{
31
    /**
32
     * @var float Cache Time to Live, in seconds. This is only for how long we keep cache object around in-memory.
33
     */
34
    private $ttl;
35
36
    /**
37
     * @var int The limit of objects in cache pool at a given time
38
     */
39
    private $limit;
40
41
    /**
42
     * @var bool Switch for enabeling/disabling in-memory cache
43
     */
44
    private $enabled;
45
46
    /**
47
     * Cache objects by primary key.
48
     *
49
     * @var object[]
50
     */
51
    private $cache = [];
52
53
    /**
54
     * @var float[] Expiry timestamp (float microtime) for individual cache (by primary key).
55
     */
56
    private $cacheExpiryTime = [];
57
58
    /**
59
     * @var int[] Access counter for individual cache (by primary key), to order by by popularity on vacuum().
60
     */
61
    private $cacheAccessCount = [];
62
63
    /**
64
     * Mapping of secondary index to primary key.
65
     *
66
     * @var string[]
67
     */
68
    private $cacheIndex = [];
69
70
    /**
71
     * In Memory Cache constructor.
72
     *
73
     * @param int $ttl Seconds for the cache to live, by default 300 milliseconds
74
     * @param int $limit Limit for values to keep in cache, by default 100 cache values.
75
     * @param bool $enabled For use by configuration to be able to disable or enable depending on needs.
76
     */
77
    public function __construct(int $ttl = 300, int $limit = 100, bool $enabled = true)
78
    {
79
        $this->ttl = $ttl / 1000;
0 ignored issues
show
Documentation Bug introduced by
The property $ttl was declared of type double, but $ttl / 1000 is of type integer. Maybe add a type cast?

This check looks for assignments to scalar types that may be of the wrong type.

To ensure the code behaves as expected, it may be a good idea to add an explicit type cast.

$answer = 42;

$correct = false;

$correct = (bool) $answer;
Loading history...
80
        $this->limit = $limit;
81
        $this->enabled = $enabled;
82
    }
83
84
    /**
85
     * @param bool $enabled
86
     *
87
     * @return bool Prior value
88
     */
89
    public function setEnabled(bool $enabled = true): bool
90
    {
91
        $was = $this->enabled;
92
        $this->enabled = $enabled;
93
94
        return $was;
95
    }
96
97
    /**
98
     * Returns a cache objects.
99
     *
100
     * @param string $key Primary or secondary index to look for cache on.
101
     *
102
     * @return object|null Object if found, null if not.
103
     */
104
    public function get(string $key)
105
    {
106
        if ($this->enabled === false) {
107
            return null;
108
        }
109
110
        $index = $this->cacheIndex[$key] ?? $key;
111
        if (!isset($this->cache[$index]) || $this->cacheExpiryTime[$index] < microtime(true)) {
112
            return null;
113
        }
114
115
        ++$this->cacheAccessCount[$index];
116
117
        return $this->cache[$index];
118
    }
119
120
    /**
121
     * Set object in in-memory cache.
122
     *
123
     * Should only set Cache hits here!
124
     *
125
     * @param object[] $objects
126
     * @param callable $objectIndexes Return array of indexes per object (first argument), must return at least 1 primary index
127
     * @param string|null $listIndex Optional index for list of items
128
     */
129
    public function setMulti(array $objects, callable $objectIndexes, string $listIndex = null): void
130
    {
131
        // If objects accounts for more then 20% of our limit, assume it's bulk load and skip saving in-memory
132
        if ($this->enabled === false || \count($objects) >= $this->limit / 5) {
133
            return;
134
        }
135
136
        // check if we will reach limit by adding these objects, if so remove old cache
137
        if (\count($this->cache) + \count($objects) >= $this->limit) {
138
            $this->vacuum();
139
        }
140
141
        $expiryTime = microtime(true) + $this->ttl;
142
        // if set add objects to cache on list index (typically a "all" key)
143
        if ($listIndex) {
0 ignored issues
show
Bug Best Practice introduced by
The expression $listIndex of type null|string is loosely compared to true; this is ambiguous if the string can be empty. You might want to explicitly use !== null instead.

In PHP, under loose comparison (like ==, or !=, or switch conditions), values of different types might be equal.

For string values, the empty string '' is a special case, in particular the following results might be unexpected:

''   == false // true
''   == null  // true
'ab' == false // false
'ab' == null  // false

// It is often better to use strict comparison
'' === false // false
'' === null  // false
Loading history...
144
            $this->cache[$listIndex] = $objects;
145
            $this->cacheExpiryTime[$listIndex] = $expiryTime;
146
            $this->cacheAccessCount[$listIndex] = 0;
147
        }
148
149
        foreach ($objects as $object) {
150
            // Skip if there are no indexes
151
            if (!$indexes = $objectIndexes($object)) {
152
                continue;
153
            }
154
155
            $key = \array_shift($indexes);
156
            $this->cache[$key] = $object;
157
            $this->cacheExpiryTime[$key] = $expiryTime;
158
            $this->cacheAccessCount[$key] = 0;
159
160
            foreach ($indexes as $index) {
161
                $this->cacheIndex[$index] = $key;
162
            }
163
        }
164
    }
165
166
    /**
167
     * Removes multiple in-memory cache from the pool.
168
     *
169
     * @param string[] $keys An array of keys that should be removed from the pool.
170
     */
171
    public function deleteMulti(array $keys): void
172
    {
173
        if ($this->enabled === false) {
174
            return;
175
        }
176
177
        foreach ($keys as $index) {
178
            if ($key = $this->cacheIndex[$index] ?? null) {
179
                unset($this->cacheIndex[$index]);
180
            } else {
181
                $key = $index;
182
            }
183
184
            unset($this->cache[$key], $this->cacheExpiryTime[$key], $this->cacheAccessCount[$key]);
185
        }
186
    }
187
188
    /**
189
     * Deletes all cache in the in-memory pool.
190
     */
191
    public function clear(): void
192
    {
193
        // On purpose does not check if enabled, in case of prior phase being enabled we clear cache when someone asks.
194
        $this->cache = $this->cacheIndex = $this->cacheExpiryTime = $this->cacheAccessCount = [];
195
    }
196
197
    /**
198
     * Call to reduce cache items when $limit has been reached.
199
     *
200
     * Deletes items using LFU approach where the least frequently used items are evicted from bottom and up.
201
     * Within groups of cache used equal amount of times, the oldest keys will be deleted first (FIFO).
202
     */
203
    private function vacuum(): void
204
    {
205
        // To not having to call this too often, we aim to clear 33% of cache values in bulk
206
        $deleteTarget = (int) ($this->limit / 3);
207
208
        // First we flip the cacheAccessCount and sort so order is first by access (LFU) then secondly by order (FIFO)
209
        $groupedAccessCount = [];
210
        foreach ($this->cacheAccessCount as $key => $accessCount) {
211
            $groupedAccessCount[$accessCount][] = $key;
212
        }
213
        \ksort($groupedAccessCount, SORT_NUMERIC);
214
215
        // Merge the resulting sorted array of arrays to flatten the result
216
        foreach (\array_merge(...$groupedAccessCount) as $key) {
217
            unset($this->cache[$key], $this->cacheExpiryTime[$key], $this->cacheAccessCount[$key]);
218
            --$deleteTarget;
219
220
            if ($deleteTarget <= 0) {
221
                break;
222
            }
223
        }
224
225
        // Cleanup secondary indexes for missing primary keys
226
        foreach ($this->cacheIndex as $index => $key) {
227
            if (!isset($this->cache[$key])) {
228
                unset($this->cacheIndex[$index]);
229
            }
230
        }
231
    }
232
}
233