Skip to content

Commit ed5e2c1

Browse files
authored
Merge pull request #888 from estolfo/change-stream
RUBY-1228 Change Streams
2 parents 5d31fff + 5cec119 commit ed5e2c1

File tree

12 files changed

+985
-3
lines changed

12 files changed

+985
-3
lines changed

lib/mongo.rb

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,8 +29,8 @@
2929
require 'mongo/protocol'
3030
require 'mongo/client'
3131
require 'mongo/cluster'
32-
require 'mongo/collection'
3332
require 'mongo/cursor'
33+
require 'mongo/collection'
3434
require 'mongo/database'
3535
require 'mongo/dbref'
3636
require 'mongo/grid'

lib/mongo/collection.rb

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -281,6 +281,38 @@ def aggregate(pipeline, options = {})
281281
View.new(self, {}).aggregate(pipeline, options)
282282
end
283283

284+
# As of version 3.6 of the MongoDB server, a ``$changeStream`` pipeline stage is supported
285+
# in the aggregation framework. This stage allows users to request that notifications are sent for
286+
# all changes to a particular collection.
287+
#
288+
# @example Get change notifications for a given collection.
289+
# collection.watch([{ '$match' => { operationType: { '$in' => ['insert', 'replace'] } } }])
290+
#
291+
# @param [ Array<Hash> ] pipeline Optional additional filter operators.
292+
# @param [ Hash ] options The change stream options.
293+
#
294+
# @option options [ String ] :full_document Allowed values: ‘default’, ‘updateLookup’. Defaults to ‘default’.
295+
# When set to ‘updateLookup’, the change notification for partial updates will include both a delta
296+
# describing the changes to the document, as well as a copy of the entire document that was changed
297+
# from some time after the change occurred.
298+
# @option options [ BSON::Document, Hash ] :resume_after Specifies the logical starting point for the
299+
# new change stream.
300+
# @option options [ Integer ] :max_await_time_ms The maximum amount of time for the server to wait
301+
# on new documents to satisfy a change stream query.
302+
# @option options [ Integer ] :batch_size The number of documents to return per batch.
303+
# @option options [ BSON::Document, Hash ] :collation The collation to use.
304+
#
305+
# @note A change stream only allows 'majority' read concern.
306+
# @note This helper method is preferable to running a raw aggregation with a $changeStream stage,
307+
# for the purpose of supporting resumability.
308+
#
309+
# @return [ ChangeStream ] The change stream object.
310+
#
311+
# @since 2.5.0
312+
def watch(pipeline = [], options = {})
313+
View::ChangeStream.new(View.new(self, {}, options), pipeline, options)
314+
end
315+
284316
# Get a count of matching documents in the collection.
285317
#
286318
# @example Get the count.

lib/mongo/collection/view.rb

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
require 'mongo/collection/view/iterable'
1818
require 'mongo/collection/view/explainable'
1919
require 'mongo/collection/view/aggregation'
20+
require 'mongo/collection/view/change_stream'
2021
require 'mongo/collection/view/map_reduce'
2122
require 'mongo/collection/view/readable'
2223
require 'mongo/collection/view/writable'

lib/mongo/collection/view/builder/aggregation.rb

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,9 @@ class Aggregation
2929
MAPPINGS = BSON::Document.new(
3030
:allow_disk_use => 'allowDiskUse',
3131
:max_time_ms => 'maxTimeMS',
32+
# This is intentional; max_await_time_ms is an alias for maxTimeMS used on getmore
33+
# commands for change streams.
34+
:max_await_time_ms => 'maxTimeMS',
3235
:explain => 'explain',
3336
:bypass_document_validation => 'bypassDocumentValidation',
3437
:collation => 'collation',
Lines changed: 159 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,159 @@
1+
# Copyright (C) 2017 MongoDB, Inc.
2+
#
3+
# Licensed under the Apache License, Version 2.0 (the "License");
4+
# you may not use this file except in compliance with the License.
5+
# You may obtain a copy of the License at
6+
#
7+
# http://www.apache.org/licenses/LICENSE-2.0
8+
#
9+
# Unless required by applicable law or agreed to in writing, software
10+
# distributed under the License is distributed on an "AS IS" BASIS,
11+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
# See the License for the specific language governing permissions and
13+
# limitations under the License.
14+
15+
require 'mongo/collection/view/change_stream/retryable'
16+
17+
module Mongo
18+
class Collection
19+
class View
20+
21+
# Provides behaviour around a `$changeStream` pipeline stage in the
22+
# aggregation framework. Specifying this stage allows users to request that
23+
# notifications are sent for all changes to a particular collection or database.
24+
#
25+
# @note Only available in server versions 3.6 and higher.
26+
# @note ChangeStreams do not work properly with JRuby because of the issue documented
27+
# here: https://github.com/jruby/jruby/issues/4212
28+
# Namely, JRuby eagerly evaluates #next on an Enumerator in a background green thread.
29+
# So calling #next on the change stream will cause getmores to be called in a loop in the background.
30+
#
31+
#
32+
# @since 2.5.0
33+
class ChangeStream < Aggregation
34+
include Retryable
35+
36+
# @return [ String ] The fullDocument option default value.
37+
#
38+
# @since 2.5.0
39+
FULL_DOCUMENT_DEFAULT = 'default'.freeze
40+
41+
# @return [ BSON::Document ] The change stream options.
42+
#
43+
# @since 2.5.0
44+
attr_reader :options
45+
46+
# Initialize the change stream for the provided collection view, pipeline
47+
# and options.
48+
#
49+
# @example Create the new change stream view.
50+
# ChangeStream.new(view, pipeline, options)
51+
#
52+
# @param [ Collection::View ] view The collection view.
53+
# @param [ Array<Hash> ] pipeline The pipeline of operators to filter the change notifications.
54+
# @param [ Hash ] opts The change stream options.
55+
#
56+
# @option options [ String ] :full_document Allowed values: ‘default’, ‘updateLookup’. Defaults to ‘default’.
57+
# When set to ‘updateLookup’, the change notification for partial updates will include both a delta
58+
# describing the changes to the document, as well as a copy of the entire document that was changed
59+
# from some time after the change occurred.
60+
# @option options [ BSON::Document, Hash ] :resume_after Specifies the logical starting point for the
61+
# new change stream.
62+
# @option options [ Integer ] :max_await_time_ms The maximum amount of time for the server to wait
63+
# on new documents to satisfy a change stream query.
64+
# @option options [ Integer ] :batch_size The number of documents to return per batch.
65+
# @option options [ BSON::Document, Hash ] :collation The collation to use.
66+
#
67+
# @since 2.5.0
68+
def initialize(view, pipeline, options = {})
69+
@view = view
70+
@change_stream_filters = pipeline && pipeline.dup
71+
@options = options && options.dup.freeze
72+
@resume_token = @options[:resume_after]
73+
read_with_one_retry { create_cursor! }
74+
end
75+
76+
# Iterate through documents returned by the change stream.
77+
#
78+
# @example Iterate through the stream of documents.
79+
# stream.each do |document|
80+
# p document
81+
# end
82+
#
83+
# @return [ Enumerator ] The enumerator.
84+
#
85+
# @since 2.5.0
86+
#
87+
# @yieldparam [ BSON::Document ] Each change stream document.
88+
def each
89+
raise StopIteration.new if closed?
90+
begin
91+
@cursor.each do |doc|
92+
cache_resume_token(doc)
93+
yield doc
94+
end if block_given?
95+
@cursor.to_enum
96+
rescue => e
97+
close
98+
if retryable?(e)
99+
create_cursor!
100+
retry
101+
end
102+
raise
103+
end
104+
end
105+
106+
# Close the change stream.
107+
#
108+
# @example Close the change stream.
109+
# stream.close
110+
#
111+
# @return [ nil ] nil.
112+
#
113+
# @since 2.5.0
114+
def close
115+
unless closed?
116+
begin; @cursor.send(:kill_cursors); rescue; end
117+
@cursor = nil
118+
end
119+
end
120+
121+
# Is the change stream closed?
122+
#
123+
# @example Determine whether the change stream is closed.
124+
# stream.closed?
125+
#
126+
# @return [ true, false ] If the change stream is closed.
127+
#
128+
# @since 2.5.0
129+
def closed?
130+
@cursor.nil?
131+
end
132+
133+
private
134+
135+
def cache_resume_token(doc)
136+
unless @resume_token = (doc[:_id] && doc[:_id].dup)
137+
raise Error::MissingResumeToken.new
138+
end
139+
end
140+
141+
def create_cursor!
142+
server = server_selector.select_server(cluster, false)
143+
result = send_initial_query(server)
144+
@cursor = Cursor.new(view, result, server, disable_retry: true)
145+
end
146+
147+
def pipeline
148+
change_doc = { fullDocument: ( @options[:full_document] || FULL_DOCUMENT_DEFAULT ) }
149+
change_doc[:resumeAfter] = @resume_token if @resume_token
150+
[{ '$changeStream' => change_doc }] + @change_stream_filters
151+
end
152+
153+
def send_initial_query(server)
154+
initial_query_op.execute(server)
155+
end
156+
end
157+
end
158+
end
159+
end
Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
# Copyright (C) 2017 MongoDB, Inc.
2+
#
3+
# Licensed under the Apache License, Version 2.0 (the "License");
4+
# you may not use this file except in compliance with the License.
5+
# You may obtain a copy of the License at
6+
#
7+
# http://www.apache.org/licenses/LICENSE-2.0
8+
#
9+
# Unless required by applicable law or agreed to in writing, software
10+
# distributed under the License is distributed on an "AS IS" BASIS,
11+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
# See the License for the specific language governing permissions and
13+
# limitations under the License.
14+
15+
module Mongo
16+
class Collection
17+
class View
18+
class ChangeStream < Aggregation
19+
20+
# Behavior around resuming a change stream.
21+
#
22+
# @since 2.5.0
23+
module Retryable
24+
25+
private
26+
27+
RETRY_MESSAGES = [
28+
'not master',
29+
'(43)' # cursor not found error code
30+
].freeze
31+
32+
def read_with_one_retry
33+
yield
34+
rescue => e
35+
if retryable?(e)
36+
yield
37+
else
38+
raise(e)
39+
end
40+
end
41+
42+
def retryable?(error)
43+
network_error?(error) || retryable_operation_failure?(error)
44+
end
45+
46+
def network_error?(error)
47+
[ Error::SocketError, Error::SocketTimeoutError].include?(error.class)
48+
end
49+
50+
def retryable_operation_failure?(error)
51+
error.is_a?(Error::OperationFailure) && RETRY_MESSAGES.any? { |m| error.message.include?(m) }
52+
end
53+
end
54+
end
55+
end
56+
end
57+
end

lib/mongo/cursor.rb

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -50,15 +50,20 @@ class Cursor
5050
# @param [ CollectionView ] view The +CollectionView+ defining the query.
5151
# @param [ Operation::Result ] result The result of the first execution.
5252
# @param [ Server ] server The server this cursor is locked to.
53+
# @param [ Hash ] options The cursor options.
54+
#
55+
# @option options [ true, false ] :disable_retry Whether to disable retrying on
56+
# error when sending getmores.
5357
#
5458
# @since 2.0.0
55-
def initialize(view, result, server)
59+
def initialize(view, result, server, options = {})
5660
@view = view
5761
@server = server
5862
@initial_result = result
5963
@remaining = limit if limited?
6064
@cursor_id = result.cursor_id
6165
@coll_name = nil
66+
@options = options
6267
register
6368
ObjectSpace.define_finalizer(self, self.class.finalize(result.cursor_id,
6469
cluster,
@@ -185,8 +190,12 @@ def exhausted?
185190
end
186191

187192
def get_more
188-
read_with_retry do
193+
if @options[:disable_retry]
189194
process(get_more_operation.execute(@server))
195+
else
196+
read_with_retry do
197+
process(get_more_operation.execute(@server))
198+
end
190199
end
191200
end
192201

@@ -203,6 +212,8 @@ def kill_cursors
203212
read_with_one_retry do
204213
kill_cursors_operation.execute(@server)
205214
end
215+
ensure
216+
@cursor_id = 0
206217
end
207218

208219
def kill_cursors_operation

lib/mongo/error.rb

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -103,6 +103,7 @@ class Error < StandardError
103103
require 'mongo/error/unexpected_chunk_length'
104104
require 'mongo/error/unexpected_response'
105105
require 'mongo/error/missing_file_chunk'
106+
require 'mongo/error/missing_resume_token'
106107
require 'mongo/error/unsupported_array_filters'
107108
require 'mongo/error/unknown_payload_type'
108109
require 'mongo/error/unsupported_collation'
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
# Copyright (C) 2017 MongoDB, Inc.
2+
#
3+
# Licensed under the Apache License, Version 2.0 (the "License");
4+
# you may not use this file except in compliance with the License.
5+
# You may obtain a copy of the License at
6+
#
7+
# http://www.apache.org/licenses/LICENSE-2.0
8+
#
9+
# Unless required by applicable law or agreed to in writing, software
10+
# distributed under the License is distributed on an "AS IS" BASIS,
11+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
# See the License for the specific language governing permissions and
13+
# limitations under the License.
14+
15+
module Mongo
16+
class Error
17+
18+
# Raised if a change stream document is returned without a resume token.
19+
#
20+
# @since 2.5.0
21+
class MissingResumeToken < Error
22+
23+
# The error message.
24+
#
25+
# @since 2.5.0
26+
MESSAGE = 'Cannot provide resume functionality when the resume token is missing'.freeze
27+
28+
# Create the new exception.
29+
#
30+
# @example Create the new exception.
31+
# Mongo::Error::MissingResumeToken.new
32+
#
33+
# @since 2.5.0
34+
def initialize
35+
super(MESSAGE)
36+
end
37+
end
38+
end
39+
end

0 commit comments

Comments
 (0)