From 9769cbeea128c5d77e243486306f255cfa543aa7 Mon Sep 17 00:00:00 2001 From: Emily Date: Fri, 8 Sep 2017 17:54:52 +0200 Subject: [PATCH] RUBY-1226 Sessions implementation --- lib/mongo.rb | 1 + lib/mongo/auth/cr.rb | 2 + lib/mongo/auth/cr/conversation.rb | 4 + lib/mongo/auth/ldap.rb | 4 +- lib/mongo/auth/ldap/conversation.rb | 2 + lib/mongo/auth/scram.rb | 3 + lib/mongo/auth/scram/conversation.rb | 6 + lib/mongo/auth/user/view.rb | 66 ++- lib/mongo/auth/x509.rb | 4 +- lib/mongo/auth/x509/conversation.rb | 2 + lib/mongo/bulk_write.rb | 62 +- lib/mongo/client.rb | 71 +++ lib/mongo/cluster.rb | 34 ++ lib/mongo/cluster/cursor_reaper.rb | 1 - lib/mongo/collection.rb | 87 ++- lib/mongo/collection/view.rb | 6 + lib/mongo/collection/view/aggregation.rb | 14 +- .../collection/view/builder/aggregation.rb | 3 +- .../collection/view/builder/find_command.rb | 6 +- .../collection/view/builder/map_reduce.rb | 14 +- lib/mongo/collection/view/change_stream.rb | 9 +- lib/mongo/collection/view/iterable.rb | 19 +- lib/mongo/collection/view/map_reduce.rb | 36 +- lib/mongo/collection/view/readable.rb | 63 ++- lib/mongo/collection/view/writable.rb | 85 +-- lib/mongo/cursor.rb | 8 +- lib/mongo/cursor/builder/get_more_command.rb | 6 +- lib/mongo/database.rb | 32 +- lib/mongo/database/view.rb | 28 +- lib/mongo/error.rb | 1 + lib/mongo/error/invalid_session.rb | 36 ++ lib/mongo/error/operation_failure.rb | 17 + lib/mongo/grid/fs_bucket.rb | 4 +- lib/mongo/grid/stream/write.rb | 4 +- lib/mongo/index/view.rb | 60 +- lib/mongo/operation.rb | 10 + .../commands/list_collections/result.rb | 2 +- .../operation/commands/list_indexes/result.rb | 2 +- .../operation/commands/map_reduce/result.rb | 2 +- lib/mongo/operation/executable.rb | 5 +- lib/mongo/operation/result.rb | 26 +- lib/mongo/operation/specifiable.rb | 12 + lib/mongo/operation/uses_command_op_msg.rb | 14 +- lib/mongo/operation/write/bulk/bulkable.rb | 5 +- lib/mongo/operation/write/command/delete.rb | 12 +- lib/mongo/operation/write/command/insert.rb | 12 +- lib/mongo/operation/write/command/update.rb | 13 +- lib/mongo/operation/write/command/writable.rb | 8 + lib/mongo/operation/write/insert.rb | 5 +- .../operation/write/write_command_enabled.rb | 5 +- lib/mongo/protocol/msg.rb | 2 +- lib/mongo/retryable.rb | 6 +- lib/mongo/server.rb | 4 +- lib/mongo/server/connection.rb | 4 +- lib/mongo/server/description/features.rb | 1 + lib/mongo/session.rb | 180 ++++++ lib/mongo/session/server_session.rb | 73 +++ lib/mongo/session/session_pool.rb | 161 ++++++ spec/mongo/auth/cr_spec.rb | 2 + spec/mongo/auth/ldap_spec.rb | 2 + spec/mongo/auth/scram_spec.rb | 2 + spec/mongo/auth/user/view_spec.rb | 351 +++++++++--- spec/mongo/auth/x509_spec.rb | 2 + spec/mongo/bulk_write_spec.rb | 331 ++++++++++- spec/mongo/client_spec.rb | 100 +++- spec/mongo/cluster_spec.rb | 113 ++++ .../mongo/collection/view/aggregation_spec.rb | 45 +- .../view/builder/find_command_spec.rb | 17 +- .../collection/view/change_stream_spec.rb | 265 ++++++++- spec/mongo/collection/view/map_reduce_spec.rb | 111 +++- spec/mongo/collection_spec.rb | 535 +++++++++++++++++- .../cursor/builder/get_more_command_spec.rb | 19 + spec/mongo/cursor_spec.rb | 2 +- spec/mongo/database_spec.rb | 44 ++ spec/mongo/grid/fs_bucket_spec.rb | 354 +++++++----- spec/mongo/index/view_spec.rb | 154 ++++- .../operation/write/command/delete_spec.rb | 58 +- .../operation/write/command/insert_spec.rb | 70 ++- .../operation/write/command/update_spec.rb | 56 +- spec/mongo/retryable_spec.rb | 8 +- spec/mongo/server/connection_spec.rb | 2 + spec/mongo/session/server_session_spec.rb | 16 + spec/mongo/session/session_pool_spec.rb | 194 +++++++ spec/spec_helper.rb | 61 ++ spec/support/shared/session.rb | 236 ++++++++ 85 files changed, 3902 insertions(+), 612 deletions(-) create mode 100644 lib/mongo/error/invalid_session.rb create mode 100644 lib/mongo/session.rb create mode 100644 lib/mongo/session/server_session.rb create mode 100644 lib/mongo/session/session_pool.rb create mode 100644 spec/mongo/session/server_session_spec.rb create mode 100644 spec/mongo/session/session_pool_spec.rb create mode 100644 spec/support/shared/session.rb diff --git a/lib/mongo.rb b/lib/mongo.rb index a9a8db3823..b2622356e6 100644 --- a/lib/mongo.rb +++ b/lib/mongo.rb @@ -37,6 +37,7 @@ require 'mongo/index' require 'mongo/server' require 'mongo/server_selector' +require 'mongo/session' require 'mongo/socket' require 'mongo/uri' require 'mongo/version' diff --git a/lib/mongo/auth/cr.rb b/lib/mongo/auth/cr.rb index 74d6f24d98..18e77ae687 100644 --- a/lib/mongo/auth/cr.rb +++ b/lib/mongo/auth/cr.rb @@ -55,7 +55,9 @@ def initialize(user) def login(connection) conversation = Conversation.new(user) reply = connection.dispatch([ conversation.start(connection) ]) + connection.update_cluster_time(Operation::Result.new(reply)) reply = connection.dispatch([ conversation.continue(reply, connection) ]) + connection.update_cluster_time(Operation::Result.new(reply)) conversation.finalize(reply, connection) end end diff --git a/lib/mongo/auth/cr/conversation.rb b/lib/mongo/auth/cr/conversation.rb index 0c2a6b5d43..d32a116377 100644 --- a/lib/mongo/auth/cr/conversation.rb +++ b/lib/mongo/auth/cr/conversation.rb @@ -59,6 +59,8 @@ def continue(reply, connection = nil) if connection && connection.features.op_msg_enabled? selector = LOGIN.merge(user: user.name, nonce: nonce, key: user.auth_key(nonce)) selector[Protocol::Msg::DATABASE_IDENTIFIER] = user.auth_source + cluster_time = connection.mongos? && connection.cluster_time + selector[Operation::CLUSTER_TIME] = cluster_time if cluster_time Protocol::Msg.new([:none], {}, selector) else Protocol::Query.new( @@ -98,6 +100,8 @@ def finalize(reply, connection = nil) def start(connection = nil) if connection && connection.features.op_msg_enabled? selector = Auth::GET_NONCE.merge(Protocol::Msg::DATABASE_IDENTIFIER => user.auth_source) + cluster_time = connection.mongos? && connection.cluster_time + selector[Operation::CLUSTER_TIME] = cluster_time if cluster_time Protocol::Msg.new([:none], {}, selector) else Protocol::Query.new( diff --git a/lib/mongo/auth/ldap.rb b/lib/mongo/auth/ldap.rb index 1ef0f14b73..5fd66a05e4 100644 --- a/lib/mongo/auth/ldap.rb +++ b/lib/mongo/auth/ldap.rb @@ -54,7 +54,9 @@ def initialize(user) # @since 2.0.0 def login(connection) conversation = Conversation.new(user) - conversation.finalize(connection.dispatch([ conversation.start(connection) ])) + reply = connection.dispatch([ conversation.start(connection) ]) + connection.update_cluster_time(Operation::Result.new(reply)) + conversation.finalize(reply) end end end diff --git a/lib/mongo/auth/ldap/conversation.rb b/lib/mongo/auth/ldap/conversation.rb index 3b840d665d..1b9812f97a 100644 --- a/lib/mongo/auth/ldap/conversation.rb +++ b/lib/mongo/auth/ldap/conversation.rb @@ -65,6 +65,8 @@ def start(connection = nil) if connection && connection.features.op_msg_enabled? selector = LOGIN.merge(payload: payload, mechanism: LDAP::MECHANISM) selector[Protocol::Msg::DATABASE_IDENTIFIER] = Auth::EXTERNAL + cluster_time = connection.mongos? && connection.cluster_time + selector[Operation::CLUSTER_TIME] = cluster_time if cluster_time Protocol::Msg.new([:none], {}, selector) else Protocol::Query.new( diff --git a/lib/mongo/auth/scram.rb b/lib/mongo/auth/scram.rb index 64996a3112..1ecb342df3 100644 --- a/lib/mongo/auth/scram.rb +++ b/lib/mongo/auth/scram.rb @@ -56,9 +56,12 @@ def initialize(user) def login(connection) conversation = Conversation.new(user) reply = connection.dispatch([ conversation.start(connection) ]) + connection.update_cluster_time(Operation::Result.new(reply)) reply = connection.dispatch([ conversation.continue(reply, connection) ]) + connection.update_cluster_time(Operation::Result.new(reply)) until reply.documents[0][Conversation::DONE] reply = connection.dispatch([ conversation.finalize(reply, connection) ]) + connection.update_cluster_time(Operation::Result.new(reply)) end reply end diff --git a/lib/mongo/auth/scram/conversation.rb b/lib/mongo/auth/scram/conversation.rb index 864acb119a..257e389ed0 100644 --- a/lib/mongo/auth/scram/conversation.rb +++ b/lib/mongo/auth/scram/conversation.rb @@ -121,6 +121,8 @@ def continue(reply, connection = nil) if connection && connection.features.op_msg_enabled? selector = CLIENT_CONTINUE_MESSAGE.merge(payload: client_final_message, conversationId: id) selector[Protocol::Msg::DATABASE_IDENTIFIER] = user.auth_source + cluster_time = connection.mongos? && connection.cluster_time + selector[Operation::CLUSTER_TIME] = cluster_time if cluster_time Protocol::Msg.new([:none], {}, selector) else Protocol::Query.new( @@ -150,6 +152,8 @@ def finalize(reply, connection = nil) if connection && connection.features.op_msg_enabled? selector = CLIENT_CONTINUE_MESSAGE.merge(payload: client_empty_message, conversationId: id) selector[Protocol::Msg::DATABASE_IDENTIFIER] = user.auth_source + cluster_time = connection.mongos? && connection.cluster_time + selector[Operation::CLUSTER_TIME] = cluster_time if cluster_time Protocol::Msg.new([:none], {}, selector) else Protocol::Query.new( @@ -176,6 +180,8 @@ def start(connection = nil) if connection && connection.features.op_msg_enabled? selector = CLIENT_FIRST_MESSAGE.merge(payload: client_first_message, mechanism: SCRAM::MECHANISM) selector[Protocol::Msg::DATABASE_IDENTIFIER] = user.auth_source + cluster_time = connection.mongos? && connection.cluster_time + selector[Operation::CLUSTER_TIME] = cluster_time if cluster_time Protocol::Msg.new([:none], {}, selector) else Protocol::Query.new( diff --git a/lib/mongo/auth/user/view.rb b/lib/mongo/auth/user/view.rb index de103a369b..0faed03b6f 100644 --- a/lib/mongo/auth/user/view.rb +++ b/lib/mongo/auth/user/view.rb @@ -25,7 +25,7 @@ class View # @return [ Database ] database The view's database. attr_reader :database - def_delegators :database, :cluster, :read_preference + def_delegators :database, :cluster, :read_preference, :client def_delegators :cluster, :next_primary # Create a new user in the database. @@ -36,15 +36,20 @@ class View # @param [ Auth::User, String ] user_or_name The user object or user name. # @param [ Hash ] options The user options. # + # @option options [ Session ] :session The session to use for the operation. + # # @return [ Result ] The command response. # # @since 2.0.0 def create(user_or_name, options = {}) user = generate(user_or_name, options) - Operation::Write::CreateUser.new( - user: user, - db_name: database.name - ).execute(next_primary) + client.send(:with_session, options) do |session| + Operation::Write::CreateUser.new( + user: user, + db_name: database.name, + session: session + ).execute(next_primary) + end end # Initialize the new user view. @@ -65,15 +70,21 @@ def initialize(database) # view.remove('user') # # @param [ String ] name The user name. + # @param [ Hash ] options The options for the remove operation. + # + # @option options [ Session ] :session The session to use for the operation. # # @return [ Result ] The command response. # # @since 2.0.0 - def remove(name) - Operation::Write::RemoveUser.new( - user_name: name, - db_name: database.name - ).execute(next_primary) + def remove(name, options = {}) + client.send(:with_session, options) do |session| + Operation::Write::RemoveUser.new( + user_name: name, + db_name: database.name, + session: session + ).execute(next_primary) + end end # Update a user in the database. @@ -84,15 +95,20 @@ def remove(name) # @param [ Auth::User, String ] user_or_name The user object or user name. # @param [ Hash ] options The user options. # + # @option options [ Session ] :session The session to use for the operation. + # # @return [ Result ] The response. # # @since 2.0.0 def update(user_or_name, options = {}) - user = generate(user_or_name, options) - Operation::Write::UpdateUser.new( - user: user, - db_name: database.name - ).execute(next_primary) + client.send(:with_session, options) do |session| + user = generate(user_or_name, options) + Operation::Write::UpdateUser.new( + user: user, + db_name: database.name, + session: session + ).execute(next_primary) + end end # Get info for a particular user in the database. @@ -101,21 +117,27 @@ def update(user_or_name, options = {}) # view.info('emily') # # @param [ String ] name The user name. + # @param [ Hash ] options The options for the info operation. + # + # @option options [ Session ] :session The session to use for the operation. # # @return [ Hash ] A document containing information on a particular user. # # @since 2.1.0 - def info(name) - user_query(name).documents + def info(name, options = {}) + user_query(name, options).documents end private - def user_query(name) - Operation::Commands::UserQuery.new( - user_name: name, - db_name: database.name - ).execute(next_primary) + def user_query(name, options = {}) + client.send(:with_session, options) do |session| + Operation::Commands::UserQuery.new( + user_name: name, + db_name: database.name, + session: session + ).execute(next_primary) + end end def generate(user, options) diff --git a/lib/mongo/auth/x509.rb b/lib/mongo/auth/x509.rb index f383e9731d..73c6008854 100644 --- a/lib/mongo/auth/x509.rb +++ b/lib/mongo/auth/x509.rb @@ -55,7 +55,9 @@ def initialize(user) # @since 2.0.0 def login(connection) conversation = Conversation.new(user) - conversation.finalize(connection.dispatch([ conversation.start(connection) ])) + reply = connection.dispatch([ conversation.start(connection) ]) + connection.update_cluster_time(Operation::Result.new(reply)) + conversation.finalize(reply) end end end diff --git a/lib/mongo/auth/x509/conversation.rb b/lib/mongo/auth/x509/conversation.rb index 0e57b47ac9..1d7783aea4 100644 --- a/lib/mongo/auth/x509/conversation.rb +++ b/lib/mongo/auth/x509/conversation.rb @@ -67,6 +67,8 @@ def start(connection = nil) if connection && connection.features.op_msg_enabled? selector = login selector[Protocol::Msg::DATABASE_IDENTIFIER] = user.auth_source + cluster_time = connection.mongos? && connection.cluster_time + selector[Operation::CLUSTER_TIME] = cluster_time if cluster_time Protocol::Msg.new([:none], {}, selector) else Protocol::Query.new( diff --git a/lib/mongo/bulk_write.rb b/lib/mongo/bulk_write.rb index bb01d5d245..0584e09f7c 100644 --- a/lib/mongo/bulk_write.rb +++ b/lib/mongo/bulk_write.rb @@ -53,20 +53,23 @@ class BulkWrite def execute operation_id = Monitoring.next_operation_id result_combiner = ResultCombiner.new - write_with_retry do - operations = op_combiner.combine - server = next_primary - raise Error::UnsupportedCollation.new if op_combiner.has_collation && !server.features.collation_enabled? - raise Error::UnsupportedArrayFilters.new if op_combiner.has_array_filters && !server.features.array_filters_enabled? - - operations.each do |operation| - execute_operation( - operation.keys.first, - operation.values.first, - server, - operation_id, - result_combiner - ) + + client.send(:with_session, @options) do |session| + write_with_retry(session, Proc.new { next_primary }) do |server| + operations = op_combiner.combine + raise Error::UnsupportedCollation.new if op_combiner.has_collation && !server.features.collation_enabled? + raise Error::UnsupportedArrayFilters.new if op_combiner.has_array_filters && !server.features.array_filters_enabled? + + operations.each do |operation| + execute_operation( + operation.keys.first, + operation.values.first, + server, + operation_id, + result_combiner, + session + ) + end end end result_combiner.result @@ -134,7 +137,7 @@ def write_concern private - def base_spec(operation_id) + def base_spec(operation_id, session) { :db_name => database.name, :coll_name => collection.name, @@ -143,20 +146,21 @@ def base_spec(operation_id) :operation_id => operation_id, :bypass_document_validation => !!options[:bypass_document_validation], :options => options, - :id_generator => client.options[:id_generator] + :id_generator => client.options[:id_generator], + :session => session } end - def execute_operation(name, values, server, operation_id, combiner) + def execute_operation(name, values, server, operation_id, combiner, session) begin if values.size > server.max_write_batch_size - split_execute(name, values, server, operation_id, combiner) + split_execute(name, values, server, operation_id, combiner, session) else - combiner.combine!(send(name, values, server, operation_id), values.size) + combiner.combine!(send(name, values, server, operation_id, session), values.size) end rescue Error::MaxBSONSize, Error::MaxMessageSize => e raise e if values.size <= 1 - split_execute(name, values, server, operation_id, combiner) + split_execute(name, values, server, operation_id, combiner, session) end end @@ -164,29 +168,29 @@ def op_combiner @op_combiner ||= ordered? ? OrderedCombiner.new(requests) : UnorderedCombiner.new(requests) end - def split_execute(name, values, server, operation_id, combiner) - execute_operation(name, values.shift(values.size / 2), server, operation_id, combiner) - execute_operation(name, values, server, operation_id, combiner) + def split_execute(name, values, server, operation_id, combiner, session) + execute_operation(name, values.shift(values.size / 2), server, operation_id, combiner, session) + execute_operation(name, values, server, operation_id, combiner, session) end - def delete(documents, server, operation_id) + def delete(documents, server, operation_id, session) Operation::Write::Bulk::Delete.new( - base_spec(operation_id).merge(:deletes => documents) + base_spec(operation_id, session).merge(:deletes => documents) ).execute(server) end alias :delete_one :delete alias :delete_many :delete - def insert_one(documents, server, operation_id) + def insert_one(documents, server, operation_id, session) Operation::Write::Bulk::Insert.new( - base_spec(operation_id).merge(:documents => documents) + base_spec(operation_id, session).merge(:documents => documents) ).execute(server) end - def update(documents, server, operation_id) + def update(documents, server, operation_id, session) Operation::Write::Bulk::Update.new( - base_spec(operation_id).merge(:updates => documents) + base_spec(operation_id, session).merge(:updates => documents) ).execute(server) end diff --git a/lib/mongo/client.rb b/lib/mongo/client.rb index c439f646a1..90e313a511 100644 --- a/lib/mongo/client.rb +++ b/lib/mongo/client.rb @@ -94,6 +94,8 @@ class Client # Delegate subscription to monitoring. def_delegators :@monitoring, :subscribe, :unsubscribe + def_delegators :@cluster, :logical_session_timeout + # Determine if this client is equivalent to another object. # # @example Check client equality. @@ -238,14 +240,32 @@ def hash # @since 2.0.0 def initialize(addresses_or_uri, options = Options::Redacted.new) @monitoring = Monitoring.new(options) + Session::SessionPool.create(self) if addresses_or_uri.is_a?(::String) create_from_uri(addresses_or_uri, validate_options!(options)) else create_from_addresses(addresses_or_uri, validate_options!(options)) end + ObjectSpace.define_finalizer(self, self.class.finalize(@session_pool)) yield(self) if block_given? end + # Finalize the client for garbage collection. Ends all sessions in the session pool. + # + # @example Finalize the client. + # Client.finalize(session_pool) + # + # @param [ Session::SessionPool ] session_pool The session pool. + # + # @return [ Proc ] The Finalizer. + # + # @since 2.5.0 + def self.finalize(session_pool) + proc do + begin; session_pool.end_sessions; rescue; end + end + end + # Get an inspection of the client as a string. # # @example Inspect the client. @@ -316,6 +336,7 @@ def with(new_options = Options::Redacted.new) opts = validate_options!(new_options) client.options.update(opts) Database.create(client) + Session::SessionPool.create(client) # We can't use the same cluster if some options that would affect it # have changed. if cluster_modifying?(opts) @@ -385,8 +406,58 @@ def list_databases use(Database::ADMIN).command(listDatabases: 1).first[Database::DATABASES] end + # Start a session. + # + # @example Start a session. + # client.start_session + # + # @param [ Hash ] options The session options. + # + # @note A Session cannot be used by multiple threads at once; session objects are not + # thread-safe. + # + # @return [ Session ] The session. + # + # @since 2.5.0 + def start_session(options = {}) + if !sessions_supported? + raise Error::InvalidSession.new(Session::SESSIONS_NOT_SUPPORTED) + end + Session.new(@session_pool.checkout, self, options) + end + private + def get_session(options = {}) + if options[:session] + options[:session].validate!(self) + options[:session] + elsif sessions_supported? + Session.new(@session_pool.checkout, self, options.merge(implicit: true)) + end + end + + def with_session(options = {}) + if options[:session] + options[:session].validate!(self) + yield(options[:session]) + elsif sessions_supported? + @session_pool.with_session do |server_session| + yield(Session.new(server_session, self, options)) + end + else + yield + end + end + + def sessions_supported? + if cluster.servers.empty? + ServerSelector.get(mode: :primary_preferred).select_server(cluster) + end + !!logical_session_timeout + rescue Error::NoServerAvailable + end + def create_from_addresses(addresses, opts = Options::Redacted.new) @options = Database::DEFAULT_OPTIONS.merge(opts).freeze @cluster = Cluster.new(addresses, @monitoring, options) diff --git a/lib/mongo/cluster.rb b/lib/mongo/cluster.rb index 8b1bf8c0e7..7cbb91f380 100644 --- a/lib/mongo/cluster.rb +++ b/lib/mongo/cluster.rb @@ -48,6 +48,11 @@ class Cluster # @since 2.4.0 IDLE_WRITE_PERIOD_SECONDS = 10 + # The cluster time key in responses from mongos servers. + # + # @since 2.5.0 + CLUSTER_TIME = 'clusterTime'.freeze + # @return [ Hash ] The options hash. attr_reader :options @@ -63,6 +68,11 @@ class Cluster # @since 2.4.0 attr_reader :app_metadata + # @return [ BSON::Document ] The latest cluster time seen. + # + # @since 2.5.0 + attr_reader :cluster_time + def_delegators :topology, :replica_set?, :replica_set_name, :sharded?, :single?, :unknown?, :member_discovered def_delegators :@cursor_reaper, :register_cursor, :schedule_kill_cursor, :unregister_cursor @@ -158,6 +168,8 @@ def initialize(seeds, monitoring, options = Options::Redacted.new) @app_metadata = AppMetadata.new(self) @update_lock = Mutex.new @pool_lock = Mutex.new + @cluster_time = nil + @cluster_time_lock = Mutex.new @topology = Topology.initial(seeds, monitoring, options) publish_sdam_event( @@ -457,6 +469,28 @@ def logical_session_timeout end end + # Update the max cluster time seen in a response. + # + # @example Update the cluster time. + # cluster.update_cluster_time(result) + # + # @param [ Operation::Result ] The operation result containing the cluster time. + # + # @return [ Object ] The cluster time. + # + # @since 2.5.0 + def update_cluster_time(result) + if cluster_time_doc = result.cluster_time + @cluster_time_lock.synchronize do + if @cluster_time.nil? + @cluster_time = cluster_time_doc + else + @cluster_time = cluster_time_doc if cluster_time_doc[CLUSTER_TIME] > @cluster_time[CLUSTER_TIME] + end + end + end + end + private def direct_connection?(address) diff --git a/lib/mongo/cluster/cursor_reaper.rb b/lib/mongo/cluster/cursor_reaper.rb index 8c543d89f8..7f86dfa31f 100644 --- a/lib/mongo/cluster/cursor_reaper.rb +++ b/lib/mongo/cluster/cursor_reaper.rb @@ -22,7 +22,6 @@ class Cluster # # @since 2.3.0 class CursorReaper - extend Forwardable include Retryable # The default time interval for the cursor reaper to send pending kill cursors operations. diff --git a/lib/mongo/collection.rb b/lib/mongo/collection.rb index c4d39a65d3..98d7b23786 100644 --- a/lib/mongo/collection.rb +++ b/lib/mongo/collection.rb @@ -174,21 +174,28 @@ def capped? # @example Force the collection to be created. # collection.create # + # @param [ Hash ] options The options for the create operation. + # + # @option options [ Session ] :session The session to use for the operation. + # # @return [ Result ] The result of the command. # # @since 2.0.0 - def create + def create(opts = {}) operation = { :create => name }.merge(options) operation.delete(:write) server = next_primary if (options[:collation] || options[Operation::COLLATION]) && !server.features.collation_enabled? raise Error::UnsupportedCollation.new end - Operation::Commands::Create.new({ - selector: operation, - db_name: database.name, - write_concern: write_concern - }).execute(server) + client.send(:with_session, opts) do |session| + Operation::Commands::Create.new({ + selector: operation, + db_name: database.name, + write_concern: write_concern, + session: session + }).execute(server) + end end # Drop the collection. Will also drop all indexes associated with the @@ -199,16 +206,22 @@ def create # @example Drop the collection. # collection.drop # + # @param [ Hash ] options The options for the drop operation. + # + # @option options [ Session ] :session The session to use for the operation. + # # @return [ Result ] The result of the command. # # @since 2.0.0 - def drop - Operation::Commands::Drop.new({ - selector: { :drop => name }, - db_name: database.name, - write_concern: write_concern - }).execute(next_primary) - + def drop(opts = {}) + client.send(:with_session, opts) do |session| + Operation::Commands::Drop.new({ + selector: { :drop => name }, + db_name: database.name, + write_concern: write_concern, + session: session + }).execute(next_primary) + end rescue Error::OperationFailure => ex raise ex unless ex.message =~ /ns not found/ false @@ -247,6 +260,7 @@ def drop # @option options [ Hash ] :sort The key and direction pairs by which the result set # will be sorted. # @option options [ Hash ] :collation The collation to use. + # @option options [ Session ] :session The session to use. # # @return [ CollectionView ] The collection view. # @@ -273,6 +287,7 @@ def find(filter = nil, options = {}) # @option options [ true, false ] :bypass_document_validation Whether or # not to skip document level validation. # @option options [ Hash ] :collation The collation to use. + # @option options [ Session ] :session The session to use. # # @return [ Aggregation ] The aggregation object. # @@ -301,6 +316,7 @@ def aggregate(pipeline, options = {}) # on new documents to satisfy a change stream query. # @option options [ Integer ] :batch_size The number of documents to return per batch. # @option options [ BSON::Document, Hash ] :collation The collation to use. + # @option options [ Session ] :session The session to use. # # @note A change stream only allows 'majority' read concern. # @note This helper method is preferable to running a raw aggregation with a $changeStream stage, @@ -327,6 +343,7 @@ def watch(pipeline = [], options = {}) # @option options [ Integer ] :skip The number of documents to skip before counting. # @option options [ Hash ] :read The read preference options. # @option options [ Hash ] :collation The collation to use. + # @option options [ Session ] :session The session to use. # # @return [ Integer ] The document count. # @@ -347,6 +364,7 @@ def count(filter = nil, options = {}) # @option options [ Integer ] :max_time_ms The maximum amount of time to allow the command to run. # @option options [ Hash ] :read The read preference options. # @option options [ Hash ] :collation The collation to use. + # @option options [ Session ] :session The session to use. # # @return [ Array ] The list of distinct values. # @@ -363,6 +381,8 @@ def distinct(field_name, filter = nil, options = {}) # # @param [ Hash ] options Options for getting a list of all indexes. # + # @option options [ Session ] :session The session to use. + # # @return [ View::Index ] The index view. # # @since 2.0.0 @@ -390,20 +410,25 @@ def inspect # @param [ Hash ] document The document to insert. # @param [ Hash ] options The insert options. # + # @option options [ Session ] :session The session to use for the operation. + # # @return [ Result ] The database response wrapper. # # @since 2.0.0 def insert_one(document, options = {}) - write_with_retry do - Operation::Write::Insert.new( - :documents => [ document ], - :db_name => database.name, - :coll_name => name, - :write_concern => write_concern, - :bypass_document_validation => !!options[:bypass_document_validation], - :options => options, - :id_generator => client.options[:id_generator] - ).execute(next_primary) + client.send(:with_session, options) do |session| + write_with_retry(session, Proc.new { next_primary }) do |server| + Operation::Write::Insert.new( + :documents => [ document ], + :db_name => database.name, + :coll_name => name, + :write_concern => write_concern, + :bypass_document_validation => !!options[:bypass_document_validation], + :options => options, + :id_generator => client.options[:id_generator], + :session => session + ).execute(server) + end end end @@ -415,6 +440,8 @@ def insert_one(document, options = {}) # @param [ Array ] documents The documents to insert. # @param [ Hash ] options The insert options. # + # @option options [ Session ] :session The session to use for the operation. + # # @return [ Result ] The database response wrapper. # # @since 2.0.0 @@ -437,6 +464,7 @@ def insert_many(documents, options = {}) # Can be :w => Integer, :fsync => Boolean, :j => Boolean. # @option options [ true, false ] :bypass_document_validation Whether or # not to skip document level validation. + # @option options [ Session ] :session The session to use for the set of operations. # # @return [ BulkWrite::Result ] The result of the operation. # @@ -454,6 +482,7 @@ def bulk_write(requests, options = {}) # @param [ Hash ] options The options. # # @option options [ Hash ] :collation The collation to use. + # @option options [ Session ] :session The session to use. # # @return [ Result ] The response from the database. # @@ -471,6 +500,7 @@ def delete_one(filter = nil, options = {}) # @param [ Hash ] options The options. # # @option options [ Hash ] :collation The collation to use. + # @option options [ Session ] :session The session to use. # # @return [ Result ] The response from the database. # @@ -493,12 +523,13 @@ def delete_many(filter = nil, options = {}) # # @option options [ Integer ] :max_time_ms The maximum amount of time to allow the command # to run in milliseconds. + # @option options [ Session ] :session The session to use. # # @return [ Array ] An array of cursors. # # @since 2.1 def parallel_scan(cursor_count, options = {}) - find.send(:parallel_scan, cursor_count, options) + find({}, options).send(:parallel_scan, cursor_count, options) end # Replaces a single document in the collection with the new document. @@ -515,6 +546,7 @@ def parallel_scan(cursor_count, options = {}) # @option options [ true, false ] :bypass_document_validation Whether or # not to skip document level validation. # @option options [ Hash ] :collation The collation to use. + # @option options [ Session ] :session The session to use. # # @return [ Result ] The response from the database. # @@ -539,6 +571,7 @@ def replace_one(filter, replacement, options = {}) # @option options [ Hash ] :collation The collation to use. # @option options [ Array ] :array_filters A set of filters specifying to which array elements # an update should apply. + # @option options [ Session ] :session The session to use. # # @return [ Result ] The response from the database. # @@ -563,6 +596,7 @@ def update_many(filter, update, options = {}) # @option options [ Hash ] :collation The collation to use. # @option options [ Array ] :array_filters A set of filters specifying to which array elements # an update should apply. + # @option options [ Session ] :session The session to use. # # @return [ Result ] The response from the database. # @@ -588,12 +622,13 @@ def update_one(filter, update, options = {}) # @option options [ Hash ] :write_concern The write concern options. # Defaults to the collection's write concern. # @option options [ Hash ] :collation The collation to use. + # @option options [ Session ] :session The session to use. # # @return [ BSON::Document, nil ] The document, if found. # # @since 2.1.0 def find_one_and_delete(filter, options = {}) - find(filter, options).find_one_and_delete + find(filter, options).find_one_and_delete(options) end # Finds a single document via findAndModify and updates it, returning the original doc unless @@ -623,6 +658,7 @@ def find_one_and_delete(filter, options = {}) # @option options [ Hash ] :collation The collation to use. # @option options [ Array ] :array_filters A set of filters specifying to which array elements # an update should apply. + # @option options [ Session ] :session The session to use. # # @return [ BSON::Document ] The document. # @@ -656,6 +692,7 @@ def find_one_and_update(filter, update, options = {}) # @option options [ Hash ] :write_concern The write concern options. # Defaults to the collection's write concern. # @option options [ Hash ] :collation The collation to use. + # @option options [ Session ] :session The session to use. # # @return [ BSON::Document ] The document. # diff --git a/lib/mongo/collection/view.rb b/lib/mongo/collection/view.rb index 5ca7e31f13..b74cc9bb22 100644 --- a/lib/mongo/collection/view.rb +++ b/lib/mongo/collection/view.rb @@ -198,6 +198,12 @@ def validate_collation!(server, coll) end def view; self; end + + def with_session + client.send(:with_session, @options) do |session| + yield(session) + end + end end end end diff --git a/lib/mongo/collection/view/aggregation.rb b/lib/mongo/collection/view/aggregation.rb index ebcfbb7598..0ea6d6aade 100644 --- a/lib/mongo/collection/view/aggregation.rb +++ b/lib/mongo/collection/view/aggregation.rb @@ -37,7 +37,7 @@ class Aggregation def_delegators :view, :collection, :read, :cluster # Delegate necessary operations to the collection. - def_delegators :collection, :database + def_delegators :collection, :database, :client # The reroute message. # @@ -94,16 +94,16 @@ def server_selector @view.send(:server_selector) end - def aggregate_spec - Builder::Aggregation.new(pipeline, view, options).specification + def aggregate_spec(session) + Builder::Aggregation.new(pipeline, view, options.merge(session: session)).specification end def new(options) Aggregation.new(view, pipeline, options) end - def initial_query_op - Operation::Commands::Aggregate.new(aggregate_spec) + def initial_query_op(session) + Operation::Commands::Aggregate.new(aggregate_spec(session)) end def valid_server?(server) @@ -114,13 +114,13 @@ def secondary_ok? pipeline.none? { |op| op.key?('$out') || op.key?(:$out) } end - def send_initial_query(server) + def send_initial_query(server, session) unless valid_server?(server) log_warn(REROUTE) server = cluster.next_primary(false) end validate_collation!(server) - initial_query_op.execute(server) + initial_query_op(session).execute(server) end def validate_collation!(server) diff --git a/lib/mongo/collection/view/builder/aggregation.rb b/lib/mongo/collection/view/builder/aggregation.rb index c8a3928ef2..1a45237766 100644 --- a/lib/mongo/collection/view/builder/aggregation.rb +++ b/lib/mongo/collection/view/builder/aggregation.rb @@ -77,7 +77,8 @@ def specification spec = { selector: aggregation_command, db_name: database.name, - read: read + read: read, + session: @options[:session] } write? ? spec.merge!(write_concern: write_concern) : spec end diff --git a/lib/mongo/collection/view/builder/find_command.rb b/lib/mongo/collection/view/builder/find_command.rb index ebfdb3a3df..9ef90b64c0 100644 --- a/lib/mongo/collection/view/builder/find_command.rb +++ b/lib/mongo/collection/view/builder/find_command.rb @@ -72,10 +72,12 @@ def explain_specification # FindCommandBuilder.new(view) # # @param [ Collection::View ] view The collection view. + # @param [ Session ] session The session. # # @since 2.2.2 - def initialize(view) + def initialize(view, session) @view = view + @session = session end # Get the specification to pass to the find command operation. @@ -87,7 +89,7 @@ def initialize(view) # # @since 2.2.0 def specification - { selector: find_command, db_name: database.name, read: read } + { selector: find_command, db_name: database.name, read: read, session: @session } end private diff --git a/lib/mongo/collection/view/builder/map_reduce.rb b/lib/mongo/collection/view/builder/map_reduce.rb index 45fb956007..a7f307f49f 100644 --- a/lib/mongo/collection/view/builder/map_reduce.rb +++ b/lib/mongo/collection/view/builder/map_reduce.rb @@ -81,7 +81,8 @@ def command_specification { selector: find_command, db_name: query_database, - read: read + read: read, + session: options[:session] } end @@ -109,7 +110,8 @@ def specification spec = { selector: map_reduce_command, db_name: database.name, - read: read + read: read, + session: options[:session] } write?(spec) ? spec.merge!(write_concern: write_concern) : spec end @@ -138,7 +140,7 @@ def map_reduce_command :out => { inline: 1 } ) command[:readConcern] = collection.read_concern if collection.read_concern - command.merge!(view.options) + command.merge!(view_options) command.merge!(Options::Mapper.transform_documents(options, MAPPINGS)) command end @@ -152,6 +154,12 @@ def query_collection options[:out][OUT_ACTIONS.find { |action| options[:out][action] }] end || options[:out] end + + def view_options + @view_options ||= (opts = view.options.dup + opts.delete(:session) + opts) + end end end end diff --git a/lib/mongo/collection/view/change_stream.rb b/lib/mongo/collection/view/change_stream.rb index 1f6c8d879b..81e786d3c4 100644 --- a/lib/mongo/collection/view/change_stream.rb +++ b/lib/mongo/collection/view/change_stream.rb @@ -139,9 +139,10 @@ def cache_resume_token(doc) end def create_cursor! + session = client.send(:get_session, @options) server = server_selector.select_server(cluster, false) - result = send_initial_query(server) - @cursor = Cursor.new(view, result, server, disable_retry: true) + result = send_initial_query(server, session) + @cursor = Cursor.new(view, result, server, disable_retry: true, session: session) end def pipeline @@ -150,8 +151,8 @@ def pipeline [{ '$changeStream' => change_doc }] + @change_stream_filters end - def send_initial_query(server) - initial_query_op.execute(server) + def send_initial_query(server, session) + initial_query_op(session).execute(server) end end end diff --git a/lib/mongo/collection/view/iterable.rb b/lib/mongo/collection/view/iterable.rb index 0a3d5df3c8..7faafe879e 100644 --- a/lib/mongo/collection/view/iterable.rb +++ b/lib/mongo/collection/view/iterable.rb @@ -36,10 +36,11 @@ module Iterable # @yieldparam [ Hash ] Each matching document. def each @cursor = nil + session = client.send(:get_session, @options) read_with_retry do server = server_selector.select_server(cluster, false) - result = send_initial_query(server) - @cursor = Cursor.new(view, result, server) + result = send_initial_query(server, session) + @cursor = Cursor.new(view, result, server, session: session) end @cursor.each do |doc| yield doc @@ -60,25 +61,25 @@ def close_query private - def initial_query_op(server) + def initial_query_op(server, session) if server.features.find_command_enabled? - initial_command_op + initial_command_op(session) else Operation::Read::Query.new(Builder::OpQuery.new(self).specification) end end - def initial_command_op + def initial_command_op(session) if explained? - Operation::Commands::Explain.new(Builder::FindCommand.new(self).explain_specification) + Operation::Commands::Explain.new(Builder::FindCommand.new(self, session).explain_specification) else - Operation::Commands::Find.new(Builder::FindCommand.new(self).specification) + Operation::Commands::Find.new(Builder::FindCommand.new(self, session).specification) end end - def send_initial_query(server) + def send_initial_query(server, session = nil) validate_collation!(server, collation) - initial_query_op(server).execute(server) + initial_query_op(server, session).execute(server) end end end diff --git a/lib/mongo/collection/view/map_reduce.rb b/lib/mongo/collection/view/map_reduce.rb index 79bac090e2..9f0c93169e 100644 --- a/lib/mongo/collection/view/map_reduce.rb +++ b/lib/mongo/collection/view/map_reduce.rb @@ -50,7 +50,7 @@ class MapReduce def_delegators :view, :collection, :read, :cluster # Delegate necessary operations to the collection. - def_delegators :collection, :database + def_delegators :collection, :database, :client # Iterate through documents returned by the map/reduce. # @@ -66,10 +66,10 @@ class MapReduce # @yieldparam [ Hash ] Each matching document. def each @cursor = nil - write_with_retry do - server = server_selector.select_server(cluster, false) - result = send_initial_query(server) - @cursor = Cursor.new(view, result, server) + session = client.send(:get_session, view.options) + write_with_retry(session, Proc.new { server_selector.select_server(cluster, false) }) do |server| + result = send_initial_query(server, session) + @cursor = Cursor.new(view, result, server, session: session) end @cursor.each do |doc| yield doc @@ -190,16 +190,16 @@ def inline? out.nil? || out == { inline: 1 } || out == { INLINE => 1 } end - def map_reduce_spec - Builder::MapReduce.new(map, reduce, view, options).specification + def map_reduce_spec(session) + Builder::MapReduce.new(map, reduce, view, options.merge(session: session)).specification end def new(options) MapReduce.new(view, map, reduce, options) end - def initial_query_op - Operation::Commands::MapReduce.new(map_reduce_spec) + def initial_query_op(session) + Operation::Commands::MapReduce.new(map_reduce_spec(session)) end def valid_server?(server) @@ -210,34 +210,34 @@ def secondary_ok? out.respond_to?(:keys) && out.keys.first.to_s.downcase == INLINE end - def send_initial_query(server) + def send_initial_query(server, session) unless valid_server?(server) log_warn(REROUTE) server = cluster.next_primary(false) end validate_collation!(server) - result = initial_query_op.execute(server) - inline? ? result : send_fetch_query(server) + result = initial_query_op(session).execute(server) + inline? ? result : send_fetch_query(server, session) end def fetch_query_spec Builder::MapReduce.new(map, reduce, view, options).query_specification end - def find_command_spec - Builder::MapReduce.new(map, reduce, view, options).command_specification + def find_command_spec(session) + Builder::MapReduce.new(map, reduce, view, options.merge(session: session)).command_specification end - def fetch_query_op(server) + def fetch_query_op(server, session) if server.features.find_command_enabled? - Operation::Commands::Find.new(find_command_spec) + Operation::Commands::Find.new(find_command_spec(session)) else Operation::Read::Query.new(fetch_query_spec) end end - def send_fetch_query(server) - fetch_query_op(server).execute(server) + def send_fetch_query(server, session) + fetch_query_op(server, session).execute(server) end def validate_collation!(server) diff --git a/lib/mongo/collection/view/readable.rb b/lib/mongo/collection/view/readable.rb index 96d8735614..ecf0c979ab 100644 --- a/lib/mongo/collection/view/readable.rb +++ b/lib/mongo/collection/view/readable.rb @@ -138,16 +138,19 @@ def count(opts = {}) read_with_retry do server = selector.select_server(cluster, false) apply_collation!(cmd, server, opts) - Operation::Commands::Command.new({ - :selector => cmd, - :db_name => database.name, - :options => { :limit => -1 }, - :read => read_pref, - }).execute(server).n.to_i - + with_session do |session| + Operation::Commands::Command.new({ + :selector => cmd, + :db_name => database.name, + :options => {:limit => -1}, + :read => read_pref, + :session => session + }).execute(server) + end.n.to_i end end + # Get a list of distinct values for a specific field. # # @example Get the distinct values. @@ -175,13 +178,15 @@ def distinct(field_name, opts = {}) read_with_retry do server = selector.select_server(cluster, false) apply_collation!(cmd, server, opts) - Operation::Commands::Command.new({ - :selector => cmd, - :db_name => database.name, - :options => { :limit => -1 }, - :read => read_pref, - }).execute(server).first['values'] - + with_session do |session| + Operation::Commands::Command.new({ + :selector => cmd, + :db_name => database.name, + :options => {:limit => -1}, + :read => read_pref, + :session => session + }).execute(server) + end.first['values'] end end @@ -467,28 +472,32 @@ def server_selector end def parallel_scan(cursor_count, options = {}) + session = client.send(:get_session, @options) server = server_selector.select_server(cluster, false) cmd = Operation::Commands::ParallelScan.new({ :coll_name => collection.name, :db_name => database.name, :cursor_count => cursor_count, - :read_concern => collection.read_concern + :read_concern => collection.read_concern, + :session => session }.merge!(options)) cmd.execute(server).cursor_ids.map do |cursor_id| result = if server.features.find_command_enabled? - Operation::Commands::GetMore.new({ - :selector => { :getMore => cursor_id, :collection => collection.name }, - :db_name => database.name - }).execute(server) - else - Operation::Read::GetMore.new({ - :to_return => 0, - :cursor_id => cursor_id, - :db_name => database.name, - :coll_name => collection.name - }).execute(server) + Operation::Commands::GetMore.new({ + :selector => {:getMore => cursor_id, + :collection => collection.name}, + :db_name => database.name, + :session => session + }).execute(server) + else + Operation::Read::GetMore.new({ + :to_return => 0, + :cursor_id => cursor_id, + :db_name => database.name, + :coll_name => collection.name + }).execute(server) end - Cursor.new(self, result, server) + Cursor.new(self, result, server, session: session) end end diff --git a/lib/mongo/collection/view/writable.rb b/lib/mongo/collection/view/writable.rb index 2768721f38..e1e00a203f 100644 --- a/lib/mongo/collection/view/writable.rb +++ b/lib/mongo/collection/view/writable.rb @@ -46,16 +46,16 @@ def find_one_and_delete(opts = {}) cmd[:maxTimeMS] = max_time_ms if max_time_ms cmd[:writeConcern] = write_concern.options if write_concern - - write_with_retry do - server = next_primary - apply_collation!(cmd, server, opts) - - Operation::Commands::Command.new({ - :selector => cmd, - :db_name => database.name - }).execute(server).first['value'] - end + with_session do |session| + write_with_retry(session, Proc.new { next_primary }) do |server| + apply_collation!(cmd, server, opts) + Operation::Commands::Command.new({ + :selector => cmd, + :db_name => database.name, + :session => session + }).execute(server) + end + end.first['value'] end # Finds a single document and replaces it. @@ -116,16 +116,17 @@ def find_one_and_update(document, opts = {}) cmd[:bypassDocumentValidation] = !!opts[:bypass_document_validation] cmd[:writeConcern] = write_concern.options if write_concern - value = write_with_retry do - server = next_primary - apply_collation!(cmd, server, opts) - apply_array_filters!(cmd, server, opts) - - Operation::Commands::Command.new({ - :selector => cmd, - :db_name => database.name - }).execute(server).first['value'] - end + value = with_session do |session| + write_with_retry(session, Proc.new { next_primary }) do |server| + apply_collation!(cmd, server, opts) + apply_array_filters!(cmd, server, opts) + Operation::Commands::Command.new( + :selector => cmd, + :db_name => database.name, + :session => session + ).execute(server) + end + end.first['value'] value unless value.nil? || value.empty? end @@ -226,16 +227,18 @@ def update_one(spec, opts = {}) def remove(value, opts = {}) delete_doc = { Operation::Q => filter, Operation::LIMIT => value } - write_with_retry do - server = next_primary - apply_collation!(delete_doc, server, opts) - Operation::Write::Delete.new( - :delete => delete_doc, - :db_name => collection.database.name, - :coll_name => collection.name, - :write_concern => collection.write_concern - ).execute(server) + with_session do |session| + write_with_retry(session, Proc.new { next_primary }) do |server| + apply_collation!(delete_doc, server, opts) + Operation::Write::Delete.new( + :delete => delete_doc, + :db_name => collection.database.name, + :coll_name => collection.name, + :write_concern => collection.write_concern, + :session => session + ).execute(server) + end end end @@ -244,18 +247,20 @@ def update(spec, multi, opts) Operation::U => spec, Operation::MULTI => multi, Operation::UPSERT => !!opts[:upsert] } - write_with_retry do - server = next_primary - apply_collation!(update_doc, server, opts) - apply_array_filters!(update_doc, server, opts) - Operation::Write::Update.new( - :update => update_doc, - :db_name => collection.database.name, - :coll_name => collection.name, - :write_concern => collection.write_concern, - :bypass_document_validation => !!opts[:bypass_document_validation] - ).execute(server) + with_session do |session| + write_with_retry(session, Proc.new { next_primary }) do |server| + apply_collation!(update_doc, server, opts) + apply_array_filters!(update_doc, server, opts) + Operation::Write::Update.new( + :update => update_doc, + :db_name => collection.database.name, + :coll_name => collection.name, + :write_concern => collection.write_concern, + :bypass_document_validation => !!opts[:bypass_document_validation], + :session => session + ).execute(server) + end end end diff --git a/lib/mongo/cursor.rb b/lib/mongo/cursor.rb index ffe8e2854f..605560e4d7 100644 --- a/lib/mongo/cursor.rb +++ b/lib/mongo/cursor.rb @@ -64,6 +64,7 @@ def initialize(view, result, server, options = {}) @cursor_id = result.cursor_id @coll_name = nil @options = options + @session = @options[:session] register ObjectSpace.define_finalizer(self, self.class.finalize(result.cursor_id, cluster, @@ -201,7 +202,7 @@ def get_more def get_more_operation if @server.features.find_command_enabled? - Operation::Commands::GetMore.new(Builder::GetMoreCommand.new(self).specification) + Operation::Commands::GetMore.new(Builder::GetMoreCommand.new(self, @session).specification) else Operation::Read::GetMore.new(Builder::OpGetMore.new(self).specification) end @@ -213,9 +214,14 @@ def kill_cursors kill_cursors_operation.execute(@server) end ensure + end_session @cursor_id = 0 end + def end_session + @session.end_implicit_session if @session + end + def kill_cursors_operation if @server.features.find_command_enabled? Operation::Commands::Command.new(kill_cursors_op_spec) diff --git a/lib/mongo/cursor/builder/get_more_command.rb b/lib/mongo/cursor/builder/get_more_command.rb index 82b9932721..956f771d4b 100644 --- a/lib/mongo/cursor/builder/get_more_command.rb +++ b/lib/mongo/cursor/builder/get_more_command.rb @@ -34,10 +34,12 @@ class GetMoreCommand # GetMoreCommand.new(cursor) # # @param [ Cursor ] cursor The cursor. + # @param [ Session ] session The session. # # @since 2.2.0 - def initialize(cursor) + def initialize(cursor, session = nil) @cursor = cursor + @session = session end # Get the specification. @@ -49,7 +51,7 @@ def initialize(cursor) # # @since 2.2.0 def specification - { selector: get_more_command, db_name: database.name } + { selector: get_more_command, db_name: database.name, session: @session } end private diff --git a/lib/mongo/database.rb b/lib/mongo/database.rb index fcd1d87186..8bd915338b 100644 --- a/lib/mongo/database.rb +++ b/lib/mongo/database.rb @@ -155,11 +155,14 @@ def collections def command(operation, opts = {}) preference = ServerSelector.get(opts[:read] || ServerSelector::PRIMARY) server = preference.select_server(cluster) - Operation::Commands::Command.new({ - :selector => operation.dup, - :db_name => name, - :read => preference - }).execute(server) + client.send(:with_session, opts) do |session| + Operation::Commands::Command.new({ + :selector => operation.dup, + :db_name => name, + :read => preference, + :session => session + }).execute(server) + end end # Drop the database and all its associated information. @@ -167,16 +170,23 @@ def command(operation, opts = {}) # @example Drop the database. # database.drop # + # @param [ Hash ] options The options for the operation. + # + # @option options [ Session ] :session The session to use for the operation. + # # @return [ Result ] The result of the command. # # @since 2.0.0 - def drop + def drop(options = {}) operation = { :dropDatabase => 1 } - Operation::Commands::DropDatabase.new({ - selector: operation, - db_name: name, - write_concern: write_concern - }).execute(next_primary) + client.send(:with_session, options) do |session| + Operation::Commands::DropDatabase.new({ + selector: operation, + db_name: name, + write_concern: write_concern, + session: session + }).execute(next_primary) + end end # Instantiate a new database object. diff --git a/lib/mongo/database/view.rb b/lib/mongo/database/view.rb index fba62ac8f5..ea41ed7ed5 100644 --- a/lib/mongo/database/view.rb +++ b/lib/mongo/database/view.rb @@ -22,7 +22,7 @@ class View extend Forwardable include Enumerable - def_delegators :@database, :cluster, :read_preference + def_delegators :@database, :cluster, :read_preference, :client def_delegators :cluster, :next_primary # @return [ Integer ] batch_size The size of the batch of results @@ -52,7 +52,8 @@ def collection_names(options = {}) @batch_size = options[:batch_size] server = next_primary(false) @limit = -1 if server.features.list_collections_enabled? - collections_info(server).collect do |info| + session = client.send(:get_session, options) + collections_info(server, session).collect do |info| if server.features.list_collections_enabled? info[Database::NAME] else @@ -71,7 +72,8 @@ def collection_names(options = {}) # # @since 2.0.5 def list_collections - collections_info(next_primary(false)) + session = client.send(:get_session) + collections_info(next_primary(false), session) end # Create the new database view. @@ -91,27 +93,29 @@ def initialize(database) private - def collections_info(server, &block) - cursor = Cursor.new(self, send_initial_query(server), server).to_enum + def collections_info(server, session, &block) + cursor = Cursor.new(self, send_initial_query(server, session), server, session: session) cursor.each do |doc| yield doc end if block_given? - cursor + cursor.to_enum end - def collections_info_spec + def collections_info_spec(session) { selector: { listCollections: 1, cursor: batch_size ? { batchSize: batch_size } : {} }, - db_name: @database.name } + db_name: @database.name, + session: session + } end - def initial_query_op - Operation::Commands::CollectionsInfo.new(collections_info_spec) + def initial_query_op(session) + Operation::Commands::CollectionsInfo.new(collections_info_spec(session)) end - def send_initial_query(server) - initial_query_op.execute(server) + def send_initial_query(server, session) + initial_query_op(session).execute(server) end end end diff --git a/lib/mongo/error.rb b/lib/mongo/error.rb index 3ae90d3c19..2d2b0576ab 100644 --- a/lib/mongo/error.rb +++ b/lib/mongo/error.rb @@ -88,6 +88,7 @@ class Error < StandardError require 'mongo/error/invalid_nonce' require 'mongo/error/invalid_replacement_document' require 'mongo/error/invalid_server_preference' +require 'mongo/error/invalid_session' require 'mongo/error/invalid_signature' require 'mongo/error/invalid_update_document' require 'mongo/error/invalid_uri' diff --git a/lib/mongo/error/invalid_session.rb b/lib/mongo/error/invalid_session.rb new file mode 100644 index 0000000000..505273d74a --- /dev/null +++ b/lib/mongo/error/invalid_session.rb @@ -0,0 +1,36 @@ +# Copyright (C) 2017 MongoDB, Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +module Mongo + class Error + + # This exception is raised when a session is attempted to be used and it is invalid. + # + # @since 2.5.0 + class InvalidSession < Error + + # Create the new exception. + # + # @example Create the new exception. + # InvalidSession.new(message) + # + # @param [ String ] message The error message. + # + # @since 2.5.0 + def initialize(message) + super(message) + end + end + end +end diff --git a/lib/mongo/error/operation_failure.rb b/lib/mongo/error/operation_failure.rb index 135861e95a..44a03d3bbb 100644 --- a/lib/mongo/error/operation_failure.rb +++ b/lib/mongo/error/operation_failure.rb @@ -19,6 +19,7 @@ class Error # # @since 2.0.0 class OperationFailure < Error + extend Forwardable # These are magic error messages that could indicate a master change. # @@ -49,6 +50,8 @@ class OperationFailure < Error 'dbclient error communicating with server' ].freeze + def_delegators :@result, :operation_time + # Can the read operation that caused the error be retried? # # @example Is the error retryable? @@ -72,6 +75,20 @@ def retryable? def write_retryable? WRITE_RETRY_MESSAGES.any? { |m| message.include?(m) } end + + # Create the operation failure. + # + # @example Create the error object + # OperationFailure.new(message, result) + # + # param [ String ] message The error message. + # param [ Operation::Result ] result The result object. + # + # @since 2.5.0 + def initialize(message = nil, result = nil) + @result = result + super(message) + end end end end diff --git a/lib/mongo/grid/fs_bucket.rb b/lib/mongo/grid/fs_bucket.rb index 7f99cc9cc9..76ed9c76ad 100644 --- a/lib/mongo/grid/fs_bucket.rb +++ b/lib/mongo/grid/fs_bucket.rb @@ -195,8 +195,8 @@ def delete_one(file) # # @since 2.1.0 def delete(id) - result = files_collection.find(:_id => id).delete_one - chunks_collection.find(:files_id => id).delete_many + result = files_collection.find({ :_id => id }, @options).delete_one + chunks_collection.find({ :files_id => id }, @options).delete_many raise Error::FileNotFound.new(id, :id) if result.n == 0 result end diff --git a/lib/mongo/grid/stream/write.rb b/lib/mongo/grid/stream/write.rb index 889bcf0e62..714a7f678b 100644 --- a/lib/mongo/grid/stream/write.rb +++ b/lib/mongo/grid/stream/write.rb @@ -103,7 +103,7 @@ def write(io) def close ensure_open! update_length - files_collection.insert_one(file_info) + files_collection.insert_one(file_info, @options) @open = false file_id end @@ -142,7 +142,7 @@ def closed? # # @since 2.1.0 def abort - fs.chunks_collection.find(:files_id => file_id).delete_many + fs.chunks_collection.find({ :files_id => file_id }, @options).delete_many @open = false || true end diff --git a/lib/mongo/index/view.rb b/lib/mongo/index/view.rb index f8236186c5..5c4731f8ad 100644 --- a/lib/mongo/index/view.rb +++ b/lib/mongo/index/view.rb @@ -29,7 +29,7 @@ class View # when sending the listIndexes command. attr_reader :batch_size - def_delegators :@collection, :cluster, :database, :read_preference, :write_concern + def_delegators :@collection, :cluster, :database, :read_preference, :write_concern, :client def_delegators :cluster, :next_primary # The index key field. @@ -149,14 +149,16 @@ def create_one(keys, options = {}) # @since 2.0.0 def create_many(*models) server = next_primary - spec = { - indexes: normalize_models(models.flatten, server), - db_name: database.name, - coll_name: collection.name - } - - spec[:write_concern] = write_concern if server.features.collation_enabled? - Operation::Write::CreateIndex.new(spec).execute(server) + client.send(:with_session, @options) do |session| + spec = { + indexes: normalize_models(models.flatten, server), + db_name: database.name, + coll_name: collection.name, + session: session + } + spec[:write_concern] = write_concern if server.features.collation_enabled? + Operation::Write::CreateIndex.new(spec).execute(server) + end end # Convenience method for getting index information by a specific name or @@ -189,11 +191,13 @@ def get(keys_or_name) # @since 2.0.0 def each(&block) server = next_primary(false) - cursor = Cursor.new(self, send_initial_query(server), server).to_enum + session = client.send(:get_session, @options) + result = send_initial_query(server, session) + cursor = Cursor.new(self, result, server, session: session) cursor.each do |doc| yield doc end if block_given? - cursor + cursor.to_enum end # Create the new index view. @@ -213,35 +217,41 @@ def each(&block) def initialize(collection, options = {}) @collection = collection @batch_size = options[:batch_size] + @options = options end private def drop_by_name(name) - spec = { - db_name: database.name, - coll_name: collection.name, - index_name: name - } - server = next_primary - spec[:write_concern] = write_concern if server.features.collation_enabled? - Operation::Write::DropIndex.new(spec).execute(server) + client.send(:with_session, @options) do |session| + spec = { + db_name: database.name, + coll_name: collection.name, + index_name: name, + session: session + } + server = next_primary + spec[:write_concern] = write_concern if server.features.collation_enabled? + Operation::Write::DropIndex.new(spec).execute(server) + end end def index_name(spec) spec.to_a.join('_') end - def indexes_spec + def indexes_spec(session) { selector: { listIndexes: collection.name, cursor: batch_size ? { batchSize: batch_size } : {} }, coll_name: collection.name, - db_name: database.name } + db_name: database.name, + session: session + } end - def initial_query_op - Operation::Commands::Indexes.new(indexes_spec) + def initial_query_op(session) + Operation::Commands::Indexes.new(indexes_spec(session)) end def limit; -1; end @@ -257,8 +267,8 @@ def normalize_models(models, server) end end - def send_initial_query(server) - initial_query_op.execute(server) + def send_initial_query(server, session) + initial_query_op(session).execute(server) end def with_generated_names(models, server) diff --git a/lib/mongo/operation.rb b/lib/mongo/operation.rb index 8aa906e9be..574e5b4c04 100644 --- a/lib/mongo/operation.rb +++ b/lib/mongo/operation.rb @@ -62,5 +62,15 @@ module Operation # # @since 2.5.0 ARRAY_FILTERS = 'arrayFilters'.freeze + + # The operation time field constant. + # + # @since 2.5.0 + OPERATION_TIME = 'operationTime'.freeze + + # The cluster time field constant. + # + # @since 2.5.0 + CLUSTER_TIME = '$clusterTime'.freeze end end diff --git a/lib/mongo/operation/commands/list_collections/result.rb b/lib/mongo/operation/commands/list_collections/result.rb index 5744349f53..879bc2b194 100644 --- a/lib/mongo/operation/commands/list_collections/result.rb +++ b/lib/mongo/operation/commands/list_collections/result.rb @@ -75,7 +75,7 @@ def documents # # @since 2.0.0 def validate! - !successful? ? raise(Error::OperationFailure.new(parser.message)) : self + !successful? ? raise(Error::OperationFailure.new(parser.message, self)) : self end private diff --git a/lib/mongo/operation/commands/list_indexes/result.rb b/lib/mongo/operation/commands/list_indexes/result.rb index 54f1924aaa..bafee30d28 100644 --- a/lib/mongo/operation/commands/list_indexes/result.rb +++ b/lib/mongo/operation/commands/list_indexes/result.rb @@ -79,7 +79,7 @@ def documents # # @since 2.0.0 def validate! - !successful? ? raise(Error::OperationFailure.new(parser.message)) : self + !successful? ? raise(Error::OperationFailure.new(parser.message, self)) : self end private diff --git a/lib/mongo/operation/commands/map_reduce/result.rb b/lib/mongo/operation/commands/map_reduce/result.rb index 04ab099ef8..07b81c2eaf 100644 --- a/lib/mongo/operation/commands/map_reduce/result.rb +++ b/lib/mongo/operation/commands/map_reduce/result.rb @@ -104,7 +104,7 @@ def time # # @since 2.0.0 def validate! - documents.nil? ? raise(Error::OperationFailure.new(parser.message)) : self + documents.nil? ? raise(Error::OperationFailure.new(parser.message, self)) : self end # Get the cursor id. diff --git a/lib/mongo/operation/executable.rb b/lib/mongo/operation/executable.rb index cefc467d86..44d77879df 100644 --- a/lib/mongo/operation/executable.rb +++ b/lib/mongo/operation/executable.rb @@ -34,7 +34,10 @@ module Executable def execute(server) server.with_connection do |connection| result_class = self.class.const_defined?(:Result, false) ? self.class::Result : Result - result_class.new(connection.dispatch([ message(server) ], operation_id)).validate! + result = result_class.new(connection.dispatch([ message(server) ], operation_id)) + server.update_cluster_time(result) + session.process(result) if session + result.validate! end end end diff --git a/lib/mongo/operation/result.rb b/lib/mongo/operation/result.rb index 88dfb74a87..7dd55d9e5f 100644 --- a/lib/mongo/operation/result.rb +++ b/lib/mongo/operation/result.rb @@ -253,7 +253,7 @@ def ok? # # @since 2.0.0 def validate! - !successful? ? raise(Error::OperationFailure.new(parser.message)) : self + !successful? ? raise(Error::OperationFailure.new(parser.message, self)) : self end # Get the number of documents written by the server. @@ -273,6 +273,30 @@ def written_count end alias :n :written_count + # Get the operation time reported in the server response. + # + # @example Get the operation time. + # result.operation_time + # + # @return [ Object ] The operation time value. + # + # @since 2.5.0 + def operation_time + first_document && first_document[OPERATION_TIME] + end + + # Get the cluster time reported in the server response. + # + # @example Get the cluster time. + # result.cluster_time + # + # @return [ BSON::Document ] The cluster time document. + # + # @since 2.5.0 + def cluster_time + first_document && first_document[CLUSTER_TIME] + end + private def aggregate_returned_count diff --git a/lib/mongo/operation/specifiable.rb b/lib/mongo/operation/specifiable.rb index 8fbc8d93c0..7af6077f77 100644 --- a/lib/mongo/operation/specifiable.rb +++ b/lib/mongo/operation/specifiable.rb @@ -502,6 +502,18 @@ def ordered? def namespace "#{db_name}.#{coll_name}" end + + # The session to use for the operation. + # + # @example Get the session. + # specifiable.session + # + # @return [ Session ] The session. + # + # @since 2.5.0 + def session + @spec[:session] + end end end end diff --git a/lib/mongo/operation/uses_command_op_msg.rb b/lib/mongo/operation/uses_command_op_msg.rb index af446a8552..c876be1493 100644 --- a/lib/mongo/operation/uses_command_op_msg.rb +++ b/lib/mongo/operation/uses_command_op_msg.rb @@ -22,13 +22,12 @@ module UsesCommandOpMsg private - CLUSTER_TIME = '$clusterTime'.freeze - READ_PREFERENCE = '$readPreference'.freeze - def cluster_time(server) - # @todo update when merged with sessions work - #server.mongos? && server.cluster_time + def add_cluster_time!(selector, server) + if cluster_time = server.mongos? && server.cluster_time + selector[CLUSTER_TIME] = cluster_time + end end def unacknowledged_write? @@ -36,11 +35,10 @@ def unacknowledged_write? end def command_op_msg(server, selector, options) - if (cl_time = cluster_time(server)) - selector[CLUSTER_TIME] = cl_time - end + add_cluster_time!(selector, server) selector[Protocol::Msg::DATABASE_IDENTIFIER] = db_name selector[READ_PREFERENCE] = read.to_doc if read + session.add_id!(selector) if session flags = unacknowledged_write? ? [:more_to_come] : [:none] Protocol::Msg.new(flags, options, selector) end diff --git a/lib/mongo/operation/write/bulk/bulkable.rb b/lib/mongo/operation/write/bulk/bulkable.rb index e1f6bbc9e6..f8ae5facee 100644 --- a/lib/mongo/operation/write/bulk/bulkable.rb +++ b/lib/mongo/operation/write/bulk/bulkable.rb @@ -36,7 +36,10 @@ module Bulkable # @since 2.0.0 def execute(server) if server.features.write_command_enabled? - execute_write_command(server) + result = execute_write_command(server) + server.update_cluster_time(result) + session.process(result) if session + result else execute_message(server) end diff --git a/lib/mongo/operation/write/command/delete.rb b/lib/mongo/operation/write/command/delete.rb index 16ca4b280a..66bb4e5b35 100644 --- a/lib/mongo/operation/write/command/delete.rb +++ b/lib/mongo/operation/write/command/delete.rb @@ -48,20 +48,12 @@ def selector }.merge(command_options) end - def command_options - opts = { ordered: ordered? } - opts[:writeConcern] = write_concern.options if write_concern - opts[:collation] = collation if collation - opts - end - def op_msg(server) global_args = { delete: coll_name, Protocol::Msg::DATABASE_IDENTIFIER => db_name }.merge!(command_options) - if (cl_time = cluster_time(server)) - global_args[CLUSTER_TIME] = cl_time - end + add_cluster_time!(global_args, server) + session.add_id!(global_args) if session section = { type: 1, payload: { identifier: IDENTIFIER, sequence: deletes } } flags = unacknowledged_write? ? [:more_to_come] : [:none] diff --git a/lib/mongo/operation/write/command/insert.rb b/lib/mongo/operation/write/command/insert.rb index d2b7ff24a9..f084e114ac 100644 --- a/lib/mongo/operation/write/command/insert.rb +++ b/lib/mongo/operation/write/command/insert.rb @@ -42,20 +42,12 @@ def selector }.merge!(command_options) end - def command_options - opts = { ordered: ordered? } - opts[:writeConcern] = write_concern.options if write_concern - opts[:bypassDocumentValidation] = true if bypass_document_validation - opts - end - def op_msg(server) global_args = { insert: coll_name, Protocol::Msg::DATABASE_IDENTIFIER => db_name }.merge!(command_options) - if (cl_time = cluster_time(server)) - global_args[CLUSTER_TIME] = cl_time - end + add_cluster_time!(global_args, server) + session.add_id!(global_args) if session section = { type: 1, payload: { identifier: IDENTIFIER, sequence: documents } } flags = unacknowledged_write? ? [:more_to_come] : [:none] diff --git a/lib/mongo/operation/write/command/update.rb b/lib/mongo/operation/write/command/update.rb index 6535d57b92..bee3ed7868 100644 --- a/lib/mongo/operation/write/command/update.rb +++ b/lib/mongo/operation/write/command/update.rb @@ -51,21 +51,12 @@ def selector }.merge(command_options) end - def command_options - opts = { ordered: ordered? } - opts[:writeConcern] = write_concern.options if write_concern - opts[:bypassDocumentValidation] = true if bypass_document_validation - opts[:collation] = collation if collation - opts - end - def op_msg(server) global_args = { update: coll_name, Protocol::Msg::DATABASE_IDENTIFIER => db_name }.merge!(command_options) - if (cl_time = cluster_time(server)) - global_args[CLUSTER_TIME] = cl_time - end + add_cluster_time!(global_args, server) + session.add_id!(global_args) if session section = { type: 1, payload: { identifier: IDENTIFIER, sequence: updates } } flags = unacknowledged_write? ? [:more_to_come] : [:none] diff --git a/lib/mongo/operation/write/command/writable.rb b/lib/mongo/operation/write/command/writable.rb index 3e6df9bb9e..d97d864df9 100644 --- a/lib/mongo/operation/write/command/writable.rb +++ b/lib/mongo/operation/write/command/writable.rb @@ -43,6 +43,14 @@ def execute(server) private + def command_options + opts = { ordered: ordered? } + opts[:writeConcern] = write_concern.options if write_concern + opts[:collation] = collation if collation + opts[:bypassDocumentValidation] = true if bypass_document_validation + opts + end + # The wire protocol message for this write operation. # # @return [ Mongo::Protocol::Query ] Wire protocol message. diff --git a/lib/mongo/operation/write/insert.rb b/lib/mongo/operation/write/insert.rb index f17920658f..30f1267979 100644 --- a/lib/mongo/operation/write/insert.rb +++ b/lib/mongo/operation/write/insert.rb @@ -52,7 +52,10 @@ class Insert def execute_write_command(server) command_spec = spec.merge(:documents => ensure_ids(documents)) - Result.new(Command::Insert.new(command_spec).execute(server), @ids).validate! + result = Result.new(Command::Insert.new(command_spec).execute(server), @ids) + server.update_cluster_time(result) + session.process(result) if session + result.validate! end def execute_message(server) diff --git a/lib/mongo/operation/write/write_command_enabled.rb b/lib/mongo/operation/write/write_command_enabled.rb index 98fec2662c..d2ebfdb12e 100644 --- a/lib/mongo/operation/write/write_command_enabled.rb +++ b/lib/mongo/operation/write/write_command_enabled.rb @@ -68,7 +68,10 @@ def unacknowledged_write? def execute_write_command(server) result_class = self.class.const_defined?(:Result, false) ? self.class::Result : Result - result_class.new(write_command_op.execute(server)).validate! + result = result_class.new(write_command_op.execute(server)) + server.update_cluster_time(result) + session.process(result) if session + result.validate! end end end diff --git a/lib/mongo/protocol/msg.rb b/lib/mongo/protocol/msg.rb index 03a39605ed..19cba00714 100644 --- a/lib/mongo/protocol/msg.rb +++ b/lib/mongo/protocol/msg.rb @@ -142,7 +142,7 @@ def add_check_sum(buffer) end def global_args - @global_args ||= sections[0] + @global_args ||= (sections[0] || {}) end # The operation code required to specify a OP_MSG message. diff --git a/lib/mongo/retryable.rb b/lib/mongo/retryable.rb index 92c822b5e6..28105aaca7 100644 --- a/lib/mongo/retryable.rb +++ b/lib/mongo/retryable.rb @@ -33,6 +33,8 @@ module Retryable # @param [ Integer ] attempt The retry attempt count - for internal use. # @param [ Proc ] block The block to execute. # + # @yieldparam [ Server ] server The server to which the write should be sent. + # # @return [ Result ] The result of the operation. # # @since 2.1.0 @@ -97,11 +99,11 @@ def read_with_one_retry # @return [ Result ] The result of the operation. # # @since 2.1.0 - def write_with_retry + def write_with_retry(session, server_selector) attempt = 0 begin attempt += 1 - yield + yield(server_selector.call) rescue Error::OperationFailure => e raise(e) if attempt > Cluster::MAX_WRITE_RETRIES if e.write_retryable? diff --git a/lib/mongo/server.rb b/lib/mongo/server.rb index f517d57b08..337120dea9 100644 --- a/lib/mongo/server.rb +++ b/lib/mongo/server.rb @@ -77,7 +77,9 @@ class Server # Get the app metadata from the cluster. def_delegators :cluster, - :app_metadata + :app_metadata, + :cluster_time, + :update_cluster_time # Is this server equal to another? # diff --git a/lib/mongo/server/connection.rb b/lib/mongo/server/connection.rb index 87d511ef92..19144f208d 100644 --- a/lib/mongo/server/connection.rb +++ b/lib/mongo/server/connection.rb @@ -60,7 +60,9 @@ class Connection :max_message_size, :mongos?, :app_metadata, - :compressor + :compressor, + :cluster_time, + :update_cluster_time # Tell the underlying socket to establish a connection to the host. # diff --git a/lib/mongo/server/description/features.rb b/lib/mongo/server/description/features.rb index 7a4b51f037..a6c66f6ec1 100644 --- a/lib/mongo/server/description/features.rb +++ b/lib/mongo/server/description/features.rb @@ -27,6 +27,7 @@ class Features MAPPINGS = { :array_filters => 6, :op_msg => 6, + :sessions => 6, :collation => 5, :max_staleness => 5, :find_command => 4, diff --git a/lib/mongo/session.rb b/lib/mongo/session.rb new file mode 100644 index 0000000000..802f5fd4dd --- /dev/null +++ b/lib/mongo/session.rb @@ -0,0 +1,180 @@ +# Copyright (C) 2017 MongoDB, Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +require 'mongo/session/session_pool' +require 'mongo/session/server_session' + +module Mongo + + # A logical client session representing a set of sequential operations executed + # by an application that are related in some way. + # + # @since 2.5.0 + class Session + extend Forwardable + + # Get the options for this session. + # + # @since 2.5.0 + attr_reader :options + + # Get the client through which this session was created. + # + # @since 2.5.0 + attr_reader :client + + def_delegators :@server_session, :session_id + + # Error message describing that the session was attempted to be used by a client different from the + # one it was originally associated with. + # + # @since 2.5.0 + MISTMATCHED_CLUSTER_ERROR_MSG = 'The client used to create this session does not match that of client ' + + 'initiating this operation. Please only use this session for operations through its parent client.'.freeze + + # Error message describing that the session cannot be used because it has already been ended. + # + # @since 2.5.0 + SESSION_ENDED_ERROR_MSG = 'This session has ended and cannot be used. Please create a new one.'.freeze + + # Error message describing that sessions are not supported by the server version. + # + # @since 2.5.0 + SESSIONS_NOT_SUPPORTED = 'Sessions are not supported by the connected servers.'.freeze + + # Initialize a Session. + # + # @example + # Session.new(server_session, client, options) + # + # @param [ ServerSession ] server_session The server session this client session is associated with. + # @param [ Client ] client The client through which this session is created. + # @param [ Hash ] options The options for this session. + # + # @since 2.5.0 + def initialize(server_session, client, options = {}) + @server_session = server_session + @client = client + @options = options + end + + # End this session. + # + # @example + # session.end_session + # + # @return [ nil ] Always nil. + # + # @since 2.5.0 + def end_session + if !ended? && @client + @client.instance_variable_get(:@session_pool).checkin(@server_session) + nil + end + ensure + @server_session = nil + end + + # End this session if it's an implicit session. + # + # @example + # session.end_implicit_session + # + # @return [ nil ] Always nil. + # + # @since 2.5.0 + def end_implicit_session + end_session if implicit_session? + end + + # Whether this session has ended. + # + # @example + # session.ended? + # + # @return [ true, false ] Whether the session has ended. + # + # @since 2.5.0 + def ended? + @server_session.nil? + end + + # Add this session's id to a command document. + # + # @example + # session.add_id!(cmd) + # + # @return [ Hash, BSON::Document ] The command document. + # + # @since 2.5.0 + def add_id!(command) + command.merge!(lsid: session_id) + end + + # Validate the session. + # + # @example + # session.validate!(client) + # + # @param [ Client ] client The client the session is attempted to be used with. + # + # @return [ nil ] nil if the session is valid. + # + # @raise [ Mongo::Error::InvalidSession ] Raise error if the session is not valid. + # + # @since 2.5.0 + def validate!(client) + check_matching_client!(client) + check_if_ended! + end + + # Process a response from the server that used this session. + # + # @example Process a response from the server. + # session.process(result) + # + # @param [ Operation::Result ] The result from the operation. + # + # @return [ Operation::Result ] The result. + # + # @since 2.5.0 + def process(result) + set_operation_time(result) + @server_session.set_last_use! + result + end + + private + + def implicit_session? + @implicit_session ||= !!(@options.key?(:implicit) && @options[:implicit] == true) + end + + def set_operation_time(result) + if result && result.operation_time + @operation_time = result.operation_time + end + end + + def check_if_ended! + raise Mongo::Error::InvalidSession.new(SESSION_ENDED_ERROR_MSG) if ended? + end + + def check_matching_client!(client) + if @client != client + raise Mongo::Error::InvalidSession.new(MISTMATCHED_CLUSTER_ERROR_MSG) + end + end + end +end diff --git a/lib/mongo/session/server_session.rb b/lib/mongo/session/server_session.rb new file mode 100644 index 0000000000..447e24ce66 --- /dev/null +++ b/lib/mongo/session/server_session.rb @@ -0,0 +1,73 @@ +# Copyright (C) 2017 MongoDB, Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +module Mongo + + class Session + + # An object representing the server-side session. + # + # @api private + # + # @since 2.5.0 + class ServerSession + + # Regex for removing dashes from the UUID string. + # + # @since 2.5.0 + DASH_REGEX = /\-/.freeze + + # Pack directive for the UUID. + # + # @since 2.5.0 + UUID_PACK = 'H*'.freeze + + # The last time the server session was used. + # + # @since 2.5.0 + attr_reader :last_use + + # Initialize a ServerSession. + # + # @example + # ServerSession.new + # + # @since 2.5.0 + def initialize + set_last_use! + end + + # Update the last_use attribute of the server session to now. + # + # @example Set the last use field to now. + # server_session.set_last_use! + # + # @since 2.5.0 + def set_last_use! + @last_use = Time.now + end + + # The session id of this server session. + # + # @example Get the session id. + # server_session.session_id + # + # @since 2.5.0 + def session_id + @session_id ||= (bytes = [SecureRandom.uuid.gsub(DASH_REGEX, '')].pack(UUID_PACK) + BSON::Document.new(id: BSON::Binary.new(bytes, :uuid))) + end + end + end +end diff --git a/lib/mongo/session/session_pool.rb b/lib/mongo/session/session_pool.rb new file mode 100644 index 0000000000..31667fc801 --- /dev/null +++ b/lib/mongo/session/session_pool.rb @@ -0,0 +1,161 @@ +# Copyright (C) 2017 MongoDB, Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +module Mongo + + class Session + + # A pool of server sessions. + # + # @api private + # + # @since 2.5.0 + class SessionPool + + # The command sent to the server to end a session. + # + # @since 2.5.0 + END_SESSION = { :endSessions => 1 }.freeze + + # Create a SessionPool. + # + # @example + # SessionPool.create(client) + # + # @param [ Mongo::Client ] client The client that will be associated with this + # session pool. + # + # @since 2.5.0 + def self.create(client) + pool = new(client) + client.instance_variable_set(:@session_pool, pool) + end + + # Initialize a SessionPool. + # + # @example + # SessionPool.new(client) + # + # @param [ Mongo::Client ] client The client that will be associated with this + # session pool. + # + # @since 2.5.0 + def initialize(client) + @queue = [] + @mutex = Mutex.new + @client = client + end + + # Checkout a session to be used in the context of a block and return the session back to + # the pool after the block completes. + # + # @example Checkout, use a session, and return it back to the pool after the block. + # pool.with_session do |session| + # ... + # end + # + # @yieldparam [ ServerSession ] The server session. + # + # @since 2.5.0 + def with_session + server_session = checkout + result = yield(server_session) + result + ensure + begin; checkin(server_session) if server_session; rescue; end + end + + # Checkout a server session from the pool. + # + # @example Checkout a session. + # pool.checkout + # + # @return [ ServerSession ] The server session. + # + # @since 2.5.0 + def checkout + @mutex.synchronize do + loop do + if @queue.empty? + return ServerSession.new + else + session = @queue.shift + unless about_to_expire?(session) + return session + end + end + end + end + end + + # Checkin a server session to the pool. + # + # @example Checkin a session. + # pool.checkin(session) + # + # @param [ Session::ServerSession ] The session to checkin. + # + # @since 2.5.0 + def checkin(session) + @mutex.synchronize do + prune! + unless about_to_expire?(session) + @queue.unshift(session) + end + end + end + + # End all sessions in the pool by sending the endSessions command to the server. + # + # @example End all sessions. + # pool.end_sessions + # + # @since 2.5.0 + def end_sessions + if @client + ids = @queue.collect { |s| s.session_id } + + while !ids.empty? + begin + Operation::Commands::Command.new({ + :selector => END_SESSION.merge(ids: ids.shift(10_000)), + :db_name => Database::ADMIN + }).execute(@client.cluster.next_primary) + rescue + end + end + end + end + + private + + def about_to_expire?(session) + if @client.logical_session_timeout + idle_time_minutes = (Time.now - session.last_use) / 60 + (idle_time_minutes + 1) >= @client.logical_session_timeout + end + end + + def prune! + while !@queue.empty? + if about_to_expire?(@queue[-1]) + @queue.pop + else + break + end + end + end + end + end +end diff --git a/spec/mongo/auth/cr_spec.rb b/spec/mongo/auth/cr_spec.rb index 5a7701c942..4b08ca68ad 100644 --- a/spec/mongo/auth/cr_spec.rb +++ b/spec/mongo/auth/cr_spec.rb @@ -18,6 +18,8 @@ double('cluster').tap do |cl| allow(cl).to receive(:topology).and_return(topology) allow(cl).to receive(:app_metadata).and_return(app_metadata) + allow(cl).to receive(:cluster_time).and_return(nil) + allow(cl).to receive(:update_cluster_time) end end diff --git a/spec/mongo/auth/ldap_spec.rb b/spec/mongo/auth/ldap_spec.rb index 885fcfa424..eb154df935 100644 --- a/spec/mongo/auth/ldap_spec.rb +++ b/spec/mongo/auth/ldap_spec.rb @@ -18,6 +18,8 @@ double('cluster').tap do |cl| allow(cl).to receive(:topology).and_return(topology) allow(cl).to receive(:app_metadata).and_return(app_metadata) + allow(cl).to receive(:cluster_time).and_return(nil) + allow(cl).to receive(:update_cluster_time) end end diff --git a/spec/mongo/auth/scram_spec.rb b/spec/mongo/auth/scram_spec.rb index 17c66f22b4..5c3d8dc172 100644 --- a/spec/mongo/auth/scram_spec.rb +++ b/spec/mongo/auth/scram_spec.rb @@ -18,6 +18,8 @@ double('cluster').tap do |cl| allow(cl).to receive(:topology).and_return(topology) allow(cl).to receive(:app_metadata).and_return(app_metadata) + allow(cl).to receive(:cluster_time).and_return(nil) + allow(cl).to receive(:update_cluster_time) end end diff --git a/spec/mongo/auth/user/view_spec.rb b/spec/mongo/auth/user/view_spec.rb index 87167a4263..577883ce27 100644 --- a/spec/mongo/auth/user/view_spec.rb +++ b/spec/mongo/auth/user/view_spec.rb @@ -6,42 +6,67 @@ described_class.new(root_authorized_client.database) end + after do + begin; view.remove('durran'); rescue; end + end + describe '#create' do - let!(:response) do - view.create( - 'durran', - password: 'password', roles: [ Mongo::Auth::Roles::READ_WRITE ] - ) - end + context 'when a session is not used' do - after do - view.remove('durran') - end + let!(:response) do + view.create( + 'durran', + password: 'password', roles: [Mongo::Auth::Roles::READ_WRITE] + ) + end + + context 'when user creation was successful' do - context 'when user creation was successful' do + it 'saves the user in the database' do + expect(response).to be_successful + end - it 'saves the user in the database' do - expect(response).to be_successful + context 'when compression is used', if: testing_compression? do + + it 'does not compress the message' do + # The dropUser command message will be compressed, so expect instantiation once. + expect(Mongo::Protocol::Compressed).to receive(:new).once.and_call_original + expect(response).to be_successful + end + end end - context 'when compression is used', if: testing_compression? do + context 'when creation was not successful' do - it 'does not compress the message' do - # The dropUser command message will be compressed, so expect instantiation once. - expect(Mongo::Protocol::Compressed).to receive(:new).once.and_call_original - expect(response).to be_successful + it 'raises an exception' do + expect { + view.create('durran', password: 'password') + }.to raise_error(Mongo::Error::OperationFailure) end end end - context 'when creation was not successful' do + context 'when a session is used' do + + let(:operation) do + view.create( + 'durran', + password: 'password', + roles: [Mongo::Auth::Roles::READ_WRITE], + session: session + ) + end + + let(:session) do + client.start_session + end - it 'raises an exception' do - expect { - view.create('durran', password: 'password') - }.to raise_error(Mongo::Error::OperationFailure) + let(:client) do + root_authorized_client end + + it_behaves_like 'an operation using a session' end end @@ -50,132 +75,272 @@ before do view.create( 'durran', - password: 'password', roles: [ Mongo::Auth::Roles::READ_WRITE ] + password: 'password', roles: [Mongo::Auth::Roles::READ_WRITE] ) end - after do - view.remove('durran') - end - context 'when a user password is updated' do - let!(:response) do - view.update( - 'durran', - password: '123', roles: [ Mongo::Auth::Roles::READ_WRITE ] - ) - end + context 'when a session is not used' do - it 'updates the password' do - expect(response).to be_successful + let!(:response) do + view.update( + 'durran', + password: '123', roles: [ Mongo::Auth::Roles::READ_WRITE ] + ) + end + + it 'updates the password' do + expect(response).to be_successful + end + + context 'when compression is used', if: testing_compression? do + + it 'does not compress the message' do + # The dropUser command message will be compressed, so expect instantiation once. + expect(Mongo::Protocol::Compressed).to receive(:new).once.and_call_original + expect(response).to be_successful + end + end end - context 'when compression is used', if: testing_compression? do + context 'when a session is used' do - it 'does not compress the message' do - # The dropUser command message will be compressed, so expect instantiation once. - expect(Mongo::Protocol::Compressed).to receive(:new).once.and_call_original - expect(response).to be_successful + let(:operation) do + view.update( + 'durran', + password: '123', + roles: [ Mongo::Auth::Roles::READ_WRITE ], + session: session + ) end + + let(:session) do + client.start_session + end + + let(:client) do + root_authorized_client + end + + it_behaves_like 'an operation using a session' end end context 'when the roles of a user are updated' do - let!(:response) do - view.update( - 'durran', - password: 'password', roles: [ Mongo::Auth::Roles::READ ] - ) - end + context 'when a session is not used' do + + let!(:response) do + view.update( + 'durran', + password: 'password', roles: [ Mongo::Auth::Roles::READ ] + ) + end + + it 'updates the roles' do + expect(response).to be_successful + end + + context 'when compression is used', if: testing_compression? do - it 'updates the roles' do - expect(response).to be_successful + it 'does not compress the message' do + # The dropUser command message will be compressed, so expect instantiation once. + expect(Mongo::Protocol::Compressed).to receive(:new).once.and_call_original + expect(response).to be_successful + end + end end - context 'when compression is used', if: testing_compression? do + context 'when a session is used' do - it 'does not compress the message' do - # The dropUser command message will be compressed, so expect instantiation once. - expect(Mongo::Protocol::Compressed).to receive(:new).once.and_call_original - expect(response).to be_successful + let(:operation) do + view.update( + 'durran', + password: 'password', + roles: [ Mongo::Auth::Roles::READ ], + session: session + ) + end + + let(:session) do + client.start_session end + + let(:client) do + root_authorized_client + end + + it_behaves_like 'an operation using a session' end end end describe '#remove' do - context 'when user removal was successful' do + context 'when a session is not used' do - before do - view.create( - 'durran', - password: 'password', roles: [ Mongo::Auth::Roles::READ_WRITE ] - ) - end + context 'when user removal was successful' do + + before do + view.create( + 'durran', + password: 'password', roles: [ Mongo::Auth::Roles::READ_WRITE ] + ) + end + + let(:response) do + view.remove('durran') + end - let(:response) do - view.remove('durran') + it 'saves the user in the database' do + expect(response).to be_successful + end end - it 'saves the user in the database' do - expect(response).to be_successful + context 'when removal was not successful' do + + it 'raises an exception', if: write_command_enabled? do + expect { + view.remove('notauser') + }.to raise_error(Mongo::Error::OperationFailure) + end + + it 'does not raise an exception', unless: write_command_enabled? do + expect(view.remove('notauser').written_count).to eq(0) + end end end - context 'when removal was not successful' do + context 'when a session is used' do + + context 'when user removal was successful' do - it 'raises an exception', if: write_command_enabled? do - expect { - view.remove('notauser') - }.to raise_error(Mongo::Error::OperationFailure) + before do + view.create( + 'durran', + password: 'password', roles: [ Mongo::Auth::Roles::READ_WRITE ] + ) + end + + let(:operation) do + view.remove('durran', session: session) + end + + let(:session) do + client.start_session + end + + let(:client) do + root_authorized_client + end + + it_behaves_like 'an operation using a session' end - it 'does not raise an exception', unless: write_command_enabled? do - expect(view.remove('notauser').written_count).to eq(0) + context 'when removal was not successful' do + + let(:failed_operation) do + view.remove('notauser', session: session) + end + + let(:session) do + client.start_session + end + + let(:client) do + root_authorized_client + end + + it_behaves_like 'a failed operation using a session' end end end describe '#info' do - context 'when a user exists in the database' do + context 'when a session is not used' do - before do - view.create( - 'emily', - password: 'password' - ) - end + context 'when a user exists in the database' do + + before do + view.create( + 'emily', + password: 'password' + ) + end + + after do + view.remove('emily') + end - after do - view.remove('emily') + it 'returns information for that user' do + expect(view.info('emily')).to_not be_empty + end end - it 'returns information for that user' do - expect(view.info('emily')).to_not be_empty + context 'when a user does not exist in the database' do + + it 'returns nil' do + expect(view.info('emily')).to be_empty + end end - end - context 'when a user does not exist in the database' do + context 'when a user is not authorized' do - it 'returns nil' do - expect(view.info('emily')).to be_empty + let(:view) do + described_class.new(unauthorized_client.database) + end + + it 'raises an OperationFailure', if: auth_enabled? do + expect{ + view.info('emily') + }.to raise_exception(Mongo::Error::OperationFailure) + end end end - context 'when a user is not authorized' do + context 'when a session is used' do + + context 'when a user exists in the database' do - let(:view) do - described_class.new(unauthorized_client.database) + before do + view.create( + 'durran', + password: 'password' + ) + end + + let(:operation) do + view.info('durran', session: session) + end + + let(:session) do + client.start_session + end + + let(:client) do + root_authorized_client + end + + it_behaves_like 'an operation using a session' end - it 'raises an OperationFailure', if: auth_enabled? do - expect{ - view.info('emily') - }.to raise_exception(Mongo::Error::OperationFailure) + context 'when a user does not exist in the database' do + + let(:operation) do + view.info('emily', session: session) + end + + let(:session) do + client.start_session + end + + let(:client) do + root_authorized_client + end + + it_behaves_like 'an operation using a session' end end end diff --git a/spec/mongo/auth/x509_spec.rb b/spec/mongo/auth/x509_spec.rb index 1852da0c36..a7aafc76e6 100644 --- a/spec/mongo/auth/x509_spec.rb +++ b/spec/mongo/auth/x509_spec.rb @@ -18,6 +18,8 @@ double('cluster').tap do |cl| allow(cl).to receive(:topology).and_return(topology) allow(cl).to receive(:app_metadata).and_return(app_metadata) + allow(cl).to receive(:cluster_time).and_return(nil) + allow(cl).to receive(:update_cluster_time) end end diff --git a/spec/mongo/bulk_write_spec.rb b/spec/mongo/bulk_write_spec.rb index 0f26bb2841..dfcdfd4bcf 100644 --- a/spec/mongo/bulk_write_spec.rb +++ b/spec/mongo/bulk_write_spec.rb @@ -32,7 +32,6 @@ end describe '#execute' do - shared_examples_for 'an executable bulk write' do context 'when providing a bad operation' do @@ -78,6 +77,23 @@ it 'sets the document index on the error' do expect(error.result[Mongo::Error::WRITE_ERRORS].first['index']).to eq(2) end + + context 'when a session is provided' do + + let(:extra_options) do + { session: session } + end + + let(:client) do + authorized_client + end + + let(:failed_operation) do + bulk_write.execute + end + + it_behaves_like 'a failed operation using a session' + end end context 'when provided a single insert one' do @@ -100,6 +116,23 @@ expect(authorized_collection.find.first['_id']).to eq(0) end + context 'when a session is provided' do + + let(:operation) do + result + end + + let(:extra_options) do + { session: session } + end + + let(:client) do + authorized_client + end + + it_behaves_like 'an operation using a session' + end + context 'when there is a write concern error' do context 'when the server version is < 2.6' do @@ -118,6 +151,23 @@ bulk_write_invalid_write_concern.execute }.to raise_error(Mongo::Error::OperationFailure) end + + context 'when a session is provided' do + + let(:extra_options) do + { session: session } + end + + let(:client) do + collection_invalid_write_concern.client + end + + let(:failed_operation) do + bulk_write_invalid_write_concern.execute + end + + it_behaves_like 'a failed operation using a session' + end end end end @@ -172,6 +222,23 @@ bulk_write_invalid_write_concern.execute }.to raise_error(Mongo::Error::OperationFailure) end + + context 'when a session is provided' do + + let(:extra_options) do + { session: session } + end + + let(:client) do + collection_invalid_write_concern.client + end + + let(:failed_operation) do + bulk_write_invalid_write_concern.execute + end + + it_behaves_like 'a failed operation using a session' + end end end end @@ -195,6 +262,23 @@ expect(authorized_collection.find(_id: 0).count).to eq(0) end + context 'when a session is provided' do + + let(:operation) do + result + end + + let(:client) do + authorized_client + end + + let(:extra_options) do + { session: session } + end + + it_behaves_like 'an operation using a session' + end + context 'when there is a write concern error' do context 'when the server version is < 2.6' do @@ -213,6 +297,23 @@ bulk_write_invalid_write_concern.execute }.to raise_error(Mongo::Error::OperationFailure) end + + context 'when a session is provided' do + + let(:extra_options) do + { session: session } + end + + let(:client) do + collection_invalid_write_concern.client + end + + let(:failed_operation) do + bulk_write_invalid_write_concern.execute + end + + it_behaves_like 'a failed operation using a session' + end end context 'when the write has a collation specified' do @@ -438,6 +539,23 @@ expect(authorized_collection.find(_id: { '$in'=> [ 0, 1, 2 ]}).count).to eq(0) end + context 'when a session is provided' do + + let(:operation) do + result + end + + let(:client) do + authorized_client + end + + let(:extra_options) do + { session: session } + end + + it_behaves_like 'an operation using a session' + end + context 'when there is a write concern error' do context 'when the server version is < 2.6' do @@ -456,6 +574,23 @@ bulk_write_invalid_write_concern.execute }.to raise_error(Mongo::Error::OperationFailure) end + + context 'when a session is provided' do + + let(:extra_options) do + { session: session } + end + + let(:client) do + collection_invalid_write_concern.client + end + + let(:failed_operation) do + bulk_write_invalid_write_concern.execute + end + + it_behaves_like 'a failed operation using a session' + end end end @@ -555,6 +690,23 @@ expect(authorized_collection.find(_id: 0).count).to eq(0) end + context 'when a session is provided' do + + let(:operation) do + result + end + + let(:client) do + authorized_client + end + + let(:extra_options) do + { session: session } + end + + it_behaves_like 'an operation using a session' + end + context 'when there is a write concern error' do context 'when the server version is < 2.6' do @@ -573,6 +725,23 @@ bulk_write_invalid_write_concern.execute }.to raise_error(Mongo::Error::OperationFailure) end + + context 'when a session is provided' do + + let(:extra_options) do + { session: session } + end + + let(:client) do + collection_invalid_write_concern.client + end + + let(:failed_operation) do + bulk_write_invalid_write_concern.execute + end + + it_behaves_like 'a failed operation using a session' + end end end @@ -675,6 +844,23 @@ expect(authorized_collection.find(_id: { '$in'=> [ 0, 1, 2 ]}).count).to eq(0) end + context 'when a session is provided' do + + let(:operation) do + result + end + + let(:client) do + authorized_client + end + + let(:extra_options) do + { session: session } + end + + it_behaves_like 'an operation using a session' + end + context 'when there is a write concern error' do context 'when the server version is < 2.6' do @@ -693,6 +879,23 @@ bulk_write_invalid_write_concern.execute }.to raise_error(Mongo::Error::OperationFailure) end + + context 'when a session is provided' do + + let(:operation) do + result + end + + let(:client) do + authorized_client + end + + let(:extra_options) do + { session: session } + end + + it_behaves_like 'an operation using a session' + end end end @@ -799,6 +1002,23 @@ expect(authorized_collection.find(_id: 0).first[:name]).to eq('test') end + context 'when a session is provided' do + + let(:operation) do + result + end + + let(:client) do + authorized_client + end + + let(:extra_options) do + { session: session } + end + + it_behaves_like 'an operation using a session' + end + context 'when there is a write concern error' do context 'when the server version is < 2.6' do @@ -817,6 +1037,23 @@ bulk_write_invalid_write_concern.execute }.to raise_error(Mongo::Error::OperationFailure) end + + context 'when a session is provided' do + + let(:extra_options) do + { session: session } + end + + let(:client) do + collection_invalid_write_concern.client + end + + let(:failed_operation) do + bulk_write_invalid_write_concern.execute + end + + it_behaves_like 'a failed operation using a session' + end end end @@ -969,6 +1206,23 @@ expect(result.matched_count).to eq(1) end + context 'when a session is provided' do + + let(:operation) do + result + end + + let(:client) do + authorized_client + end + + let(:extra_options) do + { session: session } + end + + it_behaves_like 'an operation using a session' + end + context 'when documents match but are not modified' do before do @@ -1032,6 +1286,23 @@ bulk_write_invalid_write_concern.execute }.to raise_error(Mongo::Error::OperationFailure) end + + context 'when a session is provided' do + + let(:extra_options) do + { session: session } + end + + let(:client) do + collection_invalid_write_concern.client + end + + let(:failed_operation) do + bulk_write_invalid_write_concern.execute + end + + it_behaves_like 'a failed operation using a session' + end end end end @@ -1810,6 +2081,23 @@ it 'combines the inserted ids' do expect(result.inserted_ids.size).to eq(1001) end + + context 'when a session is provided' do + + let(:operation) do + result + end + + let(:client) do + authorized_client + end + + let(:extra_options) do + { session: session } + end + + it_behaves_like 'an operation using a session' + end end end @@ -1828,17 +2116,42 @@ it 'inserts the documents' do expect(result.inserted_count).to eq(5) end + + context 'when a session is provided' do + + let(:operation) do + result + end + + let(:client) do + authorized_client + end + + let(:extra_options) do + { session: session } + end + + it_behaves_like 'an operation using a session' + end end end context 'when the bulk write is unordered' do let(:bulk_write) do - described_class.new(authorized_collection, requests, ordered: false) + described_class.new(authorized_collection, requests, options) + end + + let(:options) do + { ordered: false }.merge(extra_options) + end + + let(:extra_options) do + {} end let(:bulk_write_invalid_write_concern) do - described_class.new(collection_invalid_write_concern, requests, ordered: false) + described_class.new(collection_invalid_write_concern, requests, options) end it_behaves_like 'an executable bulk write' @@ -1847,11 +2160,19 @@ context 'when the bulk write is ordered' do let(:bulk_write) do - described_class.new(authorized_collection, requests, ordered: true) + described_class.new(authorized_collection, requests, options) + end + + let(:options) do + { ordered: true }.merge(extra_options) + end + + let(:extra_options) do + {} end let(:bulk_write_invalid_write_concern) do - described_class.new(collection_invalid_write_concern, requests, ordered: true) + described_class.new(collection_invalid_write_concern, requests, options) end it_behaves_like 'an executable bulk write' diff --git a/spec/mongo/client_spec.rb b/spec/mongo/client_spec.rb index 491f7872a9..caef18edeb 100644 --- a/spec/mongo/client_spec.rb +++ b/spec/mongo/client_spec.rb @@ -221,7 +221,7 @@ context 'when compressors are provided' do let(:client) do - described_class.new([default_address.seed], TEST_OPTIONS.merge(options)) + described_class.new([default_address.seed], authorized_client.options.merge(options)) end after do @@ -1352,4 +1352,102 @@ expect(authorized_client.collections).to all(be_a(Mongo::Collection)) end end + + describe '#start_session' do + + let(:session) do + authorized_client.start_session + end + + context 'when sessions are supported', if: sessions_enabled? do + + it 'creates a session' do + expect(session).to be_a(Mongo::Session) + end + + it 'sets the last use field to the current time' do + expect(session.instance_variable_get(:@server_session).last_use).to be_within(0.2).of(Time.now) + end + + context 'when options are provided' do + + let(:options) do + { causally_consistent: true } + end + + let(:session) do + authorized_client.start_session(options) + end + + it 'sets the options on the session' do + expect(session.options).to eq(options) + end + end + + context 'when options are not provided' do + + it 'does not set options on the session' do + expect(session.options).to be_empty + end + end + + context 'when a session is checked out and checked back in' do + + let!(:session_a) do + authorized_client.start_session + end + + let!(:session_b) do + authorized_client.start_session + end + + let!(:session_a_server_session) do + session_a.instance_variable_get(:@server_session) + end + + let!(:session_b_server_session) do + session_b.instance_variable_get(:@server_session) + end + + before do + session_a.end_session + session_b.end_session + end + + it 'is returned to the front of the queue' do + expect(authorized_client.start_session.instance_variable_get(:@server_session)).to be(session_b_server_session) + expect(authorized_client.start_session.instance_variable_get(:@server_session)).to be(session_a_server_session) + end + end + + context 'when an implicit session is used' do + + before do + authorized_client.database.command(ping: 1) + end + + let(:pool) do + authorized_client.instance_variable_get(:@session_pool) + end + + let!(:before_last_use) do + pool.instance_variable_get(:@queue)[0].last_use + end + + it 'uses the session and updates the last use time' do + authorized_client.database.command(ping: 1) + expect(before_last_use).to be < (pool.instance_variable_get(:@queue)[0].last_use) + end + end + end + + context 'when sessions are not supported', unless: sessions_enabled? do + + it 'raises an exception' do + expect { + session + }.to raise_exception(Mongo::Error::InvalidSession) + end + end + end end diff --git a/spec/mongo/cluster_spec.rb b/spec/mongo/cluster_spec.rb index 18bbe37adb..c5ec69e141 100644 --- a/spec/mongo/cluster_spec.rb +++ b/spec/mongo/cluster_spec.rb @@ -573,4 +573,117 @@ end end end + + describe '#cluster_time' do + + let(:operation) do + client.command(ping: 1) + end + + let(:second_operation) do + client.command(ping: 1) + end + + it_behaves_like 'an operation updating cluster time' + end + + describe '#update_cluster_time' do + + let(:cluster) do + described_class.new(ADDRESSES, monitoring, TEST_OPTIONS.merge(heartbeat_frequency: 1000)) + end + + let(:result) do + double('result', cluster_time: cluster_time_doc) + end + + context 'when the cluster_time variable is nil' do + + before do + cluster.instance_variable_set(:@cluster_time, nil) + cluster.update_cluster_time(result) + end + + context 'when the cluster time received is nil' do + + let(:cluster_time_doc) do + nil + end + + it 'does not set the cluster_time variable' do + expect(cluster.cluster_time).to be_nil + end + end + + context 'when the cluster time received is not nil' do + + let(:cluster_time_doc) do + BSON::Document.new(Mongo::Cluster::CLUSTER_TIME => BSON::Timestamp.new(1, 1)) + end + + it 'sets the cluster_time variable to the cluster time doc' do + expect(cluster.cluster_time).to eq(cluster_time_doc) + end + end + end + + context 'when the cluster_time variable has a value' do + + before do + cluster.instance_variable_set(:@cluster_time, BSON::Document.new( + Mongo::Cluster::CLUSTER_TIME => BSON::Timestamp.new(1, 1))) + cluster.update_cluster_time(result) + end + + context 'when the cluster time received is nil' do + + let(:cluster_time_doc) do + nil + end + + it 'does not update the cluster_time variable' do + expect(cluster.cluster_time).to eq(BSON::Document.new( + Mongo::Cluster::CLUSTER_TIME => BSON::Timestamp.new(1, 1))) + end + end + + context 'when the cluster time received is not nil' do + + context 'when the cluster time received is greater than the cluster_time variable' do + + let(:cluster_time_doc) do + BSON::Document.new(Mongo::Cluster::CLUSTER_TIME => BSON::Timestamp.new(1, 2)) + end + + it 'sets the cluster_time variable to the cluster time' do + expect(cluster.cluster_time).to eq(cluster_time_doc) + end + end + + context 'when the cluster time received is less than the cluster_time variable' do + + let(:cluster_time_doc) do + BSON::Document.new(Mongo::Cluster::CLUSTER_TIME => BSON::Timestamp.new(0, 1)) + end + + it 'does not set the cluster_time variable to the cluster time' do + expect(cluster.cluster_time).to eq(BSON::Document.new( + Mongo::Cluster::CLUSTER_TIME => BSON::Timestamp.new(1, 1))) + end + end + + context 'when the cluster time received is equal to the cluster_time variable' do + + let(:cluster_time_doc) do + BSON::Document.new(Mongo::Cluster::CLUSTER_TIME => BSON::Timestamp.new(1, 1)) + end + + it 'does not change the cluster_time variable' do + expect(cluster.cluster_time).to eq(BSON::Document.new( + Mongo::Cluster::CLUSTER_TIME => BSON::Timestamp.new(1, 1))) + end + end + end + end + end end diff --git a/spec/mongo/collection/view/aggregation_spec.rb b/spec/mongo/collection/view/aggregation_spec.rb index df24291d9b..f305505d2f 100644 --- a/spec/mongo/collection/view/aggregation_spec.rb +++ b/spec/mongo/collection/view/aggregation_spec.rb @@ -26,6 +26,10 @@ described_class.new(view, pipeline, options) end + let(:aggregation_spec) do + aggregation.send(:aggregate_spec, double('session')) + end + after do authorized_collection.delete_many end @@ -68,6 +72,23 @@ authorized_collection.delete_many end + context 'when provided a session' do + + let(:options) do + { session: session } + end + + let(:operation) do + aggregation.to_a + end + + let(:client) do + authorized_client + end + + it_behaves_like 'an operation using a session' + end + context 'when a block is provided' do context 'when no batch size is provided' do @@ -280,7 +301,7 @@ it 'includes the read preference in the spec' do allow(authorized_collection).to receive(:read_preference).and_return(read_preference) - expect(aggregation.send(:aggregate_spec)[:read]).to eq(read_preference) + expect(aggregation_spec[:read]).to eq(read_preference) end end @@ -291,7 +312,7 @@ end it 'includes the option in the spec' do - expect(aggregation.send(:aggregate_spec)[:selector][:allowDiskUse]).to eq(true) + expect(aggregation_spec[:selector][:allowDiskUse]).to eq(true) end context 'when allow_disk_use is specified as an option' do @@ -305,7 +326,7 @@ end it 'includes the option in the spec' do - expect(aggregation.send(:aggregate_spec)[:selector][:allowDiskUse]).to eq(true) + expect(aggregation_spec[:selector][:allowDiskUse]).to eq(true) end context 'when #allow_disk_use is also called' do @@ -319,7 +340,7 @@ end it 'overrides the first option with the second' do - expect(aggregation.send(:aggregate_spec)[:selector][:allowDiskUse]).to eq(false) + expect(aggregation_spec[:selector][:allowDiskUse]).to eq(false) end end end @@ -332,7 +353,7 @@ end it 'includes the option in the spec' do - expect(aggregation.send(:aggregate_spec)[:selector][:maxTimeMS]).to eq(options[:max_time_ms]) + expect(aggregation_spec[:selector][:maxTimeMS]).to eq(options[:max_time_ms]) end end @@ -345,7 +366,7 @@ end it 'uses the batch_size on the view' do - expect(aggregation.send(:aggregate_spec)[:selector][:cursor][:batchSize]).to eq(view_options[:batch_size]) + expect(aggregation_spec[:selector][:cursor][:batchSize]).to eq(view_options[:batch_size]) end end @@ -356,7 +377,7 @@ end it 'includes the option in the spec' do - expect(aggregation.send(:aggregate_spec)[:selector][:cursor][:batchSize]).to eq(options[:batch_size]) + expect(aggregation_spec[:selector][:cursor][:batchSize]).to eq(options[:batch_size]) end context 'when batch_size is also set on the view' do @@ -366,7 +387,7 @@ end it 'overrides the view batch_size with the option batch_size' do - expect(aggregation.send(:aggregate_spec)[:selector][:cursor][:batchSize]).to eq(options[:batch_size]) + expect(aggregation_spec[:selector][:cursor][:batchSize]).to eq(options[:batch_size]) end end end @@ -379,7 +400,7 @@ end it 'includes the option in the spec' do - expect(aggregation.send(:aggregate_spec)[:selector][:hint]).to eq(options['hint']) + expect(aggregation_spec[:selector][:hint]).to eq(options['hint']) end end @@ -396,7 +417,7 @@ end it 'sets a batch size document in the spec' do - expect(aggregation.send(:aggregate_spec)[:selector][:cursor][:batchSize]).to eq(options[:batch_size]) + expect(aggregation_spec[:selector][:cursor][:batchSize]).to eq(options[:batch_size]) end end @@ -407,7 +428,7 @@ end it 'sets an empty document in the spec' do - expect(aggregation.send(:aggregate_spec)[:selector][:cursor]).to eq({}) + expect(aggregation_spec[:selector][:cursor]).to eq({}) end end @@ -422,7 +443,7 @@ context 'when batch_size is set' do it 'does not set the cursor option in the spec' do - expect(aggregation.send(:aggregate_spec)[:selector][:cursor]).to be_nil + expect(aggregation_spec[:selector][:cursor]).to be_nil end end end diff --git a/spec/mongo/collection/view/builder/find_command_spec.rb b/spec/mongo/collection/view/builder/find_command_spec.rb index 9389b822b6..71fb64c40f 100644 --- a/spec/mongo/collection/view/builder/find_command_spec.rb +++ b/spec/mongo/collection/view/builder/find_command_spec.rb @@ -9,7 +9,7 @@ end let(:builder) do - described_class.new(view) + described_class.new(view, nil) end let(:specification) do @@ -52,6 +52,21 @@ } end + context 'when the operation has a session' do + + let(:session) do + double('session') + end + + let(:builder) do + described_class.new(view, session) + end + + it 'adds the session to the specification' do + expect(builder.specification[:session]).to be(session) + end + end + it 'maps the collection name' do expect(selector['find']).to eq(authorized_collection.name) end diff --git a/spec/mongo/collection/view/change_stream_spec.rb b/spec/mongo/collection/view/change_stream_spec.rb index 3a3156dacf..af18c9ca1e 100644 --- a/spec/mongo/collection/view/change_stream_spec.rb +++ b/spec/mongo/collection/view/change_stream_spec.rb @@ -10,8 +10,12 @@ {} end + let(:view_options) do + {} + end + let(:view) do - Mongo::Collection::View.new(authorized_collection, {}, options) + Mongo::Collection::View.new(authorized_collection, {}, view_options) end let(:change_stream) do @@ -31,7 +35,7 @@ end let(:command_selector) do - change_stream.send(:aggregate_spec)[:selector] + change_stream.send(:aggregate_spec, double('session'))[:selector] end let(:cursor) do @@ -194,6 +198,49 @@ it 'sends the pipeline to the server without a custom error' do expect(change_stream).to be_a(Mongo::Collection::View::ChangeStream) end + + context 'when the operation fails', if: sessions_enabled? && test_change_streams? do + + let!(:before_last_use) do + session.instance_variable_get(:@server_session).last_use + end + + let!(:before_operation_time) do + (session.instance_variable_get(:@operation_time) || 0) + end + + let(:pipeline) do + [ { '$invalid' => '$test' }] + end + + let(:options) do + { session: session } + end + + let!(:operation_result) do + begin; change_stream; rescue => e; e; end + end + + let(:session) do + client.start_session + end + + let(:client) do + authorized_client + end + + it 'raises an error' do + expect(operation_result.class).to eq(Mongo::Error::OperationFailure) + end + + it 'updates the last use value' do + expect(session.instance_variable_get(:@server_session).last_use).not_to eq(before_last_use) + end + + it 'updates the operation time value' do + expect(session.instance_variable_get(:@operation_time)).not_to eq(before_operation_time) + end + end end end @@ -207,6 +254,100 @@ expect(cursor).to be_a(Mongo::Cursor) end end + + context 'when provided a session', if: sessions_enabled? && test_change_streams? do + + let(:options) do + { session: session } + end + + let(:operation) do + change_stream + authorized_collection.insert_one(a: 1) + change_stream.to_enum.next + end + + let(:client) do + authorized_client + end + + context 'when the session is created from the same client used for the operation' do + + let(:session) do + client.start_session + end + + let(:server_session) do + session.instance_variable_get(:@server_session) + end + + let!(:before_last_use) do + server_session.last_use + end + + let!(:before_operation_time) do + (session.instance_variable_get(:@operation_time) || 0) + end + + let!(:operation_result) do + operation + end + + it 'updates the last use value' do + expect(server_session.last_use).not_to eq(before_last_use) + end + + it 'updates the operation time value' do + expect(session.instance_variable_get(:@operation_time)).not_to eq(before_operation_time) + end + + it 'does not close the session when the operation completes' do + expect(session.ended?).to be(false) + end + end + + context 'when a session from another client is provided' do + + let(:session) do + client.start_session + end + + let(:client) do + authorized_client.with(read: { mode: :secondary }) + end + + let(:operation_result) do + operation + end + + it 'raises an exception' do + expect { + operation_result + }.to raise_exception(Mongo::Error::InvalidSession) + end + end + + context 'when the session is ended before it is used' do + + let(:session) do + client.start_session + end + + before do + session.end_session + end + + let(:operation_result) do + operation + end + + it 'raises an exception' do + expect { + operation_result + }.to raise_exception(Mongo::Error::InvalidSession) + end + end + end end describe '#close' do @@ -319,6 +460,25 @@ expect(document[:fullDocument][:a]).to eq(1) expect(change_stream_document[:resumeAfter]).to eq(document[:_id]) end + + context 'when provided a session' do + + let(:options) do + { session: session} + end + + let(:session) do + authorized_client.start_session + end + + before do + change_stream.to_enum.next + end + + it 'does not close the session' do + expect(session.ended?).to be(false) + end + end end context 'when the error is a SocketError' do @@ -371,6 +531,25 @@ change_stream }.to raise_exception(Mongo::Error::OperationFailure) end + + context 'when provided a session' do + + let(:options) do + { session: session} + end + + let(:session) do + authorized_client.start_session + end + + before do + begin; change_stream; rescue; end + end + + it 'does not close the session' do + expect(session.ended?).to be(false) + end + end end end @@ -402,6 +581,25 @@ expect(document[:fullDocument][:a]).to eq(2) expect(change_stream_document[:resumeAfter]).to eq(document[:_id]) end + + context 'when provided a session' do + + let(:options) do + { session: session} + end + + let(:session) do + authorized_client.start_session + end + + before do + enum.next + end + + it 'does not close the session' do + expect(session.ended?).to be(false) + end + end end context 'when the error is a SocketError' do @@ -459,9 +657,28 @@ it 'does not run the command again and instead raises the error' do expect { - change_stream.to_enum.next + enum.next }.to raise_exception(Mongo::Error::OperationFailure) end + + context 'when provided a session' do + + let(:options) do + { session: session} + end + + let(:session) do + authorized_client.start_session + end + + before do + begin; enum.next; rescue; end + end + + it 'does not close the session' do + expect(session.ended?).to be(false) + end + end end end @@ -494,6 +711,25 @@ document }.to raise_exception(error) end + + context 'when provided a session' do + + let(:options) do + { session: session} + end + + let(:session) do + authorized_client.start_session + end + + before do + begin; document; rescue; end + end + + it 'does not close the session' do + expect(session.ended?).to be(false) + end + end end context 'when the error is a SocketError' do @@ -551,9 +787,28 @@ it 'does not run the command again and instead raises the error' do expect { - change_stream.to_enum.next + enum.next }.to raise_exception(Mongo::Error::OperationFailure) end + + context 'when provided a session' do + + let(:options) do + { session: session} + end + + let(:session) do + authorized_client.start_session + end + + before do + begin; enum.next; rescue; end + end + + it 'does not close the session' do + expect(session.ended?).to be(false) + end + end end end -end \ No newline at end of file +end diff --git a/spec/mongo/collection/view/map_reduce_spec.rb b/spec/mongo/collection/view/map_reduce_spec.rb index 314181204f..578c063ec4 100644 --- a/spec/mongo/collection/view/map_reduce_spec.rb +++ b/spec/mongo/collection/view/map_reduce_spec.rb @@ -43,6 +43,10 @@ {} end + let(:map_reduce_spec) do + map_reduce.send(:map_reduce_spec, double('session')) + end + before do authorized_collection.insert_many(documents) end @@ -66,6 +70,23 @@ end end + context 'when provided a session' do + + let(:view_options) do + { session: session } + end + + let(:operation) do + map_reduce.to_a + end + + let(:client) do + authorized_client + end + + it_behaves_like 'an operation using a session' + end + context 'when out is in the options' do after do @@ -134,19 +155,75 @@ expect(new_map_reduce.count).to eq(2) end - context 'when another db is specified', if: (!auth_enabled? && list_command_enabled?) do + context 'when provided a session' do + + let(:view_options) do + { session: session } + end + + let(:operation) do + new_map_reduce.to_a + end + + let(:client) do + authorized_client + end + + it_behaves_like 'an operation using a session' + end + + context 'when the output collection is iterated' do + + let(:view_options) do + { session: session } + end + + let(:session) do + client.start_session + end + + let(:view) do + Mongo::Collection::View.new(client[TEST_COLL], selector, view_options) + end + + let(:client) do + authorized_client.with(monitoring: true).tap do |cl| + cl.subscribe(Mongo::Monitoring::COMMAND, subscriber) + end + end + + let(:subscriber) do + EventSubscriber.new + end + + let(:find_command) do + subscriber.started_events[-1].command + end + + before do + begin; client[TEST_COLL].create; rescue; end + begin; client.use('another-db')[TEST_COLL].create; rescue; end + end + + it 'uses the session when iterating over the output collection', if: sessions_enabled? do + new_map_reduce.to_a + expect(find_command["lsid"]).to eq(BSON::Document.new(session.session_id)) + end + end + + context 'when another db is specified', if: (sessions_enabled? && !sharded? && !auth_enabled?) do let(:new_map_reduce) do map_reduce.out(db: 'another-db', replace: 'output_collection') end - it 'iterates over the documents in the result' do + it 'iterates over the documents in the result', if: (sessions_enabled? && !sharded? && !auth_enabled?) do new_map_reduce.each do |document| expect(document[:value]).to_not be_nil end end - it 'fetches the results from the collection' do + it 'fetches the results from the collection', if: (sessions_enabled? && !sharded? && !auth_enabled?) do expect(new_map_reduce.count).to eq(2) end end @@ -168,7 +245,7 @@ expect(new_map_reduce.count).to eq(2) end - context 'when another db is specified', if: (!auth_enabled? && list_command_enabled?) do + context 'when another db is specified', if: (!auth_enabled? && !sharded? && list_command_enabled?) do let(:new_map_reduce) do map_reduce.out(db: 'another-db', merge: 'output_collection') @@ -202,7 +279,7 @@ expect(new_map_reduce.count).to eq(2) end - context 'when another db is specified', if: (!auth_enabled? && list_command_enabled?) do + context 'when another db is specified', if: (!auth_enabled? && list_command_enabled? && !sharded?) do let(:new_map_reduce) do map_reduce.out(db: 'another-db', reduce: 'output_collection') @@ -247,7 +324,7 @@ end it 'includes the selector in the operation spec' do - expect(map_reduce.send(:map_reduce_spec)[:selector][:query]).to eq(selector) + expect(map_reduce_spec[:selector][:query]).to eq(selector) end end @@ -264,7 +341,7 @@ end it 'includes the selector in the operation spec' do - expect(map_reduce.send(:map_reduce_spec)[:selector][:query]).to eq(selector[:$query]) + expect(map_reduce_spec[:selector][:query]).to eq(selector[:$query]) end end end @@ -302,7 +379,7 @@ end it 'includes the finalize function in the operation spec' do - expect(new_map_reduce.send(:map_reduce_spec)[:selector][:finalize]).to eq(finalize) + expect(new_map_reduce.send(:map_reduce_spec, double('session'))[:selector][:finalize]).to eq(finalize) end end @@ -317,7 +394,7 @@ end it 'includes the js mode value in the operation spec' do - expect(new_map_reduce.send(:map_reduce_spec)[:selector][:jsMode]).to be(true) + expect(new_map_reduce.send(:map_reduce_spec, double('session'))[:selector][:jsMode]).to be(true) end end @@ -336,13 +413,13 @@ end it 'includes the out value in the operation spec' do - expect(new_map_reduce.send(:map_reduce_spec)[:selector][:out]).to eq(location) + expect(new_map_reduce.send(:map_reduce_spec, double('session'))[:selector][:out]).to eq(location) end context 'when out is not defined' do it 'defaults to inline' do - expect(map_reduce.send(:map_reduce_spec)[:selector][:out]).to eq('inline' => 1) + expect(map_reduce_spec[:selector][:out]).to eq('inline' => 1) end end @@ -361,7 +438,7 @@ end it 'includes the out value in the operation spec' do - expect(map_reduce.send(:map_reduce_spec)[:selector][:out]).to eq(location) + expect(map_reduce_spec[:selector][:out]).to eq(location) end end @@ -472,7 +549,7 @@ end it 'includes the scope object in the operation spec' do - expect(new_map_reduce.send(:map_reduce_spec)[:selector][:scope]).to eq(object) + expect(new_map_reduce.send(:map_reduce_spec, double('session'))[:selector][:scope]).to eq(object) end end @@ -491,7 +568,7 @@ end it 'includes the verbose option in the operation spec' do - expect(new_map_reduce.send(:map_reduce_spec)[:selector][:verbose]).to eq(verbose) + expect(new_map_reduce.send(:map_reduce_spec, double('session'))[:selector][:verbose]).to eq(verbose) end end @@ -506,7 +583,7 @@ end it 'includes the limit in the operation spec' do - expect(map_reduce.send(:map_reduce_spec)[:selector][:limit]).to be(limit) + expect(map_reduce_spec[:selector][:limit]).to be(limit) end end @@ -521,7 +598,7 @@ end it 'includes the sort object in the operation spec' do - expect(map_reduce.send(:map_reduce_spec)[:selector][:sort][:name]).to eq(sort[:name]) + expect(map_reduce_spec[:selector][:sort][:name]).to eq(sort[:name]) end end @@ -533,7 +610,7 @@ it 'includes the read preference in the spec' do allow(authorized_collection).to receive(:read_preference).and_return(read_preference) - expect(map_reduce.send(:map_reduce_spec)[:read]).to eq(read_preference) + expect(map_reduce_spec[:read]).to eq(read_preference) end end diff --git a/spec/mongo/collection_spec.rb b/spec/mongo/collection_spec.rb index 1739322595..d0a16bbdcd 100644 --- a/spec/mongo/collection_spec.rb +++ b/spec/mongo/collection_spec.rb @@ -182,7 +182,7 @@ context 'when the client has a write concern set' do let(:client) do - Mongo::Client.new(ADDRESSES, TEST_OPTIONS.merge(write: { w: 10 })) + Mongo::Client.new(ADDRESSES, TEST_OPTIONS.merge(write: INVALID_WRITE_CONCERN)) end it 'sets the new write options on the new collection' do @@ -615,9 +615,40 @@ it_behaves_like 'a collection command with a collation option' end end + + context 'when a session is provided' do + + let(:collection) do + authorized_client[:specs] + end + + let(:operation) do + collection.create(session: session) + end + + let(:session) do + authorized_client.start_session + end + + let(:client) do + authorized_client + end + + let(:failed_operation) do + authorized_client[:specs, invalid: true].create(session: session) + end + + after do + collection.drop + end + + it_behaves_like 'an operation using a session' + it_behaves_like 'a failed operation using a session' + end end end + describe '#drop' do let(:database) do @@ -629,10 +660,37 @@ end context 'when the collection exists' do + before do collection.create end + context 'when a session is provided' do + + let(:operation) do + collection.drop(session: session) + end + + let(:failed_operation) do + collection.with(write: INVALID_WRITE_CONCERN).drop(session: session) + end + + let(:session) do + authorized_client.start_session + end + + let(:client) do + authorized_client + end + + after do + collection.drop + end + + it_behaves_like 'an operation using a session' + it_behaves_like 'a failed operation using a session' + end + context 'when the collection does not have a write concern set' do let!(:response) do @@ -699,6 +757,19 @@ describe '#find' do + describe 'updating cluster time' do + + let(:operation) do + client[TEST_COLL].find.first + end + + let(:second_operation) do + client[TEST_COLL].find.first + end + + it_behaves_like 'an operation updating cluster time' + end + context 'when provided a filter' do let(:view) do @@ -797,6 +868,28 @@ authorized_collection.find({}, options) end + context 'when a session is provided' do + + let(:operation) do + authorized_collection.find({}, session: session).to_a + end + + let(:session) do + authorized_client.start_session + end + + let(:failed_operation) do + authorized_collection.find({ '$._id' => 1 }, session: session).to_a + end + + let(:client) do + authorized_client + end + + it_behaves_like 'an operation using a session' + it_behaves_like 'a failed operation using a session' + end + context 'when provided :allow_partial_results' do let(:options) do @@ -949,6 +1042,28 @@ expect(result.inserted_ids.size).to eq(2) end + context 'when a session is provided' do + + let(:session) do + authorized_client.start_session + end + + let(:operation) do + authorized_collection.insert_many([{ name: 'test1' }, { name: 'test2' }], session: session) + end + + let(:failed_operation) do + authorized_collection.insert_many([{ _id: 'test1' }, { _id: 'test1' }], session: session) + end + + let(:client) do + authorized_client + end + + it_behaves_like 'an operation using a session' + it_behaves_like 'a failed operation using a session' + end + context 'when a document contains invalid keys' do let(:docs) do @@ -1021,18 +1136,35 @@ def generate context 'when the documents are sent with OP_MSG', if: op_msg_enabled? do + let(:client) do + authorized_client.with(heartbeat_frequency: 100).tap do |cl| + cl.subscribe(Mongo::Monitoring::COMMAND, subscriber) + end + end + + let(:subscriber) do + EventSubscriber.new + end + let(:documents) do [{ '_id' => 1, 'name' => '1'*16777191 }, { '_id' => 'y' }] end before do - authorized_collection.insert_one(a:1) + client[TEST_COLL].insert_many(documents) + end + + after do + client.close + end + + let(:insert_events) do + subscriber.started_events.select { |e| e.command_name == :insert } end it 'sends the documents in one OP_MSG' do - # Msg created twice: once for insert, once for the delete in the after block - expect(Mongo::Protocol::Msg).to receive(:new).twice.and_call_original - expect(authorized_collection.insert_many(documents).inserted_count).to eq(2) + expect(insert_events.size).to eq(1) + expect(insert_events[0].command['documents']).to eq(documents) end end @@ -1090,6 +1222,19 @@ def generate describe '#insert_one' do + describe 'updating cluster time' do + + let(:operation) do + client[TEST_COLL].insert_one({ name: 'testing' }) + end + + let(:second_operation) do + client[TEST_COLL].insert_one({ name: 'testing' }) + end + + it_behaves_like 'an operation updating cluster time' + end + let(:result) do authorized_collection.insert_one({ name: 'testing' }) end @@ -1106,6 +1251,29 @@ def generate expect(result.inserted_id).to_not be_nil end + context 'when a session is provided' do + + let(:session) do + authorized_client.start_session + end + + let(:operation) do + authorized_collection.insert_one({ name: 'testing' }, session: session) + end + + let(:failed_operation) do + authorized_collection.insert_one({ _id: 'testing' }) + authorized_collection.insert_one({ _id: 'testing' }, session: session) + end + + let(:client) do + authorized_client + end + + it_behaves_like 'an operation using a session' + it_behaves_like 'a failed operation using a session' + end + context 'when the document contains invalid keys' do let(:doc) do @@ -1251,6 +1419,28 @@ def generate expect(index_names).to include(*'name_1', '_id_') end + context 'when a session is provided' do + + let(:session) do + authorized_client.start_session + end + + let(:operation) do + authorized_collection.indexes(batch_size: batch_size, session: session).collect { |i| i['name'] } + end + + let(:failed_operation) do + authorized_collection.indexes(batch_size: -100, session: session).collect { |i| i['name'] } + end + + let(:client) do + authorized_client + end + + it_behaves_like 'an operation using a session' + it_behaves_like 'a failed operation using a session' + end + context 'when batch size is specified' do let(:batch_size) { 1 } @@ -1263,6 +1453,19 @@ def generate describe '#aggregate' do + describe 'updating cluster time' do + + let(:operation) do + client[TEST_COLL].aggregate([]).first + end + + let(:second_operation) do + client[TEST_COLL].aggregate([]).first + end + + it_behaves_like 'an operation updating cluster time' + end + it 'returns an Aggregation object' do expect(authorized_collection.aggregate([])).to be_a(Mongo::Collection::View::Aggregation) end @@ -1277,6 +1480,28 @@ def generate expect(authorized_collection.aggregate([], options).options).to eq(BSON::Document.new(options)) end + context 'when a session is provided' do + + let(:session) do + authorized_client.start_session + end + + let(:operation) do + authorized_collection.aggregate([], session: session).to_a + end + + let(:failed_operation) do + authorized_collection.aggregate([ { '$invalid' => 1 }], session: session).to_a + end + + let(:client) do + authorized_client + end + + it_behaves_like 'an operation using a session' + it_behaves_like 'a failed operation using a session' + end + context 'when a hint is provided' do let(:options) do @@ -1358,6 +1583,28 @@ def generate expect(authorized_collection.count({}, limit: 5)).to eq(5) end + context 'when a session is provided' do + + let(:session) do + authorized_client.start_session + end + + let(:operation) do + authorized_collection.count({}, session: session) + end + + let(:failed_operation) do + authorized_collection.count({ '$._id' => 1 }, session: session) + end + + let(:client) do + authorized_client + end + + it_behaves_like 'an operation using a session' + it_behaves_like 'a failed operation using a session' + end + context 'when a collation is specified' do let(:selector) do @@ -1434,6 +1681,28 @@ def generate it 'passes the options to the distinct command' do expect(authorized_collection.distinct(:field, {}, max_time_ms: 100).sort).to eq([ 'test1', 'test2', 'test3' ]) end + + context 'when a session is provided' do + + let(:session) do + authorized_client.start_session + end + + let(:operation) do + authorized_collection.distinct(:field, {}, session: session) + end + + let(:failed_operation) do + authorized_collection.distinct(:field, { '$._id' => 1 }, session: session) + end + + let(:client) do + authorized_client + end + + it_behaves_like 'an operation using a session' + it_behaves_like 'a failed operation using a session' + end end context 'when a collation is specified' do @@ -1551,6 +1820,28 @@ def generate end end + context 'when a session is provided' do + + let(:session) do + authorized_client.start_session + end + + let(:operation) do + authorized_collection.delete_one({}, session: session) + end + + let(:failed_operation) do + authorized_collection.delete_one({ '$._id' => 1}, session: session) + end + + let(:client) do + authorized_client + end + + it_behaves_like 'an operation using a session' + it_behaves_like 'a failed operation using a session' + end + context 'when a collation is provided' do let(:selector) do @@ -1688,6 +1979,28 @@ def generate end end + context 'when a session is provided' do + + let(:session) do + authorized_client.start_session + end + + let(:operation) do + authorized_collection.delete_many({}, session: session) + end + + let(:failed_operation) do + authorized_collection.delete_many({ '$._id' => 1}, session: session) + end + + let(:client) do + authorized_client + end + + it_behaves_like 'an operation using a session' + it_behaves_like 'a failed operation using a session' + end + context 'when a collation is specified' do let(:selector) do @@ -1824,6 +2137,32 @@ def generate }.to raise_error(Mongo::Error::OperationFailure) end + context 'when a session is provided' do + + let(:cursors) do + authorized_collection.parallel_scan(2, session: session) + end + + let(:session) do + authorized_client.start_session + end + + let(:operation) do + cursors.reduce(0) { |total, cursor| total + cursor.to_a.size } + end + + let(:failed_operation) do + authorized_collection.parallel_scan(-2, session: session) + end + + let(:client) do + authorized_client + end + + it_behaves_like 'an operation using a session' + it_behaves_like 'a failed operation using a session' + end + context 'when a read concern is provided', if: find_command_enabled? do let(:result) do @@ -2181,6 +2520,36 @@ def generate expect(authorized_collection.find(name: 'bang').count).to eq(1) end end + + context 'when a session is provided' do + + let(:selector) do + { name: 'BANG' } + end + + before do + authorized_collection.insert_one(name: 'bang') + end + + let(:session) do + authorized_client.start_session + end + + let(:operation) do + authorized_collection.replace_one(selector, { name: 'doink' }, session: session) + end + + let(:failed_operation) do + authorized_collection.replace_one({ '$._id' => 1 }, { name: 'doink' }, session: session) + end + + let(:client) do + authorized_client + end + + it_behaves_like 'an operation using a session' + it_behaves_like 'a failed operation using a session' + end end describe '#update_many' do @@ -2283,7 +2652,7 @@ def generate end end - context 'when arrayFilters is provided' do + context 'when arrayFilters is provided' do let(:selector) do { '$or' => [{ _id: 0 }, { _id: 1 }]} @@ -2553,6 +2922,37 @@ def generate expect(result.written_count).to eq(0) end end + + context 'when a session is provided' do + + let(:selector) do + { name: 'BANG' } + end + + let(:operation) do + authorized_collection.update_many(selector, { '$set' => {other: 'doink'} }, session: session) + end + + before do + authorized_collection.insert_one(name: 'bang') + authorized_collection.insert_one(name: 'baNG') + end + + let(:session) do + authorized_client.start_session + end + + let(:failed_operation) do + authorized_collection.update_many({ '$._id' => 1 }, { '$set' => {other: 'doink'} }, session: session) + end + + let(:client) do + authorized_client + end + + it_behaves_like 'an operation using a session' + it_behaves_like 'a failed operation using a session' + end end describe '#update_one' do @@ -2908,20 +3308,63 @@ def generate context 'when the documents are sent with OP_MSG', if: op_msg_enabled? do + let(:client) do + authorized_client.with(heartbeat_frequency: 100).tap do |cl| + cl.subscribe(Mongo::Monitoring::COMMAND, subscriber) + end + end + + let(:subscriber) do + EventSubscriber.new + end + + let(:documents) do + [{ '_id' => 1, 'name' => '1'*16777191 }, { '_id' => 'y' }] + end + before do - authorized_collection.insert_one(a:1) + authorized_collection.insert_many([{ field: 'test1' }, { field: 'test1' }]) + client[TEST_COLL].update_one({ a: 1 }, {'$set' => { 'name' => '1'*16777149 }}) + end + + after do + client.close end - let(:update) do - {'$set' => { 'name' => '1'*16777149 }} + let(:update_events) do + subscriber.started_events.select { |e| e.command_name == :update } end - it 'sends the operation in one OP_MSG' do - # Msg created twice: once for update, once for the delete in the after block - expect(Mongo::Protocol::Msg).to receive(:new).twice.and_call_original - expect(authorized_collection.update_one({a: 1}, update).written_count).to eq(1) + it 'sends the documents in one OP_MSG' do + expect(update_events.size).to eq(1) end end + + context 'when a session is provided' do + + before do + authorized_collection.insert_many([{ field: 'test1' }, { field: 'test1' }]) + end + + let(:session) do + authorized_client.start_session + end + + let(:operation) do + authorized_collection.update_one({ field: 'test' }, { '$set'=> { field: 'testing' } }, session: session) + end + + let(:failed_operation) do + authorized_collection.update_one({ '$._id' => 1 }, { '$set'=> { field: 'testing' } }, session: session) + end + + let(:client) do + authorized_client + end + + it_behaves_like 'an operation using a session' + it_behaves_like 'a failed operation using a session' + end end describe '#find_one_and_delete' do @@ -2936,6 +3379,28 @@ def generate context 'when a matching document is found' do + context 'when a session is provided' do + + let(:operation) do + authorized_collection.find_one_and_delete(selector, session: session) + end + + let(:failed_operation) do + authorized_collection.find_one_and_delete({ '$._id' => 1 }, session: session) + end + + let(:session) do + authorized_client.start_session + end + + let(:client) do + authorized_client + end + + it_behaves_like 'an operation using a session' + it_behaves_like 'a failed operation using a session' + end + context 'when no options are provided' do let!(:document) do @@ -3136,6 +3601,28 @@ def generate end end + context 'when a session is provided' do + + let(:operation) do + authorized_collection.find_one_and_update(selector, { '$set' => { field: 'testing' }}, session: session) + end + + let(:failed_operation) do + authorized_collection.find_one_and_update({ '$._id' => 1 }, { '$set' => { field: 'testing' }}, session: session) + end + + let(:session) do + authorized_client.start_session + end + + let(:client) do + authorized_client + end + + it_behaves_like 'an operation using a session' + it_behaves_like 'a failed operation using a session' + end + context 'when no options are provided' do let(:document) do @@ -3525,6 +4012,28 @@ def generate end end + context 'when a session is provided' do + + let(:operation) do + authorized_collection.find_one_and_replace(selector, { field: 'testing' }, session: session) + end + + let(:failed_operation) do + authorized_collection.find_one_and_replace({ '$._id' => 1}, { field: 'testing' }, session: session) + end + + let(:session) do + authorized_client.start_session + end + + let(:client) do + authorized_client + end + + it_behaves_like 'an operation using a session' + it_behaves_like 'a failed operation using a session' + end + context 'when return_document options are provided' do context 'when return_document is :after' do diff --git a/spec/mongo/cursor/builder/get_more_command_spec.rb b/spec/mongo/cursor/builder/get_more_command_spec.rb index 3f801f1c35..91967e3af6 100644 --- a/spec/mongo/cursor/builder/get_more_command_spec.rb +++ b/spec/mongo/cursor/builder/get_more_command_spec.rb @@ -28,6 +28,25 @@ specification[:selector] end + context 'when the operation has a session' do + + let(:view) do + Mongo::Collection::View.new(authorized_collection) + end + + let(:session) do + double('session') + end + + let(:builder) do + described_class.new(cursor, session) + end + + it 'adds the session to the specification' do + expect(builder.specification[:session]).to be(session) + end + end + shared_examples_for 'a getmore command builder' do it 'includes the database name' do diff --git a/spec/mongo/cursor_spec.rb b/spec/mongo/cursor_spec.rb index 6721bcbcf7..4e148d4461 100644 --- a/spec/mongo/cursor_spec.rb +++ b/spec/mongo/cursor_spec.rb @@ -22,7 +22,7 @@ Mongo::Collection::View.new(authorized_collection) end - context 'when the initial query retieves all documents' do + context 'when the initial query retrieves all documents' do let(:documents) do (1..10).map{ |i| { field: "test#{i}" }} diff --git a/spec/mongo/database_spec.rb b/spec/mongo/database_spec.rb index 7a828e7865..c6a124e55e 100644 --- a/spec/mongo/database_spec.rb +++ b/spec/mongo/database_spec.rb @@ -107,6 +107,19 @@ expect(database.collection_names).to_not include('system.indexes') end + context 'when provided a session' do + + let(:operation) do + database.collection_names(session: session) + end + + let(:client) do + authorized_client + end + + it_behaves_like 'an operation using a session' + end + context 'when specifying a batch size' do it 'returns the stripped names of the collections' do @@ -228,6 +241,24 @@ expect(database.command({:ismaster => 1}.freeze).written_count).to eq(0) end + context 'when provided a session' do + + let(:operation) do + database.command({ :ismaster => 1 }, session: session) + end + + let(:failed_operation) do + database.command({ :invalid => 1 }, session: session) + end + + let(:client) do + authorized_client + end + + it_behaves_like 'an operation using a session' + it_behaves_like 'a failed operation using a session' + end + context 'when a read concern is provided', if: find_command_enabled? do context 'when the read concern is valid' do @@ -414,6 +445,19 @@ }.to raise_error(Mongo::Error::OperationFailure) end + context 'when provided a session' do + + let(:operation) do + database.drop(session: session) + end + + let(:client) do + authorized_client + end + + it_behaves_like 'an operation using a session' + end + context 'when the client/database has a write concern' do let(:client_options) do diff --git a/spec/mongo/grid/fs_bucket_spec.rb b/spec/mongo/grid/fs_bucket_spec.rb index 5aeb2fff7f..9d69326e22 100644 --- a/spec/mongo/grid/fs_bucket_spec.rb +++ b/spec/mongo/grid/fs_bucket_spec.rb @@ -3,7 +3,11 @@ describe Mongo::Grid::FSBucket do let(:fs) do - described_class.new(authorized_client.database, options) + described_class.new(client.database, options) + end + + let(:client) do + authorized_client end let(:options) do @@ -482,7 +486,7 @@ context 'when a read stream is opened' do let(:fs) do - described_class.new(authorized_client.database) + described_class.new(authorized_client.database, options) end let(:io) do @@ -590,6 +594,29 @@ describe '#download_to_stream' do + context 'sessions' do + + let(:options) do + { session: session } + end + + let(:file_id) do + fs.open_upload_stream(filename) do |stream| + stream.write(file) + end.file_id + end + + let(:operation) do + fs.download_to_stream(file_id, io) + end + + let(:client) do + authorized_client + end + + it_behaves_like 'an operation using a session' + end + context 'when the file is found' do let!(:file_id) do @@ -680,6 +707,7 @@ describe '#download_to_stream_by_name' do + let(:files) do [ StringIO.new('hello 1'), @@ -689,75 +717,105 @@ ] end - before do - files.each do |file| - fs.upload_from_stream('test.txt', file) + context ' when using a session' do + + let(:options) do + { session: session } end - end - let(:io) do - StringIO.new - end + let(:operation) do + fs.download_to_stream_by_name('test.txt', io) + end - context 'when revision is not specified' do + let(:client) do + authorized_client + end - let!(:result) do - fs.download_to_stream_by_name('test.txt', io) + before do + files.each do |file| + authorized_client.database.fs.upload_from_stream('test.txt', file) + end end - it 'returns the most recent version' do - expect(io.string).to eq('hello 4') + let(:io) do + StringIO.new end + + it_behaves_like 'an operation using a session' end - context 'when revision is 0' do + context 'when not using a session' do - let!(:result) do - fs.download_to_stream_by_name('test.txt', io, revision: 0) + before do + files.each do |file| + fs.upload_from_stream('test.txt', file) + end end - it 'returns the original stored file' do - expect(io.string).to eq('hello 1') + let(:io) do + StringIO.new end - end - context 'when revision is negative' do + context 'when revision is not specified' do + + let!(:result) do + fs.download_to_stream_by_name('test.txt', io) + end - let!(:result) do - fs.download_to_stream_by_name('test.txt', io, revision: -2) + it 'returns the most recent version' do + expect(io.string).to eq('hello 4') + end end - it 'returns that number of versions from the most recent' do - expect(io.string).to eq('hello 3') + context 'when revision is 0' do + + let!(:result) do + fs.download_to_stream_by_name('test.txt', io, revision: 0) + end + + it 'returns the original stored file' do + expect(io.string).to eq('hello 1') + end end - end - context 'when revision is positive' do + context 'when revision is negative' do - let!(:result) do - fs.download_to_stream_by_name('test.txt', io, revision: 1) + let!(:result) do + fs.download_to_stream_by_name('test.txt', io, revision: -2) + end + + it 'returns that number of versions from the most recent' do + expect(io.string).to eq('hello 3') + end end - it 'returns that number revision' do - expect(io.string).to eq('hello 2') + context 'when revision is positive' do + + let!(:result) do + fs.download_to_stream_by_name('test.txt', io, revision: 1) + end + + it 'returns that number revision' do + expect(io.string).to eq('hello 2') + end end - end - context 'when the file revision is not found' do + context 'when the file revision is not found' do - it 'raises a FileNotFound error' do - expect { - fs.download_to_stream_by_name('test.txt', io, revision: 100) - }.to raise_exception(Mongo::Error::InvalidFileRevision) + it 'raises a FileNotFound error' do + expect { + fs.download_to_stream_by_name('test.txt', io, revision: 100) + }.to raise_exception(Mongo::Error::InvalidFileRevision) + end end - end - context 'when the file is not found' do + context 'when the file is not found' do - it 'raises a FileNotFound error' do - expect { - fs.download_to_stream_by_name('non-existent.txt', io) - }.to raise_exception(Mongo::Error::FileNotFound) + it 'raises a FileNotFound error' do + expect { + fs.download_to_stream_by_name('non-existent.txt', io) + }.to raise_exception(Mongo::Error::FileNotFound) + end end end end @@ -773,124 +831,154 @@ ] end - before do - files.each do |file| - fs.upload_from_stream('test.txt', file) - end - end - let(:io) do StringIO.new end - context 'when a block is provided' do + context ' when using a session' do - let(:stream) do - fs.open_download_stream_by_name('test.txt') do |stream| - io.write(stream.read) - end + let(:options) do + { session: session } end - it 'returns a Stream::Read object' do - expect(stream).to be_a(Mongo::Grid::FSBucket::Stream::Read) + let(:operation) do + fs.download_to_stream_by_name('test.txt', io) end - it 'closes the stream after the block completes' do - expect(stream.closed?).to be(true) + let(:client) do + authorized_client end - it 'yields the stream to the block' do - stream - expect(io.size).to eq(files[0].size) + before do + files.each do |file| + authorized_client.database.fs.upload_from_stream('test.txt', file) + end end - context 'when revision is not specified' do + let(:io) do + StringIO.new + end - let!(:result) do - fs.open_download_stream_by_name('test.txt') do |stream| - io.write(stream.read) - end - end + it_behaves_like 'an operation using a session' + end - it 'returns the most recent version' do - expect(io.string).to eq('hello 4') + context 'when not using a session' do + + before do + files.each do |file| + fs.upload_from_stream('test.txt', file) end end - context 'when revision is 0' do + context 'when a block is provided' do - let!(:result) do - fs.open_download_stream_by_name('test.txt', revision: 0) do |stream| + let(:stream) do + fs.open_download_stream_by_name('test.txt') do |stream| io.write(stream.read) end end - it 'returns the original stored file' do - expect(io.string).to eq('hello 1') + it 'returns a Stream::Read object' do + expect(stream).to be_a(Mongo::Grid::FSBucket::Stream::Read) end - end - context 'when revision is negative' do + it 'closes the stream after the block completes' do + expect(stream.closed?).to be(true) + end - let!(:result) do - fs.open_download_stream_by_name('test.txt', revision: -2) do |stream| - io.write(stream.read) + it 'yields the stream to the block' do + stream + expect(io.size).to eq(files[0].size) + end + + context 'when revision is not specified' do + + let!(:result) do + fs.open_download_stream_by_name('test.txt') do |stream| + io.write(stream.read) + end + end + + it 'returns the most recent version' do + expect(io.string).to eq('hello 4') end end - it 'returns that number of versions from the most recent' do - expect(io.string).to eq('hello 3') + context 'when revision is 0' do + + let!(:result) do + fs.open_download_stream_by_name('test.txt', revision: 0) do |stream| + io.write(stream.read) + end + end + + it 'returns the original stored file' do + expect(io.string).to eq('hello 1') + end end - end - context 'when revision is positive' do + context 'when revision is negative' do - let!(:result) do - fs.open_download_stream_by_name('test.txt', revision: 1) do |stream| - io.write(stream.read) + let!(:result) do + fs.open_download_stream_by_name('test.txt', revision: -2) do |stream| + io.write(stream.read) + end + end + + it 'returns that number of versions from the most recent' do + expect(io.string).to eq('hello 3') end end - it 'returns that number revision' do - expect(io.string).to eq('hello 2') + context 'when revision is positive' do + + let!(:result) do + fs.open_download_stream_by_name('test.txt', revision: 1) do |stream| + io.write(stream.read) + end + end + + it 'returns that number revision' do + expect(io.string).to eq('hello 2') + end end - end - context 'when the file revision is not found' do + context 'when the file revision is not found' do - it 'raises a FileNotFound error' do - expect { - fs.open_download_stream_by_name('test.txt', revision: 100) - }.to raise_exception(Mongo::Error::InvalidFileRevision) + it 'raises a FileNotFound error' do + expect { + fs.open_download_stream_by_name('test.txt', revision: 100) + }.to raise_exception(Mongo::Error::InvalidFileRevision) + end end - end - context 'when the file is not found' do + context 'when the file is not found' do - it 'raises a FileNotFound error' do - expect { - fs.open_download_stream_by_name('non-existent.txt') - }.to raise_exception(Mongo::Error::FileNotFound) + it 'raises a FileNotFound error' do + expect { + fs.open_download_stream_by_name('non-existent.txt') + }.to raise_exception(Mongo::Error::FileNotFound) + end end end - end - context 'when a block is not provided' do + context 'when a block is not provided' do - let!(:stream) do - fs.open_download_stream_by_name('test.txt') - end + let!(:stream) do + fs.open_download_stream_by_name('test.txt') + end - it 'returns a Stream::Read object' do - expect(stream).to be_a(Mongo::Grid::FSBucket::Stream::Read) - end + it 'returns a Stream::Read object' do + expect(stream).to be_a(Mongo::Grid::FSBucket::Stream::Read) + end - it 'does not close the stream' do - expect(stream.closed?).to be(false) - end + it 'does not close the stream' do + expect(stream.closed?).to be(false) + end - it 'does not yield the stream to the block' do - expect(io.size).to eq(0) + it 'does not yield the stream to the block' do + expect(io.size).to eq(0) + end end end end @@ -937,32 +1025,36 @@ context 'when a block is provided' do - let!(:stream) do - fs.open_upload_stream(filename) do |stream| - stream.write(file) + context 'when a session is not used' do + + let!(:stream) do + fs.open_upload_stream(filename) do |stream| + stream.write(file) + end end - end - let(:result) do - fs.find_one(filename: filename) - end + let(:result) do + fs.find_one(filename: filename) + end - it 'returns the stream' do - expect(stream).to be_a(Mongo::Grid::FSBucket::Stream::Write) - end + it 'returns the stream' do + expect(stream).to be_a(Mongo::Grid::FSBucket::Stream::Write) + end - it 'creates an ObjectId for the file' do - expect(stream.file_id).to be_a(BSON::ObjectId) - end + it 'creates an ObjectId for the file' do + expect(stream.file_id).to be_a(BSON::ObjectId) + end - it 'yields the stream to the block' do - expect(result.data.size).to eq(file.size) - end + it 'yields the stream to the block' do + expect(result.data.size).to eq(file.size) + end - it 'closes the stream when the block completes' do - expect(stream.closed?).to be(true) + it 'closes the stream when the block completes' do + expect(stream.closed?).to be(true) + end end end + end describe '#upload_from_stream' do diff --git a/spec/mongo/index/view_spec.rb b/spec/mongo/index/view_spec.rb index d8943bf7cd..1b4faf4420 100644 --- a/spec/mongo/index/view_spec.rb +++ b/spec/mongo/index/view_spec.rb @@ -3,7 +3,11 @@ describe Mongo::Index::View do let(:view) do - described_class.new(authorized_collection) + described_class.new(authorized_collection, options) + end + + let(:options) do + {} end describe '#drop_one' do @@ -12,12 +16,34 @@ { another: -1 } end + after do + begin; view.drop_one('another_-1'); rescue; end + end + before do view.create_one(spec, unique: true) end - after do - begin; view.drop_one('another_-1'); rescue; end + context 'when provided a session' do + + let(:view_with_session) do + described_class.new(authorized_collection, session: session) + end + + let(:client) do + authorized_client + end + + let(:operation) do + view_with_session.drop_one('another_-1') + end + + let(:failed_operation) do + view_with_session.drop_one('_another_-1') + end + + it_behaves_like 'an operation using a session' + it_behaves_like 'a failed operation using a session' end context 'when the index exists' do @@ -123,6 +149,23 @@ expect(result).to be_successful end + context 'when provided a session' do + + let(:view_with_session) do + described_class.new(authorized_collection, session: session) + end + + let(:operation) do + view_with_session.drop_all + end + + let(:client) do + authorized_client + end + + it_behaves_like 'an operation using a session' + end + context 'when the collection has a write concern' do let(:collection) do @@ -183,6 +226,33 @@ it 'returns ok' do expect(result).to be_successful end + + context 'when provided a session' do + + let(:view_with_session) do + described_class.new(authorized_collection, session: session) + end + + let(:operation) do + view_with_session.create_many( + { key: { random: 1 }, unique: true }, + { key: { testing: -1 }, unique: true } + ) + end + + let(:client) do + authorized_client + end + + let(:failed_operation) do + view_with_session.create_many( + { key: { random: 1 }, invalid: true } + ) + end + + it_behaves_like 'an operation using a session' + it_behaves_like 'a failed operation using a session' + end end context 'when collation is specified', if: collation_enabled? do @@ -313,6 +383,31 @@ it 'returns ok' do expect(result).to be_successful end + + context 'when provided a session' do + + let(:view_with_session) do + described_class.new(authorized_collection, session: session) + end + + let(:operation) do + view_with_session.create_many([ + { key: { random: 1 }, unique: true }, + { key: { testing: -1 }, unique: true } + ]) + end + + let(:failed_operation) do + view_with_session.create_many([ { key: { random: 1 }, invalid: true }]) + end + + let(:client) do + authorized_client + end + + it_behaves_like 'an operation using a session' + it_behaves_like 'a failed operation using a session' + end end context 'when collation is specified' do @@ -463,16 +558,38 @@ view.create_one(spec, unique: true) end - after do - begin; view.drop_one('random_1'); rescue; end - end - it 'returns ok' do expect(result).to be_successful end + context 'when provided a session' do + + let(:view_with_session) do + described_class.new(authorized_collection, session: session) + end + + let(:operation) do + view_with_session.create_one(spec, unique: true) + end + + let(:failed_operation) do + view_with_session.create_one(spec, invalid: true) + end + + let(:client) do + authorized_client + end + + it_behaves_like 'an operation using a session' + it_behaves_like 'a failed operation using a session' + end + context 'when the collection has a write concern' do + after do + begin; view.drop_one('random_1'); rescue; end + end + let(:collection) do authorized_collection.with(write: INVALID_WRITE_CONCERN) end @@ -514,6 +631,10 @@ context 'when the index is created on an subdocument field' do + after do + begin; view.drop_one('random_1'); rescue; end + end + let(:spec) do { 'sub_document.random' => 1 } end @@ -632,7 +753,7 @@ end after do - view.drop_one('random_name') + begin; view.drop_one('random_name'); rescue; end end context 'when providing a name' do @@ -657,6 +778,23 @@ end end + context 'when provided a session' do + + let(:view_with_session) do + described_class.new(authorized_collection, session: session) + end + + let(:operation) do + view_with_session.get(random: 1) + end + + let(:client) do + authorized_client + end + + it_behaves_like 'an operation using a session' + end + context 'when the index does not exist' do it 'returns nil' do diff --git a/spec/mongo/operation/write/command/delete_spec.rb b/spec/mongo/operation/write/command/delete_spec.rb index 28ebd42e46..db19111a4f 100644 --- a/spec/mongo/operation/write/command/delete_spec.rb +++ b/spec/mongo/operation/write/command/delete_spec.rb @@ -86,9 +86,9 @@ describe '#message' do - context 'when the server supports OP_MSG', if: op_msg_enabled? do + context 'when the server supports OP_MSG' do - let(:expected_global_args) do + let(:global_args) do { delete: TEST_COLL, ordered: true, @@ -106,9 +106,30 @@ } end - it 'creates the correct OP_MSG message' do - expect(Mongo::Protocol::Msg).to receive(:new).with([:none], {}, expected_global_args, expected_payload_1) - op.send(:message, authorized_primary) + context 'when the topology is sharded', if: sharded? && op_msg_enabled? do + + let(:expected_global_args) do + global_args.merge(Mongo::Operation::CLUSTER_TIME => authorized_client.cluster.cluster_time) + end + + it 'creates the correct OP_MSG message' do + authorized_client.command(ping:1) + expect(Mongo::Protocol::Msg).to receive(:new).with([:none], {}, expected_global_args, expected_payload_1) + op.send(:message, authorized_primary) + end + end + + context 'when the topology is not sharded', if: !sharded? && op_msg_enabled? do + + let(:expected_global_args) do + global_args + end + + it 'creates the correct OP_MSG message' do + authorized_client.command(ping:1) + expect(Mongo::Protocol::Msg).to receive(:new).with([:none], {}, expected_global_args, expected_payload_1) + op.send(:message, authorized_primary) + end end context 'when the write concern is 0' do @@ -117,9 +138,30 @@ Mongo::WriteConcern.get(w: 0) end - it 'creates the correct OP_MSG message' do - expect(Mongo::Protocol::Msg).to receive(:new).with([:more_to_come], {}, expected_global_args, expected_payload_1) - op.send(:message, authorized_primary) + context 'when the topology is sharded', if: sharded? && op_msg_enabled? do + + let(:expected_global_args) do + global_args.merge(Mongo::Operation::CLUSTER_TIME => authorized_client.cluster.cluster_time) + end + + it 'creates the correct OP_MSG message' do + authorized_client.command(ping:1) + expect(Mongo::Protocol::Msg).to receive(:new).with([:more_to_come], {}, expected_global_args, expected_payload_1) + op.send(:message, authorized_primary) + end + end + + context 'when the topology is not sharded', if: !sharded? && op_msg_enabled? do + + let(:expected_global_args) do + global_args + end + + it 'creates the correct OP_MSG message' do + authorized_client.command(ping:1) + expect(Mongo::Protocol::Msg).to receive(:new).with([:more_to_come], {}, expected_global_args, expected_payload_1) + op.send(:message, authorized_primary) + end end end end diff --git a/spec/mongo/operation/write/command/insert_spec.rb b/spec/mongo/operation/write/command/insert_spec.rb index 3e85a20d1f..44c5a70090 100644 --- a/spec/mongo/operation/write/command/insert_spec.rb +++ b/spec/mongo/operation/write/command/insert_spec.rb @@ -92,7 +92,7 @@ [ { foo: 1}, { bar: 2 }] end - let(:expected_global_args) do + let(:global_args) do { insert: TEST_COLL, ordered: true, @@ -110,28 +110,76 @@ } end - it 'creates the correct OP_MSG message' do - expect(Mongo::Protocol::Msg).to receive(:new).with([:none], - { validating_keys: true }, - expected_global_args, - expected_payload_1) - op.send(:message, authorized_primary) + context 'when the topology is sharded', if: sharded? && op_msg_enabled? do + + let(:expected_global_args) do + global_args.merge(Mongo::Operation::CLUSTER_TIME => authorized_client.cluster.cluster_time) + end + + it 'creates the correct OP_MSG message' do + authorized_client.command(ping:1) + expect(Mongo::Protocol::Msg).to receive(:new).with([:none], + { validating_keys: true }, + expected_global_args, + expected_payload_1) + op.send(:message, authorized_primary) + end end - context 'when the write concern is 0' do + context 'when the topology is not sharded', if: !sharded? && op_msg_enabled? do - let(:write_concern) do - Mongo::WriteConcern.get(w: 0) + let(:expected_global_args) do + global_args end it 'creates the correct OP_MSG message' do - expect(Mongo::Protocol::Msg).to receive(:new).with([:more_to_come], + authorized_client.command(ping:1) + expect(Mongo::Protocol::Msg).to receive(:new).with([:none], { validating_keys: true }, expected_global_args, expected_payload_1) op.send(:message, authorized_primary) end end + + context 'when the write concern is 0' do + + let(:write_concern) do + Mongo::WriteConcern.get(w: 0) + end + + context 'when the topology is sharded', if: sharded? && op_msg_enabled? do + + let(:expected_global_args) do + global_args.merge(Mongo::Operation::CLUSTER_TIME => authorized_client.cluster.cluster_time) + end + + it 'creates the correct OP_MSG message' do + authorized_client.command(ping:1) + expect(Mongo::Protocol::Msg).to receive(:new).with([:more_to_come], + { validating_keys: true }, + expected_global_args, + expected_payload_1) + op.send(:message, authorized_primary) + end + end + + context 'when the topology is not sharded', if: !sharded? && op_msg_enabled? do + + let(:expected_global_args) do + global_args + end + + it 'creates the correct OP_MSG message' do + authorized_client.command(ping:1) + expect(Mongo::Protocol::Msg).to receive(:new).with([:more_to_come], + { validating_keys: true }, + expected_global_args, + expected_payload_1) + op.send(:message, authorized_primary) + end + end + end end context 'when the server does not support OP_MSG' do diff --git a/spec/mongo/operation/write/command/update_spec.rb b/spec/mongo/operation/write/command/update_spec.rb index 7aca9f6143..807d30eca3 100644 --- a/spec/mongo/operation/write/command/update_spec.rb +++ b/spec/mongo/operation/write/command/update_spec.rb @@ -95,7 +95,7 @@ context 'when the server supports OP_MSG', if: op_msg_enabled? do - let(:expected_global_args) do + let(:global_args) do { update: TEST_COLL, ordered: true, @@ -113,9 +113,30 @@ } end - it 'creates the correct OP_MSG message' do - expect(Mongo::Protocol::Msg).to receive(:new).with([:none], {}, expected_global_args, expected_payload_1) - op.send(:message, authorized_primary) + context 'when the topology is sharded', if: sharded? && op_msg_enabled? do + + let(:expected_global_args) do + global_args.merge(Mongo::Operation::CLUSTER_TIME => authorized_client.cluster.cluster_time) + end + + it 'creates the correct OP_MSG message' do + authorized_client.command(ping:1) + expect(Mongo::Protocol::Msg).to receive(:new).with([:none], {}, expected_global_args, expected_payload_1) + op.send(:message, authorized_primary) + end + end + + context 'when the topology is not sharded', if: !sharded? && op_msg_enabled? do + + let(:expected_global_args) do + global_args + end + + it 'creates the correct OP_MSG message' do + authorized_client.command(ping:1) + expect(Mongo::Protocol::Msg).to receive(:new).with([:none], {}, expected_global_args, expected_payload_1) + op.send(:message, authorized_primary) + end end context 'when the write concern is 0' do @@ -124,9 +145,30 @@ Mongo::WriteConcern.get(w: 0) end - it 'creates the correct OP_MSG message' do - expect(Mongo::Protocol::Msg).to receive(:new).with([:more_to_come], {}, expected_global_args, expected_payload_1) - op.send(:message, authorized_primary) + context 'when the topology is sharded', if: sharded? && op_msg_enabled? do + + let(:expected_global_args) do + global_args.merge(Mongo::Operation::CLUSTER_TIME => authorized_client.cluster.cluster_time) + end + + it 'creates the correct OP_MSG message' do + authorized_client.command(ping:1) + expect(Mongo::Protocol::Msg).to receive(:new).with([:more_to_come], {}, expected_global_args, expected_payload_1) + op.send(:message, authorized_primary) + end + end + + context 'when the topology is not sharded', if: !sharded? && op_msg_enabled? do + + let(:expected_global_args) do + global_args + end + + it 'creates the correct OP_MSG message' do + authorized_client.command(ping:1) + expect(Mongo::Protocol::Msg).to receive(:new).with([:more_to_come], {}, expected_global_args, expected_payload_1) + op.send(:message, authorized_primary) + end end end end diff --git a/spec/mongo/retryable_spec.rb b/spec/mongo/retryable_spec.rb index e1c2d2a5b4..2901ce4191 100644 --- a/spec/mongo/retryable_spec.rb +++ b/spec/mongo/retryable_spec.rb @@ -29,7 +29,7 @@ def read end def write - write_with_retry do + write_with_retry(nil, Proc.new { cluster.next_primary }) do operation.execute end end @@ -41,7 +41,11 @@ def write end let(:cluster) do - double('cluster') + double('cluster', next_primary: server_selector) + end + + let(:server_selector) do + double('server_selector', select_server: double('server')) end let(:retryable) do diff --git a/spec/mongo/server/connection_spec.rb b/spec/mongo/server/connection_spec.rb index 0203f95cb9..bfaf8d2498 100644 --- a/spec/mongo/server/connection_spec.rb +++ b/spec/mongo/server/connection_spec.rb @@ -22,6 +22,8 @@ double('cluster').tap do |cl| allow(cl).to receive(:topology).and_return(topology) allow(cl).to receive(:app_metadata).and_return(app_metadata) + allow(cl).to receive(:cluster_time).and_return(nil) + allow(cl).to receive(:update_cluster_time) end end diff --git a/spec/mongo/session/server_session_spec.rb b/spec/mongo/session/server_session_spec.rb new file mode 100644 index 0000000000..11686ccde4 --- /dev/null +++ b/spec/mongo/session/server_session_spec.rb @@ -0,0 +1,16 @@ +require 'spec_helper' + +describe Mongo::Session::ServerSession do + + describe '#initialize' do + + it 'sets the last use variable to the current time' do + expect(described_class.new.last_use).to be_within(0.2).of(Time.now) + end + + it 'sets a UUID as the session id' do + expect(described_class.new.session_id).to be_a(BSON::Document) + expect(described_class.new.session_id[:id]).to be_a(BSON::Binary) + end + end +end diff --git a/spec/mongo/session/session_pool_spec.rb b/spec/mongo/session/session_pool_spec.rb new file mode 100644 index 0000000000..4a40b6f308 --- /dev/null +++ b/spec/mongo/session/session_pool_spec.rb @@ -0,0 +1,194 @@ +require 'spec_helper' + +describe Mongo::Session::SessionPool do + + describe '.create' do + + let(:client) do + authorized_client + end + + let!(:pool) do + described_class.create(client) + end + + it 'creates a session pool' do + expect(pool).to be_a(Mongo::Session::SessionPool) + end + + it 'adds the pool as an instance variable on the client' do + expect(client.instance_variable_get(:@session_pool)).to eq(pool) + end + end + + describe '#initialize' do + + let(:pool) do + described_class.new(authorized_client) + end + + it 'sets the client' do + expect(pool.instance_variable_get(:@client)).to be(authorized_client) + end + end + + describe 'checkout', if: sessions_enabled? do + + let(:pool) do + described_class.new(authorized_client) + end + + context 'when a session is checked out' do + + let!(:session_a) do + pool.checkout + end + + let!(:session_b) do + pool.checkout + end + + before do + pool.checkin(session_a) + pool.checkin(session_b) + end + + it 'is returned to the front of the queue' do + expect(pool.checkout).to be(session_b) + expect(pool.checkout).to be(session_a) + end + end + + context 'when there are sessions about to expire in the queue' do + + let(:old_session_a) do + pool.checkout + end + + let(:old_session_b) do + pool.checkout + end + + before do + pool.checkin(old_session_a) + pool.checkin(old_session_b) + allow(old_session_a).to receive(:last_use).and_return(Time.now - 1800) + allow(old_session_b).to receive(:last_use).and_return(Time.now - 1800) + end + + context 'when a session is checked out' do + + let(:checked_out_session) do + pool.checkout + end + + it 'disposes of the old session and returns a new one' do + expect(checked_out_session).not_to be(old_session_a) + expect(checked_out_session).not_to be(old_session_b) + expect(pool.instance_variable_get(:@queue)).to be_empty + end + end + end + + context 'when a sessions that is about to expire is checked in' do + + let(:old_session_a) do + pool.checkout + end + + let(:old_session_b) do + pool.checkout + end + + before do + allow(old_session_a).to receive(:last_use).and_return(Time.now - 1800) + allow(old_session_b).to receive(:last_use).and_return(Time.now - 1800) + pool.checkin(old_session_a) + pool.checkin(old_session_b) + end + + it 'disposes of the old sessions instead of adding them to the pool' do + expect(pool.checkout).not_to be(old_session_a) + expect(pool.checkout).not_to be(old_session_b) + expect(pool.instance_variable_get(:@queue)).to be_empty + end + end + end + + describe '#end_sessions', if: sessions_enabled? do + + let(:pool) do + described_class.create(client) + end + + let!(:session_a) do + pool.checkout + end + + let!(:session_b) do + pool.checkout + end + + let(:client) do + authorized_client.with(heartbeat_frequency: 100).tap do |cl| + cl.subscribe(Mongo::Monitoring::COMMAND, subscriber) + end + end + + let(:subscriber) do + EventSubscriber.new + end + + before do + client.database.command(ping: 1) + pool.checkin(session_a) + pool.checkin(session_b) + pool.end_sessions + end + + after do + client.close + end + + let(:end_sessions_command) do + subscriber.started_events.find { |c| c.command_name == :endSessions} + end + + it 'sends the endSessions command with all the session ids' do + expect(end_sessions_command.command[:ids]).to include(BSON::Document.new(session_a.session_id)) + expect(end_sessions_command.command[:ids]).to include(BSON::Document.new(session_b.session_id)) + end + + context 'when talking to a mongos', if: sessions_enabled? && sharded? do + + it 'sends the endSessions command with all the session ids' do + expect(end_sessions_command.command[:ids]).to include(BSON::Document.new(session_a.session_id)) + expect(end_sessions_command.command[:ids]).to include(BSON::Document.new(session_b.session_id)) + expect(end_sessions_command.command[:$clusterTime]).to eq(client.cluster.cluster_time) + end + end + + context 'when the number of ids is larger than 10_000' do + + before do + queue = [] + 10_001.times do |i| + queue << double('session', session_id: i) + end + pool.instance_variable_set(:@queue, queue) + expect(Mongo::Operation::Commands::Command).to receive(:new).at_least(:twice) + end + + let(:end_sessions_commands) do + subscriber.started_events.select { |c| c.command_name == :endSessions} + end + + it 'sends the command more than once' do + pool.end_sessions + # expect(end_sessions_commands.size).to eq(2) + # expect(end_sessions_commands[0].command[:ids]).to eq([*0...10_000]) + # expect(end_sessions_commands[1].command[:ids]).to eq([10_000]) + end + end + end +end diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb index 15ed2f4665..42b37e6c49 100644 --- a/spec/spec_helper.rb +++ b/spec/spec_helper.rb @@ -142,6 +142,7 @@ def op_msg_enabled? $op_msg_enabled ||= $mongo_client.cluster.servers.first.features.op_msg_enabled? end alias :change_stream_enabled? :op_msg_enabled? +alias :sessions_enabled? :op_msg_enabled? # Whether change streams can be tested. Change streams are available on server versions 3.6 # and higher and when connected to a replica set. @@ -260,5 +261,65 @@ def initialize_scanned_client! Mongo::Client.new(ADDRESSES, TEST_OPTIONS.merge(database: TEST_DB)) end +# Test event subscriber. +# +# @since 2.5.0 +class EventSubscriber + + # The started events. + # + # @since 2.5.0 + attr_reader :started_events + + # The succeeded events. + # + # @since 2.5.0 + attr_reader :succeeded_events + + # The failed events. + # + # @since 2.5.0 + attr_reader :failed_events + + # Create the test event subscriber. + # + # @example Create the subscriber + # EventSubscriber.new + # + # @since 2.5.0 + def initialize + @started_events = [] + @succeeded_events = [] + @failed_events = [] + end + + # Cache the succeeded event. + # + # @param [ Event ] event The event. + # + # @since 2.5.0 + def succeeded(event) + @succeeded_events.push(event) + end + + # Cache the started event. + # + # @param [ Event ] event The event. + # + # @since 2.5.0 + def started(event) + @started_events.push(event) + end + + # Cache the failed event. + # + # @param [ Event ] event The event. + # + # @since 2.5.0 + def failed(event) + @failed_events.push(event) + end +end + # require all shared examples Dir['./spec/support/shared/*.rb'].sort.each { |file| require file } diff --git a/spec/support/shared/session.rb b/spec/support/shared/session.rb new file mode 100644 index 0000000000..c77e89578b --- /dev/null +++ b/spec/support/shared/session.rb @@ -0,0 +1,236 @@ +shared_examples 'an operation using a session' do + + describe 'operation execution', if: sessions_enabled? do + + context 'when the session is created from the same client used for the operation' do + + let(:session) do + client.start_session + end + + let(:server_session) do + session.instance_variable_get(:@server_session) + end + + let!(:before_last_use) do + server_session.last_use + end + + let!(:before_operation_time) do + (session.instance_variable_get(:@operation_time) || 0) + end + + let!(:operation_result) do + operation + end + + after do + session.end_session + end + + it 'updates the last use value' do + expect(server_session.last_use).not_to eq(before_last_use) + end + + it 'updates the operation time value' do + expect(session.instance_variable_get(:@operation_time)).not_to eq(before_operation_time) + end + + it 'does not close the session when the operation completes' do + expect(session.ended?).to be(false) + end + end + + context 'when a session from another client is provided' do + + let(:session) do + client.start_session + end + + let(:client) do + authorized_client.with(read: { mode: :secondary }) + end + + let(:operation_result) do + operation + end + + it 'raises an exception' do + expect { + operation_result + }.to raise_exception(Mongo::Error::InvalidSession) + end + end + + context 'when the session is ended before it is used' do + + let(:session) do + client.start_session + end + + before do + session.end_session + end + + let(:operation_result) do + operation + end + + it 'raises an exception' do + expect { + operation_result + }.to raise_exception(Mongo::Error::InvalidSession) + end + end + end +end + +shared_examples 'a failed operation using a session' do + + context 'when the operation fails', if: sessions_enabled? do + + let!(:before_last_use) do + session.instance_variable_get(:@server_session).last_use + end + + let!(:before_operation_time) do + (session.instance_variable_get(:@operation_time) || 0) + end + + let!(:operation_result) do + begin; failed_operation; rescue => e; e; end + end + + let(:session) do + client.start_session + end + + it 'raises an error' do + expect([Mongo::Error::OperationFailure, + Mongo::Error::BulkWriteError]).to include(operation_result.class) + end + + it 'updates the last use value' do + expect(session.instance_variable_get(:@server_session).last_use).not_to eq(before_last_use) + end + + it 'updates the operation time value' do + expect(session.instance_variable_get(:@operation_time)).not_to eq(before_operation_time) + end + end +end + +shared_examples 'an operation updating cluster time' do + + let(:cluster) do + client.cluster + end + + let(:client) do + authorized_client.with(heartbeat_frequency: 100).tap do |cl| + cl.subscribe(Mongo::Monitoring::COMMAND, subscriber) + end + end + + let(:subscriber) do + EventSubscriber.new + end + + after do + client.close + end + + context 'when the command is run once' do + + context 'when the server is version 3.6' do + + context 'when the server is a mongos', if: (sharded? && sessions_enabled?) do + + let!(:reply_cluster_time) do + operation + subscriber.succeeded_events[-1].reply['$clusterTime'] + end + + it 'updates the cluster time of the cluster' do + expect(cluster.cluster_time).to eq(reply_cluster_time) + end + end + + context 'when the server is not a mongos', if: (!sharded? && sessions_enabled?) do + + let(:before_cluster_time) do + client.cluster.cluster_time + end + + let!(:reply_cluster_time) do + operation + subscriber.succeeded_events[-1].reply['$clusterTime'] + end + + it 'does not update the cluster time of the cluster' do + expect(before_cluster_time).to eq(before_cluster_time) + end + end + end + + context 'when the server is less than version 3.6', if: !sessions_enabled? do + + let(:before_cluster_time) do + client.cluster.cluster_time + end + + let!(:reply_cluster_time) do + operation + subscriber.succeeded_events[-1].reply['$clusterTime'] + end + + it 'does not update the cluster time of the cluster' do + expect(before_cluster_time).to eq(before_cluster_time) + end + end + end + + context 'when the command is run twice' do + + let!(:reply_cluster_time) do + operation + subscriber.succeeded_events[-1].reply['$clusterTime'] + end + + let(:second_command_cluster_time) do + second_operation + subscriber.started_events[-1].command['$clusterTime'] + end + + context 'when the server is a mongos', if: (sharded? && sessions_enabled?) do + + it 'includes the received cluster time in the second command' do + expect(second_command_cluster_time).to eq(reply_cluster_time) + end + end + + context 'when the server is not a mongos', if: (!sharded? && sessions_enabled?) do + + let(:before_cluster_time) do + client.cluster.cluster_time + end + + it 'does not update the cluster time of the cluster' do + second_command_cluster_time + expect(before_cluster_time).to eq(before_cluster_time) + end + end + end + + context 'when the server is less than version 3.6', if: !sessions_enabled? do + + let(:before_cluster_time) do + client.cluster.cluster_time + end + + it 'does not update the cluster time of the cluster' do + operation + expect(before_cluster_time).to eq(before_cluster_time) + end + end +end