breaker_machines 0.19.0 → 0.20.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: 6172f37c8307d66dbacea1526038a00deb457e16ea404ea877cd4d4c8f5c18bd
4
- data.tar.gz: e246c2d0d5a4df91003105a4ccb2ee5d00593bda8a4d339f8eeb5fd28ac16d41
3
+ metadata.gz: a533c91306760488de32d6ef3b3a3bfc9a6c874b1b6e77c6fed42b429a2da6b6
4
+ data.tar.gz: 88991049dc9e83392a13880557ea199830291d21213749a84cd3716c56c9eaac
5
5
  SHA512:
6
- metadata.gz: b4145bd486e2645b57eab3058671ba08143ac1422a11a68ba00b3079d3c35981d6c8b8c470e2f0de75af59c2799d780a360f74739e43b1022bfd447de6bc5dd0
7
- data.tar.gz: 00f7382d4b5ea05c057c3ba77cf6289d3fe96e5a74351b5cc5280d8106240add688156b2b2a76ae1c609bb4c330bbec60745185a9abb1640bde2e5499c040218
6
+ metadata.gz: 7c25929aa77bacbca4857bc1961110680838e2ae83fee1356d10f3f67297e623f2b1bafe0a104485a372618281d45e542da063482e4ead55294fe9f2e7655478
7
+ data.tar.gz: 7ef7f97ca50b64a56589ff6a3a062fa4016e33b70e81e3becd3fb705254a3ffbab6af89e75a6d8f0fc17f2c8a3784b48dbdaba4d8fe78383ca244fe15e7a0a5f
@@ -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
@@ -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)
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module BreakerMachines
4
- VERSION = '0.19.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,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: breaker_machines
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.19.0
4
+ version: 0.20.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Abdelkader Boudih
@@ -15,28 +15,28 @@ dependencies:
15
15
  requirements:
16
16
  - - ">="
17
17
  - !ruby/object:Gem::Version
18
- version: '8.0'
18
+ version: '8.1'
19
19
  type: :runtime
20
20
  prerelease: false
21
21
  version_requirements: !ruby/object:Gem::Requirement
22
22
  requirements:
23
23
  - - ">="
24
24
  - !ruby/object:Gem::Version
25
- version: '8.0'
25
+ version: '8.1'
26
26
  - !ruby/object:Gem::Dependency
27
27
  name: chrono_machines
28
28
  requirement: !ruby/object:Gem::Requirement
29
29
  requirements:
30
30
  - - "~>"
31
31
  - !ruby/object:Gem::Version
32
- version: '0.9'
32
+ version: '0.10'
33
33
  type: :runtime
34
34
  prerelease: false
35
35
  version_requirements: !ruby/object:Gem::Requirement
36
36
  requirements:
37
37
  - - "~>"
38
38
  - !ruby/object:Gem::Version
39
- version: '0.9'
39
+ version: '0.10'
40
40
  - !ruby/object:Gem::Dependency
41
41
  name: concurrent-ruby
42
42
  requirement: !ruby/object:Gem::Requirement
@@ -85,14 +85,14 @@ dependencies:
85
85
  requirements:
86
86
  - - "~>"
87
87
  - !ruby/object:Gem::Version
88
- version: '5.16'
88
+ version: '6.0'
89
89
  type: :development
90
90
  prerelease: false
91
91
  version_requirements: !ruby/object:Gem::Requirement
92
92
  requirements:
93
93
  - - "~>"
94
94
  - !ruby/object:Gem::Version
95
- version: '5.16'
95
+ version: '6.0'
96
96
  - !ruby/object:Gem::Dependency
97
97
  name: rb_sys
98
98
  requirement: !ruby/object:Gem::Requirement