distribot 0.1.1

Sign up to get free protection for your applications and to get access to all the features.
Files changed (56) hide show
  1. checksums.yaml +7 -0
  2. data/.gitignore +13 -0
  3. data/.rspec +3 -0
  4. data/.rubocop.yml +3 -0
  5. data/.travis.yml +10 -0
  6. data/Dockerfile +9 -0
  7. data/Gemfile +6 -0
  8. data/Gemfile.lock +153 -0
  9. data/LICENSE +201 -0
  10. data/README.md +107 -0
  11. data/Rakefile +16 -0
  12. data/bin/distribot.flow-created +6 -0
  13. data/bin/distribot.flow-finished +6 -0
  14. data/bin/distribot.handler-finished +6 -0
  15. data/bin/distribot.phase-finished +6 -0
  16. data/bin/distribot.phase-started +6 -0
  17. data/bin/distribot.task-finished +6 -0
  18. data/distribot.gemspec +35 -0
  19. data/docker-compose.yml +29 -0
  20. data/examples/controller +168 -0
  21. data/examples/distribot.eye +49 -0
  22. data/examples/status +38 -0
  23. data/examples/worker +135 -0
  24. data/lib/distribot/connector.rb +162 -0
  25. data/lib/distribot/flow.rb +200 -0
  26. data/lib/distribot/flow_created_handler.rb +12 -0
  27. data/lib/distribot/flow_finished_handler.rb +12 -0
  28. data/lib/distribot/handler.rb +40 -0
  29. data/lib/distribot/handler_finished_handler.rb +29 -0
  30. data/lib/distribot/phase.rb +46 -0
  31. data/lib/distribot/phase_finished_handler.rb +19 -0
  32. data/lib/distribot/phase_handler.rb +15 -0
  33. data/lib/distribot/phase_started_handler.rb +69 -0
  34. data/lib/distribot/task_finished_handler.rb +37 -0
  35. data/lib/distribot/worker.rb +148 -0
  36. data/lib/distribot.rb +108 -0
  37. data/provision/nodes.sh +80 -0
  38. data/provision/templates/fluentd.conf +27 -0
  39. data/spec/distribot/bunny_connector_spec.rb +196 -0
  40. data/spec/distribot/connection_sharer_spec.rb +34 -0
  41. data/spec/distribot/connector_spec.rb +63 -0
  42. data/spec/distribot/flow_created_handler_spec.rb +32 -0
  43. data/spec/distribot/flow_finished_handler_spec.rb +32 -0
  44. data/spec/distribot/flow_spec.rb +661 -0
  45. data/spec/distribot/handler_finished_handler_spec.rb +112 -0
  46. data/spec/distribot/handler_spec.rb +32 -0
  47. data/spec/distribot/module_spec.rb +163 -0
  48. data/spec/distribot/multi_subscription_spec.rb +37 -0
  49. data/spec/distribot/phase_finished_handler_spec.rb +61 -0
  50. data/spec/distribot/phase_started_handler_spec.rb +150 -0
  51. data/spec/distribot/subscription_spec.rb +40 -0
  52. data/spec/distribot/task_finished_handler_spec.rb +71 -0
  53. data/spec/distribot/worker_spec.rb +281 -0
  54. data/spec/fixtures/simple_flow.json +49 -0
  55. data/spec/spec_helper.rb +74 -0
  56. metadata +371 -0
@@ -0,0 +1,200 @@
1
+
2
+ module Distribot
3
+ class NotRunningError < StandardError; end
4
+ class NotPausedError < StandardError; end
5
+
6
+ # rubocop:disable ClassLength
7
+ class Flow
8
+ attr_accessor :id, :phases, :consumer, :finished_callback_queue, :created_at, :data
9
+
10
+ def initialize(attrs = {})
11
+ self.id = attrs[:id]
12
+ self.created_at = attrs[:created_at] unless attrs[:created_at].nil?
13
+ self.phases = []
14
+ (attrs[:phases] || []).each do |options|
15
+ add_phase(options)
16
+ end
17
+ self.data = attrs[:data]
18
+ end
19
+
20
+ def self.active
21
+ redis.smembers('distribot.flows.active').map do |id|
22
+ self.find(id)
23
+ end
24
+ end
25
+
26
+ def self.create!(attrs = {})
27
+ new(attrs).save!
28
+ end
29
+
30
+ # rubocop:disable Metrics/AbcSize
31
+ def save!(&block)
32
+ fail StandardError, 'Cannot re-save a flow' if id
33
+ self.id = SecureRandom.uuid
34
+ record_id = redis_id + ':definition'
35
+ self.created_at = Time.now.to_f
36
+
37
+ # Actually save the record:
38
+ redis.set record_id, serialize
39
+
40
+ # Transition into the first phase:
41
+ add_transition to: current_phase, timestamp: Time.now.utc.to_f
42
+
43
+ # Add our id to the list of active flows:
44
+ redis.sadd 'distribot.flows.active', id
45
+ redis.incr('distribot.flows.running')
46
+
47
+ # Announce our arrival to the rest of the system:
48
+ Distribot.publish! 'distribot.flow.created', flow_id: id
49
+
50
+ wait_for_flow_to_finish(block) if block_given?
51
+ self
52
+ end
53
+
54
+ def self.find(id)
55
+ redis_id = Distribot.redis_id('flow', id)
56
+ raw_json = redis.get("#{redis_id}:definition") || return
57
+ new(
58
+ JSON.parse(raw_json, symbolize_names: true)
59
+ )
60
+ end
61
+
62
+ def add_phase(options = {})
63
+ phases << Phase.new(options)
64
+ end
65
+
66
+ def phase(name)
67
+ phases.find { |x| x.name == name }
68
+ end
69
+
70
+ def pause!
71
+ fail NotRunningError, 'Cannot pause unless running' unless running?
72
+ add_transition(
73
+ from: current_phase,
74
+ to: 'paused',
75
+ timestamp: Time.now.utc.to_f
76
+ )
77
+ end
78
+
79
+ def resume!
80
+ fail NotPausedError, 'Cannot resume unless paused' unless paused?
81
+
82
+ # Find the last transition before we were paused:
83
+ prev_phase = transitions.reverse.find { |x| x.to != 'paused' }
84
+ # Back to where we once belonged
85
+ add_transition(
86
+ from: 'paused', to: prev_phase.to, timestamp: Time.now.utc.to_f
87
+ )
88
+ end
89
+
90
+ def paused?
91
+ current_phase == 'paused'
92
+ end
93
+
94
+ def cancel!
95
+ fail NotRunningError, 'Cannot cancel unless running' unless running?
96
+ add_transition(
97
+ from: current_phase, to: 'canceled', timestamp: Time.now.utc.to_f
98
+ )
99
+ redis.decr 'distribot.flows.running'
100
+ redis.srem 'distribot.flows.active', id
101
+ end
102
+
103
+ def canceled?
104
+ current_phase == 'canceled'
105
+ end
106
+
107
+ def running?
108
+ ! (paused? || canceled? || finished?)
109
+ end
110
+
111
+ def redis_id
112
+ @redis_id ||= Distribot.redis_id('flow', id)
113
+ end
114
+
115
+ def transition_to!(phase)
116
+ previous_transition = transitions.last
117
+ prev = previous_transition ? previous_transition[:to] : nil
118
+ add_transition(from: prev, to: phase, timestamp: Time.now.utc.to_f)
119
+ Distribot.publish!(
120
+ 'distribot.flow.phase.started',
121
+ flow_id: id,
122
+ phase: phase
123
+ )
124
+ end
125
+
126
+ def add_transition(item)
127
+ redis.sadd(redis_id + ':transitions', item.to_json)
128
+ end
129
+
130
+ def transitions
131
+ redis.smembers(redis_id + ':transitions').map do |item|
132
+ OpenStruct.new JSON.parse(item, symbolize_names: true)
133
+ end.sort_by(&:timestamp)
134
+ end
135
+
136
+ def current_phase
137
+ latest_transition = transitions.last
138
+ if latest_transition
139
+ latest_transition.to
140
+ else
141
+ phases.find(&:is_initial).name
142
+ end
143
+ end
144
+
145
+ def next_phase
146
+ current = current_phase
147
+ phases.find { |x| x.name == current }.transitions_to
148
+ end
149
+
150
+ def finished?
151
+ phase(transitions.last.to).is_final
152
+ end
153
+
154
+ def stubbornly(task, &block)
155
+ loop do
156
+ begin
157
+ return block.call
158
+ rescue NoMethodError => e
159
+ warn "Error during #{task}: #{e} --- #{e.backtrace.join("\n")}"
160
+ sleep 1
161
+ end
162
+ end
163
+ end
164
+
165
+ private
166
+
167
+ def wait_for_flow_to_finish(block)
168
+ Thread.new do
169
+ loop do
170
+ sleep 1
171
+ if finished? || canceled?
172
+ block.call(flow_id: id)
173
+ break
174
+ end
175
+ end
176
+ end
177
+ end
178
+
179
+ def self.redis
180
+ Distribot.redis
181
+ end
182
+
183
+ def redis
184
+ self.class.redis
185
+ end
186
+
187
+ def serialize
188
+ to_hash.to_json
189
+ end
190
+
191
+ def to_hash
192
+ {
193
+ id: id,
194
+ created_at: created_at,
195
+ phases: phases.map(&:to_hash),
196
+ data: self.data
197
+ }
198
+ end
199
+ end
200
+ end
@@ -0,0 +1,12 @@
1
+
2
+ module Distribot
3
+ class FlowCreatedHandler
4
+ include Distribot::Handler
5
+ subscribe_to 'distribot.flow.created', handler: :callback
6
+
7
+ def callback(message)
8
+ flow = Distribot::Flow.find(message[:flow_id])
9
+ flow.transition_to! flow.next_phase
10
+ end
11
+ end
12
+ end
@@ -0,0 +1,12 @@
1
+
2
+ module Distribot
3
+ class FlowFinishedHandler
4
+ include Distribot::Handler
5
+ subscribe_to 'distribot.flow.finished', handler: :callback
6
+
7
+ def callback(message)
8
+ Distribot.redis.decr('distribot.flows.running')
9
+ Distribot.redis.srem 'distribot.flows.active', message[:flow_id]
10
+ end
11
+ end
12
+ end
@@ -0,0 +1,40 @@
1
+
2
+ module Distribot
3
+ module Handler
4
+ attr_accessor :queue_name, :consumers
5
+
6
+ def self.included(base)
7
+ base.extend ClassMethods
8
+ end
9
+
10
+ def initialize
11
+ self.consumers = []
12
+ self.queue_name = self.class.queue
13
+ handler = self.class.handler
14
+ Distribot.subscribe(queue_name, self.class.subscribe_args) do |message|
15
+ send(handler, message)
16
+ end
17
+ end
18
+
19
+ def self.handler_for(klass)
20
+ klass.handler
21
+ end
22
+
23
+ def self.queue_for(klass)
24
+ klass.queue
25
+ end
26
+
27
+ module ClassMethods
28
+ class << self
29
+ attr_accessor :queue, :handler, :subscribe_args
30
+ end
31
+ attr_reader :handler, :queue, :subscribe_args
32
+
33
+ def subscribe_to(queue_name, handler_args)
34
+ @queue = queue_name
35
+ @handler = handler_args.delete :handler
36
+ @subscribe_args = handler_args
37
+ end
38
+ end
39
+ end
40
+ end
@@ -0,0 +1,29 @@
1
+
2
+ module Distribot
3
+ class HandlerFinishedHandler
4
+ include Distribot::Handler
5
+ subscribe_to 'distribot.flow.handler.finished', handler: :callback
6
+
7
+ def callback(message)
8
+ flow = Distribot::Flow.find(message[:flow_id])
9
+ phase = flow.phase(message[:phase])
10
+
11
+ Distribot.publish!(
12
+ 'distribot.flow.phase.finished',
13
+ flow_id: flow.id,
14
+ phase: phase.name
15
+ ) if self.all_phase_handler_tasks_are_complete?(flow, phase)
16
+ end
17
+
18
+ def all_phase_handler_tasks_are_complete?(flow, phase)
19
+ redis = Distribot.redis
20
+ name = phase.name
21
+ phase.handlers
22
+ .map { |h| "distribot.flow.#{flow.id}.#{name}.#{h}.tasks" }
23
+ .map { |task_counter_key| redis.get(task_counter_key) }
24
+ .reject(&:nil?)
25
+ .select { |val| val.to_i > 0 }
26
+ .empty?
27
+ end
28
+ end
29
+ end
@@ -0,0 +1,46 @@
1
+
2
+ module Distribot
3
+ class Phase
4
+ attr_accessor :id,
5
+ :name,
6
+ :is_initial,
7
+ :is_final,
8
+ :transitions_to,
9
+ :on_error_transition_to,
10
+ :handlers
11
+
12
+ def initialize(attrs = {})
13
+ attrs.each do |key, val|
14
+ next if key.to_s == 'handlers'
15
+ public_send("#{key}=", val)
16
+ end
17
+ self.name = name
18
+ self.handlers = []
19
+ initialize_handlers(attrs[:handlers] || [])
20
+ end
21
+
22
+ def to_hash
23
+ {
24
+ id: id,
25
+ name: name,
26
+ is_initial: is_initial || false,
27
+ is_final: is_final || false,
28
+ transitions_to: transitions_to,
29
+ on_error_transition_to: on_error_transition_to,
30
+ handlers: handlers
31
+ }
32
+ end
33
+
34
+ private
35
+
36
+ def initialize_handlers(handler_args)
37
+ handler_args.each do |handler|
38
+ if handler.is_a? Hash
39
+ handlers.push(PhaseHandler.new handler)
40
+ else
41
+ handlers.push(PhaseHandler.new name: handler)
42
+ end
43
+ end
44
+ end
45
+ end
46
+ end
@@ -0,0 +1,19 @@
1
+
2
+ module Distribot
3
+ class PhaseFinishedHandler
4
+ include Distribot::Handler
5
+ subscribe_to 'distribot.flow.phase.finished', handler: :callback
6
+
7
+ def callback(message)
8
+ flow = Distribot::Flow.find(message[:flow_id])
9
+ return unless flow.current_phase == message[:phase]
10
+ if flow.next_phase
11
+ flow.transition_to! flow.next_phase
12
+ else
13
+ Distribot.publish!(
14
+ 'distribot.flow.finished', flow_id: flow.id
15
+ )
16
+ end
17
+ end
18
+ end
19
+ end
@@ -0,0 +1,15 @@
1
+
2
+ module Distribot
3
+ class PhaseHandler
4
+ attr_accessor :name, :version
5
+ def initialize(attrs = {})
6
+ attrs.each do |key, val|
7
+ public_send("#{key}=", val)
8
+ end
9
+ end
10
+
11
+ def to_s
12
+ name
13
+ end
14
+ end
15
+ end
@@ -0,0 +1,69 @@
1
+
2
+ module Distribot
3
+ require 'semantic'
4
+ class PhaseStartedHandler
5
+ include Distribot::Handler
6
+ subscribe_to 'distribot.flow.phase.started', handler: :callback
7
+
8
+ def callback(message)
9
+ flow = Distribot::Flow.find(message[:flow_id])
10
+ phase = flow.phase(message[:phase])
11
+ if phase.handlers.empty?
12
+ Distribot.publish!(
13
+ 'distribot.flow.phase.finished',
14
+ flow_id: flow.id,
15
+ phase: phase.name
16
+ )
17
+ else
18
+ handler_versions = phase.handlers.map do |handler|
19
+ version = best_version(handler)
20
+ unless version && !version.blank?
21
+ fail "Cannot find a good #{handler} version #{handler.version}"
22
+ end
23
+ {
24
+ handler.to_s => version
25
+ }
26
+ end.reduce({}, :merge)
27
+ phase.handlers.each do |handler|
28
+ init_handler(flow, phase, handler, handler_versions[handler.to_s])
29
+ end
30
+ end
31
+ end
32
+
33
+ # rubocop:disable Metrics/LineLength
34
+ def init_handler(flow, phase, handler, version)
35
+ Distribot.publish!(
36
+ "distribot.flow.handler.#{handler}.#{version}.enumerate",
37
+ flow_id: flow.id,
38
+ phase: phase.name,
39
+ task_queue: "distribot.flow.handler.#{handler}.#{version}.tasks",
40
+ task_counter: "distribot.flow.#{flow.id}.#{phase.name}.#{handler}.finished",
41
+ finished_queue: 'distribot.flow.task.finished'
42
+ )
43
+ end
44
+
45
+ def best_version(handler)
46
+ if handler.version
47
+ wanted_version = Gem::Dependency.new(handler.to_s, handler.version)
48
+ # Figure out the highest acceptable version of the handler we can assign work to:
49
+ handler_versions(handler.to_s)
50
+ .reverse
51
+ .find { |x| wanted_version.match?(handler.to_s, x.to_s) }
52
+ .to_s
53
+ else
54
+ # Find the highest version for this queue:
55
+ handler_versions(handler.to_s).last
56
+ end
57
+ end
58
+
59
+ def handler_versions(handler)
60
+ queue_prefix = "distribot.flow.handler.#{handler}."
61
+ Distribot.connector.queues
62
+ .select { |x| x.start_with? queue_prefix }
63
+ .reject { |x| x.end_with? '.enumerate' }
64
+ .map { |x| x.gsub(/^#{queue_prefix}/, '').gsub(/\.tasks$/, '') }
65
+ .map { |x| Semantic::Version.new x }
66
+ .sort
67
+ end
68
+ end
69
+ end
@@ -0,0 +1,37 @@
1
+
2
+ module Distribot
3
+ class TaskFinishedHandler
4
+ include Distribot::Handler
5
+ subscribe_to 'distribot.flow.task.finished', handler: :callback
6
+
7
+ def callback(message)
8
+ task_counter_key = task_counter(message)
9
+ current_value = Distribot.redis.get(task_counter_key) || return
10
+ return unless current_value.to_i == 0
11
+ Distribot.redis.del(task_counter_key)
12
+ announce_handler_has_finished(message)
13
+ end
14
+
15
+ def announce_handler_has_finished(message)
16
+ Distribot.publish!(
17
+ 'distribot.flow.handler.finished',
18
+ flow_id: message[:flow_id],
19
+ phase: message[:phase],
20
+ handler: message[:handler],
21
+ task_queue: message[:task_queue]
22
+ )
23
+ end
24
+
25
+ def task_counter(message)
26
+ # i.e. - distribot.flow.flowId.phaseName.handlerName.finished
27
+ [
28
+ 'distribot',
29
+ 'flow',
30
+ message[:flow_id],
31
+ message[:phase],
32
+ message[:handler].to_s,
33
+ 'finished'
34
+ ].join('.')
35
+ end
36
+ end
37
+ end
@@ -0,0 +1,148 @@
1
+
2
+ module Distribot
3
+ class FlowCanceledError < StandardError; end
4
+ class FlowPausedError < StandardError; end
5
+ module Worker
6
+ def self.included(base)
7
+ base.extend(ClassMethods)
8
+ end
9
+
10
+ module ClassMethods
11
+ class << self
12
+ attr_accessor :version, :enumerator, :process_tasks_with, :processor
13
+ end
14
+
15
+ attr_reader :processor, :enumerator
16
+
17
+ def enumerate_with(callback)
18
+ @enumerator = callback
19
+ end
20
+
21
+ def process_tasks_with(callback)
22
+ @processor = callback
23
+ end
24
+
25
+ # Does both setting/getting:
26
+ def version(val = nil)
27
+ @version ||= '0.0.0'
28
+ @version = val unless val.nil?
29
+ @version
30
+ end
31
+
32
+ def enumeration_queue
33
+ self.version ||= '0.0.0'
34
+ "distribot.flow.handler.#{self}.#{self.version}.enumerate"
35
+ end
36
+
37
+ def task_queue
38
+ self.version ||= '0.0.0'
39
+ "distribot.flow.handler.#{self}.#{self.version}.tasks"
40
+ end
41
+ end
42
+
43
+ attr_accessor :task_consumers
44
+
45
+ def run
46
+ prepare_for_enumeration
47
+ subscribe_to_task_queue
48
+ self
49
+ end
50
+
51
+ def logger
52
+ Distribot.logger
53
+ end
54
+
55
+ def prepare_for_enumeration
56
+ logger.tagged("handler:#{self.class}") do
57
+ pool = Concurrent::FixedThreadPool.new(5)
58
+ Distribot.subscribe(self.class.enumeration_queue, solo: true) do |message|
59
+ pool.post do
60
+ logger.tagged(message.map { |k, v| [k, v].join(':') }) do
61
+ context = OpenStruct.new(message)
62
+ trycatch do
63
+ tasks = enumerate_tasks(message)
64
+ announce_tasks(context, message, tasks)
65
+ end
66
+ end
67
+ end
68
+ end
69
+ end
70
+ end
71
+
72
+ def subscribe_to_task_queue
73
+ logger.tagged("handler:#{self.class}") do
74
+ subscribe_args = { reenqueue_on_failure: true, solo: true }
75
+ pool = Concurrent::FixedThreadPool.new(500)
76
+ Distribot.subscribe(self.class.task_queue, subscribe_args) do |task|
77
+ pool.post do
78
+ logger_tags = task.map { |k, v| [k, v].join(':') }
79
+ logger.tagged(logger_tags) do
80
+ handle_task_execution(task)
81
+ end
82
+ end
83
+ end
84
+ end
85
+ end
86
+
87
+ def handle_task_execution(task)
88
+ context = OpenStruct.new(
89
+ flow_id: task[:flow_id],
90
+ phase: task[:phase],
91
+ finished_queue: 'distribot.flow.task.finished'
92
+ )
93
+ trycatch do
94
+ process_single_task(context, task)
95
+ end
96
+ end
97
+
98
+ def process_single_task(context, task)
99
+ inspect_task!(context)
100
+ # Your code is called right here:
101
+ send(self.class.processor, context, task)
102
+ task_counter_key = "distribot.flow.#{context.flow_id}.#{context.phase}.#{self.class}.finished"
103
+ Distribot.redis.decr(task_counter_key)
104
+ publish_args = {
105
+ flow_id: context.flow_id,
106
+ phase: context.phase,
107
+ handler: self.class.to_s
108
+ }
109
+ Distribot.publish!(context.finished_queue, publish_args)
110
+ end
111
+
112
+ def enumerate_tasks(message)
113
+ trycatch do
114
+ context = OpenStruct.new(message)
115
+ flow = Distribot::Flow.find(context.flow_id)
116
+ fail FlowCanceledError if flow.canceled?
117
+ send(self.class.enumerator, context)
118
+ end
119
+ end
120
+
121
+ private
122
+
123
+ def announce_tasks(context, message, tasks)
124
+ task_counter = message[:task_counter]
125
+ Distribot.redis.incrby task_counter, tasks.count
126
+ Distribot.redis.incrby "#{task_counter}.total", tasks.count
127
+ tasks.each do |task|
128
+ task.merge!(flow_id: context.flow_id, phase: context.phase)
129
+ Distribot.publish! message[:task_queue], task
130
+ end
131
+ end
132
+
133
+ def inspect_task!(context)
134
+ flow = Distribot::Flow.find(context.flow_id)
135
+ fail FlowCanceledError if flow.canceled?
136
+ fail FlowPausedError if flow.paused?
137
+ end
138
+
139
+ def trycatch(&block)
140
+ # Put the try/catch logic here to reduce boilerplate code:
141
+ block.call
142
+ rescue StandardError => e
143
+ logger.error "ERROR: #{e} --- #{e.backtrace.join("\n")}"
144
+ warn "ERROR: #{e} --- #{e.backtrace.join("\n")}"
145
+ raise e
146
+ end
147
+ end
148
+ end