Created
April 22, 2025 09:37
-
-
Save julik/3e786ca3ba28d8113432c3b2ff21f38b to your computer and use it in GitHub Desktop.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
# 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