resilient_reads 0.1.0 → 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 6e7c0878b6b094bf3bad43d07eb6ee29d7dd27aa49aa50ea125a5d760fb3669e
4
- data.tar.gz: 5a04bbd5b53d6aab8bfcf713e27a520f0c82bca1274e48271d737a2a79319bb8
3
+ metadata.gz: 4bbb75d50de2bb59656b1f4e944a3187c01c16c36fb02a21c30a7c3dc7eee953
4
+ data.tar.gz: 6474b24d88c87626dc7d35a2b080ea06d603d0a4071733122e73273cd2143404
5
5
  SHA512:
6
- metadata.gz: 76042b2aa715011260f54d32e8960a224b95a95a69cb3d53ea41c72512751189ed06af05cd9cf584c6358883688e23d22cf4661cf43e9572a7e97a95db808a1e
7
- data.tar.gz: 1871f6426fff9338da34e3142f633c5107fff4045fa7c770fd74a32e5c534c700972d6c876e2d26e5ae42440896e0d8ab840b68cd2875f91a5003eecf40b135f
6
+ metadata.gz: a6c3241dd6d5a240262da022d8f53a5946d54d15c1efb1d47e3d23fb645db3d4d58ebcd5713ace5700e0d733a1171188692f3ffe00819fa79d7a43bed6260f4c
7
+ data.tar.gz: 82e30dc4fcbffa079390b60fa4e2f35cfb68025ea7fc88a33896ce5aed3faabc1b094d52cd558c017e7de3fc64bb017b41c6217fdb0709295a0a1b9946db4331
data/.idea/.gitignore ADDED
@@ -0,0 +1,10 @@
1
+ # Default ignored files
2
+ /shelf/
3
+ /workspace.xml
4
+ # Editor-based HTTP Client requests
5
+ /httpRequests/
6
+ # Ignored default folder with query files
7
+ /queries/
8
+ # Datasource local storage ignored files
9
+ /dataSources/
10
+ /dataSources.local.xml
data/.idea/modules.xml ADDED
@@ -0,0 +1,8 @@
1
+ <?xml version="1.0" encoding="UTF-8"?>
2
+ <project version="4">
3
+ <component name="ProjectModuleManager">
4
+ <modules>
5
+ <module fileurl="file://$PROJECT_DIR$/.idea/resilient_reads.iml" filepath="$PROJECT_DIR$/.idea/resilient_reads.iml" />
6
+ </modules>
7
+ </component>
8
+ </project>
@@ -0,0 +1,18 @@
1
+ <?xml version="1.0" encoding="UTF-8"?>
2
+ <module type="RUBY_MODULE" version="4">
3
+ <component name="ModuleRunConfigurationManager">
4
+ <shared />
5
+ </component>
6
+ <component name="NewModuleRootManager">
7
+ <content url="file://$MODULE_DIR$">
8
+ <sourceFolder url="file://$MODULE_DIR$/features" isTestSource="true" />
9
+ <sourceFolder url="file://$MODULE_DIR$/spec" isTestSource="true" />
10
+ <sourceFolder url="file://$MODULE_DIR$/test" isTestSource="true" />
11
+ </content>
12
+ <orderEntry type="jdk" jdkName="RVM: ruby-3.4.2" jdkType="RUBY_SDK" />
13
+ <orderEntry type="sourceFolder" forTests="false" />
14
+ <orderEntry type="library" scope="PROVIDED" name="bundler (v2.7.2, RVM: ruby-3.4.2) [gem]" level="application" />
15
+ <orderEntry type="library" scope="PROVIDED" name="minitest (v5.25.4, RVM: ruby-3.4.2) [gem]" level="application" />
16
+ <orderEntry type="library" scope="PROVIDED" name="rake (v13.3.1, RVM: ruby-3.4.2) [gem]" level="application" />
17
+ </component>
18
+ </module>
data/.idea/vcs.xml ADDED
@@ -0,0 +1,6 @@
1
+ <?xml version="1.0" encoding="UTF-8"?>
2
+ <project version="4">
3
+ <component name="VcsDirectoryMappings">
4
+ <mapping directory="$PROJECT_DIR$" vcs="Git" />
5
+ </component>
6
+ </project>
@@ -0,0 +1,21 @@
1
+ module ResilientReads
2
+ # Mix into ActiveJob::Base to provide a class-level +distribute_reads+
3
+ # macro that wraps the entire +perform+ in a distribute_reads block.
4
+ #
5
+ # class ReportJob < ApplicationJob
6
+ # distribute_reads
7
+ # def perform; ... end
8
+ # end
9
+ #
10
+ module ActiveJobExtension
11
+ extend ActiveSupport::Concern
12
+
13
+ class_methods do
14
+ def distribute_reads(**options)
15
+ around_perform do |_job, block|
16
+ ResilientReads.run(**options, &block)
17
+ end
18
+ end
19
+ end
20
+ end
21
+ end
@@ -0,0 +1,147 @@
1
+ module ResilientReads
2
+ # Prepended onto the database adapter (e.g. PostgreSQLAdapter, Mysql2Adapter, TrilogyAdapter).
3
+ # Intercepts raw_execute and routes SELECT queries to a healthy replica
4
+ # when inside a distribute_reads block. Writes always pass through to
5
+ # the primary connection.
6
+ #
7
+ # Uses *args/**kwargs to stay compatible across Rails versions:
8
+ # Rails 7.1/7.2: raw_execute(sql, name, async:, allow_retry:, materialize_transactions:)
9
+ # Rails 8.0+: raw_execute(sql, name, binds, prepare:, async:, allow_retry:, materialize_transactions:, batch:)
10
+ module AdapterPatch
11
+ # Query names that should never be routed to a replica. These are
12
+ # schema introspection or internal bookkeeping queries that run during
13
+ # model loading and connection setup.
14
+ SKIP_NAMES = Set.new(%w[SCHEMA EXPLAIN]).freeze
15
+
16
+ def raw_execute(sql, *args, **kwargs)
17
+ ctx = Thread.current[:resilient_reads_context]
18
+ name = args.first
19
+
20
+ if ctx &&
21
+ ctx[:distributing] &&
22
+ !ctx[:on_replica] &&
23
+ !ctx[:routing] &&
24
+ !skip_replica_routing?(sql, name) &&
25
+ open_transactions.zero?
26
+
27
+ execute_on_replica(sql, ctx, *args, **kwargs)
28
+ else
29
+ if ctx && ctx[:distributing] && write_query?(sql)
30
+ ResilientReads.log_query("primary", sql, name, reason: "write query")
31
+ end
32
+ super(sql, *args, **kwargs)
33
+ end
34
+ end
35
+
36
+ private
37
+
38
+ # Schema queries, internal queries, and queries without a name
39
+ # (connection setup, etc.) should always hit the primary.
40
+ def skip_replica_routing?(sql, name)
41
+ return true if name.nil? || name == ""
42
+ return true if SKIP_NAMES.include?(name)
43
+ return true if write_query?(sql)
44
+ false
45
+ end
46
+
47
+ def execute_on_replica(sql, ctx, *args, **kwargs)
48
+ # Ensure the primary adapter is connected and its type_map is
49
+ # initialized. After raw_execute returns the replica's PG::Result,
50
+ # the caller (cast_result) invokes get_oid_type on *self* (primary)
51
+ # to resolve column OIDs. If all prior reads were routed to replicas,
52
+ # the primary connection was never materialized — leaving @type_map
53
+ # nil and causing: NoMethodError: undefined method `key?' for nil.
54
+ connect! unless type_map
55
+
56
+ replica = ResilientReads.replica_pool.next_healthy
57
+
58
+ unless replica
59
+ ResilientReads.log_query("primary", sql, args.first, reason: "no healthy replicas")
60
+ return execute_on_primary(sql, ctx, *args, **kwargs)
61
+ end
62
+
63
+ # Optional lag check — uses cached value to avoid querying on every request.
64
+ if ResilientReads.config.max_lag
65
+ lag = replica.cached_lag
66
+ if lag && lag > ResilientReads.config.max_lag
67
+ if ResilientReads.config.lag_failover
68
+ ResilientReads.log_query("primary", sql, args.first, reason: "replica '#{replica.name}' lag #{lag.round(1)}s > max #{ResilientReads.config.max_lag}s")
69
+ return execute_on_primary(sql, ctx, *args, **kwargs)
70
+ else
71
+ raise TooMuchLag, "Replication lag is #{lag.round(1)}s (max #{ResilientReads.config.max_lag}s)"
72
+ end
73
+ end
74
+ end
75
+
76
+ # Re-entrancy guard: prevent recursive routing when the replica
77
+ # connection is being established (its init queries should not
78
+ # be routed again).
79
+ ctx[:on_replica] = true
80
+ ctx[:routing] = true
81
+ begin
82
+ ResilientReads.log_query(replica.name, sql, args.first)
83
+ result = replica.connection.raw_execute(sql, *args, **kwargs)
84
+ ctx[:routing] = false
85
+ result
86
+ rescue ActiveRecord::ConnectionNotEstablished,
87
+ ActiveRecord::StatementInvalid,
88
+ ActiveRecord::ConnectionFailed => e
89
+ ctx[:routing] = false
90
+ raise unless connection_level_error?(e)
91
+
92
+ ResilientReads.replica_pool.mark_unhealthy(replica)
93
+ ResilientReads.log(:warn, "Replica '#{replica.name}' failed (#{e.class}), trying next")
94
+
95
+ # One retry on a different replica
96
+ retry_replica = ResilientReads.replica_pool.next_healthy
97
+ if retry_replica
98
+ begin
99
+ ctx[:routing] = true
100
+ ResilientReads.log_query(retry_replica.name, sql, args.first, reason: "retry after '#{replica.name}' failed")
101
+ result = retry_replica.connection.raw_execute(sql, *args, **kwargs)
102
+ ctx[:routing] = false
103
+ return result
104
+ rescue => retry_err
105
+ ctx[:routing] = false
106
+ ResilientReads.replica_pool.mark_unhealthy(retry_replica)
107
+ ResilientReads.log(:warn, "Retry replica '#{retry_replica.name}' also failed: #{retry_err.message}")
108
+ end
109
+ end
110
+
111
+ if ResilientReads.config.failover
112
+ ResilientReads.log_query("primary", sql, args.first, reason: "all replicas exhausted")
113
+ execute_on_primary(sql, ctx, *args, **kwargs)
114
+ else
115
+ raise NoHealthyReplica, "No healthy replicas available and failover is disabled"
116
+ end
117
+ ensure
118
+ ctx[:on_replica] = false
119
+ ctx[:routing] = false
120
+ replica&.release_connection rescue nil
121
+ end
122
+ end
123
+
124
+ def execute_on_primary(sql, ctx, *args, **kwargs)
125
+ ctx[:distributing] = false
126
+ raw_execute(sql, *args, **kwargs)
127
+ ensure
128
+ ctx[:distributing] = true
129
+ end
130
+
131
+ def connection_level_error?(error)
132
+ case error
133
+ when ActiveRecord::ConnectionNotEstablished, ActiveRecord::ConnectionFailed
134
+ true
135
+ when ActiveRecord::StatementInvalid
136
+ cause = error.cause
137
+ cause.is_a?(PG::Error) ||
138
+ cause.is_a?(IOError) ||
139
+ (defined?(PG::ConnectionBad) && cause.is_a?(PG::ConnectionBad)) ||
140
+ (defined?(Trilogy::Error) && cause.is_a?(Trilogy::Error)) ||
141
+ (defined?(Mysql2::Error) && cause.is_a?(Mysql2::Error))
142
+ else
143
+ false
144
+ end
145
+ end
146
+ end
147
+ end
@@ -0,0 +1,90 @@
1
+ module ResilientReads
2
+ class Configuration
3
+ # When true, all reads are distributed to replicas by default (via middleware).
4
+ attr_accessor :by_default
5
+
6
+ # When true, ActiveRecord::Relation returned from distribute_reads blocks
7
+ # are automatically loaded to ensure execution on the replica.
8
+ attr_accessor :eager_load
9
+
10
+ # Load balancing strategy: :round_robin or :random
11
+ attr_accessor :balancing_strategy
12
+
13
+ # Seconds between background health checks on replicas.
14
+ attr_accessor :health_check_interval
15
+
16
+ # Maximum acceptable replication lag in seconds. nil = no check.
17
+ attr_accessor :max_lag
18
+
19
+ # Seconds to cache the lag check result per replica. Prevents querying
20
+ # the replica for lag on every single read. Default: 5.
21
+ attr_accessor :lag_check_interval
22
+
23
+ # When true and max_lag is set, queries fall back to primary instead of raising.
24
+ attr_accessor :lag_failover
25
+
26
+ # When true, queries fall back to primary if no healthy replicas are available.
27
+ # When false, raises ResilientReads::NoHealthyReplica.
28
+ attr_accessor :failover
29
+
30
+ # Logger instance. Defaults to Rails.logger when available.
31
+ attr_accessor :logger
32
+
33
+ # When true, logs which connection (primary / replica name) handled each
34
+ # query routed through the adapter patch.
35
+ # Set to false to silence per-query routing logs.
36
+ attr_accessor :log_query_routing
37
+
38
+ # Log level for per-query routing messages. Defaults to :info so messages
39
+ # appear in standard Rails development/production logs.
40
+ # Set to :debug if the output is too noisy.
41
+ attr_accessor :log_query_level
42
+
43
+ # Explicit list of replica database config names (symbols).
44
+ # Example: [:replica, :replica2, :replica3]
45
+ # When nil, replicas are auto-detected from database.yml.
46
+ attr_accessor :replicas
47
+
48
+ # When true and replicas is nil, auto-detect replica configs from database.yml.
49
+ attr_accessor :auto_detect_replicas
50
+
51
+ # Regex pattern for auto-detecting replica config names.
52
+ # Only configs matching this pattern AND having replica: true are used.
53
+ attr_accessor :replica_pattern
54
+
55
+ # Seconds to keep using primary after a write (read-your-own-write protection).
56
+ attr_accessor :primary_delay
57
+
58
+ # Hash of default options for distribute_reads blocks.
59
+ attr_accessor :default_options
60
+
61
+ # When true, caches SQL pattern-matching results (write_query? /
62
+ # skip_replica_routing?) in an in-memory LRU cache so the regex
63
+ # doesn't run on every identical query string.
64
+ attr_accessor :query_cache_enabled
65
+
66
+ # Maximum number of entries in the SQL pattern cache.
67
+ attr_accessor :query_cache_max_size
68
+
69
+ def initialize
70
+ @by_default = false
71
+ @eager_load = false
72
+ @balancing_strategy = :round_robin
73
+ @health_check_interval = 30
74
+ @max_lag = nil
75
+ @lag_check_interval = 5
76
+ @lag_failover = false
77
+ @failover = true
78
+ @logger = nil
79
+ @log_query_routing = true
80
+ @log_query_level = :info
81
+ @replicas = nil
82
+ @auto_detect_replicas = true
83
+ @replica_pattern = /\Areplica\d*\z/
84
+ @primary_delay = 2
85
+ @default_options = {}
86
+ @query_cache_enabled = true
87
+ @query_cache_max_size = 10_000
88
+ end
89
+ end
90
+ end
@@ -0,0 +1,57 @@
1
+ module ResilientReads
2
+ # Background thread that periodically verifies replica reachability.
3
+ # Unhealthy replicas are re-checked so they can be restored to the pool
4
+ # once they recover.
5
+ class HealthChecker
6
+ def initialize(replica_pool, interval:)
7
+ @replica_pool = replica_pool
8
+ @interval = interval
9
+ @thread = nil
10
+ @running = false
11
+ end
12
+
13
+ def start
14
+ return if @replica_pool.empty?
15
+
16
+ # Prevent duplicate threads — stop any existing thread first.
17
+ stop if @running || @thread&.alive?
18
+
19
+ @running = true
20
+ @thread = Thread.new { run_loop }
21
+ @thread.name = "resilient_reads_health"
22
+ @thread.abort_on_exception = false
23
+ @thread.report_on_exception = false
24
+ end
25
+
26
+ def stop
27
+ @running = false
28
+ @thread&.wakeup rescue nil
29
+ @thread&.join(5) rescue nil
30
+ @thread = nil
31
+ end
32
+
33
+ def running?
34
+ @running && @thread&.alive?
35
+ end
36
+
37
+ private
38
+
39
+ def run_loop
40
+ while @running
41
+ sleep @interval
42
+ check_all
43
+ end
44
+ rescue => e
45
+ ResilientReads.log(:error, "Health checker crashed: #{e.message}")
46
+ retry if @running
47
+ end
48
+
49
+ def check_all
50
+ @replica_pool.each do |replica|
51
+ replica.check_health!
52
+ end
53
+ rescue => e
54
+ ResilientReads.log(:error, "Health check cycle error: #{e.message}")
55
+ end
56
+ end
57
+ end
@@ -0,0 +1,73 @@
1
+ module ResilientReads
2
+ module LagChecker
3
+ # Returns the replication lag in seconds for a replica.
4
+ # Supports PostgreSQL and MySQL/MariaDB.
5
+ # Returns 0 when the replica is fully caught up, nil on error.
6
+ def self.lag_for(replica)
7
+ conn = replica.connection
8
+ adapter_name = conn.adapter_name.downcase
9
+
10
+ if adapter_name.include?("postgresql")
11
+ lag_for_postgresql(conn)
12
+ elsif adapter_name.include?("mysql") || adapter_name.include?("trilogy")
13
+ lag_for_mysql(conn)
14
+ else
15
+ ResilientReads.log(:debug, "Lag check not supported for adapter '#{adapter_name}'")
16
+ nil
17
+ end
18
+ rescue => e
19
+ ResilientReads.log(:debug, "Lag check failed for '#{replica.name}': #{e.message}")
20
+ nil
21
+ ensure
22
+ replica.release_connection rescue nil
23
+ end
24
+
25
+ # Convenience: check replication lag using the default reading connection.
26
+ def self.replication_lag
27
+ replica = ResilientReads.replica_pool.next_healthy
28
+ return nil unless replica
29
+
30
+ lag_for(replica)
31
+ end
32
+
33
+ private
34
+
35
+ def self.lag_for_postgresql(conn)
36
+ result = conn.execute(<<~SQL)
37
+ SELECT CASE
38
+ WHEN pg_last_wal_receive_lsn() IS NULL THEN NULL
39
+ WHEN pg_last_wal_receive_lsn() = pg_last_wal_replay_lsn() THEN 0
40
+ ELSE EXTRACT(EPOCH FROM now() - pg_last_xact_replay_timestamp())::float
41
+ END AS lag
42
+ SQL
43
+ lag = result.first&.fetch("lag", nil)
44
+ lag&.to_f
45
+ end
46
+
47
+ def self.lag_for_mysql(conn)
48
+ result = conn.execute("SHOW SLAVE STATUS")
49
+ # MySQL 8.0.22+ uses SHOW REPLICA STATUS
50
+ result = conn.execute("SHOW REPLICA STATUS") if result.respond_to?(:count) && result.count == 0
51
+
52
+ row = if result.respond_to?(:first)
53
+ result.first
54
+ elsif result.respond_to?(:to_a)
55
+ result.to_a.first
56
+ end
57
+
58
+ return nil unless row
59
+
60
+ # Seconds_Behind_Master / Seconds_Behind_Source (MySQL 8.0.22+)
61
+ lag = if row.is_a?(Hash)
62
+ row["Seconds_Behind_Master"] || row["Seconds_Behind_Source"]
63
+ elsif row.respond_to?(:[])
64
+ row["Seconds_Behind_Master"] || row["Seconds_Behind_Source"]
65
+ end
66
+
67
+ lag&.to_f
68
+ rescue => e
69
+ ResilientReads.log(:debug, "MySQL lag check failed: #{e.message}")
70
+ nil
71
+ end
72
+ end
73
+ end
@@ -0,0 +1,59 @@
1
+ module ResilientReads
2
+ # Rack middleware that automatically wraps GET/HEAD requests in a
3
+ # distribute_reads context. Respects the "read your own write"
4
+ # delay: after a mutating request, subsequent reads stay on primary
5
+ # for +primary_delay+ seconds.
6
+ class Middleware
7
+ WRITE_METHODS = %w[POST PUT PATCH DELETE].freeze
8
+ COOKIE_NAME = "_resilient_reads_last_write".freeze
9
+
10
+ def initialize(app)
11
+ @app = app
12
+ end
13
+
14
+ def call(env)
15
+ return @app.call(env) unless ResilientReads.config.by_default
16
+ return @app.call(env) if ResilientReads.replica_pool.empty?
17
+
18
+ request = Rack::Request.new(env)
19
+
20
+ if write_request?(request)
21
+ status, headers, body = @app.call(env)
22
+ set_last_write_cookie(headers)
23
+ [ status, headers, body ]
24
+ elsif recent_write?(request)
25
+ # Within the primary_delay window — skip replica routing.
26
+ @app.call(env)
27
+ else
28
+ ResilientReads.run { @app.call(env) }
29
+ end
30
+ end
31
+
32
+ private
33
+
34
+ def write_request?(request)
35
+ WRITE_METHODS.include?(request.request_method)
36
+ end
37
+
38
+ def recent_write?(request)
39
+ cookie = request.cookies[COOKIE_NAME]
40
+ return false unless cookie
41
+
42
+ last_write = cookie.to_f
43
+ (Time.now.to_f - last_write) < ResilientReads.config.primary_delay
44
+ rescue
45
+ false
46
+ end
47
+
48
+ def set_last_write_cookie(headers)
49
+ Rack::Utils.set_cookie_header!(
50
+ headers,
51
+ COOKIE_NAME,
52
+ value: Time.now.to_f.to_s,
53
+ path: "/",
54
+ httponly: true,
55
+ same_site: :lax
56
+ )
57
+ end
58
+ end
59
+ end
@@ -0,0 +1,85 @@
1
+ require "digest"
2
+
3
+ module ResilientReads
4
+ # Caches SQL pattern-matching results (read vs write classification) so
5
+ # the regex does not need to run on every identical query string.
6
+ #
7
+ # Mirrors the caching concept from active_record_proxy_adapters but is
8
+ # simpler — we only cache the boolean result of +write_query?+ and
9
+ # +skip_replica_routing?+ keyed by the SQL string.
10
+ #
11
+ # Thread-safe via a Mutex around the internal Hash. Uses an LRU eviction
12
+ # strategy when the cache exceeds +max_size+.
13
+ class QueryCache
14
+ DEFAULT_MAX_SIZE = 10_000
15
+
16
+ attr_reader :max_size, :key_prefix
17
+
18
+ def initialize(max_size: DEFAULT_MAX_SIZE, key_prefix: "rr_")
19
+ @max_size = max_size
20
+ @key_prefix = key_prefix
21
+ @store = {}
22
+ @mutex = Mutex.new
23
+ @hits = 0
24
+ @misses = 0
25
+ end
26
+
27
+ # Fetch a cached value or compute it from the block.
28
+ # The block receives the SQL and should return the value to cache.
29
+ #
30
+ # cache.fetch(sql) { |s| ResilientReads.write_query?(s) }
31
+ #
32
+ def fetch(sql)
33
+ key = cache_key(sql)
34
+
35
+ @mutex.synchronize do
36
+ if @store.key?(key)
37
+ @hits += 1
38
+ # Move to end (most recently used)
39
+ value = @store.delete(key)
40
+ @store[key] = value
41
+ return value
42
+ end
43
+ end
44
+
45
+ value = yield(sql)
46
+
47
+ @mutex.synchronize do
48
+ @misses += 1
49
+ @store[key] = value
50
+ evict! if @store.size > @max_size
51
+ end
52
+
53
+ value
54
+ end
55
+
56
+ def size
57
+ @mutex.synchronize { @store.size }
58
+ end
59
+
60
+ def stats
61
+ @mutex.synchronize { { hits: @hits, misses: @misses, size: @store.size } }
62
+ end
63
+
64
+ def clear!
65
+ @mutex.synchronize do
66
+ @store.clear
67
+ @hits = 0
68
+ @misses = 0
69
+ end
70
+ end
71
+
72
+ private
73
+
74
+ def cache_key(sql)
75
+ "#{@key_prefix}#{Digest::SHA2.hexdigest(sql)}"
76
+ end
77
+
78
+ # Evict the oldest 25% of entries when over capacity.
79
+ def evict!
80
+ evict_count = @store.size / 4
81
+ evict_count = 1 if evict_count < 1
82
+ evict_count.times { @store.shift }
83
+ end
84
+ end
85
+ end
@@ -0,0 +1,42 @@
1
+ module ResilientReads
2
+ class Railtie < Rails::Railtie
3
+ initializer "resilient_reads.configure_logger" do
4
+ ResilientReads.config.logger ||= Rails.logger
5
+ end
6
+
7
+ # Set up replica pools and patch the adapter after AR is ready.
8
+ initializer "resilient_reads.setup", after: "active_record.initialize_database" do
9
+ ActiveSupport.on_load(:active_record) do
10
+ ResilientReads.setup_replicas!
11
+ ResilientReads.patch_adapter!
12
+ ResilientReads.start_health_checker!
13
+ end
14
+ end
15
+
16
+ # Insert middleware for by_default mode.
17
+ initializer "resilient_reads.middleware" do |app|
18
+ app.middleware.insert_before 0, ResilientReads::Middleware
19
+ end
20
+
21
+ # Restart health checker after Puma/Unicorn forks.
22
+ config.after_initialize do
23
+ if defined?(::Puma) || defined?(::Unicorn)
24
+ ActiveSupport.on_load(:active_record) do
25
+ if ActiveRecord::Base.respond_to?(:connection_pool)
26
+ at_exit { ResilientReads.stop_health_checker! }
27
+ end
28
+ end
29
+ end
30
+
31
+ if defined?(::Puma)
32
+ Puma::Plugin.create do
33
+ def start(launcher)
34
+ launcher.events.on_booted do
35
+ ResilientReads.restart_health_checker!
36
+ end
37
+ end
38
+ end rescue nil
39
+ end
40
+ end
41
+ end
42
+ end
@@ -0,0 +1,100 @@
1
+ module ResilientReads
2
+ class Replica
3
+ attr_reader :name, :connection_class
4
+
5
+ def initialize(name, connection_class)
6
+ @name = name.to_s
7
+ @connection_class = connection_class
8
+ @healthy = true
9
+ @failure_count = 0
10
+ @last_check_at = nil
11
+ @last_lag = nil
12
+ @last_lag_check_at = nil
13
+ @mutex = Mutex.new
14
+ end
15
+
16
+ def healthy?
17
+ @mutex.synchronize { @healthy }
18
+ end
19
+
20
+ def mark_healthy!
21
+ @mutex.synchronize do
22
+ was_unhealthy = !@healthy
23
+ @healthy = true
24
+ @failure_count = 0
25
+ @last_check_at = Process.clock_gettime(Process::CLOCK_MONOTONIC)
26
+ ResilientReads.log(:info, "Replica '#{@name}' recovered") if was_unhealthy
27
+ end
28
+ end
29
+
30
+ def mark_unhealthy!
31
+ @mutex.synchronize do
32
+ was_healthy = @healthy
33
+ @healthy = false
34
+ @failure_count += 1
35
+ @last_check_at = Process.clock_gettime(Process::CLOCK_MONOTONIC)
36
+ ResilientReads.log(:warn, "Replica '#{@name}' marked unhealthy (failure ##{@failure_count})") if was_healthy
37
+ end
38
+ end
39
+
40
+ def failure_count
41
+ @mutex.synchronize { @failure_count }
42
+ end
43
+
44
+ # Returns the cached lag value if still fresh, otherwise queries the
45
+ # replica for the current replication lag. The TTL is controlled by
46
+ # +ResilientReads.config.lag_check_interval+ (default 5 s).
47
+ def cached_lag
48
+ now = Process.clock_gettime(Process::CLOCK_MONOTONIC)
49
+ ttl = ResilientReads.config.lag_check_interval
50
+
51
+ @mutex.synchronize do
52
+ if @last_lag_check_at && (now - @last_lag_check_at) < ttl
53
+ return @last_lag
54
+ end
55
+ end
56
+
57
+ lag = LagChecker.lag_for(self)
58
+
59
+ @mutex.synchronize do
60
+ @last_lag = lag
61
+ @last_lag_check_at = Process.clock_gettime(Process::CLOCK_MONOTONIC)
62
+ end
63
+
64
+ lag
65
+ end
66
+
67
+ # Invalidate the cached lag so the next call to +cached_lag+ re-queries.
68
+ def invalidate_lag_cache!
69
+ @mutex.synchronize do
70
+ @last_lag_check_at = nil
71
+ @last_lag = nil
72
+ end
73
+ end
74
+
75
+ def connection
76
+ @connection_class.connection
77
+ end
78
+
79
+ def connection_pool
80
+ @connection_class.connection_pool
81
+ end
82
+
83
+ def release_connection
84
+ @connection_class.connection_pool.release_connection
85
+ end
86
+
87
+ # Verify the replica is reachable. Returns true/false and updates health.
88
+ def check_health!
89
+ @connection_class.connection.execute("SELECT 1")
90
+ mark_healthy!
91
+ true
92
+ rescue => e
93
+ mark_unhealthy!
94
+ ResilientReads.log(:debug, "Health check failed for '#{@name}': #{e.message}")
95
+ false
96
+ ensure
97
+ release_connection rescue nil
98
+ end
99
+ end
100
+ end
@@ -0,0 +1,69 @@
1
+ module ResilientReads
2
+ class ReplicaPool
3
+ attr_reader :replicas
4
+
5
+ def initialize
6
+ @replicas = []
7
+ @mutex = Mutex.new
8
+ @rr_index = 0
9
+ end
10
+
11
+ def add(replica)
12
+ @mutex.synchronize { @replicas << replica }
13
+ end
14
+
15
+ def size
16
+ @replicas.size
17
+ end
18
+
19
+ def empty?
20
+ @replicas.empty?
21
+ end
22
+
23
+ # Select the next healthy replica using the configured strategy.
24
+ # Returns nil when no healthy replica is available.
25
+ def next_healthy
26
+ @mutex.synchronize do
27
+ healthy = @replicas.select(&:healthy?)
28
+ return nil if healthy.empty?
29
+
30
+ case ResilientReads.config.balancing_strategy
31
+ when :round_robin
32
+ idx = @rr_index % healthy.size
33
+ @rr_index += 1
34
+ healthy[idx]
35
+ when :random
36
+ healthy.sample
37
+ else
38
+ healthy.first
39
+ end
40
+ end
41
+ end
42
+
43
+ def any_healthy?
44
+ @replicas.any?(&:healthy?)
45
+ end
46
+
47
+ def healthy_count
48
+ @replicas.count(&:healthy?)
49
+ end
50
+
51
+ def mark_unhealthy(replica)
52
+ replica.mark_unhealthy!
53
+ end
54
+
55
+ def check_all_health!
56
+ @replicas.each(&:check_health!)
57
+ end
58
+
59
+ def release_all_connections
60
+ @replicas.each do |r|
61
+ r.release_connection rescue nil
62
+ end
63
+ end
64
+
65
+ def each(&block)
66
+ @replicas.each(&block)
67
+ end
68
+ end
69
+ end
@@ -1,5 +1,3 @@
1
- # frozen_string_literal: true
2
-
3
1
  module ResilientReads
4
- VERSION = "0.1.0"
2
+ VERSION = "0.1.1"
5
3
  end
@@ -1,8 +1,321 @@
1
- # frozen_string_literal: true
1
+ require "active_support"
2
+ require "active_record"
2
3
 
3
4
  require_relative "resilient_reads/version"
5
+ require_relative "resilient_reads/configuration"
6
+ require_relative "resilient_reads/replica"
7
+ require_relative "resilient_reads/replica_pool"
8
+ require_relative "resilient_reads/health_checker"
9
+ require_relative "resilient_reads/lag_checker"
10
+ require_relative "resilient_reads/query_cache"
11
+ require_relative "resilient_reads/adapter_patch"
12
+ require_relative "resilient_reads/middleware"
13
+ require_relative "resilient_reads/active_job_extension"
4
14
 
5
15
  module ResilientReads
6
- class Error < StandardError; end
7
- # Your code goes here...
16
+ class TooMuchLag < StandardError; end
17
+ class NoHealthyReplica < StandardError; end
18
+
19
+ # SQL patterns that indicate a write operation (PostgreSQL + MySQL/MariaDB).
20
+ WRITE_PATTERN = /\A\s*(INSERT|UPDATE|DELETE|CREATE|ALTER|DROP|TRUNCATE|GRANT|REVOKE|LOCK\s+TABLE|SET\s|BEGIN|COMMIT|ROLLBACK|SAVEPOINT|RELEASE|COPY\s|REPLACE\s|LOAD\s+DATA|CALL\s)/i
21
+
22
+ class << self
23
+ def config
24
+ @config ||= Configuration.new
25
+ end
26
+
27
+ def configure
28
+ yield config
29
+ end
30
+
31
+ def replica_pool
32
+ @replica_pool ||= ReplicaPool.new
33
+ end
34
+
35
+ def health_checker
36
+ @health_checker
37
+ end
38
+
39
+ # -------------------------------------------------------------------
40
+ # Core entry point — wraps a block so read queries go to a replica.
41
+ # -------------------------------------------------------------------
42
+ def run(**options, &block)
43
+ opts = config.default_options.merge(options)
44
+
45
+ # Explicit primary override
46
+ if opts[:primary]
47
+ return block.call
48
+ end
49
+
50
+ # No replicas configured — just run on primary.
51
+ if replica_pool.empty? || !replica_pool.any_healthy?
52
+ if opts.fetch(:failover, config.failover)
53
+ return block.call
54
+ else
55
+ raise NoHealthyReplica, "No healthy replicas available"
56
+ end
57
+ end
58
+
59
+ prev_ctx = Thread.current[:resilient_reads_context]
60
+ Thread.current[:resilient_reads_context] = {
61
+ distributing: true,
62
+ on_replica: false,
63
+ routing: false,
64
+ options: opts
65
+ }
66
+
67
+ begin
68
+ result = block.call
69
+ if config.eager_load && result.is_a?(ActiveRecord::Relation) && !result.loaded?
70
+ result = result.load
71
+ end
72
+ result
73
+ ensure
74
+ Thread.current[:resilient_reads_context] = prev_ctx
75
+ replica_pool.release_all_connections
76
+ end
77
+ end
78
+
79
+ # Are we currently inside a distribute_reads block?
80
+ def distributing?
81
+ ctx = Thread.current[:resilient_reads_context]
82
+ ctx && ctx[:distributing]
83
+ end
84
+
85
+ # Convenience: get current replication lag (seconds).
86
+ def replication_lag
87
+ LagChecker.replication_lag
88
+ end
89
+
90
+ def query_cache
91
+ @query_cache ||= QueryCache.new(
92
+ max_size: config.query_cache_max_size
93
+ )
94
+ end
95
+
96
+ def write_query?(sql)
97
+ if config.query_cache_enabled
98
+ query_cache.fetch(sql) { |s| WRITE_PATTERN.match?(s) }
99
+ else
100
+ WRITE_PATTERN.match?(sql)
101
+ end
102
+ end
103
+
104
+ # Clear cached SQL pattern results.
105
+ def bust_query_cache!
106
+ @query_cache&.clear!
107
+ end
108
+
109
+ def log(level, message)
110
+ logger = config.logger
111
+ return unless logger
112
+
113
+ logger.public_send(level, "[ResilientReads] #{message}")
114
+ rescue
115
+ # Never let logging break query flow.
116
+ end
117
+
118
+ # Per-query routing log. Only emits when config.log_query_routing is true.
119
+ # Uses config.log_query_level (default :info) so messages are visible in
120
+ # standard Rails development/production logs.
121
+ #
122
+ # Output example:
123
+ # [ResilientReads] → replica 'replica1' | User Load | SELECT "users".* …
124
+ # [ResilientReads] → primary (write query) | User Create | INSERT INTO …
125
+ def log_query(connection_name, sql, query_name = nil, reason: nil)
126
+ return unless config.log_query_routing
127
+
128
+ logger = config.logger
129
+ return unless logger
130
+
131
+ label = if connection_name == "primary"
132
+ reason ? "primary (#{reason})" : "primary"
133
+ else
134
+ reason ? "replica '#{connection_name}' (#{reason})" : "replica '#{connection_name}'"
135
+ end
136
+
137
+ truncated_sql = sql.length > 120 ? "#{sql[0, 120]}…" : sql
138
+ parts = [ "[ResilientReads] → #{label}" ]
139
+ parts << query_name if query_name && !query_name.empty?
140
+ parts << truncated_sql.gsub(/\s+/, " ").strip
141
+ logger.public_send(config.log_query_level, parts.join(" | "))
142
+ rescue
143
+ # Never let logging break query flow.
144
+ end
145
+
146
+ # ---------------------------------------------------------------
147
+ # Setup helpers (called from Railtie or manually in non-Rails apps)
148
+ # ---------------------------------------------------------------
149
+
150
+ def setup_replicas!
151
+ names = replica_config_names
152
+
153
+ if names.empty?
154
+ log(:info, "No replica configs detected (pattern: #{config.replica_pattern.inspect}). All reads will use primary.")
155
+ return
156
+ end
157
+
158
+ names.each do |name|
159
+ klass = build_replica_class(name)
160
+ replica = Replica.new(name, klass)
161
+ replica_pool.add(replica)
162
+ end
163
+
164
+ # Initial health probe — failures are non-fatal.
165
+ replica_pool.each do |r|
166
+ r.check_health!
167
+ rescue => e
168
+ log(:warn, "Initial health check for '#{r.name}' failed: #{e.message}")
169
+ end
170
+
171
+ log(:info, "Configured #{replica_pool.size} replica(s): #{names.join(', ')} " \
172
+ "(#{replica_pool.healthy_count} healthy)")
173
+ end
174
+
175
+ def patch_adapter!
176
+ patched = []
177
+ if defined?(ActiveRecord::ConnectionAdapters::PostgreSQLAdapter)
178
+ ActiveRecord::ConnectionAdapters::PostgreSQLAdapter.prepend(AdapterPatch)
179
+ patched << "PostgreSQLAdapter"
180
+ end
181
+ if defined?(ActiveRecord::ConnectionAdapters::Mysql2Adapter)
182
+ ActiveRecord::ConnectionAdapters::Mysql2Adapter.prepend(AdapterPatch)
183
+ patched << "Mysql2Adapter"
184
+ end
185
+ if defined?(ActiveRecord::ConnectionAdapters::TrilogyAdapter)
186
+ ActiveRecord::ConnectionAdapters::TrilogyAdapter.prepend(AdapterPatch)
187
+ patched << "TrilogyAdapter"
188
+ end
189
+
190
+ log(:debug, "Patched adapters: #{patched.join(', ')}") if patched.any?
191
+ end
192
+
193
+ def start_health_checker!
194
+ return if replica_pool.empty?
195
+
196
+ @health_checker = HealthChecker.new(
197
+ replica_pool,
198
+ interval: config.health_check_interval
199
+ )
200
+ @health_checker.start
201
+ end
202
+
203
+ def stop_health_checker!
204
+ @health_checker&.stop
205
+ end
206
+
207
+ def restart_health_checker!
208
+ stop_health_checker!
209
+ start_health_checker!
210
+ end
211
+
212
+ private
213
+
214
+ def replica_config_names
215
+ if config.replicas
216
+ Array(config.replicas).map(&:to_sym)
217
+ elsif config.auto_detect_replicas
218
+ detect_replicas
219
+ else
220
+ []
221
+ end
222
+ end
223
+
224
+ def detect_replicas
225
+ env = defined?(Rails) ? Rails.env : ENV.fetch("RAILS_ENV", "development")
226
+ # include_hidden: true is required because Rails hides replica configs
227
+ # by default (they have database_tasks: false).
228
+ configs = ActiveRecord::Base.configurations.configs_for(env_name: env, include_hidden: true)
229
+ detected = configs.select { |c| c.replica? && c.name.to_s.match?(config.replica_pattern) }
230
+ .map { |c| c.name.to_sym }
231
+ log(:debug, "detect_replicas: found #{configs.size} configs, #{detected.size} matched replica pattern")
232
+ detected
233
+ end
234
+
235
+ # Creates an abstract AR class with its own connection pool pointing
236
+ # at the given replica database config. This avoids touching the
237
+ # main ApplicationRecord pool.
238
+ def build_replica_class(name)
239
+ klass = Class.new(ActiveRecord::Base) do
240
+ self.abstract_class = true
241
+ end
242
+ class_name = "ReplicaConnection#{name.to_s.camelize}"
243
+ const_set(class_name, klass) unless const_defined?(class_name)
244
+
245
+ begin
246
+ klass.establish_connection(name)
247
+ rescue => e
248
+ log(:warn, "Could not establish connection for replica '#{name}': #{e.message}")
249
+ end
250
+
251
+ klass
252
+ end
253
+ end
254
+ end
255
+
256
+ # -----------------------------------------------------------------------
257
+ # Global helper — available everywhere just like the original gem.
258
+ # -----------------------------------------------------------------------
259
+ module ResilientReadsGlobal
260
+ def distribute_reads(**options, &block)
261
+ ResilientReads.run(**options, &block)
262
+ end
263
+
264
+ def resilient_reads(**options, &block)
265
+ ResilientReads.run(**options, &block)
266
+ end
267
+ end
268
+
269
+ Object.include ResilientReadsGlobal
270
+
271
+ # Backward-compatible constant so existing initializers using
272
+ # DistributeReads.eager_load = true etc. still work.
273
+ module DistributeReads
274
+ class TooMuchLag < ResilientReads::TooMuchLag; end
275
+
276
+ class << self
277
+ def eager_load=(val)
278
+ ResilientReads.config.eager_load = val
279
+ end
280
+
281
+ def eager_load
282
+ ResilientReads.config.eager_load
283
+ end
284
+
285
+ def by_default=(val)
286
+ ResilientReads.config.by_default = val
287
+ end
288
+
289
+ def by_default
290
+ ResilientReads.config.by_default
291
+ end
292
+
293
+ def default_options=(val)
294
+ ResilientReads.config.default_options = val
295
+ end
296
+
297
+ def default_options
298
+ ResilientReads.config.default_options
299
+ end
300
+
301
+ def logger=(val)
302
+ ResilientReads.config.logger = val
303
+ end
304
+
305
+ def logger
306
+ ResilientReads.config.logger
307
+ end
308
+
309
+ def replication_lag
310
+ ResilientReads.replication_lag
311
+ end
312
+ end
8
313
  end
314
+
315
+ # ActiveJob integration
316
+ ActiveSupport.on_load(:active_job) do
317
+ include ResilientReads::ActiveJobExtension
318
+ end
319
+
320
+ # Railtie auto-loads when Rails is present.
321
+ require_relative "resilient_reads/railtie" if defined?(Rails::Railtie)
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: resilient_reads
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.0
4
+ version: 0.1.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Jamie Puckett
@@ -17,11 +17,25 @@ executables: []
17
17
  extensions: []
18
18
  extra_rdoc_files: []
19
19
  files:
20
+ - ".idea/.gitignore"
21
+ - ".idea/modules.xml"
22
+ - ".idea/resilient_reads.iml"
23
+ - ".idea/vcs.xml"
20
24
  - CHANGELOG.md
21
25
  - LICENSE.txt
22
26
  - README.md
23
27
  - Rakefile
24
28
  - lib/resilient_reads.rb
29
+ - lib/resilient_reads/active_job_extension.rb
30
+ - lib/resilient_reads/adapter_patch.rb
31
+ - lib/resilient_reads/configuration.rb
32
+ - lib/resilient_reads/health_checker.rb
33
+ - lib/resilient_reads/lag_checker.rb
34
+ - lib/resilient_reads/middleware.rb
35
+ - lib/resilient_reads/query_cache.rb
36
+ - lib/resilient_reads/railtie.rb
37
+ - lib/resilient_reads/replica.rb
38
+ - lib/resilient_reads/replica_pool.rb
25
39
  - lib/resilient_reads/version.rb
26
40
  - sig/resilient_reads.rbs
27
41
  homepage: https://github.com/chronicaust/resilient_reads