At Buildkite, we’ve got Active Record set up with multiple databases, including some horizontal sharding, and we utilise automatic and (granular) manual role switching quite heavily.
A pattern that arises with such a setup is manually switching the connection for specific write queries when in a request that has been automatically switched to use the reader role (e.g., GET/HEAD). Conversely, we also manually switch the connection for specific, large read queries to reduce load on the writer when the request isn’t readonly. Example:
# app/models/application_record.rb
class ApplicationRecord < ActiveRecord::Base
self.abstract_class = true
connects_to database: { reading: :primary_replica, writing: :primary }
def self.with_replica(&block) = connected_to(role: :reading, &block)
end
# app/models/sub_abstract_record.rb
class SubAbstractRecord < ApplicationRecord
self.abstract_class = true
connects_to shards: {
shard_one: { writing: :shard_one, reading: :shard_one_replica },
shard_two: { writing: :shard_two, reading: :shard_two_replica }
}
end
# app/models/application_model.rb
class ApplicationModel < ApplicationRecord; end
# app/models/sub_model.rb
class SubModel < SubAbstractRecord; end
# In a POST request
...
record = SubAbstractRecord.with_replica do
SubModel.where(...).find_by(...)
end
...
records = ApplicationRecord.with_replica do
ApplicationModel.where(...).to_a
end
...
My feature request is for a relation API that allows switching the connection within the relation/query itself (at the point of execution), avoiding the need to wrap them in blocks. Example:
# In a POST request
...
record = SubModel.connected_to(role: :reading).where(...).find_by(...)
...
records = ApplicationModel.connected_to(role: :reading).where(...).to_a
...
We could then abstract this into a class method and it’ll be accessible as a scope:
# app/models/application_record.rb
class ApplicationRecord < ActiveRecord::Base
self.abstract_class = true
connects_to database: { reading: :primary_replica, writing: :primary }
# for relations
def self.with_replica = connected_to(role: :reading)
# for blocks
def self.with_replica(&block) = connected_to(role: :reading, &block)
end
# In a POST request
...
record = ApplicationModel.with_replica.where(...).find_by(...)
...
records = ApplicationModel.with_replica.where(...).to_a
...
A side effect of such an implementation would be that we would enable role switching on non-base/abstract classes which is currently not allowed, and infer the target abstract class, which doesn’t seem difficult to me.
I hacked on this a bit and managed to get it working a while back, so opening this to see if there’s any interest in such a feature and to get any feedback from the core team so I can send a PR. TIA!