kim-toms-starling 1.0.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,239 @@
1
+ module StarlingServer
2
+
3
+ ##
4
+ # This is an internal class that's used by Starling::Server to handle the
5
+ # MemCache protocol and act as an interface between the Server and the
6
+ # QueueCollection.
7
+
8
+ class Handler < EventMachine::Connection
9
+
10
+ DATA_PACK_FMT = "Ia*".freeze
11
+
12
+ # ERROR responses
13
+ ERR_UNKNOWN_COMMAND = "CLIENT_ERROR bad command line format\r\n".freeze
14
+
15
+ CMD_RE = /\A(g)et\s+([!-~]{1,250})\s{0,}\r\n\Z|\A(s)et\s+([!-~]{1,250})\s+(\d+)\s+(\d+)\s+(\d+)\s{0,}(noreply)?\s{0,}\r\n\Z|\A(d)elete\s+([!-~]{1,250})\s+(\d+)\s{0,}\r\n\Z|\As(t)ats\s{0,}\r\n\Z|\As(h)utdown\s{0,}\r\n\Z|\A(q)uit\s{0,}\r\n\Z/
16
+ # First character will be
17
+ # g for get,
18
+ # s for set,
19
+ # d for delete,
20
+ # t for stats
21
+ # h for shutdown
22
+ # q for quit
23
+
24
+ # GET Responses
25
+
26
+ GET_RESPONSE = "VALUE %s %s %s\r\n%s\r\nEND\r\n".freeze
27
+ GET_RESPONSE_EMPTY = "END\r\n".freeze
28
+
29
+ # SET Responses
30
+ SET_RESPONSE_SUCCESS = "STORED\r\n".freeze
31
+ SET_RESPONSE_FAILURE = "NOT STORED\r\n".freeze
32
+ SET_CLIENT_DATA_ERROR = "CLIENT_ERROR bad data chunk\r\nERROR\r\n".freeze
33
+
34
+ # DELETE Responses
35
+ DELETE_RESPONSE = "END\r\n".freeze
36
+
37
+ # STAT Response
38
+ STATS_RESPONSE = "STAT pid %d\r
39
+ STAT uptime %d\r
40
+ STAT time %d\r
41
+ STAT version %s\r
42
+ STAT rusage_user %0.6f\r
43
+ STAT rusage_system %0.6f\r
44
+ STAT curr_items %d\r
45
+ STAT total_items %d\r
46
+ STAT bytes %d\r
47
+ STAT curr_connections %d\r
48
+ STAT total_connections %d\r
49
+ STAT cmd_get %d\r
50
+ STAT cmd_set %d\r
51
+ STAT get_hits %d\r
52
+ STAT get_misses %d\r
53
+ STAT bytes_read %d\r
54
+ STAT bytes_written %d\r
55
+ STAT limit_maxbytes %d\r
56
+ %sEND\r\n".freeze
57
+ QUEUE_STATS_RESPONSE = "STAT queue_%s_items %d\r
58
+ STAT queue_%s_total_items %d\r
59
+ STAT queue_%s_logsize %d\r
60
+ STAT queue_%s_expired_items %d\r
61
+ STAT queue_%s_age %d\r\n".freeze
62
+
63
+ @@next_session_id = 1
64
+
65
+ ##
66
+ # Creates a new handler for the MemCache protocol that communicates with a
67
+ # given client.
68
+
69
+ def initialize(options = {})
70
+ @opts = options
71
+ end
72
+
73
+ ##
74
+ # Process incoming commands from the attached client.
75
+
76
+ def post_init
77
+ @stash = []
78
+ @data = ""
79
+ @data_buf = ""
80
+ @server = @opts[:server]
81
+ @logger = StarlingServer::Base.logger
82
+ @expiry_stats = Hash.new(0)
83
+ @expected_length = nil
84
+ @server.stats[:total_connections] += 1
85
+ set_comm_inactivity_timeout @opts[:timeout]
86
+ @queue_collection = @opts[:queue]
87
+
88
+ @session_id = @@next_session_id
89
+ @@next_session_id += 1
90
+
91
+ peer = Socket.unpack_sockaddr_in(get_peername)
92
+ #@logger.debug "(#{@session_id}) New session from #{peer[1]}:#{peer[0]}"
93
+ end
94
+
95
+ def receive_data(incoming)
96
+ @server.stats[:bytes_read] += incoming.size
97
+ @data << incoming
98
+
99
+ while ind = @data.index("\r\n")
100
+ to_proc = @data[0,ind+2]
101
+ @data = @data[ind+2,@data.size]
102
+ response = process(to_proc)
103
+ send_data response if response
104
+ end
105
+ end
106
+
107
+ def process(data)
108
+ data = @data_buf + data unless @data_buf.size == 0
109
+ if @expected_length && data.size == @expected_length
110
+ @data_buf = ""
111
+ return set_data(data)
112
+ elsif @expected_length
113
+ @data_buf = data
114
+ return
115
+ end
116
+ m = CMD_RE.match(data)
117
+ if ! m.nil?
118
+ args = m.captures.compact
119
+ case args[0][0]
120
+ when ?s #set command
121
+ @server.stats[:set_requests] += 1
122
+ set(args[1], args[2], args[3], args[4].to_i)
123
+ when ?g #get command
124
+ @server.stats[:get_requests] += 1
125
+ get(args[1])
126
+ when ?t # stats command
127
+ stats
128
+ when ?h # shutdown command
129
+ Runner::shutdown
130
+ when ?d # delete command
131
+ delete args[1]
132
+ when ?q # quit command
133
+ # ignore the command, client is closing connection.
134
+ return nil
135
+ else
136
+ logger.warn "Unknown command (in case): #{data}."
137
+ respond ERR_UNKNOWN_COMMAND
138
+ end
139
+ else
140
+ logger.warn "Unknown command: #{data}."
141
+ respond ERR_UNKNOWN_COMMAND
142
+ end
143
+ end
144
+
145
+ def unbind
146
+ #@logger.debug "(#{@session_id}) connection ends"
147
+ end
148
+
149
+ private
150
+
151
+ def delete(queue)
152
+ @queue_collection.delete(queue)
153
+ respond DELETE_RESPONSE
154
+ end
155
+
156
+ def respond(str, *args)
157
+ response = sprintf(str, *args)
158
+ @server.stats[:bytes_written] += response.length
159
+ response
160
+ end
161
+
162
+ def set(key, flags, expiry, len)
163
+ @expected_length = len + 2
164
+ @stash = [ key, flags, expiry ]
165
+ nil
166
+ end
167
+
168
+ def set_data(incoming)
169
+ key, flags, expiry = @stash
170
+ data = incoming.slice(0...@expected_length-2)
171
+ @stash = []
172
+ @expected_length = nil
173
+
174
+ internal_data = [expiry.to_i, data].pack(DATA_PACK_FMT)
175
+ if @queue_collection.put(key, internal_data)
176
+ respond SET_RESPONSE_SUCCESS
177
+ else
178
+ respond SET_RESPONSE_FAILURE
179
+ end
180
+ end
181
+
182
+ def get(key)
183
+ now = Time.now.to_i
184
+
185
+ while response = @queue_collection.take(key)
186
+ expiry, data = response.unpack(DATA_PACK_FMT)
187
+
188
+ break if expiry == 0 || expiry >= now
189
+
190
+ @expiry_stats[key] += 1
191
+ expiry, data = nil
192
+ end
193
+
194
+ if data
195
+ respond GET_RESPONSE, key, 0, data.size, data
196
+ else
197
+ respond GET_RESPONSE_EMPTY
198
+ end
199
+ end
200
+
201
+ def stats
202
+ respond STATS_RESPONSE,
203
+ Process.pid, # pid
204
+ Time.now - @server.stats(:start_time), # uptime
205
+ Time.now.to_i, # time
206
+ StarlingServer::VERSION, # version
207
+ Process.times.utime, # rusage_user
208
+ Process.times.stime, # rusage_system
209
+ @queue_collection.stats(:current_size), # curr_items
210
+ @queue_collection.stats(:total_items), # total_items
211
+ @queue_collection.stats(:current_bytes), # bytes
212
+ @server.stats(:connections), # curr_connections
213
+ @server.stats(:total_connections), # total_connections
214
+ @server.stats(:get_requests), # get count
215
+ @server.stats(:set_requests), # set count
216
+ @queue_collection.stats(:get_hits),
217
+ @queue_collection.stats(:get_misses),
218
+ @server.stats(:bytes_read), # total bytes read
219
+ @server.stats(:bytes_written), # total bytes written
220
+ 0, # limit_maxbytes
221
+ queue_stats
222
+ end
223
+
224
+ def queue_stats
225
+ @queue_collection.queues.inject("") do |m,(k,v)|
226
+ m + sprintf(QUEUE_STATS_RESPONSE,
227
+ k, v.length,
228
+ k, v.total_items,
229
+ k, v.logsize,
230
+ k, @expiry_stats[k],
231
+ k, v.current_age)
232
+ end
233
+ end
234
+
235
+ def logger
236
+ @logger
237
+ end
238
+ end
239
+ end
@@ -0,0 +1,156 @@
1
+ module StarlingServer
2
+
3
+ ##
4
+ # PersistentQueue is a subclass of Ruby's thread-safe Queue class. It adds a
5
+ # transactional log to the in-memory Queue, which enables quickly rebuilding
6
+ # the Queue in the event of a sever outage.
7
+
8
+ class PersistentQueue < Queue
9
+
10
+ ##
11
+ # When a log reaches the SOFT_LOG_MAX_SIZE, the Queue will wait until
12
+ # it is empty, and will then rotate the log file.
13
+
14
+ SOFT_LOG_MAX_SIZE = 16 * (1024**2) # 16 MB
15
+
16
+ TRX_CMD_PUSH = "\000".freeze
17
+ TRX_CMD_POP = "\001".freeze
18
+
19
+ TRX_PUSH = "\000%s%s".freeze
20
+ TRX_POP = "\001".freeze
21
+
22
+ attr_reader :initial_bytes
23
+ attr_reader :total_items
24
+ attr_reader :logsize
25
+ attr_reader :current_age
26
+
27
+ ##
28
+ # Create a new PersistentQueue at +persistence_path+/+queue_name+.
29
+ # If a queue log exists at that path, the Queue will be loaded from
30
+ # disk before being available for use.
31
+
32
+ def initialize(persistence_path, queue_name, debug = false)
33
+ @persistence_path = persistence_path
34
+ @queue_name = queue_name
35
+ @total_items = 0
36
+ super()
37
+ @initial_bytes = replay_transaction_log(debug)
38
+ @current_age = 0
39
+ end
40
+
41
+ ##
42
+ # Pushes +value+ to the queue. By default, +push+ will write to the
43
+ # transactional log. Set +log_trx=false+ to override this behaviour.
44
+
45
+ def push(value, log_trx = true)
46
+ if log_trx
47
+ raise NoTransactionLog unless @trx
48
+ size = [value.size].pack("I")
49
+ transaction sprintf(TRX_PUSH, size, value)
50
+ end
51
+
52
+ @total_items += 1
53
+ super([now_usec, value])
54
+ end
55
+
56
+ ##
57
+ # Retrieves data from the queue.
58
+
59
+ def pop(log_trx = true)
60
+ raise NoTransactionLog if log_trx && !@trx
61
+
62
+ begin
63
+ rv = super(!log_trx)
64
+ rescue ThreadError
65
+ puts "WARNING: The queue was empty when trying to pop(). Technically this shouldn't ever happen. Probably a bug in the transactional underpinnings. Or maybe shutdown didn't happen cleanly at some point. Ignoring."
66
+ rv = [now_usec, '']
67
+ end
68
+ transaction "\001" if log_trx
69
+ @current_age = (now_usec - rv[0]) / 1000
70
+ rv[1]
71
+ end
72
+
73
+ ##
74
+ # Safely closes the transactional queue.
75
+
76
+ def close
77
+ # Ok, yeah, this is lame, and is *technically* a race condition. HOWEVER,
78
+ # the QueueCollection *should* have stopped processing requests, and I don't
79
+ # want to add yet another Mutex around all the push and pop methods. So we
80
+ # do the next simplest thing, and minimize the time we'll stick around before
81
+ # @trx is nil.
82
+ @not_trx = @trx
83
+ @trx = nil
84
+ @not_trx.close
85
+ end
86
+
87
+ def purge
88
+ close
89
+ File.delete(log_path)
90
+ end
91
+
92
+ private
93
+
94
+ def log_path #:nodoc:
95
+ File.join(@persistence_path, @queue_name)
96
+ end
97
+
98
+ def reopen_log #:nodoc:
99
+ @trx = File.new(log_path, File::CREAT|File::RDWR)
100
+ @logsize = File.size(log_path)
101
+ end
102
+
103
+ def rotate_log #:nodoc:
104
+ @trx.close
105
+ backup_logfile = "#{log_path}.#{Time.now.to_i}"
106
+ File.rename(log_path, backup_logfile)
107
+ reopen_log
108
+ File.unlink(backup_logfile)
109
+ end
110
+
111
+ def replay_transaction_log(debug) #:nodoc:
112
+ reopen_log
113
+ bytes_read = 0
114
+
115
+ print "Reading back transaction log for #{@queue_name} " if debug
116
+
117
+ while !@trx.eof?
118
+ cmd = @trx.read(1)
119
+ case cmd
120
+ when TRX_CMD_PUSH
121
+ print ">" if debug
122
+ raw_size = @trx.read(4)
123
+ next unless raw_size
124
+ size = raw_size.unpack("I").first
125
+ data = @trx.read(size)
126
+ next unless data
127
+ push(data, false)
128
+ bytes_read += data.size
129
+ when TRX_CMD_POP
130
+ print "<" if debug
131
+ bytes_read -= pop(false).size
132
+ else
133
+ puts "Error reading transaction log: " +
134
+ "I don't understand '#{cmd}' (skipping)." if debug
135
+ end
136
+ end
137
+
138
+ print " done.\n" if debug
139
+
140
+ return bytes_read
141
+ end
142
+
143
+ def transaction(data) #:nodoc:
144
+ raise "no transaction log handle. that totally sucks." unless @trx
145
+
146
+ @trx.write_nonblock data
147
+ @logsize += data.size
148
+ rotate_log if @logsize > SOFT_LOG_MAX_SIZE && self.length == 0
149
+ end
150
+
151
+ def now_usec
152
+ now = Time.now
153
+ now.to_i * 1000000 + now.usec
154
+ end
155
+ end
156
+ end
@@ -0,0 +1,147 @@
1
+ require 'thread'
2
+ require File.join(File.dirname(__FILE__), 'persistent_queue')
3
+
4
+ module StarlingServer
5
+ class InaccessibleQueuePath < Exception #:nodoc:
6
+ end
7
+
8
+ ##
9
+ # QueueCollection is a proxy to a collection of PersistentQueue instances.
10
+
11
+ class QueueCollection
12
+
13
+ ##
14
+ # Create a new QueueCollection at +path+
15
+
16
+ def initialize(path)
17
+ unless File.directory?(path) && File.writable?(path)
18
+ raise InaccessibleQueuePath.new("'#{path}' must exist and be read-writable by #{Etc.getpwuid(Process.uid).name}.")
19
+ end
20
+
21
+ @shutdown_mutex = Mutex.new
22
+
23
+ @path = path
24
+ @logger = StarlingServer::Base.logger
25
+
26
+ @queues = {}
27
+ @queue_init_mutexes = {}
28
+
29
+ @stats = Hash.new(0)
30
+ end
31
+
32
+ ##
33
+ # Puts +data+ onto the queue named +key+
34
+
35
+ def put(key, data)
36
+ queue = queues(key)
37
+ return nil unless queue
38
+
39
+ @stats[:current_bytes] += data.size
40
+ @stats[:total_items] += 1
41
+
42
+ queue.push(data)
43
+
44
+ return true
45
+ end
46
+
47
+ ##
48
+ # Retrieves data from the queue named +key+
49
+
50
+ def take(key)
51
+ queue = queues(key)
52
+ if queue.nil? || queue.length == 0
53
+ @stats[:get_misses] += 1
54
+ return nil
55
+ else
56
+ @stats[:get_hits] += 1
57
+ end
58
+ result = queue.pop
59
+ @stats[:current_bytes] -= result.size
60
+ result
61
+ end
62
+
63
+ def delete(key)
64
+ queue = @queues.delete(key)
65
+ return if queue.nil?
66
+ queue.purge
67
+ end
68
+
69
+ ##
70
+ # Returns all active queues.
71
+
72
+ def queues(key=nil)
73
+ return nil if @shutdown_mutex.locked?
74
+
75
+ return @queues if key.nil?
76
+
77
+ # First try to return the queue named 'key' if it's available.
78
+ return @queues[key] if @queues[key]
79
+
80
+ # If the queue wasn't available, create or get the mutex that will
81
+ # wrap creation of the Queue.
82
+ @queue_init_mutexes[key] ||= Mutex.new
83
+
84
+ # Otherwise, check to see if another process is already loading
85
+ # the queue named 'key'.
86
+ if @queue_init_mutexes[key].locked?
87
+ # return an empty/false result if we're waiting for the queue
88
+ # to be loaded and we're not the first process to request the queue
89
+ return nil
90
+ else
91
+ begin
92
+ @queue_init_mutexes[key].lock
93
+ # we've locked the mutex, but only go ahead if the queue hasn't
94
+ # been loaded. There's a race condition otherwise, and we could
95
+ # end up loading the queue multiple times.
96
+ if @queues[key].nil?
97
+ @queues[key] = PersistentQueue.new(@path, key)
98
+ @stats[:current_bytes] += @queues[key].initial_bytes
99
+ end
100
+ rescue Object => exc
101
+ puts "ZOMG There was an exception reading back the queue. That totally sucks."
102
+ puts "The exception was: #{exc}. Backtrace: #{exc.backtrace.join("\n")}"
103
+ ensure
104
+ @queue_init_mutexes[key].unlock
105
+ end
106
+ end
107
+
108
+ return @queues[key]
109
+ end
110
+
111
+ ##
112
+ # Returns statistic +stat_name+ for the QueueCollection.
113
+ #
114
+ # Valid statistics are:
115
+ #
116
+ # [:get_misses] Total number of get requests with empty responses
117
+ # [:get_hits] Total number of get requests that returned data
118
+ # [:current_bytes] Current size in bytes of items in the queues
119
+ # [:current_size] Current number of items across all queues
120
+ # [:total_items] Total number of items stored in queues.
121
+
122
+ def stats(stat_name)
123
+ case stat_name
124
+ when nil; @stats
125
+ when :current_size; current_size
126
+ else; @stats[stat_name]
127
+ end
128
+ end
129
+
130
+ ##
131
+ # Safely close all queues.
132
+
133
+ def close
134
+ @shutdown_mutex.lock
135
+ @queues.each_pair do |name,queue|
136
+ queue.close
137
+ @queues.delete(name)
138
+ end
139
+ end
140
+
141
+ private
142
+
143
+ def current_size #:nodoc:
144
+ @queues.inject(0) { |m, (k,v)| m + v.length }
145
+ end
146
+ end
147
+ end
@@ -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 = "1.0.0"
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