From e33e8cd0c6dfd7a5a883f813c089ccda41c7777b Mon Sep 17 00:00:00 2001 From: kwasniew Date: Thu, 28 Aug 2025 14:31:10 +0200 Subject: [PATCH 1/5] feat: read backup file and bootstrap for streaming --- lib/unleash/backup_file_reader.rb | 30 ++++++++++++++++++++++++ lib/unleash/streaming_client_executor.rb | 29 +++++++++++++++++++++++ lib/unleash/toggle_fetcher.rb | 25 ++++---------------- 3 files changed, 64 insertions(+), 20 deletions(-) create mode 100644 lib/unleash/backup_file_reader.rb diff --git a/lib/unleash/backup_file_reader.rb b/lib/unleash/backup_file_reader.rb new file mode 100644 index 0000000..ca697c3 --- /dev/null +++ b/lib/unleash/backup_file_reader.rb @@ -0,0 +1,30 @@ +require 'unleash/configuration' +require 'json' + +module Unleash + class BackupFileReader + def self.read! + Unleash.logger.debug "read!()" + + backup_file = Unleash.configuration.backup_file + return nil unless File.exist?(backup_file) + + File.read(backup_file) + rescue IOError => e + # :nocov: + Unleash.logger.error "Unable to read the backup_file: #{e}" + # :nocov: + nil + rescue JSON::ParserError => e + # :nocov: + Unleash.logger.error "Unable to parse JSON from existing backup_file: #{e}" + # :nocov: + nil + rescue StandardError => e + # :nocov: + Unleash.logger.error "Unable to extract valid data from backup_file. Exception thrown: #{e}" + # :nocov: + nil + end + end +end diff --git a/lib/unleash/streaming_client_executor.rb b/lib/unleash/streaming_client_executor.rb index a0c4085..a6f87d2 100644 --- a/lib/unleash/streaming_client_executor.rb +++ b/lib/unleash/streaming_client_executor.rb @@ -1,4 +1,6 @@ require 'unleash/streaming_event_processor' +require 'unleash/bootstrap/handler' +require 'unleash/backup_file_reader' require 'unleash/util/event_source_wrapper' module Unleash @@ -10,6 +12,20 @@ def initialize(name, engine) self.event_source = nil self.event_processor = Unleash::StreamingEventProcessor.new(engine) self.running = false + + begin + # if bootstrap configuration is available, initialize. Otherwise read backup file + if Unleash.configuration.use_bootstrap? + bootstrap(engine) + else + read_backup_file!(engine) + end + rescue StandardError => e + # fail back to reading the backup file + Unleash.logger.warn "StreamingClientExecutor was unable to initialize, attempting to read from backup file." + Unleash.logger.debug "Exception Caught: #{e}" + read_backup_file!(engine) + end end def run(&_block) @@ -81,5 +97,18 @@ def handle_event(event) Unleash.logger.error "Streaming client #{self.name} threw exception #{e.class}: '#{e}'" Unleash.logger.debug "stacktrace: #{e.backtrace}" end + + def read_backup_file!(engine) + backup_data = Unleash::BackupFileReader.read! + engine.take_state(backup_data) if backup_data + end + + def bootstrap(engine) + bootstrap_payload = Unleash::Bootstrap::Handler.new(Unleash.configuration.bootstrap_config).retrieve_toggles + engine.take_state(bootstrap_payload) + + # reset Unleash.configuration.bootstrap_data to free up memory, as we will never use it again + Unleash.configuration.bootstrap_config = nil + end end end diff --git a/lib/unleash/toggle_fetcher.rb b/lib/unleash/toggle_fetcher.rb index ebcb3be..e7a56c1 100755 --- a/lib/unleash/toggle_fetcher.rb +++ b/lib/unleash/toggle_fetcher.rb @@ -1,6 +1,7 @@ require 'unleash/configuration' require 'unleash/bootstrap/handler' require 'unleash/backup_file_writer' +require 'unleash/backup_file_reader' require 'net/http' require 'json' require 'yggdrasil_engine' @@ -27,7 +28,7 @@ def initialize(engine) # fail back to reading the backup file Unleash.logger.warn "ToggleFetcher was unable to fetch from the network, attempting to read from backup file." Unleash.logger.debug "Exception Caught: #{e}" - read! + read_backup_file! end # once initialized, somewhere else you will want to start a loop with fetch() @@ -71,25 +72,9 @@ def update_engine_state!(toggle_data) Unleash.logger.error "Failed to hydrate state: #{e.backtrace}" end - def read! - Unleash.logger.debug "read!()" - backup_file = Unleash.configuration.backup_file - return nil unless File.exist?(backup_file) - - backup_data = File.read(backup_file) - update_engine_state!(backup_data) - rescue IOError => e - # :nocov: - Unleash.logger.error "Unable to read the backup_file: #{e}" - # :nocov: - rescue JSON::ParserError => e - # :nocov: - Unleash.logger.error "Unable to parse JSON from existing backup_file: #{e}" - # :nocov: - rescue StandardError => e - # :nocov: - Unleash.logger.error "Unable to extract valid data from backup_file. Exception thrown: #{e}" - # :nocov: + def read_backup_file! + backup_data = Unleash::BackupFileReader.read! + update_engine_state!(backup_data) if backup_data end def bootstrap From 29592975fcb41a8724ed75639770cc03f500a82d Mon Sep 17 00:00:00 2001 From: kwasniew Date: Thu, 28 Aug 2025 16:11:45 +0200 Subject: [PATCH 2/5] test: reading backup and bootstrap --- .../unleash/streaming_client_executor_spec.rb | 167 ++++++++++++++++++ 1 file changed, 167 insertions(+) create mode 100644 spec/unleash/streaming_client_executor_spec.rb diff --git a/spec/unleash/streaming_client_executor_spec.rb b/spec/unleash/streaming_client_executor_spec.rb new file mode 100644 index 0000000..1071b61 --- /dev/null +++ b/spec/unleash/streaming_client_executor_spec.rb @@ -0,0 +1,167 @@ +RSpec.describe Unleash::StreamingClientExecutor do + unless RUBY_ENGINE == 'jruby' + before do + Unleash.configure do |config| + config.url = 'http://streaming-test-url/' + config.app_name = 'streaming-test-app' + config.instance_id = 'rspec/streaming' + config.disable_metrics = true + config.experimental_mode = { type: 'streaming' } + end + + WebMock.stub_request(:post, "http://streaming-test-url/client/register") + .to_return(status: 200, body: "", headers: {}) + + Unleash.logger = Unleash.configuration.logger + end + + after do + WebMock.reset! + File.delete(Unleash.configuration.backup_file) if File.exist?(Unleash.configuration.backup_file) + + # Reset configuration to prevent interference with other tests + Unleash.configuration.bootstrap_config = nil + Unleash.configuration.experimental_mode = nil + Unleash.configuration.disable_metrics = false + end + + describe '.new' do + let(:engine) { YggdrasilEngine.new } + let(:executor_name) { 'streaming_client_executor_spec' } + + context 'when there are problems connecting to streaming endpoint' do + let(:backup_toggles) do + { + version: 1, + features: [ + { + name: "backup-feature", + description: "Feature from backup", + enabled: true, + strategies: [{ + "name": "default" + }] + } + ] + } + end + + let(:streaming_executor) { described_class.new(executor_name, engine) } + + before do + backup_file = Unleash.configuration.backup_file + + # manually create a stub cache on disk, so we can test that we read it correctly later. + File.open(backup_file, "w") do |file| + file.write(backup_toggles.to_json) + end + + # Simulate streaming connection failure + WebMock.stub_request(:get, "http://streaming-test-url/client/streaming") + .to_return(status: 500, body: "Internal Server Error", headers: {}) + + streaming_executor + end + + it 'reads the backup file for values' do + enabled = engine.enabled?('backup-feature', {}) + expect(enabled).to eq(true) + end + end + + context 'when bootstrap is configured' do + let(:bootstrap_data) do + { + version: 1, + features: [ + { + name: "bootstrap-feature", + enabled: true, + strategies: [{ name: "default" }] + } + ] + } + end + + let(:bootstrap_config) do + Unleash::Bootstrap::Configuration.new({ + 'data' => bootstrap_data.to_json + }) + end + + let(:streaming_executor) { described_class.new(executor_name, engine) } + + before do + Unleash.configuration.bootstrap_config = bootstrap_config + + # Streaming connection might succeed or fail, doesn't matter for bootstrap + WebMock.stub_request(:get, "http://streaming-test-url/client/streaming") + .to_return(status: 200, body: "", headers: {}) + + streaming_executor + end + + after do + Unleash.configuration.bootstrap_config = nil + end + + it 'uses bootstrap data on initialization' do + enabled = engine.enabled?('bootstrap-feature', {}) + expect(enabled).to eq(true) + end + + it 'clears bootstrap config after use' do + expect(Unleash.configuration.bootstrap_config).to be_nil + end + end + + context 'when bootstrap fails and backup file exists' do + let(:invalid_bootstrap_config) do + Unleash::Bootstrap::Configuration.new({ + 'data' => 'invalid json' + }) + end + + let(:fallback_toggles) do + { + version: 1, + features: [ + { + name: "fallback-feature", + enabled: true, + strategies: [{ name: "default" }] + } + ] + } + end + + let(:streaming_executor) { described_class.new(executor_name, engine) } + + before do + backup_file = Unleash.configuration.backup_file + + File.open(backup_file, "w") do |file| + file.write(fallback_toggles.to_json) + end + + Unleash.configuration.bootstrap_config = invalid_bootstrap_config + + # Streaming connection failure doesn't matter here + WebMock.stub_request(:get, "http://streaming-test-url/client/streaming") + .to_return(status: 500, body: "", headers: {}) + + streaming_executor + end + + after do + Unleash.configuration.bootstrap_config = nil + end + + it 'falls back to reading backup file when bootstrap fails' do + enabled = engine.enabled?('fallback-feature', {}) + expect(enabled).to eq(true) + end + end + end + end +end \ No newline at end of file From 06908711bd18146f3f58716517d9220a9627c9e2 Mon Sep 17 00:00:00 2001 From: kwasniew Date: Thu, 28 Aug 2025 16:16:00 +0200 Subject: [PATCH 3/5] test: reading backup and bootstrap --- spec/unleash/streaming_client_executor_spec.rb | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/spec/unleash/streaming_client_executor_spec.rb b/spec/unleash/streaming_client_executor_spec.rb index 1071b61..dcd72e4 100644 --- a/spec/unleash/streaming_client_executor_spec.rb +++ b/spec/unleash/streaming_client_executor_spec.rb @@ -18,7 +18,7 @@ after do WebMock.reset! File.delete(Unleash.configuration.backup_file) if File.exist?(Unleash.configuration.backup_file) - + # Reset configuration to prevent interference with other tests Unleash.configuration.bootstrap_config = nil Unleash.configuration.experimental_mode = nil @@ -164,4 +164,4 @@ end end end -end \ No newline at end of file +end From 425c1c47f0618ca8fdc71058cbd60b77fbbaf6e8 Mon Sep 17 00:00:00 2001 From: kwasniew Date: Thu, 28 Aug 2025 16:21:55 +0200 Subject: [PATCH 4/5] test: reading backup and bootstrap --- spec/unleash/streaming_client_executor_spec.rb | 13 +++---------- 1 file changed, 3 insertions(+), 10 deletions(-) diff --git a/spec/unleash/streaming_client_executor_spec.rb b/spec/unleash/streaming_client_executor_spec.rb index dcd72e4..ad772c5 100644 --- a/spec/unleash/streaming_client_executor_spec.rb +++ b/spec/unleash/streaming_client_executor_spec.rb @@ -1,4 +1,6 @@ RSpec.describe Unleash::StreamingClientExecutor do + subject(:streaming_executor) { Unleash::StreamingClientExecutor.new(executor_name, engine) } + unless RUBY_ENGINE == 'jruby' before do Unleash.configure do |config| @@ -39,15 +41,13 @@ description: "Feature from backup", enabled: true, strategies: [{ - "name": "default" + name: "default" }] } ] } end - let(:streaming_executor) { described_class.new(executor_name, engine) } - before do backup_file = Unleash.configuration.backup_file @@ -56,7 +56,6 @@ file.write(backup_toggles.to_json) end - # Simulate streaming connection failure WebMock.stub_request(:get, "http://streaming-test-url/client/streaming") .to_return(status: 500, body: "Internal Server Error", headers: {}) @@ -89,12 +88,9 @@ }) end - let(:streaming_executor) { described_class.new(executor_name, engine) } - before do Unleash.configuration.bootstrap_config = bootstrap_config - # Streaming connection might succeed or fail, doesn't matter for bootstrap WebMock.stub_request(:get, "http://streaming-test-url/client/streaming") .to_return(status: 200, body: "", headers: {}) @@ -135,8 +131,6 @@ } end - let(:streaming_executor) { described_class.new(executor_name, engine) } - before do backup_file = Unleash.configuration.backup_file @@ -146,7 +140,6 @@ Unleash.configuration.bootstrap_config = invalid_bootstrap_config - # Streaming connection failure doesn't matter here WebMock.stub_request(:get, "http://streaming-test-url/client/streaming") .to_return(status: 500, body: "", headers: {}) From 3ab22d685b3eb5c63518a3de8cb4fd91721c38ae Mon Sep 17 00:00:00 2001 From: kwasniew Date: Wed, 3 Sep 2025 16:04:40 +0200 Subject: [PATCH 5/5] chore: apply PR feedback --- lib/unleash/backup_file_reader.rb | 6 ------ lib/unleash/streaming_client_executor.rb | 2 +- lib/unleash/toggle_fetcher.rb | 2 +- 3 files changed, 2 insertions(+), 8 deletions(-) diff --git a/lib/unleash/backup_file_reader.rb b/lib/unleash/backup_file_reader.rb index ca697c3..f2a3831 100644 --- a/lib/unleash/backup_file_reader.rb +++ b/lib/unleash/backup_file_reader.rb @@ -1,5 +1,4 @@ require 'unleash/configuration' -require 'json' module Unleash class BackupFileReader @@ -15,11 +14,6 @@ def self.read! Unleash.logger.error "Unable to read the backup_file: #{e}" # :nocov: nil - rescue JSON::ParserError => e - # :nocov: - Unleash.logger.error "Unable to parse JSON from existing backup_file: #{e}" - # :nocov: - nil rescue StandardError => e # :nocov: Unleash.logger.error "Unable to extract valid data from backup_file. Exception thrown: #{e}" diff --git a/lib/unleash/streaming_client_executor.rb b/lib/unleash/streaming_client_executor.rb index a6f87d2..b194e94 100644 --- a/lib/unleash/streaming_client_executor.rb +++ b/lib/unleash/streaming_client_executor.rb @@ -21,7 +21,7 @@ def initialize(name, engine) read_backup_file!(engine) end rescue StandardError => e - # fail back to reading the backup file + # fall back to reading the backup file Unleash.logger.warn "StreamingClientExecutor was unable to initialize, attempting to read from backup file." Unleash.logger.debug "Exception Caught: #{e}" read_backup_file!(engine) diff --git a/lib/unleash/toggle_fetcher.rb b/lib/unleash/toggle_fetcher.rb index e7a56c1..5d1b3c8 100755 --- a/lib/unleash/toggle_fetcher.rb +++ b/lib/unleash/toggle_fetcher.rb @@ -25,7 +25,7 @@ def initialize(engine) fetch end rescue StandardError => e - # fail back to reading the backup file + # fall back to reading the backup file Unleash.logger.warn "ToggleFetcher was unable to fetch from the network, attempting to read from backup file." Unleash.logger.debug "Exception Caught: #{e}" read_backup_file!