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 +4 -4
- data/CHANGELOG.md +49 -0
- data/README.md +2 -1
- data/lib/togglefleet/version.rb +1 -1
- data/lib/togglefleet.rb +86 -12
- metadata +4 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 0f09f92537778aa772a14ed6250748d881500c9e84d35154f776fffecdecedc1
|
|
4
|
+
data.tar.gz: deb6b017894d8fb1373c23fae52acda0a0b602a6c066c5ebde5e31b97fca6682
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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`
|
|
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
|
data/lib/togglefleet/version.rb
CHANGED
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
|
|
50
|
-
@groups
|
|
51
|
-
@flags
|
|
52
|
-
@etag
|
|
53
|
-
@loaded
|
|
54
|
-
@mutex
|
|
55
|
-
@poller
|
|
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
|
-
|
|
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
|
|
89
|
+
return if @poller && @poller_pid == Process.pid
|
|
90
|
+
@poller = Thread.new do
|
|
71
91
|
loop do
|
|
72
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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:
|