fastly_nsq 0.9.5 → 0.10.0
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +4 -4
- data/.gitignore +1 -0
- data/README.md +14 -0
- data/bin/fastly_nsq +15 -0
- data/example_config_class.rb +19 -0
- data/fastly_nsq.gemspec +1 -0
- data/lib/fastly_nsq.rb +39 -3
- data/lib/fastly_nsq/cli.rb +238 -0
- data/lib/fastly_nsq/launcher.rb +60 -0
- data/lib/fastly_nsq/listener.rb +76 -21
- data/lib/fastly_nsq/listener/config.rb +34 -0
- data/lib/fastly_nsq/manager.rb +105 -0
- data/lib/fastly_nsq/safe_thread.rb +18 -0
- data/lib/fastly_nsq/version.rb +1 -1
- data/spec/lib/fastly_nsq/cli_spec.rb +5 -0
- data/spec/lib/fastly_nsq/launcher_spec.rb +56 -0
- data/spec/lib/fastly_nsq/listener_spec.rb +77 -2
- data/spec/lib/fastly_nsq/manager_spec.rb +129 -0
- metadata +28 -3
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA1:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: e00ef3c8f9fbbd967b2e97e2621a63734cbf953b
|
4
|
+
data.tar.gz: f5d6e19dbcd964481c2543b9a772ccde35c6b22f
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: 304ccf3b5012900f32aae93993656b5f6d5866d325b91267848b64d4c2c321e78ee40cbe812e418761f4c597827153598c5d874e7bfe0d8059cfd4ab2a4a3f6c
|
7
|
+
data.tar.gz: 7aac209a0fdb90f650bd0287393a367eb16585de140bf2768635f0a0bcb39f98d6600eb5d96c4ad66ff6da5ce4d68a1236c6f20f27cde2e0889c9545706ba672
|
data/.gitignore
CHANGED
data/README.md
CHANGED
@@ -159,6 +159,20 @@ The task can also define a `call`-able "preprocessor" (called before any `Proces
|
|
159
159
|
See the [`Rakefile`](examples/Rakefile) file
|
160
160
|
for more detail.
|
161
161
|
|
162
|
+
### `FastlyNsq::CLI`
|
163
|
+
|
164
|
+
To help facilitate running the `FastlyNsq::Listener` in a blocking fashion
|
165
|
+
outside your application, a `CLI` and bin script [`fastly_nsq`](bin/fastly_nsq)
|
166
|
+
are provided.
|
167
|
+
|
168
|
+
This can be setup ahead of time by calling `FastlyNsq.configure` and passing
|
169
|
+
block. An exmaple of this can be found here: [`Example Config`](exmaple_config_class.rb)
|
170
|
+
|
171
|
+
An example of using the cli:
|
172
|
+
```bash
|
173
|
+
./bin/fastly_nsq -r ./example_config_class.rb -L ./test.log -P ./fastly_nsq.pid -v -d
|
174
|
+
```
|
175
|
+
|
162
176
|
### FastlyNsq::Messgener
|
163
177
|
|
164
178
|
Wrapper around a producer for sending messages and persisting producer objects.
|
data/bin/fastly_nsq
ADDED
@@ -0,0 +1,15 @@
|
|
1
|
+
#!/usr/bin/env ruby
|
2
|
+
|
3
|
+
require 'pry'
|
4
|
+
require 'fastly_nsq/cli'
|
5
|
+
|
6
|
+
begin
|
7
|
+
cli = FastlyNsq::CLI.instance
|
8
|
+
cli.parse_options
|
9
|
+
cli.run
|
10
|
+
rescue => e
|
11
|
+
raise e if $DEBUG
|
12
|
+
STDERR.puts e.message
|
13
|
+
STDERR.puts e.backtrace.join("\n")
|
14
|
+
exit 1
|
15
|
+
end
|
@@ -0,0 +1,19 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
class MessageProcessor
|
3
|
+
def self.process(message)
|
4
|
+
FastlyNsq.logger.info("IN PROCESS: #{message}")
|
5
|
+
|
6
|
+
# Do Soemthing with message
|
7
|
+
end
|
8
|
+
end
|
9
|
+
|
10
|
+
FastlyNsq.configure do |config|
|
11
|
+
config.channel = 'my_channel'
|
12
|
+
config.logger = Logger.new
|
13
|
+
config.preprocessor = ->(_) { FastlyNsq.logger.info 'PREPROCESSESES' }
|
14
|
+
|
15
|
+
config.listener_config do |lc|
|
16
|
+
lc.add_topic('some_topic', MessageProcessor)
|
17
|
+
lc.add_topic('some_other_topic', MessageProcessor)
|
18
|
+
end
|
19
|
+
end
|
data/fastly_nsq.gemspec
CHANGED
@@ -23,6 +23,7 @@ Gem::Specification.new do |gem|
|
|
23
23
|
gem.add_development_dependency 'awesome_print', '~> 1.6'
|
24
24
|
gem.add_development_dependency 'bundler', '~> 1.12'
|
25
25
|
gem.add_development_dependency 'bundler-audit', '~> 0.5.0'
|
26
|
+
gem.add_development_dependency 'dotenv'
|
26
27
|
gem.add_development_dependency 'overcommit', '~> 0.32.0'
|
27
28
|
gem.add_development_dependency 'pry-byebug', '~> 3.3'
|
28
29
|
gem.add_development_dependency 'rake', '~> 11.1.2'
|
data/lib/fastly_nsq.rb
CHANGED
@@ -10,15 +10,51 @@ require 'fastly_nsq/tls_options'
|
|
10
10
|
require 'fastly_nsq/version'
|
11
11
|
|
12
12
|
module FastlyNsq
|
13
|
-
|
13
|
+
module_function
|
14
|
+
|
15
|
+
def channel=(channel)
|
16
|
+
@channel ||= channel
|
17
|
+
end
|
18
|
+
|
19
|
+
def logger=(logger)
|
14
20
|
strategy.logger = logger
|
15
21
|
end
|
16
22
|
|
17
|
-
def self.
|
23
|
+
def self.preprocessor=(preprocessor)
|
24
|
+
@preprocessor ||= preprocessor
|
25
|
+
end
|
26
|
+
|
27
|
+
def channel
|
28
|
+
@channel
|
29
|
+
end
|
30
|
+
|
31
|
+
def logger
|
18
32
|
strategy.logger
|
19
33
|
end
|
20
34
|
|
21
|
-
def self.
|
35
|
+
def self.preprocessor
|
36
|
+
@preprocessor
|
37
|
+
end
|
38
|
+
|
39
|
+
def strategy
|
22
40
|
Strategy.for_queue
|
23
41
|
end
|
42
|
+
|
43
|
+
def configure
|
44
|
+
yield self
|
45
|
+
end
|
46
|
+
|
47
|
+
def topic_map
|
48
|
+
@listener_config.topic_map
|
49
|
+
end
|
50
|
+
|
51
|
+
def listener_config
|
52
|
+
@listener_config ||= FastlyNsq::Listener::Config.new
|
53
|
+
yield @listener_config if block_given?
|
54
|
+
@listener_config
|
55
|
+
end
|
56
|
+
|
57
|
+
def reset_config
|
58
|
+
@listener_config = nil
|
59
|
+
end
|
24
60
|
end
|
@@ -0,0 +1,238 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
$stdout.sync = true
|
3
|
+
|
4
|
+
require 'fastly_nsq'
|
5
|
+
require 'fastly_nsq/launcher'
|
6
|
+
require 'fastly_nsq/manager'
|
7
|
+
require 'fileutils'
|
8
|
+
require 'optparse'
|
9
|
+
require 'singleton'
|
10
|
+
|
11
|
+
class FastlyNsq::CLI
|
12
|
+
include Singleton
|
13
|
+
|
14
|
+
attr_reader :options
|
15
|
+
|
16
|
+
def parse_options(args = ARGV)
|
17
|
+
parse(args)
|
18
|
+
setup_logger
|
19
|
+
check_pid
|
20
|
+
daemonize if daemonize?
|
21
|
+
write_pid
|
22
|
+
end
|
23
|
+
|
24
|
+
def run
|
25
|
+
startup
|
26
|
+
begin
|
27
|
+
# Multithreading begins here ----
|
28
|
+
launcher.run
|
29
|
+
|
30
|
+
read_loop
|
31
|
+
rescue Interrupt
|
32
|
+
FastlyNsq.logger.info 'Shutting down'
|
33
|
+
launcher.stop
|
34
|
+
# Explicitly exit so busy Processor threads can't block
|
35
|
+
# process shutdown.
|
36
|
+
FastlyNsq.logger.info 'Bye!'
|
37
|
+
exit(0)
|
38
|
+
end
|
39
|
+
end
|
40
|
+
|
41
|
+
private
|
42
|
+
|
43
|
+
def launcher
|
44
|
+
@launcher ||= FastlyNsq::Launcher.new(options)
|
45
|
+
end
|
46
|
+
|
47
|
+
def read_loop
|
48
|
+
trapped_read_io = trap_signals
|
49
|
+
loop do
|
50
|
+
readable_io = IO.select([trapped_read_io])
|
51
|
+
break unless readable_io
|
52
|
+
signal = readable_io.first[0].gets.strip
|
53
|
+
handle_signal signal
|
54
|
+
end
|
55
|
+
end
|
56
|
+
|
57
|
+
def startup
|
58
|
+
require options[:require] if options[:require]
|
59
|
+
FastlyNsq.logger.info "Running in #{RUBY_DESCRIPTION}"
|
60
|
+
FastlyNsq.logger.info 'Starting processing, hit Ctrl-C to stop' unless options[:daemon]
|
61
|
+
end
|
62
|
+
|
63
|
+
def parse(args)
|
64
|
+
opts = {}
|
65
|
+
|
66
|
+
@parser = OptionParser.new do |o|
|
67
|
+
o.on '-d', '--daemon', 'Daemonize process' do |arg|
|
68
|
+
opts[:daemonize] = arg
|
69
|
+
end
|
70
|
+
|
71
|
+
o.on '-L', '--logfile PATH', 'path to writable logfile' do |arg|
|
72
|
+
opts[:logfile] = arg
|
73
|
+
end
|
74
|
+
|
75
|
+
o.on '-P', '--pidfile PATH', 'path to pidfile' do |arg|
|
76
|
+
opts[:pidfile] = arg
|
77
|
+
end
|
78
|
+
|
79
|
+
o.on '-r', '--require [PATH|DIR]', 'Location of message_processor definition' do |arg|
|
80
|
+
opts[:require] = arg
|
81
|
+
end
|
82
|
+
|
83
|
+
o.on '-v', '--verbose', 'enable verbose logging output' do |arg|
|
84
|
+
opts[:verbose] = arg
|
85
|
+
end
|
86
|
+
end
|
87
|
+
|
88
|
+
@parser.banner = 'fastly_nsq [options]'
|
89
|
+
@parser.parse!(args)
|
90
|
+
|
91
|
+
@options = opts
|
92
|
+
end
|
93
|
+
|
94
|
+
def check_pid
|
95
|
+
if pidfile?
|
96
|
+
case pid_status(pidfile)
|
97
|
+
when :running, :not_owned
|
98
|
+
puts "A server is already running. Check #{pidfile}"
|
99
|
+
exit(1)
|
100
|
+
when :dead
|
101
|
+
File.delete(pidfile)
|
102
|
+
end
|
103
|
+
end
|
104
|
+
end
|
105
|
+
|
106
|
+
def pid_status(pidfile)
|
107
|
+
return :exited unless File.exist?(pidfile)
|
108
|
+
pid = ::File.read(pidfile).to_i
|
109
|
+
return :dead if pid == 0
|
110
|
+
Process.kill(0, pid) # check process status
|
111
|
+
:running
|
112
|
+
rescue Errno::ESRCH
|
113
|
+
:dead
|
114
|
+
rescue Errno::EPERM
|
115
|
+
:not_owned
|
116
|
+
end
|
117
|
+
|
118
|
+
def setup_logger
|
119
|
+
FastlyNsq.logger = Logger.new(options[:logfile]) if options[:logfile]
|
120
|
+
|
121
|
+
FastlyNsq.logger.level = ::Logger::DEBUG if options[:verbose]
|
122
|
+
end
|
123
|
+
|
124
|
+
def write_pid
|
125
|
+
if pidfile?
|
126
|
+
begin
|
127
|
+
File.open(pidfile, ::File::CREAT | ::File::EXCL | ::File::WRONLY) do |f|
|
128
|
+
f.write Process.pid.to_s
|
129
|
+
end
|
130
|
+
at_exit { File.delete(pidfile) if File.exist?(pidfile) }
|
131
|
+
rescue Errno::EEXIST
|
132
|
+
check_pid
|
133
|
+
retry
|
134
|
+
end
|
135
|
+
end
|
136
|
+
end
|
137
|
+
|
138
|
+
def trap_signals
|
139
|
+
self_read, self_write = IO.pipe
|
140
|
+
sigs = %w(INT TERM TTIN USR1)
|
141
|
+
|
142
|
+
sigs.each do |sig|
|
143
|
+
begin
|
144
|
+
trap sig do
|
145
|
+
self_write.puts(sig)
|
146
|
+
end
|
147
|
+
rescue ArgumentError
|
148
|
+
puts "Signal #{sig} not supported"
|
149
|
+
end
|
150
|
+
end
|
151
|
+
self_read
|
152
|
+
end
|
153
|
+
|
154
|
+
def handle_signal(sig)
|
155
|
+
FastlyNsq.logger.debug "Got #{sig} signal"
|
156
|
+
case sig
|
157
|
+
when 'INT'
|
158
|
+
# Handle Ctrl-C in JRuby like MRI
|
159
|
+
# http://jira.codehaus.org/browse/JRUBY-4637
|
160
|
+
raise Interrupt
|
161
|
+
when 'TERM'
|
162
|
+
# Heroku sends TERM and then waits 10 seconds for process to exit.
|
163
|
+
raise Interrupt
|
164
|
+
when 'USR1'
|
165
|
+
FastlyNsq.logger.info 'Received USR1, no longer accepting new work'
|
166
|
+
launcher.quiet
|
167
|
+
when 'TTIN'
|
168
|
+
handle_ttin
|
169
|
+
end
|
170
|
+
end
|
171
|
+
|
172
|
+
def handle_ttin
|
173
|
+
Thread.list.each do |thread|
|
174
|
+
FastlyNsq.logger.warn "Thread TID-#{thread.object_id.to_s(36)} #{thread['fastly_nsq_label']}"
|
175
|
+
if thread.backtrace
|
176
|
+
FastlyNsq.logger.warn thread.backtrace.join("\n")
|
177
|
+
else
|
178
|
+
FastlyNsq.logger.warn '<no backtrace available>'
|
179
|
+
end
|
180
|
+
end
|
181
|
+
end
|
182
|
+
|
183
|
+
def daemonize
|
184
|
+
return unless options[:daemonize]
|
185
|
+
|
186
|
+
files_to_reopen = []
|
187
|
+
ObjectSpace.each_object(File) do |file|
|
188
|
+
files_to_reopen << file unless file.closed?
|
189
|
+
end
|
190
|
+
|
191
|
+
::Process.daemon(true, true)
|
192
|
+
|
193
|
+
reopen(files_to_reopen)
|
194
|
+
|
195
|
+
[$stdout, $stderr].each do |io|
|
196
|
+
File.open(options.fetch(:logfile, '/dev/null'), 'ab') do |f|
|
197
|
+
io.reopen(f)
|
198
|
+
end
|
199
|
+
io.sync = true
|
200
|
+
end
|
201
|
+
$stdin.reopen('/dev/null')
|
202
|
+
|
203
|
+
setup_logger
|
204
|
+
end
|
205
|
+
|
206
|
+
def reopen(files)
|
207
|
+
files.each do |file|
|
208
|
+
begin
|
209
|
+
file.reopen file.path, 'a+'
|
210
|
+
file.sync = true
|
211
|
+
rescue IOError => e
|
212
|
+
FastlyNsq.logger.warn "IOError reopening file: #{e.message}"
|
213
|
+
rescue StandardError => e
|
214
|
+
FastlyNsq.logger.error "Non IOError reopening file: #{e.message}"
|
215
|
+
end
|
216
|
+
end
|
217
|
+
end
|
218
|
+
|
219
|
+
def logfile?
|
220
|
+
!logfile.nil?
|
221
|
+
end
|
222
|
+
|
223
|
+
def pidfile?
|
224
|
+
!pidfile.nil?
|
225
|
+
end
|
226
|
+
|
227
|
+
def daemonize?
|
228
|
+
options[:daemonize]
|
229
|
+
end
|
230
|
+
|
231
|
+
def logfile
|
232
|
+
options[:logfile]
|
233
|
+
end
|
234
|
+
|
235
|
+
def pidfile
|
236
|
+
options[:pidfile]
|
237
|
+
end
|
238
|
+
end
|
@@ -0,0 +1,60 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
require 'fastly_nsq/safe_thread'
|
3
|
+
|
4
|
+
class FastlyNsq::Launcher
|
5
|
+
include FastlyNsq::SafeThread
|
6
|
+
|
7
|
+
def initialize(options)
|
8
|
+
@done = false
|
9
|
+
@manager = FastlyNsq::Manager.new options
|
10
|
+
@options = options
|
11
|
+
end
|
12
|
+
|
13
|
+
def run
|
14
|
+
@thread = safe_thread('heartbeat', &method(:start_heartbeat))
|
15
|
+
@manager.start
|
16
|
+
end
|
17
|
+
|
18
|
+
def quiet
|
19
|
+
@done = true
|
20
|
+
@manager.quiet
|
21
|
+
end
|
22
|
+
|
23
|
+
# Shuts down the process. This method does not
|
24
|
+
# return until all work is complete and cleaned up.
|
25
|
+
# It can take up to the timeout to complete.
|
26
|
+
def stop
|
27
|
+
deadline = Time.now + @options.fetch(:timeout, 10)
|
28
|
+
quiet
|
29
|
+
@manager.stop deadline
|
30
|
+
end
|
31
|
+
|
32
|
+
def stopping?
|
33
|
+
@done
|
34
|
+
end
|
35
|
+
|
36
|
+
private
|
37
|
+
|
38
|
+
def heartbeat
|
39
|
+
FastlyNsq.logger.debug do
|
40
|
+
[
|
41
|
+
'HEARTBEAT:',
|
42
|
+
'thread_status:', @manager.listeners.map(&:status).join(', '),
|
43
|
+
'listener_count:', @manager.listeners.count
|
44
|
+
].join(' ')
|
45
|
+
end
|
46
|
+
|
47
|
+
# TODO: Check the health of the system overall and kill it if needed
|
48
|
+
# ::Process.kill('dieing because...', $$)
|
49
|
+
rescue => e
|
50
|
+
FastlyNsq.logger.error "heartbeat error: #{e.message}"
|
51
|
+
end
|
52
|
+
|
53
|
+
def start_heartbeat
|
54
|
+
loop do
|
55
|
+
heartbeat
|
56
|
+
sleep 5
|
57
|
+
end
|
58
|
+
FastlyNsq.logger.info('Heartbeat stopping...')
|
59
|
+
end
|
60
|
+
end
|
data/lib/fastly_nsq/listener.rb
CHANGED
@@ -1,59 +1,114 @@
|
|
1
|
+
# frozen_string_literal: true
|
1
2
|
require 'fastly_nsq/message'
|
3
|
+
require 'fastly_nsq/manager'
|
4
|
+
require 'fastly_nsq/safe_thread'
|
5
|
+
require 'fastly_nsq/listener/config'
|
2
6
|
|
3
7
|
module FastlyNsq
|
4
8
|
class Listener
|
5
|
-
|
6
|
-
|
9
|
+
include FastlyNsq::SafeThread
|
10
|
+
|
11
|
+
def self.listen_to(*args)
|
12
|
+
new(*args).go
|
7
13
|
end
|
8
14
|
|
9
15
|
def initialize(topic:, processor:, channel: nil, consumer: nil, **options)
|
10
|
-
@topic = topic
|
11
|
-
@processor = processor
|
12
16
|
@consumer = consumer || FastlyNsq::Consumer.new(topic: topic, channel: channel)
|
17
|
+
@done = false
|
13
18
|
@logger = options.fetch :logger, FastlyNsq.logger
|
19
|
+
@manager = options[:manager] || FastlyNsq::Manager.new
|
14
20
|
@preprocessor = options[:preprocessor]
|
21
|
+
@processor = processor
|
22
|
+
@thread = nil
|
23
|
+
@topic = topic
|
15
24
|
end
|
16
25
|
|
17
|
-
def
|
18
|
-
|
19
|
-
|
26
|
+
def identity
|
27
|
+
{
|
28
|
+
consumer: @consumer,
|
29
|
+
logger: @logger,
|
30
|
+
manager: @manager,
|
31
|
+
preprocessor: @preprocessor,
|
32
|
+
processor: @processor,
|
33
|
+
topic: @topic,
|
34
|
+
}
|
35
|
+
end
|
36
|
+
|
37
|
+
def reset_then_dup
|
38
|
+
reset
|
39
|
+
dup
|
40
|
+
end
|
20
41
|
|
21
|
-
|
42
|
+
def start
|
43
|
+
@logger.info { "> Listener Started: topic #{@topic}" }
|
44
|
+
@thread ||= safe_thread('listener', &method(:go))
|
45
|
+
end
|
46
|
+
|
47
|
+
def go(run_once: false)
|
48
|
+
until @done
|
22
49
|
next_message do |message|
|
23
50
|
log message
|
24
51
|
preprocess message
|
25
|
-
processor.process message
|
52
|
+
@processor.process message
|
26
53
|
end
|
27
54
|
|
28
|
-
|
55
|
+
terminate if run_once
|
29
56
|
end
|
30
57
|
|
31
|
-
|
58
|
+
@manager.listener_stopped(self)
|
59
|
+
rescue FastlyNsq::Shutdown
|
60
|
+
@manager.listener_stopped(self)
|
61
|
+
rescue Exception => ex # rubocop:disable Lint/RescueException
|
62
|
+
@logger.error ex.inspect
|
63
|
+
@manager.listener_killed(self)
|
64
|
+
ensure
|
65
|
+
cleanup
|
32
66
|
end
|
33
67
|
|
34
|
-
|
68
|
+
def status
|
69
|
+
@thread.status if @thread
|
70
|
+
end
|
71
|
+
|
72
|
+
def terminate
|
73
|
+
@done = true
|
74
|
+
return unless @thread
|
75
|
+
@logger.info "< Listener TERM: topic #{@topic}"
|
76
|
+
end
|
35
77
|
|
36
|
-
|
78
|
+
def kill
|
79
|
+
@done = true
|
80
|
+
return unless @thread
|
81
|
+
@logger.info "< Listener KILL: topic #{@topic}"
|
82
|
+
@thread.raise FastlyNsq::Shutdown
|
83
|
+
end
|
84
|
+
|
85
|
+
private
|
37
86
|
|
38
87
|
def log(message)
|
39
|
-
logger.info "[NSQ] Message Received: #{message}" if logger
|
88
|
+
@logger.info "[NSQ] Message Received: #{message}" if @logger
|
89
|
+
end
|
90
|
+
|
91
|
+
def cleanup
|
92
|
+
@consumer.terminate
|
93
|
+
@logger.info '< Consumer terminated'
|
40
94
|
end
|
41
95
|
|
42
96
|
def next_message
|
43
|
-
message = consumer.pop # TODO: consumer.pop do |message|
|
97
|
+
message = @consumer.pop # TODO: consumer.pop do |message|
|
44
98
|
result = yield FastlyNsq::Message.new(message.body)
|
45
99
|
message.finish if result
|
46
100
|
end
|
47
101
|
|
48
102
|
def preprocess(message)
|
49
|
-
preprocessor.call(message) if preprocessor
|
103
|
+
@preprocessor.call(message) if @preprocessor
|
50
104
|
end
|
51
105
|
|
52
|
-
def
|
53
|
-
|
54
|
-
|
55
|
-
|
56
|
-
end
|
106
|
+
def reset
|
107
|
+
@done = false
|
108
|
+
@thread = nil
|
109
|
+
self
|
57
110
|
end
|
58
111
|
end
|
59
112
|
end
|
113
|
+
|
114
|
+
class FastlyNsq::Shutdown < StandardError; end
|
@@ -0,0 +1,34 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
module FastlyNsq
|
3
|
+
class Listener
|
4
|
+
class Config
|
5
|
+
attr_reader :topic_map
|
6
|
+
|
7
|
+
def initialize
|
8
|
+
@topic_map = {}
|
9
|
+
end
|
10
|
+
|
11
|
+
def add_topic(topic_name, processor)
|
12
|
+
FastlyNsq.logger.info("topic: #{topic_name} : klass #{processor}")
|
13
|
+
validate topic_name, processor
|
14
|
+
topic_map[topic_name] = processor
|
15
|
+
end
|
16
|
+
|
17
|
+
private
|
18
|
+
|
19
|
+
def validate(topic_name, processor)
|
20
|
+
unless processor.respond_to? :process
|
21
|
+
error_msg = "ConfigurationError: processor: #{processor} for #{topic_name} does not respond to :process!"
|
22
|
+
FastlyNsq.logger.error error_msg
|
23
|
+
raise ::ConfigurationError, error_msg
|
24
|
+
end
|
25
|
+
|
26
|
+
if topic_map[topic_name]
|
27
|
+
FastlyNsq.logger.warn("topic: #{topic_name} was added more than once")
|
28
|
+
end
|
29
|
+
end
|
30
|
+
end
|
31
|
+
end
|
32
|
+
end
|
33
|
+
|
34
|
+
class ConfigurationError < StandardError; end
|
@@ -0,0 +1,105 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
require 'set'
|
3
|
+
|
4
|
+
class FastlyNsq::Manager
|
5
|
+
attr_reader :listeners
|
6
|
+
|
7
|
+
def initialize(options = {})
|
8
|
+
@options = options
|
9
|
+
@done = false
|
10
|
+
@listeners = Set.new
|
11
|
+
@plock = Mutex.new
|
12
|
+
end
|
13
|
+
|
14
|
+
def start
|
15
|
+
setup_configured_listeners
|
16
|
+
@listeners.each(&:start)
|
17
|
+
end
|
18
|
+
|
19
|
+
def quiet
|
20
|
+
return if @done
|
21
|
+
@done = true
|
22
|
+
|
23
|
+
FastlyNsq.logger.info { 'Terminating quiet listeners' }
|
24
|
+
@listeners.each(&:terminate)
|
25
|
+
end
|
26
|
+
|
27
|
+
PAUSE_TIME = 0.5
|
28
|
+
|
29
|
+
def stop(deadline)
|
30
|
+
quiet
|
31
|
+
|
32
|
+
sleep PAUSE_TIME
|
33
|
+
return if @listeners.empty?
|
34
|
+
|
35
|
+
FastlyNsq.logger.info { 'Pausing to allow workers to finish...' }
|
36
|
+
remaining = deadline - Time.now
|
37
|
+
while remaining > PAUSE_TIME
|
38
|
+
return if @listeners.empty?
|
39
|
+
sleep PAUSE_TIME
|
40
|
+
remaining = deadline - Time.now
|
41
|
+
end
|
42
|
+
return if @listeners.empty?
|
43
|
+
|
44
|
+
hard_shutdown
|
45
|
+
end
|
46
|
+
|
47
|
+
def stopped?
|
48
|
+
@done
|
49
|
+
end
|
50
|
+
|
51
|
+
def listener_stopped(listener)
|
52
|
+
@plock.synchronize do
|
53
|
+
@listeners.delete listener
|
54
|
+
end
|
55
|
+
end
|
56
|
+
|
57
|
+
def listener_killed(listener)
|
58
|
+
@plock.synchronize do
|
59
|
+
@listeners.delete listener
|
60
|
+
unless @done
|
61
|
+
FastlyNsq.logger.info { "recreating listener for: #{listener.identity}" }
|
62
|
+
new_listener = listener.reset_then_dup
|
63
|
+
@listeners << new_listener
|
64
|
+
new_listener.start
|
65
|
+
end
|
66
|
+
end
|
67
|
+
end
|
68
|
+
|
69
|
+
private
|
70
|
+
|
71
|
+
def setup_configured_listeners
|
72
|
+
FastlyNsq.logger.debug { "options #{@options.inspect}" }
|
73
|
+
FastlyNsq.logger.debug { "starting listeners: #{FastlyNsq.topic_map.inspect}" }
|
74
|
+
|
75
|
+
FastlyNsq.topic_map.each_pair do |topic, processor|
|
76
|
+
@listeners << setup_listener(topic, processor)
|
77
|
+
end
|
78
|
+
end
|
79
|
+
|
80
|
+
def setup_listener(topic, processor)
|
81
|
+
FastlyNsq.logger.info { "Listening to topic:'#{topic}' on channel: '#{FastlyNsq.channel}'" }
|
82
|
+
FastlyNsq::Listener.new(
|
83
|
+
{
|
84
|
+
topic: topic,
|
85
|
+
channel: FastlyNsq.channel,
|
86
|
+
processor: processor,
|
87
|
+
preprocessor: FastlyNsq.preprocessor,
|
88
|
+
manager: self,
|
89
|
+
},
|
90
|
+
)
|
91
|
+
end
|
92
|
+
|
93
|
+
def hard_shutdown
|
94
|
+
cleanup = nil
|
95
|
+
@plock.synchronize do
|
96
|
+
cleanup = @listeners.dup
|
97
|
+
end
|
98
|
+
|
99
|
+
unless cleanup.empty?
|
100
|
+
FastlyNsq.logger.warn { "Terminating #{cleanup.size} busy worker threads" }
|
101
|
+
end
|
102
|
+
|
103
|
+
cleanup.each(&:kill)
|
104
|
+
end
|
105
|
+
end
|
@@ -0,0 +1,18 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
module FastlyNsq::SafeThread
|
3
|
+
def safe_thread(name, &block)
|
4
|
+
Thread.new do
|
5
|
+
Thread.current['fastly_nsq_label'] = name
|
6
|
+
watchdog(name, &block)
|
7
|
+
end
|
8
|
+
end
|
9
|
+
|
10
|
+
def watchdog(last_words)
|
11
|
+
yield
|
12
|
+
rescue => ex
|
13
|
+
FastlyNsq.logger.error ex
|
14
|
+
FastlyNsq.logger.error last_words
|
15
|
+
FastlyNsq.logger.error ex.backtrace.join("\n") unless ex.backtrace.nil?
|
16
|
+
raise ex
|
17
|
+
end
|
18
|
+
end
|
data/lib/fastly_nsq/version.rb
CHANGED
@@ -0,0 +1,56 @@
|
|
1
|
+
require 'spec_helper'
|
2
|
+
require 'fastly_nsq/launcher'
|
3
|
+
|
4
|
+
RSpec.describe FastlyNsq::Launcher do
|
5
|
+
let(:launcher) { FastlyNsq::Launcher.new options }
|
6
|
+
let(:manager) { instance_double 'Manager', start: nil, quiet: nil, stop: nil }
|
7
|
+
let(:thread) { instance_double 'Thread' }
|
8
|
+
let(:options) { { joe: 'biden' } }
|
9
|
+
|
10
|
+
before do
|
11
|
+
allow(FastlyNsq::Manager).to receive(:new).and_return(manager)
|
12
|
+
allow(launcher).to receive(:safe_thread).and_return(thread)
|
13
|
+
end
|
14
|
+
|
15
|
+
it 'creates a manager with correct options' do
|
16
|
+
expect(FastlyNsq::Manager).to have_received(:new).with(options)
|
17
|
+
end
|
18
|
+
|
19
|
+
describe '#run' do
|
20
|
+
it 'creates a heartbeat thread' do
|
21
|
+
launcher.run
|
22
|
+
expect(launcher).to have_received(:safe_thread).with('heartbeat')
|
23
|
+
expect(launcher.stopping?).to eq false
|
24
|
+
end
|
25
|
+
|
26
|
+
it 'starts the setup manager' do
|
27
|
+
launcher.run
|
28
|
+
expect(manager).to have_received(:start)
|
29
|
+
expect(launcher.stopping?).to eq false
|
30
|
+
end
|
31
|
+
end
|
32
|
+
|
33
|
+
describe '#quiet' do
|
34
|
+
it 'quites the manager and sets done' do
|
35
|
+
expect(launcher.stopping?).to eq false
|
36
|
+
launcher.quiet
|
37
|
+
expect(manager).to have_received(:quiet)
|
38
|
+
expect(launcher.stopping?).to eq true
|
39
|
+
end
|
40
|
+
end
|
41
|
+
|
42
|
+
describe '#stop' do
|
43
|
+
it 'stops the manager within a deadline' do
|
44
|
+
now = Time.now
|
45
|
+
allow(Time).to receive(:now).and_return(now)
|
46
|
+
launcher.stop
|
47
|
+
expect(manager).to have_received(:stop).with(now + 10)
|
48
|
+
end
|
49
|
+
|
50
|
+
it 'quites the manager' do
|
51
|
+
launcher.stop
|
52
|
+
expect(manager).to have_received(:quiet)
|
53
|
+
expect(launcher.stopping?).to eq true
|
54
|
+
end
|
55
|
+
end
|
56
|
+
end
|
@@ -4,7 +4,7 @@ RSpec.describe FastlyNsq::Listener do
|
|
4
4
|
let(:topic) { 'testing_topic' }
|
5
5
|
let(:channel) { 'testing_channel' }
|
6
6
|
let(:consumer) { FastlyNsq::FakeBackend::Consumer.new topic: topic, channel: channel }
|
7
|
-
let(:logger) { double 'Logger', info: nil }
|
7
|
+
let(:logger) { double 'Logger', info: nil, debug: nil, error: nil }
|
8
8
|
|
9
9
|
module TestMessageProcessor
|
10
10
|
@@messages_processed = []
|
@@ -36,7 +36,7 @@ RSpec.describe FastlyNsq::Listener do
|
|
36
36
|
|
37
37
|
let(:message) { TestMessageProcessor::Message.new 'this is message body' }
|
38
38
|
let(:messages_processed) { TestMessageProcessor.messages_processed }
|
39
|
-
let(:expected_message) { TestMessageProcessor::Message.new
|
39
|
+
let(:expected_message) { TestMessageProcessor::Message.new 'this is message body' }
|
40
40
|
let(:expected_messages) { [expected_message] }
|
41
41
|
|
42
42
|
describe 'instantiating without a consumer' do
|
@@ -53,6 +53,12 @@ RSpec.describe FastlyNsq::Listener do
|
|
53
53
|
end
|
54
54
|
end
|
55
55
|
|
56
|
+
context 'when not passed a manager' do
|
57
|
+
it 'creates a blank manager' do
|
58
|
+
expect(listener.identity[:manager]).to_not be_nil
|
59
|
+
end
|
60
|
+
end
|
61
|
+
|
56
62
|
context 'when using the fake queue and it is empty', fake_queue: true do
|
57
63
|
before do
|
58
64
|
TestMessageProcessor.clear
|
@@ -112,5 +118,74 @@ RSpec.describe FastlyNsq::Listener do
|
|
112
118
|
expect(preprocessor_was_called).to be_truthy
|
113
119
|
end
|
114
120
|
end
|
121
|
+
|
122
|
+
context 'when running as a thread' do
|
123
|
+
let(:manager) { double 'Manager', listener_stopped: nil, listener_killed: nil }
|
124
|
+
let(:thread) { double 'FakeThread', raise: nil, kill: nil, status: 'fake_thread' }
|
125
|
+
let(:listener) do
|
126
|
+
FastlyNsq::Listener.new topic: topic,
|
127
|
+
processor: TestMessageProcessor,
|
128
|
+
logger: logger,
|
129
|
+
consumer: consumer,
|
130
|
+
manager: manager
|
131
|
+
end
|
132
|
+
|
133
|
+
describe 'shutdown' do
|
134
|
+
it 'informs the manager of a shutdown when run once' do
|
135
|
+
listener.go run_once: true
|
136
|
+
|
137
|
+
expect(manager).to have_received(:listener_stopped).with(listener)
|
138
|
+
end
|
139
|
+
end
|
140
|
+
|
141
|
+
before do
|
142
|
+
allow(listener).to receive(:safe_thread).and_return(thread)
|
143
|
+
end
|
144
|
+
|
145
|
+
it 'starts and provide status' do
|
146
|
+
listener.start
|
147
|
+
expect(logger).to have_received(:info)
|
148
|
+
expect(listener.status).to eq 'fake_thread'
|
149
|
+
end
|
150
|
+
|
151
|
+
it 'can describe itself' do
|
152
|
+
id = listener.identity
|
153
|
+
expect(id[:consumer]).to_not be_nil
|
154
|
+
expect(id[:logger]).to be logger
|
155
|
+
expect(id[:manager]).to be manager
|
156
|
+
expect(id[:preprocessor]).to be_nil
|
157
|
+
expect(id[:processor]).to_not be_nil
|
158
|
+
expect(id[:topic]).to_not be_nil
|
159
|
+
end
|
160
|
+
|
161
|
+
it 'can be cleanly duplicated' do
|
162
|
+
new_listener = listener.reset_then_dup
|
163
|
+
|
164
|
+
expect(listener.identity).to eq new_listener.identity
|
165
|
+
end
|
166
|
+
|
167
|
+
it 'can be terminated' do
|
168
|
+
listener.start
|
169
|
+
state = listener.instance_variable_get(:@done)
|
170
|
+
expect(state).to eq false
|
171
|
+
listener.terminate
|
172
|
+
|
173
|
+
state = listener.instance_variable_get(:@done)
|
174
|
+
expect(logger).to have_received(:info).twice
|
175
|
+
expect(state).to eq true
|
176
|
+
end
|
177
|
+
|
178
|
+
it 'can be killed' do
|
179
|
+
listener.start
|
180
|
+
state = listener.instance_variable_get(:@done)
|
181
|
+
expect(state).to eq false
|
182
|
+
listener.kill
|
183
|
+
|
184
|
+
state = listener.instance_variable_get(:@done)
|
185
|
+
expect(logger).to have_received(:info).twice
|
186
|
+
expect(thread).to have_received(:raise).with(FastlyNsq::Shutdown)
|
187
|
+
expect(state).to eq true
|
188
|
+
end
|
189
|
+
end
|
115
190
|
end
|
116
191
|
end
|
@@ -0,0 +1,129 @@
|
|
1
|
+
require 'spec_helper'
|
2
|
+
require 'fastly_nsq/manager'
|
3
|
+
|
4
|
+
RSpec.describe FastlyNsq::Manager do
|
5
|
+
class TestProcessor
|
6
|
+
def self.process(message)
|
7
|
+
FastlyNsq.logger.info("IN PROCESS: #{message}")
|
8
|
+
end
|
9
|
+
end
|
10
|
+
|
11
|
+
let(:listener_1) { instance_double 'Listener1', new: nil, start: nil, terminate: nil, kill: nil, reset_then_dup: listener_dup }
|
12
|
+
let(:listener_2) { instance_double 'Listener2', new: nil, start: nil, terminate: nil, kill: nil, reset_then_dup: listener_dup }
|
13
|
+
let(:listener_dup) { instance_double 'ListenerDup', start: nil }
|
14
|
+
let(:manager) { FastlyNsq::Manager.new options }
|
15
|
+
let(:options) { { joe: 'biden' } }
|
16
|
+
|
17
|
+
let(:configed_topics) do
|
18
|
+
[
|
19
|
+
{ topic: 'warm_topic', klass: TestProcessor },
|
20
|
+
{ topic: 'cool_topic', klass: TestProcessor },
|
21
|
+
]
|
22
|
+
end
|
23
|
+
|
24
|
+
before do
|
25
|
+
logger = Logger.new(STDOUT)
|
26
|
+
logger.level = Logger::FATAL
|
27
|
+
|
28
|
+
FastlyNsq.configure do |config|
|
29
|
+
config.channel = 'william'
|
30
|
+
config.logger = logger
|
31
|
+
config.listener_config do |lc|
|
32
|
+
configed_topics.each do |t|
|
33
|
+
lc.add_topic t[:topic], t[:klass]
|
34
|
+
end
|
35
|
+
end
|
36
|
+
end
|
37
|
+
|
38
|
+
allow(FastlyNsq::Listener).to receive(:new).and_return(listener_1, listener_2)
|
39
|
+
manager.start
|
40
|
+
end
|
41
|
+
|
42
|
+
after do
|
43
|
+
FastlyNsq.reset_config
|
44
|
+
end
|
45
|
+
|
46
|
+
describe '#start' do
|
47
|
+
it 'sets up each configured listener' do
|
48
|
+
configed_topics.each do |t|
|
49
|
+
expect(FastlyNsq::Listener).to have_received(:new).with(
|
50
|
+
{
|
51
|
+
channel: FastlyNsq.channel,
|
52
|
+
manager: manager,
|
53
|
+
preprocessor: FastlyNsq.preprocessor,
|
54
|
+
processor: t[:klass],
|
55
|
+
topic: t[:topic],
|
56
|
+
},
|
57
|
+
)
|
58
|
+
end
|
59
|
+
end
|
60
|
+
|
61
|
+
it 'starts all listeners' do
|
62
|
+
expect(listener_1).to have_received(:start)
|
63
|
+
expect(listener_2).to have_received(:start)
|
64
|
+
end
|
65
|
+
|
66
|
+
it 'populates @listeners with all created listeners' do
|
67
|
+
expect(manager.listeners).to eq Set.new([listener_1, listener_2])
|
68
|
+
end
|
69
|
+
end
|
70
|
+
|
71
|
+
describe '#quiet' do
|
72
|
+
it 'does nothing if stopping' do
|
73
|
+
manager.instance_variable_set(:@done, true)
|
74
|
+
manager.quiet
|
75
|
+
expect(listener_1).to_not have_received(:terminate)
|
76
|
+
expect(listener_2).to_not have_received(:terminate)
|
77
|
+
end
|
78
|
+
|
79
|
+
it 'terminates all listeners' do
|
80
|
+
manager.quiet
|
81
|
+
expect(listener_1).to have_received(:terminate)
|
82
|
+
expect(listener_2).to have_received(:terminate)
|
83
|
+
end
|
84
|
+
end
|
85
|
+
|
86
|
+
describe '#stop' do
|
87
|
+
it 'does nothing if no listeners exist post quiet' do
|
88
|
+
allow(manager).to receive(:quiet)
|
89
|
+
allow(manager).to receive(:hard_shutdown)
|
90
|
+
manager.instance_variable_set(:@listeners, Set.new)
|
91
|
+
|
92
|
+
manager.stop(Time.now)
|
93
|
+
|
94
|
+
expect(manager).to have_received(:quiet)
|
95
|
+
expect(manager).to_not have_received(:hard_shutdown)
|
96
|
+
end
|
97
|
+
|
98
|
+
it 'forces shutdown if listeners remain after deadline' do
|
99
|
+
allow(manager).to receive(:hard_shutdown).and_call_original
|
100
|
+
|
101
|
+
manager.stop(Time.now)
|
102
|
+
|
103
|
+
expect(manager).to have_received(:hard_shutdown)
|
104
|
+
expect(listener_1).to have_received(:kill)
|
105
|
+
expect(listener_2).to have_received(:kill)
|
106
|
+
end
|
107
|
+
end
|
108
|
+
|
109
|
+
describe '#listener_stopped' do
|
110
|
+
it 'removes listeners from set of listeners' do
|
111
|
+
manager.listener_stopped(listener_1)
|
112
|
+
expect(manager.listeners).to eq Set.new([listener_2])
|
113
|
+
end
|
114
|
+
end
|
115
|
+
|
116
|
+
describe '#listener_killed' do
|
117
|
+
it 'removes listeners from set of listeners' do
|
118
|
+
manager.quiet # mark for stopping
|
119
|
+
manager.listener_killed(listener_1)
|
120
|
+
expect(manager.listeners).to eq Set.new([listener_2])
|
121
|
+
end
|
122
|
+
|
123
|
+
it 'creates and starts replacements if not stopping' do
|
124
|
+
manager.listener_killed(listener_1)
|
125
|
+
expect(manager.listeners).to eq Set.new([listener_dup, listener_2])
|
126
|
+
expect(listener_dup).to have_received(:start)
|
127
|
+
end
|
128
|
+
end
|
129
|
+
end
|
metadata
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
2
2
|
name: fastly_nsq
|
3
3
|
version: !ruby/object:Gem::Version
|
4
|
-
version: 0.
|
4
|
+
version: 0.10.0
|
5
5
|
platform: ruby
|
6
6
|
authors:
|
7
7
|
- Tommy O'Neil
|
@@ -11,7 +11,7 @@ authors:
|
|
11
11
|
autorequire:
|
12
12
|
bindir: bin
|
13
13
|
cert_chain: []
|
14
|
-
date: 2017-
|
14
|
+
date: 2017-06-09 00:00:00.000000000 Z
|
15
15
|
dependencies:
|
16
16
|
- !ruby/object:Gem::Dependency
|
17
17
|
name: awesome_print
|
@@ -55,6 +55,20 @@ dependencies:
|
|
55
55
|
- - "~>"
|
56
56
|
- !ruby/object:Gem::Version
|
57
57
|
version: 0.5.0
|
58
|
+
- !ruby/object:Gem::Dependency
|
59
|
+
name: dotenv
|
60
|
+
requirement: !ruby/object:Gem::Requirement
|
61
|
+
requirements:
|
62
|
+
- - ">="
|
63
|
+
- !ruby/object:Gem::Version
|
64
|
+
version: '0'
|
65
|
+
type: :development
|
66
|
+
prerelease: false
|
67
|
+
version_requirements: !ruby/object:Gem::Requirement
|
68
|
+
requirements:
|
69
|
+
- - ">="
|
70
|
+
- !ruby/object:Gem::Version
|
71
|
+
version: '0'
|
58
72
|
- !ruby/object:Gem::Dependency
|
59
73
|
name: overcommit
|
60
74
|
requirement: !ruby/object:Gem::Requirement
|
@@ -175,7 +189,8 @@ dependencies:
|
|
175
189
|
version: 2.0.5
|
176
190
|
description: Helper classes for Fastly's NSQ Services
|
177
191
|
email: tommy@fastly.com
|
178
|
-
executables:
|
192
|
+
executables:
|
193
|
+
- fastly_nsq
|
179
194
|
extensions: []
|
180
195
|
extra_rdoc_files: []
|
181
196
|
files:
|
@@ -192,25 +207,35 @@ files:
|
|
192
207
|
- LICENSE.txt
|
193
208
|
- README.md
|
194
209
|
- Rakefile
|
210
|
+
- bin/fastly_nsq
|
195
211
|
- env_configuration_for_local_gem_tests.yml
|
212
|
+
- example_config_class.rb
|
196
213
|
- examples/.sample.env
|
197
214
|
- examples/Rakefile
|
198
215
|
- fastly_nsq.gemspec
|
199
216
|
- lib/fastly_nsq.rb
|
217
|
+
- lib/fastly_nsq/cli.rb
|
200
218
|
- lib/fastly_nsq/consumer.rb
|
201
219
|
- lib/fastly_nsq/fake_backend.rb
|
220
|
+
- lib/fastly_nsq/launcher.rb
|
202
221
|
- lib/fastly_nsq/listener.rb
|
222
|
+
- lib/fastly_nsq/listener/config.rb
|
223
|
+
- lib/fastly_nsq/manager.rb
|
203
224
|
- lib/fastly_nsq/message.rb
|
204
225
|
- lib/fastly_nsq/messenger.rb
|
205
226
|
- lib/fastly_nsq/producer.rb
|
206
227
|
- lib/fastly_nsq/rake_task.rb
|
228
|
+
- lib/fastly_nsq/safe_thread.rb
|
207
229
|
- lib/fastly_nsq/strategy.rb
|
208
230
|
- lib/fastly_nsq/tls_options.rb
|
209
231
|
- lib/fastly_nsq/version.rb
|
232
|
+
- spec/lib/fastly_nsq/cli_spec.rb
|
210
233
|
- spec/lib/fastly_nsq/consumer_spec.rb
|
211
234
|
- spec/lib/fastly_nsq/fake_backend_spec.rb
|
212
235
|
- spec/lib/fastly_nsq/fastly_nsq_spec.rb
|
236
|
+
- spec/lib/fastly_nsq/launcher_spec.rb
|
213
237
|
- spec/lib/fastly_nsq/listener_spec.rb
|
238
|
+
- spec/lib/fastly_nsq/manager_spec.rb
|
214
239
|
- spec/lib/fastly_nsq/message_spec.rb
|
215
240
|
- spec/lib/fastly_nsq/messenger_spec.rb
|
216
241
|
- spec/lib/fastly_nsq/producer_spec.rb
|