togglefleet 0.1.0 → 0.2.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: 608c0c99556263faeb1c847374977afa7173f249b83062523d80078891b187ca
4
- data.tar.gz: 5a498caa5253020f3b096d495801701fba336126cc8e66f5c10e60f7628c5dd1
3
+ metadata.gz: 0f09f92537778aa772a14ed6250748d881500c9e84d35154f776fffecdecedc1
4
+ data.tar.gz: deb6b017894d8fb1373c23fae52acda0a0b602a6c066c5ebde5e31b97fca6682
5
5
  SHA512:
6
- metadata.gz: 7fc721286453dbda46fae8f0bcebb8a4a8adf22a8aaefd1beefeeddffda82b350ecad4ae39fdc1a26abced744116778a2ff31f8b3deae93b1c18a7a1c3b52bdf
7
- data.tar.gz: 28d5b041acb5538a9681bada4c0f1c7b636784bc5b875b7415a64aa2bfebedd4bad876d6eab447760615780b6cc4657ed6e948a0f763a52be3d03f3edc9c90e3
6
+ metadata.gz: 8e92f5e26d72e1b324fe2ce6d9f60144c46fa8a50f10fa7b124cdda3c2bf160ca07b7b94a82df0f29822eccc7b0d6e1251b5565f81e239128bce90529e47b6ec
7
+ data.tar.gz: f526fe055723c08c9f9dcc26e749d10e7a434ddecc59870372e4660aeb452fcd6fdfd1804943927c33d09f006753414455717b133bf04e216833e6cfa097bebd
data/CHANGELOG.md ADDED
@@ -0,0 +1,49 @@
1
+ # Changelog
2
+
3
+ All notable changes to the ToggleFleet Ruby SDK are documented here.
4
+ This project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
5
+
6
+ ## [0.2.0] — 2026-07-26
7
+
8
+ Reliability release. Every change here is about the SDK staying out of your
9
+ request path when something goes wrong.
10
+
11
+ ### Fixed
12
+
13
+ - **A failed config fetch no longer blocks every flag check.** Previously, if the
14
+ first fetch failed, `enabled?` retried the HTTP request on *every* call — so an
15
+ unreachable config endpoint added up to `open_timeout + read_timeout` (8s by
16
+ default) to each flag check, turning a background problem into a foreground
17
+ outage. A failed attempt is now not retried until `refresh_interval` has
18
+ elapsed; in between, evaluation returns `config.default` immediately with no
19
+ network access at all.
20
+
21
+ - **Forked web servers now refresh their config.** Threads do not survive `fork`,
22
+ so under Puma, Unicorn or Passenger in clustered mode every worker inherited a
23
+ dead poller and served the boot-time configuration forever. The client now
24
+ detects that it is running in a forked child and restarts the poller
25
+ automatically — no `on_worker_boot` wiring required.
26
+
27
+ - **`ToggleFleet.configure` no longer leaks a thread.** Reconfiguring abandoned
28
+ the previous client's poller, which kept running against the old config. The
29
+ previous client is now stopped first. This mostly bit test suites, which
30
+ reconfigure repeatedly.
31
+
32
+ ### Added
33
+
34
+ - `ToggleFleet.stop` / `Client#stop` — cleanly terminate the background refresh
35
+ thread. Safe to call more than once.
36
+ - Refresh interval is now jittered by ±15% so a fleet of processes that booted
37
+ together does not poll the config endpoint in lockstep.
38
+
39
+ ### Notes
40
+
41
+ - No API changes. Upgrading from 0.1.0 requires no code changes.
42
+ - Flag evaluation semantics are unchanged and remain byte-identical to
43
+ server-side evaluation, including MD5 bucketing for sticky percentage rollouts.
44
+
45
+ ## [0.1.0] — 2026-06-27
46
+
47
+ Initial release: local evaluation of all five gates (boolean, actor, group,
48
+ percentage of actors, percentage of time), background refresh with conditional
49
+ ETag requests, and fail-safe defaults.
data/README.md CHANGED
@@ -81,7 +81,8 @@ ToggleFleet.all(actor: current_user)
81
81
  - **Background refresh** — a single daemon thread polls every `refresh_interval` seconds with an
82
82
  `If-None-Match` ETag, so unchanged configs cost one `304` and zero parsing.
83
83
  - **Fail-safe** — if the service is unreachable, the gem serves the last good config; if it never
84
- loaded, every flag returns `config.default` (defaults to `false`).
84
+ loaded, `start` continues with the background poller and every flag returns `config.default`
85
+ (defaults to `false`). An instrumentation callback failure is logged without changing a flag result.
85
86
  - **Instrumentation** — hook every evaluation for metrics or logging:
86
87
 
87
88
  ```ruby
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module ToggleFleet
4
- VERSION = "0.1.0"
4
+ VERSION = "0.2.0"
5
5
  end
data/lib/togglefleet.rb CHANGED
@@ -46,13 +46,15 @@ module ToggleFleet
46
46
  attr_reader :config
47
47
 
48
48
  def initialize(config)
49
- @config = config
50
- @groups = {} # name => predicate proc
51
- @flags = {} # flag key => state hash
52
- @etag = nil
53
- @loaded = false
54
- @mutex = Mutex.new
55
- @poller = nil
49
+ @config = config
50
+ @groups = {} # name => predicate proc
51
+ @flags = {} # flag key => state hash
52
+ @etag = nil
53
+ @loaded = false
54
+ @mutex = Mutex.new
55
+ @poller = nil
56
+ @poller_pid = nil # pid that owns @poller; threads do not survive fork
57
+ @last_attempt = nil # monotonic time of the last fetch attempt (success or failure)
56
58
  end
57
59
 
58
60
  # Register a group predicate. Group membership is decided in YOUR code, so a flag enabled
@@ -65,25 +67,58 @@ module ToggleFleet
65
67
 
66
68
  # Pull the config once and start the background refresh thread. Idempotent.
67
69
  def start
68
- sync
70
+ attempt_sync
71
+ start_poller
72
+ self
73
+ end
74
+
75
+ # Stop the background refresh thread. Safe to call more than once.
76
+ def stop
77
+ thread = @mutex.synchronize do
78
+ current = @poller
79
+ @poller = nil
80
+ @poller_pid = nil
81
+ current
82
+ end
83
+ thread&.kill
84
+ self
85
+ end
86
+
87
+ private def start_poller
69
88
  @mutex.synchronize do
70
- @poller ||= Thread.new do
89
+ return if @poller && @poller_pid == Process.pid
90
+ @poller = Thread.new do
71
91
  loop do
72
- sleep(@config.refresh_interval)
92
+ # Jitter the interval so a fleet of processes that booted together
93
+ # does not stampede the config endpoint in lockstep.
94
+ sleep(@config.refresh_interval * (0.85 + Kernel.rand * 0.3))
73
95
  begin; sync; rescue StandardError => e; log("refresh failed: #{e.class}: #{e.message}"); end
74
96
  end
75
97
  end
76
98
  @poller.name = "togglefleet-refresh" if @poller.respond_to?(:name=)
99
+ @poller_pid = Process.pid
77
100
  end
78
- self
101
+ end
102
+
103
+ # Threads do not survive fork. Under Puma/Unicorn/Passenger in clustered
104
+ # mode the workers inherit @loaded=true and a dead poller, so without this
105
+ # they would serve the boot-time config forever and never refresh again.
106
+ private def restart_poller_if_forked
107
+ return if @poller_pid.nil? || @poller_pid == Process.pid
108
+ @mutex.synchronize do
109
+ @poller = nil
110
+ @poller_pid = nil
111
+ end
112
+ start_poller
79
113
  end
80
114
 
81
115
  # The whole point: evaluate locally, no network call here.
82
116
  def enabled?(flag, actor: nil, groups: nil)
117
+ restart_poller_if_forked
83
118
  ensure_loaded
84
119
  state = @mutex.synchronize { @flags[flag.to_s] }
85
120
  result = state ? evaluate(state, actor, groups) : @config.default
86
- @config.on_evaluation&.call(flag.to_s, actor, result)
121
+ notify_evaluation(flag.to_s, actor, result)
87
122
  result
88
123
  rescue StandardError => e
89
124
  log("enabled?(#{flag}) error: #{e.class}: #{e.message}")
@@ -126,11 +161,38 @@ module ToggleFleet
126
161
 
127
162
  private
128
163
 
164
+ # Lazy first load, rate-limited.
165
+ #
166
+ # Previously this retried on EVERY enabled? call while unloaded, so if the
167
+ # config endpoint was unreachable each flag check blocked for open_timeout +
168
+ # read_timeout (up to 8s) — turning a background outage into a foreground
169
+ # one, which is the exact opposite of the gem's promise. Now a failed attempt
170
+ # is not repeated until refresh_interval has elapsed; until then evaluation
171
+ # returns config.default immediately with no network at all.
129
172
  def ensure_loaded
130
173
  return if @loaded
174
+ return unless claim_attempt
131
175
  begin; sync; rescue StandardError => e; log("initial load failed, using defaults: #{e.message}"); end
132
176
  end
133
177
 
178
+ # True at most once per refresh_interval. Deliberately records the attempt
179
+ # before it happens, so a hung request cannot let a second caller through.
180
+ def claim_attempt
181
+ now = Process.clock_gettime(Process::CLOCK_MONOTONIC)
182
+ @mutex.synchronize do
183
+ return false if @last_attempt && (now - @last_attempt) < @config.refresh_interval
184
+ @last_attempt = now
185
+ true
186
+ end
187
+ end
188
+
189
+ def attempt_sync
190
+ return unless claim_attempt
191
+ sync
192
+ rescue StandardError => e
193
+ log("initial load failed, using defaults: #{e.class}: #{e.message}")
194
+ end
195
+
134
196
  # Mirrors the server's evaluation byte-for-byte (same MD5 bucketing) so a sticky rollout
135
197
  # is identical whether you evaluate here or call /v1/evaluate.
136
198
  def evaluate(state, actor, explicit_groups)
@@ -177,13 +239,25 @@ module ToggleFleet
177
239
  names.uniq
178
240
  end
179
241
 
242
+ def notify_evaluation(flag, actor, result)
243
+ @config.on_evaluation&.call(flag, actor, result)
244
+ rescue StandardError => e
245
+ log("on_evaluation callback raised: #{e.class}: #{e.message}")
246
+ end
247
+
180
248
  def log(msg)
181
249
  @config.logger&.warn("[togglefleet] #{msg}")
250
+ rescue StandardError
251
+ nil
182
252
  end
183
253
  end
184
254
 
185
255
  class << self
186
256
  def configure
257
+ # Stop the previous client first: reconfiguring used to abandon its poller
258
+ # thread, which kept running forever against the old config — a thread and
259
+ # socket leak every time configure was called (common in test suites).
260
+ @client&.stop
187
261
  @config = Configuration.new
188
262
  yield @config if block_given?
189
263
  @client = Client.new(@config)
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: togglefleet
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.0
4
+ version: 0.2.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - ToggleFleet
@@ -19,6 +19,7 @@ executables: []
19
19
  extensions: []
20
20
  extra_rdoc_files: []
21
21
  files:
22
+ - CHANGELOG.md
22
23
  - LICENSE
23
24
  - README.md
24
25
  - lib/togglefleet.rb
@@ -30,6 +31,8 @@ metadata:
30
31
  homepage_uri: https://togglefleet.com
31
32
  source_code_uri: https://github.com/takeaseatventure/togglefleet-ruby
32
33
  documentation_uri: https://togglefleet.com/docs
34
+ changelog_uri: https://github.com/takeaseatventure/togglefleet-ruby/blob/main/CHANGELOG.md
35
+ bug_tracker_uri: https://github.com/takeaseatventure/togglefleet-ruby/issues
33
36
  rubygems_mfa_required: 'true'
34
37
  rdoc_options: []
35
38
  require_paths: