ctcherry-starling 0.9.10

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.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