Merge "ParserOutputAccess: cache ouput for old revisions"

This commit is contained in:
jenkins-bot 2020-11-18 19:52:10 +00:00 committed by Gerrit Code Review
commit 2be635539f
9 changed files with 512 additions and 51 deletions

View file

@ -20,7 +20,6 @@ Some specific notes for MediaWiki 1.36 upgrades are below:
For notes on 1.35.x and older releases, see HISTORY.
=== Configuration changes for system administrators in 1.36 ===
==== New configuration ====
@ -29,6 +28,8 @@ For notes on 1.35.x and older releases, see HISTORY.
configuration variable sets the maximum number of revisions of a page that
will be checked against every new edit. Set this to 0 to disable the feature
entirely.
* $wgOldRevisionParserCacheExpireTime was added, to control caching of
ParserOutput for old revisions (T244058).
* …
==== Changed configuration ====

View file

@ -1261,6 +1261,7 @@ $wgAutoloadLocalClasses = [
'PoolCounterWorkViaCallback' => __DIR__ . '/includes/poolcounter/PoolCounterWorkViaCallback.php',
'PoolWorkArticleView' => __DIR__ . '/includes/poolcounter/PoolWorkArticleView.php',
'PoolWorkArticleViewCurrent' => __DIR__ . '/includes/poolcounter/PoolWorkArticleViewCurrent.php',
'PoolWorkArticleViewOld' => __DIR__ . '/includes/poolcounter/PoolWorkArticleViewOld.php',
'PopulateArchiveRevId' => __DIR__ . '/maintenance/populateArchiveRevId.php',
'PopulateBacklinkNamespace' => __DIR__ . '/maintenance/populateBacklinkNamespace.php',
'PopulateCategory' => __DIR__ . '/maintenance/populateCategory.php',

View file

@ -2652,6 +2652,12 @@ $wgMainStash = 'db-replicated';
*/
$wgParserCacheExpireTime = 86400;
/**
* The expiry time for the parser cache for old revisions, in seconds.
* The default is 3600 (cache disabled).
*/
$wgOldRevisionParserCacheExpireTime = 60 * 60;
/**
* The expiry time to use for session storage, in seconds.
*/

View file

@ -947,9 +947,12 @@ return [
'ParserOutputAccess' => function ( MediaWikiServices $services ) : ParserOutputAccess {
return new ParserOutputAccess(
$services->getParserCache(),
ObjectCache::getLocalClusterInstance(),
$services->getMainConfig()->get( 'OldRevisionParserCacheExpireTime' ),
$services->getRevisionRenderer(),
$services->getStatsdDataFactory(),
$services->getDBLoadBalancerFactory()
$services->getDBLoadBalancerFactory(),
$services->getJsonUnserializer()
);
},

View file

@ -58,6 +58,12 @@ class JsonUnserializer {
$json = (array)$json;
}
if ( !is_array( $json ) ) {
throw new InvalidArgumentException(
'Expected array, got ' . gettype( $json )
);
}
if ( !$this->canMakeNewFromValue( $json ) ) {
throw new InvalidArgumentException( 'JSON did not have ' . self::TYPE_ANNOTATION );
}

View file

@ -21,8 +21,10 @@
*/
namespace MediaWiki\Page;
use BagOStuff;
use IBufferingStatsdDataFactory;
use InvalidArgumentException;
use MediaWiki\Json\JsonUnserializer;
use MediaWiki\Revision\RevisionRecord;
use MediaWiki\Revision\RevisionRenderer;
use ParserCache;
@ -30,6 +32,7 @@ use ParserOptions;
use ParserOutput;
use PoolWorkArticleView;
use PoolWorkArticleViewCurrent;
use PoolWorkArticleViewOld;
use Status;
use Wikimedia\Rdbms\ILBFactory;
use WikiPage;
@ -72,9 +75,26 @@ class ParserOutputAccess {
*/
public const OPT_NO_CACHE = self::OPT_NO_UPDATE_CACHE | self::OPT_NO_CHECK_CACHE;
/** @var string Do not read or write any cache */
private const CACHE_NONE = 'none';
/** @var string Use primary cache */
private const CACHE_PRIMARY = 'primary';
/** @var string Use secondary cache */
private const CACHE_SECONDARY = 'secondary';
/** @var ParserCache */
private $primaryCache;
/**
* @var BagOStuff
*/
private $secondaryCache;
/** @var int */
private $secondaryCacheExpiry;
/** @var RevisionRenderer */
private $revisionRenderer;
@ -84,22 +104,34 @@ class ParserOutputAccess {
/** @var ILBFactory */
private $lbFactory;
/** @var JsonUnserializer */
private $jsonUnserializer;
/**
* @param ParserCache $primaryCache
* @param BagOStuff $secondaryCache
* @param int $secondaryCacheExpiry
* @param RevisionRenderer $revisionRenderer
* @param IBufferingStatsdDataFactory $statsDataFactory
* @param ILBFactory $lbFactory
* @param JsonUnserializer $jsonUnserializer
*/
public function __construct(
ParserCache $primaryCache,
BagOStuff $secondaryCache,
int $secondaryCacheExpiry,
RevisionRenderer $revisionRenderer,
IBufferingStatsdDataFactory $statsDataFactory,
ILBFactory $lbFactory
ILBFactory $lbFactory,
JsonUnserializer $jsonUnserializer
) {
$this->primaryCache = $primaryCache;
$this->secondaryCache = $secondaryCache;
$this->secondaryCacheExpiry = $secondaryCacheExpiry;
$this->revisionRenderer = $revisionRenderer;
$this->statsDataFactory = $statsDataFactory;
$this->lbFactory = $lbFactory;
$this->jsonUnserializer = $jsonUnserializer;
}
/**
@ -109,7 +141,7 @@ class ParserOutputAccess {
* @param ParserOptions $parserOptions ParserOptions to check
* @param RevisionRecord|null $rev
*
* @return bool
* @return string One of the CACHE_XXX constants.
*/
private function shouldUseCache(
WikiPage $page,
@ -118,17 +150,34 @@ class ParserOutputAccess {
) {
if ( $rev && !$rev->getId() ) {
// The revision isn't from the database, so the output can't safely be cached.
return false;
return self::CACHE_NONE;
}
// NOTE: Keep in sync with ParserWikiPage::shouldCheckParserCache().
// NOTE: when we allow caching of old revisions in the future,
// we must not allow caching of deleted revisions.
$oldId = $rev ? $rev->getId() : 0;
return $parserOptions->getStubThreshold() == 0
&& $page->exists()
&& ( $oldId === null || $oldId === 0 || $oldId === $page->getLatest() )
&& $page->getContentHandler()->isParserCacheSupported();
if ( $parserOptions->getStubThreshold() !== 0
|| !$page->exists()
|| !$page->getContentHandler()->isParserCacheSupported()
) {
return self::CACHE_NONE;
}
if ( !$rev || $rev->getId() === $page->getLatest() ) {
// current revision
return self::CACHE_PRIMARY;
}
if ( !$rev->audienceCan( RevisionRecord::DELETED_TEXT, RevisionRecord::FOR_PUBLIC ) ) {
// deleted/suppressed revision
return self::CACHE_NONE;
}
if ( $this->secondaryCacheExpiry > 0 ) {
return self::CACHE_SECONDARY;
}
return self::CACHE_NONE;
}
/**
@ -152,11 +201,17 @@ class ParserOutputAccess {
return null;
}
if ( !$this->shouldUseCache( $page, $parserOptions, $revision ) ) {
return null;
}
$useCache = $this->shouldUseCache( $page, $parserOptions, $revision );
$output = $this->primaryCache->get( $page, $parserOptions );
if ( $useCache === self::CACHE_PRIMARY ) {
$output = $this->primaryCache->get( $page, $parserOptions );
} elseif ( $useCache === self::CACHE_SECONDARY ) {
$cacheKey = $this->getSecondaryCacheKey( $parserOptions, $revision );
$json = $this->secondaryCache->get( $cacheKey );
$output = $json ? $this->jsonUnserializer->unserialize( $json ) : null;
} else {
$output = null;
}
return $output ?: null; // convert false to null
}
@ -295,6 +350,18 @@ class ParserOutputAccess {
return null;
}
private function getSecondaryCacheKey(
ParserOptions $parserOptions,
?RevisionRecord $revision
) {
// NOTE: For now, split the cache on all options. Eventually, we may implement a
// two-tiered system like in ParserCache, or generalize ParserCache itself
// to cover old revisions.
$revId = $revision ? $revision->getId() : 0;
$hash = $parserOptions->optionsHash( ParserOptions::allCacheVaryingOptions() );
return $this->secondaryCache->makeKey( __CLASS__, $hash, self::CACHE_SECONDARY, $revId );
}
/**
* @param WikiPage $page
* @param ParserOptions $parserOptions
@ -310,40 +377,53 @@ class ParserOutputAccess {
int $options
): PoolWorkArticleView {
if ( $options & self::OPT_NO_UPDATE_CACHE ) {
$useCache = false;
$useCache = self::CACHE_NONE;
} else {
$useCache = $this->shouldUseCache( $page, $parserOptions, $revision );
}
$parserCacheMetadata = $this->primaryCache->getMetadata( $page );
$cacheKey = $this->primaryCache->makeParserOutputKey( $page, $parserOptions,
$parserCacheMetadata ? $parserCacheMetadata->getUsedOptions() : null
);
switch ( $useCache ) {
case self::CACHE_PRIMARY:
$parserCacheMetadata = $this->primaryCache->getMetadata( $page );
$cacheKey = $this->primaryCache->makeParserOutputKey( $page, $parserOptions,
$parserCacheMetadata ? $parserCacheMetadata->getUsedOptions() : null
);
$workKey = $cacheKey . ':revid:' . $revision->getId();
$workKey = $cacheKey . ':revid:' . $revision->getId();
if ( $useCache ) {
$workKey .= ':current';
$work = new PoolWorkArticleViewCurrent(
$workKey,
$page,
$revision,
$parserOptions,
$this->revisionRenderer,
$this->primaryCache,
$this->lbFactory
);
} else {
$workKey .= ':uncached';
$work = new PoolWorkArticleView(
$workKey,
$revision,
$parserOptions,
$this->revisionRenderer
);
return new PoolWorkArticleViewCurrent(
$workKey,
$page,
$revision,
$parserOptions,
$this->revisionRenderer,
$this->primaryCache,
$this->lbFactory
);
case $useCache == self::CACHE_SECONDARY:
$cacheKey = $this->getSecondaryCacheKey( $parserOptions, $revision );
return new PoolWorkArticleViewOld(
$cacheKey,
$this->secondaryCacheExpiry,
$this->secondaryCache,
$revision,
$parserOptions,
$this->revisionRenderer,
$this->jsonUnserializer
);
default:
$workKey = $this->getSecondaryCacheKey( $parserOptions, $revision ) . ':uncached';
return new PoolWorkArticleView(
$workKey,
$revision,
$parserOptions,
$this->revisionRenderer
);
}
return $work;
// unreachable
}
}

View file

@ -0,0 +1,171 @@
<?php
/**
* This program is free software; you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation; either version 2 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License along
* with this program; if not, write to the Free Software Foundation, Inc.,
* 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
* http://www.gnu.org/copyleft/gpl.html
*
* @file
*/
use MediaWiki\Json\JsonUnserializer;
use MediaWiki\Logger\LoggerFactory;
use MediaWiki\Revision\RevisionRecord;
use MediaWiki\Revision\RevisionRenderer;
use Psr\Log\LoggerInterface;
use Wikimedia\Assert\Assert;
/**
* PoolWorkArticleView for an old revision of a page, using a simple cache.
*
* @internal
*/
class PoolWorkArticleViewOld extends PoolWorkArticleView {
/** @var int */
private $cacheExpiry;
/** @var BagOStuff */
private $cache;
/** @var string */
private $cacheKey;
/** @var JsonUnserializer */
private $jsonUnserializer;
/** @var LoggerInterface */
private $logger;
/**
* @param string $cacheKey Key for the ParserOutput to use in $cache.
* Also used as the PoolCounter key.
* @param int $cacheExpiry Expiry for ParserOutput in $cache.
* @param BagOStuff $cache The cache to store ParserOutput in.
* @param RevisionRecord $revision Revision to render
* @param ParserOptions $parserOptions ParserOptions to use for the parse
* @param RevisionRenderer $revisionRenderer
* @param JsonUnserializer $jsonUnserializer
*/
public function __construct(
string $cacheKey,
int $cacheExpiry,
BagOStuff $cache,
RevisionRecord $revision,
ParserOptions $parserOptions,
RevisionRenderer $revisionRenderer,
JsonUnserializer $jsonUnserializer
) {
Assert::parameter( $cacheExpiry > 0, '$cacheExpiry', 'must be greater than zero' );
parent::__construct( $cacheKey, $revision, $parserOptions, $revisionRenderer );
$this->cacheKey = $cacheKey;
$this->cacheExpiry = $cacheExpiry;
$this->cache = $cache;
$this->jsonUnserializer = $jsonUnserializer;
// TODO: inject logger into all PoolWorkArticleView subclasses via ParserOutputAccess
$this->logger = LoggerFactory::getInstance( 'PoolWorkArticleView' );
$this->cacheable = true;
}
/**
* @param ParserOutput $output
* @param string $cacheTime
*/
protected function saveInCache( ParserOutput $output, string $cacheTime ) {
$json = $this->encodeAsJson( $output );
if ( $json === null ) {
return;
}
$this->cache->set( $this->cacheKey, $json, $this->cacheExpiry );
}
/**
* @return bool
*/
public function getCachedWork() {
$json = $this->cache->get( $this->cacheKey );
if ( $json === false ) {
$this->logger->debug( __METHOD__ . ": output cache miss" );
return false;
} else {
$this->logger->debug( __METHOD__ . ": output cache hit" );
}
$output = $this->restoreFromJson( $json );
// Note: if $output is null, $this->parserOutput remains false, not null.
if ( $output === null ) {
return false;
}
$this->parserOutput = $output;
return true;
}
/**
* @param string $json
*
* @return ParserOutput|null
*/
private function restoreFromJson( string $json ) {
try {
/** @var ParserOutput $obj */
$obj = $this->jsonUnserializer->unserialize( $json, ParserOutput::class );
return $obj;
} catch ( InvalidArgumentException $e ) {
$this->logger->error( "Unable to unserialize JSON", [
'cache_key' => $this->cacheKey,
'message' => $e->getMessage()
] );
return null;
}
}
/**
* @param ParserOutput $output
*
* @return string|null
*/
private function encodeAsJson( ParserOutput $output ) {
$data = $output->jsonSerialize();
$json = FormatJson::encode( $data, false, FormatJson::ALL_OK );
if ( !$json ) {
$this->logger->error( "JSON encoding failed", [
'cache_key' => $this->cacheKey,
'json_error' => json_last_error(),
] );
return null;
}
// Detect if the array contained any properties non-serializable
// to json. We will not be able to deserialize the value correctly
// anyway, so return null. This is done after calling FormatJson::encode
// to avoid walking over circular structures.
$unserializablePath = FormatJson::detectNonSerializableData( $data, true );
if ( $unserializablePath ) {
$this->logger->error( 'Non-serializable {class} property set', [
'class' => get_class( $output ),
'cache_key' => $this->cacheKey,
'path' => $unserializablePath,
] );
return null;
}
return $json;
}
}

View file

@ -4,6 +4,7 @@ use MediaWiki\Json\JsonUnserializer;
use MediaWiki\Page\ParserOutputAccess;
use MediaWiki\Revision\MutableRevisionRecord;
use MediaWiki\Revision\RevisionRecord;
use MediaWiki\Revision\RevisionRenderer;
use MediaWiki\Revision\SlotRecord;
use MediaWiki\Storage\RevisionStore;
use PHPUnit\Framework\MockObject\MockObject;
@ -24,7 +25,9 @@ class ParserOutputAccessTest extends MediaWikiIntegrationTestCase {
$value = $value->getText();
}
$html = trim( preg_replace( '/<!--.*?-->/s', '', $value ) );
$html = preg_replace( '/<!--.*?-->/s', '', $value );
$html = trim( preg_replace( '/[\r\n]{2,}/s', "\n", $html ) );
$html = trim( preg_replace( '/\s{2,}/s', ' ', $html ) );
return $html;
}
@ -76,18 +79,52 @@ class ParserOutputAccessTest extends MediaWikiIntegrationTestCase {
/**
* @param ParserCache|null $parserCache
* @param BagOStuff|null $secondaryCache
* @param int $secondaryExpiry
*
* @param int|bool $maxRenderCalls
*
* @return ParserOutputAccess
* @throws Exception
*/
private function getParserOutputAccessWithCache( $parserCache = null ) {
private function getParserOutputAccessWithCache(
$parserCache = null,
$secondaryCache = null,
$secondaryExpiry = 3600,
$maxRenderCalls = false
) {
if ( !$parserCache ) {
$parserCache = $this->getParserCache( new HashBagOStuff() );
}
if ( !$secondaryCache ) {
$secondaryCache = new HashBagOStuff();
}
$revRenderer = $this->getServiceContainer()->getRevisionRenderer();
$lbFactory = $this->getServiceContainer()->getDBLoadBalancerFactory();
$stats = new NullStatsdDataFactory();
return new ParserOutputAccess( $parserCache, $revRenderer, $stats, $lbFactory );
$jsonUnserializer = new JsonUnserializer();
if ( $maxRenderCalls ) {
$realRevRenderer = $revRenderer;
$revRenderer =
$this->createNoOpMock( RevisionRenderer::class, [ 'getRenderedRevision' ] );
$revRenderer->expects( $this->atMost( $maxRenderCalls ) )
->method( 'getRenderedRevision' )
->willReturnCallback( [ $realRevRenderer, 'getRenderedRevision' ] );
}
return new ParserOutputAccess(
$parserCache,
$secondaryCache,
$secondaryExpiry,
$revRenderer,
$stats,
$lbFactory,
$jsonUnserializer
);
}
/**
@ -95,7 +132,7 @@ class ParserOutputAccessTest extends MediaWikiIntegrationTestCase {
*/
private function getParserOutputAccessNoCache() {
$cache = $this->getParserCache( new EmptyBagOStuff() );
return $this->getParserOutputAccessWithCache( $cache );
return $this->getParserOutputAccessWithCache( $cache, new EmptyBagOStuff() );
}
/**
@ -139,18 +176,18 @@ class ParserOutputAccessTest extends MediaWikiIntegrationTestCase {
* Tests that cached output in the ParserCache will be used for the latest revision.
*/
public function testLatestRevisionUseCached() {
$parserCache = $this->getParserCache( new HashBagOStuff() );
$access = $this->getParserOutputAccessWithCache( $parserCache );
// Allow only one render call, use default caches
$access = $this->getParserOutputAccessWithCache( null, null, 3600, 1 );
$parserOptions = $this->getParserOptions();
$page = $this->getNonexistingTestPage( __METHOD__ );
$this->editPage( $page, 'Hello \'\'World\'\'!' );
$expectedOutput = new ParserOutput( 'Cached Text' );
$parserCache->save( $expectedOutput, $page, $parserOptions );
$access->getParserOutput( $page, $parserOptions );
// The second call should use cached output
$status = $access->getParserOutput( $page, $parserOptions );
$this->assertSameHtml( $expectedOutput, $status );
$this->assertContainsHtml( 'Hello <i>World</i>!', $status );
}
/**
@ -400,14 +437,87 @@ class ParserOutputAccessTest extends MediaWikiIntegrationTestCase {
* Tests that output for an old revision is fetched from the secondary parser cache if possible.
*/
public function testOldRevisionUseCached() {
$this->markTestSkipped( 'Caching not yet implemented for old revisions' );
// Allow only one render call, use default caches
$access = $this->getParserOutputAccessWithCache( null, null, 3600, 1 );
$parserOptions = $this->getParserOptions();
$page = $this->getNonexistingTestPage( __METHOD__ );
$this->editPage( $page, 'First' );
$oldRev = $page->getRevisionRecord();
$this->editPage( $page, 'Second' );
$firstStatus = $access->getParserOutput( $page, $parserOptions, $oldRev );
// The second call should use cached output
$secondStatus = $access->getParserOutput( $page, $parserOptions, $oldRev );
$this->assertSameHtml( $firstStatus, $secondStatus );
}
/**
* Tests that output for an old revision is fetched from the secondary parser cache if possible.
*/
public function testOldRevisionDisableCached() {
// Use default caches, but expiry 0 for the secondary cache
$access = $this->getParserOutputAccessWithCache( null, null, 0 );
$parserOptions = $this->getParserOptions();
$page = $this->getNonexistingTestPage( __METHOD__ );
$this->editPage( $page, 'First' );
$oldRev = $page->getRevisionRecord();
$this->editPage( $page, 'Second' );
$access->getParserOutput( $page, $parserOptions, $oldRev );
// Should not be cached!
$cachedOutput = $access->getCachedParserOutput( $page, $parserOptions, $oldRev );
$this->assertNull( $cachedOutput );
}
/**
* Tests that the secondary cache for output for old revisions is split on parser options.
*/
public function testOldRevisionCacheSplit() {
$this->markTestSkipped( 'Caching not yet implemented for old revisions' );
$access = $this->getParserOutputAccessWithCache();
$frenchOptions = ParserOptions::newCanonical( 'canonical' );
$frenchOptions->setUserLang( 'fr' );
$tongaOptions = ParserOptions::newCanonical( 'canonical' );
$tongaOptions->setUserLang( 'to' );
$page = $this->getNonexistingTestPage( __METHOD__ );
$this->editPage( $page, 'Test {{int:ok}}!' );
$oldRev = $page->getRevisionRecord();
$this->editPage( $page, 'Latest Test' );
$frenchResult = $access->getParserOutput( $page, $frenchOptions, $oldRev );
$this->assertContainsHtml( 'Test', $frenchResult );
// sanity check that French output was cached
$cachedFrenchOutput =
$access->getCachedParserOutput( $page, $frenchOptions, $oldRev );
$this->assertNotNull( $cachedFrenchOutput, 'French output should be in the cache' );
// check that we don't get the French output when asking for Tonga
$cachedTongaOutput =
$access->getCachedParserOutput( $page, $tongaOptions, $oldRev );
$this->assertNull( $cachedTongaOutput, 'Tonga output should not be in the cache yet' );
// check that we can generate the Tonga output, and it's different from French
$tongaResult = $access->getParserOutput( $page, $tongaOptions, $oldRev );
$this->assertContainsHtml( 'Test', $tongaResult );
$this->assertNotSameHtml(
$frenchResult,
$tongaResult,
'Tonga output should be different from French'
);
// check that the Tonga output is cached
$cachedTongaOutput =
$access->getCachedParserOutput( $page, $tongaOptions, $oldRev );
$this->assertNotNull( $cachedTongaOutput, 'Tonga output should be in the cache' );
}
/**

View file

@ -0,0 +1,83 @@
<?php
use MediaWiki\Json\JsonUnserializer;
use MediaWiki\Revision\RevisionRecord;
/**
* @covers PoolWorkArticleViewOld
* @group Database
*/
class PoolWorkArticleViewOldTest extends PoolWorkArticleViewTest {
/** @var BagOStuff */
private $cache = null;
/**
* @param WikiPage $page
* @param RevisionRecord|null $rev
* @param ParserOptions|null $options
*
* @return PoolWorkArticleView
*/
protected function newPoolWorkArticleView(
WikiPage $page,
RevisionRecord $rev = null,
$options = null
) {
if ( !$options ) {
$options = ParserOptions::newCanonical( 'canonical' );
}
if ( !$rev ) {
$rev = $page->getRevisionRecord();
}
if ( !$this->cache ) {
$this->installCache();
}
$renderer = $this->getServiceContainer()->getRevisionRenderer();
$unserializer = new JsonUnserializer();
return new PoolWorkArticleViewOld(
'test:' . $rev->getId(),
60 * 60,
$this->cache,
$rev,
$options,
$renderer,
$unserializer
);
}
/**
* @param BagOStuff $bag
*
* @return BagOStuff
*/
private function installCache( $bag = null ) {
$this->cache = $bag ?: new HashBagOStuff();
return $this->cache;
}
public function testUpdateCachedOutput() {
$page = $this->getExistingTestPage( __METHOD__ );
$cache = $this->installCache();
$work = $this->newPoolWorkArticleView( $page );
$this->assertTrue( $work->execute() );
$cacheKey = 'test:' . $page->getLatest();
$cachedJson = $cache->get( $cacheKey );
$this->assertIsString( $cachedJson );
$jsonUnserialiezr = new JsonUnserializer();
$cachedOutput = $jsonUnserialiezr->unserialize( $cachedJson );
$this->assertNotEmpty( $cachedOutput );
$this->assertSame( $work->getParserOutput()->getText(), $cachedOutput->getText() );
}
}