fastly_nsq 0.13.2 → 1.0.2

Sign up to get free protection for your applications and to get access to all the features.
Files changed (68) hide show
  1. checksums.yaml +5 -5
  2. data/.env +4 -0
  3. data/.overcommit.yml +3 -3
  4. data/.rubocop.yml +11 -1
  5. data/.travis.yml +8 -1
  6. data/Gemfile +9 -0
  7. data/README.md +52 -82
  8. data/Rakefile +2 -0
  9. data/bin/fastly_nsq +1 -0
  10. data/docker-compose.yml +23 -0
  11. data/examples/.sample.env +0 -3
  12. data/fastly_nsq.gemspec +7 -8
  13. data/lib/fastly_nsq.rb +44 -50
  14. data/lib/fastly_nsq/cli.rb +20 -14
  15. data/lib/fastly_nsq/consumer.rb +26 -30
  16. data/lib/fastly_nsq/feeder.rb +16 -0
  17. data/lib/fastly_nsq/http/nsqd.rb +7 -1
  18. data/lib/fastly_nsq/http/nsqlookupd.rb +1 -1
  19. data/lib/fastly_nsq/launcher.rb +31 -23
  20. data/lib/fastly_nsq/listener.rb +34 -103
  21. data/lib/fastly_nsq/manager.rb +48 -72
  22. data/lib/fastly_nsq/message.rb +2 -0
  23. data/lib/fastly_nsq/messenger.rb +5 -5
  24. data/lib/fastly_nsq/priority_queue.rb +12 -0
  25. data/lib/fastly_nsq/priority_thread_pool.rb +32 -0
  26. data/lib/fastly_nsq/producer.rb +52 -32
  27. data/lib/fastly_nsq/testing.rb +239 -0
  28. data/lib/fastly_nsq/tls_options.rb +2 -0
  29. data/lib/fastly_nsq/version.rb +3 -1
  30. data/spec/{lib/fastly_nsq/cli_spec.rb → cli_spec.rb} +2 -0
  31. data/spec/consumer_spec.rb +59 -0
  32. data/spec/fastly_nsq_spec.rb +72 -0
  33. data/spec/feeder_spec.rb +22 -0
  34. data/spec/{lib/fastly_nsq/http → http}/nsqd_spec.rb +1 -1
  35. data/spec/{lib/fastly_nsq/http → http}/nsqlookupd_spec.rb +1 -1
  36. data/spec/{lib/fastly_nsq/http_spec.rb → http_spec.rb} +3 -1
  37. data/spec/integration_spec.rb +48 -0
  38. data/spec/launcher_spec.rb +50 -0
  39. data/spec/listener_spec.rb +184 -0
  40. data/spec/manager_spec.rb +111 -0
  41. data/spec/matchers/delegate.rb +32 -0
  42. data/spec/{lib/fastly_nsq/message_spec.rb → message_spec.rb} +2 -0
  43. data/spec/{lib/fastly_nsq/messenger_spec.rb → messenger_spec.rb} +7 -5
  44. data/spec/priority_thread_pool_spec.rb +19 -0
  45. data/spec/producer_spec.rb +94 -0
  46. data/spec/spec_helper.rb +32 -28
  47. data/spec/support/http.rb +37 -0
  48. data/spec/support/webmock.rb +22 -0
  49. data/spec/{lib/fastly_nsq/tls_options_spec.rb → tls_options_spec.rb} +2 -0
  50. metadata +54 -96
  51. data/env_configuration_for_local_gem_tests.yml +0 -5
  52. data/example_config_class.rb +0 -20
  53. data/examples/Rakefile +0 -41
  54. data/lib/fastly_nsq/fake_backend.rb +0 -114
  55. data/lib/fastly_nsq/listener/config.rb +0 -35
  56. data/lib/fastly_nsq/rake_task.rb +0 -78
  57. data/lib/fastly_nsq/strategy.rb +0 -36
  58. data/spec/lib/fastly_nsq/consumer_spec.rb +0 -72
  59. data/spec/lib/fastly_nsq/fake_backend_spec.rb +0 -135
  60. data/spec/lib/fastly_nsq/fastly_nsq_spec.rb +0 -10
  61. data/spec/lib/fastly_nsq/launcher_spec.rb +0 -56
  62. data/spec/lib/fastly_nsq/listener_spec.rb +0 -213
  63. data/spec/lib/fastly_nsq/manager_spec.rb +0 -127
  64. data/spec/lib/fastly_nsq/producer_spec.rb +0 -60
  65. data/spec/lib/fastly_nsq/rake_task_spec.rb +0 -142
  66. data/spec/lib/fastly_nsq/strategy_spec.rb +0 -36
  67. data/spec/lib/fastly_nsq_spec.rb +0 -18
  68. data/spec/support/env_helpers.rb +0 -15
@@ -25,19 +25,17 @@ class FastlyNsq::CLI
25
25
 
26
26
  def run
27
27
  startup
28
- begin
29
- # Multithreading begins here ----
30
- launcher.run
31
-
32
- read_loop
33
- rescue Interrupt
34
- FastlyNsq.logger.info 'Shutting down'
35
- launcher.stop
36
- # Explicitly exit so busy Processor threads can't block
37
- # process shutdown.
38
- FastlyNsq.logger.info 'Bye!'
39
- exit(0)
40
- end
28
+
29
+ launcher.beat
30
+
31
+ read_loop
32
+ rescue Interrupt
33
+ FastlyNsq.logger.info 'Shutting down'
34
+ launcher.stop
35
+ # Explicitly exit so busy Processor threads can't block
36
+ # process shutdown.
37
+ FastlyNsq.logger.info 'Bye!'
38
+ exit(0)
41
39
  end
42
40
 
43
41
  private
@@ -75,6 +73,10 @@ class FastlyNsq::CLI
75
73
  opts = {}
76
74
 
77
75
  @parser = OptionParser.new do |o|
76
+ o.on '-c', '--concurrency COUNT', 'Number of threads used to process messages' do |arg|
77
+ opts[:max_threads] = arg
78
+ end
79
+
78
80
  o.on '-d', '--daemon', 'Daemonize process' do |arg|
79
81
  opts[:daemonize] = arg
80
82
  end
@@ -178,7 +180,7 @@ class FastlyNsq::CLI
178
180
  raise Interrupt
179
181
  when 'USR1'
180
182
  FastlyNsq.logger.info 'Received USR1, no longer accepting new work'
181
- launcher.quiet
183
+ launcher.stop_listeners
182
184
  when 'TTIN'
183
185
  handle_ttin
184
186
  end
@@ -247,6 +249,10 @@ class FastlyNsq::CLI
247
249
  options[:logfile]
248
250
  end
249
251
 
252
+ def max_threads
253
+ options[:max_threads]
254
+ end
255
+
250
256
  def pidfile
251
257
  options[:pidfile]
252
258
  end
@@ -1,43 +1,39 @@
1
- require 'forwardable'
1
+ # frozen_string_literal: true
2
2
 
3
- module FastlyNsq
4
- class Consumer
5
- extend Forwardable
6
- def_delegator :connection, :pop
7
- def_delegator :connection, :pop_without_blocking
8
- def_delegator :connection, :size
9
- def_delegator :connection, :terminate
3
+ class FastlyNsq::Consumer
4
+ extend Forwardable
10
5
 
11
- def initialize(topic:, channel:, tls_options: nil, connector: nil)
12
- @topic = topic
13
- @channel = channel
14
- @tls_options = TlsOptions.as_hash(tls_options)
15
- @connector = connector
16
- connection
17
- end
6
+ DEFAULT_CONNECTION_TIMEOUT = 5 # seconds
18
7
 
19
- def empty?
20
- connection.size.zero?
21
- end
8
+ attr_reader :channel, :topic, :connection, :connect_timeout
22
9
 
23
- private
10
+ def_delegators :connection, :size, :terminate, :connected?, :pop, :pop_without_blocking
24
11
 
25
- attr_reader :channel, :topic, :tls_options
12
+ def initialize(topic:, channel:, queue: nil, tls_options: nil, connect_timeout: DEFAULT_CONNECTION_TIMEOUT)
13
+ @topic = topic
14
+ @channel = channel
15
+ @tls_options = FastlyNsq::TlsOptions.as_hash(tls_options)
16
+ @connect_timeout = connect_timeout
26
17
 
27
- def connection
28
- @connection ||= connector.new(params)
29
- end
18
+ @connection = connect(queue)
19
+ end
20
+
21
+ def empty?
22
+ size.zero?
23
+ end
24
+
25
+ private
30
26
 
31
- def connector
32
- @connector || FastlyNsq.strategy::Consumer
33
- end
27
+ attr_reader :tls_options
34
28
 
35
- def params
29
+ def connect(queue)
30
+ Nsq::Consumer.new(
36
31
  {
37
- nsqlookupd: ENV.fetch('NSQLOOKUPD_HTTP_ADDRESS').split(',').map(&:strip),
32
+ nsqlookupd: FastlyNsq.lookupd_http_addresses,
38
33
  topic: topic,
39
34
  channel: channel,
40
- }.merge(tls_options)
41
- end
35
+ queue: queue,
36
+ }.merge(tls_options),
37
+ )
42
38
  end
43
39
  end
@@ -0,0 +1,16 @@
1
+ # frozen_string_literal: true
2
+
3
+ class FastlyNsq::Feeder
4
+ attr_reader :processor, :priority
5
+
6
+ def initialize(processor, priority)
7
+ @processor = processor
8
+ @priority = priority
9
+ end
10
+
11
+ # @see http://ruby-concurrency.github.io/concurrent-ruby/Concurrent/ThreadPoolExecutor.html#post-instance_method
12
+ # @see {Nsq::Connection#read_loop}
13
+ def push(message)
14
+ FastlyNsq.manager.pool.post(priority) { processor.call(message) }
15
+ end
16
+ end
@@ -8,7 +8,13 @@ class FastlyNsq::Http
8
8
  def_delegator :client, :get
9
9
  def_delegator :client, :post
10
10
 
11
- BASE_NSQD_URL = ENV.fetch 'NSQD_URL', "https://#{ENV.fetch('NSQD_HTTPS_ADDRESS', '')}"
11
+ BASE_NSQD_URL = ENV.fetch('NSQD_URL') do
12
+ if ENV['NSQD_HTTPS_ADDRESS']
13
+ "https://#{ENV.fetch('NSQD_HTTPS_ADDRESS')}"
14
+ else
15
+ "http://#{ENV.fetch('NSQD_HTTP_ADDRESS')}"
16
+ end
17
+ end
12
18
  VALID_FORMATS = %w[text json].freeze
13
19
 
14
20
  ##
@@ -7,7 +7,7 @@ class FastlyNsq::Http
7
7
  extend Forwardable
8
8
  def_delegator :client, :get
9
9
 
10
- BASE_NSQLOOKUPD_URL = "http://#{ENV.fetch('NSQLOOKUPD_HTTP_ADDRESS', '').split(',')[0]}".freeze
10
+ BASE_NSQLOOKUPD_URL = "http://#{ENV.fetch('NSQLOOKUPD_HTTP_ADDRESS', '').split(',')[0]}"
11
11
 
12
12
  ##
13
13
  # List of producers for a given topic
@@ -5,29 +5,34 @@ require 'fastly_nsq/safe_thread'
5
5
  class FastlyNsq::Launcher
6
6
  include FastlyNsq::SafeThread
7
7
 
8
- def initialize(options)
9
- @done = false
10
- @manager = FastlyNsq::Manager.new options
11
- @options = options
8
+ attr_reader :timeout, :logger
9
+ attr_accessor :pulse
10
+
11
+ def manager
12
+ FastlyNsq.manager
12
13
  end
13
14
 
14
- def run
15
- @thread = safe_thread('heartbeat', &method(:start_heartbeat))
16
- @manager.start
15
+ def initialize(timeout: 5, pulse: 5, logger: FastlyNsq.logger, **options)
16
+ @done = false
17
+ @timeout = timeout
18
+ @pulse = pulse
19
+ @logger = logger
20
+
21
+ FastlyNsq.manager = FastlyNsq::Manager.new(options)
17
22
  end
18
23
 
19
- def quiet
20
- @done = true
21
- @manager.quiet
24
+ def beat
25
+ @heartbeat ||= safe_thread('heartbeat', &method(:start_heartbeat))
22
26
  end
23
27
 
24
- # Shuts down the process. This method does not
25
- # return until all work is complete and cleaned up.
26
- # It can take up to the timeout to complete.
27
28
  def stop
28
- deadline = Time.now + @options.fetch(:timeout, 5)
29
- quiet
30
- @manager.stop deadline
29
+ @done = true
30
+ manager.terminate(timeout)
31
+ end
32
+
33
+ def stop_listeners
34
+ @done = true
35
+ manager.stop_listeners
31
36
  end
32
37
 
33
38
  def stopping?
@@ -37,25 +42,28 @@ class FastlyNsq::Launcher
37
42
  private
38
43
 
39
44
  def heartbeat
40
- FastlyNsq.logger.debug do
45
+ logger.debug do
41
46
  [
42
47
  'HEARTBEAT:',
43
- 'thread_status:', @manager.listeners.map(&:status).join(', '),
44
- 'listener_count:', @manager.listeners.count
48
+ 'busy:', manager.pool.length,
49
+ 'processed:', manager.pool.completed_task_count,
50
+ 'max_threads:', manager.pool.max_length,
51
+ 'max_queue_size:', manager.pool.largest_length,
52
+ 'listeners:', manager.listeners.count
45
53
  ].join(' ')
46
54
  end
47
55
 
48
56
  # TODO: Check the health of the system overall and kill it if needed
49
57
  # ::Process.kill('dieing because...', $$)
50
58
  rescue => e
51
- FastlyNsq.logger.error "heartbeat error: #{e.message}"
59
+ logger.error "Heartbeat error: #{e.message}"
52
60
  end
53
61
 
54
62
  def start_heartbeat
55
- loop do
63
+ until manager.stopped?
56
64
  heartbeat
57
- sleep 5
65
+ sleep pulse
58
66
  end
59
- FastlyNsq.logger.info('Heartbeat stopping...')
67
+ logger.info('Heartbeat stopping...')
60
68
  end
61
69
  end
@@ -1,117 +1,48 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require 'fastly_nsq/message'
4
- require 'fastly_nsq/manager'
5
- require 'fastly_nsq/safe_thread'
6
- require 'fastly_nsq/listener/config'
3
+ class FastlyNsq::Listener
4
+ extend Forwardable
7
5
 
8
- module FastlyNsq
9
- class Listener
10
- include FastlyNsq::SafeThread
6
+ DEFAULT_PRIORITY = 0
7
+ DEFAULT_CONNECTION_TIMEOUT = 5 # seconds
11
8
 
12
- def self.listen_to(*args)
13
- new(*args).go
14
- end
9
+ def_delegators :consumer, :connected?
15
10
 
16
- def initialize(topic:, processor:, channel: nil, consumer: nil, **options)
17
- @consumer = consumer || FastlyNsq::Consumer.new(topic: topic, channel: channel)
18
- @done = false
19
- @logger = options.fetch :logger, FastlyNsq.logger
20
- @manager = options[:manager] || FastlyNsq::Manager.new
21
- @preprocessor = options[:preprocessor]
22
- @processor = processor
23
- @thread = nil
24
- @topic = topic
25
- end
11
+ attr_reader :preprocessor, :topic, :processor, :priority, :channel, :logger, :consumer
26
12
 
27
- def identity
28
- {
29
- consumer: @consumer,
30
- manager: @manager,
31
- preprocessor: @preprocessor,
32
- processor: @processor,
33
- topic: @topic,
34
- }
35
- end
13
+ def initialize(topic:, processor:, preprocessor: FastlyNsq.preprocessor, channel: FastlyNsq.channel, consumer: nil,
14
+ logger: FastlyNsq.logger, priority: DEFAULT_PRIORITY, connect_timeout: DEFAULT_CONNECTION_TIMEOUT)
36
15
 
37
- def reset_then_dup
38
- reset
39
- dup
40
- end
16
+ raise ArgumentError, "processor #{processor.inspect} does not respond to #call" unless processor.respond_to?(:call)
17
+ raise ArgumentError, "priority #{priority.inspect} must be a Integer" unless priority.is_a?(Integer)
41
18
 
42
- def start
43
- @logger.info { "> Listener Started: topic #{@topic}" }
44
- @thread ||= safe_thread('listener', &method(:go))
45
- end
19
+ @channel = channel
20
+ @logger = logger
21
+ @preprocessor = preprocessor
22
+ @processor = processor
23
+ @topic = topic
24
+ @priority = priority
46
25
 
47
- def go(run_once: false)
48
- until @done
49
- next_message do |message|
50
- log message
51
- preprocess message
52
- @processor.process message
53
- end
26
+ @consumer = consumer || FastlyNsq::Consumer.new(topic: topic,
27
+ connect_timeout: connect_timeout,
28
+ channel: channel,
29
+ queue: FastlyNsq::Feeder.new(self, priority))
54
30
 
55
- terminate if run_once
56
- end
57
-
58
- @manager.listener_stopped(self)
59
- rescue FastlyNsq::Shutdown
60
- @manager.listener_stopped(self)
61
- rescue Exception => e # rubocop:disable Lint/RescueException
62
- @logger.error e.inspect
63
- @manager.listener_killed(self)
64
- end
65
-
66
- def status
67
- @thread.status if @thread
68
- end
69
-
70
- def terminate
71
- @done = true
72
- cleanup
73
- return unless @thread
74
- @logger.info "< Listener TERM: topic #{@topic}"
75
- # Interrupt a Consumer blocking in pop with no messages otherwise it will never shutdown
76
- @thread.raise FastlyNsq::Shutdown if @consumer.empty?
77
- end
78
-
79
- def kill
80
- @done = true
81
- cleanup
82
- return unless @thread
83
- @logger.info "< Listener KILL: topic #{@topic}"
84
- @thread.raise FastlyNsq::Shutdown
85
- end
86
-
87
- private
88
-
89
- def log(message)
90
- @logger.info "[NSQ] Message received on topic [#{@topic}]: #{message}" if @logger
91
- end
92
-
93
- def cleanup
94
- @consumer.terminate
95
- @logger.info "< Consumer terminated: topic [#{@topic}]"
96
- end
97
-
98
- def next_message
99
- nsq_message = @consumer.pop # TODO: consumer.pop do |message|
100
- message = FastlyNsq::Message.new(nsq_message)
101
- result = yield message
102
- message.finish if result
103
- end
31
+ FastlyNsq.manager.add_listener(self)
32
+ end
104
33
 
105
- def preprocess(message)
106
- @preprocessor.call(message) if @preprocessor
107
- end
34
+ def call(nsq_message)
35
+ message = FastlyNsq::Message.new(nsq_message)
36
+ logger.info "[NSQ] Message received on topic [#{topic}]: #{message}"
37
+ preprocessor&.call(message)
38
+ result = processor.call(message)
39
+ message.finish if result
40
+ message
41
+ end
108
42
 
109
- def reset
110
- @done = false
111
- @thread = nil
112
- self
113
- end
43
+ def terminate
44
+ return unless connected?
45
+ consumer.terminate
46
+ logger.info "< Consumer terminated: topic [#{topic}]"
114
47
  end
115
48
  end
116
-
117
- class FastlyNsq::Shutdown < StandardError; end
@@ -1,104 +1,80 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require 'set'
4
-
5
3
  class FastlyNsq::Manager
6
- attr_reader :listeners
4
+ DEADLINE = 30
5
+ DEFAULT_POOL_SIZE = 5
6
+
7
+ attr_reader :done, :pool, :logger
7
8
 
8
- def initialize(options = {})
9
- @options = options
10
- @done = false
11
- @listeners = Set.new
12
- @plock = Mutex.new
9
+ def initialize(logger: FastlyNsq.logger, **pool_options)
10
+ @done = false
11
+ @logger = logger
12
+ @pool = FastlyNsq::PriorityThreadPool.new(
13
+ { fallback_policy: :caller_runs, max_threads: DEFAULT_POOL_SIZE }.merge(pool_options),
14
+ )
13
15
  end
14
16
 
15
- def start
16
- setup_configured_listeners
17
- @listeners.each(&:start)
17
+ def topic_listeners
18
+ @topic_listeners ||= {}
18
19
  end
19
20
 
20
- def quiet
21
- return if @done
22
- @done = true
21
+ def topics
22
+ topic_listeners.keys
23
+ end
23
24
 
24
- FastlyNsq.logger.info { 'Terminating quiet listeners' }
25
- @listeners.each(&:terminate)
25
+ def listeners
26
+ topic_listeners.values.to_set
26
27
  end
27
28
 
28
- PAUSE_TIME = 0.5
29
+ def terminate(deadline = DEADLINE)
30
+ return if done
29
31
 
30
- def stop(deadline)
31
- quiet
32
+ stop_listeners
32
33
 
33
- sleep PAUSE_TIME
34
- return if @listeners.empty?
34
+ return if pool.shutdown?
35
35
 
36
- FastlyNsq.logger.info { 'Pausing to allow workers to finish...' }
37
- remaining = deadline - Time.now
38
- while remaining > PAUSE_TIME
39
- return if @listeners.empty?
40
- sleep PAUSE_TIME
41
- remaining = deadline - Time.now
42
- end
43
- return if @listeners.empty?
36
+ stop_processing(deadline)
44
37
 
45
- hard_shutdown
38
+ @done = true
46
39
  end
47
40
 
48
41
  def stopped?
49
- @done
42
+ done
50
43
  end
51
44
 
52
- def listener_stopped(listener)
53
- @plock.synchronize do
54
- @listeners.delete listener
55
- end
56
- end
45
+ def add_listener(listener)
46
+ logger.info { "Listening to topic:'#{listener.topic}' on channel: '#{listener.channel}'" }
57
47
 
58
- def listener_killed(listener)
59
- @plock.synchronize do
60
- @listeners.delete listener
61
- unless @done
62
- FastlyNsq.logger.info { "recreating listener for: #{listener.identity}" }
63
- new_listener = listener.reset_then_dup
64
- @listeners << new_listener
65
- new_listener.start
66
- end
48
+ if topic_listeners[listener.topic]
49
+ logger.warn { "topic: #{listener.topic} was added more than once" }
67
50
  end
68
- end
69
-
70
- private
71
51
 
72
- def setup_configured_listeners
73
- FastlyNsq.logger.debug { "options #{@options.inspect}" }
74
- FastlyNsq.logger.debug { "starting listeners: #{FastlyNsq.topic_map.inspect}" }
52
+ topic_listeners[listener.topic] = listener
53
+ end
75
54
 
76
- FastlyNsq.topic_map.each_pair do |topic, processor|
77
- @listeners << setup_listener(topic, processor)
78
- end
55
+ def transfer(new_manager, deadline: DEADLINE)
56
+ new_manager.topic_listeners.merge!(topic_listeners)
57
+ stop_processing(deadline)
58
+ topic_listeners.clear
59
+ @done = true
79
60
  end
80
61
 
81
- def setup_listener(topic, processor)
82
- FastlyNsq.logger.info { "Listening to topic:'#{topic}' on channel: '#{FastlyNsq.channel}'" }
83
- FastlyNsq::Listener.new(
84
- topic: topic,
85
- channel: FastlyNsq.channel,
86
- processor: processor,
87
- preprocessor: FastlyNsq.preprocessor,
88
- manager: self,
89
- )
62
+ def stop_listeners
63
+ logger.info { 'Stopping listeners' }
64
+ listeners.each(&:terminate)
65
+ topic_listeners.clear
90
66
  end
91
67
 
92
- def hard_shutdown
93
- cleanup = nil
94
- @plock.synchronize do
95
- cleanup = @listeners.dup
96
- end
68
+ protected
97
69
 
98
- unless cleanup.empty?
99
- FastlyNsq.logger.warn { "Terminating #{cleanup.size} busy worker threads" }
100
- end
70
+ def stop_processing(deadline)
71
+ logger.info { 'Stopping processors' }
72
+ pool.shutdown
73
+
74
+ logger.info { 'Waiting for processors to finish...' }
75
+ return if pool.wait_for_termination(deadline)
101
76
 
102
- cleanup.each(&:kill)
77
+ logger.info { 'Killing processors...' }
78
+ pool.kill
103
79
  end
104
80
  end