togglefleet 0.1.0 → 0.2.1

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: add053306fe4d2d5cabba8a3777222eb7434804f2d248d6c2570de4904b63b55
4
+ data.tar.gz: e6e7c6a0dd21a431998a47b121dd8eada0c5fae401cb910af6f6874f50b74e14
5
5
  SHA512:
6
- metadata.gz: 7fc721286453dbda46fae8f0bcebb8a4a8adf22a8aaefd1beefeeddffda82b350ecad4ae39fdc1a26abced744116778a2ff31f8b3deae93b1c18a7a1c3b52bdf
7
- data.tar.gz: 28d5b041acb5538a9681bada4c0f1c7b636784bc5b875b7415a64aa2bfebedd4bad876d6eab447760615780b6cc4657ed6e948a0f763a52be3d03f3edc9c90e3
6
+ metadata.gz: a14afc7241970e98e1f4faa754045bd9b21bbfa6c027c01b6600060f1770e0dd5ba72ec8f83aa7e9a073547a75acd9753ae55f0410f9de38d6324fe8f4c3c938
7
+ data.tar.gz: dfa555c326077d674e3ef8157d5a72fca16000c39be3e4ad707ebe1edf7b10aa306f6d44516972e8020823f28aface6a7c22ed2798b6e0d795942b3ce1f35d49
data/CHANGELOG.md ADDED
@@ -0,0 +1,62 @@
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.1] — 2026-07-26
7
+
8
+ Documentation only. No code changes; upgrading is optional.
9
+
10
+ ### Changed
11
+
12
+ - Expanded the gem description. rubygems.org does not render `README.md` — the
13
+ gem page shows only the description — so the things a developer needs in order
14
+ to evaluate the library (hot-path cost, failure behaviour under an outage,
15
+ zero-dependency footprint, fork safety, and the fact that group membership is
16
+ resolved in your own process so no user data leaves it) are now stated there
17
+ rather than being visible only on GitHub.
18
+
19
+ ## [0.2.0] — 2026-07-26
20
+
21
+ Reliability release. Every change here is about the SDK staying out of your
22
+ request path when something goes wrong.
23
+
24
+ ### Fixed
25
+
26
+ - **A failed config fetch no longer blocks every flag check.** Previously, if the
27
+ first fetch failed, `enabled?` retried the HTTP request on *every* call — so an
28
+ unreachable config endpoint added up to `open_timeout + read_timeout` (8s by
29
+ default) to each flag check, turning a background problem into a foreground
30
+ outage. A failed attempt is now not retried until `refresh_interval` has
31
+ elapsed; in between, evaluation returns `config.default` immediately with no
32
+ network access at all.
33
+
34
+ - **Forked web servers now refresh their config.** Threads do not survive `fork`,
35
+ so under Puma, Unicorn or Passenger in clustered mode every worker inherited a
36
+ dead poller and served the boot-time configuration forever. The client now
37
+ detects that it is running in a forked child and restarts the poller
38
+ automatically — no `on_worker_boot` wiring required.
39
+
40
+ - **`ToggleFleet.configure` no longer leaks a thread.** Reconfiguring abandoned
41
+ the previous client's poller, which kept running against the old config. The
42
+ previous client is now stopped first. This mostly bit test suites, which
43
+ reconfigure repeatedly.
44
+
45
+ ### Added
46
+
47
+ - `ToggleFleet.stop` / `Client#stop` — cleanly terminate the background refresh
48
+ thread. Safe to call more than once.
49
+ - Refresh interval is now jittered by ±15% so a fleet of processes that booted
50
+ together does not poll the config endpoint in lockstep.
51
+
52
+ ### Notes
53
+
54
+ - No API changes. Upgrading from 0.1.0 requires no code changes.
55
+ - Flag evaluation semantics are unchanged and remain byte-identical to
56
+ server-side evaluation, including MD5 bucketing for sticky percentage rollouts.
57
+
58
+ ## [0.1.0] — 2026-06-27
59
+
60
+ Initial release: local evaluation of all five gates (boolean, actor, group,
61
+ percentage of actors, percentage of time), background refresh with conditional
62
+ 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.1"
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.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - ToggleFleet
@@ -9,16 +9,30 @@ bindir: bin
9
9
  cert_chain: []
10
10
  date: 1980-01-02 00:00:00.000000000 Z
11
11
  dependencies: []
12
- description: ToggleFleet is a cloud feature-flag service. This gem fetches your environment's
13
- flags, caches them, and evaluates all five gates (boolean, actor, group, % of actors,
14
- % of time) locally so checking a flag is a hash lookup, not a network call. Background
15
- refresh uses conditional ETag requests; evaluation is byte-identical to the server.
12
+ description: |
13
+ ToggleFleet is a hosted feature-flag service for Ruby. This gem fetches your environment's
14
+ flags once, refreshes them in the background with conditional ETag requests, and evaluates
15
+ all five gates boolean, actor, group, percentage-of-actors (sticky), and percentage-of-time
16
+ — entirely in-process. Checking a flag is a hash lookup, not a network call, so flags cost
17
+ nothing on the hot path and keep working at their last known values if ToggleFleet is
18
+ unreachable.
19
+
20
+ Zero runtime dependencies — only the Ruby standard library. Thread-safe, fork-safe under
21
+ Puma/Unicorn/Passenger, and fail-safe by design: any error returns your configured default
22
+ rather than raising into a request.
23
+
24
+ Evaluation is byte-identical to server-side evaluation, including MD5 bucketing, so a sticky
25
+ rollout targets exactly the same actors whether it is resolved locally or through the API.
26
+ Group membership is decided by predicates in your own code, so no user data leaves your process.
27
+
28
+ Requires a ToggleFleet account for an SDK key. Full documentation at https://togglefleet.com/docs.
16
29
  email:
17
30
  - support@togglefleet.com
18
31
  executables: []
19
32
  extensions: []
20
33
  extra_rdoc_files: []
21
34
  files:
35
+ - CHANGELOG.md
22
36
  - LICENSE
23
37
  - README.md
24
38
  - lib/togglefleet.rb
@@ -30,6 +44,8 @@ metadata:
30
44
  homepage_uri: https://togglefleet.com
31
45
  source_code_uri: https://github.com/takeaseatventure/togglefleet-ruby
32
46
  documentation_uri: https://togglefleet.com/docs
47
+ changelog_uri: https://github.com/takeaseatventure/togglefleet-ruby/blob/main/CHANGELOG.md
48
+ bug_tracker_uri: https://github.com/takeaseatventure/togglefleet-ruby/issues
33
49
  rubygems_mfa_required: 'true'
34
50
  rdoc_options: []
35
51
  require_paths: