Skip to content

Commit c02d25c

Browse files
Sebastian Hallum Clarkembroadst
Sebastian Hallum Clarke
authored andcommitted
feat(change-streams): add support for change streams
* Adding collection support * Setting up basic collections.changes() support * Adding pipeline support * Tidying up, adding DB support * Now showing only messages recieved after the current time * Fixing bugs in timestamp and database support * Getting aggregation pipeline support to work * Refactoring to use promises * Removing co dependency * Changing to use Readable stream * Tidying * Rearranging arguments * Error handling and alerts when oplogStream closes * Using ChangeStream * Starting ChangeStream tests * Renaming function * Creeating close() and resumeToken() menthods * Tidying console log * Getting imperative form working * Linting and tidying up * Extending test coverage * Testing closing works properly * Tidying * Adding JSDoc details * Fixing package.json * Increasing modularity and documentation * Re-enabling simulator * Caching resume tokens and erroring if projected out * Enforcing semicolons * Support cursor invalidation and testing resume token caching NODE-1063 * Making resumability work * Testing resumability using callbacks * Adding promise test * Updating to use assert.ifError() * Using crypto to generate simulator noConflict tokens * Testing resumeAfter behaviour * Testing invalidate behaviour * Fixing minor bugs * Workaround for bug with unknown cause * Update to use watch() instead of changes() * Adding function definition * Uppercase supportedStages * JSdoc for .watch() * Remove resumeToken() method * Impove input validation * Change eslint to warn when semicolon not used * Improve Change Stream documentation * Support full document lookup NODE-1059 * Isolate noConflict inside simulator * Use instanceof to check for errors and replset * Improve modularity * Improve tests * remove disableResume support * Simplify logic and use instanceof * Tidy resumeToken * Restructure aggregation command options * Connect to real server * Removing simulated MongoNetworkError support, using Kill instead * Improve resumability and read preference support * Renaming changeNotification to change * Improve stream compatibility and documentation * Basic stream support * Document stream method * Testing for multiple pipes * Remove Change Stream on Database support * Improve test reliability and coverage * Support .resume(), .pause() and resumability * Make cursorOptions top-level constant * Remove database support * add test for multiple pipes * Remove Db.watch() in tests * Improve test coverage * Tweaking Travis config * Re-enable testing against multiple MongoDB versions * temporarily use patched topology manager * Revert "temporarily use patched topology manager"
1 parent 6339625 commit c02d25c

11 files changed

+7552
-5
lines changed

.eslintrc

+2-1
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
"plugins": [
1111
],
1212
"rules": {
13-
"no-console":0
13+
"no-console": 0,
14+
"semi": 1
1415
}
1516
}

.travis.yml

+1
Original file line numberDiff line numberDiff line change
@@ -17,3 +17,4 @@ env:
1717
- MONGODB_VERSION=3.0.x
1818
- MONGODB_VERSION=3.2.x
1919
- MONGODB_VERSION=3.4.x
20+
- MONGODB_VERSION=3.5.x

conf.json

+1
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
"test/functional/authentication_tests.js",
99
"test/functional/gridfs_stream_tests.js",
1010
"lib/admin.js",
11+
"lib/change_stream.js",
1112
"lib/collection.js",
1213
"lib/cursor.js",
1314
"lib/aggregation_cursor.js",

lib/change_stream.js

+337
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,337 @@
1+
var EventEmitter = require('events'),
2+
inherits = require('util').inherits,
3+
MongoNetworkError = require('mongodb-core').MongoNetworkError;
4+
5+
var cursorOptionNames = ['maxAwaitTimeMS', 'collation', 'readPreference'];
6+
7+
/**
8+
* Creates a new Change Stream instance. Normally created using {@link Collection#watch|Collection.watch()}.
9+
* @class ChangeStream
10+
* @since 3.0.0
11+
* @param {(Db|Collection)} changeDomain The collection against which to create the change stream
12+
* @param {Array} pipeline An array of {@link https://docs.mongodb.com/manual/reference/operator/aggregation-pipeline/|aggregation pipeline stages} through which to pass change stream documents
13+
* @param {object} [options=null] Optional settings
14+
* @param {string} [options.fullDocument=none] Allowed values: ‘none’, ‘lookup’. When set to ‘lookup’, the change stream will include both a delta describing the changes to the document, as well as a copy of the entire document that was changed from some time after the change occurred.
15+
* @param {number} [options.maxAwaitTimeMS] The maximum amount of time for the server to wait on new documents to satisfy a change stream query
16+
* @param {object} [options.resumeAfter=null] Specifies the logical starting point for the new change stream. This should be the _id field from a previously returned change stream document.
17+
* @param {number} [options.batchSize=null] The number of documents to return per batch. See {@link https://docs.mongodb.com/manual/reference/command/aggregate|aggregation documentation}.
18+
* @param {object} [options.collation=null] Specify collation settings for operation. See {@link https://docs.mongodb.com/manual/reference/command/aggregate|aggregation documentation}.
19+
* @param {ReadPreference} [options.readPreference=null] The read preference. Defaults to the read preference of the database or collection. See {@link https://docs.mongodb.com/manual/reference/read-preference|read preference documentation}.
20+
* @fires ChangeStream#close
21+
* @fires ChangeStream#data
22+
* @fires ChangeStream#end
23+
* @fires ChangeStream#error
24+
* @return {ChangeStream} a ChangeStream instance.
25+
*/
26+
var ChangeStream = function(collection, pipeline, options) {
27+
var Collection = require('./collection');
28+
29+
// Ensure the provided collection is actually a collection
30+
if (!(collection instanceof Collection)) {
31+
throw new Error('collection provided to ChangeStream constructor is not an instance of Collection');
32+
}
33+
34+
var self = this;
35+
self.pipeline = pipeline || [];
36+
self.options = options || {};
37+
self.promiseLibrary = collection.s.promiseLibrary;
38+
39+
// Extract namespace and serverConfig from the collection
40+
self.namespace = {
41+
collection: collection.collectionName,
42+
database: collection.s.db.databaseName
43+
};
44+
45+
self.serverConfig = collection.s.db.serverConfig;
46+
47+
// Determine correct read preference
48+
self.options.readPreference = self.options.readPreference || collection.s.readPreference;
49+
50+
// Create contained Change Stream cursor
51+
self.cursor = createChangeStreamCursor(self);
52+
53+
// Listen for any data listeners being added to ChangeStream
54+
self.on('newListener', function(eventName) {
55+
if (eventName === 'data' && self.cursor && self.cursor.listenerCount('data') === 0) {
56+
self.cursor.on('data', function (change) {
57+
processNewChange(self, null, change);
58+
});
59+
}
60+
});
61+
62+
// Listen for all data listeners being removed from ChangeStream
63+
self.on('removeListener', function(eventName){
64+
if (eventName === 'data' && self.listenerCount('data') === 0 && self.cursor) {
65+
self.cursor.removeAllListeners('data');
66+
}
67+
});
68+
69+
};
70+
71+
// Create a new change stream cursor based on self's configuration
72+
var createChangeStreamCursor = function (self) {
73+
if (self.resumeToken) {
74+
self.options.resumeAfter = self.resumeToken;
75+
}
76+
77+
var changeStreamCursor =
78+
buildChangeStreamAggregationCommand(self.serverConfig, self.namespace, self.pipeline, self.resumeToken, self.options);
79+
80+
/**
81+
* Fired for each new matching change in the specified namespace. Attaching a 'data' event listener to a Change Stream will switch the stream into flowing mode. Data will then be passed as soon as it is available.
82+
*
83+
* @event ChangeStream#data
84+
* @type {object}
85+
*/
86+
if (self.listenerCount('data') > 0) {
87+
changeStreamCursor.on('data', function (change) {
88+
processNewChange(self, null, change);
89+
});
90+
}
91+
92+
/**
93+
* Change stream close event
94+
*
95+
* @event ChangeStream#close
96+
* @type {null}
97+
*/
98+
changeStreamCursor.on('close', function () {
99+
self.emit('close');
100+
});
101+
102+
/**
103+
* Change stream end event
104+
*
105+
* @event ChangeStream#end
106+
* @type {null}
107+
*/
108+
changeStreamCursor.on('end', function () {
109+
self.emit('end');
110+
});
111+
112+
/**
113+
* Fired when the stream encounters an error.
114+
*
115+
* @event ChangeStream#error
116+
* @type {Error}
117+
*/
118+
changeStreamCursor.on('error', function(error) {
119+
self.emit('error', error);
120+
});
121+
122+
return changeStreamCursor;
123+
};
124+
125+
var buildChangeStreamAggregationCommand = function (serverConfig, namespace, pipeline, resumeToken, options) {
126+
var changeNotificationStageOptions = {};
127+
if (options.fullDocument) {
128+
changeNotificationStageOptions.fullDocument = options.fullDocument;
129+
}
130+
131+
if (resumeToken || options.resumeAfter) {
132+
changeNotificationStageOptions.resumeAfter = resumeToken || options.resumeAfter;
133+
}
134+
135+
// Map cursor options
136+
var cursorOptions = {};
137+
cursorOptionNames.forEach(function(optionName) {
138+
if (options[optionName]) {
139+
cursorOptions[optionName] = options[optionName];
140+
}
141+
});
142+
143+
var changeStreamPipeline = [
144+
{ $changeNotification: changeNotificationStageOptions }
145+
];
146+
147+
changeStreamPipeline = changeStreamPipeline.concat(pipeline);
148+
149+
var command = {
150+
aggregate : namespace.collection,
151+
pipeline : changeStreamPipeline,
152+
cursor: {
153+
batchSize: options.batchSize || 1
154+
}
155+
};
156+
157+
// Create and return the cursor
158+
return serverConfig.cursor(namespace.database + '.' + namespace.collection, command, cursorOptions);
159+
};
160+
161+
/**
162+
* Check if there is any document still available in the Change Stream
163+
* @function ChangeStream.prototype.hasNext
164+
* @param {ChangeStream~resultCallback} [callback] The result callback.
165+
* @throws {MongoError}
166+
* @return {Promise} returns Promise if no callback passed
167+
*/
168+
ChangeStream.prototype.hasNext = function (callback) {
169+
return this.cursor.hasNext(callback);
170+
};
171+
172+
/**
173+
* Get the next available document from the Change Stream, returns null if no more documents are available.
174+
* @function ChangeStream.prototype.next
175+
* @param {ChangeStream~resultCallback} [callback] The result callback.
176+
* @throws {MongoError}
177+
* @return {Promise} returns Promise if no callback passed
178+
*/
179+
ChangeStream.prototype.next = function (callback) {
180+
var self = this;
181+
if (this.isClosed()) {
182+
if (callback) return callback(new Error('Change Stream is not open.'), null);
183+
return self.promiseLibrary.reject(new Error('Change Stream is not open.'));
184+
}
185+
return this.cursor.next().then(function(change) {
186+
return processNewChange(self, null, change, callback);
187+
}).catch(function(err) {
188+
return processNewChange(self, err, null, callback);
189+
});
190+
};
191+
192+
/**
193+
* Is the cursor closed
194+
* @method ChangeStream.prototype.isClosed
195+
* @return {boolean}
196+
*/
197+
ChangeStream.prototype.isClosed = function () {
198+
if (this.cursor) {
199+
return this.cursor.isClosed();
200+
}
201+
return true;
202+
};
203+
204+
/**
205+
* Close the Change Stream
206+
* @method ChangeStream.prototype.close
207+
* @param {ChangeStream~resultCallback} [callback] The result callback.
208+
* @return {Promise} returns Promise if no callback passed
209+
*/
210+
ChangeStream.prototype.close = function (callback) {
211+
if (!this.cursor) {
212+
if (callback) return callback();
213+
return this.promiseLibrary.resolve();
214+
}
215+
216+
// Tidy up the existing cursor
217+
var cursor = this.cursor;
218+
delete this.cursor;
219+
return cursor.close(callback);
220+
};
221+
222+
/**
223+
* This method pulls all the data out of a readable stream, and writes it to the supplied destination, automatically managing the flow so that the destination is not overwhelmed by a fast readable stream.
224+
* @method
225+
* @param {Writable} destination The destination for writing data
226+
* @param {object} [options] {@link https://nodejs.org/api/stream.html#stream_readable_pipe_destination_options|Pipe options}
227+
* @return {null}
228+
*/
229+
ChangeStream.prototype.pipe = function (destination, options) {
230+
if (!this.pipeDestinations) {
231+
this.pipeDestinations = [];
232+
}
233+
this.pipeDestinations.push(destination);
234+
return this.cursor.pipe(destination, options);
235+
};
236+
237+
/**
238+
* This method will remove the hooks set up for a previous pipe() call.
239+
* @param {Writable} [destination] The destination for writing data
240+
* @return {null}
241+
*/
242+
ChangeStream.prototype.unpipe = function (destination) {
243+
if (this.pipeDestinations && this.pipeDestinations.indexOf(destination) > -1) {
244+
this.pipeDestinations.splice(this.pipeDestinations.indexOf(destination), 1);
245+
}
246+
return this.cursor.unpipe(destination);
247+
};
248+
249+
/**
250+
* This method will cause a stream in flowing mode to stop emitting data events. Any data that becomes available will remain in the internal buffer.
251+
* @return {null}
252+
*/
253+
ChangeStream.prototype.pause = function () {
254+
return this.cursor.pause();
255+
};
256+
257+
/**
258+
* This method will cause the readable stream to resume emitting data events.
259+
* @return {null}
260+
*/
261+
ChangeStream.prototype.resume = function () {
262+
return this.cursor.resume();
263+
};
264+
265+
/**
266+
* Return a modified Readable stream including a possible transform method.
267+
* @method
268+
* @param {object} [options=null] Optional settings.
269+
* @param {function} [options.transform=null] A transformation method applied to each document emitted by the stream.
270+
* @return {Cursor}
271+
*/
272+
ChangeStream.prototype.stream = function (options) {
273+
this.streamOptions = options;
274+
return this.cursor.stream(options);
275+
};
276+
277+
// Handle new change events. This method brings together the routes from the callback, event emitter, and promise ways of using ChangeStream.
278+
var processNewChange = function (self, err, change, callback) {
279+
// Handle errors
280+
if (err) {
281+
// Handle resumable MongoNetworkErrors
282+
if (err instanceof MongoNetworkError && !self.attemptingResume) {
283+
self.attemptingResume = true;
284+
return self.cursor.close(function(closeErr) {
285+
if (closeErr) {
286+
if (callback) return callback(err, null);
287+
return self.promiseLibrary.reject(err);
288+
}
289+
290+
// Establish a new cursor
291+
self.cursor = createChangeStreamCursor(self);
292+
293+
// Attempt to reconfigure piping
294+
if (self.pipeDestinations) {
295+
var cursorStream = self.cursor.stream(self.streamOptions);
296+
for (var pipeDestination in self.pipeDestinations) {
297+
cursorStream.pipe(pipeDestination);
298+
}
299+
}
300+
301+
// Attempt the next() operation again
302+
if (callback) return self.next(callback);
303+
return self.next();
304+
});
305+
}
306+
307+
if (typeof callback === 'function') return callback(err, null);
308+
if (self.listenerCount('error')) return self.emit('error', err);
309+
return self.promiseLibrary.reject(err);
310+
}
311+
self.attemptingResume = false;
312+
313+
// Cache the resume token if it is present. If it is not present return an error.
314+
if (!change || !change._id) {
315+
var noResumeTokenError = new Error('A change stream document has been recieved that lacks a resume token (_id).');
316+
if (typeof callback === 'function') return callback(noResumeTokenError, null);
317+
if (self.listenerCount('error')) return self.emit('error', noResumeTokenError);
318+
return self.promiseLibrary.reject(noResumeTokenError);
319+
}
320+
self.resumeToken = change._id;
321+
322+
// Return the change
323+
if (typeof callback === 'function') return callback(err, change);
324+
if (self.listenerCount('data')) return self.emit('data', change);
325+
return self.promiseLibrary.resolve(change);
326+
};
327+
328+
/**
329+
* The callback format for results
330+
* @callback ChangeStream~resultCallback
331+
* @param {MongoError} error An error instance representing the error during the execution.
332+
* @param {(object|null)} result The result object if the command was executed successfully.
333+
*/
334+
335+
inherits(ChangeStream, EventEmitter);
336+
337+
module.exports = ChangeStream;

0 commit comments

Comments
 (0)