From da3443bfa2a925a7578f1378d7bfc1bb1acb4587 Mon Sep 17 00:00:00 2001 From: Ori Livneh Date: Mon, 5 Oct 2015 16:10:56 -0700 Subject: [PATCH] Add MemoizedCallable for APC-backed function memoization Add a simple class to `libs/` for memoizing functions by caching return values in APC. I decided not to make this an external library just yet because I see this as potentially a part of a larger functional programming library. Doesn't use APCBagOStuff for two reasons: (1) avoid dependency on MediaWiki code; (2) ability to pass third &$success parameter to apc_store, to distinguish between cache misses and cached false values. Use this in ResourceLoaderFileModule to cache CSSMin::remap. Change-Id: I00a47983a2583655d4631ecc9c6ba17597e36b5f --- autoload.php | 1 + includes/libs/MemoizedCallable.php | 151 ++++++++++++++++++ .../ResourceLoaderFileModule.php | 5 +- .../includes/libs/MemoizedCallableTest.php | 134 ++++++++++++++++ 4 files changed, 288 insertions(+), 3 deletions(-) create mode 100644 includes/libs/MemoizedCallable.php create mode 100644 tests/phpunit/includes/libs/MemoizedCallableTest.php diff --git a/autoload.php b/autoload.php index 6820fc740d..0055009c00 100644 --- a/autoload.php +++ b/autoload.php @@ -781,6 +781,7 @@ $wgAutoloadLocalClasses = array( 'MemcachedBagOStuff' => __DIR__ . '/includes/objectcache/MemcachedBagOStuff.php', 'MemcachedPeclBagOStuff' => __DIR__ . '/includes/objectcache/MemcachedPeclBagOStuff.php', 'MemcachedPhpBagOStuff' => __DIR__ . '/includes/objectcache/MemcachedPhpBagOStuff.php', + 'MemoizedCallable' => __DIR__ . '/includes/libs/MemoizedCallable.php', 'MemoryFileBackend' => __DIR__ . '/includes/filebackend/MemoryFileBackend.php', 'MergeHistoryPager' => __DIR__ . '/includes/specials/SpecialMergeHistory.php', 'MergeLogFormatter' => __DIR__ . '/includes/logging/MergeLogFormatter.php', diff --git a/includes/libs/MemoizedCallable.php b/includes/libs/MemoizedCallable.php new file mode 100644 index 0000000000..14de6b9034 --- /dev/null +++ b/includes/libs/MemoizedCallable.php @@ -0,0 +1,151 @@ +invoke( 5, 8 ); // result: array( 5, 6, 7, 8 ) + * $memoizedStrrev->invokeArgs( array( 5, 8 ) ); // same + * MemoizedCallable::call( 'range', array( 5, 8 ) ); // same + * @endcode + * + * 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 + * @author Ori Livneh + * @since 1.27 + */ +class MemoizedCallable { + + /** @var callable */ + private $callable; + + /** @var string Unique name of callable; used for cache keys. */ + private $callableName; + + /** + * Constructor. + * + * @throws InvalidArgumentException if $callable is not a callable. + * @param callable $callable Function or method to memoize. + * @param int $ttl TTL in seconds. Defaults to 3600 (1hr). Capped at 86400 (24h). + */ + public function __construct( $callable, $ttl = 3600 ) { + if ( !is_callable( $callable, false, $this->callableName ) ) { + throw new InvalidArgumentException( + 'Argument 1 passed to MemoizedCallable::__construct() must ' . + 'be an instance of callable; ' . gettype( $callable ) . ' given' + ); + } + + if ( $this->callableName === 'Closure::__invoke' ) { + // Differentiate anonymous functions from one another + $this->callableName .= uniqid(); + } + + $this->callable = $callable; + $this->ttl = min( max( $ttl, 1 ), 86400 ); + } + + /** + * Fetch the result of a previous invocation from APC. + * + * @param string $key + * @param bool &$success + */ + protected function fetchResult( $key, &$success ) { + $success = false; + if ( function_exists( 'apc_fetch' ) ) { + return apc_fetch( $key, $success ); + } + return false; + } + + /** + * Store the result of an invocation in APC. + * + * @param string $key + * @param mixed $result + */ + protected function storeResult( $key, $result ) { + if ( function_exists( 'apc_store' ) ) { + apc_store( $key, $result, $this->ttl ); + } + } + + /** + * Invoke the memoized function or method. + * + * @throws InvalidArgumentException If parameters list contains non-scalar items. + * @param array $args Parameters for memoized function or method. + * @return mixed The memoized callable's return value. + */ + public function invokeArgs( Array $args = array() ) { + foreach ( $args as $arg ) { + if ( $arg !== null && !is_scalar( $arg ) ) { + throw new InvalidArgumentException( + 'MemoizedCallable::invoke() called with non-scalar ' . + 'argument' + ); + } + } + + $hash = md5( serialize( $args ) ); + $key = __CLASS__ . ':' . $this->callableName . ':' . $hash; + $success = false; + $result = $this->fetchResult( $key, $success ); + if ( !$success ) { + $result = call_user_func_array( $this->callable, $args ); + $this->storeResult( $key, $result ); + } + + return $result; + } + + /** + * Invoke the memoized function or method. + * + * Like MemoizedCallable::invokeArgs(), but variadic. + * + * @param mixed ...$params Parameters for memoized function or method. + * @return mixed The memoized callable's return value. + */ + public function invoke() { + return $this->invokeArgs( func_get_args() ); + } + + /** + * Shortcut method for creating a MemoizedCallable and invoking it + * with the specified arguments. + * + * @param callable $callable + * @param array $args + * @param int $ttl + */ + public static function call( $callable, Array $args = array(), $ttl = 3600 ) { + $instance = new self( $callable, $ttl ); + return $instance->invokeArgs( $args ); + } +} diff --git a/includes/resourceloader/ResourceLoaderFileModule.php b/includes/resourceloader/ResourceLoaderFileModule.php index a637b935e6..0e5354790c 100644 --- a/includes/resourceloader/ResourceLoaderFileModule.php +++ b/includes/resourceloader/ResourceLoaderFileModule.php @@ -924,9 +924,8 @@ class ResourceLoaderFileModule extends ResourceLoaderModule { $this->missingLocalFileRefs[] = $file; } } - return CSSMin::remap( - $style, $localDir, $remoteDir, true - ); + return MemoizedCallable::call( 'CSSMin::remap', + array( $style, $localDir, $remoteDir, true ) ); } /** diff --git a/tests/phpunit/includes/libs/MemoizedCallableTest.php b/tests/phpunit/includes/libs/MemoizedCallableTest.php new file mode 100644 index 0000000000..921bba8466 --- /dev/null +++ b/tests/phpunit/includes/libs/MemoizedCallableTest.php @@ -0,0 +1,134 @@ +cache ) ) { + $success = true; + return $this->cache[$key]; + } + $success = false; + return false; + } + + protected function storeResult( $key, $result ) { + $this->cache[$key] = $result; + } +} + + +/** + * PHP Unit tests for MemoizedCallable class. + * @covers MemoizedCallable + */ +class MemoizedCallableTest extends PHPUnit_Framework_TestCase { + + /** + * The memoized callable should relate inputs to outputs in the same + * way as the original underlying callable. + */ + public function testReturnValuePassedThrough() { + $mock = $this->getMock( 'stdClass', array( 'reverse' ) ); + $mock->expects( $this->any() ) + ->method( 'reverse' ) + ->will( $this->returnCallback( 'strrev' ) ); + + $memoized = new MemoizedCallable( array( $mock, 'reverse' ) ); + $this->assertEquals( 'flow', $memoized->invoke( 'wolf' ) ); + } + + /** + * Consecutive calls to the memoized callable with the same arguments + * should result in just one invocation of the underlying callable. + * + * @requires function apc_store + */ + public function testCallableMemoized() { + $observer = $this->getMock( 'stdClass', array( 'computeSomething' ) ); + $observer->expects( $this->once() ) + ->method( 'computeSomething' ) + ->will( $this->returnValue( 'ok' ) ); + + $memoized = new ArrayBackedMemoizedCallable( array( $observer, 'computeSomething' ) ); + + // First invocation -- delegates to $observer->computeSomething() + $this->assertEquals( 'ok', $memoized->invoke() ); + + // Second invocation -- returns memoized result + $this->assertEquals( 'ok', $memoized->invoke() ); + } + + /** + * @covers MemoizedCallable::invoke + */ + public function testInvokeVariadic() { + $memoized = new MemoizedCallable( 'sprintf' ); + $this->assertEquals( + $memoized->invokeArgs( array( 'this is %s', 'correct' ) ), + $memoized->invoke( 'this is %s', 'correct' ) + ); + } + + /** + * @covers MemoizedCallable::call + */ + public function testShortcutMethod() { + $this->assertEquals( + 'this is correct', + MemoizedCallable::call( 'sprintf', array( 'this is %s', 'correct' ) ) + ); + } + + /** + * Outlier TTL values should be coerced to range 1 - 86400. + */ + public function testTTLMaxMin() { + $memoized = new MemoizedCallable( 'abs', 100000 ); + $this->assertEquals( 86400, $this->readAttribute( $memoized, 'ttl' ) ); + + $memoized = new MemoizedCallable( 'abs', -10 ); + $this->assertEquals( 1, $this->readAttribute( $memoized, 'ttl' ) ); + } + + /** + * Closure names should be distinct. + */ + public function testMemoizedClosure() { + $a = new MemoizedCallable( function () { + return 'a'; + } ); + + $b = new MemoizedCallable( function () { + return 'b'; + } ); + + $this->assertEquals( $a->invokeArgs(), 'a' ); + $this->assertEquals( $b->invokeArgs(), 'b' ); + + $this->assertNotEquals( + $this->readAttribute( $a, 'callableName' ), + $this->readAttribute( $b, 'callableName' ) + ); + } + + /** + * @expectedExceptionMessage non-scalar argument + * @expectedException InvalidArgumentException + */ + public function testNonScalarArguments() { + $memoized = new MemoizedCallable( 'gettype' ); + $memoized->invoke( new stdClass() ); + } + + /** + * @expectedExceptionMessage must be an instance of callable + * @expectedException InvalidArgumentException + */ + public function testNotCallable() { + $memoized = new MemoizedCallable( 14 ); + } +} -- 2.20.1