From 9b1b0ed209ff33c983a092460f237d03cc23cf8b Mon Sep 17 00:00:00 2001 From: Ivo Anjo Date: Wed, 28 Jun 2017 15:55:04 +0100 Subject: [PATCH 01/16] Bootstrap warm-blanket gem Used `bundler gem warm-blanket` to generate template and slightly tweaked it to our standards. --- .gitignore | 12 ++++++++++++ .ruby-version | 1 + Gemfile | 4 ++++ bin/pry | 17 +++++++++++++++++ lib/warm-blanket.rb | 1 + lib/warm_blanket/version.rb | 5 +++++ warm-blanket.gemspec | 26 ++++++++++++++++++++++++++ 7 files changed, 66 insertions(+) create mode 100644 .gitignore create mode 100644 .ruby-version create mode 100644 Gemfile create mode 100755 bin/pry create mode 100644 lib/warm-blanket.rb create mode 100644 lib/warm_blanket/version.rb create mode 100644 warm-blanket.gemspec diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..8eb3b06 --- /dev/null +++ b/.gitignore @@ -0,0 +1,12 @@ +/.bundle/ +/.yardoc +/Gemfile.lock +/_yardoc/ +/coverage/ +/doc/ +/pkg/ +/spec/reports/ +/tmp/ + +# rspec failure tracking +.rspec_status diff --git a/.ruby-version b/.ruby-version new file mode 100644 index 0000000..87d3afa --- /dev/null +++ b/.ruby-version @@ -0,0 +1 @@ +jruby-9.1.12.0 diff --git a/Gemfile b/Gemfile new file mode 100644 index 0000000..0c9fbb0 --- /dev/null +++ b/Gemfile @@ -0,0 +1,4 @@ +source 'https://rubygems.org' + +# Specify your gem's dependencies in warm-blanket.gemspec +gemspec diff --git a/bin/pry b/bin/pry new file mode 100755 index 0000000..927a638 --- /dev/null +++ b/bin/pry @@ -0,0 +1,17 @@ +#!/usr/bin/env ruby +# frozen_string_literal: true +# +# This file was generated by Bundler. +# +# The application 'pry' is installed as part of a gem, and +# this file is here to facilitate running it. +# + +require 'pathname' +ENV['BUNDLE_GEMFILE'] ||= File.expand_path('../../Gemfile', + Pathname.new(__FILE__).realpath) + +require 'rubygems' +require 'bundler/setup' + +load Gem.bin_path('pry', 'pry') diff --git a/lib/warm-blanket.rb b/lib/warm-blanket.rb new file mode 100644 index 0000000..c9f8654 --- /dev/null +++ b/lib/warm-blanket.rb @@ -0,0 +1 @@ +require 'warm_blanket/version' diff --git a/lib/warm_blanket/version.rb b/lib/warm_blanket/version.rb new file mode 100644 index 0000000..90f5fee --- /dev/null +++ b/lib/warm_blanket/version.rb @@ -0,0 +1,5 @@ +# frozen_string_literal: true + +module WarmBlanket + VERSION = '0.1.0' +end diff --git a/warm-blanket.gemspec b/warm-blanket.gemspec new file mode 100644 index 0000000..bc5b772 --- /dev/null +++ b/warm-blanket.gemspec @@ -0,0 +1,26 @@ +lib = File.expand_path('../lib', __FILE__) +$LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib) + +require 'warm_blanket/version' + +Gem::Specification.new do |spec| + spec.name = 'warm-blanket' + spec.version = WarmBlanket::VERSION + spec.authors = ['Talkdesk Engineering'] + spec.email = ['tech@talkdesk.com'] + + spec.summary = 'Ruby gem for warming up web services on boot' + spec.description = 'Ruby gem for warming up web services on boot' + spec.homepage = 'https://github.com/Talkdesk/warm-blanket' + + spec.files = `git ls-files -z`.split("\x0").reject { |f| f.match(%r{^(test|spec|features)/}) } + spec.require_paths = ['lib'] + + spec.add_development_dependency 'bundler', '~> 1.15' + spec.add_development_dependency 'rspec', '~> 3.6' + spec.add_development_dependency 'pry' + spec.add_development_dependency 'pry-byebug' unless RUBY_PLATFORM == 'java' + spec.add_development_dependency 'pry-debugger-jruby' if RUBY_PLATFORM == 'java' + + spec.add_dependency 'faraday', '~> 0.9' +end From 5c9430a9314c1c08eaa29c825a62cfb27d295eea Mon Sep 17 00:00:00 2001 From: Ivo Anjo Date: Wed, 28 Jun 2017 16:03:47 +0100 Subject: [PATCH 02/16] Bootstrap rspec testing --- .gitignore | 1 + .rspec | 2 + bin/rspec | 17 ++++++++ spec/spec_helper.rb | 79 ++++++++++++++++++++++++++++++++++ spec/unit/warm_blanket_spec.rb | 5 +++ 5 files changed, 104 insertions(+) create mode 100644 .rspec create mode 100755 bin/rspec create mode 100644 spec/spec_helper.rb create mode 100644 spec/unit/warm_blanket_spec.rb diff --git a/.gitignore b/.gitignore index 8eb3b06..24e5f45 100644 --- a/.gitignore +++ b/.gitignore @@ -10,3 +10,4 @@ # rspec failure tracking .rspec_status +spec/examples.txt diff --git a/.rspec b/.rspec new file mode 100644 index 0000000..3687797 --- /dev/null +++ b/.rspec @@ -0,0 +1,2 @@ +--require spec_helper +--color diff --git a/bin/rspec b/bin/rspec new file mode 100755 index 0000000..7420b57 --- /dev/null +++ b/bin/rspec @@ -0,0 +1,17 @@ +#!/usr/bin/env ruby +# frozen_string_literal: true +# +# This file was generated by Bundler. +# +# The application 'rspec' is installed as part of a gem, and +# this file is here to facilitate running it. +# + +require 'pathname' +ENV['BUNDLE_GEMFILE'] ||= File.expand_path('../../Gemfile', + Pathname.new(__FILE__).realpath) + +require 'rubygems' +require 'bundler/setup' + +load Gem.bin_path('rspec-core', 'rspec') diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb new file mode 100644 index 0000000..1a3ac1b --- /dev/null +++ b/spec/spec_helper.rb @@ -0,0 +1,79 @@ +# This file is supposed to be required by all specs, so please keep it as +# light-weight as possible. + +# See http://rubydoc.info/gems/rspec-core/RSpec/Core/Configuration +# for more rspec configuration details. + +RSpec.configure do |config| + config.expect_with :rspec do |expectations| + # This option will default to `true` in RSpec 4. It makes the `description` + # and `failure_message` of custom matchers include text for helper methods + # defined using `chain`. + expectations.include_chain_clauses_in_custom_matcher_descriptions = true + end + + config.mock_with :rspec do |mocks| + # Prevents you from mocking or stubbing a method that does not exist on + # a real object. This is generally recommended, and will default to + # `true` in RSpec 4. + mocks.verify_partial_doubles = true + end + + # This option will default to `:apply_to_host_groups` in RSpec 4 (and will + # have no way to turn it off -- the option exists only for backwards + # compatibility in RSpec 3). It causes shared context metadata to be + # inherited by the metadata hash of host groups and examples, rather than + # triggering implicit auto-inclusion in groups with matching metadata. + config.shared_context_metadata_behavior = :apply_to_host_groups + + # This allows you to limit a spec run to individual examples or groups + # you care about by tagging them with `:focus` metadata. When nothing + # is tagged with `:focus`, all examples get run. RSpec also provides + # aliases for `it`, `describe`, and `context` that include `:focus` + # metadata: `fit`, `fdescribe` and `fcontext`, respectively. + config.filter_run_when_matching :focus + + # Allows RSpec to persist some state between runs in order to support + # the `--only-failures` and `--next-failure` CLI options. We recommend + # you configure your source control system to ignore this file. + config.example_status_persistence_file_path = "spec/examples.txt" + + # Limits the available syntax to the non-monkey patched syntax that is + # recommended. + config.disable_monkey_patching! + + # This setting enables warnings. It's recommended, but in some cases may + # be too noisy due to issues in dependencies. + config.warnings = true + + # Many RSpec users commonly either run the entire suite or an individual + # file, and it's useful to allow more verbose output when running an + # individual spec file. + if config.files_to_run.one? + # Use the documentation formatter for detailed output, + # unless a formatter has already been configured + # (e.g. via a command-line flag). + config.default_formatter = "doc" + end + + # Print the n slowest examples and example groups at the + # end of the spec run, to help surface which specs are running + # particularly slow. + config.profile_examples = 5 + + # Run specs in random order to surface order dependencies. If you find an + # order dependency and want to debug it, you can fix the order by providing + # the seed, which is printed after each run. + # --seed 1234 + config.order = :random + + # Seed global randomization in this process using the `--seed` CLI option. + # Setting this allows you to use `--seed` to deterministically reproduce + # test failures related to randomization by passing the same `--seed` value + # as the one that triggered the failure. + Kernel.srand config.seed +end + +require 'pry' + +require 'warm-blanket' diff --git a/spec/unit/warm_blanket_spec.rb b/spec/unit/warm_blanket_spec.rb new file mode 100644 index 0000000..5c913b2 --- /dev/null +++ b/spec/unit/warm_blanket_spec.rb @@ -0,0 +1,5 @@ +RSpec.describe WarmBlanket do + it 'has a version number' do + expect(WarmBlanket::VERSION).not_to be nil + end +end From 9d522c1b9699ee7f776bc44f8c1e565de6abebc0 Mon Sep 17 00:00:00 2001 From: Ivo Anjo Date: Wed, 28 Jun 2017 16:31:07 +0100 Subject: [PATCH 03/16] Add first version of README --- README.md | 48 ++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 48 insertions(+) create mode 100644 README.md diff --git a/README.md b/README.md new file mode 100644 index 0000000..98f33ef --- /dev/null +++ b/README.md @@ -0,0 +1,48 @@ +# WarmBlanket + +**WarmBlanket is still a prototype. YMMV** + +WarmBlanket is a Ruby gem for warming up web services on boot. Its main target are JRuby web services, although it is not JRuby-specific in any way. + +# Why do we need to warm up web services? + +When the Java Virtual Machine (JVM) starts, it starts by interpreting Java bytecode. As it starts to detect code that runs often, it just-in-time compiles that code into native machine code, improving performance. + +This is a known challenge for most JVMs, and the same applies to JRuby applications, which also run on the JVM. + +A widely-documented solution to this problem is to perform a warm-up step when starting a service: + +* +* +* + +# What does WarmBlanket do? + +WarmBlanket warms services by performing repeated web requests for a configurable number of seconds. + +# How does WarmBlanket work? + +WarmBlanket spawns a configurable number of background threads that run inside the service process, and then uses an http client to perform local requests to the web server, simulating load. + +As it simulates requests, the JVM is warmed up and thus when real requests come in, no performance degradation is observed. + +# Limitations/caveats + +We strongly recommend that any services using WarmBlanket, if deployed on Heroku, use [Preboot](https://devcenter.heroku.com/articles/preboot). Preboot allows a service instance to be warmed up for 3 minutes before Heroku starts sending live traffic its way, which is preferable to doing it live. + +# Installation + +To install using Bundler, add the following to your `Gemfile`: + +```ruby +gem 'warm-blanket', '~> 0.1', + git: 'https://github.com/Talkdesk/warm-blanket.git' +``` + +To install a particular version, add the `tag` option: + +```ruby +gem 'warm-blanket', '~> 0.1', + git: 'https://github.com/Talkdesk/warm-blanket.git', + tag: 'v0.1.0' +``` From 94aded432e45ec598f0deb712d8a4fbb513db2f1 Mon Sep 17 00:00:00 2001 From: Ivo Anjo Date: Wed, 28 Jun 2017 17:00:21 +0100 Subject: [PATCH 04/16] Document expected interface --- README.md | 54 ++++++++++++++++++++++++++++++++++++++++++++++++------ 1 file changed, 48 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index 98f33ef..23457cd 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,9 @@ WarmBlanket is a Ruby gem for warming up web services on boot. Its main target are JRuby web services, although it is not JRuby-specific in any way. -# Why do we need to warm up web services? +# How the magic happens + +## Why do we need to warm up web services? When the Java Virtual Machine (JVM) starts, it starts by interpreting Java bytecode. As it starts to detect code that runs often, it just-in-time compiles that code into native machine code, improving performance. @@ -16,21 +18,25 @@ A widely-documented solution to this problem is to perform a warm-up step when s * * -# What does WarmBlanket do? +## What does WarmBlanket do? -WarmBlanket warms services by performing repeated web requests for a configurable number of seconds. +WarmBlanket warms services by performing repeated web requests for a configurable number of seconds. After that time, it closes shop and you'll never hear about it until the next service restart or deploy. -# How does WarmBlanket work? +## How does WarmBlanket work? WarmBlanket spawns a configurable number of background threads that run inside the service process, and then uses an http client to perform local requests to the web server, simulating load. As it simulates requests, the JVM is warmed up and thus when real requests come in, no performance degradation is observed. -# Limitations/caveats +## Limitations/caveats We strongly recommend that any services using WarmBlanket, if deployed on Heroku, use [Preboot](https://devcenter.heroku.com/articles/preboot). Preboot allows a service instance to be warmed up for 3 minutes before Heroku starts sending live traffic its way, which is preferable to doing it live. -# Installation +# How can I make use of it? + +To make use of WarmBlanket, you'll need to follow the next sections, which will guide you through installing, configuring and enabling the gem. + +## Installation To install using Bundler, add the following to your `Gemfile`: @@ -46,3 +52,39 @@ gem 'warm-blanket', '~> 0.1', git: 'https://github.com/Talkdesk/warm-blanket.git', tag: 'v0.1.0' ``` + +## Configuration settings + +This gem can be configured via the following environment variables: + +* `PORT`: Local webserver port (automatically set on Heroku) +* `WARMBLANKET_ENABLED`: Enable warmup (defaults to `false`; `true` or `1` enables) +* `WARMBLANKET_WARMUP_THREADS`: Number of warmup threads to use (defaults to `2`) +* `WARMBLANKET_WARMUP_TIME_SECONDS`: Time, in seconds, during which to warm up the service (defaults to `150`) + +### Configuring endpoints to be called + +Configure endpoints to be called as follows (on a `config/warm_blanket.rb`: + +```ruby +require 'warm-blanket' + +WarmBlanket.configure do |config| + common_headers = { + 'X-Api-Key': ENV['API_KEY'].split(',').first, + } + + config.endpoints = [ + {get: '/apps', headers: common_headers}, + {get: '/', headers: common_headers}, + ] +end +``` + +## Trigger warmup + +Add the following to the end of your `config.ru` file: + +```ruby +WarmBlanket.trigger_warmup +``` From 838cb2abd8fbec1e870a43d6cdab2afbdc5663bf Mon Sep 17 00:00:00 2001 From: Ivo Anjo Date: Wed, 28 Jun 2017 17:37:50 +0100 Subject: [PATCH 05/16] Add first stab at Requester class This class takes a list of endpoints and makes a request to one of them (in order) every time it is called. --- lib/warm_blanket/requester.rb | 64 ++++++++++++++++++++++++ spec/integration/requester_spec.rb | 80 ++++++++++++++++++++++++++++++ warm-blanket.gemspec | 1 + 3 files changed, 145 insertions(+) create mode 100644 lib/warm_blanket/requester.rb create mode 100644 spec/integration/requester_spec.rb diff --git a/lib/warm_blanket/requester.rb b/lib/warm_blanket/requester.rb new file mode 100644 index 0000000..0ded872 --- /dev/null +++ b/lib/warm_blanket/requester.rb @@ -0,0 +1,64 @@ +require 'faraday' + +module WarmBlanket + # Issues one request per call to the configured endpoint + class Requester + + private + + attr_reader :base_url + attr_reader :default_headers + attr_reader :endpoints + attr_reader :logger + attr_reader :connection_factory + + attr_accessor :next_endpoint_position + + public + + def initialize(base_url:, default_headers:, endpoints:, logger: WarmBlanket.config.logger, connection_factory: Faraday) + @base_url = base_url + @default_headers = default_headers + @endpoints = endpoints + @logger = logger + @connection_factory = connection_factory + @next_endpoint_position = 0 + end + + def call + connection = connection_factory.new(url: base_url) + + endpoint = next_endpoint + + logger.debug "Requesting #{endpoint.fetch(:get)}" + + response = connection.get do |request| + apply_headers(request, default_headers) + apply_headers(request, endpoint[:headers]) + request.url(endpoint.fetch(:get)) + end + + if response.status == 200 + logger.debug "Request successful" + else + logger.warn "Request to #{endpoint.fetch(:get)} failed with code #{response.status}" + end + + nil + end + + private + + def apply_headers(request, headers) + headers&.each do |header, value| + request.headers[header.to_s] = value + end + end + + def next_endpoint + next_endpoint = endpoints[next_endpoint_position] + self.next_endpoint_position = (next_endpoint_position + 1) % endpoints.size + next_endpoint + end + end +end diff --git a/spec/integration/requester_spec.rb b/spec/integration/requester_spec.rb new file mode 100644 index 0000000..450c0bd --- /dev/null +++ b/spec/integration/requester_spec.rb @@ -0,0 +1,80 @@ +require 'warm_blanket/requester' +require 'webmock/rspec' + +RSpec.describe WarmBlanket::Requester do + let(:base_url) { 'http://localhost:1234' } + let(:default_headers) { {'X-Foo': '123'} } + let(:endpoints) { [{get: '/apps', headers: {'X-Bar': '456'}}] } + + subject { + described_class.new(base_url: base_url, default_headers: default_headers, endpoints: endpoints) + } + + describe '.call' do + let(:call) { subject.call } + + let(:request_url) { "#{base_url}/apps" } + + before do + WebMock.enable! + stub_request(:get, request_url) + end + + after do + WebMock.disable! + end + + it 'performs a get request to the configured endpoints' do + call + + expect(a_request(:get, request_url)).to have_been_made + end + + it 'includes the default headers' do + call + + expect(a_request(:get, request_url).with(headers: {'X-Foo' => '123'})) + .to have_been_made + end + + it 'includes the configured headers' do + call + + expect(a_request(:get, request_url).with(headers: {'X-Bar' => '456'})) + .to have_been_made + end + + context 'when configured headers include headers also in default headers' do + let(:default_headers) { {'X-Bar': '42'} } + + it 'overrides default headers with configured headers' do + call + + expect(a_request(:get, request_url).with(headers: {'X-Bar' => '456'})) + .to have_been_made + end + end + + context 'when multiple endpoints are configured' do + let(:endpoints) { [{get: '/1'}, {get: '/2'}, {get: '/3'}] } + + it 'cycles between the endpoints on every call' do + stub_request(:get, "#{base_url}/1") + stub_request(:get, "#{base_url}/2") + stub_request(:get, "#{base_url}/3") + + subject.call + expect(a_request(:get, "#{base_url}/1")).to have_been_made + + subject.call + expect(a_request(:get, "#{base_url}/2")).to have_been_made + + subject.call + expect(a_request(:get, "#{base_url}/3")).to have_been_made + + subject.call + expect(a_request(:get, "#{base_url}/1")).to have_been_made.times(2) + end + end + end +end diff --git a/warm-blanket.gemspec b/warm-blanket.gemspec index bc5b772..e721690 100644 --- a/warm-blanket.gemspec +++ b/warm-blanket.gemspec @@ -18,6 +18,7 @@ Gem::Specification.new do |spec| spec.add_development_dependency 'bundler', '~> 1.15' spec.add_development_dependency 'rspec', '~> 3.6' + spec.add_development_dependency 'webmock', '~> 3.0' spec.add_development_dependency 'pry' spec.add_development_dependency 'pry-byebug' unless RUBY_PLATFORM == 'java' spec.add_development_dependency 'pry-debugger-jruby' if RUBY_PLATFORM == 'java' From e2881fcedf643a6c2061a60324f1ef5cadb2f0fc Mon Sep 17 00:00:00 2001 From: Ivo Anjo Date: Wed, 28 Jun 2017 17:38:29 +0100 Subject: [PATCH 06/16] Add first stab at WaitForPort class This class waits for a given port to be open on the target machine. --- lib/warm_blanket/wait_for_port.rb | 50 ++++++++++++++++ spec/integration/wait_for_port_spec.rb | 81 ++++++++++++++++++++++++++ 2 files changed, 131 insertions(+) create mode 100644 lib/warm_blanket/wait_for_port.rb create mode 100644 spec/integration/wait_for_port_spec.rb diff --git a/lib/warm_blanket/wait_for_port.rb b/lib/warm_blanket/wait_for_port.rb new file mode 100644 index 0000000..807193d --- /dev/null +++ b/lib/warm_blanket/wait_for_port.rb @@ -0,0 +1,50 @@ +require 'socket' + +module WarmBlanket + # Waits for given port to be available + class WaitForPort + + InvalidPort = Class.new(StandardError) + + private + + attr_reader :hostname + attr_reader :port + attr_reader :logger + attr_reader :tries_limit + + public + + def initialize(hostname: 'localhost', port:, tries_limit: 90, logger: WarmBlanket.config.logger) + raise "Invalid port (#{port.inspect})" unless (1...2**16).include?(port) + + @hostname = hostname + @port = port + @logger = logger + @tries_limit = tries_limit + end + + def call + logger.debug "Waiting for #{hostname}:#{port} to be available" + + tries = 0 + + while true + socket = nil + begin + socket = TCPSocket.new(hostname, port) + logger.debug "Service at #{hostname}:#{port} is up" + return true + rescue StandardError => e + logger.debug "Exception while waiting for port to be available #{e.class}: #{e.message}" + ensure + socket&.close + end + + tries += 1 + return false if tries >= tries_limit + sleep 1 + end + end + end +end diff --git a/spec/integration/wait_for_port_spec.rb b/spec/integration/wait_for_port_spec.rb new file mode 100644 index 0000000..9a23a07 --- /dev/null +++ b/spec/integration/wait_for_port_spec.rb @@ -0,0 +1,81 @@ +require 'warm_blanket/wait_for_port' +require 'socket' + +RSpec.describe WarmBlanket::WaitForPort do + let(:port) { (2**10...2**16).to_a.sample } + let(:default_hostname) { 'localhost' } + let(:optional_arguments) { {} } + + subject { described_class.new(port: port, **optional_arguments) } + + describe '.call' do + let(:call) { subject.call } + + context 'when service is available' do + let!(:open_socket) { TCPServer.open(port) } + let!(:server_background_thread) { Thread.new { open_socket.accept } } + + after do + server_background_thread.join + open_socket.close + end + + it do + expect(call).to be true + end + end + + context 'when service is not available' do + let(:tries_limit) { 3 } + let(:optional_arguments) { {tries_limit: tries_limit} } + + before do + allow(subject).to receive(:sleep) + end + + it do + expect(call).to be false + end + + it 'retries tries_limit times' do + expect(TCPSocket).to receive(:new) + .with(default_hostname, port).exactly(tries_limit).times.and_call_original + + call + end + + it 'sleeps one second between tries' do + should_sleep_now = false + + allow(TCPSocket).to receive(:new) do + should_sleep_now = true + raise Errno::ECONNREFUSED + end + + expect(subject).to receive(:sleep).with(1).exactly(tries_limit - 1).times do + expect(should_sleep_now).to be true + should_sleep_now = false + end + + call + end + + context 'when service becomes available after the n-th try' do + it do + attempts = 0 + + allow(TCPSocket).to receive(:new) do + attempts += 1 + if attempts > 2 + instance_double(TCPSocket, close: nil) + else + raise Errno::ECONNREFUSED + end + end + + expect(call).to be true + end + end + end + end +end From 2258a4f255d3039d463b91833f968cb70e32bfbf Mon Sep 17 00:00:00 2001 From: Ivo Anjo Date: Wed, 28 Jun 2017 17:50:43 +0100 Subject: [PATCH 07/16] Bootstrap gem configuration using dry-configurable and logging gem --- lib/warm-blanket.rb | 24 ++++++++++++++++++++++++ warm-blanket.gemspec | 2 ++ 2 files changed, 26 insertions(+) diff --git a/lib/warm-blanket.rb b/lib/warm-blanket.rb index c9f8654..0e12b69 100644 --- a/lib/warm-blanket.rb +++ b/lib/warm-blanket.rb @@ -1 +1,25 @@ require 'warm_blanket/version' + +require 'dry-configurable' +require 'logging' + +WarmBlanket.instance_eval do + extend Dry::Configurable + + # Endpoints to be called for warmup, see README + setting :endpoints, [], reader: true + + setting :logger, Logging.logger[self], reader: true + + # Local webserver port + setting :port, ENV['PORT'], reader: true + + # Enable warmup + setting :enabled, ENV['WARMBLANKET_ENABLED'], reader: true + + # Number of threads to use + setting :warmup_threads, Integer(ENV['WARMBLANKET_WARMUP_THREADS'] || 2), reader: true + + # Time, in seconds, during which to warm up the service + setting :warmup_time_seconds, Float(ENV['WARMBLANKET_WARMUP_TIME_SECONDS'] || 150), reader: true +end diff --git a/warm-blanket.gemspec b/warm-blanket.gemspec index e721690..158c3fc 100644 --- a/warm-blanket.gemspec +++ b/warm-blanket.gemspec @@ -24,4 +24,6 @@ Gem::Specification.new do |spec| spec.add_development_dependency 'pry-debugger-jruby' if RUBY_PLATFORM == 'java' spec.add_dependency 'faraday', '~> 0.9' + spec.add_dependency 'dry-configurable', '~> 0.7' + spec.add_dependency 'logging', '~> 2.1.0' end From 83c051a17f08e532a5e2d3144324038d0f6fe118 Mon Sep 17 00:00:00 2001 From: Ivo Anjo Date: Wed, 28 Jun 2017 18:51:08 +0100 Subject: [PATCH 08/16] Wire up orchestrator --- lib/warm-blanket.rb | 12 ++++ lib/warm_blanket/orchestrator.rb | 106 +++++++++++++++++++++++++++++++ 2 files changed, 118 insertions(+) create mode 100644 lib/warm_blanket/orchestrator.rb diff --git a/lib/warm-blanket.rb b/lib/warm-blanket.rb index 0e12b69..f1b20a7 100644 --- a/lib/warm-blanket.rb +++ b/lib/warm-blanket.rb @@ -1,8 +1,20 @@ require 'warm_blanket/version' +require 'warm_blanket/orchestrator' require 'dry-configurable' require 'logging' +module WarmBlanket + def self.trigger_warmup(logger: WarmBlanket.config.logger, orchestrator_factory: Orchestrator) + unless [true, 'true', '1'].include?(WarmBlanket.config.enabled) + logger.info "WarmBlanket not enabled, ignoring trigger_warmup" + return false + end + + orchestrator_factory.new.call + end +end + WarmBlanket.instance_eval do extend Dry::Configurable diff --git a/lib/warm_blanket/orchestrator.rb b/lib/warm_blanket/orchestrator.rb new file mode 100644 index 0000000..c7b8b5d --- /dev/null +++ b/lib/warm_blanket/orchestrator.rb @@ -0,0 +1,106 @@ +# frozen_string_literal: true + +require 'warm_blanket/requester' +require 'warm_blanket/wait_for_port' + +module WarmBlanket + # Orchestrates threads to wait for the port to open and to perform the warmup requests + class Orchestrator + + DEFAULT_HEADERS = { + 'X-Forwarded-Proto': 'https', + 'X-Request-Id': 'WarmBlanket', + 'X-Platform-Tid': 'WarmBlanket', + 'X-Client-Id': 'WarmBlanket', + 'X-Account': 'WarmBlanket', + }.freeze + + private + + attr_reader :requester_factory + attr_reader :wait_for_port_factory + attr_reader :logger + attr_reader :endpoints + attr_reader :hostname + attr_reader :port + attr_reader :warmup_threads + attr_reader :warmup_time_seconds + + public + + def initialize( + requester_factory: Requester, + wait_for_port_factory: WaitForPort, + logger: WarmBlanket.config.logger, + endpoints: WarmBlanket.config.endpoints, + hostname: 'localhost', + port: WarmBlanket.config.port, + warmup_threads: WarmBlanket.config.warmup_threads, + warmup_time_seconds: WarmBlanket.config.warmup_time_seconds + ) + raise "Warmup threads cannot be less than 1 (got #{warmup_threads})" if warmup_threads < 1 + + @requester_factory = requester_factory + @wait_for_port_factory = wait_for_port_factory + @logger = logger + @endpoints = endpoints + @hostname = hostname + @port = port + @warmup_threads = warmup_threads + @warmup_time_seconds = warmup_time_seconds + end + + def call + Thread.new do + logger.debug 'Started orchestrator thread' + orchestrate + end + end + + private + + def orchestrate + if wait_for_port_to_open + spawn_warmup_threads + end + end + + def wait_for_port_to_open + wait_for_port_factory.new(port: port).call + end + + def spawn_warmup_threads + # Create remaining threads + (warmup_threads - 1).times do + Thread.new do + perform_warmup_requests + end + end + + # Reuse current thread + perform_warmup_requests + end + + def perform_warmup_requests + success = false + logger.debug "Starting warmup requests" + + warmup_start = Time.now + warmup_deadline = warmup_start + warmup_time_seconds + + requester = requester_factory.new( + base_url: "http://#{hostname}:#{port}", + default_headers: DEFAULT_HEADERS, + endpoints: endpoints, + ) + + while Time.now < warmup_deadline + requester.call + end + + success = true + ensure + logger.info "Finished warmup work #{success ? 'successfully' : 'with error'}" + end + end +end From ba277756dc872fa08c2bfbb4bed189397f0d5996 Mon Sep 17 00:00:00 2001 From: Ivo Anjo Date: Wed, 28 Jun 2017 19:25:25 +0100 Subject: [PATCH 09/16] Improve background thread error handling --- lib/warm_blanket/orchestrator.rb | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/lib/warm_blanket/orchestrator.rb b/lib/warm_blanket/orchestrator.rb index c7b8b5d..a8ada98 100644 --- a/lib/warm_blanket/orchestrator.rb +++ b/lib/warm_blanket/orchestrator.rb @@ -51,7 +51,7 @@ def initialize( end def call - Thread.new do + safely_spawn_thread do logger.debug 'Started orchestrator thread' orchestrate end @@ -59,6 +59,16 @@ def call private + def safely_spawn_thread(&block) + Thread.new do + begin + block.call + rescue => e + logger.error "Caught error that caused background thread to die #{e.class}: #{e.message}" + end + end + end + def orchestrate if wait_for_port_to_open spawn_warmup_threads @@ -72,7 +82,7 @@ def wait_for_port_to_open def spawn_warmup_threads # Create remaining threads (warmup_threads - 1).times do - Thread.new do + safely_spawn_thread do perform_warmup_requests end end From 65fe3d20a30cbc6245a5cd4d709a40461a246f54 Mon Sep 17 00:00:00 2001 From: Ivo Anjo Date: Thu, 29 Jun 2017 14:05:53 +0100 Subject: [PATCH 10/16] Avoid unneeded use of instance_eval for configuration Due to a misunderstanding of dry-configuration documentation I believed that we could not use their module and configuration on a `module` rather than a `class`; but after a review suggestion it turns out that it works, so we can simplify the configuration. --- lib/warm-blanket.rb | 20 +++++++++----------- 1 file changed, 9 insertions(+), 11 deletions(-) diff --git a/lib/warm-blanket.rb b/lib/warm-blanket.rb index f1b20a7..f05fec3 100644 --- a/lib/warm-blanket.rb +++ b/lib/warm-blanket.rb @@ -5,17 +5,6 @@ require 'logging' module WarmBlanket - def self.trigger_warmup(logger: WarmBlanket.config.logger, orchestrator_factory: Orchestrator) - unless [true, 'true', '1'].include?(WarmBlanket.config.enabled) - logger.info "WarmBlanket not enabled, ignoring trigger_warmup" - return false - end - - orchestrator_factory.new.call - end -end - -WarmBlanket.instance_eval do extend Dry::Configurable # Endpoints to be called for warmup, see README @@ -34,4 +23,13 @@ def self.trigger_warmup(logger: WarmBlanket.config.logger, orchestrator_factory: # Time, in seconds, during which to warm up the service setting :warmup_time_seconds, Float(ENV['WARMBLANKET_WARMUP_TIME_SECONDS'] || 150), reader: true + + def self.trigger_warmup(logger: WarmBlanket.config.logger, orchestrator_factory: Orchestrator) + unless [true, 'true', '1'].include?(WarmBlanket.config.enabled) + logger.info "WarmBlanket not enabled, ignoring trigger_warmup" + return false + end + + orchestrator_factory.new.call + end end From 9e138cfb0777fc6a447f0d89a18a8ee2644d7c24 Mon Sep 17 00:00:00 2001 From: Ivo Anjo Date: Thu, 29 Jun 2017 14:09:25 +0100 Subject: [PATCH 11/16] Fix test description for methods The two fixed methods are instance methods, not class methods, and thus they should be prefixed with `#` and not with `.`. --- spec/integration/requester_spec.rb | 2 +- spec/integration/wait_for_port_spec.rb | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/spec/integration/requester_spec.rb b/spec/integration/requester_spec.rb index 450c0bd..62580af 100644 --- a/spec/integration/requester_spec.rb +++ b/spec/integration/requester_spec.rb @@ -10,7 +10,7 @@ described_class.new(base_url: base_url, default_headers: default_headers, endpoints: endpoints) } - describe '.call' do + describe '#call' do let(:call) { subject.call } let(:request_url) { "#{base_url}/apps" } diff --git a/spec/integration/wait_for_port_spec.rb b/spec/integration/wait_for_port_spec.rb index 9a23a07..52b94a0 100644 --- a/spec/integration/wait_for_port_spec.rb +++ b/spec/integration/wait_for_port_spec.rb @@ -8,7 +8,7 @@ subject { described_class.new(port: port, **optional_arguments) } - describe '.call' do + describe '#call' do let(:call) { subject.call } context 'when service is available' do From 09dc20464b45fe00b494b6680ea4a58ba3ed6826 Mon Sep 17 00:00:00 2001 From: Ivo Anjo Date: Thu, 29 Jun 2017 14:12:23 +0100 Subject: [PATCH 12/16] Allow port to be specified as a string Otherwise, we would fail when picking up the port from an environment variable. --- lib/warm_blanket/wait_for_port.rb | 1 + spec/integration/wait_for_port_spec.rb | 10 ++++++++++ 2 files changed, 11 insertions(+) diff --git a/lib/warm_blanket/wait_for_port.rb b/lib/warm_blanket/wait_for_port.rb index 807193d..66ec7d1 100644 --- a/lib/warm_blanket/wait_for_port.rb +++ b/lib/warm_blanket/wait_for_port.rb @@ -16,6 +16,7 @@ class WaitForPort public def initialize(hostname: 'localhost', port:, tries_limit: 90, logger: WarmBlanket.config.logger) + port = Integer(port) raise "Invalid port (#{port.inspect})" unless (1...2**16).include?(port) @hostname = hostname diff --git a/spec/integration/wait_for_port_spec.rb b/spec/integration/wait_for_port_spec.rb index 52b94a0..db62b0e 100644 --- a/spec/integration/wait_for_port_spec.rb +++ b/spec/integration/wait_for_port_spec.rb @@ -8,6 +8,16 @@ subject { described_class.new(port: port, **optional_arguments) } + describe '.new' do + context 'when port is specified as a string' do + let(:port) { '5000' } + + it 'returns a new instance' do + subject + end + end + end + describe '#call' do let(:call) { subject.call } From 5c6cc105e5c818ffeb7febcce46215d653a4c92d Mon Sep 17 00:00:00 2001 From: Ivo Anjo Date: Thu, 29 Jun 2017 15:52:07 +0100 Subject: [PATCH 13/16] Simplify orchestrate method after PR suggestions This way we remove a strange blocking operation from the if, and make it clear that this may take some time. --- lib/warm_blanket/orchestrator.rb | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/lib/warm_blanket/orchestrator.rb b/lib/warm_blanket/orchestrator.rb index a8ada98..a3fab14 100644 --- a/lib/warm_blanket/orchestrator.rb +++ b/lib/warm_blanket/orchestrator.rb @@ -70,9 +70,9 @@ def safely_spawn_thread(&block) end def orchestrate - if wait_for_port_to_open - spawn_warmup_threads - end + success = wait_for_port_to_open + + spawn_warmup_threads if success end def wait_for_port_to_open From 132830b53771d9fbaf42421a9be0978710315f76 Mon Sep 17 00:00:00 2001 From: Ivo Anjo Date: Thu, 29 Jun 2017 16:17:32 +0100 Subject: [PATCH 14/16] Add support for HTTP POST and PUT verbs --- README.md | 2 ++ lib/warm_blanket/requester.rb | 22 +++++++++++++++++++--- spec/integration/requester_spec.rb | 30 ++++++++++++++++++++++++++++++ 3 files changed, 51 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 23457cd..486f3c4 100644 --- a/README.md +++ b/README.md @@ -81,6 +81,8 @@ WarmBlanket.configure do |config| end ``` +Other HTTP verbs are supported (and you can pass in a `body` key if needed), but be careful about side effects from such verbs. And if there's no side effect from a `POST` or `PUT`, do consider if it shouldn't be a `GET` instead ;) + ## Trigger warmup Add the following to the end of your `config.ru` file: diff --git a/lib/warm_blanket/requester.rb b/lib/warm_blanket/requester.rb index 0ded872..50fa044 100644 --- a/lib/warm_blanket/requester.rb +++ b/lib/warm_blanket/requester.rb @@ -4,8 +4,13 @@ module WarmBlanket # Issues one request per call to the configured endpoint class Requester + InvalidHTTPVerb = Class.new(StandardError) + private + SUPPORTED_VERBS = [:get, :post, :put].freeze + private_constant :SUPPORTED_VERBS + attr_reader :base_url attr_reader :default_headers attr_reader :endpoints @@ -30,12 +35,15 @@ def call endpoint = next_endpoint - logger.debug "Requesting #{endpoint.fetch(:get)}" + http_verb = extract_verb(endpoint) - response = connection.get do |request| + logger.debug "Requesting #{endpoint.fetch(http_verb)}" + + response = connection.public_send(http_verb) do |request| apply_headers(request, default_headers) apply_headers(request, endpoint[:headers]) - request.url(endpoint.fetch(:get)) + request.url(endpoint.fetch(http_verb)) + request.body = endpoint[:body] if endpoint[:body] end if response.status == 200 @@ -60,5 +68,13 @@ def next_endpoint self.next_endpoint_position = (next_endpoint_position + 1) % endpoints.size next_endpoint end + + def extract_verb(endpoint) + SUPPORTED_VERBS.each do |verb| + return verb if endpoint.key?(verb) + end + + raise InvalidHTTPVerb, "Unsupported or missing HTTP verb for request: #{endpoint.inspect}" + end end end diff --git a/spec/integration/requester_spec.rb b/spec/integration/requester_spec.rb index 62580af..a76ddfe 100644 --- a/spec/integration/requester_spec.rb +++ b/spec/integration/requester_spec.rb @@ -76,5 +76,35 @@ expect(a_request(:get, "#{base_url}/1")).to have_been_made.times(2) end end + + context 'when endpoints use the post verb' do + let(:endpoints) { [{post: '/foo', body: '{"hello":"world"}'}] } + + let(:post_request_url) { "#{base_url}/foo" } + + before do + stub_request(:post, post_request_url) + end + + it 'performs a post request to the configured endpoints' do + call + + expect(a_request(:post, post_request_url)).to have_been_made + end + + it 'includes the specified body in the post request' do + call + + expect(a_request(:post, post_request_url).with(body: '{"hello":"world"}')).to have_been_made + end + end + + context 'when an unsupported http verb is used' do + let(:endpoints) { [{delete: '/foo'}] } + + it do + expect { call }.to raise_error(described_class::InvalidHTTPVerb) + end + end end end From 726bbcab8b052c9dd03e4905721762453b64c55c Mon Sep 17 00:00:00 2001 From: Ivo Anjo Date: Thu, 29 Jun 2017 16:19:28 +0100 Subject: [PATCH 15/16] Improve error message when invalid port is specified --- lib/warm_blanket/wait_for_port.rb | 4 ++-- spec/integration/wait_for_port_spec.rb | 8 ++++++++ 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/lib/warm_blanket/wait_for_port.rb b/lib/warm_blanket/wait_for_port.rb index 66ec7d1..6edc7f6 100644 --- a/lib/warm_blanket/wait_for_port.rb +++ b/lib/warm_blanket/wait_for_port.rb @@ -16,8 +16,8 @@ class WaitForPort public def initialize(hostname: 'localhost', port:, tries_limit: 90, logger: WarmBlanket.config.logger) - port = Integer(port) - raise "Invalid port (#{port.inspect})" unless (1...2**16).include?(port) + port = Integer(port) rescue nil + raise InvalidPort, "Invalid port (#{port.inspect})" unless (1...2**16).include?(port) @hostname = hostname @port = port diff --git a/spec/integration/wait_for_port_spec.rb b/spec/integration/wait_for_port_spec.rb index db62b0e..247755f 100644 --- a/spec/integration/wait_for_port_spec.rb +++ b/spec/integration/wait_for_port_spec.rb @@ -16,6 +16,14 @@ subject end end + + context 'when invalid port is specified' do + let(:port) { 'over 9000' } + + it do + expect { subject }.to raise_error(described_class::InvalidPort) + end + end end describe '#call' do From e0fa3777feffc66b8c2e799c6bbd8cbd890ed68d Mon Sep 17 00:00:00 2001 From: Ivo Anjo Date: Thu, 29 Jun 2017 16:20:11 +0100 Subject: [PATCH 16/16] Add ToC to README (auto-generated using gh-md-toc tool) --- README.md | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/README.md b/README.md index 486f3c4..cc2b853 100644 --- a/README.md +++ b/README.md @@ -4,6 +4,18 @@ WarmBlanket is a Ruby gem for warming up web services on boot. Its main target are JRuby web services, although it is not JRuby-specific in any way. +* [WarmBlanket](#warmblanket) +* [How the magic happens](#how-the-magic-happens) + * [Why do we need to warm up web services?](#why-do-we-need-to-warm-up-web-services) + * [What does WarmBlanket do?](#what-does-warmblanket-do) + * [How does WarmBlanket work?](#how-does-warmblanket-work) + * [Limitations/caveats](#limitationscaveats) +* [How can I make use of it?](#how-can-i-make-use-of-it) + * [Installation](#installation) + * [Configuration settings](#configuration-settings) + * [Configuring endpoints to be called](#configuring-endpoints-to-be-called) + * [Trigger warmup](#trigger-warmup) + # How the magic happens ## Why do we need to warm up web services?