Being Lazy: Using the Ruby driver to connect to MongoDB

Back | rake, mongodb, ruby | 9/12/2011 |

One of my rake tasks failed with an interesting error this morning.

  1. uncaught exception: error: { "$err" : "not master and slaveok=false", "code" : 13435 }

Qu’est-ce que c’est?

The problem is that I am connecting to a slave in a replica set and trying to execute a write operation that must happen on a master node. That’s because I was lazy and was executing command-line update queries, such as this one. I was being lazy. Shame on me.

  1. system "mongo #{db_host}:#{db_port}/#{db_name} -u #{db_user} -p#{db_password} --eval 'db.widgets.drop()'"

This is run in a Rake task. Lets replace this with some Ruby code, the way it’s ought to be.

  1. db = Mongo::Connection.new(db_host, db_port).db(db_name)
  2. db.authenticate(db_user, db_password) unless (db.user.nil? || db.user.blank?)      
  3. db.collection("widgets").drop()

It’s actually a lot cleaner, I am not sure why I was hung up on the command line thing. Unfortunately it doesn’t fix our problem. In a replica set we need to use a ReplSetConnection that will automatically load-balance requests and send writes to the master. It takes a list of hosts, something like

  1. db_connection = Mongo::ReplSetConnection.new(db_host_list).db(db_name)
  2. db_connection.authenticate(db_user, db_password)
  3. db_connection("widgets").drop()

Lets try to write something usable in all of our rake tasks. What I have is a YML file with the Heroku MongoHQ configuration. The first environment is a replica set, while the second is a single MongoDB.

  1. production:
  2.   config:
  3.     MONGOHQ_URL:          "mongodb://heroku:password@replica.mongohq.com:12345/production-name"
  4.     MONGOHQ_DATABASE:     "db-name"
  5.     MONGOHQ_HOST_LIST:    "[['node0.replica.mongohq.com', 12345], ['node1.replica.mongohq.com', 12345]]"
  6.     MONGOHQ_PASSWD:       "password"
  7.     MONGOHQ_USER:         "heroku"
  8.  
  9. staging:
  10.   config: &default
  11.     MONGOHQ_URL:          "mongodb://heroku:password@small.mongohq.com:12345/staging-name"

We can write a basic connect  method that picks up the right configuration, as a Rake task.

  1. namespace :mongohq do
  2.     
  3.     def heroku_config(env = Rails.env)
  4.       @@config ||= YAML.load_file(Rails.root.join("config/heroku.yml")).symbolize_keys
  5.       config_env = @@config[env.to_sym]
  6.       raise "missing '#{env}' section in config/heroku.yml" if config_env.nil?
  7.       config_env["config"]
  8.     end
  9.     
  10.     def parse_mongohq_url(url)
  11.       uri = URI.parse(url)
  12.       [ uri, uri.path.gsub("/", "") ]
  13.     end
  14.     
  15.     # connect to a MongoDB
  16.     def mongohq_connect(env = Rails.env)
  17.       config = heroku_config(env)
  18.       if ! config["MONGOHQ_HOST_LIST"].blank?
  19.         mongohq_host_list = eval(config["MONGOHQ_HOST_LIST"])
  20.         puts "[#{Time.now}] connecting to #{config["MONGOHQ_DATABASE"]} on #{eval(config["MONGOHQ_HOST_LIST"])}"
  21.         db_connection = Mongo::ReplSetConnection.new(* mongohq_host_list).db(config["MONGOHQ_DATABASE"])
  22.         db_connection.authenticate(config["MONGOHQ_USER"], config["MONGOHQ_PASSWD"])
  23.         db_connection
  24.       elsif ! config["MONGOHQ_URL"].blank?
  25.         puts "[#{Time.now}] connecting to #{config["MONGOHQ_URL"]}"
  26.         db, db_name = parse_mongohq_url(config["MONGOHQ_URL"])
  27.         db_connection = Mongo::Connection.new(db.host, db.port).db(db_name)
  28.         db_connection.authenticate(db.user, db.password) unless (db.user.nil? || db.user.blank?)
  29.         db_connection
  30.       else
  31.         raise "missing MONGOHQ_URL or MONGOHQ_HOST_LIST for #{env} environment"
  32.       end
  33.     end
  34.     
  35. end

Now, our rake tasks can call mongohq_connect(:production) or mongohq_connect(:staging) without having to worry about the kind of setup we have.