logo <

How I Enabled Sequel to Reuse Active Record's Database Connection

When I started developing the Rails integration for Rodauth, one of the first problems I needed to solve was how to make Rodauth work seamlessly with Active Record, given that it uses Sequel for database interaction. I believed these two could coexist together, because Sequel is mostly hidden from the Rodauth user anyway, and all that really matters is that Rodauth’s SQL statements get executed on the database.

My first approach was to have Sequel connect to the same database as Active Record, and create and close connections in lockstep with Active Record, so that Sequel is connected to the database if and only if Active Record is too. While this was functional, it didn’t play well with transactions. Some of Rodauth’s configuration blocks are executed within a Sequel transaction, and I wanted developers to be able to call Active Record inside them, and have it just work. But this wasn’t the case, because it turns out, if you have two different connections, they aren’t aware of each other’s transactions; you might as well be using two different processes.

ActiveRecord::Base.establish_connection("postgresql:///mydb")
DB = Sequel.connect("postgresql:///mydb")

class Account < Sequel::Model
end
class Profile < ActiveRecord::Base
end

# open sequel transaction
DB.transaction do
  # create record using sequel connection
  account = Account.create(email: "user@example.com", password_hash: "...")

  # open active record transaction
  ActiveRecord::Base.transaction do
    # create associates record using active record connection
    Profile.create(name: "User", account_id: account.id)
    #~> foreign key constraint violation: given account_id is not present in table "accounts"
  end
end

The example above fails because, while the account record was created by Sequel’s connection, its transaction hasn’t yet been committed at the time Active Record’s connection attempted to create the associated profile record. Even though Active Record’s transaction block is physically nested inside Sequel’s, transactions are tied to connections that opened them, so as far as the database is concerned these are two independent transactions.

Moreover, if Sequel did use its own database connection, that would mean the number of open connections to the database would double, which could impact performance and hit maximum connection limits. So, I knew I needed to find a way to make Sequel reuse Active Record’s database connection instead of creating its own.

I decided to build this as a database extension for Sequel, which when loaded, switches Sequel to use Active Record’s database connection:

class Sequel
  class ActiveRecordConnection
    # ... database overrides ...
  end

  Database.register_extension(:activerecord_connection, ActiveRecordConnection)
end
DB = Sequel.connect(...)
DB.extension :activerecord_connection
DB.run "SELECT ..." # executed on Active Record's database connection

Reusing the connection

Sequel’s connection pool is the one in charge of creating new database connections when they’re needed. So, the first and most important step was to bypass it, and retrieve Active Record’s database connection instead.

The Sequel connection is retrieved in Database#synchronize, which gets called for every query, so that’s the ideal place to put the override:

class Sequel::ActiveRecordConnection
  def synchronize(*)
    yield activerecord_connection.raw_connection
  end

  private

  def activerecord_connection
    ActiveRecord::Base.connection
  end
end
DB.synchronize do |connection|
  connection # Active Record's connection object
end

I also needed to account for the fact that Sequel adapters use different connection options than Active Record, and store prepared statements in the connection object. For SQLite and MySQL handling this required relatively little code, while for PostgreSQL I unfortunately needed to copy-paste methods defined on Sequel adapter’s subclass of PG::Connection.

Syncing transaction state

Both Sequel and Active Record track which transactions are in progress and in which order. Currently, even though the connection is shared, Sequel doesn’t know about transactions opened by Active Record and vice-versa.

DB.transaction do
  ActiveRecord::Base.connection.open_transactions #=> 0
end

ActiveRecord::Base.transaction do
  DB.in_transaction? #=> false
end

Syncing this state is important for handling nested transaction blocks, which should either reuse the outer transaction or use a savepoint, depending on the setup. Sequel saves informations about transactions for each connection in @transactions instance variable:

DB.transaction(auto_savepoint: true) do |conn|
  DB.instance_variable_get(:@transactions)[conn] #=> {savepoints: [{auto_savepoint: true}]}

  DB.transaction do # creates a savepoint
    DB.instance_variable_get(:@transactions)[conn] #=> {savepoints: [{auto_savepoint: true}, {auto_savepoint: nil}]}
  end
end

We’ll override the method accessing this instance variable, and sync state about any transactions opened by Active Record:

class Sequel::ActiveRecordConnection
  # ...
  private

  def _trans(conn)
    hash = super || { savepoints: [], activerecord: true }

    # add any transactions/savepoints opened via Active Record
    while hash[:savepoints].length < activerecord_connection.open_transactions
      hash[:savepoints] << { activerecord: true }
    end
    # remove any transactions/savepoints closed via Active Record
    while hash[:savepoints].length > activerecord_connection.open_transactions && hash[:savepoints].last[:activerecord]
      hash[:savepoints].pop
    end
    # sync knowledge about joinability of current Active Record transaction/savepoint
    if activerecord_connection.transaction_open? && !activerecord_connection.current_transaction.joinable?
      hash[:savepoints].last[:auto_savepoint] = true
    end

    if hash[:savepoints].empty? && hash[:activerecord] # Active Record closed last transaction
      Sequel.synchronize { @transactions.delete(conn) }
    else
      Sequel.synchronize { @transactions[conn] = hash }
    end

    super
  end
  # ...
end
ActiveRecord::Base.transaction(requires_new: true) do
  DB.in_transaction? #=> true
  DB.transaction do
    DB.send(:in_savepoint?) #=> true
  end
end

This takes care of Sequel state, now we need to ensure that Active Record’s state is updated when opening transactions via Sequel. We can do this by calling Active Record for beginning, committing, and rolling back transactions:

class Sequel::ActiveRecordConnection
  # ...
  private

  def begin_transaction(conn, opts = OPTS)
    activerecord_connection.begin_transaction(joinable: !opts[:auto_savepoint])
  end

  def commit_transaction(conn, opts = OPTS)
    activerecord_connection.commit_transaction
  end

  def rollback_transaction(conn, opts = OPTS)
    activerecord_connection.rollback_transaction
  end
  # ...
end
DB.transaction(auto_savepoint: true) do
  ActiveRecord::Base.connection.open_transactions #=> 1
  ActiveRecord::Base.transaction do
    ActiveRecord::Base.connection.open_transactions #=> 2
  end
end

Fixing transaction hooks

Because we’ve preserved the format of transaction state, Sequel’s after commit/rollback hooks still work when Sequel holds the outer transaction:

DB.transaction do
  DB.after_commit { puts "=> after commit" }
  DB.after_rollback { puts "=> after rollback" }
  DB.run "SELECT 1"
end
# BEGIN
# SELECT 1
# COMMIT
# => after commit

However, they don’t work when Active Record holds the outer transaction, because in that case Active Record is the one committing the transaction, and Sequel doesn’t get notified.

ActiveRecord::Base.transaction do
  DB.after_commit { puts "doesn't get called" }
  DB.run "SELECT 1"
end
# BEGIN
# SELECT 1
# COMMIT

We can fix this by using the after_commit_everywhere gem to register after commit/rollback callbacks into Active Record when it holds the outer transaction. Sequel will call either #add_transaction_hook or #add_savepoint_hook method, depending on whether the hook was registered within a transaction or a savepoint, so we’ll override those:

$ gem install after_commit_everywhere
require "after_commit_everywhere"

class Sequel::ActiveRecordConnection
  # ...
  private

  def add_transaction_hook(conn, type, block)
    if _trans(conn)[:activerecord] # Active Record holds the outer transaction
      AfterCommitEverywhere.public_send(type, &block)
    else
      super
    end
  end

  def add_savepoint_hook(conn, type, block)
    if _trans(conn)[:savepoints].last[:activerecord] # Active Record holds the savepoint
      AfterCommitEverywhere.public_send(type, &block)
    else
      super
    end
  end
  # ...
end
ActiveRecord::Base.transaction do
  DB.after_commit { puts "=> gets called" }
  DB.run "SELECT 1"
end
# BEGIN
# SELECT 1
# COMMIT
# => gets called

Instrumenting SQL queries

Active Record logs its SQL queries through a log subscriber that listens for sql.active_record notifications. To make the integration seamless, I wanted Sequel’s SQL queries to be logged via Active Record’s logger.

Sequel logging is happening in Database#log_connection_yield, so we’ll want to override that, and instrument the query execution with Active Support:

class Sequel::ActiveRecordConnection
  # ...
  def log_connection_yield(sql, conn, args = nil)
    sql += "; #{args.inspect}" if args # include bound variables in the output

    activerecord_log(sql) { super }
  end

  private

  def activerecord_log(sql)
    ActiveSupport::Notifications.instrument(
      "sql.active_record",
      sql:        sql,
      name:       "Sequel",
      connection: activerecord_connection,
      &block
    )
  end
  # ...
end
ActiveRecord::Base.logger = Logger.new($stdout)

DB[:records].where(foo: "bar").all
#>> Sequel (0.7ms) SELECT * "records" WHERE "foo" = 'bar'

Wrapping it up

I committed the initial version into the rodauth-rails gem, but I soon realized that getting Sequel to reuse Active Record’s database connection is not specific to Rodauth, so I extracted it into the sequel-activerecord_connection gem.

I’m glad I did, because it opened doors that weren’t previously open. People can now try out Sequel alongside Active Record without any performance cost or mental overhead, which can be pretty handy given that Sequel can do lots of things Active Record can’t.

It took several iterations to get the implementation right, but extracting it into its own gem helped me focus on this problem in isolation, and converge on the correct behaviour. The end result is a solution that covers much wider use cases than the original problem.

Janko Marohnić