breaker_machines 0.18.0-aarch64-linux → 0.20.0-aarch64-linux

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: 121f00e836e2f76f71de83d6c665295563b2acaf68158df03f6bb3fc32c831ba
4
- data.tar.gz: 00caba181f7a829dbf70893443860a366ca01b48d0e2e683a4a70b3e3ae3375f
3
+ metadata.gz: d47c90604296ccb22b984b3834addec4445eef1ebe316b86e11c84cbe53beb51
4
+ data.tar.gz: fefc4da808ddbd1e1fccbd455df5465452996a0567e164d2cf28eac08a7aca77
5
5
  SHA512:
6
- metadata.gz: 49f0b02f4629158f895c43f18f71601d4ccb9bb04a9aa549a94b24d54547a1370335b3a775d751be0ef9a1005f4eb769a57139e81d1803f2aa44b655f4305d7c
7
- data.tar.gz: 3276210d959dc000ce985a8ba6e067f67dd1040a908259006d9bfcda6fbdd0383908a15534b6bf01a92ca4b82002a8c3c774cc72f71285feec3daa5a6e13e69d
6
+ metadata.gz: 532c253b559a37a65474582d91063b5b81b040763850a38d2aa8ec327736547e143e9d907a5e8a1a3eaae4ee302925de53209b2e520331d21b2930da9dcf24d4
7
+ data.tar.gz: 35b908cded67220f81be4f204f6f261ba9dcfcebcb187ee1e280dfa9b40332e81c0d580d1871940e7bd5577d5ab93b53e5a042045b384174114b1cf25968820e
@@ -18,14 +18,28 @@ module BreakerMachines
18
18
 
19
19
  private
20
20
 
21
+ # Runs on every call: the write lock is taken only to apply a changed
22
+ # shared status or to start recovery.
21
23
  def attempt_recovery_if_ready
22
- return unless open? && reset_timeout_elapsed?
24
+ changed = stored_status_changed?
25
+ return unless changed || (open? && reset_timeout_elapsed?)
23
26
 
24
27
  @mutex.with_write_lock do
28
+ restore_status_from_storage if changed
25
29
  attempt_recovery if open? && reset_timeout_elapsed?
26
30
  end
27
31
  end
28
32
 
33
+ # Reads the shared status before taking the write lock, so storage I/O
34
+ # happens under it only when there is a change to apply.
35
+ def with_refreshed_status
36
+ changed = stored_status_changed?
37
+ @mutex.with_write_lock do
38
+ restore_status_from_storage if changed
39
+ yield
40
+ end
41
+ end
42
+
29
43
  def admit_call
30
44
  @mutex.with_read_lock do
31
45
  case status_name
@@ -55,7 +69,7 @@ module BreakerMachines
55
69
  def handle_success(admission)
56
70
  return unless admission.half_open?
57
71
 
58
- @mutex.with_write_lock do
72
+ with_refreshed_status do
59
73
  return unless admission_current?(admission) && half_open?
60
74
 
61
75
  successful_attempts = @half_open_successes.increment
@@ -68,7 +82,7 @@ module BreakerMachines
68
82
  end
69
83
 
70
84
  def handle_failure(admission)
71
- @mutex.with_write_lock do
85
+ with_refreshed_status do
72
86
  return unless admission_current?(admission)
73
87
 
74
88
  if admission.closed? && closed? && failure_threshold_exceeded?
@@ -84,7 +98,7 @@ module BreakerMachines
84
98
  def release_abandoned_admission(admission)
85
99
  return unless admission.half_open?
86
100
 
87
- @mutex.with_write_lock do
101
+ with_refreshed_status do
88
102
  next unless admission_current?(admission) && half_open?
89
103
  next unless @half_open_attempts.value.positive?
90
104
 
@@ -53,12 +53,50 @@ module BreakerMachines
53
53
  @semaphore = (Concurrent::Semaphore.new(@config[:max_concurrent]) if @config[:max_concurrent])
54
54
  end
55
55
 
56
+ # A round trip through epoch time is off by up to ~0.12µs (float64
57
+ # spacing near today's epoch); any larger difference is another opening.
58
+ OPENED_AT_TOLERANCE = 1e-6
59
+
60
+ # Reads the shared status without taking the lock. True when it differs
61
+ # from the last status this instance stored or observed.
62
+ def stored_status_changed?
63
+ stored_status = @storage&.get_status(@name)
64
+ !stored_status.nil? && !same_stored_status?(stored_status, @stored_status)
65
+ end
66
+
56
67
  def restore_status_from_storage
57
- stored_status = @storage.get_status(@name)
58
- return unless stored_status
68
+ stored_status = @storage&.get_status(@name)
69
+ return if stored_status.nil? || same_stored_status?(stored_status, @stored_status)
59
70
 
71
+ @stored_status = stored_status
60
72
  self.status = stored_status.status.to_s
61
- @opened_at.value = stored_status.opened_at if stored_status.opened_at
73
+ @opened_at.value = stored_status.opened_at
74
+ @state_epoch.increment
75
+ @half_open_attempts.value = 0
76
+ @half_open_successes.value = 0
77
+ return unless closed?
78
+
79
+ @last_error.value = nil
80
+ @last_failure_at.value = nil
81
+ end
82
+
83
+ def same_stored_status?(stored_status, known_status)
84
+ return false unless known_status && stored_status.status == known_status.status
85
+
86
+ stored_at = stored_status.opened_at
87
+ known_at = known_status.opened_at
88
+ return stored_at == known_at if stored_at.nil? || known_at.nil?
89
+
90
+ (stored_at - known_at).abs <= OPENED_AT_TOLERANCE
91
+ end
92
+
93
+ # When the storage reports a failed write (false), keep the last status
94
+ # observed there: the stale entry then reads as unchanged and cannot
95
+ # undo this transition.
96
+ def persist_status(status, opened_at = nil)
97
+ return if @storage.nil? || @storage.set_status(@name, status, opened_at) == false
98
+
99
+ @stored_status = BreakerMachines::Status.new(status: status, opened_at: opened_at)
62
100
  end
63
101
  end
64
102
  end
@@ -57,23 +57,15 @@ module BreakerMachines
57
57
 
58
58
  # Execute a block with circuit breaker protection
59
59
  #
60
+ # Admission, the open -> half-open cooldown, and half-open probe limits run
61
+ # in the native circuit. A StandardError counts as a failure and is
62
+ # re-raised; other exceptions and throw/break pass through uncounted.
63
+ #
60
64
  # @yield Block to execute
61
65
  # @return Result of the block
62
- # @raise [CircuitOpenError] if circuit is open
63
- def call
64
- raise CircuitOpenError, "Circuit '#{@name}' is open" if open?
65
-
66
- start_time = BreakerMachines.monotonic_time
67
- begin
68
- result = yield
69
- duration = BreakerMachines.monotonic_time - start_time
70
- @native_circuit.record_success(duration)
71
- result
72
- rescue StandardError => _e
73
- duration = BreakerMachines.monotonic_time - start_time
74
- @native_circuit.record_failure(duration)
75
- raise
76
- end
66
+ # @raise [CircuitOpenError] if circuit is open or its probe slots are taken
67
+ def call(&)
68
+ @native_circuit.call(&)
77
69
  end
78
70
 
79
71
  # Check if circuit is open
@@ -88,8 +80,14 @@ module BreakerMachines
88
80
  @native_circuit.is_closed
89
81
  end
90
82
 
83
+ # Check if circuit is half-open (probing for recovery)
84
+ # @return [Boolean]
85
+ def half_open?
86
+ @native_circuit.is_half_open
87
+ end
88
+
91
89
  # Get current state name
92
- # @return [String] 'open' or 'closed'
90
+ # @return [String] 'closed', 'open', or 'half_open'
93
91
  def state
94
92
  @native_circuit.state_name
95
93
  end
@@ -11,7 +11,7 @@ module BreakerMachines
11
11
  def on_circuit_open
12
12
  @state_epoch.increment
13
13
  @opened_at.value = BreakerMachines.monotonic_time
14
- @storage&.set_status(@name, :open, @opened_at.value)
14
+ persist_status(:open, @opened_at.value)
15
15
  if @storage.respond_to?(:record_event_with_details)
16
16
  @storage.record_event_with_details(@name, :state_change, 0,
17
17
  new_state: :open)
@@ -25,7 +25,7 @@ module BreakerMachines
25
25
  @opened_at.value = nil
26
26
  @last_error.value = nil
27
27
  @last_failure_at.value = nil
28
- @storage&.set_status(@name, :closed)
28
+ persist_status(:closed)
29
29
  if @storage.respond_to?(:record_event_with_details)
30
30
  @storage.record_event_with_details(@name, :state_change, 0,
31
31
  new_state: :closed)
@@ -38,7 +38,7 @@ module BreakerMachines
38
38
  @state_epoch.increment
39
39
  @half_open_attempts.value = 0
40
40
  @half_open_successes.value = 0
41
- @storage&.set_status(@name, :half_open)
41
+ persist_status(:half_open, @opened_at.value)
42
42
  if @storage.respond_to?(:record_event_with_details)
43
43
  @storage.record_event_with_details(@name, :state_change, 0,
44
44
  new_state: :half_open)
@@ -13,6 +13,8 @@ module BreakerMachines
13
13
  raise NotImplementedError
14
14
  end
15
15
 
16
+ # opened_at is a BreakerMachines.monotonic_time value. Return false when
17
+ # the write did not happen; any other value means it was stored.
16
18
  def set_status(circuit_name, status, opened_at = nil)
17
19
  raise NotImplementedError
18
20
  end
@@ -10,6 +10,12 @@ module BreakerMachines
10
10
  @cache = cache_store
11
11
  @prefix = options[:prefix] || 'breaker_machines'
12
12
  @expires_in = options[:expires_in] || 300 # 5 minutes default
13
+ # Counter resolution in seconds. Each bucket is its own cache entry, so
14
+ # stores that keep expired entries around (Solid Cache) want coarser buckets.
15
+ @bucket_seconds = Integer(options[:bucket_seconds] || 1)
16
+ raise ArgumentError, 'bucket_seconds must be positive' unless @bucket_seconds.positive?
17
+
18
+ @warned_windows = Concurrent::Set.new
13
19
  end
14
20
 
15
21
  def get_status(circuit_name)
@@ -18,7 +24,7 @@ module BreakerMachines
18
24
 
19
25
  BreakerMachines::Status.new(
20
26
  status: data[:status].to_sym,
21
- opened_at: data[:opened_at]
27
+ opened_at: local_opened_at(data)
22
28
  )
23
29
  end
24
30
 
@@ -28,6 +34,7 @@ module BreakerMachines
28
34
  {
29
35
  status: status,
30
36
  opened_at: opened_at,
37
+ opened_at_epoch: opened_at && BreakerMachines.monotonic_to_epoch(opened_at),
31
38
  updated_at: monotonic_time
32
39
  },
33
40
  expires_in: @expires_in
@@ -55,6 +62,12 @@ module BreakerMachines
55
62
  @cache.delete(success_key(circuit_name))
56
63
  @cache.delete(failure_key(circuit_name))
57
64
  @cache.delete(events_key(circuit_name))
65
+ return unless @cache.respond_to?(:increment)
66
+
67
+ keys = [success_key(circuit_name), failure_key(circuit_name)].flat_map do |key|
68
+ bucket_keys(key, bucket_count(@expires_in) + 1)
69
+ end
70
+ delete_counters(keys)
58
71
  end
59
72
 
60
73
  def clear_all
@@ -103,10 +116,20 @@ module BreakerMachines
103
116
 
104
117
  private
105
118
 
119
+ # Hosts do not share a monotonic clock origin, so other hosts read the
120
+ # epoch copy. Entries written before it existed carry only the writer's
121
+ # monotonic time.
122
+ def local_opened_at(data)
123
+ epoch = data[:opened_at_epoch]
124
+ epoch ? BreakerMachines.epoch_to_monotonic(epoch) : data[:opened_at]
125
+ end
126
+
106
127
  def increment_counter(key)
107
128
  # Use increment if available, otherwise fetch-and-update
108
129
  if @cache.respond_to?(:increment)
109
- @cache.increment(key, 1, expires_in: @expires_in)
130
+ # Expiry counts from the first increment, so a bucket must outlive
131
+ # the retention period by its own length.
132
+ @cache.increment("#{key}:#{current_bucket}", 1, expires_in: @expires_in + @bucket_seconds)
110
133
  else
111
134
  # Fallback for caches without atomic increment
112
135
  current = @cache.fetch(key) { {} }
@@ -117,32 +140,58 @@ module BreakerMachines
117
140
  end
118
141
 
119
142
  def get_window_count(key, window_seconds)
143
+ warn_window_exceeds_retention(window_seconds)
144
+ count = bucket_count([window_seconds, @expires_in].min)
145
+
120
146
  if @cache.respond_to?(:increment)
121
- # For simple counter-based caches, we can't get windowed counts
122
- # Would need to implement bucketing similar to fallback
123
- @cache.read(key) || 0
147
+ read_counters(bucket_keys(key, count)).sum(&:to_i)
124
148
  else
125
- # Bucket-based counting for accurate windows
126
149
  buckets = @cache.read(key) || {}
127
- current_time = current_bucket
150
+ now = current_bucket
151
+ count.times.sum { |offset| buckets[now - offset] || 0 }
152
+ end
153
+ end
128
154
 
129
- total = 0
130
- window_seconds.times do |i|
131
- bucket_key = current_time - i
132
- total += buckets[bucket_key] || 0
133
- end
155
+ # Counters live for expires_in, so a longer window silently undercounts.
156
+ def warn_window_exceeds_retention(window_seconds)
157
+ return if window_seconds <= @expires_in
158
+ return unless @warned_windows.add?(window_seconds)
134
159
 
135
- total
136
- end
160
+ BreakerMachines.logger&.warn(
161
+ "[BreakerMachines] Cache storage keeps counts for #{@expires_in}s, so a #{window_seconds}s " \
162
+ "window only sees the last #{@expires_in}s. Set expires_in to at least the window."
163
+ )
164
+ end
165
+
166
+ # read_multi is a single round trip; stores without it read each bucket.
167
+ def read_counters(keys)
168
+ return @cache.read_multi(*keys, raw: true).values if @cache.respond_to?(:read_multi)
169
+
170
+ keys.filter_map { |key| @cache.read(key) }
171
+ end
172
+
173
+ def delete_counters(keys)
174
+ return @cache.delete_multi(keys) if @cache.respond_to?(:delete_multi)
175
+
176
+ keys.each { |key| @cache.delete(key) }
177
+ end
178
+
179
+ def bucket_count(seconds)
180
+ (seconds.to_f / @bucket_seconds).ceil
181
+ end
182
+
183
+ def bucket_keys(key, count)
184
+ now = current_bucket
185
+ Array.new(count) { |offset| "#{key}:#{now - offset}" }
137
186
  end
138
187
 
139
188
  def prune_old_buckets(buckets)
140
- cutoff = current_bucket - 300 # Keep 5 minutes of data
141
- buckets.delete_if { |time, _| time < cutoff }
189
+ cutoff = current_bucket - bucket_count(@expires_in)
190
+ buckets.delete_if { |bucket, _| bucket < cutoff }
142
191
  end
143
192
 
144
193
  def current_bucket
145
- Time.now.to_i
194
+ Time.now.to_i / @bucket_seconds
146
195
  end
147
196
 
148
197
  def status_key(circuit_name)
@@ -58,10 +58,12 @@ module BreakerMachines
58
58
  timestamp: current_time
59
59
  }
60
60
 
61
- # Clean old events periodically (every 100 events)
62
- return unless events.size > 100
61
+ # Keep 5 minutes of history. Events are appended oldest first, so only
62
+ # scan once the oldest has aged out; scanning on every write made each
63
+ # write O(n) once more than 100 events were retained.
64
+ cutoff_time = current_time - 300
65
+ return unless events.size > 100 && events.first[:timestamp] < cutoff_time
63
66
 
64
- cutoff_time = current_time - 300 # Keep 5 minutes of history
65
67
  events.delete_if { |e| e[:timestamp] < cutoff_time }
66
68
  end
67
69
 
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module BreakerMachines
4
- VERSION = '0.18.0'
4
+ VERSION = '0.20.0'
5
5
  end
@@ -184,6 +184,35 @@ module BreakerMachines
184
184
  def monotonic_time
185
185
  Process.clock_gettime(Process::CLOCK_MONOTONIC)
186
186
  end
187
+
188
+ # Converts a {monotonic_time} value to Unix epoch seconds. Each host's
189
+ # monotonic clock has its own origin, so shared storage must persist
190
+ # timestamps in epoch time for other hosts to read them.
191
+ #
192
+ # @param monotonic [Float] a value from {monotonic_time}
193
+ # @return [Float] Unix epoch seconds
194
+ def monotonic_to_epoch(monotonic)
195
+ monotonic + clock_offset
196
+ end
197
+
198
+ # Converts Unix epoch seconds to this host's {monotonic_time}.
199
+ #
200
+ # @param epoch [Float] Unix epoch seconds
201
+ # @return [Float] the matching {monotonic_time} value on this host
202
+ def epoch_to_monotonic(epoch)
203
+ epoch - clock_offset
204
+ end
205
+
206
+ private
207
+
208
+ # Wall clock minus monotonic clock. Memoized so that converting the same
209
+ # timestamp twice gives the same result; re-measured only when the wall
210
+ # clock has stepped by more than a second.
211
+ def clock_offset
212
+ offset = Process.clock_gettime(Process::CLOCK_REALTIME) - monotonic_time
213
+ @clock_offset = offset if @clock_offset.nil? || (offset - @clock_offset).abs > 1.0
214
+ @clock_offset
215
+ end
187
216
  end
188
217
 
189
218
  # Set up notifications on first use
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: breaker_machines
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.18.0
4
+ version: 0.20.0
5
5
  platform: aarch64-linux
6
6
  authors:
7
7
  - Abdelkader Boudih
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2026-10-05 00:00:00.000000000 Z
11
+ date: 2026-10-12 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: activesupport
@@ -16,28 +16,28 @@ dependencies:
16
16
  requirements:
17
17
  - - ">="
18
18
  - !ruby/object:Gem::Version
19
- version: '8.0'
19
+ version: '8.1'
20
20
  type: :runtime
21
21
  prerelease: false
22
22
  version_requirements: !ruby/object:Gem::Requirement
23
23
  requirements:
24
24
  - - ">="
25
25
  - !ruby/object:Gem::Version
26
- version: '8.0'
26
+ version: '8.1'
27
27
  - !ruby/object:Gem::Dependency
28
28
  name: chrono_machines
29
29
  requirement: !ruby/object:Gem::Requirement
30
30
  requirements:
31
31
  - - "~>"
32
32
  - !ruby/object:Gem::Version
33
- version: '0.9'
33
+ version: '0.10'
34
34
  type: :runtime
35
35
  prerelease: false
36
36
  version_requirements: !ruby/object:Gem::Requirement
37
37
  requirements:
38
38
  - - "~>"
39
39
  - !ruby/object:Gem::Version
40
- version: '0.9'
40
+ version: '0.10'
41
41
  - !ruby/object:Gem::Dependency
42
42
  name: concurrent-ruby
43
43
  requirement: !ruby/object:Gem::Requirement
@@ -86,14 +86,14 @@ dependencies:
86
86
  requirements:
87
87
  - - "~>"
88
88
  - !ruby/object:Gem::Version
89
- version: '5.16'
89
+ version: '6.0'
90
90
  type: :development
91
91
  prerelease: false
92
92
  version_requirements: !ruby/object:Gem::Requirement
93
93
  requirements:
94
94
  - - "~>"
95
95
  - !ruby/object:Gem::Version
96
- version: '5.16'
96
+ version: '6.0'
97
97
  - !ruby/object:Gem::Dependency
98
98
  name: rake
99
99
  requirement: !ruby/object:Gem::Requirement