Repository: magnusvk/counter_culture
Pull request #405: Add global configuration for read replica support
Author: login: ashishra0
Created: 2025-03-14T16:03:36Z | Merged: 2025-03-20T14:45:23Z

## Description
Adds a global configuration option to use read replicas for counting operations in counter cache reconciliation. This simplifies the process of using read replicas - instead of configuring a custom connection builder for each call, users can now enable it globally.

For feature #403 

## Changes
- Added `use_read_replica` configuration option
- When enabled, automatically uses:
  - Read replica for counting operations
  - Primary database for updates
- Maintains compatibility with existing `db_connection_builder` option (custom builders take precedence)

## Usage
Enable read replica support globally:
```ruby
# config/initializers/counter_culture.rb
CounterCulture.configure do |config|
  config.use_read_replica = true
end
```

Previous approach (still supported):
```ruby
# Manual configuration per call
Product.counter_culture_fix_counts(
  db_connection_builder: proc{|reading, block|
    if reading
      Product.connected_to(role: :reading, &block)
    else
      Product.connected_to(role: :writing, &block)
    end
  }
)
```


Diff:
diff --git a/README.md b/README.md
index 30f8c4ca..6063a903 100644
--- a/README.md
+++ b/README.md
@@ -618,6 +618,39 @@ Image.counter_culture_fix_counts(polymorphic_classes: Product)
 Image.counter_culture_fix_counts(polymorphic_classes: [Product, Employee])
 ```
 
+## Using Read Replicas
+
+When using `counter_culture_fix_counts`, you can configure counter_culture to use read replicas for counting operations, which can help reduce load on your primary database. Write operations will still use the primary database.
+
+### Global Configuration
+
+You can enable read replica support globally:
+
+```ruby
+# config/initializers/counter_culture.rb
+CounterCulture.configure do |config|
+  config.use_read_replica = true
+end
+```
+
+### Per-Call Configuration
+
+You can also specify read replica usage per call (this takes precedence over global configuration):
+
+```ruby
+Model.counter_culture_fix_counts(
+  db_connection_builder: proc { |reading, block|
+    if reading
+      ApplicationRecord.connected_to(role: :reading, &block)
+    else
+      ApplicationRecord.connected_to(role: :writing, &block)
+    end
+  }
+)
+```
+
+Note: This feature requires Rails 7.1 or higher for full read replica support. On older versions, it will fall back to using the primary database.
+
 ## Contributing to counter_culture
 
 * Check out the latest master to make sure the feature hasn't been implemented or the bug hasn't been fixed yet.
diff --git a/lib/counter_culture.rb b/lib/counter_culture.rb
index 6706fcdd..53ff74b5 100644
--- a/lib/counter_culture.rb
+++ b/lib/counter_culture.rb
@@ -3,6 +3,7 @@
 
 require 'counter_culture/version'
 require 'counter_culture/extensions'
+require 'counter_culture/configuration'
 require 'counter_culture/counter'
 require 'counter_culture/reconciler'
 require 'counter_culture/skip_updates'
diff --git a/lib/counter_culture/configuration.rb b/lib/counter_culture/configuration.rb
new file mode 100644
index 00000000..95702188
--- /dev/null
+++ b/lib/counter_culture/configuration.rb
@@ -0,0 +1,34 @@
+module CounterCulture
+  def self.configuration
+    @configuration ||= Configuration.new
+  end
+
+  def self.configure
+    yield(configuration)
+  end
+
+  def self.reset_configuration
+    @configuration = Configuration.new
+  end
+
+  class Configuration
+    attr_reader :use_read_replica
+
+    def initialize
+      @use_read_replica = false
+    end
+
+    def use_read_replica=(value)
+      if value && !rails_supports_read_replica?
+        raise "Counter Culture's read replica support requires Rails 6.1 or higher"
+      end
+      @use_read_replica = value
+    end
+
+    private
+
+    def rails_supports_read_replica?
+      Gem::Requirement.new('>= 7.1.0').satisfied_by?(ActiveRecord.version)
+    end
+  end
+end
diff --git a/lib/counter_culture/reconciler.rb b/lib/counter_culture/reconciler.rb
index 66e633e9..cb889d05 100644
--- a/lib/counter_culture/reconciler.rb
+++ b/lib/counter_culture/reconciler.rb
@@ -66,6 +66,7 @@ def initialize(counter, changes_holder, options, relation_class)
         @counter, @options, = counter, options
         @relation_class = relation_class
         @changes_holder = changes_holder
+        @connection_handler = WithConnection.new(relation_class)
       end
 
       def perform
@@ -314,7 +315,7 @@ def join_clauses(where)
       # using Postgres with schema-namespaced tables. But then it's required,
       # and otherwise it's just a no-op, so why not do it?
       def quote_table_name(table_name)
-        WithConnection.new(relation_class).call do |connection|
+        @connection_handler.call do |connection|
           connection.quote_table_name(table_name)
         end
       end
@@ -331,7 +332,9 @@ def with_reading_db_connection(&block)
         if builder = options[:db_connection_builder]
           builder.call(true, block)
         else
-          yield
+          @connection_handler.call(reading: true) do |_conn|
+            yield
+          end
         end
       end
 
@@ -339,7 +342,9 @@ def with_writing_db_connection(&block)
         if builder = options[:db_connection_builder]
           builder.call(false, block)
         else
-          yield
+          @connection_handler.call(reading: false) do |_conn|
+            yield
+          end
         end
       end
     end
diff --git a/lib/counter_culture/with_connection.rb b/lib/counter_culture/with_connection.rb
index 69872e1a..2754edad 100644
--- a/lib/counter_culture/with_connection.rb
+++ b/lib/counter_culture/with_connection.rb
@@ -6,28 +6,42 @@ def initialize(recipient)
 
     attr_reader :recipient
 
-    def call
-      if rails_7_2_or_greater?
-        recipient.with_connection do |connection|
-          yield connection
-        end
-      elsif rails_7_1?
-        recipient.connection_pool.with_connection do |connection|
-          yield connection
+    def call(reading: false)
+      if rails_7_1_or_greater?
+        use_read_replica = CounterCulture.configuration.use_read_replica && reading
+        role = use_read_replica ? :reading : :writing
+
+        ActiveRecord::Base.connected_to(role: role) do
+          yield_with_connection { |conn| yield conn }
         end
       else
-        yield recipient.connection
+        # For older Rails versions, just use normal connection
+        yield_with_connection { |conn| yield conn }
       end
     end
 
     private
 
-    def rails_7_1?
-      Gem::Requirement.new('~> 7.1.0').satisfied_by?(ActiveRecord.version)
+    def yield_with_connection
+      if rails_7_2_or_greater?
+        recipient.with_connection { |conn| yield conn }
+      elsif rails_7_1?
+        recipient.connection_pool.with_connection { |conn| yield conn }
+      else
+        yield recipient.connection
+      end
+    end
+
+    def rails_7_1_or_greater?
+      Gem::Requirement.new('>= 7.1.0').satisfied_by?(ActiveRecord.version)
     end
 
     def rails_7_2_or_greater?
       Gem::Requirement.new('>= 7.2.0').satisfied_by?(ActiveRecord.version)
     end
+
+    def rails_7_1?
+      Gem::Requirement.new('~> 7.1.0').satisfied_by?(ActiveRecord.version)
+    end
   end
 end
diff --git a/spec/counter_culture/configuration_spec.rb b/spec/counter_culture/configuration_spec.rb
new file mode 100644
index 00000000..9d4549ef
--- /dev/null
+++ b/spec/counter_culture/configuration_spec.rb
@@ -0,0 +1,63 @@
+require 'spec_helper'
+
+RSpec.describe CounterCulture::Configuration do
+  after(:each) do
+    CounterCulture.reset_configuration
+  end
+
+  describe '.configure' do
+    context 'when Rails version is >= 6.1' do
+      before do
+        allow_any_instance_of(CounterCulture::Configuration)
+          .to receive(:rails_supports_read_replica?)
+          .and_return(true)
+      end
+
+      it 'allows setting use_read_replica' do
+        CounterCulture.configure do |config|
+          config.use_read_replica = true
+        end
+
+        expect(CounterCulture.configuration.use_read_replica).to be true
+      end
+
+      it 'defaults to false' do
+        expect(CounterCulture.configuration.use_read_replica).to be false
+      end
+    end
+
+    context 'when Rails version is < 6.1' do
+      before do
+        allow_any_instance_of(CounterCulture::Configuration)
+          .to receive(:rails_supports_read_replica?)
+          .and_return(false)
+      end
+
+      it 'raises error when setting use_read_replica to true' do
+        expect {
+          CounterCulture.configure do |config|
+            config.use_read_replica = true
+          end
+        }.to raise_error("Counter Culture's read replica support requires Rails 6.1 or higher")
+      end
+    end
+  end
+
+  describe '.reset_configuration' do
+    before do
+      allow_any_instance_of(CounterCulture::Configuration)
+        .to receive(:rails_supports_read_replica?)
+        .and_return(true)
+    end
+
+    it 'resets to default values' do
+      CounterCulture.configure do |config|
+        config.use_read_replica = true
+      end
+
+      CounterCulture.reset_configuration
+
+      expect(CounterCulture.configuration.use_read_replica).to be false
+    end
+  end
+end
diff --git a/spec/counter_culture_spec.rb b/spec/counter_culture_spec.rb
index 8ef8e703..ad277544 100644
--- a/spec/counter_culture_spec.rb
+++ b/spec/counter_culture_spec.rb
@@ -3336,4 +3336,83 @@ def mess_up_counts
       expect(article_group.articles_count).to eq(1)
     end
   end
+
+  context "with read replica configuration" do
+    let(:company) { Company.create! }
+
+    before do
+      company.children << Company.create!
+      company.update_column(:children_count, -1)
+    end
+
+    context "with Rails 7.1+" do
+      before do
+        allow_any_instance_of(CounterCulture::WithConnection)
+          .to receive(:rails_7_1_or_greater?)
+          .and_return(true)
+        allow_any_instance_of(CounterCulture::Configuration)
+          .to receive(:rails_supports_read_replica?)
+          .and_return(true)
+      end
+
+      it "uses read replica when enabled" do
+        CounterCulture.configure do |config|
+          config.use_read_replica = true
+        end
+
+        roles_used = []
+        allow(ActiveRecord::Base).to receive(:connected_to).and_wrap_original do |original, **kwargs, &block|
+          roles_used << kwargs[:role]
+          block.call if block
+        end
+
+        Company.counter_culture_fix_counts
+
+        expect(roles_used).to include(:reading)
+        expect(roles_used - [:reading]).to all(eq(:writing))
+      end
+
+      it "does not use read replica when disabled" do
+        CounterCulture.configure do |config|
+          config.use_read_replica = false
+        end
+
+        roles_used = []
+        allow(ActiveRecord::Base).to receive(:connected_to).and_wrap_original do |original, **kwargs, &block|
+          roles_used << kwargs[:role]
+          block.call if block
+        end
+
+        Company.counter_culture_fix_counts
+
+        expect(roles_used).to include(:writing)
+      end
+    end
+
+    context "with Rails < 7.1" do
+      before do
+        allow_any_instance_of(CounterCulture::WithConnection)
+          .to receive(:rails_7_1_or_greater?)
+          .and_return(false)
+        allow_any_instance_of(CounterCulture::Configuration)
+          .to receive(:rails_supports_read_replica?)
+          .and_return(false)
+      end
+
+      it "works without read replica support" do
+        # Should raise error when trying to enable read replica
+        expect {
+          CounterCulture.configure do |config|
+            config.use_read_replica = true
+          end
+        }.to raise_error("Counter Culture's read replica support requires Rails 6.1 or higher")
+
+        # Should not use connected_to at all
+        expect(ActiveRecord::Base).not_to receive(:connected_to)
+  
+        Company.counter_culture_fix_counts
+        expect(company.reload.children_count).to eq(1)
+      end
+    end
+  end
 end


Reviews:
user: login: magnusvk
state: COMMENTED
author_association: OWNER
submitted_at: 2025-03-14T19:48:03Z
user: login: ashishra0
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-15T05:02:54Z
user: login: magnusvk
state: COMMENTED
author_association: OWNER
submitted_at: 2025-03-17T17:12:49Z
user: login: magnusvk
state: COMMENTED
author_association: OWNER
submitted_at: 2025-03-17T17:13:01Z
user: login: magnusvk
state: APPROVED
author_association: OWNER
submitted_at: 2025-03-17T17:14:11Z
user: login: ashishra0
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-18T16:27:54Z
user: login: ashishra0
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-18T16:30:12Z
user: login: magnusvk
state: COMMENTED
author_association: OWNER
submitted_at: 2025-03-20T02:48:28Z
user: login: ashishra0
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-20T04:16:54Z

Review comments:
diff_hunk: @@ -6,22 +6,27 @@ def initialize(recipient)
 
     attr_reader :recipient
 
-    def call
+    def call(reading: false)
+      use_read_replica = CounterCulture.configuration.use_read_replica && reading
+      role = use_read_replica ? :reading : :writing
+
+      ActiveRecord::Base.connected_to(role: role) do
path: lib/counter_culture/with_connection.rb
user: login: magnusvk
body: I think to get the tests to pass for older Rails versions we'll have to do this only if we're at Rails 7.1 or greater, right?
created_at: 2025-03-14T19:48:03Z
author_association: OWNER
diff_hunk: @@ -6,22 +6,27 @@ def initialize(recipient)
 
     attr_reader :recipient
 
-    def call
+    def call(reading: false)
+      use_read_replica = CounterCulture.configuration.use_read_replica && reading
+      role = use_read_replica ? :reading : :writing
+
+      ActiveRecord::Base.connected_to(role: role) do
path: lib/counter_culture/with_connection.rb
user: login: ashishra0
body: Yeah that's right!
created_at: 2025-03-15T05:02:54Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,21 @@
+module CounterCulture
+  def self.configuration
+    @configuration ||= Configuration.new
+  end
+
+  def self.configure
+    yield(configuration)
+  end
+
+  def self.reset_configuration
+    @configuration = Configuration.new
+  end
+
+  class Configuration
+    attr_accessor :use_read_replica
path: lib/counter_culture/configuration.rb
user: login: magnusvk
body: It would be great to have this raise an exception if someone tries to set it on an older Rails version. Is that simple to add? Not a requirement if not.
created_at: 2025-03-17T17:12:48Z
author_association: OWNER
diff_hunk: @@ -618,6 +618,39 @@ Image.counter_culture_fix_counts(polymorphic_classes: Product)
 Image.counter_culture_fix_counts(polymorphic_classes: [Product, Employee])
 ```
 
+## Using Read Replicas
+
+When using `counter_culture_fix_counts`, you can configure Counter Culture to use read replicas for counting operations, which can help reduce load on your primary database. Write operations will still use the primary database.
path: README.md
user: login: magnusvk
body: ```suggestion
When using `counter_culture_fix_counts`, you can configure counter_culture to use read replicas for counting operations, which can help reduce load on your primary database. Write operations will still use the primary database.
```
created_at: 2025-03-17T17:13:01Z
author_association: OWNER
diff_hunk: @@ -0,0 +1,21 @@
+module CounterCulture
+  def self.configuration
+    @configuration ||= Configuration.new
+  end
+
+  def self.configure
+    yield(configuration)
+  end
+
+  def self.reset_configuration
+    @configuration = Configuration.new
+  end
+
+  class Configuration
+    attr_accessor :use_read_replica
path: lib/counter_culture/configuration.rb
user: login: ashishra0
body: Yeah should be simple enough!
created_at: 2025-03-18T16:27:54Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,21 @@
+module CounterCulture
+  def self.configuration
+    @configuration ||= Configuration.new
+  end
+
+  def self.configure
+    yield(configuration)
+  end
+
+  def self.reset_configuration
+    @configuration = Configuration.new
+  end
+
+  class Configuration
+    attr_accessor :use_read_replica
path: lib/counter_culture/configuration.rb
user: login: ashishra0
body: Pushed a commit for the same
created_at: 2025-03-18T16:30:11Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,36 @@
+module CounterCulture
+  def self.configuration
+    @configuration ||= Configuration.new
+  end
+
+  def self.configure
+    yield(configuration)
+  end
+
+  def self.reset_configuration
+    @configuration = Configuration.new
+  end
+
+  class Configuration
+    attr_reader :use_read_replica
+
+    def initialize
+      @use_read_replica = false
+    end
+
+    def use_read_replica=(value)
+      if value && !rails_supports_read_replica?
+        raise "Counter Culture's read replica support requires Rails 6.1 or higher"
+      end
+      @use_read_replica = value
+    end
+
+    private
+
+    def rails_supports_read_replica?
+      Rails::VERSION::STRING.to_f >= 6.1
+    rescue NameError
+      false
path: lib/counter_culture/configuration.rb
user: login: magnusvk
body: ```suggestion
      Gem::Requirement.new('>= 7.1.0').satisfied_by?(ActiveRecord.version)
```
I think it's 7.1, not 6.1 that's required? And if we use this same approach of checking the ActiveRecord version then I think that avoids needing to rescue `NameError`
created_at: 2025-03-20T02:48:27Z
author_association: OWNER
diff_hunk: @@ -0,0 +1,36 @@
+module CounterCulture
+  def self.configuration
+    @configuration ||= Configuration.new
+  end
+
+  def self.configure
+    yield(configuration)
+  end
+
+  def self.reset_configuration
+    @configuration = Configuration.new
+  end
+
+  class Configuration
+    attr_reader :use_read_replica
+
+    def initialize
+      @use_read_replica = false
+    end
+
+    def use_read_replica=(value)
+      if value && !rails_supports_read_replica?
+        raise "Counter Culture's read replica support requires Rails 6.1 or higher"
+      end
+      @use_read_replica = value
+    end
+
+    private
+
+    def rails_supports_read_replica?
+      Rails::VERSION::STRING.to_f >= 6.1
+    rescue NameError
+      false
path: lib/counter_culture/configuration.rb
user: login: ashishra0
body: Oh yes you're right!
created_at: 2025-03-20T04:16:54Z
author_association: CONTRIBUTOR

PR comments:
user: login: ashishra0
created_at: 2025-03-14T16:08:05Z
body: @magnusvk Does this make sense?
author_association: CONTRIBUTOR
user: login: magnusvk
created_at: 2025-03-14T19:49:20Z
body: I think this looks good, yeah—I'd be happy to add this. Could you make sure the tests pass for older Rails versions, too?
author_association: OWNER
user: login: ashishra0
created_at: 2025-03-15T04:13:59Z
body: > I think this looks good, yeah—I'd be happy to add this. Could you make sure the tests pass for older Rails versions, too?

Yeah I will get that sorted!
author_association: CONTRIBUTOR
user: login: magnusvk
created_at: 2025-03-20T15:07:56Z
body: Released as gem version 3.9.0. Thanks for the contribution!
author_association: OWNER
user: login: ashishra0
created_at: 2025-03-20T15:45:13Z
body: Thank you so much for the quick review and merging it :)
author_association: CONTRIBUTOR