class APCBagOStuff extends BagOStuff {
/**
* @param $key string
+ * @param $casToken[optional] int
* @return mixed
*/
- public function get( $key ) {
+ public function get( $key, &$casToken = null ) {
$val = apc_fetch( $key );
+ $casToken = $val;
+
if ( is_string( $val ) ) {
if ( $this->isInteger( $val ) ) {
$val = intval( $val );
return true;
}
+ /**
+ * @param $casToken mixed
+ * @param $key string
+ * @param $value mixed
+ * @param $exptime int
+ * @return bool
+ */
+ public function cas( $casToken, $key, $value, $exptime = 0 ) {
+ // APC's CAS functions only work on integers
+ throw new MWException( "CAS is not implemented in " . __CLASS__ );
+ }
+
/**
* @param $key string
* @param $time int
return true;
}
+ /**
+ * @param $key string
+ * @param $callback closure Callback method to be executed
+ * @param $exptime int Either an interval in seconds or a unix timestamp for expiry
+ * @param $attempts int The amount of times to attempt a merge in case of failure
+ * @return bool success
+ */
+ public function merge( $key, closure $callback, $exptime = 0, $attempts = 10 ) {
+ return $this->mergeViaLock( $key, $callback, $exptime, $attempts );
+ }
+
public function incr( $key, $value = 1 ) {
return apc_inc( $key, $value );
}
/**
* Get an item with the given key. Returns false if it does not exist.
* @param $key string
+ * @param $casToken[optional] mixed
* @return mixed Returns false on failure
*/
- abstract public function get( $key );
+ abstract public function get( $key, &$casToken = null );
/**
* Set an item.
*/
abstract public function set( $key, $value, $exptime = 0 );
+ /**
+ * Check and set an item.
+ * @param $casToken mixed
+ * @param $key string
+ * @param $value mixed
+ * @param $exptime int Either an interval in seconds or a unix timestamp for expiry
+ * @return bool success
+ */
+ abstract public function cas( $casToken, $key, $value, $exptime = 0 );
+
/**
* Delete an item.
* @param $key string
abstract public function delete( $key, $time = 0 );
/**
+ * Merge changes into the existing cache value (possibly creating a new one)
+ *
* @param $key string
- * @param $timeout integer
+ * @param $callback closure Callback method to be executed
+ * @param $exptime int Either an interval in seconds or a unix timestamp for expiry
+ * @param $attempts int The amount of times to attempt a merge in case of failure
* @return bool success
*/
- public function lock( $key, $timeout = 0 ) {
- /* stub */
- return true;
+ public function merge( $key, closure $callback, $exptime = 0, $attempts = 10 ) {
+ return $this->mergeViaCas( $key, $callback, $exptime, $attempts );
+ }
+
+ /**
+ * @see BagOStuff::merge()
+ *
+ * @param $key string
+ * @param $callback closure Callback method to be executed
+ * @param $exptime int Either an interval in seconds or a unix timestamp for expiry
+ * @param $attempts int The amount of times to attempt a merge in case of failure
+ * @return bool success
+ */
+ protected function mergeViaCas( $key, closure $callback, $exptime = 0, $attempts = 10 ) {
+ do {
+ $casToken = null; // passed by reference
+ $currentValue = $this->get( $key, $casToken ); // get the old value
+ $value = $callback( $this, $key, $currentValue ); // derive the new value
+
+ if ( $value === false ) {
+ $success = true; // do nothing
+ } elseif ( $currentValue === false ) {
+ // Try to create the key, failing if it gets created in the meantime
+ $success = $this->add( $key, $value, $exptime );
+ } else {
+ // Try to update the key, failing if it gets changed in the meantime
+ $success = $this->cas( $casToken, $key, $value, $exptime );
+ }
+ } while ( !$success && --$attempts );
+
+ return $success;
+ }
+
+ /**
+ * @see BagOStuff::merge()
+ *
+ * @param $key string
+ * @param $callback closure Callback method to be executed
+ * @param $exptime int Either an interval in seconds or a unix timestamp for expiry
+ * @param $attempts int The amount of times to attempt a merge in case of failure
+ * @return bool success
+ */
+ protected function mergeViaLock( $key, closure $callback, $exptime = 0, $attempts = 10 ) {
+ if ( !$this->lock( $key, 60 ) ) {
+ return false;
+ }
+
+ $currentValue = $this->get( $key ); // get the old value
+ $value = $callback( $this, $key, $currentValue ); // derive the new value
+
+ if ( $value === false ) {
+ $success = true; // do nothing
+ } else {
+ $success = $this->set( $key, $value, $exptime ); // set the new value
+ }
+
+ if ( !$this->unlock( $key ) ) {
+ // this should never happen
+ trigger_error( "Could not release lock for key '$key'." );
+ }
+
+ return $success;
+ }
+
+ /**
+ * @param $key string
+ * @param $timeout integer [optional]
+ * @return bool success
+ */
+ public function lock( $key, $timeout = 60 ) {
+ $timestamp = microtime( true ); // starting UNIX timestamp
+ if ( $this->add( "{$key}:lock", $timeout ) ) {
+ return true;
+ }
+
+ $uRTT = ceil( 1e6 * ( microtime( true ) - $timestamp ) ); // estimate RTT (us)
+ $sleep = 2*$uRTT; // rough time to do get()+set()
+
+ $locked = false; // lock acquired
+ $attempts = 0; // failed attempts
+ do {
+ if ( ++$attempts >= 3 && $sleep <= 1e6 ) {
+ // Exponentially back off after failed attempts to avoid network spam.
+ // About 2*$uRTT*(2^n-1) us of "sleep" happen for the next n attempts.
+ $sleep *= 2;
+ }
+ usleep( $sleep ); // back off
+ $locked = $this->add( "{$key}:lock", $timeout );
+ } while( !$locked );
+
+ return $locked;
}
/**
* @return bool success
*/
public function unlock( $key ) {
- /* stub */
- return true;
+ return $this->delete( "{$key}:lock" );
}
/**
/**
* @param $key string
+ * @param $casToken[optional] mixed
* @return mixed
*/
- public function get( $key ) {
+ public function get( $key, &$casToken = null ) {
wfProfileIn( __METHOD__ );
wfDebug( __METHOD__ . "($key)\n" );
$val = false;
}
+ $casToken = $val;
+
wfProfileOut( __METHOD__ );
+
return $val;
}
return $ret;
}
+ /**
+ * @param $casToken mixed
+ * @param $key string
+ * @param $value mixed
+ * @param $exptime int
+ * @return bool
+ */
+ public function cas( $casToken, $key, $value, $exptime = 0 ) {
+ wfProfileIn( __METHOD__ );
+ wfDebug( __METHOD__ . "($key)\n" );
+
+ $blob = $this->encode( $value, $exptime );
+
+ $handle = $this->getWriter();
+ if ( !$handle ) {
+ wfProfileOut( __METHOD__ );
+ return false;
+ }
+
+ // DBA is locked to any other write connection, so we can safely
+ // compare the current & previous value before saving new value
+ $val = dba_fetch( $key, $handle );
+ list( $val, $exptime ) = $this->decode( $val );
+ if ( $casToken !== $val ) {
+ dba_close( $handle );
+ return false;
+ }
+
+ $ret = dba_replace( $key, $blob, $handle );
+ dba_close( $handle );
+
+ wfProfileOut( __METHOD__ );
+ return $ret;
+ }
+
/**
* @param $key string
* @param $time int
/**
* @param $key string
+ * @param $casToken[optional] mixed
* @return bool|mixed
*/
- public function get( $key ) {
+ public function get( $key, &$casToken = null ) {
wfProfileIn( __METHOD__ );
$response = $this->doItemRequest( $key );
if ( !$response || $response['http_code'] == 404 ) {
return false;
}
+ $casToken = $body;
+
wfProfileOut( __METHOD__ );
return $data;
}
return $result;
}
+ /**
+ * @param $casToken mixed
+ * @param $key string
+ * @param $value mixed
+ * @param $exptime int
+ * @return bool
+ */
+ public function cas( $casToken, $key, $value, $exptime = 0 ) {
+ // Not sure if we can implement CAS for ehcache. There appears to be CAS-support per
+ // http://ehcache.org/documentation/get-started/consistency-options#cas-cache-operations,
+ // but I can't find any docs for our current implementation.
+ throw new MWException( "CAS is not implemented in " . __CLASS__ );
+ }
+
/**
* @param $key string
* @param $time int
return $result;
}
+ /**
+ * @see BagOStuff::merge()
+ * @return bool success
+ */
+ public function merge( $key, closure $callback, $exptime = 0, $attempts = 10 ) {
+ return $this->mergeViaLock( $key, $callback, $exptime, $attempts );
+ }
+
/**
* @param $key string
* @return string
/**
* @param $key string
+ * @param $casToken[optional] mixed
* @return bool
*/
- function get( $key ) {
+ function get( $key, &$casToken = null ) {
return false;
}
return true;
}
+ /**
+ * @param $casToken mixed
+ * @param $key string
+ * @param $value mixed
+ * @param $exp int
+ * @return bool
+ */
+ function cas( $casToken, $key, $value, $exp = 0 ) {
+ return true;
+ }
+
/**
* @param $key string
* @param $time int
function delete( $key, $time = 0 ) {
return true;
}
+
+ /**
+ * @param $key string
+ * @param $callback closure Callback method to be executed
+ * @param $exptime int Either an interval in seconds or a unix timestamp for expiry
+ * @param $attempts int The amount of times to attempt a merge in case of failure
+ * @return bool success
+ */
+ public function merge( $key, closure $callback, $exptime = 0, $attempts = 10 ) {
+ return true;
+ }
}
/**
/**
* @param $key string
+ * @param $casToken[optional] mixed
* @return bool|mixed
*/
- function get( $key ) {
+ function get( $key, &$casToken = null ) {
if ( !isset( $this->bag[$key] ) ) {
return false;
}
return false;
}
+ $casToken = $this->bag[$key][0];
+
return $this->bag[$key][0];
}
return true;
}
+ /**
+ * @param $casToken mixed
+ * @param $key string
+ * @param $value mixed
+ * @param $exptime int
+ * @return bool
+ */
+ function cas( $casToken, $key, $value, $exptime = 0 ) {
+ if ( $this->get( $key ) === $casToken ) {
+ return $this->set( $key, $value, $exptime );
+ }
+
+ return false;
+ }
+
/**
* @param $key string
* @param $time int
/**
* @param $key string
+ * @param $casToken[optional] mixed
* @return Mixed
*/
- public function get( $key ) {
- return $this->client->get( $this->encodeKey( $key ) );
+ public function get( $key, &$casToken = null ) {
+ return $this->client->get( $this->encodeKey( $key ), $casToken );
}
/**
$this->fixExpiry( $exptime ) );
}
+ /**
+ * @param $key string
+ * @param $casToken mixed
+ * @param $value
+ * @param $exptime int
+ * @return bool
+ */
+ public function cas( $casToken, $key, $value, $exptime = 0 ) {
+ return $this->client->cas( $casToken, $this->encodeKey( $key ),
+ $value, $this->fixExpiry( $exptime ) );
+ }
+
/**
* @param $key string
* @param $time int
* Retrieves the value associated with the key from the memcache server
*
* @param $key array|string key to retrieve
+ * @param $casToken[optional] Float
*
* @return Mixed
*/
- public function get( $key ) {
+ public function get( $key, &$casToken = null ) {
wfProfileIn( __METHOD__ );
if ( $this->_debug ) {
$this->stats['get'] = 1;
}
- $cmd = "get $key\r\n";
+ $cmd = "gets $key\r\n";
if ( !$this->_fwrite( $sock, $cmd ) ) {
wfProfileOut( __METHOD__ );
return false;
}
$val = array();
- $this->_load_items( $sock, $val );
+ $this->_load_items( $sock, $val, $casToken );
if ( $this->_debug ) {
foreach ( $val as $k => $v ) {
$gather = array();
// Send out the requests
foreach ( $socks as $sock ) {
- $cmd = 'get';
+ $cmd = 'gets';
foreach ( $sock_keys[ intval( $sock ) ] as $key ) {
$cmd .= ' ' . $key;
}
// Parse responses
$val = array();
foreach ( $gather as $sock ) {
- $this->_load_items( $sock, $val );
+ $this->_load_items( $sock, $val, $casToken );
}
if ( $this->_debug ) {
return $this->_set( 'set', $key, $value, $exp );
}
+ // }}}
+ // {{{ cas()
+
+ /**
+ * Sets a key to a given value in the memcache if the current value still corresponds
+ * to a known, given value. Returns true if set successfully.
+ *
+ * @param $casToken Float: current known value
+ * @param $key String: key to set value as
+ * @param $value Mixed: value to set
+ * @param $exp Integer: (optional) Expiration time. This can be a number of seconds
+ * to cache for (up to 30 days inclusive). Any timespans of 30 days + 1 second or
+ * longer must be the timestamp of the time at which the mapping should expire. It
+ * is safe to use timestamps in all cases, regardless of exipration
+ * eg: strtotime("+3 hour")
+ *
+ * @return Boolean: TRUE on success
+ */
+ public function cas( $casToken, $key, $value, $exp = 0 ) {
+ return $this->_set( 'cas', $key, $value, $exp, $casToken );
+ }
+
// }}}
// {{{ set_compress_threshold()
*
* @param $sock Resource: socket to read from
* @param $ret Array: returned values
+ * @param $casToken[optional] Float
* @return boolean True for success, false for failure
*
* @access private
*/
- function _load_items( $sock, &$ret ) {
+ function _load_items( $sock, &$ret, &$casToken = null ) {
while ( 1 ) {
$decl = $this->_fgets( $sock );
if( $decl === false ) {
return false;
} elseif ( $decl == "END" ) {
return true;
- } elseif ( preg_match( '/^VALUE (\S+) (\d+) (\d+)$/', $decl, $match ) ) {
- list( $rkey, $flags, $len ) = array( $match[1], $match[2], $match[3] );
+ } elseif ( preg_match( '/^VALUE (\S+) (\d+) (\d+) (\d+)$/', $decl, $match ) ) {
+ list( $rkey, $flags, $len, $casToken ) = array( $match[1], $match[2], $match[3], $match[4] );
$data = $this->_fread( $sock, $len + 2 );
if ( $data === false ) {
return false;
* longer must be the timestamp of the time at which the mapping should expire. It
* is safe to use timestamps in all cases, regardless of exipration
* eg: strtotime("+3 hour")
+ * @param $casToken[optional] Float
*
* @return Boolean
* @access private
*/
- function _set( $cmd, $key, $val, $exp ) {
+ function _set( $cmd, $key, $val, $exp, $casToken = null ) {
if ( !$this->_active ) {
return false;
}
$flags |= self::COMPRESSED;
}
}
- if ( !$this->_fwrite( $sock, "$cmd $key $flags $exp $len\r\n$val\r\n" ) ) {
+
+ $command = "$cmd $key $flags $exp $len";
+ if ( $casToken ) {
+ $command .= " $casToken";
+ }
+
+ if ( !$this->_fwrite( $sock, "$command\r\n$val\r\n" ) ) {
return false;
}
/**
* @param $key string
+ * @param $casToken[optional] float
* @return Mixed
*/
- public function get( $key ) {
+ public function get( $key, &$casToken = null ) {
wfProfileIn( __METHOD__ );
$this->debugLog( "get($key)" );
- $value = $this->checkResult( $key, parent::get( $key ) );
+ $result = $this->client->get( $this->encodeKey( $key ), null, $casToken );
+ $result = $this->checkResult( $key, $result );
wfProfileOut( __METHOD__ );
- return $value;
+ return $result;
}
/**
return $this->checkResult( $key, parent::set( $key, $value, $exptime ) );
}
+ /**
+ * @param $casToken float
+ * @param $key string
+ * @param $value
+ * @param $exptime int
+ * @return bool
+ */
+ public function cas( $casToken, $key, $value, $exptime = 0 ) {
+ $this->debugLog( "cas($key)" );
+ return $this->checkResult( $key, parent::cas( $casToken, $key, $value, $exptime ) );
+ }
+
/**
* @param $key string
* @param $time int
/**
* @param $key string
+ * @param $casToken[optional] mixed
* @return bool|mixed
*/
- public function get( $key ) {
+ public function get( $key, &$casToken = null ) {
foreach ( $this->caches as $cache ) {
$value = $cache->get( $key );
if ( $value !== false ) {
return false;
}
+ /**
+ * @param $casToken mixed
+ * @param $key string
+ * @param $value mixed
+ * @param $exptime int
+ * @return bool
+ */
+ public function cas( $casToken, $key, $value, $exptime = 0 ) {
+ throw new MWException( "CAS is not implemented in " . __CLASS__ );
+ }
+
/**
* @param $key string
* @param $value mixed
}
}
+ /**
+ * @param $key string
+ * @param $callback closure Callback method to be executed
+ * @param $exptime int Either an interval in seconds or a unix timestamp for expiry
+ * @param $attempts int The amount of times to attempt a merge in case of failure
+ * @return bool success
+ */
+ public function merge( $key, closure $callback, $exptime = 0, $attempts = 10 ) {
+ return $this->doWrite( 'merge', $key, $callback, $exptime );
+ }
+
/**
* @param $method string
* @return bool
}
}
- public function get( $key ) {
+ public function get( $key, &$casToken = null ) {
wfProfileIn( __METHOD__ );
list( $server, $conn ) = $this->getConnection( $key );
if ( !$conn ) {
$result = false;
$this->handleException( $server, $e );
}
+ $casToken = $result;
$this->logRequest( 'get', $key, $server, $result );
wfProfileOut( __METHOD__ );
return $result;
return $result;
}
+ /**
+ * @param $casToken mixed
+ * @param $key string
+ * @param $value mixed
+ * @param $exptime int
+ * @return bool
+ */
+ public function cas( $casToken, $key, $value, $expiry = 0 ) {
+ wfProfileIn( __METHOD__ );
+ list( $server, $conn ) = $this->getConnection( $key );
+ if ( !$conn ) {
+ wfProfileOut( __METHOD__ );
+ return false;
+ }
+ $expiry = $this->convertToRelative( $expiry );
+ try {
+ $conn->watch( $key );
+
+ if ( $this->get( $key ) !== $casToken ) {
+ wfProfileOut( __METHOD__ );
+ return false;
+ }
+
+ $conn->multi();
+
+ if ( !$expiry ) {
+ // No expiry, that is very different from zero expiry in Redis
+ $conn->set( $key, $value );
+ } else {
+ $conn->setex( $key, $expiry, $value );
+ }
+
+ $result = $conn->exec();
+ } catch ( RedisException $e ) {
+ $result = false;
+ $this->handleException( $server, $e );
+ }
+
+ $this->logRequest( 'cas', $key, $server, $result );
+ wfProfileOut( __METHOD__ );
+ return $result;
+ }
+
public function delete( $key, $time = 0 ) {
wfProfileIn( __METHOD__ );
list( $server, $conn ) = $this->getConnection( $key );
/**
* @param $key string
+ * @param $casToken[optional] mixed
* @return mixed
*/
- public function get( $key ) {
+ public function get( $key, &$casToken = null ) {
$values = $this->getMulti( array( $key ) );
- return array_key_exists( $key, $values ) ? $values[$key] : false;
+ if ( array_key_exists( $key, $values ) ) {
+ $casToken = $values[$key];
+ return $values[$key];
+ }
+ return false;
}
/**
return true;
}
+ /**
+ * @param $casToken mixed
+ * @param $key string
+ * @param $value mixed
+ * @param $exptime int
+ * @return bool
+ */
+ public function cas( $casToken, $key, $value, $exptime = 0 ) {
+ $db = $this->getDB();
+ $exptime = intval( $exptime );
+
+ if ( $exptime < 0 ) {
+ $exptime = 0;
+ }
+
+ if ( $exptime == 0 ) {
+ $encExpiry = $this->getMaxDateTime();
+ } else {
+ if ( $exptime < 3.16e8 ) { # ~10 years
+ $exptime += time();
+ }
+
+ $encExpiry = $db->timestamp( $exptime );
+ }
+ try {
+ $db->begin( __METHOD__ );
+ // (bug 24425) use a replace if the db supports it instead of
+ // delete/insert to avoid clashes with conflicting keynames
+ $db->update(
+ $this->getTableByKey( $key ),
+ array(
+ 'keyname' => $key,
+ 'value' => $db->encodeBlob( $this->serialize( $value ) ),
+ 'exptime' => $encExpiry
+ ),
+ array(
+ 'keyname' => $key,
+ 'value' => $db->encodeBlob( $this->serialize( $casToken ) )
+ ), __METHOD__ );
+ $db->commit( __METHOD__ );
+ } catch ( DBQueryError $e ) {
+ $this->handleWriteError( $e );
+
+ return false;
+ }
+
+ return (bool) $db->affectedRows();
+ }
+
/**
* @param $key string
* @param $time int
* Get a value from the WinCache object cache
*
* @param $key String: cache key
+ * @param $casToken[optional] int: cas token
* @return mixed
*/
- public function get( $key ) {
+ public function get( $key, &$casToken = null ) {
$val = wincache_ucache_get( $key );
+ $casToken = $val;
+
if ( is_string( $val ) ) {
$val = unserialize( $val );
}
return ( is_array( $result ) && $result === array() ) || $result;
}
+ /**
+ * Store a value in the WinCache object cache, race condition-safe
+ *
+ * @param $casToken int: cas token
+ * @param $key String: cache key
+ * @param $value int: object to store
+ * @param $exptime Int: expiration time
+ * @return bool
+ */
+ public function cas( $casToken, $key, $value, $exptime = 0 ) {
+ return wincache_ucache_cas( $key, $casToken, serialize( $value ) );
+ }
+
/**
* Remove a value from the WinCache object cache
*
* Get a value from the XCache object cache
*
* @param $key String: cache key
+ * @param $casToken mixed: cas token
* @return mixed
*/
- public function get( $key ) {
+ public function get( $key, &$casToken = null ) {
$val = xcache_get( $key );
if ( is_string( $val ) ) {
return true;
}
+ /**
+ * @param $casToken mixed
+ * @param $key string
+ * @param $value mixed
+ * @param $exptime int
+ * @return bool
+ */
+ public function cas( $casToken, $key, $value, $exptime = 0 ) {
+ // Can't find any documentation on xcache cas
+ throw new MWException( "CAS is not implemented in " . __CLASS__ );
+ }
+
/**
* Remove a value from the XCache object cache
*
return true;
}
+ /**
+ * Merge an item.
+ * XCache does not seem to support any way of performing CAS - this however will
+ * provide a way to perform CAS-like functionality.
+ *
+ * @param $key string
+ * @param $callback closure Callback method to be executed
+ * @param $exptime int Either an interval in seconds or a unix timestamp for expiry
+ * @param $attempts int The amount of times to attempt a merge in case of failure
+ * @return bool success
+ */
+ public function merge( $key, closure $callback, $exptime = 0, $attempts = 10 ) {
+ return $this->mergeViaLock( $key, $callback, $exptime, $attempts );
+ }
+
public function incr( $key, $value = 1 ) {
return xcache_inc( $key, $value );
}
'regex=' => false,
'file=' => false,
'use-filebackend=' => false,
+ 'use-bagostuff=' => false,
'keep-uploads' => false,
'use-normal-tables' => false,
'reuse-db' => false,
--- /dev/null
+<?php
+/**
+ * This class will test BagOStuff.
+ *
+ * @author Matthias Mullie <mmullie@wikimedia.org>
+ */
+class BagOStuffTest extends MediaWikiTestCase {
+ private $cache;
+
+ protected function setUp() {
+ parent::setUp();
+
+ // type defined through parameter
+ if ( $this->getCliArg( 'use-bagostuff=' ) ) {
+ $name = $this->getCliArg( 'use-bagostuff=' );
+
+ $this->cache = ObjectCache::newFromId( $name );
+
+ // no type defined - use simple hash
+ } else {
+ $this->cache = new HashBagOStuff;
+ }
+
+ $this->cache->delete( wfMemcKey( 'test' ) );
+ }
+
+ protected function tearDown() {
+ }
+
+ public function testMerge() {
+ $key = wfMemcKey( 'test' );
+
+ $usleep = 0;
+
+ /**
+ * Callback method: append "merged" to whatever is in cache.
+ *
+ * @param BagOStuff $cache
+ * @param string $key
+ * @param int $existingValue
+ * @use int $usleep
+ * @return int
+ */
+ $callback = function( BagOStuff $cache, $key, $existingValue ) use ( &$usleep ) {
+ // let's pretend this is an expensive callback to test concurrent merge attempts
+ usleep( $usleep );
+
+ if ( $existingValue === false ) {
+ return 'merged';
+ }
+
+ return $existingValue . 'merged';
+ };
+
+ // merge on non-existing value
+ $merged = $this->cache->merge( $key, $callback, 0 );
+ $this->assertTrue( $merged );
+ $this->assertEquals( $this->cache->get( $key ), 'merged' );
+
+ // merge on existing value
+ $merged = $this->cache->merge( $key, $callback, 0 );
+ $this->assertTrue( $merged );
+ $this->assertEquals( $this->cache->get( $key ), 'mergedmerged' );
+
+ /*
+ * Test concurrent merges by forking this process, if:
+ * - not manually called with --use-bagostuff
+ * - pcntl_fork is supported by the system
+ * - cache type will correctly support calls over forks
+ */
+ $fork = (bool) $this->getCliArg( 'use-bagostuff=' );
+ $fork &= function_exists( 'pcntl_fork' );
+ $fork &= !$this->cache instanceof HashBagOStuff;
+ $fork &= !$this->cache instanceof EmptyBagOStuff;
+ $fork &= !$this->cache instanceof MultiWriteBagOStuff;
+ if ( $fork ) {
+ // callback should take awhile now so that we can test concurrent merge attempts
+ $usleep = 5000;
+
+ $pid = pcntl_fork();
+ if ( $pid == -1 ) {
+ // can't fork, ignore this test...
+ } elseif ( $pid ) {
+ // wait a little, making sure that the child process is calling merge
+ usleep( 3000 );
+
+ // attempt a merge - this should fail
+ $merged = $this->cache->merge( $key, $callback, 0, 1 );
+
+ // merge has failed because child process was merging (and we only attempted once)
+ $this->assertFalse( $merged );
+
+ // make sure the child's merge is completed and verify
+ usleep( 3000 );
+ $this->assertEquals( $this->cache->get( $key ), 'mergedmergedmerged' );
+ } else {
+ $this->cache->merge( $key, $callback, 0, 1 );
+
+ // Note: I'm not even going to check if the merge worked, I'll
+ // compare values in the parent process to test if this merge worked.
+ // I'm just going to exit this child process, since I don't want the
+ // child to output any test results (would be rather confusing to
+ // have test output twice)
+ exit;
+ }
+ }
+ }
+}