distribot 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
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