semian 0.26.6 → 0.27.0

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: c745b1aaf79e752138bf6cbfcff3e218022dfbaac8b3f6adba3dd9bd4091bbee
4
- data.tar.gz: 03e8d192ffd5e7b86aadb3b87ad9846e4d0cc18724d67a14e72c35949a11e3f6
3
+ metadata.gz: cc25bcc8e0291e3f53d1ecb6ac19e56b375ce0c16974d16c20b9a847b6d2104d
4
+ data.tar.gz: 8c4ab8f6259e8118fa94801b28392738a05403d346941738f68bea258bc41f37
5
5
  SHA512:
6
- metadata.gz: afa858cd11acdf8047f6dd38850419927915ff87b6dfeabd820ffa888b269aafc67415cce0a85543e3d49562b733a01ea8b22fd46c6b5fc24374236a333cf0ef
7
- data.tar.gz: 9a087827f9abaefa6bdcdda853b3423e9a19fe9e0b6a35c2cea8650266857713ffeea39136560a6ed1f763940ff9f28868bcbb9e95aa84cac91764229cb2dfdb
6
+ metadata.gz: 4f0936f08aa93d042241bece483742cc03d9e50ebaf27a363aec1973026d989ee3c6c6067de13f9016a46b5a227fb40ac2136746c8da25d33895f5a4312da510
7
+ data.tar.gz: 6cada0bbab474810b4bc12617e2d6e323034e6b088cebfa0d41352f0f1c8e522b4fc37d2e9dde77328c1ac5a7fc76e34e76b94f313313da9363049a72fb83859
data/README.md CHANGED
@@ -73,6 +73,7 @@ version is the version of the public gem with the same name:
73
73
  - [`semian/redis`][redis-semian-adapter] (~> 3.2.1)
74
74
  - [`semian/net_http`][nethttp-semian-adapter]
75
75
  - [`semian/activerecord_trilogy_adapter`][activerecord-trilogy-semian-adapter]
76
+ - [`semian/activerecord_postgresql_adapter`][activerecord-postgresql-semian-adapter]
76
77
  - [`semian-postgres`][postgres-semian-adapter]
77
78
 
78
79
  ### Creating Adapters
@@ -154,6 +155,33 @@ client = Redis.new(semian: {
154
155
  })
155
156
  ```
156
157
 
158
+ ##### Redis Out-of-Memory Errors
159
+
160
+ By default, Redis Out-of-Memory (OOM) errors will open the circuit breaker. This can be
161
+ problematic because it prevents read operations and commands that could free up memory
162
+ (like `DEL`, `LPOP`, etc.) from executing, hindering Redis recovery.
163
+
164
+ To allow OOM errors to fail fast without opening the circuit, set `open_circuit_on_oom: false`:
165
+
166
+ ```ruby
167
+ client = Redis.new(semian: {
168
+ name: "inventory",
169
+ open_circuit_on_oom: false # OOM errors won't open the circuit
170
+ })
171
+ ```
172
+
173
+ This also works with `RedisClient`:
174
+
175
+ ```ruby
176
+ client = RedisClient.config(
177
+ host: "localhost",
178
+ semian: {
179
+ name: "inventory",
180
+ open_circuit_on_oom: false
181
+ }
182
+ ).new_client
183
+ ```
184
+
157
185
  #### Configuration Validation
158
186
 
159
187
  Semian now provides a flag to specify log-based and exception-based configuration validation. To
@@ -1027,6 +1055,7 @@ $ bundle install
1027
1055
  [postgres-semian-adapter]: https://github.com/mschoenlaub/semian-postgres
1028
1056
  [redis-semian-adapter]: lib/semian/redis.rb
1029
1057
  [activerecord-trilogy-semian-adapter]: lib/semian/activerecord_trilogy_adapter.rb
1058
+ [activerecord-postgres-semian-adapter]: lib/semian/activerecord_postgres_adapter.rb
1030
1059
  [semian-adapter]: lib/semian/adapter.rb
1031
1060
  [nethttp-semian-adapter]: lib/semian/net_http.rb
1032
1061
  [nethttp-default-errors]: lib/semian/net_http.rb#L35-L45
@@ -0,0 +1,125 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "semian/adapter"
4
+ require "active_record"
5
+
6
+ module Semian
7
+ module ActiveRecordAdapter
8
+ QUERY_ALLOWLIST = %r{\A(?:/\*.*?\*/)?\s*(ROLLBACK|COMMIT|RELEASE\s+SAVEPOINT)}i
9
+
10
+ module ClassMethods
11
+ def query_allowlisted?(sql, *)
12
+ # COMMIT, ROLLBACK
13
+ tx_command_statement = sql.end_with?("T", "K")
14
+
15
+ # RELEASE SAVEPOINT. Nesting past _3 levels won't get bypassed.
16
+ # Active Record does not send trailing spaces or `;`, so we are in the realm of hand crafted queries here.
17
+ savepoint_statement = sql.end_with?("_1", "_2")
18
+ unclear = sql.end_with?(" ", ";")
19
+
20
+ if !tx_command_statement && !savepoint_statement && !unclear
21
+ false
22
+ else
23
+ QUERY_ALLOWLIST.match?(sql)
24
+ end
25
+ rescue ArgumentError
26
+ return false unless sql.valid_encoding?
27
+
28
+ raise
29
+ end
30
+ end
31
+
32
+ class << self
33
+ def included(base)
34
+ base.extend(ClassMethods)
35
+ base.class_eval do
36
+ attr_reader(:raw_semian_options, :semian_identifier)
37
+ end
38
+ end
39
+ end
40
+
41
+ def initialize(*options)
42
+ *, config = options
43
+ config = config.dup
44
+ @raw_semian_options = config.delete(:semian)
45
+ @semian_identifier = begin
46
+ name = semian_options && semian_options[:name]
47
+ unless name
48
+ host = config[:host] || "localhost"
49
+ port = config[:port] || semian_adapter_default_port
50
+ name = "#{host}:#{port}"
51
+ end
52
+ :"#{semian_adapter_identifier_prefix}_#{name}"
53
+ end
54
+ super
55
+ end
56
+
57
+ if ActiveRecord.version >= Gem::Version.new("8.2.a")
58
+ def execute_intent(intent)
59
+ return super if self.class.query_allowlisted?(intent.processed_sql)
60
+
61
+ acquire_semian_resource(adapter: semian_adapter_name, scope: :query) do
62
+ super
63
+ end
64
+ end
65
+ else
66
+ def raw_execute(sql, *args, **kwargs, &block)
67
+ if self.class.query_allowlisted?(sql)
68
+ super
69
+ else
70
+ acquire_semian_resource(adapter: semian_adapter_name, scope: :query) do
71
+ super(sql, *args, **kwargs, &block)
72
+ end
73
+ end
74
+ end
75
+ end
76
+
77
+ def active?
78
+ acquire_semian_resource(adapter: semian_adapter_name, scope: :ping) do
79
+ super
80
+ end
81
+ rescue resource_busy_error_class, circuit_open_error_class
82
+ false
83
+ end
84
+
85
+ def with_resource_timeout
86
+ raise NotImplementedError, "#{self.class} must implement a `with_resource_timeout` method"
87
+ end
88
+
89
+ private
90
+
91
+ def resource_exceptions
92
+ [
93
+ ActiveRecord::AdapterTimeout,
94
+ ActiveRecord::ConnectionFailed,
95
+ ActiveRecord::ConnectionNotEstablished,
96
+ ]
97
+ end
98
+
99
+ def resource_busy_error_class
100
+ self.class::ResourceBusyError
101
+ end
102
+
103
+ def circuit_open_error_class
104
+ self.class::CircuitOpenError
105
+ end
106
+
107
+ def connect(*args)
108
+ acquire_semian_resource(adapter: semian_adapter_name, scope: :connection) do
109
+ super
110
+ end
111
+ end
112
+
113
+ def semian_adapter_name
114
+ raise NotImplementedError, "#{self.class} must implement an `semian_adapter_name` method"
115
+ end
116
+
117
+ def semian_adapter_default_port
118
+ raise NotImplementedError, "#{self.class} must implement an `semian_adapter_default_port` method"
119
+ end
120
+
121
+ def semian_adapter_identifier_prefix
122
+ raise NotImplementedError, "#{self.class} must implement an `semian_adapter_identifier_prefix` method"
123
+ end
124
+ end
125
+ end
@@ -0,0 +1,51 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "semian/activerecord_adapter"
4
+ require "active_record/connection_adapters/postgresql_adapter"
5
+
6
+ module ActiveRecord
7
+ module ConnectionAdapters
8
+ class PostgreSQLAdapter
9
+ ActiveRecord::ActiveRecordError.include(::Semian::AdapterError)
10
+
11
+ class SemianError < ConnectionNotEstablished
12
+ def initialize(semian_identifier, *args)
13
+ super(*args)
14
+ @semian_identifier = semian_identifier
15
+ end
16
+ end
17
+
18
+ ResourceBusyError = Class.new(SemianError)
19
+ CircuitOpenError = Class.new(SemianError)
20
+ end
21
+ end
22
+ end
23
+
24
+ module Semian
25
+ module ActiveRecordPostgreSQLAdapter
26
+ include Semian::Adapter
27
+ include Semian::ActiveRecordAdapter
28
+
29
+ class << self
30
+ def prepended(base)
31
+ base.extend(Semian::ActiveRecordAdapter::ClassMethods)
32
+ end
33
+ end
34
+
35
+ def with_resource_timeout(_temp_timeout)
36
+ # Resource timeouts aren't possible with PostgreSQL because there is no
37
+ # IO level timeout configuration, so we just yield.
38
+ yield
39
+ end
40
+
41
+ private
42
+
43
+ def semian_adapter_name = :postgresql_adapter
44
+
45
+ def semian_adapter_default_port = 5432
46
+
47
+ def semian_adapter_identifier_prefix = :postgresql
48
+ end
49
+ end
50
+
51
+ ActiveRecord::ConnectionAdapters::PostgreSQLAdapter.prepend(Semian::ActiveRecordPostgreSQLAdapter)
@@ -1,7 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "semian/adapter"
4
- require "active_record"
3
+ require "semian/activerecord_adapter"
5
4
  require "active_record/connection_adapters/trilogy_adapter"
6
5
 
7
6
  module ActiveRecord
@@ -25,84 +24,18 @@ end
25
24
  module Semian
26
25
  module ActiveRecordTrilogyAdapter
27
26
  include Semian::Adapter
27
+ include Semian::ActiveRecordAdapter
28
28
 
29
- ResourceBusyError = ::ActiveRecord::ConnectionAdapters::TrilogyAdapter::ResourceBusyError
30
- CircuitOpenError = ::ActiveRecord::ConnectionAdapters::TrilogyAdapter::CircuitOpenError
31
-
32
- QUERY_ALLOWLIST = %r{\A(?:/\*.*?\*/)?\s*(ROLLBACK|COMMIT|RELEASE\s+SAVEPOINT)}i
33
-
34
- # The common case here is NOT to have transaction management statements, therefore
35
- # we are exploiting the fact that Active Record will use COMMIT/ROLLBACK as
36
- # the suffix of the command string and
37
- # name savepoints by level of nesting as `active_record_1` ... n.
38
- #
39
- # Since looking at the last characters in a string using `end_with?` is a LOT cheaper than
40
- # running a regex, we are returning early if the last characters of
41
- # the SQL statements are NOT the last characters of the known transaction
42
- # control statements.
43
29
  class << self
44
- def query_allowlisted?(sql, *)
45
- # COMMIT, ROLLBACK
46
- tx_command_statement = sql.end_with?("T") || sql.end_with?("K")
47
-
48
- # RELEASE SAVEPOINT. Nesting past _3 levels won't get bypassed.
49
- # Active Record does not send trailing spaces or `;`, so we are in the realm of hand crafted queries here.
50
- savepoint_statement = sql.end_with?("_1") || sql.end_with?("_2")
51
- unclear = sql.end_with?(" ") || sql.end_with?(";")
52
-
53
- if !tx_command_statement && !savepoint_statement && !unclear
54
- false
55
- else
56
- QUERY_ALLOWLIST.match?(sql)
57
- end
58
- rescue ArgumentError
59
- return false unless sql.valid_encoding?
60
-
61
- raise
30
+ def prepended(base)
31
+ base.extend(Semian::ActiveRecordAdapter::ClassMethods)
62
32
  end
63
33
  end
64
34
 
65
- attr_reader :raw_semian_options, :semian_identifier
66
-
67
- def initialize(*options)
68
- *, config = options
69
- config = config.dup
70
- @raw_semian_options = config.delete(:semian)
71
- @semian_identifier = begin
72
- name = semian_options && semian_options[:name]
73
- unless name
74
- host = config[:host] || "localhost"
75
- port = config[:port] || 3306
76
- name = "#{host}:#{port}"
77
- end
78
- :"mysql_#{name}"
79
- end
80
- super
81
- end
82
-
83
- def raw_execute(sql, *)
84
- if Semian::ActiveRecordTrilogyAdapter.query_allowlisted?(sql)
85
- super
86
- else
87
- acquire_semian_resource(adapter: :trilogy_adapter, scope: :query) do
88
- super
89
- end
90
- end
91
- end
92
- ruby2_keywords :raw_execute
93
-
94
- def active?
95
- acquire_semian_resource(adapter: :trilogy_adapter, scope: :ping) do
96
- super
97
- end
98
- rescue ResourceBusyError, CircuitOpenError
99
- false
100
- end
101
-
102
35
  def with_resource_timeout(temp_timeout)
103
36
  if @raw_connection.nil?
104
37
  prev_read_timeout = @config[:read_timeout] || 0
105
- @config.merge!(read_timeout: temp_timeout) # Create new client with temp_timeout for read timeout
38
+ @config.merge!(read_timeout: temp_timeout)
106
39
  else
107
40
  prev_read_timeout = @raw_connection.read_timeout
108
41
  @raw_connection.read_timeout = temp_timeout
@@ -115,19 +48,11 @@ module Semian
115
48
 
116
49
  private
117
50
 
118
- def resource_exceptions
119
- [
120
- ActiveRecord::AdapterTimeout,
121
- ActiveRecord::ConnectionFailed,
122
- ActiveRecord::ConnectionNotEstablished,
123
- ]
124
- end
51
+ def semian_adapter_name = :trilogy_adapter
125
52
 
126
- def connect(*args)
127
- acquire_semian_resource(adapter: :trilogy_adapter, scope: :connection) do
128
- super
129
- end
130
- end
53
+ def semian_adapter_default_port = 3306
54
+
55
+ def semian_adapter_identifier_prefix = :mysql
131
56
  end
132
57
  end
133
58
 
@@ -5,6 +5,16 @@ require "semian/redis_client"
5
5
  class Redis
6
6
  BaseConnectionError.include(::Semian::AdapterError)
7
7
  OutOfMemoryError.include(::Semian::AdapterError)
8
+ OutOfMemoryError.class_eval do
9
+ attr_accessor :semian_open_circuit_on_oom
10
+
11
+ # By default, OOM errors open circuits (backward compatible behavior).
12
+ # Set `open_circuit_on_oom: false` to disable this if you want reads/deletes
13
+ # to continue working when Redis is OOM, allowing it to recover.
14
+ def marks_semian_circuits?
15
+ @semian_open_circuit_on_oom != false
16
+ end
17
+ end
8
18
 
9
19
  class ReadOnlyError < Redis::BaseConnectionError
10
20
  # A ReadOnlyError is a fast failure and we don't want to track these errors so that we can reconnect
@@ -47,6 +57,9 @@ module Semian
47
57
  if redis_error < ::Semian::AdapterError
48
58
  redis_error = redis_error.new(error.message)
49
59
  redis_error.semian_identifier = error.semian_identifier
60
+ if error.respond_to?(:semian_open_circuit_on_oom) && redis_error.respond_to?(:semian_open_circuit_on_oom=)
61
+ redis_error.semian_open_circuit_on_oom = error.semian_open_circuit_on_oom
62
+ end
50
63
  end
51
64
  raise redis_error, error.message, error.backtrace
52
65
  end
data/lib/semian/redis.rb CHANGED
@@ -23,6 +23,15 @@ class Redis
23
23
 
24
24
  class OutOfMemoryError < Redis::CommandError
25
25
  include ::Semian::AdapterError
26
+
27
+ attr_accessor :semian_open_circuit_on_oom
28
+
29
+ # By default, OOM errors open circuits (backward compatible behavior).
30
+ # Set `open_circuit_on_oom: false` to disable this if you want reads/deletes
31
+ # to continue working when Redis is OOM, allowing it to recover.
32
+ def marks_semian_circuits?
33
+ @semian_open_circuit_on_oom != false
34
+ end
26
35
  end
27
36
 
28
37
  class ConnectionError < Redis::BaseConnectionError
@@ -159,7 +168,9 @@ module Semian
159
168
  return unless reply.is_a?(::Redis::CommandError)
160
169
  return unless reply.message =~ /OOM command not allowed when used memory > 'maxmemory'/
161
170
 
162
- raise ::Redis::OutOfMemoryError, reply.message
171
+ error = ::Redis::OutOfMemoryError.new(reply.message)
172
+ error.semian_open_circuit_on_oom = semian_options&.fetch(:open_circuit_on_oom, true)
173
+ raise error
163
174
  end
164
175
 
165
176
  def dns_resolve_failure?(e)
@@ -13,6 +13,17 @@ class RedisClient
13
13
  end
14
14
 
15
15
  OutOfMemoryError.include(::Semian::AdapterError)
16
+ OutOfMemoryError.class_eval do
17
+ attr_accessor :semian_open_circuit_on_oom
18
+
19
+ # By default, OOM errors open circuits (backward compatible behavior).
20
+ # Set `open_circuit_on_oom: false` to disable this if you want reads/dequeues
21
+ # to continue working when Redis is OOM, allowing it to recover.
22
+ # This is considered a fast failure.
23
+ def marks_semian_circuits?
24
+ @semian_open_circuit_on_oom != false
25
+ end
26
+ end
16
27
 
17
28
  class ReadOnlyError < RedisClient::ConnectionError
18
29
  # A ReadOnlyError is a fast failure and we don't want to track these errors so that we can reconnect
@@ -110,6 +121,10 @@ module Semian
110
121
  super do |connection|
111
122
  acquire_semian_resource(adapter: :redis_client, scope: :query) do
112
123
  yield connection
124
+ rescue ::RedisClient::OutOfMemoryError => error
125
+ error.semian_identifier = semian_identifier
126
+ error.semian_open_circuit_on_oom = semian_options&.fetch(:open_circuit_on_oom, true)
127
+ raise
113
128
  end
114
129
  end
115
130
  else
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Semian
4
- VERSION = "0.26.6"
4
+ VERSION = "0.27.0"
5
5
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: semian
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.26.6
4
+ version: 0.27.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Scott Francis
@@ -47,6 +47,8 @@ files:
47
47
  - ext/semian/tickets.h
48
48
  - ext/semian/types.h
49
49
  - lib/semian.rb
50
+ - lib/semian/activerecord_adapter.rb
51
+ - lib/semian/activerecord_postgresql_adapter.rb
50
52
  - lib/semian/activerecord_trilogy_adapter.rb
51
53
  - lib/semian/adapter.rb
52
54
  - lib/semian/circuit_breaker.rb
@@ -92,7 +94,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
92
94
  - !ruby/object:Gem::Version
93
95
  version: '0'
94
96
  requirements: []
95
- rubygems_version: 3.7.2
97
+ rubygems_version: 4.0.4
96
98
  specification_version: 4
97
99
  summary: Bulkheading for Ruby with SysV semaphores
98
100
  test_files: []