18
18
namespace MongoDB \Operation ;
19
19
20
20
use MongoDB \ChangeStream ;
21
+ use MongoDB \BSON \TimestampInterface ;
21
22
use MongoDB \Driver \Command ;
23
+ use MongoDB \Driver \Cursor ;
22
24
use MongoDB \Driver \Manager ;
23
25
use MongoDB \Driver \ReadConcern ;
24
26
use MongoDB \Driver \ReadPreference ;
25
27
use MongoDB \Driver \Server ;
26
28
use MongoDB \Driver \Session ;
27
29
use MongoDB \Driver \Exception \RuntimeException ;
30
+ use MongoDB \Driver \Monitoring \CommandFailedEvent ;
31
+ use MongoDB \Driver \Monitoring \CommandSubscriber ;
32
+ use MongoDB \Driver \Monitoring \CommandStartedEvent ;
33
+ use MongoDB \Driver \Monitoring \CommandSucceededEvent ;
28
34
use MongoDB \Exception \InvalidArgumentException ;
29
35
use MongoDB \Exception \UnexpectedValueException ;
30
36
use MongoDB \Exception \UnsupportedException ;
31
37
32
38
/**
33
39
* Operation for creating a change stream with the aggregate command.
34
40
*
41
+ * Note: the implementation of CommandSubscriber is an internal implementation
42
+ * detail and should not be considered part of the public API.
43
+ *
35
44
* @api
36
45
* @see \MongoDB\Collection::watch()
37
46
* @see https://docs.mongodb.com/manual/changeStreams/
38
47
*/
39
- class Watch implements Executable
48
+ class Watch implements Executable, /* @internal */ CommandSubscriber
40
49
{
50
+ private static $ wireVersionForOperationTime = 7 ;
51
+
41
52
const FULL_DOCUMENT_DEFAULT = 'default ' ;
42
53
const FULL_DOCUMENT_UPDATE_LOOKUP = 'updateLookup ' ;
43
54
44
55
private $ aggregate ;
45
- private $ databaseName ;
56
+ private $ aggregateOptions ;
57
+ private $ changeStreamOptions ;
46
58
private $ collectionName ;
59
+ private $ databaseName ;
60
+ private $ operationTime ;
47
61
private $ pipeline ;
48
- private $ options ;
49
62
private $ resumeCallable ;
50
63
51
64
/**
@@ -79,22 +92,44 @@ class Watch implements Executable
79
92
* * resumeAfter (document): Specifies the logical starting point for the
80
93
* new change stream.
81
94
*
95
+ * Using this option in conjunction with "startAtOperationTime" will
96
+ * result in a server error. The options are mutually exclusive.
97
+ *
82
98
* * session (MongoDB\Driver\Session): Client session.
83
99
*
84
100
* Sessions are not supported for server versions < 3.6.
85
101
*
102
+ * * startAtOperationTime (MongoDB\BSON\TimestampInterface): If specified,
103
+ * the change stream will only provide changes that occurred at or after
104
+ * the specified timestamp. Any command run against the server will
105
+ * return an operation time that can be used here. Alternatively, an
106
+ * operation time may be obtained from MongoDB\Driver\Server::getInfo().
107
+ *
108
+ * Using this option in conjunction with "resumeAfter" will result in a
109
+ * server error. The options are mutually exclusive.
110
+ *
111
+ * This option is not supported for server versions < 4.0.
112
+ *
86
113
* * typeMap (array): Type map for BSON deserialization. This will be
87
114
* applied to the returned Cursor (it is not sent to the server).
88
115
*
89
- * @param string $databaseName Database name
90
- * @param string $collectionName Collection name
116
+ * Note: A database-level change stream may be created by specifying null
117
+ * for the collection name. A cluster-level change stream may be created by
118
+ * specifying null for both the database and collection name.
119
+ *
120
+ * @param Manager $manager Manager instance from the driver
121
+ * @param string|null $databaseName Database name
122
+ * @param string|null $collectionName Collection name
91
123
* @param array $pipeline List of pipeline operations
92
124
* @param array $options Command options
93
- * @param Manager $manager Manager instance from the driver
94
125
* @throws InvalidArgumentException for parameter/option parsing errors
95
126
*/
96
127
public function __construct (Manager $ manager , $ databaseName , $ collectionName , array $ pipeline , array $ options = [])
97
128
{
129
+ if (isset ($ collectionName ) && ! isset ($ databaseName )) {
130
+ throw new InvalidArgumentException ('$collectionName should also be null if $databaseName is null ' );
131
+ }
132
+
98
133
$ options += [
99
134
'fullDocument ' => self ::FULL_DOCUMENT_DEFAULT ,
100
135
'readPreference ' => new ReadPreference (ReadPreference::RP_PRIMARY ),
@@ -104,10 +139,12 @@ public function __construct(Manager $manager, $databaseName, $collectionName, ar
104
139
throw InvalidArgumentException::invalidType ('"fullDocument" option ' , $ options ['fullDocument ' ], 'string ' );
105
140
}
106
141
107
- if (isset ($ options ['resumeAfter ' ])) {
108
- if ( ! is_array ($ options ['resumeAfter ' ]) && ! is_object ($ options ['resumeAfter ' ])) {
109
- throw InvalidArgumentException::invalidType ('"resumeAfter" option ' , $ options ['resumeAfter ' ], 'array or object ' );
110
- }
142
+ if (isset ($ options ['resumeAfter ' ]) && ! is_array ($ options ['resumeAfter ' ]) && ! is_object ($ options ['resumeAfter ' ])) {
143
+ throw InvalidArgumentException::invalidType ('"resumeAfter" option ' , $ options ['resumeAfter ' ], 'array or object ' );
144
+ }
145
+
146
+ if (isset ($ options ['startAtOperationTime ' ]) && ! $ options ['startAtOperationTime ' ] instanceof TimestampInterface) {
147
+ throw InvalidArgumentException::invalidType ('"startAtOperationTime" option ' , $ options ['startAtOperationTime ' ], TimestampInterface::class);
111
148
}
112
149
113
150
/* In the absence of an explicit session, create one to ensure that the
@@ -121,15 +158,47 @@ public function __construct(Manager $manager, $databaseName, $collectionName, ar
121
158
} catch (RuntimeException $ e ) {}
122
159
}
123
160
161
+ $ this ->aggregateOptions = array_intersect_key ($ options , ['batchSize ' => 1 , 'collation ' => 1 , 'maxAwaitTimeMS ' => 1 , 'readConcern ' => 1 , 'readPreference ' => 1 , 'session ' => 1 , 'typeMap ' => 1 ]);
162
+ $ this ->changeStreamOptions = array_intersect_key ($ options , ['fullDocument ' => 1 , 'resumeAfter ' => 1 , 'startAtOperationTime ' => 1 ]);
163
+
164
+ // Null database name implies a cluster-wide change stream
165
+ if ($ databaseName === null ) {
166
+ $ databaseName = 'admin ' ;
167
+ $ this ->changeStreamOptions ['allChangesForCluster ' ] = true ;
168
+ }
169
+
124
170
$ this ->databaseName = (string ) $ databaseName ;
125
- $ this ->collectionName = ( string ) $ collectionName ;
171
+ $ this ->collectionName = isset ( $ collectionName ) ? ( string ) $ collectionName : null ;
126
172
$ this ->pipeline = $ pipeline ;
127
- $ this ->options = $ options ;
128
173
129
174
$ this ->aggregate = $ this ->createAggregate ();
130
175
$ this ->resumeCallable = $ this ->createResumeCallable ($ manager );
131
176
}
132
177
178
+ /** @internal */
179
+ final public function commandFailed (CommandFailedEvent $ event )
180
+ {
181
+ }
182
+
183
+ /** @internal */
184
+ final public function commandStarted (CommandStartedEvent $ event )
185
+ {
186
+ }
187
+
188
+ /** @internal */
189
+ final public function commandSucceeded (CommandSucceededEvent $ event )
190
+ {
191
+ if ($ event ->getCommandName () !== 'aggregate ' ) {
192
+ return ;
193
+ }
194
+
195
+ $ reply = $ event ->getReply ();
196
+
197
+ if (isset ($ reply ->operationTime ) && $ reply ->operationTime instanceof TimestampInterface) {
198
+ $ this ->operationTime = $ reply ->operationTime ;
199
+ }
200
+ }
201
+
133
202
/**
134
203
* Execute the operation.
135
204
*
@@ -141,47 +210,74 @@ public function __construct(Manager $manager, $databaseName, $collectionName, ar
141
210
*/
142
211
public function execute (Server $ server )
143
212
{
144
- $ cursor = $ this ->aggregate ->execute ($ server );
145
-
146
- return new ChangeStream ($ cursor , $ this ->resumeCallable );
213
+ return new ChangeStream ($ this ->executeAggregate ($ server ), $ this ->resumeCallable );
147
214
}
148
215
149
216
/**
150
217
* Create the aggregate command for creating a change stream.
151
218
*
152
- * This method is also used to recreate the aggregate command if a new
153
- * resume token is provided while resuming.
219
+ * This method is also used to recreate the aggregate command when resuming.
154
220
*
155
221
* @return Aggregate
156
222
*/
157
223
private function createAggregate ()
158
224
{
159
- $ changeStreamOptions = array_intersect_key ($ this ->options , ['fullDocument ' => 1 , 'resumeAfter ' => 1 ]);
160
- $ changeStream = ['$changeStream ' => (object ) $ changeStreamOptions ];
161
-
162
225
$ pipeline = $ this ->pipeline ;
163
- array_unshift ($ pipeline , $ changeStream );
226
+ array_unshift ($ pipeline , [ ' $changeStream ' => ( object ) $ this -> changeStreamOptions ] );
164
227
165
- $ aggregateOptions = array_intersect_key ($ this ->options , ['batchSize ' => 1 , 'collation ' => 1 , 'maxAwaitTimeMS ' => 1 , 'readConcern ' => 1 , 'readPreference ' => 1 , 'session ' => 1 , 'typeMap ' => 1 ]);
166
-
167
- return new Aggregate ($ this ->databaseName , $ this ->collectionName , $ pipeline , $ aggregateOptions );
228
+ return new Aggregate ($ this ->databaseName , $ this ->collectionName , $ pipeline , $ this ->aggregateOptions );
168
229
}
169
230
170
231
private function createResumeCallable (Manager $ manager )
171
232
{
172
233
return function ($ resumeToken = null ) use ($ manager ) {
173
- /* If a resume token was provided, recreate the Aggregate operation
174
- * using the new resume token . */
234
+ /* If a resume token was provided, update the "resumeAfter" option
235
+ * and ensure that "startAtOperationTime" is no longer set . */
175
236
if ($ resumeToken !== null ) {
176
- $ this ->options ['resumeAfter ' ] = $ resumeToken ;
177
- $ this ->aggregate = $ this ->createAggregate ();
237
+ $ this ->changeStreamOptions ['resumeAfter ' ] = $ resumeToken ;
238
+ unset($ this ->changeStreamOptions ['startAtOperationTime ' ]);
239
+ }
240
+
241
+ /* If we captured an operation time from the first aggregate command
242
+ * and there is no "resumeAfter" option, set "startAtOperationTime"
243
+ * so that we can resume from the original aggregate's time. */
244
+ if ($ this ->operationTime !== null && ! isset ($ this ->changeStreamOptions ['resumeAfter ' ])) {
245
+ $ this ->changeStreamOptions ['startAtOperationTime ' ] = $ this ->operationTime ;
178
246
}
179
247
248
+ $ this ->aggregate = $ this ->createAggregate ();
249
+
180
250
/* Select a new server using the read preference, execute this
181
251
* operation on it, and return the new ChangeStream. */
182
- $ server = $ manager ->selectServer ($ this ->options ['readPreference ' ]);
252
+ $ server = $ manager ->selectServer ($ this ->aggregateOptions ['readPreference ' ]);
183
253
184
254
return $ this ->execute ($ server );
185
255
};
186
256
}
257
+
258
+ /**
259
+ * Execute the aggregate command and optionally capture its operation time.
260
+ *
261
+ * @param Server $server
262
+ * @return Cursor
263
+ */
264
+ private function executeAggregate (Server $ server )
265
+ {
266
+ /* If we've already captured an operation time or the server does not
267
+ * support returning an operation time (e.g. MongoDB 3.6), execute the
268
+ * aggregation directly and return its cursor. */
269
+ if ($ this ->operationTime !== null || ! \MongoDB \server_supports_feature ($ server , self ::$ wireVersionForOperationTime )) {
270
+ return $ this ->aggregate ->execute ($ server );
271
+ }
272
+
273
+ /* Otherwise, execute the aggregation using command monitoring so that
274
+ * we can capture its operation time with commandSucceeded(). */
275
+ \MongoDB \Driver \Monitoring \addSubscriber ($ this );
276
+
277
+ try {
278
+ return $ this ->aggregate ->execute ($ server );
279
+ } finally {
280
+ \MongoDB \Driver \Monitoring \removeSubscriber ($ this );
281
+ }
282
+ }
187
283
}
0 commit comments