Merge pull request #8095 from open-sausages/pulls/3/versioned-cache-segmentation

ENHANCEMENT: Add new CacheProxy to segment versioned state
This commit is contained in:
Damian Mooyman 2018-06-05 13:17:03 +12:00 committed by GitHub
commit 9f57576975
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
12 changed files with 293 additions and 22 deletions

14
cache/Cache.php vendored
View File

@ -146,7 +146,7 @@ class SS_Cache {
* @param string $frontend (optional) The type of Zend_Cache frontend * @param string $frontend (optional) The type of Zend_Cache frontend
* @param array $frontendOptions (optional) Any frontend options to use. * @param array $frontendOptions (optional) Any frontend options to use.
* *
* @return Zend_Cache_Core The cache object * @return CacheProxy|Zend_Cache_Core
*/ */
public static function factory($for, $frontend='Output', $frontendOptions=null) { public static function factory($for, $frontend='Output', $frontendOptions=null) {
self::init(); self::init();
@ -175,8 +175,8 @@ class SS_Cache {
$backend = self::$backends[$backend_name]; $backend = self::$backends[$backend_name];
$basicOptions = array( $basicOptions = array(
'cache_id_prefix' => md5(BASE_PATH) . '_' . $for . '_', 'cache_id_prefix' => md5(BASE_PATH) . '_' . $for . '_',
); );
if ($cache_lifetime >= 0) { if ($cache_lifetime >= 0) {
$basicOptions['lifetime'] = $cache_lifetime; $basicOptions['lifetime'] = $cache_lifetime;
@ -188,8 +188,14 @@ class SS_Cache {
require_once 'Zend/Cache.php'; require_once 'Zend/Cache.php';
return Zend_Cache::factory( $cache = Zend_Cache::factory(
$frontend, $backend[0], $frontendOptions, $backend[1] $frontend, $backend[0], $frontendOptions, $backend[1]
); );
if (!empty($frontendOptions['disable-segmentation'])) {
return $cache;
}
return Injector::inst()->createWithArgs('CacheProxy', array($cache));
} }
} }

123
cache/CacheProxy.php vendored Normal file
View File

@ -0,0 +1,123 @@
<?php
require_once 'Zend/Cache.php';
/**
* A decorator for a Zend_Cache_Backend cache service that mutates cache keys
* dynamically depending on versioned state
*/
class CacheProxy extends Zend_Cache_Core {
/**
* @var Zend_Cache_Backend|Zend_Cache_Backend_ExtendedInterface
*/
protected $cache;
/**
* CacheProxy constructor.
* @param Zend_Cache_Core $cache
*/
public function __construct(Zend_Cache_Core $cache) {
$this->cache = $cache;
parent::__construct();
}
public function setDirectives($directives) {
$this->cache->setDirectives($directives);
}
public function setConfig(Zend_Config $config) {
return $this->cache->setConfig($config);
}
public function setBackend(Zend_Cache_Backend $backendObject) {
return $this->cache->setBackend($backendObject);
}
public function getBackend() {
return $this->cache->getBackend();
}
public function setOption($name, $value) {
$this->cache->setOption($name, $value);
}
public function getOption($name) {
return $this->cache->getOption($name);
}
public function setLifetime($newLifetime) {
return $this->cache->setLifetime($newLifetime);
}
public function getIds() {
return $this->cache->getIds();
}
public function getTags() {
return $this->cache->getTags();
}
public function getIdsMatchingTags($tags = array()) {
return $this->cache->getIdsMatchingTags($tags);
}
public function getIdsNotMatchingTags($tags = array()) {
return $this->cache->getIdsNotMatchingTags($tags);
}
public function getIdsMatchingAnyTags($tags = array()) {
return $this->cache->getIdsMatchingAnyTags($tags);
}
public function getFillingPercentage() {
return $this->cache->getFillingPercentage();
}
public function getMetadatas($id) {
return $this->cache->getMetadatas($this->getKeyID($id));
}
public function touch($id, $extraLifetime) {
return $this->cache->touch($this->getKeyID($id), $extraLifetime);
}
public function load($id, $doNotTestCacheValidity = false, $doNotUnserialize = false) {
return $this->cache->load($this->getKeyID($id), $doNotTestCacheValidity, $doNotUnserialize);
}
public function test($id) {
return $this->cache->test($this->getKeyID($id));
}
public function save($data, $id = null, $tags = array(), $specificLifetime = false, $priority = 8) {
return $this->cache->save(
$data,
$this->getKeyID($id),
$tags,
$specificLifetime,
$priority
);
}
public function remove($id) {
return $this->cache->remove($this->getKeyID($id));
}
public function clean($mode = Zend_Cache::CLEANING_MODE_ALL, $tags = array()) {
return $this->cache->clean($mode, $tags);
}
/**
* Creates a dynamic key based on versioned state
* @param string $key
* @return string
*/
protected function getKeyID($key) {
$state = Versioned::get_reading_mode();
if ($state) {
return $key . '_' . md5($state);
}
return $key;
}
}

View File

@ -110,7 +110,7 @@ class SS_ConfigManifest {
/** /**
* Provides a hook for mock unit tests despite no DI * Provides a hook for mock unit tests despite no DI
* @return Zend_Cache_Frontend * @return Zend_Cache_Core
*/ */
protected function getCache() protected function getCache()
{ {
@ -118,6 +118,7 @@ class SS_ConfigManifest {
'automatic_serialization' => true, 'automatic_serialization' => true,
'lifetime' => null, 'lifetime' => null,
'cache_id_prefix' => 'SS_Configuration_', 'cache_id_prefix' => 'SS_Configuration_',
'disable-segmentation' => true,
)); ));
} }

View File

@ -83,7 +83,11 @@ class SilverStripeVersionProvider
$lockData = array(); $lockData = array();
if ($cache) { if ($cache) {
$cache = SS_Cache::factory('SilverStripeVersionProvider_composerlock'); $cache = SS_Cache::factory(
'SilverStripeVersionProvider_composerlock',
'Output',
array('disable-segmentation' => true)
);
$cacheKey = filemtime($composerLockPath); $cacheKey = filemtime($composerLockPath);
if ($versions = $cache->load($cacheKey)) { if ($versions = $cache->load($cacheKey)) {
$lockData = json_decode($versions, true); $lockData = json_decode($versions, true);

View File

@ -90,6 +90,20 @@ e.g. by including the `LastEdited` value when caching `DataObject` results.
// set all caches to 3 hours // set all caches to 3 hours
SS_Cache::set_cache_lifetime('any', 60*60*3); SS_Cache::set_cache_lifetime('any', 60*60*3);
### Versioned cache segmentation
`SS_Cache` segments caches based on the versioned reading mode. This prevents developers
from caching draft data and then accidentally exposing it on the live stage without potentially
required authorisation checks. This segmentation is automatic for all caches generated using
`SS_Cache::factory` method.
Data that is not content sensitive can be cached across stages by simply opting out of the
segmented cache with the `disable-segmentation` argument.
```php
$cache = SS_Cache::factory('myapp', 'Output', array('disable-segmentation' => true));
```
## Alternative Cache Backends ## Alternative Cache Backends
By default, SilverStripe uses a file-based caching backend. By default, SilverStripe uses a file-based caching backend.

View File

@ -0,0 +1,26 @@
# 3.7.0
### Versioned cache segmentation
`SS_Cache` now maintains separate cache pools for each versioned stage. This prevents developers from caching draft data and then accidentally exposing it on the live stage without potentially required authorisation checks. Unless you rely on caching across stages, you don't need to change your own code for this change to take effect. Note that cache keys will be internally rewritten, causing any existing cache items to become invalid when this change is deployed.
```php
// Before:
$cache = SS_Cache::factory('myapp');
Versioned::set_reading_mode('Stage.Stage');
$cache->save('Some draft content. Not for public viewing yet.', 'my_key');
Versioned::set_reading_mode('Stage.Live');
$cache->load('my_key'); // 'Some draft content. Not for public viewing yet'
// After:
$cache = SS_Cache::factory('myapp');
Versioned::set_reading_mode('Stage.Stage');
$cache->save('Some draft content. Not for public viewing yet.', 'my_key');
Versioned::set_reading_mode('Stage.Live');
$cache->load('my_key'); // null
```
Data that is not content sensitive can be cached across stages by simply opting out of the segmented cache with the `disable-segmentation` argument.
```php
$cache = SS_Cache::factory('myapp', 'Output', array('disable-segmentation' => true));
```

View File

@ -39,7 +39,7 @@ class GDBackend extends SS_Object implements Image_Backend {
// If we're working with image resampling, things could take a while. Bump up the time-limit // If we're working with image resampling, things could take a while. Bump up the time-limit
increase_time_limit_to(300); increase_time_limit_to(300);
$this->cache = SS_Cache::factory('GDBackend_Manipulations'); $this->cache = SS_Cache::factory('GDBackend_Manipulations', 'Output', array('disable-segmentation' => true));
if($filename && is_readable($filename)) { if($filename && is_readable($filename)) {
$this->cacheKey = md5(implode('_', array($filename, filemtime($filename)))); $this->cacheKey = md5(implode('_', array($filename, filemtime($filename))));

View File

@ -118,10 +118,18 @@ class i18n extends SS_Object implements TemplateGlobalProvider, Flushable {
/** /**
* Return an instance of the cache used for i18n data. * Return an instance of the cache used for i18n data.
* @return Zend_Cache * @return Zend_Cache_Core
*/ */
public static function get_cache() { public static function get_cache() {
return SS_Cache::factory('i18n', 'Output', array('lifetime' => null, 'automatic_serialization' => true)); return SS_Cache::factory(
'i18n',
'Output',
array(
'lifetime' => null,
'automatic_serialization' => true,
'disable-segmentation' => true,
)
);
} }
/** /**

View File

@ -35,7 +35,7 @@ class CleanImageManipulationCache extends BuildTask {
$images = DataObject::get('Image'); $images = DataObject::get('Image');
if($images && Image::get_backend() == "GDBackend") { if($images && Image::get_backend() == "GDBackend") {
$cache = SS_Cache::factory('GDBackend_Manipulations'); $cache = SS_Cache::factory('GDBackend_Manipulations', 'Output', array('disable-segmentation' => true));
foreach($images as $image) { foreach($images as $image) {
$path = $image->getFullPath(); $path = $image->getFullPath();

View File

@ -2,7 +2,12 @@
class CacheTest extends SapphireTest { class CacheTest extends SapphireTest {
public function testCacheBasics() { public function setUpOnce() {
parent::setUpOnce();
Versioned::set_reading_mode('Stage.Live');
}
public function testCacheBasics() {
$cache = SS_Cache::factory('test'); $cache = SS_Cache::factory('test');
$cache->save('Good', 'cachekey'); $cache->save('Good', 'cachekey');
@ -64,5 +69,73 @@ class CacheTest extends SapphireTest {
$this->assertEquals(1200, $cache->getOption('lifetime')); $this->assertEquals(1200, $cache->getOption('lifetime'));
} }
public function testVersionedCacheSegmentation() {
$cacheInstance = SS_Cache::factory('versioned');
$cacheInstance->clean();
Versioned::set_reading_mode('Stage.Live');
$result = $cacheInstance->load('shared_key');
$this->assertFalse($result);
$cacheInstance->save('uncle', 'shared_key');
// Shared key is cached on LIVE
$this->assertEquals('uncle', $cacheInstance->load('shared_key'));
Versioned::set_reading_mode('Stage.Stage');
// Shared key does not exist on STAGE
$this->assertFalse($cacheInstance->load('shared_key'));
$cacheInstance->save('cheese', 'shared_key');
$cacheInstance->save('bar', 'stage_key');
// Shared key has its own value on STAGE
$this->assertEquals('cheese', $cacheInstance->load('shared_key'));
// New key is cached on STAGE
$this->assertEquals('bar', $cacheInstance->load('stage_key'));
Versioned::set_reading_mode('Stage.Live');
// New key does not exist on LIVE
$this->assertFalse($cacheInstance->load('stage_key'));
// Shared key retains its own value on LIVE
$this->assertEquals('uncle', $cacheInstance->load('shared_key'));
$cacheInstance->clean();
}
public function testDisableVersionedCacheSegmentation() {
$cacheInstance = SS_Cache::factory('versioned_disabled', 'Output', array('disable-segmentation' => true));
$cacheInstance->clean();
Versioned::set_reading_mode('Stage.Live');
$result = $cacheInstance->load('shared_key');
$this->assertFalse($result);
$cacheInstance->save('uncle', 'shared_key');
// Shared key is cached on LIVE
$this->assertEquals('uncle', $cacheInstance->load('shared_key'));
Versioned::set_reading_mode('Stage.Stage');
// Shared key is same on STAGE
$this->assertEquals('uncle', $cacheInstance->load('shared_key'));
$cacheInstance->save('cheese', 'shared_key');
$cacheInstance->save('bar', 'stage_key');
// Shared key is overwritten on STAGE
$this->assertEquals('cheese', $cacheInstance->load('shared_key'));
// New key is written on STAGE
$this->assertEquals('bar', $cacheInstance->load('stage_key'));
Versioned::set_reading_mode('Stage.Live');
// New key has same value on LIVE
$this->assertEquals('bar', $cacheInstance->load('stage_key'));
// New value for existing key is same on LIVE
$this->assertEquals('cheese', $cacheInstance->load('shared_key'));
$cacheInstance->clean();
}
} }

View File

@ -111,13 +111,21 @@ class DataObjectDuplicationTest extends SapphireTest {
"Match between relation of copy and the original"); "Match between relation of copy and the original");
$this->assertEquals(0, $oneCopy->twos()->Count(), $this->assertEquals(0, $oneCopy->twos()->Count(),
"Many-to-one relation not copied (has_many)"); "Many-to-one relation not copied (has_many)");
$this->assertEquals($three->ID, $oneCopy->threes()->First()->ID, $this->assertContains(
"Match between relation of copy and the original"); $three->ID,
$this->assertEquals($one->ID, $threeCopy->ones()->First()->ID, $oneCopy->threes()->column('ID'),
"Match between relation of copy and the original"); "Match between relation of copy and the original"
);
$this->assertEquals('three', $oneCopy->threes()->First()->TestExtra, $this->assertContains(
"Match between extra field of copy and the original"); $one->ID,
$threeCopy->ones()->column('ID'),
"Match between relation of copy and the original"
);
$this->assertContains(
'three',
$oneCopy->threes()->column('TestExtra'),
"Match between extra field of copy and the original"
);
} }
} }

View File

@ -1016,6 +1016,14 @@ class SSViewer implements Flushable {
} }
} }
/**
* @return Zend_Cache_Core
*/
protected static function defaultPartialCacheStore()
{
return SS_Cache::factory('cacheblock', 'Output', array('disable-segmentation' => true));
}
/** /**
* Call this to disable rewriting of <a href="#xxx"> links. This is useful in Ajax applications. * Call this to disable rewriting of <a href="#xxx"> links. This is useful in Ajax applications.
* It returns the SSViewer objects, so that you can call new SSViewer("X")->dontRewriteHashlinks()->process(); * It returns the SSViewer objects, so that you can call new SSViewer("X")->dontRewriteHashlinks()->process();
@ -1082,7 +1090,7 @@ class SSViewer implements Flushable {
*/ */
public static function flush_cacheblock_cache($force = false) { public static function flush_cacheblock_cache($force = false) {
if (!self::$cacheblock_cache_flushed || $force) { if (!self::$cacheblock_cache_flushed || $force) {
$cache = SS_Cache::factory('cacheblock'); $cache = self::defaultPartialCacheStore();
$backend = $cache->getBackend(); $backend = $cache->getBackend();
if( if(
@ -1120,7 +1128,7 @@ class SSViewer implements Flushable {
* @return Zend_Cache_Core * @return Zend_Cache_Core
*/ */
public function getPartialCacheStore() { public function getPartialCacheStore() {
return $this->partialCacheStore ? $this->partialCacheStore : SS_Cache::factory('cacheblock'); return $this->partialCacheStore ? $this->partialCacheStore : self::defaultPartialCacheStore();
} }
/** /**