mirror of
https://github.com/predis/predis.git
synced 2026-08-25 21:39:36 +00:00
371ac91777
Fix also a few bugs found while rewriting the test suite. In order to be able to run integration tests, the test suite requires a version of Redis >= 2.4.0. The units have been splitted into several different groups using PHPUnit @group annotation to allow developers to enable, disable and combine certain types of tests. The available groups are: - disconnected: can run without a Redis server online - connected: active connection to a Redis server is required - commands: test dedicated to a specific Redis command - slow: performs operations that can slow down execution; A list of the available groups can be obtained by running phpunit --list-groups Groups of tests can be disabled or enabled via the XML configuration file or the standard command-line test runner. Please note that due to a bug in PHPUnit, older versions ignore the --group option when the group is excluded in the XML configuration file. Please refer to http://github.com/sebastianbergmann/phpunit/issues/320 for details Integration tests in the @connected group check if the command being tested is defined in the selected server profile (see the value of the TEST_SERVER_VERSION constant in phpunit.xml). If the command is not defined in the target server profile, the integration test is automatically marked as skipped. We also provide an helper script in the bin directory that can be used to automatically generate a file with the scheleton of a test case for a Redis command by specifying the name of the class in the Predis\Commands namespace. For example, to generate a test case for SET (represented by the Predis\Commands\StringSet class): ./bin/generate-command-test.php --class=StringSet The realm of a command is automatically inferred from the name of the class, but it can be set using the --realm option.
428 lines
11 KiB
PHP
428 lines
11 KiB
PHP
<?php
|
|
|
|
/*
|
|
* This file is part of the Predis package.
|
|
*
|
|
* (c) Daniele Alessandri <suppakilla@gmail.com>
|
|
*
|
|
* For the full copyright and license information, please view the LICENSE
|
|
* file that was distributed with this source code.
|
|
*/
|
|
|
|
namespace Predis\Transaction;
|
|
|
|
use Predis\Client;
|
|
use Predis\Helpers;
|
|
use Predis\ResponseQueued;
|
|
use Predis\ClientException;
|
|
use Predis\ServerException;
|
|
use Predis\NotSupportedException;
|
|
use Predis\CommunicationException;
|
|
use Predis\Protocol\ProtocolException;
|
|
|
|
/**
|
|
* Client-side abstraction of a Redis transaction based on MULTI / EXEC.
|
|
*
|
|
* @author Daniele Alessandri <suppakilla@gmail.com>
|
|
*/
|
|
class MultiExecContext
|
|
{
|
|
const STATE_RESET = 0x00000;
|
|
const STATE_INITIALIZED = 0x00001;
|
|
const STATE_INSIDEBLOCK = 0x00010;
|
|
const STATE_DISCARDED = 0x00100;
|
|
const STATE_CAS = 0x01000;
|
|
const STATE_WATCH = 0x10000;
|
|
|
|
private $state;
|
|
private $canWatch;
|
|
|
|
protected $client;
|
|
protected $options;
|
|
protected $commands;
|
|
|
|
/**
|
|
* @param Client Client instance used by the context.
|
|
* @param array Options for the context initialization.
|
|
*/
|
|
public function __construct(Client $client, Array $options = null)
|
|
{
|
|
$this->checkCapabilities($client);
|
|
$this->options = $options ?: array();
|
|
$this->client = $client;
|
|
$this->reset();
|
|
}
|
|
|
|
/**
|
|
* Sets the internal state flags.
|
|
*
|
|
* @param int $flags Set of flags
|
|
*/
|
|
protected function setState($flags)
|
|
{
|
|
$this->state = $flags;
|
|
}
|
|
|
|
/**
|
|
* Gets the internal state flags.
|
|
*
|
|
* @return int
|
|
*/
|
|
protected function getState()
|
|
{
|
|
return $this->state;
|
|
}
|
|
|
|
/**
|
|
* Sets one or more flags.
|
|
*
|
|
* @param int $flags Set of flags
|
|
*/
|
|
protected function flagState($flags)
|
|
{
|
|
$this->state |= $flags;
|
|
}
|
|
|
|
/**
|
|
* Resets one or more flags.
|
|
*
|
|
* @param int $flags Set of flags
|
|
*/
|
|
protected function unflagState($flags)
|
|
{
|
|
$this->state &= ~$flags;
|
|
}
|
|
|
|
/**
|
|
* Checks is a flag is set.
|
|
*
|
|
* @param int $flags Flag
|
|
* @return Boolean
|
|
*/
|
|
protected function checkState($flags)
|
|
{
|
|
return ($this->state & $flags) === $flags;
|
|
}
|
|
|
|
/**
|
|
* Checks if the passed client instance satisfies the required conditions
|
|
* needed to initialize a transaction context.
|
|
*
|
|
* @param Client Client instance used by the context.
|
|
*/
|
|
private function checkCapabilities(Client $client)
|
|
{
|
|
if (Helpers::isCluster($client->getConnection())) {
|
|
throw new NotSupportedException('Cannot initialize a MULTI/EXEC context over a cluster of connections');
|
|
}
|
|
|
|
$profile = $client->getProfile();
|
|
if ($profile->supportsCommands(array('multi', 'exec', 'discard')) === false) {
|
|
throw new NotSupportedException('The current profile does not support MULTI, EXEC and DISCARD');
|
|
}
|
|
|
|
$this->canWatch = $profile->supportsCommands(array('watch', 'unwatch'));
|
|
}
|
|
|
|
/**
|
|
* Checks if WATCH and UNWATCH are supported by the server profile.
|
|
*/
|
|
private function isWatchSupported()
|
|
{
|
|
if ($this->canWatch === false) {
|
|
throw new NotSupportedException('The current profile does not support WATCH and UNWATCH');
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Resets the state of a transaction.
|
|
*/
|
|
protected function reset()
|
|
{
|
|
$this->setState(self::STATE_RESET);
|
|
$this->commands = array();
|
|
}
|
|
|
|
/**
|
|
* Initializes a new transaction.
|
|
*/
|
|
protected function initialize()
|
|
{
|
|
if ($this->checkState(self::STATE_INITIALIZED)) {
|
|
return;
|
|
}
|
|
|
|
$options = $this->options;
|
|
|
|
if (isset($options['cas']) && $options['cas']) {
|
|
$this->flagState(self::STATE_CAS);
|
|
}
|
|
if (isset($options['watch'])) {
|
|
$this->watch($options['watch']);
|
|
}
|
|
|
|
$cas = $this->checkState(self::STATE_CAS);
|
|
$discarded = $this->checkState(self::STATE_DISCARDED);
|
|
|
|
if (!$cas || ($cas && $discarded)) {
|
|
$this->client->multi();
|
|
if ($discarded) {
|
|
$this->unflagState(self::STATE_CAS);
|
|
}
|
|
}
|
|
|
|
$this->unflagState(self::STATE_DISCARDED);
|
|
$this->flagState(self::STATE_INITIALIZED);
|
|
}
|
|
|
|
/**
|
|
* Dinamically invokes a Redis command with the specified arguments.
|
|
*
|
|
* @param string $method Command ID.
|
|
* @param array $arguments Arguments for the command.
|
|
* @return MultiExecContext
|
|
*/
|
|
public function __call($method, $arguments)
|
|
{
|
|
$this->initialize();
|
|
$client = $this->client;
|
|
|
|
if ($this->checkState(self::STATE_CAS)) {
|
|
return call_user_func_array(array($client, $method), $arguments);
|
|
}
|
|
|
|
$command = $client->createCommand($method, $arguments);
|
|
$response = $client->executeCommand($command);
|
|
|
|
if (!$response instanceof ResponseQueued) {
|
|
$this->onProtocolError('The server did not respond with a QUEUED status reply');
|
|
}
|
|
|
|
$this->commands[] = $command;
|
|
|
|
return $this;
|
|
}
|
|
|
|
/**
|
|
* Executes WATCH on one or more keys.
|
|
*
|
|
* @param string|array $keys One or more keys.
|
|
* @return mixed
|
|
*/
|
|
public function watch($keys)
|
|
{
|
|
$this->isWatchSupported();
|
|
|
|
if ($this->checkState(self::STATE_INITIALIZED) && !$this->checkState(self::STATE_CAS)) {
|
|
throw new ClientException('WATCH after MULTI is not allowed');
|
|
}
|
|
|
|
$watchReply = $this->client->watch($keys);
|
|
$this->flagState(self::STATE_WATCH);
|
|
|
|
return $watchReply;
|
|
}
|
|
|
|
/**
|
|
* Finalizes the transaction on the server by executing MULTI on the server.
|
|
*
|
|
* @return MultiExecContext
|
|
*/
|
|
public function multi()
|
|
{
|
|
if ($this->checkState(self::STATE_INITIALIZED | self::STATE_CAS)) {
|
|
$this->unflagState(self::STATE_CAS);
|
|
$this->client->multi();
|
|
}
|
|
else {
|
|
$this->initialize();
|
|
}
|
|
|
|
return $this;
|
|
}
|
|
|
|
/**
|
|
* Executes UNWATCH.
|
|
*
|
|
* @return MultiExecContext
|
|
*/
|
|
public function unwatch()
|
|
{
|
|
$this->isWatchSupported();
|
|
$this->unflagState(self::STATE_WATCH);
|
|
$this->__call('unwatch', array());
|
|
|
|
return $this;
|
|
}
|
|
|
|
/**
|
|
* Resets a transaction by UNWATCHing the keys that are being WATCHed and
|
|
* DISCARDing the pending commands that have been already sent to the server.
|
|
*
|
|
* @return MultiExecContext
|
|
*/
|
|
public function discard()
|
|
{
|
|
if ($this->checkState(self::STATE_INITIALIZED)) {
|
|
$command = $this->checkState(self::STATE_CAS) ? 'unwatch' : 'discard';
|
|
$this->client->$command();
|
|
$this->reset();
|
|
$this->flagState(self::STATE_DISCARDED);
|
|
}
|
|
|
|
return $this;
|
|
}
|
|
|
|
/**
|
|
* Executes the whole transaction.
|
|
*
|
|
* @return mixed
|
|
*/
|
|
public function exec()
|
|
{
|
|
return $this->execute();
|
|
}
|
|
|
|
/**
|
|
* Checks the state of the transaction before execution.
|
|
*
|
|
* @param mixed $callable Callback for execution.
|
|
*/
|
|
private function checkBeforeExecution($callable)
|
|
{
|
|
if ($this->checkState(self::STATE_INSIDEBLOCK)) {
|
|
throw new ClientException("Cannot invoke 'execute' or 'exec' inside an active client transaction block");
|
|
}
|
|
|
|
if ($callable) {
|
|
if (!is_callable($callable)) {
|
|
throw new \InvalidArgumentException('Argument passed must be a callable object');
|
|
}
|
|
|
|
if (count($this->commands) > 0) {
|
|
$this->discard();
|
|
throw new ClientException('Cannot execute a transaction block after using fluent interface');
|
|
}
|
|
}
|
|
|
|
if (isset($this->options['retry']) && !isset($callable)) {
|
|
$this->discard();
|
|
throw new \InvalidArgumentException('Automatic retries can be used only when a transaction block is provided');
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Handles the actual execution of the whole transaction.
|
|
*
|
|
* @param mixed $callable Callback for execution.
|
|
* @return array
|
|
*/
|
|
public function execute($callable = null)
|
|
{
|
|
$this->checkBeforeExecution($callable);
|
|
|
|
$reply = null;
|
|
$returnValues = array();
|
|
$attemptsLeft = isset($this->options['retry']) ? (int)$this->options['retry'] : 0;
|
|
|
|
do {
|
|
if ($callable !== null) {
|
|
$this->executeTransactionBlock($callable);
|
|
}
|
|
|
|
if (count($this->commands) === 0) {
|
|
if ($this->checkState(self::STATE_WATCH)) {
|
|
$this->discard();
|
|
}
|
|
return;
|
|
}
|
|
|
|
$reply = $this->client->exec();
|
|
|
|
if ($reply === null) {
|
|
if ($attemptsLeft === 0) {
|
|
$message = 'The current transaction has been aborted by the server';
|
|
throw new AbortedMultiExecException($this, $message);
|
|
}
|
|
|
|
$this->reset();
|
|
|
|
if (isset($this->options['on_retry']) && is_callable($this->options['on_retry'])) {
|
|
call_user_func($this->options['on_retry'], $this, $attemptsLeft);
|
|
}
|
|
|
|
continue;
|
|
}
|
|
|
|
break;
|
|
} while ($attemptsLeft-- > 0);
|
|
|
|
$execReply = $reply instanceof \Iterator ? iterator_to_array($reply) : $reply;
|
|
$sizeofReplies = count($execReply);
|
|
$commands = $this->commands;
|
|
|
|
if ($sizeofReplies !== count($commands)) {
|
|
$this->onProtocolError("EXEC returned an unexpected number of replies");
|
|
}
|
|
|
|
for ($i = 0; $i < $sizeofReplies; $i++) {
|
|
$commandReply = $execReply[$i];
|
|
|
|
if ($commandReply instanceof \Iterator) {
|
|
$commandReply = iterator_to_array($commandReply);
|
|
}
|
|
|
|
$returnValues[$i] = $commands[$i]->parseResponse($commandReply);
|
|
unset($commands[$i]);
|
|
}
|
|
|
|
return $returnValues;
|
|
}
|
|
|
|
/**
|
|
* Passes the current transaction context to a callable block for execution.
|
|
*
|
|
* @param mixed $callable Callback.
|
|
*/
|
|
protected function executeTransactionBlock($callable)
|
|
{
|
|
$blockException = null;
|
|
$this->flagState(self::STATE_INSIDEBLOCK);
|
|
|
|
try {
|
|
$callable($this);
|
|
}
|
|
catch (CommunicationException $exception) {
|
|
$blockException = $exception;
|
|
}
|
|
catch (ServerException $exception) {
|
|
$blockException = $exception;
|
|
}
|
|
catch (\Exception $exception) {
|
|
$blockException = $exception;
|
|
$this->discard();
|
|
}
|
|
|
|
$this->unflagState(self::STATE_INSIDEBLOCK);
|
|
|
|
if ($blockException !== null) {
|
|
throw $blockException;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Helper method that handles protocol errors encountered inside a transaction.
|
|
*
|
|
* @param string $message Error message.
|
|
*/
|
|
private function onProtocolError($message)
|
|
{
|
|
// Since a MULTI/EXEC block cannot be initialized over a clustered
|
|
// connection, we can safely assume that Predis\Client::getConnection()
|
|
// will always return an instance of Predis\Network\IConnectionSingle.
|
|
Helpers::onCommunicationException(new ProtocolException(
|
|
$this->client->getConnection(), $message
|
|
));
|
|
}
|
|
}
|