added redis contrib module

This commit is contained in:
Bachir Soussi Chiadmi
2018-02-21 14:07:00 +01:00
parent 1eb61fe020
commit 18f4aba146
69 changed files with 6525 additions and 0 deletions

View File

@@ -0,0 +1,108 @@
<?php
abstract class Redis_AbstractBackend implements Redis_BackendInterface
{
/**
* Key components name separator
*/
const KEY_SEPARATOR = ':';
/**
* @var string
*/
private $prefix;
/**
* @var string
*/
private $namespace;
/**
* @var mixed
*/
private $client;
/**
* Default constructor
*
* @param mixed $client
* Redis client
* @param string $namespace
* Component namespace
* @param string $prefix
* Component prefix
*/
public function __construct($client, $namespace = null, $prefix = null)
{
$this->client = $client;
$this->prefix = $prefix;
if (null !== $namespace) {
$this->namespace = $namespace;
}
}
final public function setClient($client)
{
$this->client = $client;
}
final public function getClient()
{
return $this->client;
}
final public function setPrefix($prefix)
{
$this->prefix = $prefix;
}
final public function getPrefix()
{
return $this->prefix;
}
final public function setNamespace($namespace)
{
$this->namespace = $namespace;
}
final public function getNamespace()
{
return $this->namespace;
}
/**
* Get prefixed key
*
* @param string|string[] $parts
* Arbitrary number of strings to compose the key
*
* @return string
*/
public function getKey($parts = array())
{
$key = array();
if (null !== $this->prefix) {
$key[] = $this->prefix;
}
if (null !== $this->namespace) {
$key[] = $this->namespace;
}
if ($parts) {
if (is_array($parts)) {
foreach ($parts as $part) {
if ($part) {
$key[] = $part;
}
}
} else {
$key[] = $parts;
}
}
return implode(self::KEY_SEPARATOR, array_filter($key));
}
}

View File

@@ -0,0 +1,59 @@
<?php
/**
* Client based Redis component
*/
interface Redis_BackendInterface
{
/**
* Set client
*
* @param mixed $client
*/
public function setClient($client);
/**
* Get client
*
* @return mixed
*/
public function getClient();
/**
* Set prefix
*
* @param string $prefix
*/
public function setPrefix($prefix);
/**
* Get prefix
*
* @return string
*/
public function getPrefix();
/**
* Set namespace
*
* @param string $namespace
*/
public function setNamespace($namespace);
/**
* Get namespace
*
* @return string
*/
public function getNamespace();
/**
* Get full key name using the set prefix
*
* @param string ...
* Any numer of strings to append to path using the separator
*
* @return string
*/
public function getKey();
}

View File

@@ -0,0 +1,655 @@
<?php
/**
* Because those objects will be spawned during boostrap all its configuration
* must be set in the settings.php file.
*
* You will find the driver specific implementation in the Redis_Cache_*
* classes as they may differ in how the API handles transaction, pipelining
* and return values.
*/
class Redis_Cache
implements DrupalCacheInterface
{
/**
* Default lifetime for permanent items.
* Approximatively 1 year.
*/
const LIFETIME_PERM_DEFAULT = 31536000;
/**
* Uses EVAL scripts to flush data when called
*
* This remains the default behavior and is safe until you use a single
* Redis server instance and its version is >= 2.6 (older version don't
* support EVAL).
*/
const FLUSH_NORMAL = 0;
/**
* This mode is tailored for sharded Redis servers instances usage: it
* will never delete entries but only mark the latest flush timestamp
* into one of the servers in the shard. It will proceed to delete on
* read single entries when invalid entries are being loaded.
*/
const FLUSH_SHARD = 3;
/**
* Same as the one above, plus attempt to do pipelining when possible.
*
* This is supposed to work with sharding proxies that supports
* pipelining themselves, such as Twemproxy.
*/
const FLUSH_SHARD_WITH_PIPELINING = 4;
/**
* Computed keys are let's say arround 60 characters length due to
* key prefixing, which makes 1,000 keys DEL command to be something
* arround 50,000 bytes length: this is huge and may not pass into
* Redis, let's split this off.
* Some recommend to never get higher than 1,500 bytes within the same
* command which makes us forced to split this at a very low threshold:
* 20 seems a safe value here (1,280 average length).
*/
const KEY_THRESHOLD = 20;
/**
* @var Redis_Cache_BackendInterface
*/
protected $backend;
/**
* @var string
*/
protected $bin;
/**
* When the global 'cache_lifetime' Drupal variable is set to a value, the
* cache backends should not expire temporary entries by themselves per
* Drupal signature. Volatile items will be dropped accordingly to their
* set lifetime.
*
* @var boolean
*/
protected $allowTemporaryFlush = true;
/**
* When in shard mode, the backend cannot proceed to multiple keys
* operations, and won't delete keys on flush calls.
*
* @var boolean
*/
protected $isSharded = false;
/**
* When in shard mode, the proxy may or may not support pipelining,
* Twemproxy is known to support it.
*
* @var boolean
*/
protected $allowPipeline = false;
/**
* Default TTL for CACHE_PERMANENT items.
*
* See "Default lifetime for permanent items" section of README.txt
* file for a comprehensive explaination of why this exists.
*
* @var int
*/
protected $permTtl = self::LIFETIME_PERM_DEFAULT;
/**
* Maximum TTL for this bin from Drupal configuration.
*
* @var int
*/
protected $maxTtl = 0;
/**
* Flush permanent and volatile cached values
*
* @var string[]
* First value is permanent latest flush time and second value
* is volatile latest flush time
*/
protected $flushCache = null;
/**
* Is this bin in shard mode
*
* @return boolean
*/
public function isSharded()
{
return $this->isSharded;
}
/**
* Does this bin allow pipelining through sharded environment
*
* @return boolean
*/
public function allowPipeline()
{
return $this->allowPipeline;
}
/**
* Does this bin allow temporary item flush
*
* @return boolean
*/
public function allowTemporaryFlush()
{
return $this->allowTemporaryFlush;
}
/**
* Get TTL for CACHE_PERMANENT items.
*
* @return int
* Lifetime in seconds.
*/
public function getPermTtl()
{
return $this->permTtl;
}
/**
* Get maximum TTL for all items.
*
* @return int
* Lifetime in seconds.
*/
public function getMaxTtl()
{
return $this->maxTtl;
}
/**
* {@inheritdoc}
*/
public function __construct($bin)
{
$this->bin = $bin;
$className = Redis_Client::getClass(Redis_Client::REDIS_IMPL_CACHE);
$this->backend = new $className(Redis_Client::getClient(), $bin, Redis_Client::getDefaultPrefix($bin));
$this->refreshCapabilities();
$this->refreshPermTtl();
$this->refreshMaxTtl();
}
/**
* Find from Drupal variables the clear mode.
*/
public function refreshCapabilities()
{
if (0 < variable_get('cache_lifetime', 0)) {
// Per Drupal default behavior, when the 'cache_lifetime' variable
// is set we must not flush any temporary items since they have a
// life time.
$this->allowTemporaryFlush = false;
}
if (null !== ($mode = variable_get('redis_flush_mode', null))) {
$mode = (int)$mode;
} else {
$mode = self::FLUSH_NORMAL;
}
$this->isSharded = self::FLUSH_SHARD === $mode || self::FLUSH_SHARD_WITH_PIPELINING === $mode;
$this->allowPipeline = self::FLUSH_SHARD !== $mode;
}
/**
* Find from Drupal variables the right permanent items TTL.
*/
protected function refreshPermTtl()
{
$ttl = null;
if (null === ($ttl = variable_get('redis_perm_ttl_' . $this->bin, null))) {
if (null === ($ttl = variable_get('redis_perm_ttl', null))) {
$ttl = self::LIFETIME_PERM_DEFAULT;
}
}
if ($ttl === (int)$ttl) {
$this->permTtl = $ttl;
} else {
if ($iv = DateInterval::createFromDateString($ttl)) {
// http://stackoverflow.com/questions/14277611/convert-dateinterval-object-to-seconds-in-php
$this->permTtl = ($iv->y * 31536000 + $iv->m * 2592000 + $iv->d * 86400 + $iv->h * 3600 + $iv->i * 60 + $iv->s);
} else {
// Sorry but we have to log this somehow.
trigger_error(sprintf("Parsed TTL '%s' has an invalid value: switching to default", $ttl));
$this->permTtl = self::LIFETIME_PERM_DEFAULT;
}
}
}
/**
* Find from Drupal variables the maximum cache lifetime.
*/
public function refreshMaxTtl()
{
// And now cache lifetime. Be aware we exclude negative values
// considering those are Drupal misconfiguration.
$maxTtl = variable_get('cache_lifetime', 0);
if (0 < $maxTtl) {
if ($maxTtl < $this->permTtl) {
$this->maxTtl = $maxTtl;
} else {
$this->maxTtl = $this->permTtl;
}
} else if ($this->permTtl) {
$this->maxTtl = $this->permTtl;
}
}
/**
* Set last flush time
*
* @param string $permanent
* @param string $volatile
*/
public function setLastFlushTime($permanent = false, $volatile = false)
{
// Here we need to fetch absolute values from backend, to avoid
// concurrency problems and ensure data validity.
list($flushPerm, $flushVolatile) = $this->backend->getLastFlushTime();
$checksum = $this->getValidChecksum(
max(array(
$flushPerm,
$flushVolatile,
$permanent,
time(),
))
);
if ($permanent) {
$this->backend->setLastFlushTimeFor($checksum, false);
$this->backend->setLastFlushTimeFor($checksum, true);
$this->flushCache = array($checksum, $checksum);
} else if ($volatile) {
$this->backend->setLastFlushTimeFor($checksum, true);
$this->flushCache = array($flushPerm, $checksum);
}
}
/**
* Get latest flush time
*
* @return string[]
* First value is the latest flush time for permanent entries checksum,
* second value is the latest flush time for volatile entries checksum.
*/
public function getLastFlushTime()
{
if (!$this->flushCache) {
$this->flushCache = $this->backend->getLastFlushTime();
}
// At the very first hit, we might not have the timestamps set, thus
// we need to create them to avoid our entry being considered as
// invalid
if (!$this->flushCache[0]) {
$this->setLastFlushTime(true, true);
} else if (!$this->flushCache[1]) {
$this->setLastFlushTime(false, true);
}
return $this->flushCache;
}
/**
* Create cache entry
*
* @param string $cid
* @param mixed $data
*
* @return array
*/
protected function createEntryHash($cid, $data, $expire = CACHE_PERMANENT)
{
list($flushPerm, $flushVolatile) = $this->getLastFlushTime();
if (CACHE_TEMPORARY === $expire) {
$validityThreshold = max(array($flushVolatile, $flushPerm));
} else {
$validityThreshold = $flushPerm;
}
$time = $this->getValidChecksum($validityThreshold);
$hash = array(
'cid' => $cid,
'created' => $time,
'expire' => $expire,
);
// Let Redis handle the data types itself.
if (!is_string($data)) {
$hash['data'] = serialize($data);
$hash['serialized'] = 1;
} else {
$hash['data'] = $data;
$hash['serialized'] = 0;
}
return $hash;
}
/**
* Expand cache entry from fetched data
*
* @param array $values
* Raw values fetched from Redis server data
*
* @return array
* Or FALSE if entry is invalid
*/
protected function expandEntry(array $values, $flushPerm, $flushVolatile)
{
// Check for entry being valid.
if (empty($values['cid'])) {
return;
}
// This ensures backward compatibility with older version of
// this module's data still stored in Redis.
if (isset($values['expire'])) {
$expire = (int)$values['expire'];
// Ensure the entry is valid and have not expired.
if ($expire !== CACHE_PERMANENT && $expire !== CACHE_TEMPORARY && $expire <= time()) {
return false;
}
}
// Ensure the entry does not predate the last flush time.
if ($this->allowTemporaryFlush && !empty($values['volatile'])) {
$validityThreshold = max(array($flushPerm, $flushVolatile));
} else {
$validityThreshold = $flushPerm;
}
if ($values['created'] <= $validityThreshold) {
return false;
}
$entry = (object)$values;
// Reduce the checksum to the real timestamp part
$entry->created = (int)$entry->created;
if ($entry->serialized) {
$entry->data = unserialize($entry->data);
}
return $entry;
}
/**
* {@inheritdoc}
*/
public function get($cid)
{
$values = $this->backend->get($cid);
if (empty($values)) {
return false;
}
list($flushPerm, $flushVolatile) = $this->getLastFlushTime();
$entry = $this->expandEntry($values, $flushPerm, $flushVolatile);
if (!$entry) { // This entry exists but is invalid.
$this->backend->delete($cid);
return false;
}
return $entry;
}
/**
* {@inheritdoc}
*/
public function getMultiple(&$cids)
{
$ret = array();
$delete = array();
if (!$this->allowPipeline) {
$entries = array();
foreach ($cids as $cid) {
if ($entry = $this->backend->get($cid)) {
$entries[$cid] = $entry;
}
}
} else {
$entries = $this->backend->getMultiple($cids);
}
list($flushPerm, $flushVolatile) = $this->getLastFlushTime();
foreach ($cids as $key => $cid) {
if (!empty($entries[$cid])) {
$entry = $this->expandEntry($entries[$cid], $flushPerm, $flushVolatile);
} else {
$entry = null;
}
if (empty($entry)) {
$delete[] = $cid;
} else {
$ret[$cid] = $entry;
unset($cids[$key]);
}
}
if (!empty($delete)) {
if ($this->allowPipeline) {
foreach ($delete as $id) {
$this->backend->delete($id);
}
} else {
$this->backend->deleteMultiple($delete);
}
}
return $ret;
}
/**
* {@inheritdoc}
*/
public function set($cid, $data, $expire = CACHE_PERMANENT)
{
$hash = $this->createEntryHash($cid, $data, $expire);
$maxTtl = $this->getMaxTtl();
switch ($expire) {
case CACHE_PERMANENT:
$this->backend->set($cid, $hash, $maxTtl, false);
break;
case CACHE_TEMPORARY:
$this->backend->set($cid, $hash, $maxTtl, true);
break;
default:
$ttl = $expire - time();
// Ensure $expire consistency
if ($ttl <= 0) {
// Entry has already expired, but we may have a stalled
// older cache entry remaining there, ensure it wont
// happen by doing a preventive delete
$this->backend->delete($cid);
} else {
if ($maxTtl && $maxTtl < $ttl) {
$ttl = $maxTtl;
}
$this->backend->set($cid, $hash, $ttl, false);
}
break;
}
}
/**
* {@inheritdoc}
*/
public function clear($cid = null, $wildcard = false)
{
if (null === $cid && !$wildcard) {
// Drupal asked for volatile entries flush, this will happen
// during cron run, mostly
$this->setLastFlushTime(false, true);
if (!$this->isSharded && $this->allowTemporaryFlush) {
$this->backend->flushVolatile();
}
} else if ($wildcard) {
if (empty($cid)) {
// This seems to be an error, just do nothing.
return;
}
if ('*' === $cid) {
// Use max() to ensure we invalidate both correctly
$this->setLastFlushTime(true);
if (!$this->isSharded) {
$this->backend->flush();
}
} else {
if (!$this->isSharded) {
$this->backend->deleteByPrefix($cid);
} else {
// @todo This needs a map algorithm the same way memcache
// module implemented it for invalidity by prefixes. This
// is a very stupid fallback
$this->setLastFlushTime(true);
}
}
} else if (is_array($cid)) {
$this->backend->deleteMultiple($cid);
} else {
$this->backend->delete($cid);
}
}
public function isEmpty()
{
return false;
}
/**
* From the given timestamp build an incremental safe time-based identifier.
*
* Due to potential accidental cache wipes, when a server goes down in the
* cluster or when a server triggers its LRU algorithm wipe-out, keys that
* matches flush or tags checksum might be dropped.
*
* Per default, each new inserted tag will trigger a checksum computation to
* be stored in the Redis server as a timestamp. In order to ensure a checksum
* validity a simple comparison between the tag checksum and the cache entry
* checksum will tell us if the entry pre-dates the current checksum or not,
* thus telling us its state. The main problem we experience is that Redis
* is being so fast it is able to create and drop entries at same second,
* sometime even the same micro second. The only safe way to avoid conflicts
* is to checksum using an arbitrary computed number (a sequence).
*
* Drupal core does exactly this thus tags checksums are additions of each tag
* individual checksum; each tag checksum is a independent arbitrary serial
* that gets incremented starting with 0 (no invalidation done yet) to n (n
* invalidations) which grows over time. This way the checksum computation
* always rises and we have a sensible default that works in all cases.
*
* This model works as long as you can ensure consistency for the serial
* storage over time. Nevertheless, as explained upper, in our case this
* serial might be dropped at some point for various valid technical reasons:
* if we start over to 0, we may accidentally compute a checksum which already
* existed in the past and make invalid entries turn back to valid again.
*
* In order to prevent this behavior, using a timestamp as part of the serial
* ensures that we won't experience this problem in a time range wider than a
* single second, which is safe enough for us. But using timestamp creates a
* new problem: Redis is so fast that we can set or delete hundreds of entries
* easily during the same second: an entry created then invalidated the same
* second will create false positives (entry is being considered as valid) -
* note that depending on the check algorithm, false negative may also happen
* the same way. Therefore we need to have an abitrary serial value to be
* incremented in order to enforce our checks to be more strict.
*
* The solution to both the first (the need for a time based checksum in case
* of checksum data being dropped) and the second (the need to have an
* arbitrary predictible serial value to avoid false positives or negatives)
* we are combining the two: every checksum will be built this way:
*
* UNIXTIMESTAMP.SERIAL
*
* For example:
*
* 1429789217.017
*
* will reprensent the 17th invalidation of the 1429789217 exact second which
* happened while writing this documentation. The next tag being invalidated
* the same second will then have this checksum:
*
* 1429789217.018
*
* And so on...
*
* In order to make it consitent with PHP string and float comparison we need
* to set fixed precision over the decimal, and store as a string to avoid
* possible float precision problems when comparing.
*
* This algorithm is not fully failsafe, but allows us to proceed to 1000
* operations on the same checksum during the same second, which is a
* sufficiently great value to reduce the conflict probability to almost
* zero for most uses cases.
*
* @param int|string $timestamp
* "TIMESTAMP[.INCREMENT]" string
*
* @return string
* The next "TIMESTAMP.INCREMENT" string.
*/
public function getNextIncrement($timestamp = null)
{
if (!$timestamp) {
return time() . '.000';
}
if (false !== ($pos = strpos($timestamp, '.'))) {
$inc = substr($timestamp, $pos + 1, 3);
return ((int)$timestamp) . '.' . str_pad($inc + 1, 3, '0', STR_PAD_LEFT);
}
return $timestamp . '.000';
}
/**
* Get valid checksum
*
* @param int|string $previous
* "TIMESTAMP[.INCREMENT]" string
*
* @return string
* The next "TIMESTAMP.INCREMENT" string.
*
* @see Redis_Cache::getNextIncrement()
*/
public function getValidChecksum($previous = null)
{
if (time() === (int)$previous) {
return $this->getNextIncrement($previous);
} else {
return $this->getNextIncrement();
}
}
}

View File

@@ -0,0 +1,102 @@
<?php
/**
* Real cache backend primitives. This functions will be used by the
* Redis_Cache wrapper class that implements the high-level logic that
* allows us to be Drupal compatible.
*/
interface Redis_Cache_BackendInterface extends Redis_BackendInterface
{
/**
* Defaut constructor
*
* @param string $namespace
*/
public function __construct($client, $namespace);
/**
* Get namespace
*
* @return string
*/
public function getNamespace();
/**
* Set last flush time
*
* @param int $time
* @param boolean $volatile
*/
public function setLastFlushTimeFor($time, $volatile = false);
/**
* Get last flush time
*
* @return int[]
* First value is for non-volatile items, second value is for volatile items.
*/
public function getLastFlushTime();
/**
* Get a single entry
*
* @param string $id
*
* @return stdClass
* Cache entry or false if the entry does not exists.
*/
public function get($id);
/**
* Get multiple entries
*
* @param string[] $idList
*
* @return stdClass[]
* Existing cache entries keyed by id,
*/
public function getMultiple(array $idList);
/**
* Set a single entry
*
* @param string $id
* @param mixed $data
* @param int $ttl
* @param boolean $volatile
*/
public function set($id, $data, $ttl = null, $volatile = false);
/**
* Delete a single entry
*
* @param string $cid
*/
public function delete($id);
/**
* Delete multiple entries
*
* This method should not use a single DEL command but use a pipeline instead
*
* @param array $idList
*/
public function deleteMultiple(array $idList);
/**
* Delete entries by prefix
*
* @param string $prefix
*/
public function deleteByPrefix($prefix);
/**
* Flush all entries
*/
public function flush();
/**
* Flush all entries marked as temporary
*/
public function flushVolatile();
}

View File

@@ -0,0 +1,39 @@
<?php
/**
* @todo
* - Improve lua scripts by using SCAN family commands
* - Deambiguate why we need the namespace only for flush*() operations
* - Implement the isEmpty() method by using SCAN or KEYS
*/
abstract class Redis_Cache_Base extends Redis_AbstractBackend
{
/**
* Lastest cache flush KEY name
*/
const LAST_FLUSH_KEY = '_last_flush';
/**
* Delete by prefix lua script
*/
const EVAL_DELETE_PREFIX = <<<EOT
local keys = redis.call("KEYS", ARGV[1])
for i, k in ipairs(keys) do
redis.call("DEL", k)
end
return 1
EOT;
/**
* Delete volatile by prefix lua script
*/
const EVAL_DELETE_VOLATILE = <<<EOT
local keys = redis.call('KEYS', ARGV[1])
for i, k in ipairs(keys) do
if "1" == redis.call("HGET", k, "volatile") then
redis.call("DEL", k)
end
end
return 1
EOT;
}

View File

@@ -0,0 +1,149 @@
<?php
/**
* Predis cache backend.
*/
class Redis_Cache_PhpRedis extends Redis_Cache_Base
{
public function setLastFlushTimeFor($time, $volatile = false)
{
$client = $this->getClient();
$key = $this->getKey(self::LAST_FLUSH_KEY);
if ($volatile) {
$client->hset($key, 'volatile', $time);
} else {
$client->hmset($key, array(
'permanent' => $time,
'volatile' => $time,
));
}
}
public function getLastFlushTime()
{
$client = $this->getClient();
$key = $this->getKey(self::LAST_FLUSH_KEY);
$values = $client->hmget($key, array("permanent", "volatile"));
if (empty($values) || !is_array($values)) {
$ret = array(0, 0);
} else {
if (empty($values['permanent'])) {
$values['permanent'] = 0;
}
if (empty($values['volatile'])) {
$values['volatile'] = 0;
}
$ret = array($values['permanent'], $values['volatile']);
}
return $ret;
}
public function get($id)
{
$client = $this->getClient();
$key = $this->getKey($id);
$values = $client->hgetall($key);
// Recent versions of PhpRedis will return the Redis instance
// instead of an empty array when the HGETALL target key does
// not exists. I see what you did there.
if (empty($values) || !is_array($values)) {
return false;
}
return $values;
}
public function getMultiple(array $idList)
{
$client = $this->getClient();
$ret = array();
$pipe = $client->multi(Redis::PIPELINE);
foreach ($idList as $id) {
$pipe->hgetall($this->getKey($id));
}
$replies = $pipe->exec();
foreach (array_values($idList) as $line => $id) {
if (!empty($replies[$line]) && is_array($replies[$line])) {
$ret[$id] = $replies[$line];
}
}
return $ret;
}
public function set($id, $data, $ttl = null, $volatile = false)
{
// Ensure TTL consistency: if the caller gives us an expiry timestamp
// in the past the key will expire now and will never be read.
// Behavior between Predis and PhpRedis seems to change here: when
// setting a negative expire time, PhpRedis seems to ignore the
// command and leave the key permanent.
if (null !== $ttl && $ttl <= 0) {
return;
}
$data['volatile'] = (int)$volatile;
$client = $this->getClient();
$key = $this->getKey($id);
$pipe = $client->multi(Redis::PIPELINE);
$pipe->hmset($key, $data);
if (null !== $ttl) {
$pipe->expire($key, $ttl);
}
$pipe->exec();
}
public function delete($id)
{
$this->getClient()->del($this->getKey($id));
}
public function deleteMultiple(array $idList)
{
$client = $this->getClient();
$pipe = $client->multi(Redis::PIPELINE);
foreach ($idList as $id) {
$pipe->del($this->getKey($id));
}
// Don't care if something failed.
$pipe->exec();
}
public function deleteByPrefix($prefix)
{
$client = $this->getClient();
$ret = $client->eval(self::EVAL_DELETE_PREFIX, array($this->getKey($prefix . '*')));
if (1 != $ret) {
trigger_error(sprintf("EVAL failed: %s", $client->getLastError()), E_USER_ERROR);
}
}
public function flush()
{
$client = $this->getClient();
$ret = $client->eval(self::EVAL_DELETE_PREFIX, array($this->getKey('*')));
if (1 != $ret) {
trigger_error(sprintf("EVAL failed: %s", $client->getLastError()), E_USER_ERROR);
}
}
public function flushVolatile()
{
$client = $this->getClient();
$ret = $client->eval(self::EVAL_DELETE_VOLATILE, array($this->getKey('*')));
if (1 != $ret) {
trigger_error(sprintf("EVAL failed: %s", $client->getLastError()), E_USER_ERROR);
}
}
}

View File

@@ -0,0 +1,145 @@
<?php
/**
* Predis cache backend.
*/
class Redis_Cache_Predis extends Redis_Cache_Base
{
public function setLastFlushTimeFor($time, $volatile = false)
{
$client = $this->getClient();
$key = $this->getKey(self::LAST_FLUSH_KEY);
if ($volatile) {
$client->hset($key, 'volatile', $time);
} else {
$client->hmset($key, array(
'permanent' => $time,
'volatile' => $time,
));
}
}
public function getLastFlushTime()
{
$client = $this->getClient();
$key = $this->getKey(self::LAST_FLUSH_KEY);
$values = $client->hmget($key, array("permanent", "volatile"));
if (empty($values) || !is_array($values)) {
$values = array(0, 0);
} else {
if (empty($values[0])) {
$values[0] = 0;
}
if (empty($values[1])) {
$values[1] = 0;
}
}
return $values;
}
public function get($id)
{
$client = $this->getClient();
$key = $this->getKey($id);
$values = $client->hgetall($key);
// Recent versions of PhpRedis will return the Redis instance
// instead of an empty array when the HGETALL target key does
// not exists. I see what you did there.
if (empty($values) || !is_array($values)) {
return false;
}
return $values;
}
public function getMultiple(array $idList)
{
$ret = array();
$pipe = $this->getClient()->pipeline();
foreach ($idList as $id) {
$pipe->hgetall($this->getKey($id));
}
$replies = $pipe->execute();
foreach (array_values($idList) as $line => $id) {
// HGETALL signature seems to differ depending on Predis versions.
// This was found just after Predis update. Even though I'm not sure
// this comes from Predis or just because we're misusing it.
if (!empty($replies[$line]) && is_array($replies[$line])) {
$ret[$id] = $replies[$line];
}
}
return $ret;
}
public function set($id, $data, $ttl = null, $volatile = false)
{
// Ensure TTL consistency: if the caller gives us an expiry timestamp
// in the past the key will expire now and will never be read.
// Behavior between Predis and PhpRedis seems to change here: when
// setting a negative expire time, PhpRedis seems to ignore the
// command and leave the key permanent.
if (null !== $ttl && $ttl <= 0) {
return;
}
$key = $this->getKey($id);
$data['volatile'] = (int)$volatile;
$pipe = $this->getClient()->pipeline();
$pipe->hmset($key, $data);
if (null !== $ttl) {
$pipe->expire($key, $ttl);
}
$pipe->execute();
}
public function delete($id)
{
$client = $this->getClient();
$client->del($this->getKey($id));
}
public function deleteMultiple(array $idList)
{
$pipe = $this->getClient()->pipeline();
foreach ($idList as $id) {
$pipe->del($this->getKey($id));
}
$pipe->execute();
}
public function deleteByPrefix($prefix)
{
$client = $this->getClient();
$ret = $client->eval(self::EVAL_DELETE_PREFIX, 0, $this->getKey($prefix . '*'));
if (1 != $ret) {
trigger_error(sprintf("EVAL failed: %s", $client->getLastError()), E_USER_ERROR);
}
}
public function flush()
{
$client = $this->getClient();
$ret = $client->eval(self::EVAL_DELETE_PREFIX, 0, $this->getKey('*'));
if (1 != $ret) {
trigger_error(sprintf("EVAL failed: %s", $client->getLastError()), E_USER_ERROR);
}
}
public function flushVolatile()
{
$client = $this->getClient();
$ret = $client->eval(self::EVAL_DELETE_VOLATILE, 0, $this->getKey('*'));
if (1 != $ret) {
trigger_error(sprintf("EVAL failed: %s", $client->getLastError()), E_USER_ERROR);
}
}
}

View File

@@ -0,0 +1,66 @@
<?php
/**
* This typically brings 80..85% compression in ~20ms/mb write, 5ms/mb read.
*/
class Redis_CacheCompressed extends Redis_Cache implements DrupalCacheInterface
{
private $compressionSizeThreshold = 100;
private $compressionRatio = 1;
/**
* {@inheritdoc}
*/
public function __construct($bin)
{
parent::__construct($bin);
$this->compressionSizeThreshold = (int)variable_get('cache_compression_size_threshold', 100);
if ($this->compressionSizeThreshold < 0) {
trigger_error('cache_compression_size_threshold must be 0 or a positive integer, negative value found, switching back to default 100', E_USER_WARNING);
$this->compressionSizeThreshold = 100;
}
// Minimum compression level (1) has good ratio in low time.
$this->compressionRatio = (int)variable_get('cache_compression_ratio', 1);
if ($this->compressionRatio < 1 || 9 < $this->compressionRatio) {
trigger_error('cache_compression_ratio must be between 1 and 9, out of bounds value found, switching back to default 1', E_USER_WARNING);
$this->compressionRatio = 1;
}
}
/**
* {@inheritdoc}
*/
protected function createEntryHash($cid, $data, $expire = CACHE_PERMANENT)
{
$hash = parent::createEntryHash($cid, $data, $expire);
// Empiric level when compression makes sense.
if (!$this->compressionSizeThreshold || strlen($hash['data']) > $this->compressionSizeThreshold) {
$hash['data'] = gzcompress($hash['data'], $this->compressionRatio);
$hash['compressed'] = true;
}
return $hash;
}
/**
* {@inheritdoc}
*/
protected function expandEntry(array $values, $flushPerm, $flushVolatile)
{
if (!empty($values['data']) && !empty($values['compressed'])) {
// Uncompress, suppress warnings e.g. for broken CRC32.
$values['data'] = @gzuncompress($values['data']);
// In such cases, void the cache entry.
if ($values['data'] === false) {
return false;
}
}
return parent::expandEntry($values, $flushPerm, $flushVolatile);
}
}

View File

@@ -0,0 +1,241 @@
<?php
// It may happen we get here with no autoloader set during the Drupal core
// early bootstrap phase, at cache backend init time.
if (!interface_exists('Redis_Client_FactoryInterface')) {
require_once dirname(__FILE__) . '/Client/FactoryInterface.php';
require_once dirname(__FILE__) . '/Client/Manager.php';
}
/**
* This static class only reason to exist is to tie Drupal global
* configuration to OOP driven code of this module: it will handle
* everything that must be read from global configuration and let
* other components live without any existence of it
*/
class Redis_Client
{
/**
* Cache implementation namespace.
*/
const REDIS_IMPL_CACHE = 'Redis_Cache_';
/**
* Lock implementation namespace.
*/
const REDIS_IMPL_LOCK = 'Redis_Lock_';
/**
* Cache implementation namespace.
*/
const REDIS_IMPL_QUEUE = 'Redis_Queue_';
/**
* Path implementation namespace.
*/
const REDIS_IMPL_PATH = 'Redis_Path_';
/**
* Client factory implementation namespace.
*/
const REDIS_IMPL_CLIENT = 'Redis_Client_';
/**
* @var Redis_Client_Manager
*/
private static $manager;
/**
* @var string
*/
static protected $globalPrefix;
/**
* Get site default global prefix
*
* @return string
*/
static public function getGlobalPrefix()
{
// Provide a fallback for multisite. This is on purpose not inside the
// getPrefixForBin() function in order to decouple the unified prefix
// variable logic and custom module related security logic, that is not
// necessary for all backends. We can't just use HTTP_HOST, as multiple
// hosts might be using the same database. Or, more commonly, a site
// might not be a multisite at all, but might be using Drush leading to
// a separate HTTP_HOST of 'default'. Likewise, we can't rely on
// conf_path(), as settings.php might be modifying what database to
// connect to. To mirror what core does with database caching we use
// the DB credentials to inform our cache key.
if (null === self::$globalPrefix) {
if (isset($GLOBALS['db_url']) && is_string($GLOBALS['db_url'])) {
// Drupal 6 specifics when using the cache_backport module, we
// therefore cannot use \Database class to determine database
// settings.
self::$globalPrefix = md5($GLOBALS['db_url']);
} else {
require_once DRUPAL_ROOT . '/includes/database/database.inc';
$dbInfo = Database::getConnectionInfo();
$active = $dbInfo['default'];
self::$globalPrefix = md5($active['host'] . $active['database'] . $active['prefix']['default']);
}
}
return self::$globalPrefix;
}
/**
* Get global default prefix
*
* @param string $namespace
*
* @return string
*/
static public function getDefaultPrefix($namespace = null)
{
$ret = null;
if (!empty($GLOBALS['drupal_test_info']['test_run_id'])) {
$ret = $GLOBALS['drupal_test_info']['test_run_id'];
} else {
$prefixes = variable_get('cache_prefix', null);
if (is_string($prefixes)) {
// Variable can be a string which then considered as a default
// behavior.
$ret = $prefixes;
} else if (null !== $namespace && isset($prefixes[$namespace])) {
if (false !== $prefixes[$namespace]) {
// If entry is set and not false an explicit prefix is set
// for the bin.
$ret = $prefixes[$namespace];
} else {
// If we have an explicit false it means no prefix whatever
// is the default configuration.
$ret = '';
}
} else {
// Key is not set, we can safely rely on default behavior.
if (isset($prefixes['default']) && false !== $prefixes['default']) {
$ret = $prefixes['default'];
} else {
// When default is not set or an explicit false this means
// no prefix.
$ret = '';
}
}
}
if (empty($ret)) {
$ret = Redis_Client::getGlobalPrefix();
}
return $ret;
}
/**
* Get client manager
*
* @return Redis_Client_Manager
*/
static public function getManager()
{
global $conf;
if (null === self::$manager) {
$className = self::getClass(self::REDIS_IMPL_CLIENT);
$factory = new $className();
// Build server list from conf
$serverList = array();
if (isset($conf['redis_servers'])) {
$serverList = $conf['redis_servers'];
}
if (empty($serverList) || !isset($serverList['default'])) {
// Backward configuration compatibility with older versions
$serverList[Redis_Client_Manager::REALM_DEFAULT] = array();
foreach (array('host', 'port', 'base', 'password', 'socket') as $key) {
if (isset($conf['redis_client_' . $key])) {
$serverList[Redis_Client_Manager::REALM_DEFAULT][$key] = $conf['redis_client_' . $key];
}
}
}
self::$manager = new Redis_Client_Manager($factory, $serverList);
}
return self::$manager;
}
/**
* Find client class name
*
* @return string
*/
static public function getClientInterfaceName()
{
global $conf;
if (!empty($conf['redis_client_interface'])) {
return $conf['redis_client_interface'];
} else if (class_exists('Predis\Client')) {
// Transparent and abitrary preference for Predis library.
return $conf['redis_client_interface'] = 'Predis';
} else if (class_exists('Redis')) {
// Fallback on PhpRedis if available.
return $conf['redis_client_interface'] = 'PhpRedis';
} else {
throw new Exception("No client interface set.");
}
}
/**
* For unit test use only
*/
static public function reset(Redis_Client_Manager $manager = null)
{
self::$manager = $manager;
}
/**
* Get the client for the 'default' realm
*
* @return mixed
*
* @deprecated
*/
public static function getClient()
{
return self::getManager()->getClient();
}
/**
* Get specific class implementing the current client usage for the specific
* asked core subsystem.
*
* @param string $system
* One of the Redis_Client::IMPL_* constant.
* @param string $clientName
* Client name, if fixed.
*
* @return string
* Class name, if found.
*
* @deprecated
*/
static public function getClass($system)
{
$class = $system . self::getClientInterfaceName();
if (!class_exists($class)) {
throw new Exception(sprintf("Class '%s' does not exist", $class));
}
return $class;
}
}

View File

@@ -0,0 +1,32 @@
<?php
/**
* Client proxy, client handling class tied to the bare mininum.
*/
interface Redis_Client_FactoryInterface {
/**
* Get the connected client instance.
*
* @param array $options
* Options from the server pool configuration that may contain:
* - host
* - port
* - database
* - password
* - socket
*
* @return mixed
* Real client depends from the library behind.
*/
public function getClient($options = array());
/**
* Get underlaying library name used.
*
* This can be useful for contribution code that may work with only some of
* the provided clients.
*
* @return string
*/
public function getName();
}

View File

@@ -0,0 +1,144 @@
<?php
/**
* Client pool manager for multi-server configurations
*/
class Redis_Client_Manager
{
/**
* Redis default host
*/
const REDIS_DEFAULT_HOST = '127.0.0.1';
/**
* Redis default port
*/
const REDIS_DEFAULT_PORT = 6379;
/**
* Redis default socket (will override host and port)
*/
const REDIS_DEFAULT_SOCKET = null;
/**
* Redis default database: will select none (Database 0)
*/
const REDIS_DEFAULT_BASE = null;
/**
* Redis default password: will not authenticate
*/
const REDIS_DEFAULT_PASSWORD = null;
/**
* Default realm
*/
const REALM_DEFAULT = 'default';
/**
* Client interface name (PhpRedis or Predis)
*
* @var string
*/
private $interfaceName;
/**
* @var array[]
*/
private $serverList = array();
/**
* @var mixed[]
*/
private $clients = array();
/**
* @var Redis_Client_FactoryInterface
*/
private $factory;
/**
* Default constructor
*
* @param Redis_Client_FactoryInterface $factory
* Client factory
* @param array $serverList
* Server connection info list
*/
public function __construct(Redis_Client_FactoryInterface $factory, $serverList = array())
{
$this->factory = $factory;
$this->serverList = $serverList;
}
/**
* Get client for the given realm
*
* @param string $realm
* @param boolean $allowDefault
*
* @return mixed
*/
public function getClient($realm = self::REALM_DEFAULT, $allowDefault = true)
{
if (!isset($this->clients[$realm])) {
$client = $this->createClient($realm);
if (false === $client) {
if (self::REALM_DEFAULT !== $realm && $allowDefault) {
$this->clients[$realm] = $this->getClient(self::REALM_DEFAULT);
} else {
throw new InvalidArgumentException(sprintf("Could not find client for realm '%s'", $realm));
}
} else {
$this->clients[$realm] = $client;
}
}
return $this->clients[$realm];
}
/**
* Build connection parameters array from current Drupal settings
*
* @param string $realm
*
* @return boolean|string[]
* A key-value pairs of configuration values or false if realm is
* not defined per-configuration
*/
private function buildOptions($realm)
{
$info = null;
if (isset($this->serverList[$realm])) {
$info = $this->serverList[$realm];
} else {
return false;
}
$info += array(
'host' => self::REDIS_DEFAULT_HOST,
'port' => self::REDIS_DEFAULT_PORT,
'base' => self::REDIS_DEFAULT_BASE,
'password' => self::REDIS_DEFAULT_PASSWORD,
'socket' => self::REDIS_DEFAULT_SOCKET
);
return array_filter($info);
}
/**
* Get client singleton
*/
private function createClient($realm)
{
$info = $this->buildOptions($realm);
if (false === $info) {
return false;
}
return $this->factory->getClient($info);
}
}

View File

@@ -0,0 +1,36 @@
<?php
/**
* PhpRedis client specific implementation.
*/
class Redis_Client_PhpRedis implements Redis_Client_FactoryInterface {
public function getClient($options = array()) {
$client = new Redis;
if (!empty($options['socket'])) {
$client->connect($options['socket']);
} else {
$client->connect($options['host'], $options['port']);
}
if (isset($options['password'])) {
$client->auth($options['password']);
}
if (isset($options['base'])) {
$client->select($options['base']);
}
// Do not allow PhpRedis serialize itself data, we are going to do it
// ourself. This will ensure less memory footprint on Redis size when
// we will attempt to store small values.
$client->setOption(Redis::OPT_SERIALIZER, Redis::SERIALIZER_NONE);
return $client;
}
public function getName() {
return 'PhpRedis';
}
}

View File

@@ -0,0 +1,145 @@
<?php
/**
* Predis client specific implementation.
*/
class Redis_Client_Predis implements Redis_Client_FactoryInterface {
/**
* Circular depedency breaker.
*/
static protected $autoloaderRegistered = false;
/**
* If the first cache get operation happens after the core autoloader has
* been registered to PHP, during our autoloader registration we will
* trigger it when calling class_exists(): core autoloader will then run
* cache_get() during autoloading but sadly this will run our autoloader
* registration once again. The second time we are called the circular
* dependency breaker will act and we will do nothing, ending up in a
* class instanciation attempt while the autoloader is still not loaded.
*/
static protected $stupidCoreWorkaround = 0;
/**
* Define Predis base path if not already set, and if we need to set the
* autoloader by ourself. This will ensure no crash. Best way would have
* been that Drupal ships a PSR-0 autoloader, in which we could manually
* add our library path.
*
* We cannot do that in the file header, PHP class_exists() function wont
* see classes being loaded during the autoloading because this file is
* loaded by another autoloader: attempting the class_exists() during a
* pending autoloading would cause PHP to crash and ignore the rest of the
* file silentely (WTF!?). By delaying this at the getClient() call we
* ensure we are not in the class loading process anymore.
*/
public static function setPredisAutoload() {
if (self::$autoloaderRegistered) {
return;
}
self::$stupidCoreWorkaround++;
// If you attempt to set Drupal's bin cache_bootstrap using Redis, you
// will experience an infinite loop (breaking by itself the second time
// it passes by): the following call will wake up autoloaders (and we
// want that to work since user may have set its own autoloader) but
// will wake up Drupal's one too, and because Drupal core caches its
// file map, this will trigger this method to be called a second time
// and boom! Adios bye bye. That's why this will be called early in the
// 'redis.autoload.inc' file instead.
if (1 < self::$stupidCoreWorkaround || !class_exists('Predis\Client')) {
if (!defined('PREDIS_BASE_PATH')) {
$search = DRUPAL_ROOT . '/sites/all/libraries/predis';
define('PREDIS_BASE_PATH', $search);
} else {
$search = PREDIS_BASE_PATH;
}
if (is_dir($search . '/src')) { // Predis v1.x
define('PREDIS_VERSION_MAJOR', 1);
} else if (is_dir($search . '/lib')) { // Predis v0.x
define('PREDIS_VERSION_MAJOR', 0);
} else {
throw new Exception("PREDIS_BASE_PATH constant must be set, Predis library must live in sites/all/libraries/predis.");
}
// Register a simple autoloader for Predis library. Since the Predis
// library is PHP 5.3 only, we can afford doing closures safely.
switch (PREDIS_VERSION_MAJOR) {
case 0:
$autoload = function($classname) { // PSR-0 autoloader.
if (0 === strpos($classname, 'Predis\\')) {
$filename = PREDIS_BASE_PATH . '/lib/' . str_replace('\\', '/', $classname) . '.php';
return (bool)require_once $filename;
}
return false;
};
break;
case 1:
// Register a simple autoloader for Predis library. Since the Predis
// library is PHP 5.3 only, we can afford doing closures safely.
$autoload = function($classname) { // PSR-4 autoloader
if (0 === strpos($classname, 'Predis\\')) {
$filename = PREDIS_BASE_PATH . '/src/' . str_replace('\\', '/', substr($classname, 7)) . '.php';
return (bool)require_once $filename;
}
return false;
};
break;
}
if ($autoload) {
spl_autoload_register($autoload);
}
// Same reason why we have the stupid core workaround, if this happens
// during a second autoload call, PHP won't call the newly registered
// autoloader function, so just load the file.
if (1 < self::$stupidCoreWorkaround) {
call_user_func($autoload, 'Predis\Client');
}
}
self::$autoloaderRegistered = true;
}
public function getClient($options = array()) {
self::setPredisAutoload();
if (!empty($options['socket'])) {
$options['scheme'] = 'unix';
$options['path'] = $options['socket'];
}
foreach ($options as $key => $value) {
if (!isset($value)) {
unset($options[$key]);
}
}
// I'm not sure why but the error handler is driven crazy if timezone
// is not set at this point.
// Hopefully Drupal will restore the right one this once the current
// account has logged in.
date_default_timezone_set(@date_default_timezone_get());
$client = new \Predis\Client($options);
if (isset($options['base']) && 0 !== $options['base']) {
$client->select((int)$options['base']);
}
return $client;
}
public function getName() {
return 'Predis';
}
}

View File

@@ -0,0 +1,31 @@
<?php
/**
* Lock backend singleton handling.
*/
class Redis_Lock {
/**
* @var Redis_Lock_BackendInterface
*/
private static $instance;
/**
* Get actual lock backend.
*
* @return Redis_Lock_BackendInterface
*/
public static function getBackend()
{
if (!isset(self::$instance)) {
$className = Redis_Client::getClass(Redis_Client::REDIS_IMPL_LOCK);
self::$instance = new $className(
Redis_Client::getClient(),
Redis_Client::getDefaultPrefix('lock')
);
}
return self::$instance;
}
}

View File

@@ -0,0 +1,61 @@
<?php
/**
* Lock backend interface.
*/
interface Redis_Lock_BackendInterface {
/**
* Acquire lock.
*
* @param string $name
* Lock name.
* @param float $timeout = 30.0
* (optional) Lock lifetime in seconds.
*
* @return bool
*/
public function lockAcquire($name, $timeout = 30.0);
/**
* Check if lock is available for acquire.
*
* @param string $name
* Lock to acquire.
*
* @return bool
*/
public function lockMayBeAvailable($name);
/**
* Wait a short amount of time before a second lock acquire attempt.
*
* @param string $name
* Lock name currently being locked.
* @param int $delay = 30
* Miliseconds to wait for.
*/
public function lockWait($name, $delay = 30);
/**
* Release given lock.
*
* @param string $name
*/
public function lockRelease($name);
/**
* Release all locks for the given lock token identifier.
*
* @param string $lockId = NULL
* (optional) If none given, remove all lock from the current page.
*/
public function lockReleaseAll($lock_id = NULL);
/**
* Get the unique page token for locks. Locks will be wipeout at each end of
* page request on a token basis.
*
* @return string
*/
public function getLockId();
}

View File

@@ -0,0 +1,89 @@
<?php
/**
* Lock backend shared methods.
*/
abstract class Redis_Lock_DefaultBackend
extends Redis_AbstractBackend
implements Redis_Lock_BackendInterface
{
/**
* Current page lock token identifier.
*
* @var string
*/
protected $_lockId;
/**
* Existing locks for this page.
*
* @var array
*/
protected $_locks = array();
/**
* Default implementation from actual Drupal core.
*
* @see Redis_Lock_BackendInterface::lockWait()
*/
public function lockWait($name, $delay = 30) {
// Pause the process for short periods between calling
// lock_may_be_available(). This prevents hitting the database with constant
// database queries while waiting, which could lead to performance issues.
// However, if the wait period is too long, there is the potential for a
// large number of processes to be blocked waiting for a lock, especially
// if the item being rebuilt is commonly requested. To address both of these
// concerns, begin waiting for 25ms, then add 25ms to the wait period each
// time until it reaches 500ms. After this point polling will continue every
// 500ms until $delay is reached.
// $delay is passed in seconds, but we will be using usleep(), which takes
// microseconds as a parameter. Multiply it by 1 million so that all
// further numbers are equivalent.
$delay = (int) $delay * 1000000;
// Begin sleeping at 25ms.
$sleep = 25000;
while ($delay > 0) {
// This function should only be called by a request that failed to get a
// lock, so we sleep first to give the parallel request a chance to finish
// and release the lock.
usleep($sleep);
// After each sleep, increase the value of $sleep until it reaches
// 500ms, to reduce the potential for a lock stampede.
$delay = $delay - $sleep;
$sleep = min(500000, $sleep + 25000, $delay);
if ($this->lockMayBeAvailable($name)) {
// No longer need to wait.
return FALSE;
}
}
// The caller must still wait longer to get the lock.
return TRUE;
}
/**
* Default implementation from actual Drupal core.
*
* @see Redis_Lock_BackendInterface::getLockId()
*/
public function getLockId() {
if (!isset($this->_lockId)) {
$this->_lockId = uniqid(mt_rand(), TRUE);
// We only register a shutdown function if a lock is used.
drupal_register_shutdown_function('lock_release_all', $this->_lockId);
}
return $this->_lockId;
}
/**
* Generate a redis key name for the current lock name
*/
public function getKey($name = null) {
if (null === $name) {
return parent::getKey('lock');
} else {
return parent::getKey(array('lock', $name));
}
}
}

View File

@@ -0,0 +1,138 @@
<?php
/**
* Predis lock backend implementation.
*
* This implementation works with a single key per lock so is viable when
* doing client side sharding and/or using consistent hashing algorithm.
*/
class Redis_Lock_PhpRedis extends Redis_Lock_DefaultBackend {
public function lockAcquire($name, $timeout = 30.0) {
$client = $this->getClient();
$key = $this->getKey($name);
$id = $this->getLockId();
// Insure that the timeout is at least 1 second, we cannot do otherwise with
// Redis, this is a minor change to the function signature, but in real life
// nobody will notice with so short duration.
$timeout = ceil(max($timeout, 1));
// If we already have the lock, check for his owner and attempt a new EXPIRE
// command on it.
if (isset($this->_locks[$name])) {
// Create a new transaction, for atomicity.
$client->watch($key);
// Global tells us we are the owner, but in real life it could have expired
// and another process could have taken it, check that.
if ($client->get($key) != $id) {
// Explicit UNWATCH we are not going to run the MULTI/EXEC block.
$client->unwatch();
unset($this->_locks[$name]);
return FALSE;
}
// See https://github.com/phpredis/phpredis#watch-unwatch
// MULTI and other commands can fail, so we can't chain calls.
if (FALSE !== ($result = $client->multi())) {
$client->setex($key, $timeout, $id);
$result = $client->exec();
}
// Did it broke?
if (FALSE === $result) {
unset($this->_locks[$name]);
// Explicit transaction release which also frees the WATCH'ed key.
$client->discard();
return FALSE;
}
return ($this->_locks[$name] = TRUE);
}
else {
$client->watch($key);
$owner = $client->get($key);
// If the $key is set they lock is not available
if (!empty($owner) && $id != $owner) {
$client->unwatch();
return FALSE;
}
// See https://github.com/phpredis/phpredis#watch-unwatch
// MULTI and other commands can fail, so we can't chain calls.
if (FALSE !== ($result = $client->multi())) {
$client->setex($key, $timeout, $id);
$result->exec();
}
// If another client modified the $key value, transaction will be discarded
// $result will be set to FALSE. This means atomicity have been broken and
// the other client took the lock instead of us.
if (FALSE === $result) {
// Explicit transaction release which also frees the WATCH'ed key.
$client->discard();
return FALSE;
}
// Register the lock.
return ($this->_locks[$name] = TRUE);
}
return FALSE;
}
public function lockMayBeAvailable($name) {
$client = $this->getClient();
$key = $this->getKey($name);
$id = $this->getLockId();
$value = $client->get($key);
return FALSE === $value || $id == $value;
}
public function lockRelease($name) {
$client = $this->getClient();
$key = $this->getKey($name);
$id = $this->getLockId();
unset($this->_locks[$name]);
// Ensure the lock deletion is an atomic transaction. If another thread
// manages to removes all lock, we can not alter it anymore else we will
// release the lock for the other thread and cause race conditions.
$client->watch($key);
if ($client->get($key) == $id) {
$client->multi();
$client->delete($key);
$client->exec();
}
else {
$client->unwatch();
}
}
public function lockReleaseAll($lock_id = NULL) {
if (!isset($lock_id) && empty($this->_locks)) {
return;
}
$client = $this->getClient();
$id = isset($lock_id) ? $lock_id : $this->getLockId();
// We can afford to deal with a slow algorithm here, this should not happen
// on normal run because we should have removed manually all our locks.
foreach (array_keys($this->_locks) as $name) {
$key = $this->getKey($name);
$owner = $client->get($key);
if (empty($owner) || $owner == $id) {
$client->delete($key);
}
}
}
}

View File

@@ -0,0 +1,137 @@
<?php
/**
* Predis lock backend implementation.
*
* This implementation works with a single key per lock so is viable when
* doing client side sharding and/or using consistent hashing algorithm.
*/
class Redis_Lock_Predis extends Redis_Lock_DefaultBackend {
public function lockAcquire($name, $timeout = 30.0) {
$client = $this->getClient();
$key = $this->getKey($name);
$id = $this->getLockId();
// Insure that the timeout is at least 1 second, we cannot do otherwise with
// Redis, this is a minor change to the function signature, but in real life
// nobody will notice with so short duration.
$timeout = ceil(max($timeout, 1));
// If we already have the lock, check for his owner and attempt a new EXPIRE
// command on it.
if (isset($this->_locks[$name])) {
// Create a new transaction, for atomicity.
$client->watch($key);
// Global tells us we are the owner, but in real life it could have expired
// and another process could have taken it, check that.
if ($client->get($key) != $id) {
$client->unwatch();
unset($this->_locks[$name]);
return FALSE;
}
$replies = $client->pipeline(function($pipe) use ($key, $timeout, $id) {
$pipe->multi();
$pipe->setex($key, $timeout, $id);
$pipe->exec();
});
$execReply = array_pop($replies);
if (FALSE === $execReply[0]) {
unset($this->_locks[$name]);
return FALSE;
}
return TRUE;
}
else {
$client->watch($key);
$owner = $client->get($key);
if (!empty($owner) && $owner != $id) {
$client->unwatch();
unset($this->_locks[$name]);
return FALSE;
}
$replies = $client->pipeline(function($pipe) use ($key, $timeout, $id) {
$pipe->multi();
$pipe->setex($key, $timeout, $id);
$pipe->exec();
});
$execReply = array_pop($replies);
// If another client modified the $key value, transaction will be discarded
// $result will be set to FALSE. This means atomicity have been broken and
// the other client took the lock instead of us.
// EXPIRE and SETEX won't return something here, EXEC return is index 0
// This was determined debugging, seems to be Predis specific.
if (FALSE === $execReply[0]) {
return FALSE;
}
// Register the lock and return.
return ($this->_locks[$name] = TRUE);
}
return FALSE;
}
public function lockMayBeAvailable($name) {
$client = $this->getClient();
$key = $this->getKey($name);
$id = $this->getLockId();
$value = $client->get($key);
return empty($value) || $id == $value;
}
public function lockRelease($name) {
$client = $this->getClient();
$key = $this->getKey($name);
$id = $this->getLockId();
unset($this->_locks[$name]);
// Ensure the lock deletion is an atomic transaction. If another thread
// manages to removes all lock, we can not alter it anymore else we will
// release the lock for the other thread and cause race conditions.
$client->watch($key);
if ($client->get($key) == $id) {
$client->multi();
$client->del(array($key));
$client->exec();
}
else {
$client->unwatch();
}
}
public function lockReleaseAll($lock_id = NULL) {
if (!isset($lock_id) && empty($this->_locks)) {
return;
}
$client = $this->getClient();
$id = isset($lock_id) ? $lock_id : $this->getLockId();
// We can afford to deal with a slow algorithm here, this should not happen
// on normal run because we should have removed manually all our locks.
foreach (array_keys($this->_locks) as $name) {
$key = $this->getKey($name);
$owner = $client->get($key);
if (empty($owner) || $owner == $id) {
$client->del(array($key));
}
}
}
}

View File

@@ -0,0 +1,105 @@
<?php
/**
* Common implementation for Redis-based implementations
*/
abstract class Redis_Path_AbstractHashLookup extends Redis_AbstractBackend implements
Redis_Path_HashLookupInterface
{
/**
* @todo document me
*
* @param string $key
* @param string $hkey
* @param string $hvalue
*/
abstract protected function saveInHash($key, $hkey, $hvalue);
/**
* @todo document me
*
* @param string $key
* @param string $hkey
* @param string $hvalue
*/
abstract protected function deleteInHash($key, $hkey, $hvalue);
/**
* @todo document me
*
* @param string $keyPrefix
* @param string $hkey
* @param string $language
*/
abstract protected function lookupInHash($keyPrefix, $hkey, $language = null);
/**
* Normalize value to avoid duplicate or false negatives
*
* @param string $value
*
* @return string
*/
private function normalize($value)
{
if (null !== $value) {
return strtolower(trim($value));
}
}
/**
* {@inheritdoc}
*/
public function saveAlias($source, $alias, $language = null)
{
$alias = $this->normalize($alias);
$source = $this->normalize($source);
if (null === $language) {
$language = LANGUAGE_NONE;
}
if (!empty($source)) {
$this->saveInHash($this->getKey(array(self::KEY_ALIAS, $language)), $source, $alias);
}
if (!empty($alias)) {
$this->saveInHash($this->getKey(array(self::KEY_SOURCE, $language)), $alias, $source);
}
}
/**
* {@inheritdoc}
*/
public function deleteAlias($source, $alias, $language = null)
{
$alias = $this->normalize($alias);
$source = $this->normalize($source);
if (null === $language) {
$language = LANGUAGE_NONE;
}
$this->deleteInHash($this->getKey(array(self::KEY_ALIAS, $language)), $source, $alias);
$this->deleteInHash($this->getKey(array(self::KEY_SOURCE, $language)), $alias, $source);
}
/**
* {@inheritdoc}
*/
public function lookupAlias($source, $language = null)
{
$source = $this->normalize($source);
return $this->lookupInHash(self::KEY_ALIAS, $source, $language);
}
/**
* {@inheritdoc}
*/
public function lookupSource($alias, $language = null)
{
$alias = $this->normalize($alias);
return $this->lookupInHash(self::KEY_SOURCE, $alias, $language);
}
}

View File

@@ -0,0 +1,109 @@
<?php
/**
* Very fast hash based lookup interface.
*
* This will work for any key-value store whether it's APC, Redis, memcache...
* Rationale behind this is that Drupal calls hundreds of time per request the
* drupal_lookup_path() function and we need it to be very fast. The key of
* success to keep it stupid simple and coherent as the same time is that we
* consider this backend as a cache (more or less permanent) that might be
* cleared at any time, and synchronized as when necessary or incrementally.
* This should be very fast.
*
* Redis implementation will be the following:
*
* Aliases are stored into a Redis HASH and are stored per language basis.
* Key is:
* [SITEPREFIX:]path:dst:LANGUAGE
* Keys inside the hash are a MD5() of the source and values are the alias
*
* Sources are also stored the same way except the HASH key is the following:
* [SITEPREFIX:]path:src:LANGUAGE
* Keys inside the hash are a MD5() of the alias and values are the sources.
*
* In both case values are a comma separated list of string values.
*
* The MD5() should give us low collision algorithm and we'll keep it until
* no one experiences any problem.
*
* Alias and sources are always looked up using the language, hence the
* different keys for different languages.
*/
interface Redis_Path_HashLookupInterface
{
/**
* Alias HASH key prefix
*/
const KEY_ALIAS = 'path:a';
/**
* Source HASH key prefix
*/
const KEY_SOURCE = 'path:s';
/**
* Null value (not existing yet cached value)
*/
const VALUE_NULL = '!';
/**
* Values separator for hash values
*/
const VALUE_SEPARATOR = '#';
/**
* Alias is being inserted with the given source
*
* @param string $source
* @param string $alias
* @param string $language
*/
public function saveAlias($source, $alias, $language = null);
/**
* Alias is being deleted for the given source
*
* @param string $source
* @param string $alias
* @param string $language
*/
public function deleteAlias($source, $alias, $language = null);
/**
* A language is being deleted
*
* @param string $language
*/
public function deleteLanguage($language);
/**
* Lookup any alias for the given source
*
* First that has been inserted wins over the others
*
* @param string $source
* @param string $language
*
* @return string|null|false
* - The string value if found
* - null if not found
* - false if set as non existing
*/
public function lookupAlias($source, $language = null);
/**
* Lookup any source for the given alias
*
* First that has been inserted wins over the others
*
* @param string $alias
* @param string $language
*
* @return string|null|false
* - The string value if found
* - null if not found
* - false if set as non existing
*/
public function lookupSource($alias, $language = null);
}

View File

@@ -0,0 +1,27 @@
<?php
/**
* Null implementation.
*/
class Redis_Path_NullHashLookup implements Redis_Path_HashLookupInterface
{
public function saveAlias($source, $alias, $language = null)
{
}
public function deleteAlias($source, $alias, $language = null)
{
}
public function deleteLanguage($language)
{
}
public function lookupAlias($source, $language = null)
{
}
public function lookupSource($alias, $language = null)
{
}
}

View File

@@ -0,0 +1,108 @@
<?php
/**
* PhpRedis implementation.
*
* @todo
* Set high expire value to the hash for rotation when memory is empty
* React upon cache clear all and rebuild path list?
*/
class Redis_Path_PhpRedis extends Redis_Path_AbstractHashLookup
{
protected function saveInHash($key, $hkey, $hvalue)
{
$client = $this->getClient();
$value = $client->hget($key, $hkey);
if ($value === self::VALUE_NULL) { // Remove any null values
$value = null;
}
if ($value) {
$existing = explode(self::VALUE_SEPARATOR, $value);
if (!in_array($hvalue, $existing)) {
// Prepend the most recent path to ensure it always be
// first fetched one
// @todo Ensure in case of update that its position does
// not changes (pid ordering in Drupal core)
$value = $hvalue . self::VALUE_SEPARATOR . $value;
} else { // Do nothing on empty value
$value = null;
}
} else if (empty($hvalue)) {
$value = self::VALUE_NULL;
} else {
$value = $hvalue;
}
if (!empty($value)) {
$client->hset($key, $hkey, $value);
}
// Empty value here means that we already got it
}
protected function deleteInHash($key, $hkey, $hvalue)
{
$client = $this->getClient();
$value = $client->hget($key, $hkey);
if ($value) {
$existing = explode(self::VALUE_SEPARATOR, $value);
if (false !== ($index = array_search($hvalue, $existing))) {
if (1 === count($existing)) {
$client->hdel($key, $hkey);
} else {
unset($existing[$index]);
$client->hset($key, $hkey, implode(self::VALUE_SEPARATOR, $existing));
}
}
}
}
protected function lookupInHash($keyPrefix, $hkey, $language = null)
{
$client = $this->getClient();
if (null === $language) {
$language = LANGUAGE_NONE;
$doNoneLookup = false;
} else if (LANGUAGE_NONE === $language) {
$doNoneLookup = false;
} else {
$doNoneLookup = true;
}
$ret = $client->hget($this->getKey(array($keyPrefix, $language)), $hkey);
if ($doNoneLookup && (!$ret || self::VALUE_NULL === $ret)) {
$previous = $ret;
$ret = $client->hget($this->getKey(array($keyPrefix, LANGUAGE_NONE)), $hkey);
if (!$ret || self::VALUE_NULL === $ret) {
// Restore null placeholder else we loose conversion to false
// and drupal_lookup_path() would attempt saving it once again
$ret = $previous;
}
}
if (self::VALUE_NULL === $ret) {
return false; // Needs conversion
}
if (empty($ret)) {
return null; // Value not found
}
$existing = explode(self::VALUE_SEPARATOR, $ret);
return reset($existing);
}
/**
* {@inheritdoc}
*/
public function deleteLanguage($language)
{
$client = $this->getClient();
$client->del($this->getKey(array(self::KEY_ALIAS, $language)));
$client->del($this->getKey(array(self::KEY_SOURCE, $language)));
}
}

View File

@@ -0,0 +1,108 @@
<?php
/**
* PhpRedis implementation.
*
* @todo
* Set high expire value to the hash for rotation when memory is empty
* React upon cache clear all and rebuild path list?
*/
class Redis_Path_Predis extends Redis_Path_AbstractHashLookup
{
protected function saveInHash($key, $hkey, $hvalue)
{
$client = $this->getClient();
$value = $client->hget($key, $hkey);
if ($value === self::VALUE_NULL) { // Remove any null values
$value = null;
}
if ($value) {
$existing = explode(self::VALUE_SEPARATOR, $value);
if (!in_array($hvalue, $existing)) {
// Prepend the most recent path to ensure it always be
// first fetched one
// @todo Ensure in case of update that its position does
// not changes (pid ordering in Drupal core)
$value = $hvalue . self::VALUE_SEPARATOR . $value;
} else { // Do nothing on empty value
$value = null;
}
} else if (empty($hvalue)) {
$value = self::VALUE_NULL;
} else {
$value = $hvalue;
}
if (!empty($value)) {
$client->hset($key, $hkey, $value);
}
// Empty value here means that we already got it
}
protected function deleteInHash($key, $hkey, $hvalue)
{
$client = $this->getClient();
$value = $client->hget($key, $hkey);
if ($value) {
$existing = explode(self::VALUE_SEPARATOR, $value);
if (false !== ($index = array_search($hvalue, $existing))) {
if (1 === count($existing)) {
$client->hdel($key, $hkey);
} else {
unset($existing[$index]);
$client->hset($key, $hkey, implode(self::VALUE_SEPARATOR, $existing));
}
}
}
}
protected function lookupInHash($keyPrefix, $hkey, $language = null)
{
$client = $this->getClient();
if (null === $language) {
$language = LANGUAGE_NONE;
$doNoneLookup = false;
} else if (LANGUAGE_NONE === $language) {
$doNoneLookup = false;
} else {
$doNoneLookup = true;
}
$ret = $client->hget($this->getKey(array($keyPrefix, $language)), $hkey);
if ($doNoneLookup && (!$ret || self::VALUE_NULL === $ret)) {
$previous = $ret;
$ret = $client->hget($this->getKey(array($keyPrefix, LANGUAGE_NONE)), $hkey);
if (!$ret || self::VALUE_NULL === $ret) {
// Restore null placeholder else we loose conversion to false
// and drupal_lookup_path() would attempt saving it once again
$ret = $previous;
}
}
if (self::VALUE_NULL === $ret) {
return false; // Needs conversion
}
if (empty($ret)) {
return null; // Value not found
}
$existing = explode(self::VALUE_SEPARATOR, $ret);
return reset($existing);
}
/**
* {@inheritdoc}
*/
public function deleteLanguage($language)
{
$client = $this->getClient();
$client->del($this->getKey(array(self::KEY_ALIAS, $language)));
$client->del($this->getKey(array(self::KEY_SOURCE, $language)));
}
}

View File

@@ -0,0 +1,58 @@
<?php
class Redis_Queue implements DrupalReliableQueueInterface
{
/**
* @var DrupalQueueInterface
*/
protected $backend;
/**
* Default contructor
*
* Beware that DrupalQueueInterface does not defines the __construct
* method in the interface yet is being used from DrupalQueue::get()
*
* @param unknown $name
*/
public function __construct($name)
{
$className = Redis_Client::getClass(Redis_Client::REDIS_IMPL_QUEUE);
$this->backend = new $className(Redis_Client::getClient(), $name);
}
public function createItem($data)
{
return $this->backend->createItem($data);
}
public function numberOfItems()
{
return $this->backend->numberOfItems();
}
public function claimItem($lease_time = 3600)
{
return $this->backend->claimItem($lease_time);
}
public function deleteItem($item)
{
return $this->backend->deleteItem($item);
}
public function releaseItem($item)
{
return $this->backend->releaseItem($item);
}
public function createQueue()
{
return $this->backend->createQueue();
}
public function deleteQueue()
{
return $this->backend->deleteQueue();
}
}

View File

@@ -0,0 +1,99 @@
<?php
/**
* Redis allows implementing reliable queues, here is the spec:
*
* - For each queue, you have 4 different HASH:
*
* - One for queued items queue:NAME:queued
*
* - One for claimed items being processed: queue:NAME:claimed
*
* - One for claimed items leave time: queue:NAME:leave
* Items from this one will be arbitrarily fetched at cron
* time and released when leave is outdated.
*
* - One containing the item values and other valuable stateful
* information: queue:NAME:data ;
*
* - For example, current job maximum identifier (auto increment
* emulation) will be stored in the "sequence" HASH key
*
* - All other keys within the HASH will be the items themselves,
* keys for those will always be numeric
*
* - Each time a queue will be emptied, even during a pragmatic process,
* it will be automatically deleted, reseting the sequence counter to
* the 0 value each time
*
* - Algorithm is a variation of the one described in "Reliable queue"
* section of http://redis.io/commands/rpoplpush and partial port of what
* you can find in the http://drupal.org/project/redis_queue module.
*
* You will find the driver specific implementation in the Redis_Queue_*
* classes as they may differ in how the API handles transaction, pipelining
* and return values.
*/
abstract class Redis_Queue_Base extends Redis_AbstractBackend implements
DrupalReliableQueueInterface
{
/**
* Key prefix for queue data.
*/
const QUEUE_KEY_PREFIX = 'queue';
/**
* Data HASH sequence key name.
*/
const QUEUE_HKEY_SEQ = 'seq';
/**
* Get data HASH key
*
* Key will already be prefixed
*
* @return string
*/
public function getKeyForData()
{
return $this->getKey('data');
}
/**
* Get queued items LIST key
*
* Key will already be prefixed
*
* @return string
*/
public function getKeyForQueue()
{
return $this->getKey('queued');
}
/**
* Get claimed LIST key
*
* Key will already be prefixed
*
* @return string
*/
public function getKeyForClaimed()
{
return $this->getKey('claimed');
}
/**
* Default contructor
*
* Beware that DrupalQueueInterface does not defines the __construct
* method in the interface yet is being used from DrupalQueue::get()
*
* @param mixed $client
* @param string $name
*/
public function __construct($client, $name)
{
parent::__construct($client, self::QUEUE_KEY_PREFIX . $name);
}
}

View File

@@ -0,0 +1,106 @@
<?php
/**
* @todo
* Set high expire value to the hash for rotation when memory is empty
* React upon cache clear all and rebuild path list?
*/
class Redis_Queue_PhpRedis extends Redis_Queue_Base
{
public function createItem($data)
{
$client = $this->getClient();
$dKey = $this->getKeyForData();
$qKey = $this->getKeyForQueue();
// Identifier does not not need to be in the transaction,
// in case of any error we'll just skip a value in the sequence.
$id = $client->hincrby($dKey, self::QUEUE_HKEY_SEQ, 1);
$record = new stdClass();
$record->qid = $id;
$record->data = $data;
$record->timestamp = time();
$pipe = $client->multi(Redis::PIPELINE);
// Thanks to the redis_queue standalone module maintainers for
// this piece of code, very effective. Note that we added the
// pipeline thought.
$pipe->hsetnx($dKey, $id, serialize($record));
$pipe->llen($qKey);
$pipe->lpush($qKey, $id);
$ret = $pipe->exec();
if (!$success = ($ret[0] && $ret[1] < $ret[2])) {
if ($ret[0]) {
// HSETNEX worked but not the PUSH command we therefore
// need to drop the inserted data. I would have prefered
// a DISCARD instead but we are in pipelined transaction
// we cannot actually do a DISCARD here.
$client->hdel($dKey, $id);
}
}
return $success;
}
public function numberOfItems()
{
return $this->getClient()->llen($this->getKeyForQueue());
}
public function claimItem($lease_time = 30)
{
// @todo Deal with lease
$client = $this->getClient();
$id = $client->rpoplpush(
$this->getKeyForQueue(),
$this->getKeyForClaimed()
);
if ($id) {
if ($item = $client->hget($this->getKeyForData(), $id)) {
if ($item = unserialize($item)) {
return $item;
}
}
}
return false;
}
public function deleteItem($item)
{
$pipe = $this->getClient()->multi(Redis::PIPELINE);
$pipe->lrem($this->getKeyForQueue(), $item->qid);
$pipe->lrem($this->getKeyForClaimed(), $item->qid);
$pipe->hdel($this->getKeyForData(), $item->qid);
$pipe->exec();
}
public function releaseItem($item)
{
$pipe = $this->getClient()->multi(Redis::PIPELINE);
$pipe->lrem($this->getKeyForClaimed(), $item->qid, -1);
$pipe->lpush($this->getKeyForQueue(), $item->qid);
$ret = $pipe->exec();
return $ret[0] && $ret[1];
}
public function createQueue()
{
}
public function deleteQueue()
{
$this->getClient()->del(
$this->getKeyForQueue(),
$this->getKeyForClaimed(),
$this->getKeyForData()
);
}
}

View File

@@ -0,0 +1,141 @@
<?php
abstract class Redis_Tests_AbstractUnitTestCase extends DrupalUnitTestCase
{
/**
* @var boolean
*/
static protected $loaderEnabled = false;
/**
* Enable the autoloader
*
* This exists in this class in case the autoloader is not set into the
* settings.php file or another way
*
* @return void|boolean
*/
static protected function enableAutoload()
{
if (self::$loaderEnabled) {
return;
}
if (class_exists('Redis_Client')) {
return;
}
spl_autoload_register(function ($className) {
$parts = explode('_', $className);
if ('Redis' === $parts[0]) {
$filename = __DIR__ . '/../lib/' . implode('/', $parts) . '.php';
return (bool) include_once $filename;
}
return false;
}, null, true);
self::$loaderEnabled = true;
}
/**
* Drupal $conf array backup
*
* @var array
*/
private $originalConf = array(
'cache_lifetime' => null,
'cache_prefix' => null,
'redis_client_interface' => null,
'redis_eval_enabled' => null,
'redis_flush_mode' => null,
'redis_perm_ttl' => null,
);
/**
* Prepare Drupal environmment for testing
*/
final private function prepareDrupalEnvironment()
{
// Site on which the tests are running may define this variable
// in their own settings.php file case in which it will be merged
// with testing site
global $conf;
foreach (array_keys($this->originalConf) as $key) {
if (isset($conf[$key])) {
$this->originalConf[$key] = $conf[$key];
unset($conf[$key]);
}
}
$conf['cache_prefix'] = $this->testId;
}
/**
* Restore Drupal environment after testing.
*/
final private function restoreDrupalEnvironment()
{
$GLOBALS['conf'] = $this->originalConf + $GLOBALS['conf'];
}
/**
* Prepare client manager
*/
final private function prepareClientManager()
{
$interface = $this->getClientInterface();
if (null === $interface) {
throw new \Exception("Test skipped due to missing driver");
}
$GLOBALS['conf']['redis_client_interface'] = $interface;
Redis_Client::reset();
}
/**
* Restore client manager
*/
final private function restoreClientManager()
{
Redis_Client::reset();
}
/**
* Set up the Redis configuration.
*
* Set up the needed variables using variable_set() if necessary.
*
* @return string
* Client interface or null if not exists
*/
abstract protected function getClientInterface();
/**
* {@inheritdoc}
*/
public function setUp()
{
self::enableAutoload();
$this->prepareDrupalEnvironment();
$this->prepareClientManager();
parent::setUp();
drupal_install_schema('system');
drupal_install_schema('locale');
}
/**
* {@inheritdoc}
*/
public function tearDown()
{
drupal_uninstall_schema('locale');
drupal_uninstall_schema('system');
$this->restoreDrupalEnvironment();
$this->restoreClientManager();
parent::tearDown();
}
}

View File

@@ -0,0 +1,60 @@
<?php
class Redis_Tests_Admin_VariableTestCase extends DrupalWebTestCase
{
public static function getInfo()
{
return array(
'name' => 'Redis variables',
'description' => 'Checks that Redis module variables are correctly type hinted when saved.',
'group' => 'Redis',
);
}
protected $adminUser;
public function setUp()
{
parent::setUp('redis');
}
public function testSave()
{
$this->adminUser = $this->drupalCreateUser(array('administer site configuration'));
$this->drupalLogin($this->adminUser);
// Tests port is an int.
$this->drupalGet('admin/config/development/performance/redis');
$edit = array(
'redis_client_base' => '',
'redis_client_port' => '1234',
'redis_client_host' => 'localhost',
'redis_client_interface' => '',
);
$this->drupalPost('admin/config/development/performance/redis', $edit, t('Save configuration'));
// Force variable cache to refresh.
$test = variable_initialize();
$conf = &$GLOBALS['conf'];
$this->assertFalse(array_key_exists('redis_client_base', $conf), "Empty int value has been removed");
$this->assertFalse(array_key_exists('redis_client_interface', $conf), "Empty string value has been removed");
$this->assertIdentical($conf['redis_client_port'], 1234, "Saved int is an int");
$this->assertIdentical($conf['redis_client_host'], 'localhost', "Saved string is a string");
$this->drupalGet('admin/config/development/performance/redis');
$edit = array(
'redis_client_base' => '0',
'redis_client_port' => '1234',
'redis_client_host' => 'localhost',
'redis_client_interface' => '',
);
$this->drupalPost('admin/config/development/performance/redis', $edit, t('Save configuration'));
// Force variable cache to refresh.
$test = variable_initialize();
$conf = &$GLOBALS['conf'];
$this->assertIdentical($conf['redis_client_base'], 0, "Saved 0 valueed int is an int");
}
}

View File

@@ -0,0 +1,27 @@
<?php
if (!class_exists('Redis_Tests_Cache_FixesUnitTestCase')) {
require_once(__DIR__ . '/FixesUnitTestCase.php');
}
class Redis_Tests_Cache_CompressedPhpRedisFixesUnitTestCase extends Redis_Tests_Cache_FixesUnitTestCase
{
public static function getInfo()
{
return array(
'name' => 'Compressed PhpRedis cache fixes',
'description' => 'Tests Redis module cache fixes feature.',
'group' => 'Redis',
);
}
protected function createCacheInstance($name = null)
{
return new Redis_CacheCompressed($name);
}
protected function getClientInterface()
{
return 'PhpRedis';
}
}

View File

@@ -0,0 +1,27 @@
<?php
if (!class_exists('Redis_Tests_Cache_FlushUnitTestCase')) {
require_once(__DIR__ . '/FlushUnitTestCase.php');
}
class Redis_Tests_Cache_CompressedPhpRedisFlushUnitTestCase extends Redis_Tests_Cache_FlushUnitTestCase
{
public static function getInfo()
{
return array(
'name' => 'Compressed PhpRedis cache flush',
'description' => 'Tests Redis module cache flush modes feature.',
'group' => 'Redis',
);
}
protected function createCacheInstance($name = null)
{
return new Redis_CacheCompressed($name);
}
protected function getClientInterface()
{
return 'PhpRedis';
}
}

View File

@@ -0,0 +1,25 @@
<?php
class Redis_Tests_Cache_CompressedPhpRedisShardedFixesUnitTestCase extends Redis_Tests_Cache_FixesUnitTestCase
{
public static function getInfo()
{
return array(
'name' => 'Compressed PhpRedis cache fixes (S)',
'description' => 'Tests Redis module cache fixes feature.',
'group' => 'Redis',
);
}
protected function createCacheInstance($name = null)
{
return new Redis_CacheCompressed($name);
}
protected function getClientInterface()
{
$GLOBALS['conf']['redis_flush_mode'] = Redis_Cache::FLUSH_SHARD;
return 'PhpRedis';
}
}

View File

@@ -0,0 +1,25 @@
<?php
class Redis_Tests_Cache_CompressedPhpRedisShardedFlushUnitTestCase extends Redis_Tests_Cache_FlushUnitTestCase
{
public static function getInfo()
{
return array(
'name' => 'Compressed PhpRedis cache flush (S)',
'description' => 'Tests Redis module cache flush modes feature.',
'group' => 'Redis',
);
}
protected function createCacheInstance($name = null)
{
return new Redis_CacheCompressed($name);
}
protected function getClientInterface()
{
$GLOBALS['conf']['redis_flush_mode'] = Redis_Cache::FLUSH_SHARD;
return 'PhpRedis';
}
}

View File

@@ -0,0 +1,25 @@
<?php
class Redis_Tests_Cache_CompressedPhpRedisShardedWithPipelineFixesUnitTestCase extends Redis_Tests_Cache_FixesUnitTestCase
{
public static function getInfo()
{
return array(
'name' => 'Compressed PhpRedis cache fixes (SP)',
'description' => 'Tests Redis module cache fixes feature.',
'group' => 'Redis',
);
}
protected function createCacheInstance($name = null)
{
return new Redis_CacheCompressed($name);
}
protected function getClientInterface()
{
$GLOBALS['conf']['redis_flush_mode'] = Redis_Cache::FLUSH_SHARD_WITH_PIPELINING;
return 'PhpRedis';
}
}

View File

@@ -0,0 +1,209 @@
<?php
if (!class_exists('Redis_Tests_AbstractUnitTestCase')) {
require_once(__DIR__ . '/../AbstractUnitTestCase.php');
}
/**
* Bugfixes made over time test class.
*/
abstract class Redis_Tests_Cache_FixesUnitTestCase extends Redis_Tests_AbstractUnitTestCase
{
/**
* @var Cache bin identifier
*/
static private $id = 1;
protected function createCacheInstance($name = null)
{
return new Redis_Cache($name);
}
/**
* Get cache backend
*
* @return Redis_Cache
*/
final protected function getBackend($name = null)
{
if (null === $name) {
// This is needed to avoid conflict between tests, each test
// seems to use the same Redis namespace and conflicts are
// possible.
$name = 'cache' . (self::$id++);
}
$backend = $this->createCacheInstance($name);
$this->assert(true, "Redis client is " . ($backend->isSharded() ? '' : "NOT ") . " sharded");
$this->assert(true, "Redis client is " . ($backend->allowTemporaryFlush() ? '' : "NOT ") . " allowed to flush temporary entries");
$this->assert(true, "Redis client is " . ($backend->allowPipeline() ? '' : "NOT ") . " allowed to use pipeline");
return $backend;
}
public function testTemporaryCacheExpire()
{
global $conf; // We are in unit tests so variable table does not exist.
$backend = $this->getBackend();
// Permanent entry.
$backend->set('test1', 'foo', CACHE_PERMANENT);
$data = $backend->get('test1');
$this->assertNotEqual(false, $data);
$this->assertIdentical('foo', $data->data);
// Permanent entries should not be dropped on clear() call.
$backend->clear();
$data = $backend->get('test1');
$this->assertNotEqual(false, $data);
$this->assertIdentical('foo', $data->data);
// Expiring entry with permanent default lifetime.
$conf['cache_lifetime'] = 0;
$backend->set('test2', 'bar', CACHE_TEMPORARY);
sleep(2);
$data = $backend->get('test2');
$this->assertNotEqual(false, $data);
$this->assertIdentical('bar', $data->data);
sleep(2);
$data = $backend->get('test2');
$this->assertNotEqual(false, $data);
$this->assertIdentical('bar', $data->data);
// Expiring entry with negative lifetime.
$backend->set('test3', 'baz', time() - 100);
$data = $backend->get('test3');
$this->assertEqual(false, $data);
// Expiring entry with short lifetime.
$backend->set('test4', 'foobar', time() + 2);
$data = $backend->get('test4');
$this->assertNotEqual(false, $data);
$this->assertIdentical('foobar', $data->data);
sleep(4);
$data = $backend->get('test4');
$this->assertEqual(false, $data);
// Expiring entry with short default lifetime.
$conf['cache_lifetime'] = 1;
$backend->refreshMaxTtl();
$backend->set('test5', 'foobaz', CACHE_TEMPORARY);
$data = $backend->get('test5');
$this->assertNotEqual(false, $data);
$this->assertIdentical('foobaz', $data->data);
sleep(3);
$data = $backend->get('test5');
$this->assertEqual(false, $data);
}
public function testDefaultPermTtl()
{
global $conf;
unset($conf['redis_perm_ttl']);
$backend = $this->getBackend();
$this->assertIdentical(Redis_Cache::LIFETIME_PERM_DEFAULT, $backend->getPermTtl());
}
public function testUserSetDefaultPermTtl()
{
global $conf;
// This also testes string parsing. Not fully, but at least one case.
$conf['redis_perm_ttl'] = "3 months";
$backend = $this->getBackend();
$this->assertIdentical(7776000, $backend->getPermTtl());
}
public function testUserSetPermTtl()
{
global $conf;
// This also testes string parsing. Not fully, but at least one case.
$conf['redis_perm_ttl'] = "1 months";
$backend = $this->getBackend();
$this->assertIdentical(2592000, $backend->getPermTtl());
}
public function testGetMultiple()
{
$backend = $this->getBackend();
$backend->set('multiple1', 1);
$backend->set('multiple2', 2);
$backend->set('multiple3', 3);
$backend->set('multiple4', 4);
$cidList = array('multiple1', 'multiple2', 'multiple3', 'multiple4', 'multiple5');
$ret = $backend->getMultiple($cidList);
$this->assertEqual(1, count($cidList));
$this->assertFalse(isset($cidList[0]));
$this->assertFalse(isset($cidList[1]));
$this->assertFalse(isset($cidList[2]));
$this->assertFalse(isset($cidList[3]));
$this->assertTrue(isset($cidList[4]));
$this->assertEqual(4, count($ret));
$this->assertTrue(isset($ret['multiple1']));
$this->assertTrue(isset($ret['multiple2']));
$this->assertTrue(isset($ret['multiple3']));
$this->assertTrue(isset($ret['multiple4']));
$this->assertFalse(isset($ret['multiple5']));
}
public function testPermTtl()
{
global $conf;
// This also testes string parsing. Not fully, but at least one case.
$conf['redis_perm_ttl'] = "2 seconds";
$backend = $this->getBackend();
$this->assertIdentical(2, $backend->getPermTtl());
$backend->set('test6', 'cats are mean');
$this->assertIdentical('cats are mean', $backend->get('test6')->data);
sleep(3);
$item = $backend->get('test6');
$this->assertTrue(empty($item));
}
public function testClearAsArray()
{
$backend = $this->getBackend();
$backend->set('test7', 1);
$backend->set('test8', 2);
$backend->set('test9', 3);
$backend->clear(array('test7', 'test9'));
$item = $backend->get('test7');
$this->assertTrue(empty($item));
$item = $backend->get('test8');
$this->assertEqual(2, $item->data);
$item = $backend->get('test9');
$this->assertTrue(empty($item));
}
public function testGetMultipleAlterCidsWhenCacheHitsOnly()
{
$backend = $this->getBackend();
$backend->clear('*', true); // It seems that there are leftovers.
$backend->set('mtest1', 'pouf');
$cids_partial_hit = array('foo' => 'mtest1', 'bar' => 'mtest2');
$entries = $backend->getMultiple($cids_partial_hit);
$this->assertIdentical(1, count($entries));
// Note that the key is important because the method should
// keep the keys synchronized.
$this->assertEqual(array('bar' => 'mtest2'), $cids_partial_hit);
$backend->clear('mtest1');
$cids_no_hit = array('cat' => 'mtest1', 'dog' => 'mtest2');
$entries = $backend->getMultiple($cids_no_hit);
$this->assertIdentical(0, count($entries));
$this->assertEqual(array('cat' => 'mtest1', 'dog' => 'mtest2'), $cids_no_hit);
}
}

View File

@@ -0,0 +1,185 @@
<?php
abstract class Redis_Tests_Cache_FlushUnitTestCase extends Redis_Tests_AbstractUnitTestCase
{
/**
* @var Cache bin identifier
*/
static private $id = 1;
protected function createCacheInstance($name = null)
{
return new Redis_Cache($name);
}
/**
* Get cache backend
*
* @return Redis_Cache
*/
final protected function getBackend($name = null)
{
if (null === $name) {
// This is needed to avoid conflict between tests, each test
// seems to use the same Redis namespace and conflicts are
// possible.
$name = 'cache' . (self::$id++);
}
$backend = $this->createCacheInstance($name);
$this->assert(true, "Redis client is " . ($backend->isSharded() ? '' : "NOT ") . " sharded");
$this->assert(true, "Redis client is " . ($backend->allowTemporaryFlush() ? '' : "NOT ") . " allowed to flush temporary entries");
$this->assert(true, "Redis client is " . ($backend->allowPipeline() ? '' : "NOT ") . " allowed to use pipeline");
return $backend;
}
/**
* Tests that with a default cache lifetime temporary non expired
* items are kept even when in temporary flush mode.
*/
public function testFlushIsTemporaryWithLifetime()
{
$GLOBALS['conf']['cache_lifetime'] = 112;
$backend = $this->getBackend();
// Even though we set a flush mode into this bin, Drupal default
// behavior when a cache_lifetime is set is to override the backend
// one in order to keep the core behavior and avoid potential
// nasty bugs.
$this->assertFalse($backend->allowTemporaryFlush());
$backend->set('test7', 42, CACHE_PERMANENT);
$backend->set('test8', 'foo', CACHE_TEMPORARY);
$backend->set('test9', 'bar', time() + 1000);
$backend->clear();
$cache = $backend->get('test7');
$this->assertNotEqual(false, $cache);
$this->assertEqual($cache->data, 42);
$cache = $backend->get('test8');
$this->assertNotEqual(false, $cache);
$this->assertEqual($cache->data, 'foo');
$cache = $backend->get('test9');
$this->assertNotEqual(false, $cache);
$this->assertEqual($cache->data, 'bar');
}
/**
* Tests that with no default cache lifetime all temporary items are
* droppped when in temporary flush mode.
*/
public function testFlushIsTemporaryWithoutLifetime()
{
$backend = $this->getBackend();
$this->assertTrue($backend->allowTemporaryFlush());
$backend->set('test10', 42, CACHE_PERMANENT);
// Ugly concatenation with the mode, but it will be visible in tests
// reports if the entry shows up, thus allowing us to know which real
// test case is run at this time
$backend->set('test11', 'foo' . $backend->isSharded(), CACHE_TEMPORARY);
$backend->set('test12', 'bar' . $backend->isSharded(), time() + 10);
$backend->clear();
$cache = $backend->get('test10');
$this->assertNotEqual(false, $cache);
$this->assertEqual($cache->data, 42);
$this->assertFalse($backend->get('test11'));
$cache = $backend->get('test12');
$this->assertNotEqual(false, $cache);
}
public function testNormalFlushing()
{
$backend = $this->getBackend();
$backendUntouched = $this->getBackend();
// Set a few entries.
$backend->set('test13', 'foo');
$backend->set('test14', 'bar', CACHE_TEMPORARY);
$backend->set('test15', 'baz', time() + 3);
$backendUntouched->set('test16', 'dog');
$backendUntouched->set('test17', 'cat', CACHE_TEMPORARY);
$backendUntouched->set('test18', 'xor', time() + 5);
// This should not do anything (bugguy command)
$backend->clear('', true);
$backend->clear('', false);
$this->assertNotIdentical(false, $backend->get('test13'));
$this->assertNotIdentical(false, $backend->get('test14'));
$this->assertNotIdentical(false, $backend->get('test15'));
$this->assertNotIdentical(false, $backendUntouched->get('test16'));
$this->assertNotIdentical(false, $backendUntouched->get('test17'));
$this->assertNotIdentical(false, $backendUntouched->get('test18'));
// This should clear every one, permanent and volatile
$backend->clear('*', true);
$this->assertFalse($backend->get('test13'));
$this->assertFalse($backend->get('test14'));
$this->assertFalse($backend->get('test15'));
$this->assertNotIdentical(false, $backendUntouched->get('test16'));
$this->assertNotIdentical(false, $backendUntouched->get('test17'));
$this->assertNotIdentical(false, $backendUntouched->get('test18'));
}
public function testPrefixDeletionWithSeparatorChar()
{
$backend = $this->getBackend();
$backend->set('testprefix10', 'foo');
$backend->set('testprefix11', 'foo');
$backend->set('testprefix:12', 'bar');
$backend->set('testprefix:13', 'baz');
$backend->set('testnoprefix14', 'giraffe');
$backend->set('testnoprefix:15', 'elephant');
$backend->clear('testprefix:', true);
$this->assertFalse($backend->get('testprefix:12'));
$this->assertFalse($backend->get('testprefix:13'));
// @todo Temporary fix
// At the moment shard enabled backends will erase all data instead
// of just removing by prefix, so those tests won't pass
if (!$backend->isSharded()) {
$this->assertNotIdentical(false, $backend->get('testprefix10'));
$this->assertNotIdentical(false, $backend->get('testprefix11'));
$this->assertNotIdentical(false, $backend->get('testnoprefix14'));
$this->assertNotIdentical(false, $backend->get('testnoprefix:15'));
}
$backend->clear('testprefix', true);
$this->assertFalse($backend->get('testprefix10'));
$this->assertFalse($backend->get('testprefix11'));
// @todo Temporary fix
// At the moment shard enabled backends will erase all data instead
// of just removing by prefix, so those tests won't pass
if (!$backend->isSharded()) {
$this->assertNotIdentical(false, $backend->get('testnoprefix14'));
$this->assertNotIdentical(false, $backend->get('testnoprefix:15'));
}
}
public function testOrder()
{
$backend = $this->getBackend();
for ($i = 0; $i < 10; ++$i) {
$id = 'speedtest' . $i;
$backend->set($id, 'somevalue');
$this->assertNotIdentical(false, $backend->get($id));
$backend->clear('*', true);
// Value created the same second before is dropped
$this->assertFalse($backend->get($id));
$backend->set($id, 'somevalue');
// Value created the same second after is kept
$this->assertNotIdentical(false, $backend->get($id));
}
}
}

View File

@@ -0,0 +1,22 @@
<?php
if (!class_exists('Redis_Tests_Cache_FixesUnitTestCase')) {
require_once(__DIR__ . '/FixesUnitTestCase.php');
}
class Redis_Tests_Cache_PhpRedisFixesUnitTestCase extends Redis_Tests_Cache_FixesUnitTestCase
{
public static function getInfo()
{
return array(
'name' => 'PhpRedis cache fixes',
'description' => 'Tests Redis module cache fixes feature.',
'group' => 'Redis',
);
}
protected function getClientInterface()
{
return 'PhpRedis';
}
}

View File

@@ -0,0 +1,22 @@
<?php
if (!class_exists('Redis_Tests_Cache_FlushUnitTestCase')) {
require_once(__DIR__ . '/FlushUnitTestCase.php');
}
class Redis_Tests_Cache_PhpRedisFlushUnitTestCase extends Redis_Tests_Cache_FlushUnitTestCase
{
public static function getInfo()
{
return array(
'name' => 'PhpRedis cache flush',
'description' => 'Tests Redis module cache flush modes feature.',
'group' => 'Redis',
);
}
protected function getClientInterface()
{
return 'PhpRedis';
}
}

View File

@@ -0,0 +1,20 @@
<?php
class Redis_Tests_Cache_PhpRedisShardedFixesUnitTestCase extends Redis_Tests_Cache_FixesUnitTestCase
{
public static function getInfo()
{
return array(
'name' => 'PhpRedis cache fixes (S)',
'description' => 'Tests Redis module cache fixes feature.',
'group' => 'Redis',
);
}
protected function getClientInterface()
{
$GLOBALS['conf']['redis_flush_mode'] = Redis_Cache::FLUSH_SHARD;
return 'PhpRedis';
}
}

View File

@@ -0,0 +1,20 @@
<?php
class Redis_Tests_Cache_PhpRedisShardedFlushUnitTestCase extends Redis_Tests_Cache_FlushUnitTestCase
{
public static function getInfo()
{
return array(
'name' => 'PhpRedis cache flush (S)',
'description' => 'Tests Redis module cache flush modes feature.',
'group' => 'Redis',
);
}
protected function getClientInterface()
{
$GLOBALS['conf']['redis_flush_mode'] = Redis_Cache::FLUSH_SHARD;
return 'PhpRedis';
}
}

View File

@@ -0,0 +1,20 @@
<?php
class Redis_Tests_Cache_PhpRedisShardedWithPipelineFixesUnitTestCase extends Redis_Tests_Cache_FixesUnitTestCase
{
public static function getInfo()
{
return array(
'name' => 'PhpRedis cache fixes (SP)',
'description' => 'Tests Redis module cache fixes feature.',
'group' => 'Redis',
);
}
protected function getClientInterface()
{
$GLOBALS['conf']['redis_flush_mode'] = Redis_Cache::FLUSH_SHARD_WITH_PIPELINING;
return 'PhpRedis';
}
}

View File

@@ -0,0 +1,18 @@
<?php
class Redis_Tests_Cache_PredisFixesUnitTestCase extends Redis_Tests_Cache_FixesUnitTestCase
{
public static function getInfo()
{
return array(
'name' => 'Predis cache fixes',
'description' => 'Tests Redis module cache fixes feature.',
'group' => 'Redis',
);
}
protected function getClientInterface()
{
return 'Predis';
}
}

View File

@@ -0,0 +1,18 @@
<?php
class Redis_Tests_Cache_PredisFlushUnitTestCase extends Redis_Tests_Cache_FlushUnitTestCase
{
public static function getInfo()
{
return array(
'name' => 'Predis cache flush',
'description' => 'Tests Redis module cache flush modes feature.',
'group' => 'Redis',
);
}
protected function getClientInterface()
{
return 'Predis';
}
}

View File

@@ -0,0 +1,20 @@
<?php
class Redis_Tests_Cache_PredisShardedFixesUnitTestCase extends Redis_Tests_Cache_FixesUnitTestCase
{
public static function getInfo()
{
return array(
'name' => 'Predis cache fixes (S)',
'description' => 'Tests Redis module cache fixes feature.',
'group' => 'Redis',
);
}
protected function getClientInterface()
{
$GLOBALS['conf']['redis_flush_mode'] = Redis_Cache::FLUSH_SHARD;
return 'Predis';
}
}

View File

@@ -0,0 +1,20 @@
<?php
class Redis_Tests_Cache_PredisShardedFlushUnitTestCase extends Redis_Tests_Cache_FlushUnitTestCase
{
public static function getInfo()
{
return array(
'name' => 'Predis cache flush (S)',
'description' => 'Tests Redis module cache flush modes feature.',
'group' => 'Redis',
);
}
protected function getClientInterface()
{
$GLOBALS['conf']['redis_flush_mode'] = Redis_Cache::FLUSH_SHARD;
return 'Predis';
}
}

View File

@@ -0,0 +1,20 @@
<?php
class Redis_Tests_Cache_PredisShardedWithPipelineFixesUnitTestCase extends Redis_Tests_Cache_FixesUnitTestCase
{
public static function getInfo()
{
return array(
'name' => 'Predis cache fixes (SP)',
'description' => 'Tests Redis module cache fixes feature.',
'group' => 'Redis',
);
}
protected function getClientInterface()
{
$GLOBALS['conf']['redis_flush_mode'] = Redis_Cache::FLUSH_SHARD_WITH_PIPELINING;
return 'Predis';
}
}

View File

@@ -0,0 +1,66 @@
<?php
class Redis_Tests_Client_UnitTestCase extends Redis_Tests_AbstractUnitTestCase
{
public static function getInfo()
{
return array(
'name' => 'Redis client manager',
'description' => 'Tests Redis module client manager feature.',
'group' => 'Redis',
);
}
protected function getClientInterface()
{
return 'PhpRedis';
}
public function getManager()
{
return new Redis_Client_Manager(
new Redis_Tests_Client_MockFactory(),
array(
'default' => array(),
'foo' => array(
'host' => 'foo.com',
'port' => 666,
),
'bar' => array(
'host' => 'bar.com',
),
)
);
}
public function testManagerServerList()
{
$manager = $this->getManager();
$defaultClient = $manager->getClient();
$this->assertTrue(is_object($defaultClient));
// Ensure defaults are OK
$this->assertIdentical(Redis_Client_Manager::REDIS_DEFAULT_HOST, $defaultClient->host);
$this->assertIdentical(Redis_Client_Manager::REDIS_DEFAULT_PORT, $defaultClient->port);
$this->assertFalse(property_exists($defaultClient, 'base'));
$this->assertFalse(property_exists($defaultClient, 'password'));
$client = $manager->getClient('foo');
$this->assertIdentical('foo.com', $client->host);
$this->assertIdentical(666, $client->port);
$client = $manager->getClient('bar');
$this->assertIdentical('bar.com', $client->host);
$this->assertIdentical(Redis_Client_Manager::REDIS_DEFAULT_PORT, $client->port);
$this->assertIdentical($defaultClient, $manager->getClient('non_existing'));
try {
$manager->getClient('other_non_existing', false);
$this->assert(false);
} catch (\InvalidArgumentException $e) {
$this->assert(true);
}
}
}

View File

@@ -0,0 +1,14 @@
<?php
class Redis_Tests_Client_MockFactory implements Redis_Client_FactoryInterface
{
public function getClient($options = array())
{
return (object)$options;
}
public function getName()
{
return 'Mock';
}
}

View File

@@ -0,0 +1,119 @@
<?php
abstract class Redis_Tests_Lock_LockingUnitTestCase extends Redis_Tests_AbstractUnitTestCase
{
/**
* Ensure lock flush at tear down
*
* @var array
*/
protected $backends = array();
/**
* Get the lock client class name
*
* @return string
* Lock backend class name or null if cannot spawn it
*/
abstract protected function getLockBackendClass();
public function tearDown()
{
if (!empty($this->backends)) {
foreach ($this->backends as $backend) {
$backend->lockReleaseAll();
}
$this->backends = array();
}
parent::tearDown();
}
/**
* Create a new lock backend with a generated lock id
*
* @return Redis_Lock_BackendInterface
*/
public function createLockBackend()
{
if (!$this->getLockBackendClass()) {
throw new \Exception("Lock backend class does not exist");
}
$className = Redis_Client::getClass(Redis_Client::REDIS_IMPL_LOCK);
return $this->backends[] = new $className(
Redis_Client::getClient(),
Redis_Client::getDefaultPrefix('lock')
);
}
public function testLock()
{
$b1 = $this->createLockBackend();
$b2 = $this->createLockBackend();
$s = $b1->lockAcquire('test1', 20000);
$this->assertTrue($s, "Lock test1 acquired");
$s = $b1->lockAcquire('test1', 20000);
$this->assertTrue($s, "Lock test1 acquired a second time by the same thread");
$s = $b2->lockAcquire('test1', 20000);
$this->assertFalse($s, "Lock test1 could not be acquired by another thread");
$b2->lockRelease('test1');
$s = $b2->lockAcquire('test1');
$this->assertFalse($s, "Lock test1 could not be released by another thread");
$b1->lockRelease('test1');
$s = $b2->lockAcquire('test1');
$this->assertTrue($s, "Lock test1 has been released by the first thread");
}
public function testReleaseAll()
{
$b1 = $this->createLockBackend();
$b2 = $this->createLockBackend();
$b1->lockAcquire('test1', 200);
$b1->lockAcquire('test2', 2000);
$b1->lockAcquire('test3', 20000);
$s = $b2->lockAcquire('test2');
$this->assertFalse($s, "Lock test2 could not be released by another thread");
$s = $b2->lockAcquire('test3');
$this->assertFalse($s, "Lock test4 could not be released by another thread");
$b1->lockReleaseAll();
$s = $b2->lockAcquire('test1');
$this->assertTrue($s, "Lock test1 has been released");
$s = $b2->lockAcquire('test2');
$this->assertTrue($s, "Lock test2 has been released");
$s = $b2->lockAcquire('test3');
$this->assertTrue($s, "Lock test3 has been released");
$b2->lockReleaseAll();
}
public function testConcurentLock()
{
/*
* Code for web test case
*
$this->drupalGet('redis/acquire/test1/1000');
$this->assertText("REDIS_ACQUIRED", "Lock test1 acquired");
$this->drupalGet('redis/acquire/test1/1');
$this->assertText("REDIS_FAILED", "Lock test1 could not be acquired by a second thread");
$this->drupalGet('redis/acquire/test2/1000');
$this->assertText("REDIS_ACQUIRED", "Lock test2 acquired");
$this->drupalGet('redis/acquire/test2/1');
$this->assertText("REDIS_FAILED", "Lock test2 could not be acquired by a second thread");
*/
}
}

View File

@@ -0,0 +1,27 @@
<?php
if (!class_exists('Redis_Tests_Lock_LockingUnitTestCase')) {
require_once(__DIR__ . '/LockingUnitTestCase.php');
}
class Redis_Tests_Lock_PhpRedisLockingUnitTestCase extends Redis_Tests_Lock_LockingUnitTestCase
{
public static function getInfo()
{
return array(
'name' => 'PhpRedis Redis locking',
'description' => 'Ensure that Redis locking feature is working OK.',
'group' => 'Redis',
);
}
protected function getLockBackendClass()
{
return 'Redis_Lock_PhpRedis';
}
protected function getClientInterface()
{
return 'PhpRedis';
}
}

View File

@@ -0,0 +1,23 @@
<?php
class Redis_Tests_Lock_PredisLockingUnitTestCase extends Redis_Tests_Lock_LockingUnitTestCase
{
public static function getInfo()
{
return array(
'name' => 'Predis Redis locking',
'description' => 'Ensure that Redis locking feature is working OK.',
'group' => 'Redis',
);
}
protected function getLockBackendClass()
{
return 'Redis_Lock_Predis';
}
protected function getClientInterface()
{
return 'Predis';
}
}

View File

@@ -0,0 +1,148 @@
<?php
/**
* Bugfixes made over time test class.
*/
abstract class Redis_Tests_Path_PathUnitTestCase extends Redis_Tests_AbstractUnitTestCase
{
/**
* @var Cache bin identifier
*/
static private $id = 1;
/**
* Get cache backend
*
* @return Redis_Path_HashLookupInterface
*/
final protected function getBackend($name = null)
{
if (null === $name) {
// This is needed to avoid conflict between tests, each test
// seems to use the same Redis namespace and conflicts are
// possible.
$name = 'cache' . (self::$id++);
}
$className = Redis_Client::getClass(Redis_Client::REDIS_IMPL_PATH);
$hashLookup = new $className(Redis_Client::getClient(), 'path', Redis_Client::getDefaultPrefix('path'));
return $hashLookup;
}
/**
* Tests basic functionnality
*/
public function testPathLookup()
{
$backend = $this->getBackend();
$source = $backend->lookupSource('node-1-fr', 'fr');
$this->assertIdentical(null, $source);
$alias = $backend->lookupAlias('node/1', 'fr');
$this->assertIdentical(null, $source);
$backend->saveAlias('node/1', 'node-1-fr', 'fr');
$source = $backend->lookupSource('node-1-fr', 'fr');
$source = $backend->lookupSource('node-1-fr', 'fr');
$this->assertIdentical('node/1', $source);
$alias = $backend->lookupAlias('node/1', 'fr');
$this->assertIdentical('node-1-fr', $alias);
// Delete and ensure it does not exist anymore.
$backend->deleteAlias('node/1', 'node-1-fr', 'fr');
$source = $backend->lookupSource('node-1-fr', 'fr');
$this->assertIdentical(null, $source);
$alias = $backend->lookupAlias('node/1', 'fr');
$this->assertIdentical(null, $source);
// Set more than one aliases and ensure order at loading.
$backend->saveAlias('node/1', 'node-1-fr-1', 'fr');
$backend->saveAlias('node/1', 'node-1-fr-2', 'fr');
$backend->saveAlias('node/1', 'node-1-fr-3', 'fr');
$alias = $backend->lookupAlias('node/1', 'fr');
$this->assertIdentical('node-1-fr-3', $alias);
// Add another alias to test the delete language feature.
// Also add some other languages aliases.
$backend->saveAlias('node/1', 'node-1');
$backend->saveAlias('node/2', 'node-2-en', 'en');
$backend->saveAlias('node/3', 'node-3-ca', 'ca');
// Ok, delete fr and tests every other are still there.
$backend->deleteLanguage('fr');
$alias = $backend->lookupAlias('node/1');
$this->assertIdentical('node-1', $alias);
$alias = $backend->lookupAlias('node/2', 'en');
$this->assertIdentical('node-2-en', $alias);
$alias = $backend->lookupAlias('node/3', 'ca');
$this->assertIdentical('node-3-ca', $alias);
// Now create back a few entries in some langage and
// ensure fallback to no language also works.
$backend->saveAlias('node/4', 'node-4');
$backend->saveAlias('node/4', 'node-4-es', 'es');
$alias = $backend->lookupAlias('node/4');
$this->assertIdentical('node-4', $alias);
$alias = $backend->lookupAlias('node/4', 'es');
$this->assertIdentical('node-4-es', $alias);
$alias = $backend->lookupAlias('node/4', 'fr');
$this->assertIdentical('node-4', $alias);
}
/**
* Tests https://www.drupal.org/node/2728831
*/
public function testSomeEdgeCaseFalseNegative()
{
$backend = $this->getBackend();
$backend->deleteLanguage('fr');
$backend->deleteLanguage('und');
$backend->saveAlias('node/123', 'node-123');
// Language lookup should return the language neutral value if no value
$source = $backend->lookupSource('node-123', 'fr');
$this->assertIdentical($source, 'node/123');
$source = $backend->lookupAlias('node/123', 'fr');
$this->assertIdentical($source, 'node-123');
// Now, let's consider we have an item we don't know if it exists or
// not, per definition we should not return a strict FALSE but a NULL
// value instead to tell "we don't know anything about this". In a
// very specific use-case, if the language neutral value is a strict
// "not exists" value, it should still return NULL instead of FALSE
// if another language was asked for.
// Store "value null" for the language neutral entry
$backend->saveAlias('node/456', Redis_Path_HashLookupInterface::VALUE_NULL);
$source = $backend->lookupAlias('node/456');
$this->assertIdentical(false, $source);
$source = $backend->lookupAlias('node/456', 'fr');
$this->assertIdentical(null, $source);
}
/**
* Tests that lookup is case insensitive
*/
public function testCaseInsensitivePathLookup()
{
$backend = $this->getBackend();
$backend->saveAlias('node/1', 'Node-1-FR', 'fr');
$source = $backend->lookupSource('NODE-1-fr', 'fr');
$this->assertIdentical('node/1', $source);
$source = $backend->lookupSource('node-1-FR', 'fr');
$this->assertIdentical('node/1', $source);
$alias = $backend->lookupAlias('node/1', 'fr');
$this->assertIdentical('node-1-fr', strtolower($alias));
// Delete and ensure it does not exist anymore.
$backend->deleteAlias('node/1', 'node-1-FR', 'fr');
$source = $backend->lookupSource('Node-1-FR', 'fr');
$this->assertIdentical(null, $source);
$alias = $backend->lookupAlias('node/1', 'fr');
$this->assertIdentical(null, $source);
}
}

View File

@@ -0,0 +1,22 @@
<?php
if (!class_exists('Redis_Tests_Path_PathUnitTestCase')) {
require_once(__DIR__ . '/PathUnitTestCase.php');
}
class Redis_Tests_Path_PhpRedisPathUnitTestCase extends Redis_Tests_Path_PathUnitTestCase
{
public static function getInfo()
{
return array(
'name' => 'PhpRedis path inc replacement',
'description' => 'Tests PhpRedis path inc replacement.',
'group' => 'Redis',
);
}
protected function getClientInterface()
{
return 'PhpRedis';
}
}

View File

@@ -0,0 +1,18 @@
<?php
class Redis_Tests_Path_PredisPathUnitTestCase extends Redis_Tests_Path_PathUnitTestCase
{
public static function getInfo()
{
return array(
'name' => 'Predis path inc replacement',
'description' => 'Tests Predis path inc replacement.',
'group' => 'Redis',
);
}
protected function getClientInterface()
{
return 'Predis';
}
}

View File

@@ -0,0 +1,22 @@
<?php
if (!class_exists('Redis_Tests_Queue_QueueUnitTestCase')) {
require_once(__DIR__ . '/QueueUnitTestCase.php');
}
class Redis_Tests_Queue_PhpRedisQueueUnitTestCase extends Redis_Tests_Queue_QueueUnitTestCase
{
public static function getInfo()
{
return array(
'name' => 'PhpRedis Redis queue',
'description' => 'Ensure that Redis queue feature is working OK.',
'group' => 'Redis',
);
}
protected function getClientInterface()
{
return 'PhpRedis';
}
}

View File

@@ -0,0 +1,20 @@
<?php
/*
class Redis_Tests_Queue_PredisQueueUnitTestCase extends Redis_Tests_Queue_QueueUnitTestCase
{
public static function getInfo()
{
return array(
'name' => 'Predis Redis queue',
'description' => 'Ensure that Redis queue feature is working OK.',
'group' => 'Redis',
);
}
protected function getClientInterface()
{
return 'Predis';
}
}
*/

View File

@@ -0,0 +1,133 @@
<?php
/**
* Some tests in there credits goes to the redis_queue module.
* Thanks to their author.
*/
abstract class Redis_Tests_Queue_QueueUnitTestCase extends Redis_Tests_AbstractUnitTestCase
{
/**
* @var Redis_Queue
*/
public $queue;
/**
* @var string
*/
public $name;
public function setUp()
{
parent::setUp();
module_load_include('inc', 'system', 'system.queue');
$this->name = 'redis-queue-test-' . time();
$this->queue = new Redis_Queue($this->name);
}
public function tearDown()
{
$this->queue->deleteQueue();
$this->name = null;
parent::tearDown();
}
public function testCreate()
{
$res = $this->queue->createItem('test-queue-item-create');
$num = $this->queue->numberOfItems();
$this->assertEqual(1, $num);
}
public function testClaim()
{
$data = 'test-queue-item-claimed';
$res = $this->queue->createItem($data);
$item = $this->queue->claimItem();
$this->assertEqual($data, $item->data);
}
/*
public function testClaimBlocking()
{
$data = 'test-queue-item-claimed';
$res = $this->queue->createItem($data);
$this->assertTrue($res);
$item = $this->queue->claimItemBlocking(10);
$this->assertEqual($data, $item->data);
}
*/
public function testRelease()
{
$data = 'test-queue-item';
$res = $this->queue->createItem($data);
$item = $this->queue->claimItem();
$num = $this->queue->numberOfItems();
$this->assertEqual(0, $num);
$res = $this->queue->releaseItem($item);
$num = $this->queue->numberOfItems();
$this->assertEqual(1, $num);
}
public function testOrder()
{
$keys = array('test1', 'test2', 'test3');
foreach ($keys as $k) {
$this->queue->createItem($k);
}
$first = $this->queue->claimItem();
$this->assertEqual($first->data, $keys[0]);
$second = $this->queue->claimItem();
$this->assertEqual($second->data, $keys[1]);
$this->queue->releaseItem($first);
$third = $this->queue->claimItem();
$this->assertEqual($third->data, $keys[2]);
$first_again = $this->queue->claimItem();
$this->assertEqual($first_again->data, $keys[0]);
$num = $this->queue->numberOfItems();
$this->assertEqual(0, $num);
}
/*
public function lease()
{
$data = 'test-queue-item';
$res = $this->queue->createItem($data);
$num = $this->queue->numberOfItems();
$this->assertEquals(1, $num);
$item = $this->queue->claimItem(1);
// In Redis 2.4 the expire could be between zero to one seconds off.
sleep(2);
$expired = $this->queue->expire();
$this->assertEquals(1, $expired);
$this->assertEquals(1, $this->queue->numberOfItems());
// Create a second queue to test expireAll()
$q2 = new RedisQueue($this->name . '_2');
$q2->createItem($data);
$q2->createItem($data);
$this->assertEquals(2, $q2->numberOfItems());
$item = $this->queue->claimItem(1);
$item2 = $q2->claimItem(1);
$this->assertEquals(1, $q2->numberOfItems());
sleep(2);
$expired = $this->queue->expireAll();
$this->assertEquals(2, $expired);
$this->assertEquals(1, $this->queue->numberOfItems());
$this->assertEquals(2, $q2->numberOfItems());
$q2->deleteQueue();
}
*/
}