Commit 84c365c0 authored by Jeremy Mikola's avatar Jeremy Mikola

PHPLIB-451: ChangeStream::rewind() should never execute getMore

Tests were revised to no longer expect rewind() to encounter an error and resume. In most tests, rewind() is a NOP because the change stream has no events to return upon creation.

Since Watch now always executes aggregate commands with APM, the check for startAtOperationTime support was moved to createResumeCallable(). We continue to only capture operationTime from the very first aggregate command.

Various assertions for current() returning null were also changed to instead check valid(). Both forms are equivalent, but checking valid() is more consistent with our iteration examples.
parent b6c7f117
......@@ -24,7 +24,7 @@ use MongoDB\Driver\Exception\RuntimeException;
use MongoDB\Driver\Exception\ServerException;
use MongoDB\Exception\InvalidArgumentException;
use MongoDB\Exception\ResumeTokenException;
use IteratorIterator;
use MongoDB\Model\TailableCursorIterator;
use Iterator;
/**
......@@ -61,13 +61,14 @@ class ChangeStream implements Iterator
* Constructor.
*
* @internal
* @param Cursor $cursor
* @param Cursor $cursor
* @param callable $resumeCallable
* @param boolean $isFirstBatchEmpty
*/
public function __construct(Cursor $cursor, callable $resumeCallable)
public function __construct(Cursor $cursor, callable $resumeCallable, $isFirstBatchEmpty)
{
$this->resumeCallable = $resumeCallable;
$this->csIt = new IteratorIterator($cursor);
$this->csIt = new TailableCursorIterator($cursor, $isFirstBatchEmpty);
}
/**
......@@ -242,17 +243,11 @@ class ChangeStream implements Iterator
*/
private function resume()
{
$newChangeStream = call_user_func($this->resumeCallable, $this->resumeToken);
$this->csIt = $newChangeStream->csIt;
list($cursor, $isFirstBatchEmpty) = call_user_func($this->resumeCallable, $this->resumeToken);
$this->csIt = new TailableCursorIterator($cursor, $isFirstBatchEmpty);
$this->csIt->rewind();
/* Note: if we are resuming after a call to ChangeStream::rewind(),
* $hasAdvanced will always be false. For it to be true, rewind() would
* need to have thrown a RuntimeException with a resumable error, which
* can only happen during the first call to IteratorIterator::rewind()
* before onIteration() has a chance to set $hasAdvanced to true.
* Otherwise, IteratorIterator::rewind() would either NOP (consecutive
* rewinds) or throw a LogicException (rewind after next), neither of
* which would result in a call to resume(). */
$this->onIteration($this->hasAdvanced);
}
......
......@@ -38,12 +38,12 @@ class TailableCursorIterator extends IteratorIterator
*
* @internal
* @param Cursor $cursor
* @param boolean $isFirstBatchIsEmpty
* @param boolean $isFirstBatchEmpty
*/
public function __construct(Cursor $cursor, $isFirstBatchIsEmpty)
public function __construct(Cursor $cursor, $isFirstBatchEmpty)
{
parent::__construct($cursor);
$this->isRewindNop = $isFirstBatchIsEmpty;
$this->isRewindNop = $isFirstBatchEmpty;
}
/**
......
......@@ -57,6 +57,7 @@ class Watch implements Executable, /* @internal */ CommandSubscriber
private $changeStreamOptions;
private $collectionName;
private $databaseName;
private $isFirstBatchEmpty = false;
private $operationTime;
private $pipeline;
private $resumeCallable;
......@@ -200,6 +201,11 @@ class Watch implements Executable, /* @internal */ CommandSubscriber
/** @internal */
final public function commandStarted(CommandStartedEvent $event)
{
if ($event->getCommandName() !== 'aggregate') {
return;
}
$this->isFirstBatchEmpty = false;
}
/** @internal */
......@@ -211,9 +217,15 @@ class Watch implements Executable, /* @internal */ CommandSubscriber
$reply = $event->getReply();
if (isset($reply->operationTime) && $reply->operationTime instanceof TimestampInterface) {
/* Note: the spec only refers to collecting an operation time from the
* "original aggregation", so only capture it if we've not already. */
if (!isset($this->operationTime) && isset($reply->operationTime) && $reply->operationTime instanceof TimestampInterface) {
$this->operationTime = $reply->operationTime;
}
if (isset($reply->cursor->firstBatch) && is_array($reply->cursor->firstBatch)) {
$this->isFirstBatchEmpty = empty($reply->cursor->firstBatch);
}
}
/**
......@@ -227,7 +239,9 @@ class Watch implements Executable, /* @internal */ CommandSubscriber
*/
public function execute(Server $server)
{
return new ChangeStream($this->executeAggregate($server), $this->resumeCallable);
$cursor = $this->executeAggregate($server);
return new ChangeStream($cursor, $this->resumeCallable, $this->isFirstBatchEmpty);
}
/**
......@@ -255,40 +269,36 @@ class Watch implements Executable, /* @internal */ CommandSubscriber
unset($this->changeStreamOptions['startAtOperationTime']);
}
// Select a new server using the original read preference
$server = $manager->selectServer($this->aggregateOptions['readPreference']);
/* If we captured an operation time from the first aggregate command
* and there is no "resumeAfter" option, set "startAtOperationTime"
* so that we can resume from the original aggregate's time. */
if ($this->operationTime !== null && ! isset($this->changeStreamOptions['resumeAfter'])) {
if ($this->operationTime !== null && ! isset($this->changeStreamOptions['resumeAfter']) &&
\MongoDB\server_supports_feature($server, self::$wireVersionForStartAtOperationTime)) {
$this->changeStreamOptions['startAtOperationTime'] = $this->operationTime;
}
// Recreate the aggregate command and execute to obtain a new cursor
$this->aggregate = $this->createAggregate();
$cursor = $this->executeAggregate($server);
/* Select a new server using the read preference, execute this
* operation on it, and return the new ChangeStream. */
$server = $manager->selectServer($this->aggregateOptions['readPreference']);
return $this->execute($server);
return [$cursor, $this->isFirstBatchEmpty];
};
}
/**
* Execute the aggregate command and optionally capture its operation time.
* Execute the aggregate command.
*
* The command will be executed using APM so that we can capture its
* operation time and/or firstBatch size.
*
* @param Server $server
* @return Cursor
*/
private function executeAggregate(Server $server)
{
/* If we've already captured an operation time or the server does not
* support resuming from an operation time (e.g. MongoDB 3.6), execute
* the aggregation directly and return its cursor. */
if ($this->operationTime !== null || ! \MongoDB\server_supports_feature($server, self::$wireVersionForStartAtOperationTime)) {
return $this->aggregate->execute($server);
}
/* Otherwise, execute the aggregation using command monitoring so that
* we can capture its operation time with commandSucceeded(). */
\MongoDB\Driver\Monitoring\addSubscriber($this);
try {
......
......@@ -8,7 +8,6 @@ use MongoDB\Driver\Manager;
use MongoDB\Driver\ReadPreference;
use MongoDB\Driver\Server;
use MongoDB\Driver\WriteConcern;
use MongoDB\Driver\Exception\ConnectionTimeoutException;
use MongoDB\Driver\Exception\LogicException;
use MongoDB\Exception\ResumeTokenException;
use MongoDB\Operation\CreateCollection;
......@@ -40,7 +39,7 @@ class WatchFunctionalTest extends FunctionalTestCase
$changeStream = $operation->execute($this->getPrimaryServer());
$changeStream->rewind();
$this->assertNull($changeStream->current());
$this->assertFalse($changeStream->valid());
$this->insertDocument(['_id' => 2, 'x' => 'bar']);
......@@ -85,30 +84,25 @@ class WatchFunctionalTest extends FunctionalTestCase
$operation = new Watch($manager, $this->getDatabaseName(), $this->getCollectionName(), [], $this->defaultOptions);
$changeStream = $operation->execute($primaryServer);
/* Note: we intentionally do not start iteration with rewind() to ensure
* that we test resume functionality within next(). */
$changeStream->rewind();
$commands = [];
try {
(new CommandObserver)->observe(
function() use ($changeStream) {
$changeStream->next();
},
function(array $event) use (&$commands) {
$commands[] = $event['started']->getCommandName();
}
);
$this->fail('ConnectionTimeoutException was not thrown');
} catch (ConnectionTimeoutException $e) {}
(new CommandObserver)->observe(
function() use ($changeStream) {
$changeStream->next();
},
function(array $event) use (&$commands) {
$commands[] = $event['started']->getCommandName();
}
);
$expectedCommands = [
/* The initial aggregate command for change streams returns a cursor
* envelope with an empty initial batch, since there are no changes
* to report at the moment the change stream is created. Therefore,
* we expect a getMore to be issued when we first advance the change
* stream (with either rewind() or next()). */
* stream with next(). */
'getMore',
/* Since socketTimeoutMS is less than maxAwaitTimeMS, the previous
* getMore command encounters a client socket timeout and leaves the
......@@ -120,9 +114,6 @@ class WatchFunctionalTest extends FunctionalTestCase
* removes the last reference to the old cursor, which causes the
* driver to kill it (via mongoc_cursor_destroy()). */
'killCursors',
/* Finally, ChangeStream will rewind the new cursor as the last step
* of the resume process. This results in one last getMore. */
'getMore',
];
$this->assertSame($expectedCommands, $commands);
......@@ -153,38 +144,10 @@ class WatchFunctionalTest extends FunctionalTestCase
$operationTime = $reply->operationTime;
$this->assertInstanceOf(TimestampInterface::class, $operationTime);
$this->assertNull($changeStream->current());
$this->assertFalse($changeStream->valid());
$this->killChangeStreamCursor($changeStream);
$events = [];
(new CommandObserver)->observe(
function() use ($changeStream) {
$changeStream->rewind();
},
function (array $event) use (&$events) {
$events[] = $event;
}
);
$this->assertCount(4, $events);
$this->assertSame('getMore', $events[0]['started']->getCommandName());
$this->arrayHasKey('failed', $events[0]);
$this->assertSame('aggregate', $events[1]['started']->getCommandName());
$this->assertStartAtOperationTime($operationTime, $events[1]['started']->getCommand());
$this->arrayHasKey('succeeded', $events[1]);
// Original cursor is freed immediately after the change stream resumes
$this->assertSame('killCursors', $events[2]['started']->getCommandName());
$this->arrayHasKey('succeeded', $events[2]);
$this->assertSame('getMore', $events[3]['started']->getCommandName());
$this->arrayHasKey('succeeded', $events[3]);
$this->assertNull($changeStream->current());
$this->killChangeStreamCursor($changeStream);
$this->assertNoCommandExecuted(function() use ($changeStream) { $changeStream->rewind(); });
$events = [];
......@@ -197,7 +160,7 @@ class WatchFunctionalTest extends FunctionalTestCase
}
);
$this->assertCount(4, $events);
$this->assertCount(3, $events);
$this->assertSame('getMore', $events[0]['started']->getCommandName());
$this->arrayHasKey('failed', $events[0]);
......@@ -210,10 +173,7 @@ class WatchFunctionalTest extends FunctionalTestCase
$this->assertSame('killCursors', $events[2]['started']->getCommandName());
$this->arrayHasKey('succeeded', $events[2]);
$this->assertSame('getMore', $events[3]['started']->getCommandName());
$this->arrayHasKey('succeeded', $events[3]);
$this->assertNull($changeStream->current());
$this->assertFalse($changeStream->valid());
}
private function assertStartAtOperationTime(TimestampInterface $expectedOperationTime, stdClass $command)
......@@ -234,19 +194,30 @@ class WatchFunctionalTest extends FunctionalTestCase
$this->insertDocument(['x' => 1]);
$this->insertDocument(['x' => 2]);
$changeStream->rewind();
$this->assertNoCommandExecuted(function() use ($changeStream) { $changeStream->rewind(); });
$this->assertFalse($changeStream->valid());
$this->assertNull($changeStream->key());
$this->assertNull($changeStream->current());
// Subsequent rewind does not change iterator state
$this->assertNoCommandExecuted(function() use ($changeStream) { $changeStream->rewind(); });
$this->assertFalse($changeStream->valid());
$this->assertNull($changeStream->key());
$this->assertNull($changeStream->current());
$changeStream->next();
$this->assertTrue($changeStream->valid());
$this->assertSame(0, $changeStream->key());
$this->assertNotNull($changeStream->current());
// Subsequent rewind does not change iterator state
$changeStream->rewind();
/* Rewinding when the iterator is still at its first element is a NOP.
* Note: PHPLIB-448 may see rewind() throw after any call to next() */
$this->assertNoCommandExecuted(function() use ($changeStream) { $changeStream->rewind(); });
$this->assertTrue($changeStream->valid());
$this->assertSame(0, $changeStream->key());
$this->assertNotNull($changeStream->current());
$changeStream->next();
$this->assertTrue($changeStream->valid());
$this->assertSame(1, $changeStream->key());
$this->assertNotNull($changeStream->current());
......@@ -261,13 +232,13 @@ class WatchFunctionalTest extends FunctionalTestCase
$operation = new Watch($this->manager, $this->getDatabaseName(), $this->getCollectionName(), [], $this->defaultOptions);
$changeStream = $operation->execute($this->getPrimaryServer());
$changeStream->rewind();
$this->assertNoCommandExecuted(function() use ($changeStream) { $changeStream->rewind(); });
$this->assertFalse($changeStream->valid());
$this->assertNull($changeStream->key());
$this->assertNull($changeStream->current());
// Subsequent rewind does not change iterator state
$changeStream->rewind();
$this->assertNoCommandExecuted(function() use ($changeStream) { $changeStream->rewind(); });
$this->assertFalse($changeStream->valid());
$this->assertNull($changeStream->key());
$this->assertNull($changeStream->current());
......@@ -277,59 +248,12 @@ class WatchFunctionalTest extends FunctionalTestCase
$this->assertNull($changeStream->key());
$this->assertNull($changeStream->current());
// Rewinding after advancing the iterator is an error
$this->expectException(LogicException::class);
$changeStream->rewind();
}
public function testRewindResumesAfterConnectionException()
{
/* In order to trigger a dropped connection, we'll use a new client with
* a socket timeout that is less than the change stream's maxAwaitTimeMS
* option. */
$manager = new Manager(static::getUri(), ['socketTimeoutMS' => 50]);
$primaryServer = $manager->selectServer(new ReadPreference(ReadPreference::RP_PRIMARY));
$operation = new Watch($manager, $this->getDatabaseName(), $this->getCollectionName(), [], $this->defaultOptions);
$changeStream = $operation->execute($primaryServer);
$commands = [];
try {
(new CommandObserver)->observe(
function() use ($changeStream) {
$changeStream->rewind();
},
function(array $event) use (&$commands) {
$commands[] = $event['started']->getCommandName();
}
);
$this->fail('ConnectionTimeoutException was not thrown');
} catch (ConnectionTimeoutException $e) {}
$expectedCommands = [
/* The initial aggregate command for change streams returns a cursor
* envelope with an empty initial batch, since there are no changes
* to report at the moment the change stream is created. Therefore,
* we expect a getMore to be issued when we first advance the change
* stream (with either rewind() or next()). */
'getMore',
/* Since socketTimeoutMS is less than maxAwaitTimeMS, the previous
* getMore command encounters a client socket timeout and leaves the
* cursor open on the server. ChangeStream should catch this error
* and resume by issuing a new aggregate command. */
'aggregate',
/* When ChangeStream resumes, it overwrites its original cursor with
* the new cursor resulting from the last aggregate command. This
* removes the last reference to the old cursor, which causes the
* driver to kill it (via mongoc_cursor_destroy()). */
'killCursors',
/* Finally, ChangeStream will rewind the new cursor as the last step
* of the resume process. This results in one last getMore. */
'getMore',
];
$this->assertSame($expectedCommands, $commands);
/* Rewinding when the iterator hasn't advanced to an element is a NOP.
* Note: PHPLIB-448 may see rewind() throw after any call to next() */
$this->assertNoCommandExecuted(function() use ($changeStream) { $changeStream->rewind(); });
$this->assertFalse($changeStream->valid());
$this->assertNull($changeStream->key());
$this->assertNull($changeStream->current());
}
public function testNoChangeAfterResumeBeforeInsert()
......@@ -339,8 +263,8 @@ class WatchFunctionalTest extends FunctionalTestCase
$operation = new Watch($this->manager, $this->getDatabaseName(), $this->getCollectionName(), [], $this->defaultOptions);
$changeStream = $operation->execute($this->getPrimaryServer());
$changeStream->rewind();
$this->assertNull($changeStream->current());
$this->assertNoCommandExecuted(function() use ($changeStream) { $changeStream->rewind(); });
$this->assertFalse($changeStream->valid());
$this->insertDocument(['_id' => 2, 'x' => 'bar']);
......@@ -361,7 +285,6 @@ class WatchFunctionalTest extends FunctionalTestCase
$changeStream->next();
$this->assertFalse($changeStream->valid());
$this->assertNull($changeStream->current());
$this->insertDocument(['_id' => 3, 'x' => 'baz']);
......@@ -388,50 +311,53 @@ class WatchFunctionalTest extends FunctionalTestCase
$changeStream = $operation->execute($this->getPrimaryServer());
/* Killing the cursor when there are no results will test that neither
* the initial rewind() nor its resume attempt incremented the key. */
* the initial rewind() nor a resume attempt via next() increment the
* key. */
$this->killChangeStreamCursor($changeStream);
$changeStream->rewind();
$this->assertNoCommandExecuted(function() use ($changeStream) { $changeStream->rewind(); });
$this->assertFalse($changeStream->valid());
$this->assertNull($changeStream->key());
$this->assertNull($changeStream->current());
$this->insertDocument(['_id' => 1]);
$changeStream->next();
$this->assertFalse($changeStream->valid());
$this->assertNull($changeStream->key());
$this->assertNull($changeStream->current());
// A consecutive resume attempt should still not increment the key
$this->killChangeStreamCursor($changeStream);
$changeStream->next();
$this->assertFalse($changeStream->valid());
$this->assertNull($changeStream->key());
$this->assertNull($changeStream->current());
/* Insert a document and advance the change stream to ensure we capture
* a resume token. This is necessary when startAtOperationTime is not
* supported (i.e. 3.6 server version). */
$changeStream->next();
$this->assertTrue($changeStream->valid());
$this->assertSame(0, $changeStream->key());
$this->insertDocument(['_id' => 2]);
/* Killing the cursor and advancing when there is a result will test
* that next()'s resume attempt picks up the latest change. */
$this->killChangeStreamCursor($changeStream);
$this->insertDocument(['_id' => 1]);
$changeStream->next();
$this->assertTrue($changeStream->valid());
$this->assertSame(1, $changeStream->key());
$this->assertSame(0, $changeStream->key());
$expectedResult = [
'_id' => $changeStream->current()->_id,
'operationType' => 'insert',
'fullDocument' => ['_id' => 2],
'fullDocument' => ['_id' => 1],
'ns' => ['db' => $this->getDatabaseName(), 'coll' => $this->getCollectionName()],
'documentKey' => ['_id' => 2],
'documentKey' => ['_id' => 1],
];
$this->assertMatchesDocument($expectedResult, $changeStream->current());
/* Killing the cursor a second time will not trigger a resume until
* ChangeStream::next() is called. A successive call to rewind() should
* not change the iterator's state and preserve the current result.
* Note: PHPLIB-448 may require this rewind() to throw an exception. */
/* Insert another document and kill the cursor. ChangeStream::next()
* should resume and pick up the last insert. */
$this->insertDocument(['_id' => 2]);
$this->killChangeStreamCursor($changeStream);
$changeStream->rewind();
$changeStream->next();
$this->assertTrue($changeStream->valid());
$this->assertSame(1, $changeStream->key());
......@@ -445,8 +371,22 @@ class WatchFunctionalTest extends FunctionalTestCase
$this->assertMatchesDocument($expectedResult, $changeStream->current());
/* Insert another document and kill the cursor. It is technically
* permissable to call ChangeStream::rewind() since the previous call to
* next() will have left the cursor positioned at its first and only
* result. Assert that rewind() does not execute a getMore nor does it
* modify the iterator's state.
*
* Note: PHPLIB-448 may require rewind() to throw an exception here. */
$this->insertDocument(['_id' => 3]);
$this->killChangeStreamCursor($changeStream);
$this->assertNoCommandExecuted(function() use ($changeStream) { $changeStream->rewind(); });
$this->assertTrue($changeStream->valid());
$this->assertSame(1, $changeStream->key());
$this->assertMatchesDocument($expectedResult, $changeStream->current());
// ChangeStream::next() should resume and pick up the last insert
$changeStream->next();
$this->assertTrue($changeStream->valid());
$this->assertSame(2, $changeStream->key());
......@@ -461,9 +401,9 @@ class WatchFunctionalTest extends FunctionalTestCase
$this->assertMatchesDocument($expectedResult, $changeStream->current());
$this->killChangeStreamCursor($changeStream);
// Test one final, consecutive resume via ChangeStream::next()
$this->insertDocument(['_id' => 4]);
$this->killChangeStreamCursor($changeStream);
$changeStream->next();
$this->assertTrue($changeStream->valid());
......@@ -478,28 +418,6 @@ class WatchFunctionalTest extends FunctionalTestCase
];
$this->assertMatchesDocument($expectedResult, $changeStream->current());
/* Triggering a consecutive failure will allow us to test whether the
* resume token was properly updated after the last resume. If the
* resume token updated, the next result will be {_id: 4}; otherwise,
* we'll see {_id: 3} returned again. */
$this->killChangeStreamCursor($changeStream);
$this->insertDocument(['_id' => 5]);
$changeStream->next();
$this->assertTrue($changeStream->valid());
$this->assertSame(4, $changeStream->key());
$expectedResult = [
'_id' => $changeStream->current()->_id,
'operationType' => 'insert',
'fullDocument' => ['_id' => 5],
'ns' => ['db' => $this->getDatabaseName(), 'coll' => $this->getCollectionName()],
'documentKey' => ['_id' => 5],
];
$this->assertMatchesDocument($expectedResult, $changeStream->current());
}
public function testKey()
......@@ -510,9 +428,13 @@ class WatchFunctionalTest extends FunctionalTestCase
$this->assertFalse($changeStream->valid());
$this->assertNull($changeStream->key());
$this->assertNoCommandExecuted(function() use ($changeStream) { $changeStream->rewind(); });
$this->assertFalse($changeStream->valid());
$this->assertNull($changeStream->key());
$this->insertDocument(['_id' => 1, 'x' => 'foo']);
$changeStream->rewind();
$changeStream->next();
$this->assertTrue($changeStream->valid());
$this->assertSame(0, $changeStream->key());
......@@ -547,6 +469,9 @@ class WatchFunctionalTest extends FunctionalTestCase
$this->insertDocument(['_id' => 1]);
$changeStream->rewind();
$this->assertFalse($changeStream->valid());
$changeStream->next();
$this->assertTrue($changeStream->valid());
$expectedResult = [
......@@ -604,24 +529,6 @@ class WatchFunctionalTest extends FunctionalTestCase
$changeStream->next();
}
public function testRewindResumeTokenNotFound()
{
if (version_compare($this->getServerVersion(), '4.1.8', '>=')) {
$this->markTestSkipped('Server rejects change streams that modify resume token (SERVER-37786)');
}
$pipeline = [['$project' => ['_id' => 0 ]]];
$operation = new Watch($this->manager, $this->getDatabaseName(), $this->getCollectionName(), $pipeline, $this->defaultOptions);
$changeStream = $operation->execute($this->getPrimaryServer());
$this->insertDocument(['x' => 1]);
$this->expectException(ResumeTokenException::class);
$this->expectExceptionMessage('Resume token not found in change document');
$changeStream->rewind();
}
public function testNextResumeTokenInvalidType()
{
if (version_compare($this->getServerVersion(), '4.1.8', '>=')) {
......@@ -642,24 +549,6 @@ class WatchFunctionalTest extends FunctionalTestCase
$changeStream->next();
}
public function testRewindResumeTokenInvalidType()
{
if (version_compare($this->getServerVersion(), '4.1.8', '>=')) {
$this->markTestSkipped('Server rejects change streams that modify resume token (SERVER-37786)');
}
$pipeline = [['$project' => ['_id' => ['$literal' => 'foo']]]];
$operation = new Watch($this->manager, $this->getDatabaseName(), $this->getCollectionName(), $pipeline, $this->defaultOptions);
$changeStream = $operation->execute($this->getPrimaryServer());
$this->insertDocument(['x' => 1]);
$this->expectException(ResumeTokenException::class);
$this->expectExceptionMessage('Expected resume token to have type "array or object" but found "string"');
$changeStream->rewind();
}
public function testMaxAwaitTimeMS()
{
/* On average, an acknowledged write takes about 20 ms to appear in a
......@@ -680,21 +569,18 @@ class WatchFunctionalTest extends FunctionalTestCase
$operation = new Watch($this->manager, $this->getDatabaseName(), $this->getCollectionName(), [], ['maxAwaitTimeMS' => $maxAwaitTimeMS]);
$changeStream = $operation->execute($this->getPrimaryServer());
/* The initial change stream is empty so we should expect a delay when
* we call rewind, since it issues a getMore. Expect to wait at least
* maxAwaitTimeMS, since no new documents should be inserted to wake up
* the server's query thread. Also ensure we don't wait too long (server
* default is one second). */
// Rewinding does not issue a getMore, so we should not expect a delay.
$startTime = microtime(true);
$changeStream->rewind();
$duration = microtime(true) - $startTime;
$this->assertGreaterThan($pivot, $duration);
$this->assertLessThan($upperBound, $duration);
$this->assertLessThan($pivot, $duration);
$this->assertFalse($changeStream->valid());
/* Advancing again on a change stream will issue a getMore, so we should
* expect a delay again. */
* expect a delay. Expect to wait at least maxAwaitTimeMS, since no new
* documents will be inserted to wake up the server's query thread. Also
* ensure we don't wait too long (server default is one second). */
$startTime = microtime(true);
$changeStream->next();
$duration = microtime(true) - $startTime;
......@@ -703,10 +589,10 @@ class WatchFunctionalTest extends FunctionalTestCase
$this->assertFalse($changeStream->valid());
/* After inserting a document, the change stream will not issue a
* getMore so we should not expect a delay. */
$this->insertDocument(['_id' => 1]);
/* Advancing the change stream again will issue a getMore, but the
* server should not block since a document has been inserted. */
$startTime = microtime(true);
$changeStream->next();
$duration = microtime(true) - $startTime;
......@@ -714,34 +600,38 @@ class WatchFunctionalTest extends FunctionalTestCase
$this->assertTrue($changeStream->valid());
}
public function testRewindResumesAfterCursorNotFound()
public function testRewindExtractsResumeTokenAndNextResumes()
{
$operation = new Watch($this->manager, $this->getDatabaseName(), $this->getCollectionName(), [], $this->defaultOptions);
$changeStream = $operation->execute($this->getPrimaryServer());
$this->killChangeStreamCursor($changeStream);
$this->insertDocument(['_id' => 1, 'x' => 'foo']);
$this->insertDocument(['_id' => 2, 'x' => 'bar']);
$this->insertDocument(['_id' => 3, 'x' => 'baz']);
/* Obtain a resume token for the first insert. This will allow us to
* start a change stream from that point and ensure aggregate returns
* the second insert in its first batch, which in turn will serve as a
* resume token for rewind() to extract. */
$changeStream->rewind();
$this->assertFalse($changeStream->valid());
$this->assertNull($changeStream->current());
}
public function testRewindExtractsResumeTokenAndNextResumes()
{
$operation = new Watch($this->manager, $this->getDatabaseName(), $this->getCollectionName(), [], $this->defaultOptions);
$changeStream = $operation->execute($this->getPrimaryServer());
$changeStream->next();
$this->assertTrue($changeStream->valid());
$this->insertDocument(['_id' => 1, 'x' => 'foo']);
$this->insertDocument(['_id' => 2, 'x' => 'bar']);
$options = ['resumeAfter' => $changeStream->current()->_id] + $this->defaultOptions;
$operation = new Watch($this->manager, $this->getDatabaseName(), $this->getCollectionName(), [], $options);
$changeStream = $operation->execute($this->getPrimaryServer());
$changeStream->rewind();
$this->assertTrue($changeStream->valid());
$this->assertSame(0, $changeStream->key());
$expectedResult = [
'_id' => $changeStream->current()->_id,
'operationType' => 'insert',
'fullDocument' => ['_id' => 1, 'x' => 'foo'],
'fullDocument' => ['_id' => 2, 'x' => 'bar'],
'ns' => ['db' => $this->getDatabaseName(), 'coll' => $this->getCollectionName()],
'documentKey' => ['_id' => 1],
'documentKey' => ['_id' => 2],
];
$this->assertMatchesDocument($expectedResult, $changeStream->current());
......@@ -749,13 +639,14 @@ class WatchFunctionalTest extends FunctionalTestCase
$changeStream->next();
$this->assertTrue($changeStream->valid());
$this->assertSame(1, $changeStream->key());
$expectedResult = [
'_id' => $changeStream->current()->_id,
'operationType' => 'insert',
'fullDocument' => ['_id' => 2, 'x' => 'bar'],
'fullDocument' => ['_id' => 3, 'x' => 'baz'],
'ns' => ['db' => $this->getDatabaseName(), 'coll' => $this->getCollectionName()],
'documentKey' => ['_id' => 2],
'documentKey' => ['_id' => 3],
];
$this->assertMatchesDocument($expectedResult, $changeStream->current());
}
......@@ -841,7 +732,7 @@ class WatchFunctionalTest extends FunctionalTestCase
$changeStream = $operation->execute($this->getPrimaryServer());
$changeStream->rewind();
$this->assertNull($changeStream->current());
$this->assertFalse($changeStream->valid());
$this->insertDocument(['_id' => 1, 'x' => 'foo']);
......@@ -919,12 +810,8 @@ class WatchFunctionalTest extends FunctionalTestCase
$this->insertDocument(['x' => 2]);
$this->insertDocument(['x' => 3]);
try {
$changeStream->rewind();
$this->fail('ResumeTokenException was not thrown');
} catch (ResumeTokenException $e) {}
$this->assertSame(0, $changeStream->key());
$changeStream->rewind();
$this->assertFalse($changeStream->valid());
try {
$changeStream->next();
......@@ -991,9 +878,6 @@ class WatchFunctionalTest extends FunctionalTestCase
* removes the last reference to the old cursor, which causes the
* driver to kill it (via mongoc_cursor_destroy()). */
'killCursors',
/* Finally, ChangeStream will rewind the new cursor as the last step
* of the resume process. This results in one last getMore. */
'getMore',
];
$this->assertSame($expectedCommands, $commands);
......@@ -1025,6 +909,20 @@ class WatchFunctionalTest extends FunctionalTestCase
$this->assertNull($rp->getValue($changeStream));
}
private function assertNoCommandExecuted(callable $callable)
{
$commands = [];
(new CommandObserver)->observe(
$callable,
function(array $event) use (&$commands) {
$this->fail(sprintf('"%s" command was executed', $event['started']->getCommandName()));
}
);
$this->assertEmpty($commands);
}
private function insertDocument($document)
{
$insertOne = new InsertOne(
......
......@@ -54,10 +54,11 @@ class ChangeStreamsProseTest extends FunctionalTestCase
$this->createCollection();
$changeStream = $this->collection->watch();
$changeStream->rewind();
$this->expectException(ServerException::class);
$this->expectExceptionCode($errorCode);
$changeStream->rewind();
$changeStream->next();
}
public function provideNonResumableErrorCodes()
......
......@@ -233,22 +233,33 @@ class ChangeStreamsSpecTest extends FunctionalTestCase
* Iterate a change stream.
*
* @param ChangeStream $changeStream
* @param integer $limit
* @return BSONDocument[]
*/
private function iterateChangeStream(ChangeStream $changeStream, $limit = 0)
{
if ($limit < 0) {
throw new LogicException('$limit is negative');
}
/* Limit iterations to guard against an infinite loop should a test fail
* to return as many results as are expected. Require at least one
* iteration to allow next() a chance to throw for error tests. */
$maxIterations = $limit + 1;
$events = [];
for ($changeStream->rewind(); count($events) < $limit; $changeStream->next()) {
for ($i = 0, $changeStream->rewind(); $i < $maxIterations; $i++, $changeStream->next()) {
if ( ! $changeStream->valid()) {
continue;
}
$event = $changeStream->current();
$this->assertInstanceOf(BSONDocument::class, $event);
$events[] = $event;
if (count($events) >= $limit) {
break;
}
}
return $events;
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment