* * 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\Command\CommandInterface; use Predis\Response; use PredisTestCase; /** * @group realm-transaction */ class MultiExecTest extends PredisTestCase { /** * @group disconnected * @expectedException \Predis\NotSupportedException * @expectedExceptionMessage MULTI, EXEC and DISCARD are not supported by the current command factory. */ public function testThrowsExceptionOnUnsupportedMultiExecInCommandFactory() { $commands = $this->getMock('Predis\Command\FactoryInterface'); $commands ->expects($this->once()) ->method('supportsCommands') ->with(array('MULTI', 'EXEC', 'DISCARD')) ->will($this->returnValue(false)); $connection = $this->getMock('Predis\Connection\NodeConnectionInterface'); $client = new Client($connection, array('commands' => $commands)); new MultiExec($client); } /** * @group disconnected * @expectedException \Predis\NotSupportedException * @expectedExceptionMessage WATCH is not supported by the current command factory. */ public function testThrowsExceptionOnUnsupportedWatchInCommandFactory() { $commands = $this->getMock('Predis\Command\FactoryInterface'); $commands ->expects($this->once()) ->method('supportsCommands') ->with(array('MULTI', 'EXEC', 'DISCARD')) ->will($this->returnValue(true)); $commands ->expects($this->once()) ->method('supportsCommand') ->with('WATCH') ->will($this->returnValue(false)); $connection = $this->getMock('Predis\Connection\NodeConnectionInterface'); $client = new Client($connection, array('commands' => $commands)); $tx = new MultiExec($client, array('options' => 'cas')); $tx->watch('foo'); } /** * @group disconnected * @expectedException \Predis\NotSupportedException * @expectedExceptionMessage UNWATCH is not supported by the current command factory. */ public function testThrowsExceptionOnUnsupportedUnwatchInCommandFactory() { $commands = $this->getMock('Predis\Command\FactoryInterface'); $commands ->expects($this->once()) ->method('supportsCommands') ->with(array('MULTI', 'EXEC', 'DISCARD')) ->will($this->returnValue(true)); $commands ->expects($this->once()) ->method('supportsCommand') ->with('UNWATCH') ->will($this->returnValue(false)); $connection = $this->getMock('Predis\Connection\NodeConnectionInterface'); $client = new Client($connection, array('commands' => $commands)); $tx = new MultiExec($client, array('options' => 'cas')); $tx->unwatch('foo'); } /** * @group disconnected */ public function testExecutionWithFluentInterface() { $commands = array(); $expected = array('one', 'two', 'three'); $callback = $this->getExecuteCallback($expected, $commands); $tx = $this->getMockedTransaction($callback); $this->assertSame($expected, $tx->echo('one')->echo('two')->echo('three')->execute()); $this->assertSame(array('MULTI', 'ECHO', 'ECHO', 'ECHO', 'EXEC'), self::commandsToIDs($commands)); } /** * @group disconnected */ public function testExecutionWithCallable() { $commands = array(); $expected = array('one', 'two', 'three'); $callback = $this->getExecuteCallback($expected, $commands); $tx = $this->getMockedTransaction($callback); $responses = $tx->execute(function ($tx) { $tx->echo('one'); $tx->echo('two'); $tx->echo('three'); }); $this->assertSame($expected, $responses); $this->assertSame(array('MULTI', 'ECHO', 'ECHO', 'ECHO', 'EXEC'), self::commandsToIDs($commands)); } /** * @group disconnected */ public function testCannotMixExecutionWithFluentInterfaceAndCallable() { $commands = array(); $callback = $this->getExecuteCallback(null, $commands); $tx = $this->getMockedTransaction($callback); $exception = null; try { $tx->echo('foo')->execute(function ($tx) { $tx->echo('bar'); }); } catch (\Exception $ex) { $exception = $ex; } $this->assertInstanceOf('Predis\ClientException', $exception); $this->assertSame(array('MULTI', 'ECHO', 'DISCARD'), self::commandsToIDs($commands)); } /** * @group disconnected */ public function testEmptyTransactionDoesNotSendMultiExecCommands() { $commands = array(); $callback = $this->getExecuteCallback(null, $commands); $tx = $this->getMockedTransaction($callback); $responses = $tx->execute(function ($tx) { // NOOP }); $this->assertNull($responses); $this->assertSame(array(), self::commandsToIDs($commands)); } /** * @group disconnected * @expectedException \Predis\ClientException * @expectedExceptionMessage Cannot invoke "execute" or "exec" inside an active transaction context. */ public function testThrowsExceptionOnExecInsideTransactionBlock() { $commands = array(); $callback = $this->getExecuteCallback(null, $commands); $tx = $this->getMockedTransaction($callback); $responses = $tx->execute(function ($tx) { $tx->exec(); }); $this->assertNull($responses); $this->assertSame(array(), self::commandsToIDs($commands)); } /** * @group disconnected */ public function testEmptyTransactionIgnoresDiscard() { $commands = array(); $callback = $this->getExecuteCallback(null, $commands); $tx = $this->getMockedTransaction($callback); $responses = $tx->execute(function ($tx) { $tx->discard(); }); $this->assertNull($responses); $this->assertSame(array(), self::commandsToIDs($commands)); } /** * @group disconnected */ public function testTransactionWithCommandsSendsDiscard() { $commands = array(); $callback = $this->getExecuteCallback(null, $commands); $tx = $this->getMockedTransaction($callback); $responses = $tx->execute(function ($tx) { $tx->set('foo', 'bar'); $tx->get('foo'); $tx->discard(); }); $this->assertNull($responses); $this->assertSame(array('MULTI', 'SET', 'GET', 'DISCARD'), self::commandsToIDs($commands)); } /** * @group disconnected */ public function testSendMultiOnCommandsFollowingDiscard() { $commands = array(); $expected = array('after DISCARD'); $callback = $this->getExecuteCallback($expected, $commands); $tx = $this->getMockedTransaction($callback); $responses = $tx->execute(function ($tx) { $tx->echo('before DISCARD'); $tx->discard(); $tx->echo('after DISCARD'); }); $this->assertSame($responses, $expected); $this->assertSame(array('MULTI', 'ECHO', 'DISCARD', 'MULTI', 'ECHO', 'EXEC'), self::commandsToIDs($commands)); } /** * @group disconnected * @expectedException \Predis\ClientException */ public function testThrowsExceptionOnWatchInsideMulti() { $callback = $this->getExecuteCallback(); $tx = $this->getMockedTransaction($callback); $tx->echo('foobar')->watch('foo')->execute(); } /** * @group disconnected */ public function testUnwatchInsideMulti() { $commands = array(); $expected = array('foobar', true); $callback = $this->getExecuteCallback($expected, $commands); $tx = $this->getMockedTransaction($callback); $responses = $tx->echo('foobar')->unwatch('foo')->execute(); $this->assertSame($responses, $expected); $this->assertSame(array('MULTI', 'ECHO', 'UNWATCH', 'EXEC'), self::commandsToIDs($commands)); } /** * @group disconnected */ public function testAutomaticWatchInOptions() { $txCommands = $casCommands = array(); $expected = array('bar', 'piyo'); $options = array('watch' => array('foo', 'hoge')); $callback = $this->getExecuteCallback($expected, $txCommands, $casCommands); $tx = $this->getMockedTransaction($callback, $options); $responses = $tx->execute(function ($tx) { $tx->get('foo'); $tx->get('hoge'); }); $this->assertSame($responses, $expected); $this->assertSame(array('WATCH'), self::commandsToIDs($casCommands)); $this->assertSame(array('foo', 'hoge'), $casCommands[0]->getArguments()); $this->assertSame(array('MULTI', 'GET', 'GET', 'EXEC'), self::commandsToIDs($txCommands)); } /** * @group disconnected */ public function testCheckAndSetWithFluentInterface() { $txCommands = $casCommands = array(); $expected = array('bar', 'piyo'); $options = array('cas' => true, 'watch' => array('foo', 'hoge')); $callback = $this->getExecuteCallback($expected, $txCommands, $casCommands); $tx = $this->getMockedTransaction($callback, $options); $tx->watch('foobar'); $this->assertSame('DUMMY_RESPONSE', $tx->get('foo')); $this->assertSame('DUMMY_RESPONSE', $tx->get('hoge')); $responses = $tx ->multi() ->get('foo') ->get('hoge') ->execute(); $this->assertSame($responses, $expected); $this->assertSame(array('WATCH', 'WATCH', 'GET', 'GET'), self::commandsToIDs($casCommands)); $this->assertSame(array('MULTI', 'GET', 'GET', 'EXEC'), self::commandsToIDs($txCommands)); } /** * @group disconnected */ public function testCheckAndSetWithBlock() { $txCommands = $casCommands = array(); $expected = array('bar', 'piyo'); $options = array('cas' => true, 'watch' => array('foo', 'hoge')); $callback = $this->getExecuteCallback($expected, $txCommands, $casCommands); $tx = $this->getMockedTransaction($callback, $options); $test = $this; $responses = $tx->execute(function ($tx) use ($test) { $tx->watch('foobar'); $response1 = $tx->get('foo'); $response2 = $tx->get('hoge'); $test->assertSame('DUMMY_RESPONSE', $response1); $test->assertSame('DUMMY_RESPONSE', $response2); $tx->multi(); $tx->get('foo'); $tx->get('hoge'); }); $this->assertSame($responses, $expected); $this->assertSame(array('WATCH', 'WATCH', 'GET', 'GET'), self::commandsToIDs($casCommands)); $this->assertSame(array('MULTI', 'GET', 'GET', 'EXEC'), self::commandsToIDs($txCommands)); } /** * @group disconnected */ public function testCheckAndSetWithEmptyBlock() { $txCommands = $casCommands = array(); $options = array('cas' => true); $callback = $this->getExecuteCallback(array(), $txCommands, $casCommands); $tx = $this->getMockedTransaction($callback, $options); $tx->execute(function ($tx) { $tx->multi(); }); $this->assertSame(array(), self::commandsToIDs($casCommands)); $this->assertSame(array(), self::commandsToIDs($txCommands)); } /** * @group disconnected */ public function testCheckAndSetWithoutExec() { $txCommands = $casCommands = array(); $options = array('cas' => true); $callback = $this->getExecuteCallback(array(), $txCommands, $casCommands); $tx = $this->getMockedTransaction($callback, $options); $tx->execute(function ($tx) { $tx->get('foo'); $tx->set('hoge', 'piyo'); }); $this->assertSame(array('GET', 'SET'), self::commandsToIDs($casCommands)); $this->assertSame(array(), self::commandsToIDs($txCommands)); } /** * @group disconnected * @expectedException \Predis\ClientException * @expectedExceptionMessage Automatic retries are supported only when a callable block is provided. */ public function testThrowsExceptionOnAutomaticRetriesWithFluentInterface() { $options = array('retry' => 1); $callback = $this->getExecuteCallback(); $tx = $this->getMockedTransaction($callback, $options); $tx->echo('message')->execute(); } /** * @group disconnected */ public function testAutomaticRetryOnServerSideTransactionAbort() { $casCommands = $txCommands = array(); $expected = array('bar'); $options = array('watch' => array('foo', 'bar'), 'retry' => ($attempts = 2) + 1); $sentinel = $this->getMock('stdClass', array('signal')); $sentinel->expects($this->exactly($attempts))->method('signal'); $callback = $this->getExecuteCallback($expected, $txCommands, $casCommands); $tx = $this->getMockedTransaction($callback, $options); $responses = $tx->execute(function ($tx) use ($sentinel, &$attempts) { $tx->get('foo'); if ($attempts > 0) { $attempts -= 1; $sentinel->signal(); $tx->echo('!!ABORT!!'); } }); $this->assertSame($responses, $expected); $this->assertSame(array('WATCH'), self::commandsToIDs($casCommands)); $this->assertSame(array('foo', 'bar'), $casCommands[0]->getArguments()); $this->assertSame(array('MULTI', 'GET', 'EXEC'), self::commandsToIDs($txCommands)); } /** * @group disconnected * @expectedException \Predis\Transaction\AbortedMultiExecException */ public function testThrowsExceptionOnServerSideTransactionAbort() { $callback = $this->getExecuteCallback(); $tx = $this->getMockedTransaction($callback); $tx->execute(function ($tx) { $tx->echo('!!ABORT!!'); }); } /** * @group disconnected */ public function testHandlesStandardExceptionsInBlock() { $commands = array(); $expected = array('foobar', true); $callback = $this->getExecuteCallback($expected, $commands); $tx = $this->getMockedTransaction($callback); $responses = null; try { $responses = $tx->execute(function ($tx) { $tx->set('foo', 'bar'); $tx->get('foo'); throw new \RuntimeException('TEST'); }); } catch (\Exception $ex) { // NOOP } $this->assertNull($responses, $expected); $this->assertSame(array('MULTI', 'SET', 'GET', 'DISCARD'), self::commandsToIDs($commands)); } /** * @group disconnected */ public function testHandlesServerExceptionsInBlock() { $commands = array(); $expected = array('foobar', true); $callback = $this->getExecuteCallback($expected, $commands); $tx = $this->getMockedTransaction($callback); $responses = null; try { $responses = $tx->execute(function ($tx) { $tx->set('foo', 'bar'); $tx->echo('ERR Invalid operation'); $tx->get('foo'); }); } catch (Response\ServerException $ex) { $tx->discard(); } $this->assertNull($responses); $this->assertSame(array('MULTI', 'SET', 'ECHO', 'DISCARD'), self::commandsToIDs($commands)); } /** * @group disconnected */ public function testProperlyDiscardsTransactionAfterServerExceptionInBlock() { $connection = $this->getMockedConnection(function (CommandInterface $command) { switch ($command->getId()) { case 'MULTI': return true; case 'ECHO': return new Response\Error('ERR simulated failure on ECHO'); case 'EXEC': return new Response\Error('EXECABORT Transaction discarded because of previous errors.'); default: return new Response\Status('QUEUED'); } }); $client = new Client($connection); // First attempt $tx = new MultiExec($client); try { $tx->multi()->set('foo', 'bar')->echo('simulated failure')->exec(); } catch (\Exception $exception) { $this->assertInstanceOf('Predis\Transaction\AbortedMultiExecException', $exception); $this->assertSame('ERR simulated failure on ECHO', $exception->getMessage()); } // Second attempt $tx = new MultiExec($client); try { $tx->multi()->set('foo', 'bar')->echo('simulated failure')->exec(); } catch (\Exception $exception) { $this->assertInstanceOf('Predis\Transaction\AbortedMultiExecException', $exception); $this->assertSame('ERR simulated failure on ECHO', $exception->getMessage()); } } /** * @group disconnected */ public function testExceptionsOptionTakesPrecedenceOverClientOptionsWhenFalse() { $expected = array('before', new Response\Error('ERR simulated error'), 'after'); $connection = $this->getMockedConnection(function (CommandInterface $command) use ($expected) { switch ($command->getId()) { case 'MULTI': return true; case 'EXEC': return $expected; default: return new Response\Status('QUEUED'); } }); $client = new Client($connection, array('exceptions' => true)); $tx = new MultiExec($client, array('exceptions' => false)); $result = $tx ->multi() ->echo('before') ->echo('ERROR PLEASE!') ->echo('after') ->exec(); $this->assertSame($expected, $result); } /** * @group disconnected * @expectedException \Predis\Response\ServerException * @expectedExceptionMessage ERR simulated error */ public function testExceptionsOptionTakesPrecedenceOverClientOptionsWhenTrue() { $expected = array('before', new Response\Error('ERR simulated error'), 'after'); $connection = $this->getMockedConnection(function (CommandInterface $command) use ($expected) { switch ($command->getId()) { case 'MULTI': return true; case 'EXEC': return $expected; default: return new Response\Status('QUEUED'); } }); $client = new Client($connection, array('exceptions' => false)); $tx = new MultiExec($client, array('exceptions' => true)); $tx->multi()->echo('before')->echo('ERROR PLEASE!')->echo('after')->exec(); } /** * @group disconnected * @expectedException \Predis\Response\ServerException * @expectedExceptionMessage ERR simulated failure on EXEC */ public function testExceptionsOptionDoesNotAffectTransactionControlCommands() { $connection = $this->getMockedConnection(function (CommandInterface $command) { switch ($command->getId()) { case 'MULTI': return true; case 'EXEC': return new Response\Error('ERR simulated failure on EXEC'); default: return new Response\Status('QUEUED'); } }); $client = new Client($connection, array('exceptions' => false)); $tx = new MultiExec($client); $tx->multi()->echo('test')->exec(); } // ******************************************************************** // // ---- INTEGRATION TESTS --------------------------------------------- // // ******************************************************************** // /** * @group connected */ public function testIntegrationHandlesStandardExceptionsInBlock() { $client = $this->getClient(); $exception = null; try { $client->transaction(function ($tx) { $tx->set('foo', 'bar'); throw new \RuntimeException('TEST'); }); } catch (\Exception $ex) { $exception = $ex; } $this->assertInstanceOf('RuntimeException', $exception); $this->assertSame(0, $client->exists('foo')); } /** * @group connected */ public function testIntegrationThrowsExceptionOnRedisErrorInBlock() { $client = $this->getClient(); $exception = null; $value = (string) rand(); try { $client->transaction(function ($tx) use ($value) { $tx->set('foo', 'bar'); $tx->lpush('foo', 'bar'); $tx->set('foo', $value); }); } catch (Response\ServerException $ex) { $exception = $ex; } $this->assertInstanceOf('Predis\Response\ErrorInterface', $exception); $this->assertSame($value, $client->get('foo')); } /** * @group connected */ public function testIntegrationReturnsErrorObjectOnRedisErrorInBlock() { $client = $this->getClient(array(), array('exceptions' => false)); $responses = $client->transaction(function ($tx) { $tx->set('foo', 'bar'); $tx->lpush('foo', 'bar'); $tx->echo('foobar'); }); $this->assertInstanceOf('Predis\Response\Status', $responses[0]); $this->assertInstanceOf('Predis\Response\Error', $responses[1]); $this->assertSame('foobar', $responses[2]); } /** * @group connected * @requiresRedisVersion >= 2.0.0 */ public function testIntegrationSendMultiOnCommandsAfterDiscard() { $client = $this->getClient(); $responses = $client->transaction(function ($tx) { $tx->set('foo', 'bar'); $tx->discard(); $tx->set('hoge', 'piyo'); }); $this->assertSame(1, count($responses)); $this->assertSame(0, $client->exists('foo')); $this->assertSame(1, $client->exists('hoge')); } /** * @group connected * @requiresRedisVersion >= 2.2.0 */ public function testIntegrationWritesOnWatchedKeysAbortTransaction() { $exception = null; $client1 = $this->getClient(); $client2 = $this->getClient(); try { $client1->transaction(array('watch' => 'sentinel'), function ($tx) use ($client2) { $tx->set('sentinel', 'client1'); $tx->get('sentinel'); $client2->set('sentinel', 'client2'); }); } catch (AbortedMultiExecException $ex) { $exception = $ex; } $this->assertInstanceOf('Predis\Transaction\AbortedMultiExecException', $exception); $this->assertSame('client2', $client1->get('sentinel')); } /** * @group connected * @requiresRedisVersion >= 2.2.0 */ public function testIntegrationCheckAndSetWithDiscardAndRetry() { $client = $this->getClient(); $client->set('foo', 'bar'); $options = array('watch' => 'foo', 'cas' => true); $responses = $client->transaction($options, function ($tx) { $tx->watch('foobar'); $foo = $tx->get('foo'); $tx->multi(); $tx->set('foobar', $foo); $tx->discard(); $tx->mget('foo', 'foobar'); }); $this->assertInternalType('array', $responses); $this->assertSame(array(array('bar', null)), $responses); $hijack = true; $client2 = $this->getClient(); $client->set('foo', 'bar'); $options = array('watch' => 'foo', 'cas' => true, 'retry' => 1); $responses = $client->transaction($options, function ($tx) use ($client2, &$hijack) { $foo = $tx->get('foo'); $tx->multi(); $tx->set('foobar', $foo); $tx->discard(); if ($hijack) { $hijack = false; $client2->set('foo', 'hijacked!'); } $tx->mget('foo', 'foobar'); }); $this->assertInternalType('array', $responses); $this->assertSame(array(array('hijacked!', null)), $responses); } // ******************************************************************** // // ---- HELPER METHODS ------------------------------------------------ // // ******************************************************************** // /** * Returns a mocked instance of Predis\Connection\NodeConnectionInterface * using the specified callback to return values from executeCommand(). * * @param \Closure $executeCallback * * @return \Predis\Connection\NodeConnectionInterface */ protected function getMockedConnection($executeCallback) { $connection = $this->getMock('Predis\Connection\NodeConnectionInterface'); $connection ->expects($this->any()) ->method('executeCommand') ->will($this->returnCallback($executeCallback)); return $connection; } /** * Returns a mocked instance of Predis\Transaction\MultiExec using * the specified callback to return values from the executeCommand method * of the underlying connection. * * @param \Closure $executeCallback * @param array $txOpts * @param array $clientOpts * * @return MultiExec */ protected function getMockedTransaction($executeCallback, $txOpts = null, $clientOpts = null) { $connection = $this->getMockedConnection($executeCallback); $client = new Client($connection, $clientOpts ?: array()); $transaction = new MultiExec($client, $txOpts ?: array()); return $transaction; } /** * Returns a callback that emulates a server-side MULTI/EXEC transaction context. * * @param array $expected Expected responses. * @param array $commands Reference to an array storing the whole flow of commands. * @param array $cas Check and set operations performed by the transaction. * * @return \Closure */ protected function getExecuteCallback($expected = array(), &$commands = array(), &$cas = array()) { $multi = $watch = $abort = false; return function (CommandInterface $command) use (&$expected, &$commands, &$cas, &$multi, &$watch, &$abort) { $cmd = $command->getId(); if ($multi || $cmd === 'MULTI') { $commands[] = $command; } else { $cas[] = $command; } switch ($cmd) { case 'WATCH': if ($multi) { return new Response\Error("ERR $cmd inside MULTI is not allowed"); } return $watch = true; case 'MULTI': if ($multi) { return new Response\Error('ERR MULTI calls can not be nested'); } return $multi = true; case 'EXEC': if (!$multi) { return new Response\Error("ERR $cmd without MULTI"); } $watch = $multi = false; if ($abort) { $commands = $cas = array(); $abort = false; return; } return $expected; case 'DISCARD': if (!$multi) { return new Response\Error("ERR $cmd without MULTI"); } $watch = $multi = false; return true; case 'ECHO': @list($trigger) = $command->getArguments(); if (strpos($trigger, 'ERR ') === 0) { throw new Response\ServerException($trigger); } if ($trigger === '!!ABORT!!' && $multi) { $abort = true; } return new Response\Status('QUEUED'); case 'UNWATCH': $watch = false; // no break default: return $multi ? new Response\Status('QUEUED') : 'DUMMY_RESPONSE'; } }; } /** * Converts an array of instances of Predis\Command\CommandInterface and * returns an array containing their IDs. * * @param array $commands List of commands instances. * * @return array */ protected static function commandsToIDs($commands) { return array_map(function ($cmd) { return $cmd->getId(); }, $commands); } /** * Returns a client instance connected to the specified Redis * server instance to perform integration tests. * * @param array $parameters Additional connection parameters. * @param array $options Additional client options. * * @return Client */ protected function getClient(array $parameters = array(), array $options = array()) { return $this->createClient($parameters, $options); } }