ryana-starling 0.9.8

Sign up to get free protection for your applications and to get access to all the features.
@@ -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.8"
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,315 @@
1
+ require File.join(File.dirname(__FILE__), 'server')
2
+ require 'optparse'
3
+ require 'yaml'
4
+ require 'fileutils'
5
+
6
+ module StarlingServer
7
+ class Runner
8
+
9
+ attr_accessor :options
10
+ private :options, :options=
11
+
12
+ def self.run
13
+ new
14
+ end
15
+
16
+ def self.shutdown
17
+ @@instance.shutdown
18
+ end
19
+
20
+ def initialize
21
+ @@instance = self
22
+ parse_options
23
+
24
+ @process = ProcessHelper.new(options[:logger], options[:pid_file], options[:user], options[:group])
25
+
26
+ pid = @process.running?
27
+ if pid
28
+ STDERR.puts "There is already a Starling process running (pid #{pid}), exiting."
29
+ exit(1)
30
+ elsif pid.nil?
31
+ STDERR.puts "Cleaning up stale pidfile at #{options[:pid_file]}."
32
+ end
33
+
34
+ start
35
+ end
36
+
37
+ def load_config_file(filename)
38
+ config = YAML.load(File.open(filename))
39
+
40
+ unless config.is_a?(Hash)
41
+ STDERR.puts "Config file does not contain a hash: #{filename}, exiting."
42
+ exit(1)
43
+ end
44
+
45
+ if config['starling'].nil?
46
+ STDERR.puts "Missing starling section in config file: #{filename}, exiting."
47
+ exit(1)
48
+ end
49
+
50
+ config['starling'].each do |key, value|
51
+ # alias some keys
52
+ case key
53
+ when "queue_path" then key = "path"
54
+ when "log_file" then key = "logger"
55
+ end
56
+
57
+ if %w(logger path pid_file).include?(key)
58
+ value = File.expand_path(value)
59
+ end
60
+
61
+ options[key.to_sym] = value
62
+
63
+ if options[:log_level].instance_of?(String)
64
+ options[:log_level] = Logger.const_get(options[:log_level])
65
+ end
66
+ end
67
+ end
68
+
69
+ def parse_options
70
+ self.options = { :host => '127.0.0.1',
71
+ :port => 22122,
72
+ :path => File.join('', 'var', 'spool', 'starling'),
73
+ :log_level => Logger::INFO,
74
+ :daemonize => false,
75
+ :timeout => 0,
76
+ :pid_file => File.join('', 'var', 'run', 'starling.pid') }
77
+
78
+ OptionParser.new do |opts|
79
+ opts.summary_width = 25
80
+
81
+ opts.banner = "Starling (#{StarlingServer::VERSION})\n\n",
82
+ "usage: starling [options...]\n",
83
+ " starling --help\n",
84
+ " starling --version\n"
85
+
86
+ opts.separator ""
87
+ opts.separator "Configuration:"
88
+
89
+ opts.on("-f", "--config FILENAME",
90
+ "Config file (yaml) to load") do |filename|
91
+ load_config_file(filename)
92
+ end
93
+
94
+ opts.on("-q", "--queue_path PATH",
95
+ :REQUIRED,
96
+ "Path to store Starling queue logs", "(default: #{options[:path]})") do |queue_path|
97
+ options[:path] = File.expand_path(queue_path)
98
+ end
99
+
100
+ opts.separator ""; opts.separator "Network:"
101
+
102
+ opts.on("-hHOST", "--host HOST", "Interface on which to listen (default: #{options[:host]})") do |host|
103
+ options[:host] = host
104
+ end
105
+
106
+ opts.on("-pHOST", "--port PORT", Integer, "TCP port on which to listen (default: #{options[:port]})") do |port|
107
+ options[:port] = port
108
+ end
109
+
110
+ opts.separator ""; opts.separator "Process:"
111
+
112
+ opts.on("-d", "Run as a daemon.") do
113
+ options[:daemonize] = true
114
+ end
115
+
116
+ opts.on("-PFILE", "--pid FILENAME", "save PID in FILENAME when using -d option.", "(default: #{options[:pid_file]})") do |pid_file|
117
+ options[:pid_file] = File.expand_path(pid_file)
118
+ end
119
+
120
+ opts.on("-u", "--user USER", "User to run as") do |user|
121
+ options[:user] = user.to_i == 0 ? Etc.getpwnam(user).uid : user.to_i
122
+ end
123
+
124
+ opts.on("-gGROUP", "--group GROUP", "Group to run as") do |group|
125
+ options[:group] = group.to_i == 0 ? Etc.getgrnam(group).gid : group.to_i
126
+ end
127
+
128
+ opts.separator ""; opts.separator "Logging:"
129
+
130
+ opts.on("-L", "--log [FILE]", "Path to print debugging information.") do |log_path|
131
+ options[:logger] = File.expand_path(log_path)
132
+ end
133
+
134
+ begin
135
+ require 'syslog_logger'
136
+
137
+ opts.on("-l", "--syslog CHANNEL", "Write logs to the syslog instead of a log file.") do |channel|
138
+ options[:syslog_channel] = channel
139
+ end
140
+ rescue LoadError
141
+ end
142
+
143
+ opts.on("-v", "Increase logging verbosity (may be used multiple times).") do
144
+ options[:log_level] -= 1
145
+ end
146
+
147
+ opts.on("-t", "--timeout [SECONDS]", Integer,
148
+ "Time in seconds before disconnecting inactive clients (0 to disable).",
149
+ "(default: #{options[:timeout]})") do |timeout|
150
+ options[:timeout] = timeout
151
+ end
152
+
153
+ opts.separator ""; opts.separator "Miscellaneous:"
154
+
155
+ opts.on_tail("-?", "--help", "Display this usage information.") do
156
+ puts "#{opts}\n"
157
+ exit
158
+ end
159
+
160
+ opts.on_tail("-V", "--version", "Print version number and exit.") do
161
+ puts "Starling #{StarlingServer::VERSION}\n\n"
162
+ exit
163
+ end
164
+ end.parse!
165
+ end
166
+
167
+ def start
168
+ drop_privileges
169
+
170
+ @process.daemonize if options[:daemonize]
171
+
172
+ setup_signal_traps
173
+ @process.write_pid_file
174
+
175
+ STDOUT.puts "Starting at #{options[:host]}:#{options[:port]}."
176
+ @server = StarlingServer::Base.new(options)
177
+ @server.run
178
+
179
+ @process.remove_pid_file
180
+ end
181
+
182
+ def drop_privileges
183
+ Process.egid = options[:group] if options[:group]
184
+ Process.euid = options[:user] if options[:user]
185
+ end
186
+
187
+ def shutdown
188
+ begin
189
+ STDOUT.puts "Shutting down."
190
+ StarlingServer::Base.logger.info "Shutting down."
191
+ @server.stop
192
+ rescue Object => e
193
+ STDERR.puts "There was an error shutting down: #{e}"
194
+ exit(70)
195
+ end
196
+ end
197
+
198
+ def setup_signal_traps
199
+ Signal.trap("INT") { shutdown }
200
+ Signal.trap("TERM") { shutdown }
201
+ end
202
+ end
203
+
204
+ class ProcessHelper
205
+
206
+ def initialize(log_file = nil, pid_file = nil, user = nil, group = nil)
207
+ @log_file = log_file
208
+ @pid_file = pid_file
209
+ @user = user
210
+ @group = group
211
+ end
212
+
213
+ def safefork
214
+ begin
215
+ if pid = fork
216
+ return pid
217
+ end
218
+ rescue Errno::EWOULDBLOCK
219
+ sleep 5
220
+ retry
221
+ end
222
+ end
223
+
224
+ def daemonize
225
+ sess_id = detach_from_terminal
226
+ exit if pid = safefork
227
+
228
+ Dir.chdir("/")
229
+ File.umask 0000
230
+
231
+ close_io_handles
232
+ redirect_io
233
+
234
+ return sess_id
235
+ end
236
+
237
+ def detach_from_terminal
238
+ srand
239
+ safefork and exit
240
+
241
+ unless sess_id = Process.setsid
242
+ raise "Couldn't detach from controlling terminal."
243
+ end
244
+
245
+ trap 'SIGHUP', 'IGNORE'
246
+
247
+ sess_id
248
+ end
249
+
250
+ def close_io_handles
251
+ ObjectSpace.each_object(IO) do |io|
252
+ unless [STDIN, STDOUT, STDERR].include?(io)
253
+ begin
254
+ io.close unless io.closed?
255
+ rescue Exception
256
+ end
257
+ end
258
+ end
259
+ end
260
+
261
+ def redirect_io
262
+ begin; STDIN.reopen('/dev/null'); rescue Exception; end
263
+
264
+ if @log_file
265
+ begin
266
+ STDOUT.reopen(@log_file, "a")
267
+ STDOUT.sync = true
268
+ rescue Exception
269
+ begin; STDOUT.reopen('/dev/null'); rescue Exception; end
270
+ end
271
+ else
272
+ begin; STDOUT.reopen('/dev/null'); rescue Exception; end
273
+ end
274
+
275
+ begin; STDERR.reopen(STDOUT); rescue Exception; end
276
+ STDERR.sync = true
277
+ end
278
+
279
+ def rescue_exception
280
+ begin
281
+ yield
282
+ rescue Exception
283
+ end
284
+ end
285
+
286
+ def write_pid_file
287
+ return unless @pid_file
288
+ FileUtils.mkdir_p(File.dirname(@pid_file))
289
+ File.open(@pid_file, "w") { |f| f.write(Process.pid) }
290
+ File.chmod(0644, @pid_file)
291
+ end
292
+
293
+ def remove_pid_file
294
+ return unless @pid_file
295
+ File.unlink(@pid_file) if File.exists?(@pid_file)
296
+ end
297
+
298
+ def running?
299
+ return false unless @pid_file
300
+
301
+ pid = File.read(@pid_file).chomp.to_i rescue nil
302
+ pid = nil if pid == 0
303
+ return false unless pid
304
+
305
+ begin
306
+ Process.kill(0, pid)
307
+ return pid
308
+ rescue Errno::ESRCH
309
+ return nil
310
+ rescue Errno::EPERM
311
+ return pid
312
+ end
313
+ end
314
+ end
315
+ 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