stoplight 6.0.0.rc2 → 6.0.0.rc5

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: 0dc15c81e94d811df03fe4acdd4ff76e3633931a1c4df022b7c26480834135e8
4
- data.tar.gz: 82b93fba4751312a3ad6ac33602b0b50c40fc6ebd5d72554d66f040ab7efd133
3
+ metadata.gz: ade3c17bdafebab88e87b0f967dcf71a3fef99bb486c10a328e5110c4a4f456a
4
+ data.tar.gz: 2dff3cb5d7731a337c8d558c73d7a3091b7bf1bafb060ae7d8bd74d570f2b4c2
5
5
  SHA512:
6
- metadata.gz: 3a97e8dec725453ffd76e95d7d2ab59ef8929b89aa6ac2350a816d3e280aa49f893d3818dc28d6b465e20bf508c9cf10b37265e6968c436836ba9b5bdc374bb8
7
- data.tar.gz: 9550e279df8c4ceaa1cc4de1a76a85dcb2fa84cb079e7dbd3716e699479b5cf94fe1d577f99f713840b693b6520d1c19b8f1b2b01fc9325b6db7cf89abdcfed3
6
+ metadata.gz: bd236321933ab75c09c468c3a12fddefe3002ce16cbfc7c6a28580d56fb3b6cace531792e9e5c0cae393158f5ba0ce6d62ec1425363bf4ba83abd8b6d4cb04ee
7
+ data.tar.gz: ce3c272066f30882c34921144dbe48773fd6ed4812d6933ea96842a0848830c4e9e5b1f23b46d7d93556c87d56cb6b7345553c06135ab54f2111f21a52aac759
data/README.md CHANGED
@@ -678,8 +678,8 @@ Example: "Ruby 3.3 reaches end-of-life in March 2027, so Stoplight 7.0 will requ
678
678
  ## Development
679
679
 
680
680
  After checking out the repo, run `bundle install` to install dependencies. Run tests with `bundle exec rspec` and check
681
- code style with `bundle exec standardrb`. We follow a git flow branching strategy - see our [Git Flow wiki page] for
682
- details on branch naming, releases, and contribution workflow. Also check our CONTRIBUTING.md guide for contributors.
681
+ code style with `bundle exec standardrb`. All work happens on `main` - see [CONTRIBUTING.md] for branch naming,
682
+ releases, and the contribution workflow.
683
683
 
684
684
  ## Credits
685
685
 
@@ -708,7 +708,7 @@ Fowler’s [CircuitBreaker][] article.
708
708
  [complete list of contributors]: https://github.com/bolshakov/stoplight/graphs/contributors
709
709
  [CircuitBreaker]: http://martinfowler.com/bliki/CircuitBreaker.html
710
710
  [Redis]: https://redis.io/
711
- [Git Flow wiki page]: https://github.com/bolshakov/stoplight/wiki/Git-Flow
711
+ [CONTRIBUTING.md]: CONTRIBUTING.md
712
712
  [Valkey]: https://valkey.io/
713
713
  [Ruby Maintenance Branches]: https://www.ruby-lang.org/en/downloads/branches/
714
714
  [Redis's support policy]: https://redis.io/about/releases/
data/UPGRADING.md CHANGED
@@ -8,6 +8,7 @@ Here's what you'll want to tackle during your upgrade, roughly ordered from the
8
8
 
9
9
  - [ ] Make every `Stoplight("name", ...)` call site for the same name pass identical settings
10
10
  - [ ] Move `data_store`, `notifiers`, and `error_notifier` from individual lights to `Stoplight.configure`
11
+ - [ ] Make sure that you run at least Ruby 3.3+, Redis 7.4+ (or Valkey 7.2+)
11
12
  - [ ] Replace the `with_*` builder methods with settings passed at creation time
12
13
  - [ ] Replace proc and anonymous-class error matchers with named classes or modules
13
14
  - [ ] Account for Stoplight state reset after deployment
@@ -15,6 +16,7 @@ Here's what you'll want to tackle during your upgrade, roughly ordered from the
15
16
  - [ ] Round any fractional `window_size` up to a whole number of seconds
16
17
  - [ ] Round any fractional `cool_off_time` up to a whole number of seconds, at least 1
17
18
  - [ ] Drop `warn_on_clock_skew` from your Redis data store setup
19
+ - [ ] Compare `light.color` and `light.state` against symbols, not strings, and pass a symbol to `light.lock`
18
20
  - [ ] Test thoroughly in a staging environment
19
21
 
20
22
  ### Lights Are Registered and Reused
@@ -86,6 +88,13 @@ end
86
88
  If you genuinely need more than one data store - separate Redis instances per tenant, for example - use named
87
89
  systems, which own their own infrastructure. See [Systems: Namespace Isolation & Configuration][systems] for setup.
88
90
 
91
+ ### Ruby 3.3 and Redis 7.4 Are the new Minimum
92
+
93
+ Stoplight 6 requires Ruby 3.3 or newer.
94
+ We also require Redis 7.4 or newer and Valkey 7.2 or newer and no longer tested or supported earlier versions.
95
+
96
+ See the [maintenance policy] for the versions under test.
97
+
89
98
  ### The Light Builder API Is Gone
90
99
 
91
100
  `Light#with` and the whole `with_*` family are removed: `with_data_store`, `with_cool_off_time`, `with_threshold`,
@@ -250,6 +259,31 @@ Stoplight::DataStore::Redis.new(redis)
250
259
  All time-dependent decisions are now made from Redis's own clock, so every instance reads the same time no matter
251
260
  what its host believes. With the coordination problem solved at the source, there is no skew left to warn about.
252
261
 
262
+ ### Colors and Lock States Are Symbols
263
+
264
+ `light.color` now returns `:green`, `:yellow`, or `:red` instead of `"green"`, `"yellow"`, or `"red"`, and
265
+ `light.state` returns `:unlocked`, `:locked_green`, or `:locked_red`. The constants under `Stoplight::Color` and
266
+ `Stoplight::State` hold the same symbols, and every telemetry event carries them too: `from_color` and `to_color` on
267
+ each transition, `from_state` and `to_state` on `LockChanged`.
268
+
269
+ `light.lock` only recognises the symbols, so `light.lock("red")` now raises `Stoplight::Error::IncorrectColor`.
270
+
271
+ Code that compares against the constants keeps working. Code that compares against, or passes, string literals needs
272
+ updating:
273
+
274
+ ```ruby
275
+ # Old way that won't work anymore
276
+ alert! if light.color == "red"
277
+ light.lock("green")
278
+
279
+ # New way
280
+ alert! if light.color == Stoplight::Color::RED
281
+ light.lock(Stoplight::Color::GREEN)
282
+ ```
283
+
284
+ Symbols are the idiomatic Ruby choice for a closed set of values. Anything that serializes them - logs, metrics tags,
285
+ the Admin dashboard's JSON - renders the same text as before.
286
+
253
287
  ### Getting Help
254
288
 
255
289
  If you run into anything this guide doesn't cover, post to our [Discussions forum]. The configuration errors in 6.0
@@ -556,6 +590,7 @@ Nothing. Stoplight will function as usual.
556
590
  [Community-supported notifiers]: https://github.com/bolshakov/stoplight/tree/master#community-supported-notifiers
557
591
  [How to implement your own notifier?]: https://github.com/bolshakov/stoplight/blob/master/lib/stoplight/notifier/generic.rb
558
592
  [dropped notifiers]: https://github.com/bolshakov/stoplight/tree/v3.0.1/lib/stoplight/notifier
593
+ [maintenance policy]: https://github.com/bolshakov/stoplight#maintenance-policy
559
594
  [without passing an empty block]: https://github.com/bolshakov/stoplight-admin/blob/9c9848eb94410e46b20972548f0863db224cb6da/lib/sinatra/stoplight_admin.rb#L30
560
595
  [sliding window]: https://github.com/bolshakov/stoplight#custom-window-size
561
596
  [systems]: https://github.com/bolshakov/stoplight/blob/master/docs/systems.md
@@ -9,6 +9,10 @@ module Stoplight
9
9
  Color::RED
10
10
  ].freeze
11
11
 
12
+ def red?(light) = light.color == Color::RED
13
+
14
+ def yellow?(light) = light.color == Color::YELLOW
15
+
12
16
  def dependencies
13
17
  Dependencies.new(system: current_system)
14
18
  end
@@ -28,15 +28,15 @@
28
28
  </p>
29
29
  <span class="flex items-center text-sm font-medium text-gray-500 dark:text-gray-400 truncate me-3">
30
30
  <span class="flex w-2.5 h-2.5 bg-<%= color %>-600 rounded-full me-1.5 shrink-0"></span>
31
- <% if light.color == "red" %>
31
+ <% if red?(light) %>
32
32
  Open
33
- <% elsif light.color == "yellow" %>
33
+ <% elsif yellow?(light) %>
34
34
  Half-Open
35
35
  <% else %>
36
36
  Closed
37
37
  <% end %>
38
38
 
39
- <% if light.color == "yellow" %>
39
+ <% if yellow?(light) %>
40
40
  (Recovering)
41
41
  <% elsif light.locked? %>
42
42
  (Locked)
@@ -142,14 +142,14 @@ module Stoplight
142
142
 
143
143
  patch "/systems/:system_id/lights/:light_id/lock" do
144
144
  light_id = T.must(params[:light_id])
145
- color = T.must(params[:color])
145
+ color = T.must(params[:color]).to_sym
146
146
  dependencies.lock_action.call(light_id:, color:)
147
147
 
148
148
  redirect system_url(current_system_id, "/lights")
149
149
  end
150
150
 
151
151
  patch "/systems/:system_id/lights/lock" do
152
- color = T.must(params[:color])
152
+ color = T.must(params[:color]).to_sym
153
153
  dependencies.lock_all_action.call(color:)
154
154
 
155
155
  redirect system_url(current_system_id, "/lights")
@@ -2,8 +2,8 @@
2
2
 
3
3
  module Stoplight
4
4
  module Color
5
- GREEN = "green"
6
- YELLOW = "yellow"
7
- RED = "red"
5
+ GREEN = :green
6
+ YELLOW = :yellow
7
+ RED = :red
8
8
  end
9
9
  end
@@ -23,7 +23,7 @@ module Stoplight
23
23
  # @!attribute time
24
24
  # The time when the snapshot was taken
25
25
 
26
- # @return [String] one of +Color::GREEN+, +Color::RED+, or +Color::YELLOW+
26
+ # @return [Symbol] one of +Color::GREEN+, +Color::RED+, or +Color::YELLOW+
27
27
  def color
28
28
  if locked_state == State::LOCKED_GREEN
29
29
  Color::GREEN
@@ -0,0 +1,9 @@
1
+ -- @include now
2
+
3
+ local meta_key = KEYS[1]
4
+
5
+ local state = redis.call('HMGET', meta_key, 'breached_at', 'locked_state', 'recovery_scheduled_after', 'recovery_started_at')
6
+ local current_time = now()
7
+ state[5] = current_time
8
+
9
+ return state
@@ -1,8 +1,9 @@
1
1
  -- @include now
2
2
 
3
3
  local meta_key = KEYS[1]
4
- local recovery_scheduled_after_ts = tonumber(ARGV[1])
4
+ local cool_off_time = tonumber(ARGV[1])
5
5
  local current_ts = now() / 1000
6
+ local recovery_scheduled_after_ts = current_ts + cool_off_time
6
7
 
7
8
  -- 1 if the field is a new field in the hash and the value was set
8
9
  local became_red = redis.call('HSETNX', meta_key, 'breached_at', current_ts)
@@ -57,16 +57,19 @@ module Stoplight
57
57
  end
58
58
 
59
59
  def state_snapshot
60
- breached_at_raw, locked_state, recovery_scheduled_after_raw, recovery_started_at_raw = redis.with do |client|
61
- client.hmget(state_key, :breached_at, :locked_state, :recovery_scheduled_after, :recovery_started_at)
62
- end
60
+ breached_at_raw, locked_state, recovery_scheduled_after_raw, recovery_started_at_raw, now_ms =
61
+ scripting.call(
62
+ "state/state_snapshot",
63
+ args: [],
64
+ keys: [state_key]
65
+ )
63
66
 
64
67
  Domain::StateSnapshot.new(
65
68
  breached_at: breached_at_raw && clock.at(breached_at_raw.to_f),
66
- locked_state: locked_state || Stoplight::State::UNLOCKED,
69
+ locked_state: locked_state&.to_sym || Stoplight::State::UNLOCKED,
67
70
  recovery_scheduled_after: recovery_scheduled_after_raw && clock.at(recovery_scheduled_after_raw.to_f),
68
71
  recovery_started_at: recovery_started_at_raw && clock.at(recovery_started_at_raw.to_f),
69
- time: clock.current_time
72
+ time: clock.at(now_ms.fdiv(1000))
70
73
  )
71
74
  end
72
75
 
@@ -123,12 +126,9 @@ module Stoplight
123
126
  # Transitions to RED state and ensures only one notification
124
127
  #
125
128
  def transition_to_red
126
- current_ts = clock.current_time.to_f
127
- recovery_scheduled_after_ts = current_ts + cool_off_time
128
-
129
129
  became_red = scripting.call(
130
130
  "state/transition_to_red",
131
- args: [recovery_scheduled_after_ts],
131
+ args: [cool_off_time],
132
132
  keys: [state_key]
133
133
  )
134
134
 
@@ -18,6 +18,8 @@ local function evict_buckets(metrics_key, ts_index_key, window_start)
18
18
  return
19
19
  end
20
20
 
21
+ local failures, successes = 0, 0
22
+
21
23
  for from = 1, #buckets, EVICT_BATCH_SIZE do
22
24
  local to = math.min(from + EVICT_BATCH_SIZE - 1, #buckets)
23
25
 
@@ -29,16 +31,15 @@ local function evict_buckets(metrics_key, ts_index_key, window_start)
29
31
 
30
32
  local values = redis.call('HMGET', metrics_key, unpack(fields))
31
33
 
32
- local failures, successes = 0, 0
33
34
  for i = 1, #values, 2 do
34
35
  failures = failures + (tonumber(values[i]) or 0)
35
36
  successes = successes + (tonumber(values[i + 1]) or 0)
36
37
  end
37
38
 
38
- redis.call('HINCRBY', metrics_key, 'total_failures', -failures)
39
- redis.call('HINCRBY', metrics_key, 'total_successes', -successes)
40
39
  redis.call('HDEL', metrics_key, unpack(fields))
41
40
  end
42
41
 
42
+ redis.call('HINCRBY', metrics_key, 'total_failures', -failures)
43
+ redis.call('HINCRBY', metrics_key, 'total_successes', -successes)
43
44
  redis.call('ZREMRANGEBYSCORE', ts_index_key, '-inf', window_start)
44
45
  end
@@ -2,8 +2,8 @@
2
2
 
3
3
  module Stoplight
4
4
  module State
5
- UNLOCKED = "unlocked"
6
- LOCKED_GREEN = "locked_green"
7
- LOCKED_RED = "locked_red"
5
+ UNLOCKED = :unlocked
6
+ LOCKED_GREEN = :locked_green
7
+ LOCKED_RED = :locked_red
8
8
  end
9
9
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Stoplight
4
- VERSION = Gem::Version.new("6.0.0.rc2")
4
+ VERSION = Gem::Version.new("6.0.0.rc5")
5
5
  end
@@ -46,6 +46,9 @@ module Stoplight
46
46
  REGISTRATION_FRAME_LIMIT = 5
47
47
  private_constant :REGISTRATION_FRAME_LIMIT
48
48
 
49
+ Registration = Data.define(:light, :digest, :backtrace, :without_settings)
50
+ private_constant :Registration
51
+
49
52
  attr_reader :name
50
53
  # @api private
51
54
  attr_reader :config
@@ -113,6 +116,16 @@ module Stoplight
113
116
  traffic_control: T.undefined,
114
117
  traffic_recovery: T.undefined
115
118
  )
119
+ without_settings = cool_off_time.is_a?(Undefined) && threshold.is_a?(Undefined) &&
120
+ recovery_threshold.is_a?(Undefined) && window_size.is_a?(Undefined) &&
121
+ tracked_errors.is_a?(Undefined) && skipped_errors.is_a?(Undefined) &&
122
+ traffic_control.is_a?(Undefined) && traffic_recovery.is_a?(Undefined)
123
+
124
+ if without_settings
125
+ registration = @lights[name]
126
+ return registration.light if registration&.without_settings
127
+ end
128
+
116
129
  light_dsl = LightConfigurationDsl.new(
117
130
  name:,
118
131
  cool_off_time:,
@@ -126,8 +139,8 @@ module Stoplight
126
139
  )
127
140
  config_digest = light_dsl.digest
128
141
 
129
- light, existing_digest, existing_backtrace = @lights[name] || begin
130
- registered_at = ExternalCaller.backtrace.first(REGISTRATION_FRAME_LIMIT)
142
+ registration = @lights[name] || begin
143
+ backtrace = ExternalCaller.backtrace.first(REGISTRATION_FRAME_LIMIT)
131
144
  config = light_dsl.configure!(@config)
132
145
  @lights.compute_if_absent(name) do
133
146
  built = LightFactory.new(
@@ -137,12 +150,12 @@ module Stoplight
137
150
  telemetry: @telemetry
138
151
  ).build
139
152
  @registry.register(config)
140
- [built, config_digest, registered_at]
153
+ Registration.new(light: built, digest: config_digest, backtrace:, without_settings:)
141
154
  end
142
155
  end
143
156
 
144
- if config_digest != existing_digest
145
- original_site = existing_backtrace.map { |frame| " #{frame}" }.join("\n")
157
+ if config_digest != registration.digest
158
+ original_site = registration.backtrace.map { |frame| " #{frame}" }.join("\n")
146
159
 
147
160
  raise Stoplight::Error::ConfigurationError, <<~MSG, ExternalCaller.backtrace
148
161
  Light `#{name}` already registered with different configuration.
@@ -154,12 +167,12 @@ module Stoplight
154
167
  MSG
155
168
  end
156
169
 
157
- light
170
+ registration.light
158
171
  end
159
172
 
160
173
  # @raise [Stoplight::Error::UnregisteredLightError] if no light was registered under +name+
161
174
  def light(name)
162
- @lights[name]&.first || raise(Stoplight::Error::UnregisteredLightError, <<~MSG)
175
+ @lights[name]&.light || raise(Stoplight::Error::UnregisteredLightError, <<~MSG)
163
176
  Light `#{name}` was never registered on system `#{@name}`.
164
177
  Call `.register(#{name.inspect}, ...)` at boot before using it.
165
178
  MSG
@@ -3,7 +3,7 @@ module Stoplight
3
3
  class Admin
4
4
  module Actions
5
5
  class Action
6
- def halt: (Integer status) -> void
6
+ def halt: (Integer status) -> bot
7
7
  end
8
8
  end
9
9
  end
@@ -3,6 +3,9 @@ module Stoplight
3
3
  module Helpers : Sinatra::Base
4
4
  COLORS: Array[Domain::color]
5
5
 
6
+ def red?: (LightView) -> bool
7
+ def yellow?: (LightView) -> bool
8
+
6
9
  def dependencies: -> Dependencies
7
10
  def system_url: (String system_id, String path) -> String
8
11
  def asset_path: (String) -> String
@@ -5,15 +5,15 @@ module Stoplight
5
5
  type duration_ms = Float
6
6
 
7
7
  # Light Colors
8
- type green = "green"
9
- type yellow = "yellow"
10
- type red = "red"
8
+ type green = :green
9
+ type yellow = :yellow
10
+ type red = :red
11
11
  type color = green | yellow | red
12
12
 
13
13
  # Light States
14
- type unlocked = "unlocked"
15
- type locked_green = "locked_green"
16
- type locked_red = "locked_red"
14
+ type unlocked = :unlocked
15
+ type locked_green = :locked_green
16
+ type locked_red = :locked_red
17
17
  type state = unlocked | locked_green | locked_red
18
18
 
19
19
  type recovery_decision = :green | :yellow | :red
@@ -9,7 +9,7 @@ module Stoplight
9
9
  attr_reader scripting: Scripting
10
10
  attr_reader key_space: Key
11
11
  attr_reader clock: Domain::_Clock
12
- attr_reader cool_off_time: Domain::timestamp
12
+ attr_reader cool_off_time: Domain::duration_seconds
13
13
  attr_reader state_key: String
14
14
 
15
15
  def initialize: (
@@ -17,7 +17,7 @@ module Stoplight
17
17
  redis: redis,
18
18
  scripting: Scripting,
19
19
  key_space: Key,
20
- cool_off_time: Domain::timestamp
20
+ cool_off_time: Domain::duration_seconds
21
21
  ) -> void
22
22
 
23
23
  def transition_to_green: -> bool
@@ -6,7 +6,28 @@ module Stoplight
6
6
  @name: String
7
7
  REGISTRATION_FRAME_LIMIT: Integer
8
8
 
9
- @lights: Concurrent::Map[String, [Domain::Light, Integer, Array[String]]]
9
+ class Registration < Data
10
+ attr_reader light: Domain::Light
11
+ attr_reader digest: Integer
12
+ attr_reader backtrace: Array[String]
13
+ attr_reader without_settings: bool
14
+
15
+ def self.new: (
16
+ light: Domain::Light,
17
+ digest: Integer,
18
+ backtrace: Array[String],
19
+ without_settings: bool,
20
+ ) -> instance
21
+
22
+ def initialize: (
23
+ light: Domain::Light,
24
+ digest: Integer,
25
+ backtrace: Array[String],
26
+ without_settings: bool,
27
+ ) -> void
28
+ end
29
+
30
+ @lights: Concurrent::Map[String, Registration]
10
31
  @failover_system: _System?
11
32
  @registry: Domain::_Registry
12
33
  @telemetry: Domain::Telemetry::Bus
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: stoplight
3
3
  version: !ruby/object:Gem::Version
4
- version: 6.0.0.rc2
4
+ version: 6.0.0.rc5
5
5
  platform: ruby
6
6
  authors:
7
7
  - Tëma Bolshakov
@@ -145,6 +145,7 @@ files:
145
145
  - lib/stoplight/infrastructure/redis/storage/registry.rb
146
146
  - lib/stoplight/infrastructure/redis/storage/scripting.rb
147
147
  - lib/stoplight/infrastructure/redis/storage/state.rb
148
+ - lib/stoplight/infrastructure/redis/storage/state/state_snapshot.lua
148
149
  - lib/stoplight/infrastructure/redis/storage/state/transition_to_green.lua
149
150
  - lib/stoplight/infrastructure/redis/storage/state/transition_to_red.lua
150
151
  - lib/stoplight/infrastructure/redis/storage/state/transition_to_yellow.lua