b215ae482a8fd481bec49e87427fad2f6f7a906e
[Packages/TYPO3.CMS.git] / typo3 / sysext / core / Classes / Cache / Backend / MemcachedBackend.php
1 <?php
2 namespace TYPO3\CMS\Core\Cache\Backend;
3
4 /*
5 * This file is part of the TYPO3 CMS project.
6 *
7 * It is free software; you can redistribute it and/or modify it under
8 * the terms of the GNU General Public License, either version 2
9 * of the License, or any later version.
10 *
11 * For the full copyright and license information, please read the
12 * LICENSE.txt file that was distributed with this source code.
13 *
14 * The TYPO3 project - inspiring people to share!
15 */
16
17 use TYPO3\CMS\Core\Cache\Exception;
18 use TYPO3\CMS\Core\Cache\Frontend\FrontendInterface;
19
20 /**
21 * A caching backend which stores cache entries by using Memcached.
22 *
23 * This backend uses the following types of Memcache keys:
24 * - tag_xxx
25 * xxx is tag name, value is array of associated identifiers identifier. This
26 * is "forward" tag index. It is mainly used for obtaining content by tag
27 * (get identifier by tag -> get content by identifier)
28 * - ident_xxx
29 * xxx is identifier, value is array of associated tags. This is "reverse" tag
30 * index. It provides quick access for all tags associated with this identifier
31 * and used when removing the identifier
32 *
33 * Each key is prepended with a prefix. By default prefix consists from two parts
34 * separated by underscore character and ends in yet another underscore character:
35 * - "TYPO3"
36 * - Current site path obtained from the PATH_site constant
37 * This prefix makes sure that keys from the different installations do not
38 * conflict.
39 *
40 * Note: When using the Memcached backend to store values of more than ~1 MB,
41 * the data will be split into chunks to make them fit into the memcached limits.
42 *
43 * @api
44 */
45 class MemcachedBackend extends AbstractBackend implements TaggableBackendInterface, TransientBackendInterface
46 {
47 /**
48 * Max bucket size, (1024*1024)-42 bytes
49 *
50 * @var int
51 */
52 const MAX_BUCKET_SIZE = 1048534;
53
54 /**
55 * Instance of the PHP Memcache class
56 *
57 * @var \Memcache|\Memcached
58 */
59 protected $memcache;
60
61 /**
62 * Used PECL module for memcached
63 *
64 * @var string
65 */
66 protected $usedPeclModule = '';
67
68 /**
69 * Array of Memcache server configurations
70 *
71 * @var array
72 */
73 protected $servers = [];
74
75 /**
76 * Indicates whether the memcache uses compression or not (requires zlib),
77 * either 0 or \Memcached::OPT_COMPRESSION / MEMCACHE_COMPRESSED
78 *
79 * @var int
80 */
81 protected $flags;
82
83 /**
84 * A prefix to separate stored data from other data possibly stored in the memcache
85 *
86 * @var string
87 */
88 protected $identifierPrefix;
89
90 /**
91 * Constructs this backend
92 *
93 * @param string $context Unused, for backward compatibility only
94 * @param array $options Configuration options - depends on the actual backend
95 * @throws Exception if memcache is not installed
96 */
97 public function __construct($context, array $options = [])
98 {
99 if (!extension_loaded('memcache') && !extension_loaded('memcached')) {
100 throw new Exception('The PHP extension "memcache" or "memcached" must be installed and loaded in ' . 'order to use the Memcached backend.', 1213987706);
101 }
102
103 parent::__construct($context, $options);
104
105 if ($this->usedPeclModule === '') {
106 if (extension_loaded('memcache')) {
107 $this->usedPeclModule = 'memcache';
108 } elseif (extension_loaded('memcached')) {
109 $this->usedPeclModule = 'memcached';
110 }
111 }
112 }
113
114 /**
115 * Setter for servers to be used. Expects an array, the values are expected
116 * to be formatted like "<host>[:<port>]" or "unix://<path>"
117 *
118 * @param array $servers An array of servers to add.
119 * @api
120 */
121 protected function setServers(array $servers)
122 {
123 $this->servers = $servers;
124 }
125
126 /**
127 * Setter for compression flags bit
128 *
129 * @param bool $useCompression
130 * @api
131 */
132 protected function setCompression($useCompression)
133 {
134 $compressionFlag = $this->usedPeclModule === 'memcache' ? MEMCACHE_COMPRESSED : \Memcached::OPT_COMPRESSION;
135 if ($useCompression === true) {
136 $this->flags ^= $compressionFlag;
137 } else {
138 $this->flags &= ~$compressionFlag;
139 }
140 }
141
142 /**
143 * Getter for compression flag
144 *
145 * @return bool
146 * @api
147 */
148 protected function getCompression()
149 {
150 return $this->flags !== 0;
151 }
152
153 /**
154 * Initializes the identifier prefix
155 *
156 * @throws Exception
157 */
158 public function initializeObject()
159 {
160 if (empty($this->servers)) {
161 throw new Exception('No servers were given to Memcache', 1213115903);
162 }
163 $memcachedPlugin = '\\' . ucfirst($this->usedPeclModule);
164 $this->memcache = new $memcachedPlugin;
165 $defaultPort = $this->usedPeclModule === 'memcache' ? ini_get('memcache.default_port') : 11211;
166 foreach ($this->servers as $server) {
167 if (substr($server, 0, 7) === 'unix://') {
168 $host = $server;
169 $port = 0;
170 } else {
171 if (substr($server, 0, 6) === 'tcp://') {
172 $server = substr($server, 6);
173 }
174 if (strpos($server, ':') !== false) {
175 list($host, $port) = explode(':', $server, 2);
176 } else {
177 $host = $server;
178 $port = $defaultPort;
179 }
180 }
181 $this->memcache->addserver($host, $port);
182 }
183 if ($this->usedPeclModule === 'memcached') {
184 $this->memcache->setOption(\Memcached::OPT_COMPRESSION, $this->getCompression());
185 }
186 }
187
188 /**
189 * Sets the preferred PECL module
190 *
191 * @param string $peclModule
192 * @throws Exception
193 */
194 public function setPeclModule($peclModule)
195 {
196 if ($peclModule !== 'memcache' && $peclModule !== 'memcached') {
197 throw new Exception('PECL module must be either "memcache" or "memcached".', 1442239768);
198 }
199
200 $this->usedPeclModule = $peclModule;
201 }
202
203 /**
204 * Initializes the identifier prefix when setting the cache.
205 *
206 * @param FrontendInterface $cache The frontend for this backend
207 */
208 public function setCache(FrontendInterface $cache)
209 {
210 parent::setCache($cache);
211 $identifierHash = substr(md5(PATH_site . $this->context . $this->cacheIdentifier), 0, 12);
212 $this->identifierPrefix = 'TYPO3_' . $identifierHash . '_';
213 }
214
215 /**
216 * Saves data in the cache.
217 *
218 * @param string $entryIdentifier An identifier for this specific cache entry
219 * @param string $data The data to be stored
220 * @param array $tags Tags to associate with this cache entry
221 * @param int $lifetime Lifetime of this cache entry in seconds. If NULL is specified, the default lifetime is used. "0" means unlimited lifetime.
222 * @throws Exception if no cache frontend has been set.
223 * @throws \InvalidArgumentException if the identifier is not valid or the final memcached key is longer than 250 characters
224 * @throws Exception\InvalidDataException if $data is not a string
225 * @api
226 */
227 public function set($entryIdentifier, $data, array $tags = [], $lifetime = null)
228 {
229 if (strlen($this->identifierPrefix . $entryIdentifier) > 250) {
230 throw new \InvalidArgumentException('Could not set value. Key more than 250 characters (' . $this->identifierPrefix . $entryIdentifier . ').', 1232969508);
231 }
232 if (!$this->cache instanceof FrontendInterface) {
233 throw new Exception('No cache frontend has been set yet via setCache().', 1207149215);
234 }
235 $tags[] = '%MEMCACHEBE%' . $this->cacheIdentifier;
236 $expiration = $lifetime ?? $this->defaultLifetime;
237
238 // Memcached consideres values over 2592000 sec (30 days) as UNIX timestamp
239 // thus $expiration should be converted from lifetime to UNIX timestamp
240 if ($expiration > 2592000) {
241 $expiration += $GLOBALS['EXEC_TIME'];
242 }
243 try {
244 if (is_string($data) && strlen($data) > self::MAX_BUCKET_SIZE) {
245 $data = str_split($data, 1024 * 1000);
246 $success = true;
247 $chunkNumber = 1;
248 foreach ($data as $chunk) {
249 $success = $success && $this->setInternal($entryIdentifier . '_chunk_' . $chunkNumber, $chunk, $expiration);
250 $chunkNumber++;
251 }
252 $success = $success && $this->setInternal($entryIdentifier, 'TYPO3*chunked:' . $chunkNumber, $expiration);
253 } else {
254 $success = $this->setInternal($entryIdentifier, $data, $expiration);
255 }
256 if ($success === true) {
257 $this->removeIdentifierFromAllTags($entryIdentifier);
258 $this->addIdentifierToTags($entryIdentifier, $tags);
259 } else {
260 throw new Exception('Could not set data to memcache server.', 1275830266);
261 }
262 } catch (\Exception $exception) {
263 $this->logger->alert('Memcache: could not set value.', ['exception' => $exception]);
264 }
265 }
266
267 /**
268 * Stores the actual data inside memcache/memcached
269 *
270 * @param string $entryIdentifier
271 * @param mixed $data
272 * @param int $expiration
273 * @return bool
274 */
275 protected function setInternal($entryIdentifier, $data, $expiration)
276 {
277 if ($this->usedPeclModule === 'memcache') {
278 return $this->memcache->set($this->identifierPrefix . $entryIdentifier, $data, $this->flags, $expiration);
279 }
280 return $this->memcache->set($this->identifierPrefix . $entryIdentifier, $data, $expiration);
281 }
282
283 /**
284 * Loads data from the cache.
285 *
286 * @param string $entryIdentifier An identifier which describes the cache entry to load
287 * @return mixed The cache entry's content as a string or FALSE if the cache entry could not be loaded
288 * @api
289 */
290 public function get($entryIdentifier)
291 {
292 $value = $this->memcache->get($this->identifierPrefix . $entryIdentifier);
293 if (is_string($value) && substr($value, 0, 14) === 'TYPO3*chunked:') {
294 list(, $chunkCount) = explode(':', $value);
295 $value = '';
296 for ($chunkNumber = 1; $chunkNumber < $chunkCount; $chunkNumber++) {
297 $value .= $this->memcache->get($this->identifierPrefix . $entryIdentifier . '_chunk_' . $chunkNumber);
298 }
299 }
300 return $value;
301 }
302
303 /**
304 * Checks if a cache entry with the specified identifier exists.
305 *
306 * @param string $entryIdentifier An identifier specifying the cache entry
307 * @return bool TRUE if such an entry exists, FALSE if not
308 * @api
309 */
310 public function has($entryIdentifier)
311 {
312 if ($this->usedPeclModule === 'memcache') {
313 return $this->memcache->get($this->identifierPrefix . $entryIdentifier) !== false;
314 }
315
316 // pecl-memcached supports storing literal FALSE
317 $this->memcache->get($this->identifierPrefix . $entryIdentifier);
318 return $this->memcache->getResultCode() !== \Memcached::RES_NOTFOUND;
319 }
320
321 /**
322 * Removes all cache entries matching the specified identifier.
323 * Usually this only affects one entry but if - for what reason ever -
324 * old entries for the identifier still exist, they are removed as well.
325 *
326 * @param string $entryIdentifier Specifies the cache entry to remove
327 * @return bool TRUE if (at least) an entry could be removed or FALSE if no entry was found
328 * @api
329 */
330 public function remove($entryIdentifier)
331 {
332 $this->removeIdentifierFromAllTags($entryIdentifier);
333 return $this->memcache->delete($this->identifierPrefix . $entryIdentifier, 0);
334 }
335
336 /**
337 * Finds and returns all cache entry identifiers which are tagged by the
338 * specified tag.
339 *
340 * @param string $tag The tag to search for
341 * @return array An array of entries with all matching entries. An empty array if no entries matched
342 * @api
343 */
344 public function findIdentifiersByTag($tag)
345 {
346 $identifiers = $this->memcache->get($this->identifierPrefix . 'tag_' . $tag);
347 if ($identifiers !== false) {
348 return (array)$identifiers;
349 }
350 return [];
351 }
352
353 /**
354 * Removes all cache entries of this cache.
355 *
356 * @throws Exception
357 * @api
358 */
359 public function flush()
360 {
361 if (!$this->cache instanceof FrontendInterface) {
362 throw new Exception('No cache frontend has been set via setCache() yet.', 1204111376);
363 }
364 $this->flushByTag('%MEMCACHEBE%' . $this->cacheIdentifier);
365 }
366
367 /**
368 * Removes all cache entries of this cache which are tagged by the specified tag.
369 *
370 * @param string $tag The tag the entries must have
371 * @api
372 */
373 public function flushByTag($tag)
374 {
375 $identifiers = $this->findIdentifiersByTag($tag);
376 foreach ($identifiers as $identifier) {
377 $this->remove($identifier);
378 }
379 }
380
381 /**
382 * Associates the identifier with the given tags
383 *
384 * @param string $entryIdentifier
385 * @param array $tags
386 */
387 protected function addIdentifierToTags($entryIdentifier, array $tags)
388 {
389 // Get identifier-to-tag index to look for updates
390 $existingTags = $this->findTagsByIdentifier($entryIdentifier);
391 $existingTagsUpdated = false;
392
393 foreach ($tags as $tag) {
394 // Update tag-to-identifier index
395 $identifiers = $this->findIdentifiersByTag($tag);
396 if (!in_array($entryIdentifier, $identifiers, true)) {
397 $identifiers[] = $entryIdentifier;
398 $this->memcache->set($this->identifierPrefix . 'tag_' . $tag, $identifiers);
399 }
400 // Test if identifier-to-tag index needs update
401 if (!in_array($tag, $existingTags, true)) {
402 $existingTags[] = $tag;
403 $existingTagsUpdated = true;
404 }
405 }
406
407 // Update identifier-to-tag index if needed
408 if ($existingTagsUpdated) {
409 $this->memcache->set($this->identifierPrefix . 'ident_' . $entryIdentifier, $existingTags);
410 }
411 }
412
413 /**
414 * Removes association of the identifier with the given tags
415 *
416 * @param string $entryIdentifier
417 */
418 protected function removeIdentifierFromAllTags($entryIdentifier)
419 {
420 // Get tags for this identifier
421 $tags = $this->findTagsByIdentifier($entryIdentifier);
422 // De-associate tags with this identifier
423 foreach ($tags as $tag) {
424 $identifiers = $this->findIdentifiersByTag($tag);
425 // Formally array_search() below should never return FALSE due to
426 // the behavior of findTagsByIdentifier(). But if reverse index is
427 // corrupted, we still can get 'FALSE' from array_search(). This is
428 // not a problem because we are removing this identifier from
429 // anywhere.
430 if (($key = array_search($entryIdentifier, $identifiers)) !== false) {
431 unset($identifiers[$key]);
432 if (!empty($identifiers)) {
433 $this->memcache->set($this->identifierPrefix . 'tag_' . $tag, $identifiers);
434 } else {
435 $this->memcache->delete($this->identifierPrefix . 'tag_' . $tag, 0);
436 }
437 }
438 }
439 // Clear reverse tag index for this identifier
440 $this->memcache->delete($this->identifierPrefix . 'ident_' . $entryIdentifier, 0);
441 }
442
443 /**
444 * Finds all tags for the given identifier. This function uses reverse tag
445 * index to search for tags.
446 *
447 * @param string $identifier Identifier to find tags by
448 * @return array
449 * @api
450 */
451 protected function findTagsByIdentifier($identifier)
452 {
453 $tags = $this->memcache->get($this->identifierPrefix . 'ident_' . $identifier);
454 return $tags === false ? [] : (array)$tags;
455 }
456
457 /**
458 * Does nothing, as memcached does GC itself
459 *
460 * @api
461 */
462 public function collectGarbage()
463 {
464 }
465 }