Skip to content

Commit b0ec079

Browse files
authored
Merge pull request #948 from flippercloud/poll-shutdown-based-on-header
Shut down polling and adjust poll interval based on headers
2 parents f32c013 + c70cb5b commit b0ec079

File tree

9 files changed

+779
-27
lines changed

9 files changed

+779
-27
lines changed
Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,111 @@
1+
# Poll Interval Dynamic Adjustment Demo
2+
3+
This demo shows how the Flipper poller dynamically adjusts its polling interval based on the `poll-interval` header from the server, and how it responds to the `poll-shutdown` header.
4+
5+
## Files
6+
7+
- `server.rb` - Test server that responds with configurable headers
8+
- `client.rb` - Client that polls the server and logs interval changes
9+
- `README.md` - This file
10+
11+
## How to Run
12+
13+
### Terminal 1: Start the Server
14+
15+
```bash
16+
bundle exec ruby examples/cloud/poll_interval/server.rb
17+
```
18+
19+
The server will start on http://localhost:3000 and show a prompt where you can control what headers to send.
20+
21+
### Terminal 2: Start the Client
22+
23+
```bash
24+
bundle exec ruby examples/cloud/poll_interval/client.rb
25+
```
26+
27+
The client will start polling the server every 10 seconds (the minimum) and log all activity.
28+
29+
## Testing Scenarios
30+
31+
### 1. Change Poll Interval
32+
33+
In the **server terminal**, type a number to set the poll interval:
34+
35+
```
36+
> 20
37+
```
38+
39+
In the **client terminal**, you'll see:
40+
41+
```
42+
[HH:MM:SS] WARN: ⚠️ INTERVAL CHANGED: 10.0s → 20.0s
43+
```
44+
45+
The client will now poll every 20 seconds instead of 10.
46+
47+
### 2. Try an Invalid Interval (Below Minimum)
48+
49+
In the **server terminal**:
50+
51+
```
52+
> 5
53+
```
54+
55+
In the **client terminal**, you'll see a warning:
56+
57+
```
58+
Flipper::Cloud poll interval must be greater than or equal to 10 but was 5.0. Setting interval to 10.
59+
```
60+
61+
The interval will remain at 10 seconds (the minimum).
62+
63+
### 3. Trigger Shutdown
64+
65+
In the **server terminal**:
66+
67+
```
68+
> shutdown
69+
```
70+
71+
In the **client terminal**, you'll see:
72+
73+
```
74+
[HH:MM:SS] WARN: Shutdown requested by server via poll-shutdown header
75+
[HH:MM:SS] WARN: Poller stopped
76+
[HH:MM:SS] WARN: Poller thread is no longer running
77+
```
78+
79+
The poller will stop gracefully.
80+
81+
### 4. Reset Headers
82+
83+
In the **server terminal**:
84+
85+
```
86+
> reset
87+
```
88+
89+
The server will stop sending special headers. The client will continue with its current interval.
90+
91+
## What You'll Learn
92+
93+
- How `poll-interval` header dynamically adjusts polling frequency
94+
- How `poll-shutdown` header gracefully stops the poller
95+
- How minimum interval enforcement works (10 seconds minimum)
96+
- How the poller continues working even if the server returns errors
97+
- Real-time logging of poller events via instrumentation
98+
99+
## Implementation Details
100+
101+
The poller checks response headers in the `ensure` block of the `sync` method, which means:
102+
103+
- Interval adjustments happen even if the sync fails with an error
104+
- Shutdown signals are never missed, even during failures
105+
- The poller is resilient to network issues
106+
107+
The `interval=` setter handles all validation:
108+
109+
- Type conversion via `Flipper::Typecast.to_float`
110+
- Minimum enforcement (10 seconds)
111+
- Warning messages for invalid values
Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,108 @@
1+
# Example showing poll interval being dynamically adjusted via poll-interval header
2+
#
3+
# Usage:
4+
# 1. Terminal 1: bundle exec ruby examples/cloud/poll_interval/server.rb
5+
# 2. Terminal 2: bundle exec ruby examples/cloud/poll_interval/client.rb
6+
7+
require 'bundler/setup'
8+
require 'flipper'
9+
require 'flipper/adapters/http'
10+
require 'flipper/poller'
11+
require 'logger'
12+
13+
# Setup logging to show what's happening
14+
logger = Logger.new(STDOUT)
15+
logger.level = Logger::INFO
16+
logger.formatter = proc do |severity, datetime, progname, msg|
17+
"[#{datetime.strftime('%H:%M:%S')}] #{severity}: #{msg}\n"
18+
end
19+
20+
# Create HTTP adapter pointing to localhost:3000
21+
http_adapter = Flipper::Adapters::Http.new(url: 'http://localhost:3000/flipper')
22+
23+
# Create instrumenter to log poller events
24+
instrumenter = Module.new do
25+
def self.instrument(name, payload = {})
26+
case payload[:operation]
27+
when :poll
28+
logger.info "Polling remote adapter..."
29+
when :shutdown_requested
30+
logger.warn "Shutdown requested by server via poll-shutdown header"
31+
when :stop
32+
logger.warn "Poller stopped"
33+
when :thread_start
34+
logger.info "Poller thread started"
35+
end
36+
37+
result = yield if block_given?
38+
39+
if payload[:operation] == :poll && result
40+
logger.info "Poll completed successfully"
41+
end
42+
43+
result
44+
end
45+
46+
def self.logger=(l)
47+
@logger = l
48+
end
49+
50+
def self.logger
51+
@logger
52+
end
53+
end
54+
instrumenter.logger = logger
55+
56+
# Create poller with custom instrumenter and short initial interval
57+
poller = Flipper::Poller.new(
58+
remote_adapter: http_adapter,
59+
interval: 5, # Start with 5 second interval (will be enforced to 10 minimum)
60+
instrumenter: instrumenter,
61+
start_automatically: false,
62+
shutdown_automatically: false
63+
)
64+
65+
logger.info "Starting poller with interval: #{poller.interval} seconds"
66+
logger.info "Minimum allowed interval: #{Flipper::Poller::MINIMUM_POLL_INTERVAL} seconds"
67+
logger.info ""
68+
logger.info "Server can control polling via response headers:"
69+
logger.info " - poll-interval: <seconds> (adjust poll frequency)"
70+
logger.info " - poll-shutdown: true (stop polling)"
71+
logger.info ""
72+
73+
# Track interval changes
74+
last_interval = poller.interval
75+
76+
# Start the poller
77+
poller.start
78+
79+
# Monitor for interval changes and log them
80+
logger.info "Monitoring poller... (Ctrl+C to exit)"
81+
logger.info ""
82+
83+
begin
84+
loop do
85+
sleep 2
86+
87+
current_interval = poller.interval
88+
89+
# Highlight when it changes
90+
if current_interval != last_interval
91+
logger.warn "⚠️ INTERVAL CHANGED: #{last_interval}s → #{current_interval}s"
92+
last_interval = current_interval
93+
end
94+
95+
# Check if poller thread is still alive
96+
unless poller.thread&.alive?
97+
logger.warn "Poller thread is no longer running"
98+
break
99+
end
100+
end
101+
rescue Interrupt
102+
logger.info ""
103+
logger.info "Interrupted by user"
104+
ensure
105+
logger.info "Stopping poller..."
106+
poller.stop
107+
logger.info "Final interval: #{poller.interval} seconds"
108+
end
Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
# Simple test server for demonstrating poll interval changes
2+
#
3+
# Usage:
4+
# 1. Terminal 1: bundle exec ruby examples/cloud/poll_interval/server.rb
5+
# 2. Terminal 2: bundle exec ruby examples/cloud/poll_interval/client.rb
6+
#
7+
# Commands in server terminal:
8+
# - Type a number (e.g., "15") to set poll-interval header to that value
9+
# - Type "shutdown" to send poll-shutdown: true header
10+
# - Type "reset" to stop sending special headers
11+
# - Ctrl+C to exit
12+
13+
require 'bundler/setup'
14+
require 'webrick'
15+
require 'json'
16+
17+
# State for what headers to send
18+
$poll_interval = nil
19+
$poll_shutdown = false
20+
21+
# Thread to handle user input for changing headers
22+
input_thread = Thread.new do
23+
puts ""
24+
puts "=" * 60
25+
puts "Server Controls:"
26+
puts " Type a number (e.g., '15') to set poll-interval"
27+
puts " Type 'shutdown' to trigger poll shutdown"
28+
puts " Type 'reset' to clear all special headers"
29+
puts "=" * 60
30+
puts ""
31+
32+
loop do
33+
print "> "
34+
input = gets&.chomp
35+
break if input.nil?
36+
37+
case input
38+
when /^\d+$/
39+
$poll_interval = input.to_i
40+
puts "✓ Will send poll-interval: #{$poll_interval}"
41+
when "shutdown"
42+
$poll_shutdown = true
43+
puts "✓ Will send poll-shutdown: true"
44+
when "reset"
45+
$poll_interval = nil
46+
$poll_shutdown = false
47+
puts "✓ Cleared all special headers"
48+
else
49+
puts "Unknown command. Use a number, 'shutdown', or 'reset'"
50+
end
51+
end
52+
end
53+
54+
# Setup WEBrick server
55+
server = WEBrick::HTTPServer.new(
56+
Port: 3000,
57+
Logger: WEBrick::Log.new($stdout, WEBrick::Log::INFO),
58+
AccessLog: [[
59+
$stdout,
60+
WEBrick::AccessLog::COMMON_LOG_FORMAT
61+
]]
62+
)
63+
64+
# Handle GET /flipper/features
65+
server.mount_proc '/flipper/features' do |req, res|
66+
# Build response
67+
response_body = {
68+
features: []
69+
}
70+
71+
res.status = 200
72+
res['Content-Type'] = 'application/json'
73+
res.body = JSON.generate(response_body)
74+
75+
# Add special headers if configured
76+
if $poll_interval
77+
res['poll-interval'] = $poll_interval.to_s
78+
puts "→ Sent poll-interval: #{$poll_interval}"
79+
end
80+
81+
if $poll_shutdown
82+
res['poll-shutdown'] = 'true'
83+
puts "→ Sent poll-shutdown: true"
84+
end
85+
end
86+
87+
# Trap interrupt and shutdown gracefully
88+
trap('INT') do
89+
puts "\nShutting down server..."
90+
server.shutdown
91+
input_thread.kill
92+
end
93+
94+
puts "Server starting on http://localhost:3000"
95+
puts "Endpoint: GET http://localhost:3000/flipper/features"
96+
puts ""
97+
98+
server.start

lib/flipper/adapters/http.rb

Lines changed: 23 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,8 @@ def initialize(options = {})
2424
debug_output: options[:debug_output])
2525
@last_get_all_etag = nil
2626
@last_get_all_result = nil
27+
@last_get_all_response = nil
28+
@get_all_mutex = Mutex.new
2729
end
2830

2931
def get(feature)
@@ -58,29 +60,30 @@ def get_multi(features)
5860
end
5961

6062
def get_all(cache_bust: false)
63+
options = {}
6164
path = "/features?exclude_gate_names=true"
6265
path += "&_cb=#{Time.now.to_i}" if cache_bust
66+
etag = @get_all_mutex.synchronize { @last_get_all_etag }
6367

64-
# Pass If-None-Match header if we have an ETag
65-
options = {}
66-
if @last_get_all_etag
67-
options[:headers] = { if_none_match: @last_get_all_etag }
68+
if etag
69+
options[:headers] = { if_none_match: etag }
6870
end
6971

7072
response = @client.get(path, options)
73+
@get_all_mutex.synchronize { @last_get_all_response = response }
7174

72-
# Handle 304 Not Modified - return cached result
7375
if response.is_a?(Net::HTTPNotModified)
74-
return @last_get_all_result if @last_get_all_result
75-
# If we somehow got 304 without a cached result, treat as error
76-
raise Error, response
76+
cached_result = @get_all_mutex.synchronize { @last_get_all_result }
77+
78+
if cached_result
79+
return cached_result
80+
else
81+
raise Error, response
82+
end
7783
end
7884

7985
raise Error, response unless response.is_a?(Net::HTTPOK)
8086

81-
# Store ETag from response for future requests
82-
@last_get_all_etag = response['etag'] if response['etag']
83-
8487
parsed_response = response.body.empty? ? {} : Typecast.from_json(response.body)
8588
parsed_features = parsed_response['features'] || []
8689
gates_by_key = parsed_features.each_with_object({}) do |parsed_feature, hash|
@@ -94,11 +97,18 @@ def get_all(cache_bust: false)
9497
result[feature.key] = result_for_feature(feature, gates_by_key[feature.key])
9598
end
9699

97-
# Cache the result for 304 responses
98-
@last_get_all_result = result
100+
@get_all_mutex.synchronize do
101+
@last_get_all_etag = response['etag'] if response['etag']
102+
@last_get_all_result = result
103+
end
104+
99105
result
100106
end
101107

108+
def last_get_all_response
109+
@get_all_mutex.synchronize { @last_get_all_response }
110+
end
111+
102112
def features
103113
response = @client.get('/features?exclude_gate_names=true')
104114
raise Error, response unless response.is_a?(Net::HTTPOK)

0 commit comments

Comments
 (0)