Completed
Push — 7.x-1.x ( e1c396...0ca620 )
by Frédéric G.
01:51
created

Cache::hasException()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 3
Code Lines 2

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
cc 1
eloc 2
nc 1
nop 0
dl 0
loc 3
rs 10
c 0
b 0
f 0
1
<?php
2
3
namespace Drupal\mongodb_cache;
4
5
/*
6
 * This is the actual MongoDB cache backend.
7
 *
8
 * - It replaces the core cache backend file. See README.md for details.
9
 * - It cannot abide by PSR-1 "side-effects or symbols" rule because of the low
10
 *   level at which it operates, where the autoloader is not available.
11
 */
12
13
include_once __DIR__ . '/../mongodb.module';
14
15
/**
16
 * MongoDB cache implementation.
17
 *
18
 * This is Drupal's default cache implementation. It uses the MongoDB to store
19
 * cached data. Each cache bin corresponds to a collection by the same name.
20
 */
21
class Cache implements \DrupalCacheInterface {
22
  /**
23
   * The name of the collection holding the cache data.
24
   *
25
   * @var string
26
   */
27
  protected $bin;
28
29
  /**
30
   * A closure wrapping MongoBinData::__construct() with its default $type.
31
   *
32
   * @var \Closure
33
   */
34
  protected $binDataCreator;
35
36
  /**
37
   * The collection holding the cache data.
38
   *
39
   * @var \MongoCollection|\MongoDebugCollection|\MongodbDummy
40
   */
41
  protected $collection;
42
43
  /**
44
   * Has a connection exception already been notified ?
45
   *
46
   * @var bool
47
   *
48
   * @see \Drupal\mongodb_cache\Cache::notifyException()
49
   * @see \Drupal\mongodb_cache\Cache::hasException()
50
   *
51
   * This is a static, because the plugin assumes that connection errors will be
52
   * share between all bins, under the hypothesis that all bins will be using
53
   * the same connection.
54
   */
55
  protected static $isExceptionNotified = FALSE;
56
57
  /**
58
   * The default write options for this collection: unsafe mode.
59
   *
60
   * @var array
61
   *
62
   * @see self::__construct()
63
   */
64
  protected $unsafe;
65
66
  /**
67
   * The name of the state variable holding the latest bin expire timestamp.
68
   *
69
   * @var string
70
   */
71
  protected $flushVarName;
72
73
  /**
74
   * The number of seconds during which a new flush will be ignored.
75
   *
76
   * @var int
77
   *
78
   * @see self::__construct()
79
   */
80
  protected $stampedeDelay;
81
82
  /**
83
   * Constructor.
84
   *
85
   * @param string $bin
86
   *   The name of the cache bin for which to build a backend.
87
   *
88
   * @throws \MongoConnectionException
0 ignored issues
show
introduced by
Comment missing or not on the next line for @throws tag in function comment
Loading history...
89
   */
90
  public function __construct($bin) {
91
    $this->bin = $bin;
92
    try {
93
      $this->collection = mongodb_collection($bin);
94
    }
95
    catch (\MongoConnectionException $e) {
96
      static::notifyException($e);
97
      $this->collection = new \MongodbDummy();
98
    }
99
100
    // Default is FALSE: this is a cache, so a missed write is not an issue.
101
    $this->unsafe = mongodb_default_write_options(FALSE);
102
103
    $this->stampedeDelay = variable_get('mongodb_cache_stampede_delay', 5);
104
    $this->flushVarName = "flush_cache_{$bin}";
105
106
    $this->binDataCreator = $this->getBinDataCreator();
107
  }
108
109
  /**
110
   * Display an exception error message only once.
111
   *
112
   * @param \MongoException $e
113
   *   The exception to notify to the user.
114
   */
115
  protected static function notifyException(\MongoException $e) {
116
    if (!self::$isExceptionNotified) {
117
      drupal_set_message(t('MongoDB cache problem %exception.', [
118
        '%exception' => $e->getMessage(),
119
      ]), 'error');
120
      self::$isExceptionNotified = TRUE;
121
    }
122
  }
123
124
  /**
125
   * An alternate \MongoBinData constructor using default $type.
126
   *
127
   * @param mixed $data
128
   *   The data to convert to \MongoBinData.
129
   *
130
   * @return \Closure
131
   *   The alternate constructor with $type following the extension version.
132
   */
133
  protected function createBinData($data) {
134
    $creator = $this->binDataCreator;
135
    $result = $creator($data);
136
    return $result;
137
  }
138
139
  /**
140
   * Return the proper MongoBinData constructor with its type argument.
141
   *
142
   * The signature of \MongoBinData::__construct() changed in 1.2.11 to require
143
   * $type and default to BYTE_ARRAY, then again in 1.5.0 to default to GENERIC.
144
   *
145
   * @return \Closure
146
   *   A closure wrapping the constructor with its expected $type.
147
   */
148
  protected function getBinDataCreator() {
149
    $mongoVersion = phpversion('mongo');
150
    if (version_compare($mongoVersion, '1.2.11') < 0) {
151
      $result = function ($data) {
152
        return new \MongoBinData($data);
153
      };
154
    }
155
    else {
156
      $type = version_compare($mongoVersion, '1.5.0') < 0
157
        ? \MongoBinData::BYTE_ARRAY
158
        : \MongoBinData::GENERIC;
159
      $result = function ($data) use ($type) {
160
        return new \MongoBinData($data, $type);
161
      };
162
    }
163
164
    return $result;
165
  }
166
167
  /**
168
   * Return the timestamp of the latest flush.
169
   *
170
   * @return int
171
   *   A UNIX timestamp.
172
   */
173
  protected function getFlushTimestamp() {
174
    $result = intval(variable_get($this->flushVarName, 0));
175
    return $result;
176
  }
177
178
  /**
179
   * Record a timestamp as marking the latest flush for the current bin.
180
   *
181
   * As this performs a variable_set(), it is a costly operation.
182
   *
183
   * @param int $timestamp
184
   *   A UNIX timestamp. May be 0.
185
   */
186
  protected function setFlushTimestamp($timestamp) {
187
    variable_set($this->flushVarName, $timestamp);
188
  }
189
190
  /**
191
   * {@inheritdoc}
192
   */
193
  public function get($cid) {
194
    try {
195
      // Garbage collection necessary when enforcing a minimum cache lifetime.
196
      $this->garbageCollection();
197
198
      $cache = $this->collection->findOne(['_id' => (string) $cid]);
199
      $result = $this->prepareItem($cache);
200
    }
201
    catch (\MongoConnectionException $e) {
202
      self::notifyException($e);
203
      $result = FALSE;
204
    }
205
206
    return $result;
207
  }
208
209
  /**
210
   * {@inheritdoc}
211
   */
212
  public function getMultiple(&$cids) {
213
    $cache = [];
214
    try {
215
      // Garbage collection necessary when enforcing a minimum cache lifetime.
216
      $this->garbageCollection();
217
218
      $criteria = [
219
        '_id' => [
220
          '$in' => array_map('strval', $cids),
221
        ],
222
      ];
223
      $result = $this->collection->find($criteria);
224
225
      foreach ($result as $item) {
226
        $item = $this->prepareItem($item);
227
        if ($item) {
228
          $cache[$item->cid] = $item;
229
        }
230
      }
231
      $cids = array_diff($cids, array_keys($cache));
232
    }
233
    catch (\MongoConnectionException $e) {
234
      self::notifyException($e);
235
    }
236
237
    return $cache;
238
  }
239
240
  /**
241
   * Garbage collection for get() and getMultiple().
242
   *
243
   * @throws \MongoCursorException
0 ignored issues
show
introduced by
Comment missing or not on the next line for @throws tag in function comment
Loading history...
244
   * @throws \MongoCursorTimeoutException
245
   */
246
  protected function garbageCollection() {
247
    // Garbage collection only required when enforcing a minimum cache lifetime.
248
    $flush_timestamp = $this->getFlushTimestamp();
249
    if ($flush_timestamp && ($flush_timestamp + variable_get('cache_lifetime', 0) <= REQUEST_TIME)) {
250
      // Reset the variable immediately to prevent a meltdown under heavy load.
251
      $this->setFlushTimestamp(0);
252
253
      // Remove non-permanently cached items from the collection.
254
      $criteria = [
255
        'expire' => [
256
          '$lte' => $flush_timestamp,
257
          '$ne' => CACHE_PERMANENT,
258
        ],
259
      ];
260
      try {
261
        $this->collection->remove($criteria, $this->unsafe);
262
      }
263
      catch (\MongoConnectionException $e) {
264
        self::notifyException($e);
265
      }
266
267
      // Re-enable the expiration mechanism.
268
      $this->setFlushTimestamp(REQUEST_TIME + $this->stampedeDelay);
269
    }
270
  }
271
272
  /**
273
   * Prepare a cached item.
274
   *
275
   * Checks that items are either permanent not yet expired, and unserializes
276
   * data as appropriate.
277
   *
278
   * @param array|null $cache
279
   *   An item loaded from cache_get() or cache_get_multiple().
280
   *
281
   * @return false|object
282
   *   The item with data unserialized as appropriate or FALSE if there is no
283
   *   valid item to load.
284
   */
285
  protected function prepareItem($cache) {
1 ignored issue
show
Coding Style introduced by
prepareItem uses the super-global variable $_SESSION which is generally not recommended.

Instead of super-globals, we recommend to explicitly inject the dependencies of your class. This makes your code less dependent on global state and it becomes generally more testable:

// Bad
class Router
{
    public function generate($path)
    {
        return $_SERVER['HOST'].$path;
    }
}

// Better
class Router
{
    private $host;

    public function __construct($host)
    {
        $this->host = $host;
    }

    public function generate($path)
    {
        return $this->host.$path;
    }
}

class Controller
{
    public function myAction(Request $request)
    {
        // Instead of
        $page = isset($_GET['page']) ? intval($_GET['page']) : 1;

        // Better (assuming you use the Symfony2 request)
        $page = $request->query->get('page', 1);
    }
}
Loading history...
286
    if (!$cache || !isset($cache['data'])) {
287
      return FALSE;
288
    }
289
290
    unset($cache['_id']);
291
    $cache = (object) $cache;
292
293
    // If enforcing a minimum cache lifetime, validate that the data is
294
    // currently valid for this user before we return it by making sure the
295
    // cache entry was created before the timestamp in the current session's
296
    // cache timer. The cache variable is loaded into the $user object by
297
    // _drupal_session_read() in session.inc. If the data is permanent or we're
298
    // not enforcing a minimum cache lifetime always return the cached data.
299
    if ($cache->expire != CACHE_PERMANENT && variable_get('cache_lifetime', 0)
300
      && isset($_SESSION['cache_expiration'][$this->bin])
301
      && $_SESSION['cache_expiration'][$this->bin] > $cache->created) {
302
      // These cached data are too old and thus not valid for us, ignore it.
303
      return FALSE;
304
    }
305
    if ($cache->data instanceof \MongoBinData) {
306
      $cache->data = $cache->data->bin;
307
    }
308
    if ($cache->serialized) {
309
      $cache->data = unserialize($cache->data);
310
    }
311
312
    return $cache;
313
  }
314
315
  /**
316
   * {@inheritdoc}
317
   */
318
  public function set($cid, $data, $expire = CACHE_PERMANENT) {
319
    $scalar = is_scalar($data);
320
    $entry = array(
321
      '_id' => (string) $cid,
322
      'cid' => (string) $cid,
323
      'created' => REQUEST_TIME,
324
      'expire' => $expire,
325
      'serialized' => !$scalar,
326
      'data' => $scalar ? $data : serialize($data),
327
    );
328
329
    // Use MongoBinData for non-UTF8 strings.
330
    if (is_string($entry['data']) && !drupal_validate_utf8($entry['data'])) {
331
      $entry['data'] = $this->createBinData($entry['data']);
332
    }
333
334
    try {
335
      $this->collection->save($entry, $this->unsafe);
336
    }
337
    // Multiple possible exceptions on save(), not just connection-related.
338
    catch (\MongoException $e) {
339
      self::notifyException($e);
340
      // The database may not be available, so we'll ignore cache_set requests.
341
    }
342
  }
343
344
  /**
345
   * Attempt removing data from the collection, notifying on exceptions.
346
   *
347
   * @param array|null $criteria
348
   *   NULL means to remove all documents from the collection.
349
   *
350
   * @throws \MongoCursorException
0 ignored issues
show
introduced by
Comment missing or not on the next line for @throws tag in function comment
Loading history...
351
   * @throws \MongoCursorTimeoutException
352
   */
353
  protected function attemptRemove($criteria = NULL) {
354
    try {
355
      if ($criteria === []) {
356
        $this->collection->drop();
357
      }
358
      else {
359
        $this->collection->remove($criteria, $this->unsafe);
360
      }
361
    }
362
    catch (\MongoConnectionException $e) {
363
      self::notifyException($e);
364
    }
365
  }
366
367
  /**
368
   * {@inheritdoc}
369
   */
370
  public function clear($cid = NULL, $wildcard = FALSE) {
1 ignored issue
show
Coding Style introduced by
clear uses the super-global variable $_SESSION which is generally not recommended.

Instead of super-globals, we recommend to explicitly inject the dependencies of your class. This makes your code less dependent on global state and it becomes generally more testable:

// Bad
class Router
{
    public function generate($path)
    {
        return $_SERVER['HOST'].$path;
    }
}

// Better
class Router
{
    private $host;

    public function __construct($host)
    {
        $this->host = $host;
    }

    public function generate($path)
    {
        return $this->host.$path;
    }
}

class Controller
{
    public function myAction(Request $request)
    {
        // Instead of
        $page = isset($_GET['page']) ? intval($_GET['page']) : 1;

        // Better (assuming you use the Symfony2 request)
        $page = $request->query->get('page', 1);
    }
}
Loading history...
371
    $minimum_lifetime = variable_get('cache_lifetime', 0);
372
373
    if (empty($cid)) {
374
      if ($minimum_lifetime) {
375
        // We store the time in the current user's $user->cache variable which
376
        // will be saved into the sessions bin by _drupal_session_write(). We
377
        // then simulate that the cache was flushed for this user by not
378
        // returning cached data that was cached before the timestamp.
379
        $_SESSION['cache_expiration'][$this->bin] = REQUEST_TIME;
380
381
        $flush_timestamp = $this->getFlushTimestamp();
382
        if (empty($flush_timestamp)) {
383
          // This is the first request to clear the cache, start a timer.
384
          $this->setFlushTimestamp(REQUEST_TIME);
385
        }
386
        elseif (REQUEST_TIME > ($flush_timestamp + $minimum_lifetime)) {
387
          // Clear the cache for everyone, cache_lifetime seconds have passed
388
          // since the first request to clear the cache.
389
          $criteria = [
390
            'expire' => [
391
              '$ne' => CACHE_PERMANENT,
392
              '$lte' => REQUEST_TIME,
393
            ],
394
          ];
395
          $this->attemptRemove($criteria);
396
          $this->setFlushTimestamp(REQUEST_TIME + $this->stampedeDelay);
397
        }
398
      }
399
      else {
400
        // No minimum cache lifetime, flush all temporary cache entries now.
401
        $criteria = [
402
          'expire' => [
403
            '$ne' => CACHE_PERMANENT,
404
            '$lte' => REQUEST_TIME,
405
          ],
406
        ];
407
        $this->attemptRemove($criteria);
408
      }
409
    }
410
    else {
411
      if ($wildcard) {
412
        if ($cid == '*') {
413
          $criteria = [];
414
          $this->attemptRemove($criteria);
415
        }
416
        else {
417
          $criteria = [
418
            'cid' => new \MongoRegex('/' . preg_quote($cid) . '.*/'),
419
          ];
420
          $this->attemptRemove($criteria);
421
        }
422
      }
423
      elseif (is_array($cid)) {
424
        // Delete in chunks in case a large array is passed.
425
        do {
426
          $criteria = [
427
            'cid' => [
428
              '$in' => array_map('strval', array_splice($cid, 0, 1000)),
429
            ],
430
          ];
431
          $this->attemptRemove($criteria);
432
        } while (count($cid));
433
      }
434
      else {
435
        $criteria = [
436
          '_id' => (string) $cid,
437
        ];
438
        $this->attemptRemove($criteria);
439
      }
440
    }
441
  }
442
443
  /**
444
   * Has the plugin thrown an exception at any point ?
445
   *
446
   * @retun bool
447
   *   Has it ?
448
   *
449
   * @see mongodb_cache_exit()
450
   */
451
  public static function hasException() {
452
    return static::$isExceptionNotified;
453
  }
454
455
  /**
456
   * {@inheritdoc}
457
   */
458
  public function isEmpty() {
459
    try {
460
      // Faster than findOne().
461
      $result = !$this->collection->find([], ['_id' => 1])->limit(1)->next();
462
    }
463
    catch (\MongoConnectionException $e) {
464
      // An unreachable cache is as good as empty.
465
      $result = TRUE;
466
      self::notifyException($e);
467
    }
468
469
    return $result;
470
  }
471
472
}
473