Skip to content

Instantly share code, notes, and snippets.

@julik
Created April 22, 2025 09:37
Show Gist options
  • Save julik/3e786ca3ba28d8113432c3b2ff21f38b to your computer and use it in GitHub Desktop.
Save julik/3e786ca3ba28d8113432c3b2ff21f38b to your computer and use it in GitHub Desktop.
# This class encapsulates a unit of work done for a particular tenant, connected to that tenant's database.
# ActiveRecord makes it _very_ hard to do in a simple manner and clever stuff is required, but it is knowable.
#
# What this class provides is a "misuse" of the database "roles" of ActiveRecord to have a role per tenant.
# If all the tenants are predefined, it can be done roughly so:
#
# ActiveRecord::Base.legacy_connection_handling = false if ActiveRecord::Base.respond_to?(:legacy_connection_handling)
# $databases.each_pair do |n, db_path|
# config_hash = {
# "adapter" => 'sqlite3',
# "database" => db_path,
# "pool" => 4
# }
# ActiveRecord::Base.connection_handler.establish_connection(config_hash, role: "database_#{n}")
# end
#
# def named_databases_as_roles_using_connected_to(n, from_database_paths)
# ActiveRecord::Base.connected_to(role: "database_#{n}") do
# query_and_compare!(n)
# end
# end
#
# So what we do is this:
#
# * We want one connection pool per tenant (per database, thus)
# * We want to grab a connection from that pool and make sure our queries use that connection
# * Once we are done with our unit of work we want to return the connection to the pool
#
# This also uses a stack of Fibers because `connected_to` in ActiveRecord _wants_ to have a block, but for us
# "leaving" the context of a unit of work can happen in a Rack body close() call.
class DatabaseContext
POOL_CHECK_MUTEX = Mutex.new
def initialize(single_connection_config_hash)
if ActiveRecord::Base.respond_to?(:legacy_connection_handling) && ActiveRecord::Base.legacy_connection_handling
raise "ActiveRecord::Base.legacy_connection_handling is enabled (set to `true`) and we can't use roles that way."
end
@config_hash = single_connection_config_hash.with_indifferent_access
@role_name = "tenant_db_#{Digest::SHA1.hexdigest(@config_hash.fetch(:database))}"
@context_fiber = nil
end
def enter
create_pool_for_database_if_none_available!
@context_fiber = Fiber.new do
ActiveRecord::Base.connected_to(role: @role_name) { Fiber.yield }
end
@context_fiber.resume
true
end
def leave
fiber, @context_fiber = @context_fiber, nil
return unless fiber
fiber.resume
end
def with(&blk)
create_pool_for_database_if_none_available!
ActiveRecord::Base.connected_to(role: @role_name, &blk)
end
# Unlike Rails, we connect not when the app boots - but at the start of the first request to a particular database.
# If we don't connect - there will be a NoConnectionPool error.
def create_pool_for_database_if_none_available!
POOL_CHECK_MUTEX.synchronize do
# Maybe there is a way to connect to a particular role using just ActiveRecord::Base.establish_connection, but I could not find it.
# There is a way to connect to a particular _role_ - we have to connect using the connection handler instead of AR::Base though.
# We pass it the config hash and specify the role. This is originally made for read replicas, but honey badger don't give a s_t.
#
# AR does not check whether we are trying to connect to the same DB under the same role, so we need to check whether there are
# any pools for that role already. If there are - they will be used and there won't be any errors. If there are not - we need
# to establish_connection.
#
# We do this under a mutex because I am too lazy to try and figure out whether `connection_pool_list` and `establish_connection`
# are thread-safe or not, and under which versions. For 99% of requests it will be just an array size check anyway.
if ActiveRecord::Base.connection_handler.connection_pool_list(@role_name).none?
ActiveRecord::Base.connection_handler.establish_connection(@config_hash, role: @role_name)
end
end
end
end
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment