flagstack 0.1.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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 45801e9bdf69b3bbd9637ed2002a4510b8d6da904cb168f0e34e54515547f00e
4
+ data.tar.gz: 126b2472e808d34b95549d06739deb4d54a93700dc9a22220487a9f9e4a87fad
5
+ SHA512:
6
+ metadata.gz: 83eeef5118a376835521710bc2fbb5da7591d792bd6e5992213a5ef01cd5a678d60aa6219eb7fdc90c31358c6c9409d427c4813316bf6f26112542d37f1e7179
7
+ data.tar.gz: 1a173b66ca308fc1b2188307e61eb4ec280f970623aa5766c30826d0b617e5e0ec569b2a8c870c804ade3831ba78c2748c2e379e7c3bff49ecf01bb09bf9d419
data/LICENSE.txt ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025 Flagstack
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,379 @@
1
+ # Flagstack Ruby Client
2
+
3
+ Ruby client for [Flagstack](https://flagstack.io) feature flag management. Drop-in replacement for Flipper Cloud.
4
+
5
+ ## Installation
6
+
7
+ Add to your Gemfile:
8
+
9
+ ```ruby
10
+ gem "flagstack"
11
+ gem "flipper"
12
+ gem "flipper-active_record" # Recommended: for persistent local storage
13
+ ```
14
+
15
+ Then run the generator:
16
+
17
+ ```bash
18
+ rails generate flagstack:install
19
+ ```
20
+
21
+ ## Quick Start
22
+
23
+ Set your API token:
24
+
25
+ ```bash
26
+ export FLAGSTACK_TOKEN=fs_live_your_token_here
27
+ ```
28
+
29
+ That's it! Flagstack auto-configures when `FLAGSTACK_TOKEN` is present. Your existing Flipper code works unchanged:
30
+
31
+ ```ruby
32
+ Flipper.enabled?(:new_checkout)
33
+ Flipper.enabled?(:beta_feature, current_user)
34
+ ```
35
+
36
+ ## How It Works
37
+
38
+ Flagstack mirrors Flipper Cloud's architecture:
39
+
40
+ ```
41
+ +-----------------+
42
+ | Flagstack |
43
+ | (cloud server) |
44
+ +--------+--------+
45
+ |
46
+ | sync (every 10-30s)
47
+ v
48
+ +--------+--------+
49
+ | Local Adapter |
50
+ | (ActiveRecord |
51
+ | or Memory) |
52
+ +--------+--------+
53
+ |
54
+ | all reads
55
+ v
56
+ +--------+--------+
57
+ | Your App |
58
+ | Flipper.enabled?|
59
+ +-----------------+
60
+ ```
61
+
62
+ 1. **Flagstack is the source of truth** - Manage flags in the Flagstack UI
63
+ 2. **Data syncs to your local adapter** - Background poller keeps local data fresh
64
+ 3. **All reads are local** - Zero network latency for `enabled?` checks
65
+ 4. **Works offline** - If Flagstack is down, reads continue from local adapter
66
+
67
+ ## Configuration
68
+
69
+ ### Basic Configuration
70
+
71
+ ```ruby
72
+ # config/initializers/flagstack.rb
73
+ Flagstack.configure do |config|
74
+ config.token = ENV["FLAGSTACK_TOKEN"]
75
+ config.sync_interval = 10 # seconds (minimum 10)
76
+ end
77
+ ```
78
+
79
+ ### Full Options
80
+
81
+ ```ruby
82
+ Flagstack.configure do |config|
83
+ # Required
84
+ config.token = ENV["FLAGSTACK_TOKEN"]
85
+
86
+ # Server (default: https://flagstack.io)
87
+ config.url = ENV["FLAGSTACK_URL"]
88
+
89
+ # Sync interval in seconds (default: 10, minimum: 10)
90
+ config.sync_interval = 30
91
+
92
+ # Sync method: :poll (background thread) or :manual
93
+ config.sync_method = :poll
94
+
95
+ # Telemetry (usage metrics)
96
+ config.telemetry_enabled = true # default: true
97
+ config.telemetry_interval = 60 # seconds between submissions
98
+
99
+ # HTTP timeouts in seconds
100
+ config.read_timeout = 5
101
+ config.open_timeout = 2
102
+ config.write_timeout = 5
103
+
104
+ # Local adapter (auto-detected if flipper-active_record is present)
105
+ # Falls back to Memory adapter if not specified
106
+ config.local_adapter = Flipper::Adapters::ActiveRecord.new
107
+
108
+ # Logging
109
+ config.logger = Rails.logger
110
+
111
+ # Debug HTTP requests
112
+ config.debug_output = $stderr
113
+
114
+ # Instrumentation (for monitoring)
115
+ config.instrumenter = ActiveSupport::Notifications
116
+ end
117
+ ```
118
+
119
+ ## Telemetry
120
+
121
+ Flagstack collects anonymous usage metrics to help you understand feature flag usage patterns. This data powers the Analytics dashboard in Flagstack.
122
+
123
+ ### What's Collected
124
+
125
+ - Feature key (which flag was checked)
126
+ - Result (enabled/disabled)
127
+ - Timestamp (rounded to the minute)
128
+ - Count (aggregated locally before submission)
129
+
130
+ ### Disabling Telemetry
131
+
132
+ ```ruby
133
+ Flagstack.configure do |config|
134
+ config.token = ENV["FLAGSTACK_TOKEN"]
135
+ config.telemetry_enabled = false
136
+ end
137
+ ```
138
+
139
+ ## Usage
140
+
141
+ ### With Rails (Recommended)
142
+
143
+ When `FLAGSTACK_TOKEN` is set, Flagstack automatically configures itself. You can use either the Flagstack API or Flipper directly:
144
+
145
+ ```ruby
146
+ # Native Flagstack API (recommended for new projects)
147
+ Flagstack.enabled?(:new_checkout)
148
+ Flagstack.enabled?(:beta_feature, current_user)
149
+
150
+ # Flipper-compatible - existing code works unchanged
151
+ Flipper.enabled?(:new_checkout)
152
+ Flipper.enabled?(:beta_feature, current_user)
153
+
154
+ # Feature objects work too
155
+ Flagstack[:new_checkout].enabled?
156
+ Flipper[:new_checkout].enabled?
157
+ ```
158
+
159
+ ### Manual Configuration
160
+
161
+ ```ruby
162
+ # config/initializers/flagstack.rb
163
+ flipper = Flagstack.configure do |config|
164
+ config.token = ENV["FLAGSTACK_TOKEN"]
165
+ end
166
+
167
+ # Use the returned Flipper instance directly
168
+ flipper.enabled?(:new_checkout)
169
+
170
+ # Or access it later
171
+ Flagstack.flipper.enabled?(:new_checkout)
172
+ ```
173
+
174
+ ### Without Global State
175
+
176
+ For multi-tenant apps or testing:
177
+
178
+ ```ruby
179
+ # Create separate instances (doesn't affect Flagstack.configuration)
180
+ flipper = Flagstack.new(token: "fs_live_xxx")
181
+ flipper.enabled?(:feature)
182
+ flipper.enabled?(:feature, current_user)
183
+ ```
184
+
185
+ ## Actor Setup
186
+
187
+ For percentage rollouts and actor targeting, your user model needs a `flipper_id`:
188
+
189
+ ```ruby
190
+ class User < ApplicationRecord
191
+ # Flipper expects this method for actor-based features
192
+ def flipper_id
193
+ "User_#{id}"
194
+ end
195
+ end
196
+
197
+ # Then use it
198
+ Flipper.enabled?(:beta_feature, current_user)
199
+ ```
200
+
201
+ ## Local Features
202
+
203
+ You can still create local-only features that aren't synced from Flagstack:
204
+
205
+ ```ruby
206
+ # Enable a local feature
207
+ Flipper.enable(:local_only_feature)
208
+
209
+ # It works alongside Flagstack features
210
+ Flipper.enabled?(:local_only_feature) # true
211
+ Flipper.enabled?(:flagstack_feature) # from Flagstack
212
+ ```
213
+
214
+ Note: Flagstack sync only affects features that exist in Flagstack. Your local features are preserved.
215
+
216
+ ## Token Types
217
+
218
+ | Prefix | Environment | Use |
219
+ |--------|-------------|-----|
220
+ | `fs_live_` | Production | Live traffic |
221
+ | `fs_test_` | Staging | Pre-production testing |
222
+ | `fs_dev_` | Development | Shared development |
223
+ | `fs_personal_` | Personal | Your local machine |
224
+
225
+ ## API Reference
226
+
227
+ ### Configuration
228
+
229
+ #### `Flagstack.configure { |config| }`
230
+
231
+ Configure and return a Flipper instance. Sets `Flagstack.configuration` and `Flagstack.flipper`.
232
+
233
+ #### `Flagstack.new(options)`
234
+
235
+ Create a standalone Flipper instance without affecting global state.
236
+
237
+ #### `Flagstack.flipper`
238
+
239
+ Returns the configured Flipper instance (after `configure`).
240
+
241
+ ### Feature Flag Checks
242
+
243
+ #### `Flagstack.enabled?(feature, actor = nil)`
244
+
245
+ Check if a feature is enabled, optionally for a specific actor.
246
+
247
+ ```ruby
248
+ Flagstack.enabled?(:new_checkout)
249
+ Flagstack.enabled?(:beta_feature, current_user)
250
+ ```
251
+
252
+ #### `Flagstack.disabled?(feature, actor = nil)`
253
+
254
+ Check if a feature is disabled.
255
+
256
+ ```ruby
257
+ Flagstack.disabled?(:maintenance_mode)
258
+ ```
259
+
260
+ #### `Flagstack[feature]`
261
+
262
+ Access a feature object for method chaining.
263
+
264
+ ```ruby
265
+ Flagstack[:new_checkout].enabled?
266
+ Flagstack[:new_checkout].enabled?(current_user)
267
+ ```
268
+
269
+ ### Feature Flag Management
270
+
271
+ #### `Flagstack.enable(feature)` / `Flagstack.disable(feature)`
272
+
273
+ Enable or disable a feature globally.
274
+
275
+ ```ruby
276
+ Flagstack.enable(:new_feature)
277
+ Flagstack.disable(:old_feature)
278
+ ```
279
+
280
+ #### `Flagstack.enable_actor(feature, actor)` / `Flagstack.disable_actor(feature, actor)`
281
+
282
+ Enable or disable a feature for a specific actor.
283
+
284
+ ```ruby
285
+ Flagstack.enable_actor(:beta_feature, current_user)
286
+ ```
287
+
288
+ #### `Flagstack.enable_group(feature, group)` / `Flagstack.disable_group(feature, group)`
289
+
290
+ Enable or disable a feature for a registered group.
291
+
292
+ ```ruby
293
+ Flagstack.register(:admins) { |actor| actor.admin? }
294
+ Flagstack.enable_group(:admin_tools, :admins)
295
+ ```
296
+
297
+ #### `Flagstack.enable_percentage_of_actors(feature, percentage)`
298
+
299
+ Enable a feature for a percentage of actors (deterministic based on actor ID).
300
+
301
+ ```ruby
302
+ Flagstack.enable_percentage_of_actors(:new_feature, 25) # 25% of users
303
+ ```
304
+
305
+ #### `Flagstack.features`
306
+
307
+ List all features.
308
+
309
+ ```ruby
310
+ Flagstack.features.each { |f| puts f.key }
311
+ ```
312
+
313
+ ### Utilities
314
+
315
+ #### `Flagstack.sync`
316
+
317
+ Force a sync from Flagstack to the local adapter.
318
+
319
+ #### `Flagstack.health_check`
320
+
321
+ Check connectivity to Flagstack server. Returns `{ ok: true/false, message: "..." }`.
322
+
323
+ ```ruby
324
+ result = Flagstack.health_check
325
+ if result[:ok]
326
+ puts "Connected: #{result[:message]}"
327
+ else
328
+ puts "Error: #{result[:message]}"
329
+ end
330
+ ```
331
+
332
+ #### `Flagstack.shutdown`
333
+
334
+ Gracefully stop the poller and flush any pending telemetry. Called automatically on Rails shutdown.
335
+
336
+ #### `Flagstack.reset!`
337
+
338
+ Reset everything (clears configuration, stops poller). Useful for testing.
339
+
340
+ ## Testing
341
+
342
+ In your test setup:
343
+
344
+ ```ruby
345
+ RSpec.configure do |config|
346
+ config.before(:each) do
347
+ Flagstack.reset!
348
+ end
349
+ end
350
+ ```
351
+
352
+ For isolated tests, use `Flagstack.new` with a test token or stub the HTTP calls:
353
+
354
+ ```ruby
355
+ # With WebMock
356
+ stub_request(:get, "https://flagstack.io/api/v1/sync")
357
+ .to_return(
358
+ status: 200,
359
+ body: { features: [{ key: "test_feature", enabled: true, gates: {} }] }.to_json
360
+ )
361
+
362
+ stub_request(:post, "https://flagstack.io/api/v1/telemetry")
363
+ .to_return(status: 202)
364
+
365
+ flipper = Flagstack.new(token: "fs_test_xxx")
366
+ expect(flipper.enabled?(:test_feature)).to be true
367
+ ```
368
+
369
+ ## Migrating from Flipper Cloud
370
+
371
+ Flagstack is designed as a drop-in replacement:
372
+
373
+ 1. Replace `FLIPPER_CLOUD_TOKEN` with `FLAGSTACK_TOKEN`
374
+ 2. Replace `gem "flipper-cloud"` with `gem "flagstack"`
375
+ 3. Your `Flipper.enabled?` calls work unchanged
376
+
377
+ ## License
378
+
379
+ MIT
@@ -0,0 +1,127 @@
1
+ require "net/http"
2
+ require "json"
3
+
4
+ module Flagstack
5
+ class Client
6
+ def initialize(config)
7
+ @config = config
8
+ @etag = nil
9
+ end
10
+
11
+ def sync
12
+ uri = URI("#{@config.url}/api/v1/sync")
13
+
14
+ response = request(:get, uri) do |req|
15
+ req["If-None-Match"] = @etag if @etag
16
+ end
17
+
18
+ case response.code.to_i
19
+ when 200
20
+ @etag = response["ETag"]
21
+ @config.log("Sync successful (ETag: #{@etag})", level: :debug)
22
+ JSON.parse(response.body)
23
+ when 304
24
+ @config.log("Sync not modified (304)", level: :debug)
25
+ nil # Cache is current
26
+ when 401
27
+ raise APIError, "Invalid API token"
28
+ when 429
29
+ @config.log("Rate limited, backing off", level: :warn)
30
+ nil
31
+ else
32
+ raise APIError, "API error: #{response.code} #{response.body}"
33
+ end
34
+ rescue Timeout::Error, Net::OpenTimeout, Net::ReadTimeout, Errno::ECONNREFUSED => e
35
+ @config.log("Connection failed: #{e.message}", level: :error)
36
+ nil
37
+ end
38
+
39
+ def check(feature_key, actor_id: nil)
40
+ uri = URI("#{@config.url}/api/v1/features/#{feature_key}/check")
41
+ uri.query = URI.encode_www_form(actor_id: actor_id) if actor_id
42
+
43
+ response = request(:get, uri)
44
+ data = JSON.parse(response.body)
45
+ data["enabled"]
46
+ end
47
+
48
+ # Check if the Flagstack server is reachable and the token is valid
49
+ # Returns a hash with :ok (boolean) and :message (string)
50
+ def health_check
51
+ uri = URI("#{@config.url}/api/v1/sync")
52
+
53
+ response = request(:get, uri)
54
+
55
+ case response.code.to_i
56
+ when 200, 304
57
+ { ok: true, message: "Connected to Flagstack" }
58
+ when 401
59
+ { ok: false, message: "Invalid API token" }
60
+ when 429
61
+ { ok: true, message: "Connected (rate limited)" }
62
+ else
63
+ { ok: false, message: "Unexpected response: #{response.code}" }
64
+ end
65
+ rescue Timeout::Error, Net::OpenTimeout, Net::ReadTimeout => e
66
+ { ok: false, message: "Connection timeout: #{e.message}" }
67
+ rescue Errno::ECONNREFUSED => e
68
+ { ok: false, message: "Connection refused: #{e.message}" }
69
+ rescue => e
70
+ { ok: false, message: "Connection failed: #{e.message}" }
71
+ end
72
+
73
+ def post_telemetry(compressed_body)
74
+ uri = URI("#{@config.url}/api/v1/telemetry")
75
+
76
+ response = request(:post, uri) do |req|
77
+ req["Content-Type"] = "application/json"
78
+ req["Content-Encoding"] = "gzip"
79
+ req.body = compressed_body
80
+ end
81
+
82
+ case response.code.to_i
83
+ when 200..299
84
+ @config.log("Telemetry submitted successfully", level: :debug)
85
+ response
86
+ when 429
87
+ @config.log("Telemetry rate limited", level: :warn)
88
+ nil
89
+ else
90
+ @config.log("Telemetry failed: #{response.code}", level: :error)
91
+ nil
92
+ end
93
+ rescue Timeout::Error, Net::OpenTimeout, Net::ReadTimeout, Errno::ECONNREFUSED => e
94
+ @config.log("Telemetry connection failed: #{e.message}", level: :error)
95
+ nil
96
+ end
97
+
98
+ private
99
+
100
+ def request(method, uri)
101
+ http = Net::HTTP.new(uri.host, uri.port)
102
+ http.use_ssl = uri.scheme == "https"
103
+ http.read_timeout = @config.read_timeout
104
+ http.open_timeout = @config.open_timeout
105
+ http.write_timeout = @config.write_timeout if http.respond_to?(:write_timeout=)
106
+
107
+ # Debug output
108
+ http.set_debug_output(@config.debug_output) if @config.debug_output
109
+
110
+ request = case method
111
+ when :get then Net::HTTP::Get.new(uri)
112
+ when :post then Net::HTTP::Post.new(uri)
113
+ else raise ArgumentError, "Unknown method: #{method}"
114
+ end
115
+
116
+ request["Authorization"] = "Bearer #{@config.token}"
117
+ request["User-Agent"] = "flagstack-ruby/#{VERSION}"
118
+ request["Accept"] = "application/json"
119
+
120
+ yield request if block_given?
121
+
122
+ @config.instrument("http_request", method: method, uri: uri.to_s) do
123
+ http.request(request)
124
+ end
125
+ end
126
+ end
127
+ end
@@ -0,0 +1,112 @@
1
+ require "logger"
2
+
3
+ module Flagstack
4
+ class Configuration
5
+ # Authentication
6
+ attr_accessor :token
7
+
8
+ # Server
9
+ attr_accessor :url
10
+
11
+ # HTTP settings
12
+ attr_accessor :read_timeout, :open_timeout, :write_timeout
13
+
14
+ # Sync settings
15
+ attr_accessor :sync_interval, :sync_method
16
+
17
+ # Local adapter for fallback/caching
18
+ attr_accessor :local_adapter
19
+
20
+ # Telemetry settings
21
+ attr_accessor :telemetry_enabled, :telemetry_interval
22
+
23
+ # Logging
24
+ attr_accessor :logger, :debug_output
25
+
26
+ # Instrumentation (for monitoring systems)
27
+ attr_accessor :instrumenter
28
+
29
+ def initialize(options = {})
30
+ setup_auth(options)
31
+ setup_http(options)
32
+ setup_sync(options)
33
+ setup_telemetry(options)
34
+ setup_logging(options)
35
+ setup_instrumentation(options)
36
+ end
37
+
38
+ def validate!
39
+ raise ConfigurationError, "Token is required. Set FLAGSTACK_TOKEN or pass token option." if token.nil? || token.empty?
40
+ end
41
+
42
+ def log(message, level: :debug)
43
+ logger&.send(level, "[Flagstack] #{message}")
44
+ end
45
+
46
+ def instrument(name, payload = {}, &block)
47
+ if instrumenter
48
+ instrumenter.instrument("flagstack.#{name}", payload, &block)
49
+ elsif block
50
+ yield payload
51
+ end
52
+ end
53
+
54
+ def environment_from_token
55
+ return :unknown unless token
56
+
57
+ prefix = token.split("_")[1]
58
+ case prefix
59
+ when "live" then :production
60
+ when "test" then :staging
61
+ when "dev" then :development
62
+ when "personal" then :personal
63
+ else :unknown
64
+ end
65
+ end
66
+
67
+ private
68
+
69
+ def setup_auth(options)
70
+ @token = options.fetch(:token) { ENV["FLAGSTACK_TOKEN"] }
71
+ @url = options.fetch(:url) { ENV.fetch("FLAGSTACK_URL", "https://flagstack.io") }
72
+ end
73
+
74
+ def setup_http(options)
75
+ @read_timeout = options.fetch(:read_timeout, 5)
76
+ @open_timeout = options.fetch(:open_timeout, 2)
77
+ @write_timeout = options.fetch(:write_timeout, 5)
78
+ end
79
+
80
+ def setup_sync(options)
81
+ interval = options.fetch(:sync_interval) { ENV.fetch("FLAGSTACK_SYNC_INTERVAL", 10).to_i }
82
+ @sync_interval = [interval, 10].max # Minimum 10 seconds
83
+
84
+ @sync_method = options.fetch(:sync_method, :poll)
85
+ @local_adapter = options[:local_adapter]
86
+ end
87
+
88
+ def setup_telemetry(options)
89
+ @telemetry_enabled = options.fetch(:telemetry_enabled) {
90
+ ENV.fetch("FLAGSTACK_TELEMETRY_ENABLED", "true") == "true"
91
+ }
92
+ @telemetry_interval = options.fetch(:telemetry_interval) {
93
+ ENV.fetch("FLAGSTACK_TELEMETRY_INTERVAL", 60).to_i
94
+ }
95
+ end
96
+
97
+ def setup_logging(options)
98
+ @debug_output = options[:debug_output]
99
+ @logger = options.fetch(:logger) do
100
+ if defined?(Rails) && Rails.respond_to?(:logger)
101
+ Rails.logger
102
+ else
103
+ Logger.new($stdout, level: Logger::INFO)
104
+ end
105
+ end
106
+ end
107
+
108
+ def setup_instrumentation(options)
109
+ @instrumenter = options[:instrumenter]
110
+ end
111
+ end
112
+ end
@@ -0,0 +1,66 @@
1
+ module Flagstack
2
+ class Poller
3
+ def initialize(synchronizer, config)
4
+ @synchronizer = synchronizer
5
+ @config = config
6
+ @running = false
7
+ @thread = nil
8
+ @pid = Process.pid
9
+ end
10
+
11
+ def start
12
+ return if @running
13
+
14
+ @running = true
15
+ @thread = Thread.new { run }
16
+ @thread.abort_on_exception = false
17
+ end
18
+
19
+ def stop
20
+ @running = false
21
+ @thread&.wakeup rescue nil
22
+ @thread&.join(2)
23
+ @thread = nil
24
+ end
25
+
26
+ def running?
27
+ @running && @thread&.alive?
28
+ end
29
+
30
+ private
31
+
32
+ def run
33
+ while @running
34
+ # Check for fork (important for Puma, Unicorn, etc.)
35
+ if forked?
36
+ reset_after_fork
37
+ next
38
+ end
39
+
40
+ sleep_with_jitter
41
+
42
+ begin
43
+ @synchronizer.sync
44
+ rescue => e
45
+ @config.log("Poll sync failed: #{e.message}", level: :error)
46
+ end
47
+ end
48
+ end
49
+
50
+ def sleep_with_jitter
51
+ # Add 10% jitter to prevent thundering herd
52
+ jitter = @config.sync_interval * 0.1 * rand
53
+ sleep(@config.sync_interval + jitter)
54
+ end
55
+
56
+ def forked?
57
+ Process.pid != @pid
58
+ end
59
+
60
+ def reset_after_fork
61
+ @config.log("Fork detected, resetting poller", level: :debug)
62
+ @pid = Process.pid
63
+ @running = false
64
+ end
65
+ end
66
+ end