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 +4 -4
- data/.idea/.gitignore +10 -0
- data/.idea/modules.xml +8 -0
- data/.idea/resilient_reads.iml +18 -0
- data/.idea/vcs.xml +6 -0
- data/lib/resilient_reads/active_job_extension.rb +21 -0
- data/lib/resilient_reads/adapter_patch.rb +147 -0
- data/lib/resilient_reads/configuration.rb +90 -0
- data/lib/resilient_reads/health_checker.rb +57 -0
- data/lib/resilient_reads/lag_checker.rb +73 -0
- data/lib/resilient_reads/middleware.rb +59 -0
- data/lib/resilient_reads/query_cache.rb +85 -0
- data/lib/resilient_reads/railtie.rb +42 -0
- data/lib/resilient_reads/replica.rb +100 -0
- data/lib/resilient_reads/replica_pool.rb +69 -0
- data/lib/resilient_reads/version.rb +1 -3
- data/lib/resilient_reads.rb +316 -3
- metadata +15 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 4bbb75d50de2bb59656b1f4e944a3187c01c16c36fb02a21c30a7c3dc7eee953
|
|
4
|
+
data.tar.gz: 6474b24d88c87626dc7d35a2b080ea06d603d0a4071733122e73273cd2143404
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: a6c3241dd6d5a240262da022d8f53a5946d54d15c1efb1d47e3d23fb645db3d4d58ebcd5713ace5700e0d733a1171188692f3ffe00819fa79d7a43bed6260f4c
|
|
7
|
+
data.tar.gz: 82e30dc4fcbffa079390b60fa4e2f35cfb68025ea7fc88a33896ce5aed3faabc1b094d52cd558c017e7de3fc64bb017b41c6217fdb0709295a0a1b9946db4331
|
data/.idea/.gitignore
ADDED
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,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
|
data/lib/resilient_reads.rb
CHANGED
|
@@ -1,8 +1,321 @@
|
|
|
1
|
-
|
|
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
|
|
7
|
-
|
|
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.
|
|
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
|