MemcacheBackend.php 13.3 KB
Newer Older
damiankloip's avatar
damiankloip committed
1
2
3
4
<?php

namespace Drupal\memcache;

5
use Drupal\Component\Assertion\Inspector;
6
use Drupal\Component\Utility\Crypt;
damiankloip's avatar
damiankloip committed
7
use Drupal\Core\Cache\CacheBackendInterface;
8
use Drupal\Core\Cache\CacheTagsChecksumInterface;
9
10
use Drupal\Core\DependencyInjection\ContainerNotInitializedException;
use Drupal\Core\Logger\LoggerChannelTrait;
11
use Drupal\memcache\Invalidator\TimestampInvalidatorInterface;
12
use Symfony\Component\DependencyInjection\Exception\ServiceNotFoundException;
damiankloip's avatar
damiankloip committed
13

14
15
16
/**
 * Defines a Memcache cache backend.
 */
damiankloip's avatar
damiankloip committed
17
18
class MemcacheBackend implements CacheBackendInterface {

19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
  use LoggerChannelTrait;

  /**
   * The maximum size of an individual cache chunk.
   *
   * Memcached is about balance. With this area of functionality, we need to
   * minimize the number of split items while also considering wasted memory.
   * In Memcached, all slab "pages" contain 1MB of data, by default.  Therefore,
   * when we split items, we want to do to in a manner that comes close to
   * filling a slab page with as little remaining memory as possible, while
   * taking item overhead into consideration.
   *
   * Our tests concluded that Memached slab 39 is a perfect slab to target.
   * Slab 39 contains items roughly between 385-512KB in size.  We are targeting
   * a chunk size of 493568 bytes (482kb) - which will give us enough storage
   * for two split items, leaving as little overhead as possible.
   *
   * Note that the overhead not only includes metadata about each item, but
   * also allows compression "backfiring" (under some circumstances, compression
   * actually enlarges some data objects instead of shrinking them).   */

  const MAX_CHUNK_SIZE = 470000;

42
43
44
45
46
47
48
  /**
   * The cache bin to use.
   *
   * @var string
   */
  protected $bin;

49
50
51
52
53
54
55
  /**
   * The (micro)time the bin was last deleted.
   *
   * @var float
   */
  protected $lastBinDeletionTime;

56
57
58
59
60
61
62
  /**
   * The memcache wrapper object.
   *
   * @var \Drupal\memcache\DrupalMemcacheInterface
   */
  protected $memcache;

63
64
65
  /**
   * The cache tags checksum provider.
   *
66
   * @var \Drupal\Core\Cache\CacheTagsChecksumInterface|\Drupal\Core\Cache\CacheTagsInvalidatorInterface
67
68
69
   */
  protected $checksumProvider;

70
71
72
73
74
75
76
  /**
   * The timestamp invalidation provider.
   *
   * @var \Drupal\memcache\Invalidator\TimestampInvalidatorInterface
   */
  protected $timestampInvalidator;

77
78
  /**
   * Constructs a MemcacheBackend object.
79
   *
80
81
82
83
   * @param string $bin
   *   The bin name.
   * @param \Drupal\memcache\DrupalMemcacheInterface $memcache
   *   The memcache object.
84
   * @param \Drupal\Core\Cache\CacheTagsChecksumInterface $checksum_provider
85
   *   The cache tags checksum service.
86
   * @param \Drupal\memcache\Invalidator\TimestampInvalidatorInterface $timestamp_invalidator
87
   *   The timestamp invalidation provider.
88
   */
89
  public function __construct($bin, DrupalMemcacheInterface $memcache, CacheTagsChecksumInterface $checksum_provider, TimestampInvalidatorInterface $timestamp_invalidator) {
90
    $this->bin = $bin;
91
    $this->memcache = $memcache;
92
    $this->checksumProvider = $checksum_provider;
93
    $this->timestampInvalidator = $timestamp_invalidator;
94
95

    $this->ensureBinDeletionTimeIsSet();
96
97
  }

98
99
100
101
102
103
104
  /**
   * Check to see if debug is on. Wrap it in safety for early bootstraps.
   * 
   * @returns bool 
   */
  private function debug() :bool {
    try {
105
106
107
108
109
      $debug = \Drupal::service('memcache.settings')->get('debug');
      if ($debug) {
        return $debug;
      }
      return false;
110
111
112
113
114
115
116
117
118
    }
    catch (ServiceNotFoundException $e) {
      return false;
    }
    catch (ContainerNotInitializedException $e) {
      return false;
    }
  }

119
120
121
  /**
   * {@inheritdoc}
   */
damiankloip's avatar
damiankloip committed
122
  public function get($cid, $allow_invalid = FALSE) {
123
    $cids = [$cid];
damiankloip's avatar
damiankloip committed
124
125
    $cache = $this->getMultiple($cids, $allow_invalid);
    return reset($cache);
126
127
128
129
130
  }

  /**
   * {@inheritdoc}
   */
damiankloip's avatar
damiankloip committed
131
  public function getMultiple(&$cids, $allow_invalid = FALSE) {
132
    $cache = $this->memcache->getMulti($cids);
133
134
    $fetched = [];

135
    foreach ($cache as $result) {
136
137
138
139
      if (!$this->timeIsGreaterThanBinDeletionTime($result->created)) {
        continue;
      }

140
      if ($this->valid($result->cid, $result) || $allow_invalid) {
141
142
143
144
145
146
147
148
149
150
151
152
153

        // If the item is multipart, rebuild the original cache data by fetching
        // children and combining them back into a single item.
        if ($result->data instanceof MultipartItem) {
          $childCIDs = $result->data->getCids();
          $dataParts = $this->memcache->getMulti($childCIDs);
          if (count($dataParts) !== count($childCIDs)) {
            // We're missing a chunk of the original entry. It is not valid.
            continue;
          }
          $result->data = $this->combineItems($dataParts);
        }

154
155
        // Add it to the fetched items to diff later.
        $fetched[$result->cid] = $result;
156
157
      }
    }
damiankloip's avatar
damiankloip committed
158

159
    // Remove items from the referenced $cids array that we are returning,
160
    // per comment in Drupal\Core\Cache\CacheBackendInterface::getMultiple().
161
    $cids = array_diff($cids, array_keys($fetched));
damiankloip's avatar
damiankloip committed
162

163
    return $fetched;
164
165
166
  }

  /**
167
168
169
170
171
172
173
174
175
176
   * Determines if the cache item is valid.
   *
   * This also alters the valid property of the cache item itself.
   *
   * @param string $cid
   *   The cache ID.
   * @param \stdClass $cache
   *   The cache item.
   *
   * @return bool
177
   *   TRUE if valid, FALSE otherwise.
178
   */
179
  protected function valid($cid, \stdClass $cache) {
180
181
182
    $cache->valid = TRUE;

    // Items that have expired are invalid.
japerry's avatar
japerry committed
183
    if ($cache->expire != CacheBackendInterface::CACHE_PERMANENT && $cache->expire <= REQUEST_TIME) {
184
      $cache->valid = FALSE;
185
186
    }

187
188
189
190
191
    // Check if invalidateTags() has been called with any of the items's tags.
    if (!$this->checksumProvider->isValid($cache->checksum, $cache->tags)) {
      $cache->valid = FALSE;
    }

192
    return $cache->valid;
193
194
195
196
197
  }

  /**
   * {@inheritdoc}
   */
198
  public function set($cid, $data, $expire = CacheBackendInterface::CACHE_PERMANENT, array $tags = []) {
199
200
201
    assert(Inspector::assertAllStrings($tags));

    $tags[] = "memcache:$this->bin";
202
203
204
205
    $tags = array_unique($tags);
    // Sort the cache tags so that they are stored consistently.
    sort($tags);

206
    // Create new cache object.
damiankloip's avatar
damiankloip committed
207
    $cache = new \stdClass();
208
    $cache->cid = $cid;
209
    $cache->data = $data;
210
211
    $cache->created = round(microtime(TRUE), 3);
    $cache->expire = $expire;
212
213
    $cache->tags = $tags;
    $cache->checksum = $this->checksumProvider->getCurrentChecksum($tags);
214
215

    // Cache all items permanently. We handle expiration in our own logic.
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
    if ($this->memcache->set($cid, $cache)) {
      return TRUE;
    }

    // Assume that the item is too large.  We need to split it into multiple
    // chunks with a parent entry referencing all the chunks.
    $childKeys = [];
    foreach ($this->splitItem($cache) as $part) {
      // If a single chunk fails to be set, stop trying - we can't reconstitute
      // a value with a missing chunk.
      if (!$this->memcache->set($part->cid, $part)) {
        return FALSE;
      }
      $childKeys[] = $part->cid;
    }

    // Create and write the parent entry referencing all chunks.
    $cache->data = new MultipartItem($childKeys);
234
    return $this->memcache->set($cid, $cache);
235
236
  }

237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
 /**
   * Given a single cache item, split it into multiple child items.
   *
   * @param \stdClass $item
   *   The original cache item, before the split.
   *
   * @return \stdClass[]
   *   An array of child items.
   */
  private function splitItem(\stdClass $item) {
    $data = serialize($item->data);
    $pieces = str_split($data, static::MAX_CHUNK_SIZE);

    // Add a unique identifier each time this function is invoked.  This
    // prevents a race condition where two sets on the same multipart item can
    // clobber each other's children.  With this seed, each time a multipart
    // entry is created, they get a different CID.  The parent (multipart) entry
    // does not inherit this unique identifier, so it is still addressable using
    // the CID it was initially given.
    $seed = Crypt::randomBytesBase64();

    $children = [];

    foreach ($pieces as $i => $chunk) {
      // Child items do not need tags or expire, since that data is carried by
      // the parent.
      $chunkItem = new \stdClass();
      // @TODO: mention why we added split and picked this order...
      $chunkItem->cid = sprintf('split.%d.%s.%s', $i, $item->cid, $seed);
      $chunkItem->data = $chunk;
      $chunkItem->created = $item->created;
      $children[] = $chunkItem;
    }

    if ($this->debug()) {
      $this->getLogger('memcache')->debug(
        'Split item @cid into @num pieces',
        ['@cid' => $item->cid, '@num' => ($i+1)]
      );
    }

    return $children;
  }

  /**
   * Given an array of child cache items, recombine into a single value.
   *
   * @param \stdClass[] $items
   *   An array of child cache items.
   *
   * @return mixed
   *   The combined an unserialized value that was originally stored.
   */
  private function combineItems(array $items) {
    $data = array_reduce($items, function($collected, $item) {
      return $collected . $item->data;
    }, '');
    return unserialize($data);
  }

297
298
299
300
301
  /**
   * {@inheritdoc}
   */
  public function setMultiple(array $items) {
    foreach ($items as $cid => $item) {
302
      $item += [
303
        'expire' => CacheBackendInterface::CACHE_PERMANENT,
304
305
        'tags' => [],
      ];
306

307
308
309
310
      $this->set($cid, $item['data'], $item['expire'], $item['tags']);
    }
  }

311
312
313
314
  /**
   * {@inheritdoc}
   */
  public function delete($cid) {
315
    $this->memcache->delete($cid);
316
317
318
319
320
321
322
  }

  /**
   * {@inheritdoc}
   */
  public function deleteMultiple(array $cids) {
    foreach ($cids as $cid) {
323
      $this->memcache->delete($cid);
324
325
326
327
328
329
330
    }
  }

  /**
   * {@inheritdoc}
   */
  public function deleteAll() {
331
332
333
334
335
336
337
    if ($this->debug()) {
      $this->getLogger('memcache')->debug(
        'Called deleteAll() on bin @bin',
        ['@bin' => $this->bin]
      );
    }

338
    $this->lastBinDeletionTime = $this->timestampInvalidator->invalidateTimestamp($this->bin);
339
340
341
342
343
344
  }

  /**
   * {@inheritdoc}
   */
  public function invalidate($cid) {
345
    $this->invalidateMultiple([$cid]);
346
347
348
349
350
  }

  /**
   * Marks cache items as invalid.
   *
351
352
   * Invalid items may be returned in later calls to get(), if the
   * $allow_invalid argument is TRUE.
353
   *
354
   * @param array $cids
355
356
357
358
359
360
361
362
363
   *   An array of cache IDs to invalidate.
   *
   * @see Drupal\Core\Cache\CacheBackendInterface::deleteMultiple()
   * @see Drupal\Core\Cache\CacheBackendInterface::invalidate()
   * @see Drupal\Core\Cache\CacheBackendInterface::invalidateTags()
   * @see Drupal\Core\Cache\CacheBackendInterface::invalidateAll()
   */
  public function invalidateMultiple(array $cids) {
    foreach ($cids as $cid) {
364
      if ($item = $this->get($cid)) {
japerry's avatar
japerry committed
365
        $item->expire = REQUEST_TIME - 1;
366
        $this->memcache->set($cid, $item);
367
      }
368
369
370
371
372
373
    }
  }

  /**
   * {@inheritdoc}
   */
374
  public function invalidateAll() {
375
376
377
378
379
380
381
    if ($this->debug()) {
      $this->getLogger('memcache')->debug(
        'Called invalidateAll() on bin @bin',
        ['@bin' => $this->bin]
      );
    }

382
    $this->invalidateTags(["memcache:$this->bin"]);
383
384
  }

385
386
387
  /**
   * {@inheritdoc}
   */
388
  public function invalidateTags(array $tags) {
389
390
391
392
393
394
395
    if ($this->debug()) {
      $this->getLogger('memcache')->debug(
        'Called invalidateTags() on tags @tags',
        ['@tags' => implode(',', $tags)]
      );
    }

396
    $this->checksumProvider->invalidateTags($tags);
397
398
399
400
401
  }

  /**
   * {@inheritdoc}
   */
402
  public function removeBin() {
403
404
405
406
407
408
409
    if ($this->debug()) {
      $this->getLogger('memcache')->debug(
        'Called removeBin() on bin @bin',
        ['@bin' => $this->bin]
      );
    }

410
    $this->lastBinDeletionTime = $this->timestampInvalidator->invalidateTimestamp($this->bin);
411
412
  }

413
414
415
  /**
   * {@inheritdoc}
   */
416
417
418
  public function garbageCollection() {
    // Memcache will invalidate items; That items memory allocation is then
    // freed up and reused. So nothing needs to be deleted/cleaned up here.
419
420
  }

421
  /**
422
   * {@inheritdoc}
423
424
   */
  public function isEmpty() {
425
426
    // We do not know so err on the safe side? Not sure if we can know this?
    return TRUE;
427
428
  }

429
430
431
432
  /**
   * Determines if a (micro)time is greater than the last bin deletion time.
   *
   * @param float $item_microtime
433
434
435
   *   A given (micro)time.
   *
   * @internal
436
437
   *
   * @return bool
438
439
   *   TRUE if the (micro)time is greater than the last bin deletion time, FALSE
   *   otherwise.
440
441
442
443
444
445
446
447
448
449
   */
  protected function timeIsGreaterThanBinDeletionTime($item_microtime) {
    $last_bin_deletion = $this->getBinLastDeletionTime();

    // If there is time, assume FALSE as there is no previous deletion time
    // to compare with.
    if (!$last_bin_deletion) {
      return FALSE;
    }

450
    return $item_microtime > $last_bin_deletion;
451
452
453
454
455
456
457
  }

  /**
   * Gets the last invalidation time for the bin.
   *
   * @internal
   *
458
459
   * @return float
   *   The last invalidation timestamp of the tag.
460
461
462
   */
  protected function getBinLastDeletionTime() {
    if (!isset($this->lastBinDeletionTime)) {
463
      $this->lastBinDeletionTime = $this->timestampInvalidator->getLastInvalidationTimestamp($this->bin);
464
465
466
467
468
469
470
471
472
473
474
475
    }

    return $this->lastBinDeletionTime;
  }

  /**
   * Ensures a last bin deletion time has been set.
   *
   * @internal
   */
  protected function ensureBinDeletionTimeIsSet() {
    if (!$this->getBinLastDeletionTime()) {
476
      $this->lastBinDeletionTime = $this->timestampInvalidator->invalidateTimestamp($this->bin);
477
478
479
    }
  }

damiankloip's avatar
damiankloip committed
480
}