ctcherry-starling 0.9.10

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.
@@ -0,0 +1,125 @@
1
+ require 'socket'
2
+ require 'logger'
3
+ require 'rubygems'
4
+ require 'eventmachine'
5
+
6
+ here = File.dirname(__FILE__)
7
+
8
+ require File.join(here, 'queue_collection')
9
+ require File.join(here, 'handler')
10
+
11
+ module StarlingServer
12
+
13
+ VERSION = "0.9.9"
14
+
15
+ class Base
16
+ attr_reader :logger
17
+
18
+ DEFAULT_HOST = '127.0.0.1'
19
+ DEFAULT_PORT = 22122
20
+ DEFAULT_PATH = "/tmp/starling/"
21
+ DEFAULT_TIMEOUT = 60
22
+
23
+ ##
24
+ # Initialize a new Starling server and immediately start processing
25
+ # requests.
26
+ #
27
+ # +opts+ is an optional hash, whose valid options are:
28
+ #
29
+ # [:host] Host on which to listen (default is 127.0.0.1).
30
+ # [:port] Port on which to listen (default is 22122).
31
+ # [:path] Path to Starling queue logs. Default is /tmp/starling/
32
+ # [:timeout] Time in seconds to wait before closing connections.
33
+ # [:logger] A Logger object, an IO handle, or a path to the log.
34
+ # [:loglevel] Logger verbosity. Default is Logger::ERROR.
35
+ #
36
+ # Other options are ignored.
37
+
38
+ def self.start(opts = {})
39
+ server = self.new(opts)
40
+ server.run
41
+ end
42
+
43
+ ##
44
+ # Initialize a new Starling server, but do not accept connections or
45
+ # process requests.
46
+ #
47
+ # +opts+ is as for +start+
48
+
49
+ def initialize(opts = {})
50
+ @opts = {
51
+ :host => DEFAULT_HOST,
52
+ :port => DEFAULT_PORT,
53
+ :path => DEFAULT_PATH,
54
+ :timeout => DEFAULT_TIMEOUT,
55
+ :server => self
56
+ }.merge(opts)
57
+
58
+ @stats = Hash.new(0)
59
+
60
+ FileUtils.mkdir_p(@opts[:path])
61
+
62
+ end
63
+
64
+ ##
65
+ # Start listening and processing requests.
66
+
67
+ def run
68
+ @stats[:start_time] = Time.now
69
+
70
+ if @opts[:syslog_channel]
71
+ begin
72
+ require 'syslog_logger'
73
+ @@logger = SyslogLogger.new(@opts[:syslog_channel])
74
+ rescue LoadError
75
+ # SyslogLogger isn't available, so we're just going to use Logger
76
+ end
77
+ end
78
+
79
+ @@logger ||= case @opts[:logger]
80
+ when IO, String; Logger.new(@opts[:logger])
81
+ when Logger; @opts[:logger]
82
+ else; Logger.new(STDERR)
83
+ end
84
+
85
+ begin
86
+ @opts[:queue] = QueueCollection.new(@opts[:path])
87
+ rescue InaccessibleQueuePath => e
88
+ puts "Error: #{e.message}"
89
+ exit 1
90
+ end
91
+ @@logger.level = @opts[:log_level] || Logger::ERROR
92
+
93
+ @@logger.info "Starling STARTUP on #{@opts[:host]}:#{@opts[:port]}"
94
+
95
+ EventMachine.epoll
96
+ EventMachine.set_descriptor_table_size(4096)
97
+ EventMachine.run do
98
+ EventMachine.start_server(@opts[:host], @opts[:port], Handler, @opts)
99
+ end
100
+
101
+ # code here will get executed on shutdown:
102
+ @opts[:queue].close
103
+ end
104
+
105
+ def self.logger
106
+ @@logger
107
+ end
108
+
109
+
110
+ ##
111
+ # Stop accepting new connections and shutdown gracefully.
112
+
113
+ def stop
114
+ EventMachine.stop_event_loop
115
+ end
116
+
117
+ def stats(stat = nil) #:nodoc:
118
+ case stat
119
+ when nil; @stats
120
+ when :connections; 1
121
+ else; @stats[stat]
122
+ end
123
+ end
124
+ end
125
+ end
@@ -0,0 +1,317 @@
1
+ require File.join(File.dirname(__FILE__), 'server')
2
+ require 'erb'
3
+ require 'fileutils'
4
+ require 'optparse'
5
+ require 'yaml'
6
+ require 'fileutils'
7
+
8
+ module StarlingServer
9
+ class Runner
10
+
11
+ attr_accessor :options
12
+ private :options, :options=
13
+
14
+ def self.run
15
+ new
16
+ end
17
+
18
+ def self.shutdown
19
+ @@instance.shutdown
20
+ end
21
+
22
+ def initialize
23
+ @@instance = self
24
+ parse_options
25
+
26
+ @process = ProcessHelper.new(options[:logger], options[:pid_file], options[:user], options[:group])
27
+
28
+ pid = @process.running?
29
+ if pid
30
+ STDERR.puts "There is already a Starling process running (pid #{pid}), exiting."
31
+ exit(1)
32
+ elsif pid.nil?
33
+ STDERR.puts "Cleaning up stale pidfile at #{options[:pid_file]}."
34
+ end
35
+
36
+ start
37
+ end
38
+
39
+ def load_config_file(filename)
40
+ config = YAML.load(ERB.new(File.read(filename)).result)
41
+
42
+ unless config.is_a?(Hash)
43
+ STDERR.puts "Config file does not contain a hash: #{filename}, exiting."
44
+ exit(1)
45
+ end
46
+
47
+ if config['starling'].nil?
48
+ STDERR.puts "Missing starling section in config file: #{filename}, exiting."
49
+ exit(1)
50
+ end
51
+
52
+ config['starling'].each do |key, value|
53
+ # alias some keys
54
+ case key
55
+ when "queue_path" then key = "path"
56
+ when "log_file" then key = "logger"
57
+ end
58
+
59
+ if %w(logger path pid_file).include?(key)
60
+ value = File.expand_path(value)
61
+ end
62
+
63
+ options[key.to_sym] = value
64
+
65
+ if options[:log_level].instance_of?(String)
66
+ options[:log_level] = Logger.const_get(options[:log_level])
67
+ end
68
+ end
69
+ end
70
+
71
+ def parse_options
72
+ self.options = { :host => '127.0.0.1',
73
+ :port => 22122,
74
+ :path => File.join('', 'var', 'spool', 'starling'),
75
+ :log_level => Logger::INFO,
76
+ :daemonize => false,
77
+ :timeout => 0,
78
+ :pid_file => File.join('', 'var', 'run', 'starling.pid') }
79
+
80
+ OptionParser.new do |opts|
81
+ opts.summary_width = 25
82
+
83
+ opts.banner = "Starling (#{StarlingServer::VERSION})\n\n",
84
+ "usage: starling [options...]\n",
85
+ " starling --help\n",
86
+ " starling --version\n"
87
+
88
+ opts.separator ""
89
+ opts.separator "Configuration:"
90
+
91
+ opts.on("-f", "--config FILENAME",
92
+ "Config file (yaml) to load") do |filename|
93
+ load_config_file(filename)
94
+ end
95
+
96
+ opts.on("-q", "--queue_path PATH",
97
+ :REQUIRED,
98
+ "Path to store Starling queue logs", "(default: #{options[:path]})") do |queue_path|
99
+ options[:path] = File.expand_path(queue_path)
100
+ end
101
+
102
+ opts.separator ""; opts.separator "Network:"
103
+
104
+ opts.on("-hHOST", "--host HOST", "Interface on which to listen (default: #{options[:host]})") do |host|
105
+ options[:host] = host
106
+ end
107
+
108
+ opts.on("-pHOST", "--port PORT", Integer, "TCP port on which to listen (default: #{options[:port]})") do |port|
109
+ options[:port] = port
110
+ end
111
+
112
+ opts.separator ""; opts.separator "Process:"
113
+
114
+ opts.on("-d", "Run as a daemon.") do
115
+ options[:daemonize] = true
116
+ end
117
+
118
+ opts.on("-PFILE", "--pid FILENAME", "save PID in FILENAME when using -d option.", "(default: #{options[:pid_file]})") do |pid_file|
119
+ options[:pid_file] = File.expand_path(pid_file)
120
+ end
121
+
122
+ opts.on("-u", "--user USER", "User to run as") do |user|
123
+ options[:user] = user.to_i == 0 ? Etc.getpwnam(user).uid : user.to_i
124
+ end
125
+
126
+ opts.on("-gGROUP", "--group GROUP", "Group to run as") do |group|
127
+ options[:group] = group.to_i == 0 ? Etc.getgrnam(group).gid : group.to_i
128
+ end
129
+
130
+ opts.separator ""; opts.separator "Logging:"
131
+
132
+ opts.on("-L", "--log [FILE]", "Path to print debugging information.") do |log_path|
133
+ options[:logger] = File.expand_path(log_path)
134
+ end
135
+
136
+ begin
137
+ require 'syslog_logger'
138
+
139
+ opts.on("-l", "--syslog CHANNEL", "Write logs to the syslog instead of a log file.") do |channel|
140
+ options[:syslog_channel] = channel
141
+ end
142
+ rescue LoadError
143
+ end
144
+
145
+ opts.on("-v", "Increase logging verbosity (may be used multiple times).") do
146
+ options[:log_level] -= 1
147
+ end
148
+
149
+ opts.on("-t", "--timeout [SECONDS]", Integer,
150
+ "Time in seconds before disconnecting inactive clients (0 to disable).",
151
+ "(default: #{options[:timeout]})") do |timeout|
152
+ options[:timeout] = timeout
153
+ end
154
+
155
+ opts.separator ""; opts.separator "Miscellaneous:"
156
+
157
+ opts.on_tail("-?", "--help", "Display this usage information.") do
158
+ puts "#{opts}\n"
159
+ exit
160
+ end
161
+
162
+ opts.on_tail("-V", "--version", "Print version number and exit.") do
163
+ puts "Starling #{StarlingServer::VERSION}\n\n"
164
+ exit
165
+ end
166
+ end.parse!
167
+ end
168
+
169
+ def start
170
+ drop_privileges
171
+
172
+ @process.daemonize if options[:daemonize]
173
+
174
+ setup_signal_traps
175
+ @process.write_pid_file
176
+
177
+ STDOUT.puts "Starting at #{options[:host]}:#{options[:port]}."
178
+ @server = StarlingServer::Base.new(options)
179
+ @server.run
180
+
181
+ @process.remove_pid_file
182
+ end
183
+
184
+ def drop_privileges
185
+ Process.egid = options[:group] if options[:group]
186
+ Process.euid = options[:user] if options[:user]
187
+ end
188
+
189
+ def shutdown
190
+ begin
191
+ STDOUT.puts "Shutting down."
192
+ StarlingServer::Base.logger.info "Shutting down."
193
+ @server.stop
194
+ rescue Object => e
195
+ STDERR.puts "There was an error shutting down: #{e}"
196
+ exit(70)
197
+ end
198
+ end
199
+
200
+ def setup_signal_traps
201
+ Signal.trap("INT") { shutdown }
202
+ Signal.trap("TERM") { shutdown }
203
+ end
204
+ end
205
+
206
+ class ProcessHelper
207
+
208
+ def initialize(log_file = nil, pid_file = nil, user = nil, group = nil)
209
+ @log_file = log_file
210
+ @pid_file = pid_file
211
+ @user = user
212
+ @group = group
213
+ end
214
+
215
+ def safefork
216
+ begin
217
+ if pid = fork
218
+ return pid
219
+ end
220
+ rescue Errno::EWOULDBLOCK
221
+ sleep 5
222
+ retry
223
+ end
224
+ end
225
+
226
+ def daemonize
227
+ sess_id = detach_from_terminal
228
+ exit if pid = safefork
229
+
230
+ Dir.chdir("/")
231
+ File.umask 0000
232
+
233
+ close_io_handles
234
+ redirect_io
235
+
236
+ return sess_id
237
+ end
238
+
239
+ def detach_from_terminal
240
+ srand
241
+ safefork and exit
242
+
243
+ unless sess_id = Process.setsid
244
+ raise "Couldn't detach from controlling terminal."
245
+ end
246
+
247
+ trap 'SIGHUP', 'IGNORE'
248
+
249
+ sess_id
250
+ end
251
+
252
+ def close_io_handles
253
+ ObjectSpace.each_object(IO) do |io|
254
+ unless [STDIN, STDOUT, STDERR].include?(io)
255
+ begin
256
+ io.close unless io.closed?
257
+ rescue Exception
258
+ end
259
+ end
260
+ end
261
+ end
262
+
263
+ def redirect_io
264
+ begin; STDIN.reopen('/dev/null'); rescue Exception; end
265
+
266
+ if @log_file
267
+ begin
268
+ STDOUT.reopen(@log_file, "a")
269
+ STDOUT.sync = true
270
+ rescue Exception
271
+ begin; STDOUT.reopen('/dev/null'); rescue Exception; end
272
+ end
273
+ else
274
+ begin; STDOUT.reopen('/dev/null'); rescue Exception; end
275
+ end
276
+
277
+ begin; STDERR.reopen(STDOUT); rescue Exception; end
278
+ STDERR.sync = true
279
+ end
280
+
281
+ def rescue_exception
282
+ begin
283
+ yield
284
+ rescue Exception
285
+ end
286
+ end
287
+
288
+ def write_pid_file
289
+ return unless @pid_file
290
+ FileUtils.mkdir_p(File.dirname(@pid_file))
291
+ File.open(@pid_file, "w") { |f| f.write(Process.pid) }
292
+ File.chmod(0644, @pid_file)
293
+ end
294
+
295
+ def remove_pid_file
296
+ return unless @pid_file
297
+ File.unlink(@pid_file) if File.exists?(@pid_file)
298
+ end
299
+
300
+ def running?
301
+ return false unless @pid_file
302
+
303
+ pid = File.read(@pid_file).chomp.to_i rescue nil
304
+ pid = nil if pid == 0
305
+ return false unless pid
306
+
307
+ begin
308
+ Process.kill(0, pid)
309
+ return pid
310
+ rescue Errno::ESRCH
311
+ return nil
312
+ rescue Errno::EPERM
313
+ return pid
314
+ end
315
+ end
316
+ end
317
+ end
@@ -0,0 +1,216 @@
1
+ $:.unshift(File.join(File.dirname(__FILE__), "..", "lib"))
2
+
3
+ require 'rubygems'
4
+ require 'fileutils'
5
+ require 'memcache'
6
+ require 'digest/md5'
7
+ require 'starling'
8
+
9
+ require 'starling/server'
10
+
11
+ class StarlingServer::PersistentQueue
12
+ remove_const :SOFT_LOG_MAX_SIZE
13
+ SOFT_LOG_MAX_SIZE = 16 * 1024 # 16 KB
14
+ end
15
+
16
+ def safely_fork(&block)
17
+ # anti-race juice:
18
+ blocking = true
19
+ Signal.trap("USR1") { blocking = false }
20
+
21
+ pid = Process.fork(&block)
22
+
23
+ while blocking
24
+ sleep 0.1
25
+ end
26
+
27
+ pid
28
+ end
29
+
30
+ describe "StarlingServer" do
31
+ before do
32
+ @tmp_path = File.join(File.dirname(__FILE__), "tmp")
33
+
34
+ begin
35
+ Dir::mkdir(@tmp_path)
36
+ rescue Errno::EEXIST
37
+ end
38
+
39
+ @server_pid = safely_fork do
40
+ server = StarlingServer::Base.new(:host => '127.0.0.1',
41
+ :port => 22133,
42
+ :path => @tmp_path,
43
+ :logger => Logger.new(STDERR),
44
+ :log_level => Logger::FATAL)
45
+ Signal.trap("INT") {
46
+ server.stop
47
+ exit
48
+ }
49
+
50
+ Process.kill("USR1", Process.ppid)
51
+ server.run
52
+ end
53
+
54
+ @client = MemCache.new('127.0.0.1:22133')
55
+
56
+ end
57
+
58
+ it "should test if temp_path exists and is writeable" do
59
+ File.exist?(@tmp_path).should be_true
60
+ File.directory?(@tmp_path).should be_true
61
+ File.writable?(@tmp_path).should be_true
62
+ end
63
+
64
+ it "should set and get" do
65
+ v = rand((2**32)-1)
66
+ @client.get('test_set_and_get_one_entry').should be_nil
67
+ @client.set('test_set_and_get_one_entry', v)
68
+ @client.get('test_set_and_get_one_entry').should eql(v)
69
+ end
70
+
71
+ it "should respond to delete" do
72
+ @client.delete("my_queue").should eql("END\r\n")
73
+ starling_client = Starling.new('127.0.0.1:22133')
74
+ starling_client.set('my_queue', 50)
75
+ starling_client.available_queues.size.should eql(1)
76
+ starling_client.delete("my_queue")
77
+ starling_client.available_queues.size.should eql(0)
78
+ end
79
+
80
+ it "should expire entries" do
81
+ v = rand((2**32)-1)
82
+ @client.get('test_set_with_expiry').should be_nil
83
+ now = Time.now.to_i
84
+ @client.set('test_set_with_expiry', v + 2, now)
85
+ @client.set('test_set_with_expiry', v)
86
+ sleep(1.0)
87
+ @client.get('test_set_with_expiry').should eql(v)
88
+ end
89
+
90
+ it "should have age stat" do
91
+ now = Time.now.to_i
92
+ @client.set('test_age', 'nibbler')
93
+ sleep(1.0)
94
+ @client.get('test_age').should eql('nibbler')
95
+
96
+ stats = @client.stats['127.0.0.1:22133']
97
+ stats.has_key?('queue_test_age_age').should be_true
98
+ (stats['queue_test_age_age'] >= 1000).should be_true
99
+ end
100
+
101
+ it "should rotate log" do
102
+ log_rotation_path = File.join(@tmp_path, 'test_log_rotation')
103
+
104
+ Dir.glob("#{log_rotation_path}*").each do |file|
105
+ File.unlink(file) rescue nil
106
+ end
107
+ @client.get('test_log_rotation').should be_nil
108
+
109
+ v = 'x' * 8192
110
+
111
+ @client.set('test_log_rotation', v)
112
+ File.size(log_rotation_path).should eql(8207)
113
+ @client.get('test_log_rotation')
114
+
115
+ @client.get('test_log_rotation').should be_nil
116
+
117
+ @client.set('test_log_rotation', v)
118
+ @client.get('test_log_rotation').should eql(v)
119
+
120
+ File.size(log_rotation_path).should eql(1)
121
+ # rotated log should be erased after a successful roll.
122
+ Dir.glob("#{log_rotation_path}*").size.should eql(1)
123
+ end
124
+
125
+ it "should output statistics per server" do
126
+ stats = @client.stats
127
+ assert_kind_of Hash, stats
128
+ assert stats.has_key?('127.0.0.1:22133')
129
+
130
+ server_stats = stats['127.0.0.1:22133']
131
+
132
+ basic_stats = %w( bytes pid time limit_maxbytes cmd_get version
133
+ bytes_written cmd_set get_misses total_connections
134
+ curr_connections curr_items uptime get_hits total_items
135
+ rusage_system rusage_user bytes_read )
136
+
137
+ basic_stats.each do |stat|
138
+ server_stats.has_key?(stat).should be_true
139
+ end
140
+ end
141
+
142
+ it "should return valid response with unkown command" do
143
+ response = @client.add('blah', 1)
144
+ response.should eql("CLIENT_ERROR bad command line format\r\n")
145
+ end
146
+
147
+ it "should disconnect and reconnect again" do
148
+ v = rand(2**32-1)
149
+ @client.set('test_that_disconnecting_and_reconnecting_works', v)
150
+ @client.reset
151
+ @client.get('test_that_disconnecting_and_reconnecting_works').should eql(v)
152
+ end
153
+
154
+ it "should use epoll on linux" do
155
+ # this may take a few seconds.
156
+ # the point is to make sure that we're using epoll on Linux, so we can
157
+ # handle more than 1024 connections.
158
+
159
+ unless IO::popen("uname").read.chomp == "Linux"
160
+ pending "skipping epoll test: not on Linux"
161
+ end
162
+
163
+ fd_limit = IO::popen("bash -c 'ulimit -n'").read.chomp.to_i
164
+ unless fd_limit > 1024
165
+ pending "skipping epoll test: 'ulimit -n' = #{fd_limit}, need > 1024"
166
+ end
167
+
168
+ v = rand(2**32 - 1)
169
+ @client.set('test_epoll', v)
170
+
171
+ # we can't open 1024 connections to memcache from within this process,
172
+ # because we will hit ruby's 1024 fd limit ourselves!
173
+ pid1 = safely_fork do
174
+ unused_sockets = []
175
+ 600.times do
176
+ unused_sockets << TCPSocket.new("127.0.0.1", 22133)
177
+ end
178
+ Process.kill("USR1", Process.ppid)
179
+ sleep 90
180
+ end
181
+ pid2 = safely_fork do
182
+ unused_sockets = []
183
+ 600.times do
184
+ unused_sockets << TCPSocket.new("127.0.0.1", 22133)
185
+ end
186
+ Process.kill("USR1", Process.ppid)
187
+ sleep 90
188
+ end
189
+
190
+ begin
191
+ client = MemCache.new('127.0.0.1:22133')
192
+ client.get('test_epoll').should eql(v)
193
+ ensure
194
+ Process.kill("TERM", pid1)
195
+ Process.kill("TERM", pid2)
196
+ end
197
+ end
198
+
199
+ it "should raise error if queue collection is an invalid path" do
200
+ invalid_path = nil
201
+ while invalid_path.nil? || File.exist?(invalid_path)
202
+ invalid_path = File.join('/', Digest::MD5.hexdigest(rand(2**32-1).to_s)[0,8])
203
+ end
204
+
205
+ lambda {
206
+ StarlingServer::QueueCollection.new(invalid_path)
207
+ }.should raise_error(StarlingServer::InaccessibleQueuePath)
208
+ end
209
+
210
+ after do
211
+ Process.kill("INT", @server_pid)
212
+ Process.wait(@server_pid)
213
+ @client.reset
214
+ FileUtils.rm(Dir.glob(File.join(@tmp_path, '*')))
215
+ end
216
+ end