glowworm 0.3.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.
Files changed (55) hide show
  1. checksums.yaml +15 -0
  2. data/.gitignore +6 -0
  3. data/.yardopts +1 -0
  4. data/Gemfile +5 -0
  5. data/Gemfile.lock +129 -0
  6. data/LICENSE +19 -0
  7. data/README.md +326 -0
  8. data/Rakefile +29 -0
  9. data/bin/basic_server_tester +311 -0
  10. data/bin/em_server/config.ru +2 -0
  11. data/bin/em_server/em_server.rb +19 -0
  12. data/bin/em_server_tester +68 -0
  13. data/bin/glowworm +90 -0
  14. data/bin/load_tester +84 -0
  15. data/ci_jobs/glowworm-continuous-deploy-next-staging/run.sh +10 -0
  16. data/ci_jobs/glowworm-integrations/run.sh +15 -0
  17. data/ci_jobs/glowworm-performance/run.sh +2 -0
  18. data/ci_jobs/glowworm-robustness/run.sh +2 -0
  19. data/ci_jobs/glowworm-units/run.sh +13 -0
  20. data/ci_jobs/setup.sh +119 -0
  21. data/example/example_server.ecology +6 -0
  22. data/example/example_server.rb +32 -0
  23. data/glowworm.gemspec +54 -0
  24. data/lib/glowworm.rb +501 -0
  25. data/lib/glowworm/em.rb +8 -0
  26. data/lib/glowworm/no_bg.rb +2 -0
  27. data/lib/glowworm/version.rb +3 -0
  28. data/server/Gemfile +27 -0
  29. data/server/Gemfile.lock +87 -0
  30. data/server/PROTOCOL +39 -0
  31. data/server/check_mk_checks/check_glowworm_server +43 -0
  32. data/server/db_migrations/20111004214649_change_feature_accounts_to_string.rb +60 -0
  33. data/server/db_migrations/20111028104546_add_value_to_account_set_features.rb +12 -0
  34. data/server/db_migrations/20120217090636_add_fully_active_flag_to_features.rb +15 -0
  35. data/server/example_test_data.rb +66 -0
  36. data/server/glowworm_server.ecology.erb +16 -0
  37. data/server/glowworm_server.rb +226 -0
  38. data/server/run/server.sh +7 -0
  39. data/server/server_test.rb +72 -0
  40. data/server/version.rb +3 -0
  41. data/test/integration/basic_server_test.rb +90 -0
  42. data/test/integration/create_sqlite_data.rb +196 -0
  43. data/test/integration/em_server_test.rb +68 -0
  44. data/test/integration/gemfile_for_specific_glowworm_version +17 -0
  45. data/test/integration/gemfile_for_specific_glowworm_version.lock +55 -0
  46. data/test/integration/integration_test_helper.rb +153 -0
  47. data/test/integration/load_test.rb +59 -0
  48. data/test/integration/nginx.conf +23 -0
  49. data/test/integration/server_test.ecology.erb +6 -0
  50. data/test/test_helper.rb +47 -0
  51. data/test/units/em_test.rb +41 -0
  52. data/test/units/feature_flag_test.rb +297 -0
  53. data/test/units/no_bg_test.rb +40 -0
  54. data/test/units/request_test.rb +51 -0
  55. metadata +410 -0
data/bin/load_tester ADDED
@@ -0,0 +1,84 @@
1
+ #!/usr/bin/env ruby
2
+
3
+ require "glowworm"
4
+ require "trollop"
5
+
6
+ OPTS = Trollop::options do
7
+ opt :concurrent, "How many threads", :type => :int, :default => 1
8
+ opt :runs, "How many times to run", :type => :int, :default => 1
9
+ opt :account, "An account to query", :type => String, :required => true, :multi => true
10
+ opt :feature, "A feature to query", :type => String, :required => true, :multi => true
11
+ opt :server, "Server to query", :type => String, :default => "http://localhost:4999"
12
+ opt :fork, "Fork instead of threading", :type => :boolean, :default => false
13
+ opt :delay, "Seconds to sleep between requests (e.g. 0.001)", :type => :float, :default => 0.0
14
+ opt :header, "Print header information", :type => :boolean, :default => true
15
+ opt :log_too_much, "Log too much", :type => :boolean
16
+ opt :show_backtrace, "Show error traces", :type => :boolean
17
+ opt :timeout, "Timeout per request", :type => Float, :default => 0.1
18
+ end
19
+
20
+ if OPTS[:header]
21
+ puts "We print dot for correct responses, F for incorrect responses, and E for exceptions."
22
+ puts "#{OPTS[:concurrent]} #{OPTS[:fork] ? "processes" : "threads"}, #{OPTS[:server]}"
23
+ puts "Client version #{Glowworm::VERSION} go!"
24
+ end
25
+
26
+ CHAR_LIST = (0..9).to_a + ("a".."z").to_a + ("A".."Z").to_a
27
+ def num_to_char(num)
28
+ n = num % 62
29
+ CHAR_LIST[n]
30
+ end
31
+
32
+ Glowworm.server = OPTS[:server]
33
+
34
+ if ENV["TEST_GLOWWORM_VERSION"] && ENV["TEST_GLOWWORM_VERSION"] != "latest" && Glowworm::VERSION != ENV["TEST_GLOWWORM_VERSION"]
35
+ fail "Should be testing under #{ENV["TEST_GLOWWORM_VERSION"]}"
36
+ end
37
+
38
+ $stderr.puts "Glowworm version: #{Glowworm::VERSION}"
39
+
40
+ # Silence all logging
41
+ fake_logger = Object.new
42
+ def fake_logger.method_missing(*args); end
43
+ Glowworm.termite_logger = fake_logger
44
+
45
+ threads = []
46
+ forks = []
47
+ OPTS[:concurrent].times do
48
+ action = proc do
49
+ if OPTS[:log_too_much]
50
+ # TODO(noah): Use thread ID not PID for threads
51
+ log_path = "/tmp/gw_multi_test_#{Process.pid}_#{num_to_char(Process.pid)}"
52
+ logger = Termite::Logger.new(log_path, nil, nil, :level => "debug", :console_print => false)
53
+ Glowworm.termite_logger = logger
54
+ end
55
+ OPTS[:runs].times do
56
+ begin
57
+ x = Glowworm.feature_flag(OPTS[:account].sample, OPTS[:feature].sample, :default => false,
58
+ :timeout => OPTS[:timeout], :ttl => 0.0)
59
+ if x == true
60
+ print "."
61
+ else
62
+ print "F(#{num_to_char(Process.pid)})"
63
+ end
64
+ rescue
65
+ print "E"
66
+ puts "Backtrace: #{$!.message}\n #{$!.backtrace.join("\n")}" if OPTS[:show_backtrace]
67
+ end
68
+ sleep(OPTS[:delay]) if OPTS[:delay] > 0
69
+ end
70
+ end
71
+
72
+ if OPTS[:fork]
73
+ forks << fork(&action)
74
+ else
75
+ threads << Thread.new(&action)
76
+ end
77
+ end
78
+
79
+ if OPTS[:fork]
80
+ forks.each { |f| Process.wait f }
81
+ else
82
+ threads.each{ |t| t.join }
83
+ end
84
+ puts ""
@@ -0,0 +1,10 @@
1
+ #!/bin/bash
2
+
3
+ export PATH=~/.rbenv/bin/:${PATH}
4
+ eval "$(rbenv init - --no-rehash)"
5
+
6
+ export OO_DEPLOY_DIR=/home/role-c3/ooyala-security/deploy_configs
7
+ cd glowworm/server/
8
+ gem install --no-rdoc --no-ri bundler
9
+ bundle install --without test
10
+ bundle exec cap to_next_staging deploy:full
@@ -0,0 +1,15 @@
1
+ #!/bin/bash -l
2
+
3
+ if [[ -z $WORKSPACE ]] ; then
4
+ # Support execution from the shell
5
+ export PROJECT_DIR=$(pwd)
6
+ else
7
+ export PROJECT_DIR="$WORKSPACE/glowworm"
8
+ fi
9
+
10
+ source "$PROJECT_DIR/ci_jobs/setup.sh"
11
+
12
+ echo START TASK: tests
13
+ exec_rake_task test:integration
14
+ echo END TASK
15
+
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env bash -l
2
+ exit 1
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env bash -l
2
+ exit 1
@@ -0,0 +1,13 @@
1
+ #!/bin/bash -l
2
+
3
+ if [[ -z $WORKSPACE ]] ; then
4
+ # Support execution from the shell
5
+ export PROJECT_DIR=$(pwd);
6
+ else
7
+ export PROJECT_DIR=$WORKSPACE/glowworm;
8
+ fi
9
+
10
+ source $PROJECT_DIR/ci_jobs/setup.sh
11
+
12
+ echo START TASK: tests
13
+ exec_rake_task test:units
data/ci_jobs/setup.sh ADDED
@@ -0,0 +1,119 @@
1
+ #!/usr/bin/env bash -l
2
+ set -e
3
+
4
+ echo START TASK: Determining test config
5
+
6
+ export TAP_OUTPUT=true;
7
+
8
+ if [[ -z $WORKSPACE ]] ; then
9
+ export RESULTS_DIR="$(pwd)/results"
10
+ else
11
+ export RESULTS_DIR="$WORKSPACE/results"
12
+ fi
13
+
14
+ rm -rf "$RESULTS_DIR"
15
+ mkdir -p "$RESULTS_DIR"
16
+
17
+ cd "$PROJECT_DIR"
18
+
19
+ # The ruby versions to test are without the gemset specifier.
20
+ # Default to testing 1.9.3-p194. Which is the approved 1.9.3 ruby version.
21
+ : ${TEST_RUBY_VERSION:=1.9.3-p194}
22
+
23
+ # Default the server and client ruby versions to TEST_RUBY_VERSION
24
+ : ${TEST_SERVER_RUBY_VERSION:=$TEST_RUBY_VERSION}
25
+ : ${TEST_CLIENT_RUBY_VERSION:=$TEST_RUBY_VERSION}
26
+
27
+ export TEST_RUBY_VERSION TEST_SERVER_RUBY_VERSION TEST_CLIENT_RUBY_VERSION
28
+
29
+ # default to verifying the glowworm code contained in the repo
30
+ # Other valid values are the glowworm gem version that should be used.
31
+ : ${TEST_GLOWWORM_VERSION:=latest}
32
+
33
+ export TEST_GLOWWORM_VERSION
34
+
35
+ # The ruby distribution to test is with gemset specifier
36
+ export TEST_CLIENT_RUBY_DIST=${TEST_CLIENT_RUBY_VERSION}
37
+ export TEST_SERVER_RUBY_DIST=${TEST_SERVER_RUBY_VERSION}
38
+
39
+ echo END TASK
40
+
41
+ echo START TASK: Load test harness ruby dist
42
+
43
+ if [[ -z ${TEST_HARNESS_RUBY_VERSION} ]]; then
44
+ export TEST_HARNESS_RUBY_DIST=1.9.3-p125
45
+ else
46
+ export TEST_HARNESS_RUBY_DIST=${TEST_HARNESS_RUBY_VERSION}
47
+ fi
48
+
49
+ echo END TASK
50
+
51
+ echo START TASK: init rbenv
52
+
53
+ eval "$(rbenv init -)"
54
+
55
+ echo END TASK
56
+
57
+ function setup_ruby_dist
58
+ {
59
+ rbenv shell $1
60
+
61
+ gem install --no-rdoc --no-ri bundler
62
+
63
+ bundle install
64
+ }
65
+
66
+ echo START TASK: Init ruby distribution ${TEST_SERVER_RUBY_DIST}
67
+
68
+ # Setup the server ruby distribution
69
+ ( cd server ; setup_ruby_dist ${TEST_SERVER_RUBY_DIST} );
70
+
71
+ echo END TASK
72
+
73
+ echo START TASK: Init ruby distribution ${TEST_CLIENT_RUBY_DIST}
74
+
75
+ # If the glowworm version is 'latest' then just setup the client using bundle install
76
+ if [[ $TEST_GLOWWORM_VERSION == latest ]]; then
77
+ echo using latest;
78
+
79
+ setup_ruby_dist ${TEST_CLIENT_RUBY_DIST}
80
+ else
81
+ echo using ${TEST_GLOWWORM_VERSION}
82
+
83
+ rbenv shell ${TEST_CLIENT_RUBY_DIST}
84
+
85
+ gem install --no-rdoc --no-ri bundler
86
+
87
+ bundle install --gemfile ${PROJECT_DIR}/test/integration/gemfile_for_specific_glowworm_version
88
+ fi
89
+
90
+ echo Running test harness under ${TEST_HARNESS_RUBY_DIST};
91
+
92
+ # setup the test harness ruby distribution as a superset of the client and server distributions
93
+ # along with additional test harness specific packages.
94
+ setup_ruby_dist ${TEST_HARNESS_RUBY_DIST};
95
+ (cd server ; setup_ruby_dist ${TEST_HARNESS_RUBY_DIST});
96
+
97
+ # TODO(corey): install via a Gemfile
98
+ gem install --no-rdoc --no-ri tapout
99
+
100
+ echo END TASK
101
+
102
+ function translate_TAP_results()
103
+ {
104
+ actual_exit_code=$?
105
+ find "$RESULTS_DIR" -name '*.yaml' -exec bash -c 'tapout tap < {} > {}.tap' \;
106
+ return $actual_exit_code
107
+ }
108
+
109
+ function exec_rake_task()
110
+ {
111
+ # TODO(corey): Have the rake task output each unit test to an individual YAMLish TAP file
112
+ # Always try to translate the YAMLish TAP to PERLish TAP for jenkins
113
+ trap translate_TAP_results EXIT
114
+
115
+ # Also output to the current STDOUT so the console log has all data.
116
+ # this is last to assure the exit code of the script is the result of rake
117
+ rake "$1" "$@" | tee $RESULTS_DIR/results.yaml
118
+ exit ${PIPESTATUS[0]}
119
+ }
@@ -0,0 +1,6 @@
1
+ {
2
+ "application": "example of using Glowworm",
3
+ "features": {
4
+ "server": "http://localhost:4999"
5
+ }
6
+ }
@@ -0,0 +1,32 @@
1
+ #!/usr/bin/env ruby
2
+
3
+ require "rubygems"
4
+ require "sinatra"
5
+ require "glowworm"
6
+
7
+ Ecology.read("example_server.ecology")
8
+
9
+ configure do
10
+ set :port, "8087"
11
+ end
12
+
13
+ get "/" do
14
+ if Glowworm.feature_flag(35, "new_signup_message", :ttl => 15)
15
+ button_text = "join now for free?"
16
+ else
17
+ button_text = "sign up?"
18
+ end
19
+
20
+ <<STATIC_PAGE
21
+ <html>
22
+ <head>
23
+ <title>Feature Test App</title>
24
+ <meta http-equiv="refresh" content="5">
25
+ </head>
26
+ <body>
27
+ <h1> Feature Test App </h1>
28
+ Why don't you <button>#{button_text}</button>
29
+ </body>
30
+ </html>
31
+ STATIC_PAGE
32
+ end
data/glowworm.gemspec ADDED
@@ -0,0 +1,54 @@
1
+ # -*- encoding: utf-8 -*-
2
+ $:.push File.expand_path("../lib", __FILE__)
3
+
4
+ require "glowworm/version"
5
+
6
+ Gem::Specification.new do |s|
7
+ s.name = "glowworm"
8
+ s.version = Glowworm::VERSION
9
+ s.platform = Gem::Platform::RUBY
10
+ s.authors = ["Noah Gibbs"]
11
+ s.email = ["noah@ooyala.com"]
12
+ s.homepage = "http://www.ooyala.com"
13
+ s.summary = %q{Ruby feature management for dark deploys}
14
+ s.description = <<EOS
15
+ Glowworm queries and caches feature flags from a central
16
+ REST server, while balancing quick changes and good performance.
17
+ EOS
18
+
19
+ s.rubyforge_project = "glowworm"
20
+
21
+ ignores = File.readlines(".gitignore").grep(/\S+/).map {|pattern| pattern.chomp }
22
+ ignores += ["server/config/*", "server/Capfile", "server/shared_lib/*",
23
+ "server/etc/*", "server/tools/*"]
24
+ dotfiles = Dir[".*"]
25
+ s.files = Dir["**/*"].reject {|f| File.directory?(f) || ignores.any? {|i| File.fnmatch(i, f) } } + dotfiles
26
+ s.test_files = s.files.grep(/^test\//)
27
+ s.executables << "glowworm"
28
+
29
+ s.bindir = 'bin'
30
+ s.require_paths = ["lib"]
31
+
32
+ s.add_dependency "ecology", "~>0.0.18"
33
+ s.add_dependency "termite", "~>0.0.13"
34
+ s.add_dependency "httparty"
35
+ s.add_dependency "multi_json"
36
+ s.add_dependency "trollop"
37
+
38
+ s.add_development_dependency "bundler"
39
+ s.add_development_dependency "scope", "~>0.2.1"
40
+ s.add_development_dependency "nodule", "~>0.0.25"
41
+ s.add_development_dependency "mocha"
42
+ s.add_development_dependency "sqlite3"
43
+ s.add_development_dependency "rake"
44
+ s.add_development_dependency "minitap", ">= 0.3.5"
45
+ s.add_development_dependency "sinatra"
46
+ s.add_development_dependency "sequel"
47
+ s.add_development_dependency "mysql"
48
+ s.add_development_dependency "rack-ooyala-headers"
49
+ s.add_development_dependency "yajl-ruby"
50
+ s.add_development_dependency "tapout"
51
+ s.add_development_dependency "sinatra-synchrony"
52
+ s.add_development_dependency "thin"
53
+ s.add_development_dependency "em-net-http"
54
+ end
data/lib/glowworm.rb ADDED
@@ -0,0 +1,501 @@
1
+ require "rubygems"
2
+ require "glowworm/version"
3
+
4
+ require "ecology"
5
+ require "termite"
6
+ require "httparty"
7
+ require "uri"
8
+
9
+ # TODO(noah): convert log warnings to Hastur calls when they're actually stats.
10
+ # Right now our console output is *way* too chatty, but we don't want to lose
11
+ # this information when syslog picks what to save.
12
+
13
+ module Glowworm
14
+ class << self
15
+ attr_accessor :termite_logger
16
+ attr_accessor :thread_mutex
17
+ attr_accessor :cache_mutex
18
+ attr_accessor :ttl
19
+ attr_accessor :timeout
20
+ attr_reader :server
21
+ attr_reader :threadless
22
+
23
+ # Obsoleted on Feb 15, 2012
24
+ attr_reader :warm_on_boot
25
+ attr_reader :silent_failure
26
+
27
+ Glowworm.thread_mutex = Mutex.new
28
+ Glowworm.cache_mutex = Mutex.new
29
+
30
+ def warm_on_boot=(value)
31
+ @warm_on_boot = value
32
+ puts "Glowworm.warm_on_boot is obsolete as of Feb 15, 2012."
33
+ puts "You can just remove the line that assigns it."
34
+ Glowworm.termite_logger.warn("Used obsolete GW.warm_on_boot") if Glowworm.termite_logger
35
+ end
36
+
37
+ def silent_failure=(value)
38
+ @silent_failure = value
39
+ puts "Glowworm.silent_failure is obsolete as of Feb 15, 2012."
40
+ puts "You can just remove the line that assigns it."
41
+ Glowworm.termite_logger.warn("Used obsolete GW.silent_failure") if Glowworm.termite_logger
42
+ end
43
+
44
+ def server=(value)
45
+ @server = value
46
+ return if value.nil?
47
+ unless value[0..4] == "http:"
48
+ @server = "http://#{value}"
49
+ end
50
+ end
51
+
52
+ # Get the value of a single feature for a single account
53
+ # This is equivalent to calling feature_flag with option
54
+ # get_value => true
55
+ #
56
+ # Options: (see feature_flag)
57
+ def feature_value(account, feature, options = {})
58
+ feature_flag(account, feature, options.merge(:get_value => true))
59
+ end
60
+
61
+ # Return the freshness in seconds of this account/feature
62
+ # combination. If nil is returned, we don't have actual server
63
+ # data for this combination.
64
+ #
65
+ # Note that just calling freshness won't check for updates. You
66
+ # need to call prefetch or feature_flag if you want updates.
67
+ #
68
+ def freshness(account, feature, options = {})
69
+ @last_success
70
+ end
71
+
72
+ # Get whether a single feature is enabled for a single account
73
+ #
74
+ # Options:
75
+ # ttl: how long server data stays fresh before re-fetching
76
+ # timeout: how long to wait for a response before returning stale data
77
+ # get_value: true to return actual value rather than true/false
78
+ # defaults to false
79
+ def feature_flag(account, feature, options = {})
80
+ Ecology.read # Ecology must be initialized by now
81
+
82
+ if account.nil? || !account.respond_to?(:to_s)
83
+ raise "You must provide a string-castable account, not #{account.inspect}!"
84
+ end
85
+ if feature.nil? || !feature.respond_to?(:to_s)
86
+ raise "You must provide a string-castable feature, not #{feature.inspect}!"
87
+ end
88
+
89
+ account = account.to_s
90
+ feature = feature.to_s
91
+
92
+ status = fetch_from_server(account, feature, options)
93
+ if status.nil?
94
+ status = 504
95
+ Glowworm.termite_logger.debug "Server didn't respond within #{options[:timeout] || Glowworm.timeout} seconds"
96
+ else
97
+ Glowworm.termite_logger.debug "Received status #{status}"
98
+ end
99
+
100
+ local_account_set_cache = local_feature_cache = local_override_cache = local_fully_active_cache = nil
101
+
102
+ Glowworm.cache_mutex.synchronize do
103
+ # Store references to caches locally within mutex, then use these
104
+ local_account_set_cache = @account_set_cache
105
+ local_feature_cache = @feature_cache
106
+ local_override_cache = @override_cache
107
+ local_fully_active_cache = @fully_active_cache
108
+ end
109
+
110
+ unless local_override_cache && local_account_set_cache && local_feature_cache
111
+ Glowworm.termite_logger.debug "Caches aren't instantiated at all..."
112
+ return options[:default] if options.has_key?(:default)
113
+ Glowworm.termite_logger.error("You don't seem to have ever received any data from #{Glowworm.server}. " +
114
+ "Is Glowworm misconfigured?")
115
+ return false
116
+ end
117
+
118
+ get_value = options[:get_value] || false
119
+
120
+ if local_override_cache[feature] && local_override_cache[feature].has_key?(account)
121
+ return get_value ? local_override_cache[feature][account][:value] :
122
+ local_override_cache[feature][account][:flag]
123
+ end
124
+
125
+ account_set = local_account_set_cache[account]
126
+ if account_set && local_feature_cache[feature]
127
+ value = nil
128
+ account_set.sort.each do |single_set|
129
+ if get_value
130
+ value ||= local_feature_cache[feature][single_set]
131
+ else
132
+ value ||= local_feature_cache[feature].has_key?(single_set)
133
+ end
134
+ end
135
+ else
136
+ # Server has nothing for this account and feature.
137
+ Glowworm.termite_logger.debug "Can't find #{feature} for #{account}"
138
+ end
139
+
140
+ return value unless value.nil?
141
+
142
+ # At this point, there is no information about this feature for this specific account.
143
+ # We fall back to default first, and then true or false depending on if the feature is fully active.
144
+ if local_fully_active_cache && local_fully_active_cache[feature]
145
+ # Currently, fully_active isn't available for get_value
146
+ # features.
147
+ fully_active = local_fully_active_cache[feature]
148
+ end
149
+
150
+ message = "This feature has never been cached for the given account! "
151
+ unless (local_feature_cache || {}).has_key?(feature)
152
+ message += "Feature '#{feature[0...1_000]}' seems not to exist. "
153
+ end
154
+ unless (local_account_set_cache || {}).has_key?(account)
155
+ message += "Account '#{account[0...1_000]}' seems not to exist. "
156
+ end
157
+ (account_set || []).each do |single_set|
158
+ if local_feature_cache[feature].nil? || !local_feature_cache[feature].has_key?(single_set)
159
+ unless single_set.nil?
160
+ message += "There is no data for account set " + single_set.to_s[0...1_000] + " for this feature."
161
+ end
162
+ end
163
+ end
164
+
165
+ Glowworm.termite_logger.warn message
166
+
167
+ return options[:default] if options.has_key?(:default)
168
+
169
+ unless fully_active.nil?
170
+ # Currently, fully_active isn't available for get_value
171
+ # features.
172
+ Glowworm.termite_logger.info("This feature is fully active. Returning true.")
173
+ fully_active
174
+ else
175
+ Glowworm.termite_logger.warn("No default, feature or account never cached: #{message}")
176
+ false
177
+ end
178
+ end
179
+
180
+ # Prefetch feature data from the server if stale.
181
+ #
182
+ # account - an account or :all
183
+ # feature - a feature or :all
184
+ #
185
+ # Options:
186
+ # ttl: how long server data stays fresh
187
+ # timeout: how long to wait on the server response
188
+ # raise: raise an exception if the prefetch fails or times out
189
+ def prefetch(account, feature, options = {})
190
+ Ecology.read # Ecology must be initialized by now
191
+
192
+ account != nil && account.respond_to?(:to_s) or
193
+ raise "You must provide a string-castable account, not #{account.inspect}!"
194
+ feature != nil && feature.respond_to?(:to_s) or
195
+ raise "You must provide a string-castable feature, not #{feature.inspect}!"
196
+
197
+ account = account.to_s
198
+ feature = feature.to_s
199
+
200
+ # By default a prefetch shouldn't wait at all
201
+ options[:timeout] ||= 0.0
202
+
203
+ Glowworm.termite_logger.warn "Glowworm prefetch: '#{account}', '#{feature}'"
204
+ fetch_from_server(account, feature, options)
205
+ end
206
+
207
+ def reset
208
+ @account_set_cache = nil
209
+ @feature_cache = nil
210
+ @override_cache = nil
211
+ @fully_active_cache = nil
212
+ @last_md5 = nil
213
+ @last_fetch_time = nil
214
+ @last_success = nil
215
+ @background_thread = nil
216
+ @last_modified = nil
217
+ @threadless = nil
218
+ @em = nil
219
+
220
+ Glowworm.ttl = nil
221
+ Glowworm.timeout = nil
222
+ Glowworm.server = nil
223
+ Glowworm.termite_logger = nil
224
+ end
225
+
226
+ def all_cached_accounts
227
+ # This accessor already duplicates, so we
228
+ # can just return it.
229
+ @account_set_cache ? @account_set_cache.keys : []
230
+ end
231
+
232
+ def all_cached_features
233
+ # This accessor already duplicates, so we
234
+ # can just return it.
235
+ @feature_cache ? @feature_cache.keys : []
236
+ end
237
+
238
+ def account_set_cache
239
+ # Duplicate the cache so that the caller can't
240
+ # change cache entries - that would be bad.
241
+ @account_set_cache ? @account_set_cache.dup : nil
242
+ end
243
+
244
+ def feature_cache
245
+ # Duplicate the cache so that the caller can't
246
+ # change cache entries - that would be bad.
247
+ @feature_cache ? @feature_cache.dup : nil
248
+ end
249
+
250
+ def override_cache
251
+ # Duplicate the cache so that the caller can't
252
+ # change cache entries - that would be bad.
253
+ @override_cache ? @override_cache.dup : nil
254
+ end
255
+
256
+ def fully_active_cache
257
+ # Duplicate the cache so that the caller can't
258
+ # change cache entries - that would be bad.
259
+ @fully_active_cache ? @fully_active_cache.dup : nil
260
+ end
261
+
262
+ def last_md5
263
+ # Duplicate the md5 so that the caller can't
264
+ # change it - that would be bad.
265
+ @last_md5 ? @last_md5.dup : nil
266
+ end
267
+
268
+ # Sychronously update cache. Raises an error if the update fails.
269
+ # This will wrap in a EM synchrony block if eventmachine is being used, or update regularly otherwise.
270
+ def update_cache_in_foreground
271
+ if @em
272
+ begin
273
+ EM.synchrony do
274
+ _update_cache_in_foreground_
275
+ end
276
+ rescue NameError
277
+ _update_cache_in_foreground_
278
+ rescue NoMethodError
279
+ raise 'Eventmachine is being used without requiring "glowworm/em". Please use this require.'
280
+ end
281
+ else
282
+ _update_cache_in_foreground_
283
+ end
284
+ end
285
+
286
+ def require_em
287
+ begin
288
+ require "em-synchrony"
289
+ require "em-net-http"
290
+ rescue LoadError
291
+ raise "You must have em/sinatra-synchrony AND em-net-http installed for the Glowworm.em to work"
292
+ end
293
+ end
294
+
295
+ # If you are using EM, but can't call "glowworm/em" at require time, then set Glowworm.em
296
+ # from within your fiber.
297
+ def em
298
+ Ecology.read
299
+ Glowworm.timeout = 0
300
+ @em = true
301
+ @threadless = true
302
+ require_em
303
+ update_cache_in_foreground
304
+ end
305
+
306
+ def no_bg
307
+ Ecology.read
308
+ Glowworm.timeout = 0
309
+ @threadless = true
310
+ # This first call should be the EM-free version, as at require time EM.synchrony causes a hang.
311
+ _update_cache_in_foreground_
312
+ end
313
+
314
+ private
315
+
316
+ # This functions is marked private, as it should only be called at require time.
317
+ # This is because the internal _update_cache_in_foreground_ is called, which means that we expect
318
+ # to be in a require-time architecture, and not yet with EM loaded.
319
+ def _em_
320
+ no_bg
321
+ @em = true
322
+ end
323
+
324
+ private
325
+
326
+ def _update_cache_in_foreground_
327
+ account = feature = :all
328
+ url = url_for_account_and_feature(account, feature)
329
+ update_cache(account, feature, url, :foreground => true)
330
+ end
331
+
332
+ def url_for_account_and_feature(account, feature)
333
+ url = Glowworm.server + "/all_if_modified"
334
+ url += "?md5=#{@last_md5}" if @last_md5
335
+ url
336
+ end
337
+
338
+ def fetch_from_server(account, feature, options = {})
339
+ ttl = options[:ttl] || Glowworm.ttl
340
+ timeout = options[:timeout] || Glowworm.timeout
341
+
342
+ # If no background thread, rely on prefetched data
343
+ return 204 if @threadless
344
+
345
+ # Return cached value if it's within TTL
346
+ time_now = Time.now # Call Time.now once for consistency
347
+
348
+ if @last_fetch_time
349
+ freshness = time_now - @last_fetch_time
350
+
351
+ Glowworm.termite_logger.debug "Freshness: #{freshness} seconds"
352
+ if freshness < ttl
353
+ Glowworm.termite_logger.warn "Already fresh: account #{account}, feature: #{feature}," +
354
+ "TTL is #{ttl}"
355
+ return 200
356
+ end
357
+ end
358
+
359
+ Glowworm.cache_mutex.synchronize do
360
+ @account_set_cache ||= {}
361
+ @feature_cache ||= {}
362
+ @override_cache ||= {}
363
+ @fully_active_cache ||= {}
364
+ end
365
+ duration = Time.now - time_now
366
+
367
+ bg_thread = nil
368
+
369
+ Glowworm.thread_mutex.synchronize do
370
+ bg_thread = @background_thread
371
+
372
+ if bg_thread
373
+ Glowworm.termite_logger.debug "Using existing background thread"
374
+ else
375
+ Glowworm.termite_logger.debug "Creating new background thread"
376
+ bg_thread = @background_thread = Thread.new do
377
+ situation = "querying server #{Glowworm.server.inspect}, account #{account}, feature #{feature}"
378
+
379
+ begin
380
+ url = url_for_account_and_feature(account, feature)
381
+ update_cache(account, feature, url)
382
+ Glowworm.termite_logger.warn "Succeeded in getting updated Glowworm feature: '#{account}', '#{feature}'"
383
+ rescue Exception # catch everything
384
+ Glowworm.termite_logger.warn "Exception #{situation}, message '#{$!.message}'"
385
+ Glowworm.termite_logger.info "Exception #{situation}, message '#{$!.message}'," +
386
+ "backtrace '#{$!.backtrace.inspect}'"
387
+
388
+ raise if options[:raise]
389
+ end
390
+ end
391
+ end
392
+ end
393
+
394
+ if bg_thread.join(timeout)
395
+ Glowworm.termite_logger.debug "Successfully joined background thread"
396
+
397
+ status = bg_thread[:last_status]
398
+
399
+ Glowworm.thread_mutex.synchronize { @background_thread = nil if @background_thread == bg_thread }
400
+
401
+ status
402
+ else
403
+ Glowworm.termite_logger.debug "Join returned false after #{timeout} seconds, returning 504"
404
+ 504
405
+ end
406
+ end
407
+
408
+ def update_cache(account, feature, url, options = {})
409
+ situation = "querying server #{url}, all info, checksum #{@last_md5.inspect}"
410
+
411
+ start_time = Time.now
412
+ response = HTTParty.get(url, :headers => { "if-modified-since" => @last_modified || "" })
413
+
414
+ latency = Time.now - start_time
415
+ Glowworm.termite_logger.debug "Request latency: #{latency} seconds"
416
+
417
+ @last_modified = response.headers["last-modified"]
418
+
419
+ if options[:foreground]
420
+ raise "Cache update failed with response code #{response.code}!" unless [200, 204, 304].include? response.code
421
+ else
422
+ Thread.current[:last_status] = response.code
423
+ end
424
+ @last_fetch_time = Time.now
425
+ if response.code >= 200 && response.code < 300
426
+ Glowworm.termite_logger.info "Success #{situation}, response code #{response.code}, response size #{response.body.size}",
427
+ {}, :component => "glowworm"
428
+ refresh_cache_from_response(response.body)
429
+ @last_success = @last_fetch_time
430
+ elsif response.code >= 300 && response.code < 400
431
+ # Normally this is a 304 unmodified - no update
432
+ @last_success = @last_fetch_time
433
+ Glowworm.termite_logger.info "Up to date when #{situation}, response code #{response.code}"
434
+ elsif response.code >= 400 && response.code < 500
435
+ # This is a 404 or similar - not found.
436
+ Glowworm.termite_logger.warn("Failure #{situation}, response code #{response.code}, not found",
437
+ {}, :component => "glowworm")
438
+ else
439
+ Glowworm.termite_logger.warn("Failure #{situation}, response code #{response.code}",
440
+ {}, :component => "glowworm")
441
+ end
442
+ end
443
+
444
+ def refresh_cache_from_response(response)
445
+ # In order to keep the amount of mutex-locked code to a minimum, we will pre-construct the hashes locally,
446
+ # and then populate the caches with these local variables within a mutex
447
+ data = MultiJson.load(response)
448
+
449
+ raise "Wrong Glowworm protocol version!" unless data["version"] == 1
450
+
451
+ local_account_set_cache = {}
452
+ (data["account_sets"] || {}).each do |account, account_set|
453
+ local_account_set_cache[account] = account_set.map(&:to_i)
454
+ end
455
+
456
+ local_feature_cache = {}
457
+ (data["features"] || {}).each do |feature, account_sets|
458
+ local_feature_cache[feature] = {}
459
+ account_sets.each do |account_set, value|
460
+ # Store the value of this account_set for this feature
461
+ local_feature_cache[feature][account_set.to_i] = value
462
+ end
463
+ end
464
+
465
+ local_override_cache = {}
466
+ (data["overrides"] || {}).each do |feature, overrides|
467
+ local_override_cache[feature] = {}
468
+ overrides.each do |account, value|
469
+ local_override_cache[feature][account] = { :flag => value != 0, :value => value }
470
+ end
471
+ end
472
+
473
+ local_fully_active_cache = {}
474
+ (data["fully_active"] || {}).each do |feature, fully_active|
475
+ local_fully_active_cache[feature] = fully_active
476
+ end
477
+
478
+ local_last_md5 = data["md5"]
479
+ Glowworm.cache_mutex.synchronize do
480
+ @account_set_cache = local_account_set_cache
481
+ @feature_cache = local_feature_cache
482
+ @override_cache = local_override_cache
483
+ @fully_active_cache = local_fully_active_cache
484
+ @last_md5 = local_last_md5
485
+ end
486
+ end
487
+
488
+ Ecology.on_initialize do
489
+ Glowworm.termite_logger ||= Termite::Logger.new(:component => "glowworm", :use_logger_prefix => true)
490
+ Glowworm.ttl ||= Ecology.property("features:ttl") || Ecology.property("features:refresh") || 300
491
+ Glowworm.timeout ||= Ecology.property("features:timeout") || 0.1
492
+ Glowworm.server ||= ENV["GW_URI"] || (Ecology.property("features:server") || "http://localhost:4999")
493
+ end
494
+
495
+ # When Ecology.reset is called, reset Glowworm too.
496
+ # Usually this is for testing.
497
+ Ecology.on_reset do
498
+ Glowworm.reset
499
+ end
500
+ end
501
+ end