sidekiq-hierarchy 0.1.1

Sign up to get free protection for your applications and to get access to all the features.
Files changed (55) hide show
  1. checksums.yaml +7 -0
  2. data/.gitignore +10 -0
  3. data/.rspec +2 -0
  4. data/.ruby-gemset +1 -0
  5. data/.ruby-version +1 -0
  6. data/.travis.yml +6 -0
  7. data/CHANGELOG.md +14 -0
  8. data/CONTRIBUTING.md +57 -0
  9. data/Gemfile +7 -0
  10. data/LICENSE.txt +21 -0
  11. data/README.md +396 -0
  12. data/Rakefile +6 -0
  13. data/bin/console +14 -0
  14. data/bin/setup +7 -0
  15. data/img/dashboard.png +0 -0
  16. data/img/failed_workflow.png +0 -0
  17. data/img/in_progress_workflow.png +0 -0
  18. data/img/job.png +0 -0
  19. data/img/workflow_set.png +0 -0
  20. data/lib/sidekiq-hierarchy.rb +1 -0
  21. data/lib/sidekiq/hierarchy.rb +105 -0
  22. data/lib/sidekiq/hierarchy/callback_registry.rb +33 -0
  23. data/lib/sidekiq/hierarchy/client/middleware.rb +23 -0
  24. data/lib/sidekiq/hierarchy/faraday/middleware.rb +16 -0
  25. data/lib/sidekiq/hierarchy/http.rb +8 -0
  26. data/lib/sidekiq/hierarchy/job.rb +290 -0
  27. data/lib/sidekiq/hierarchy/notifications.rb +8 -0
  28. data/lib/sidekiq/hierarchy/observers.rb +9 -0
  29. data/lib/sidekiq/hierarchy/observers/job_update.rb +15 -0
  30. data/lib/sidekiq/hierarchy/observers/workflow_update.rb +18 -0
  31. data/lib/sidekiq/hierarchy/rack/middleware.rb +27 -0
  32. data/lib/sidekiq/hierarchy/server/middleware.rb +62 -0
  33. data/lib/sidekiq/hierarchy/version.rb +5 -0
  34. data/lib/sidekiq/hierarchy/web.rb +149 -0
  35. data/lib/sidekiq/hierarchy/workflow.rb +130 -0
  36. data/lib/sidekiq/hierarchy/workflow_set.rb +134 -0
  37. data/sidekiq-hierarchy.gemspec +33 -0
  38. data/web/views/_job_progress_bar.erb +28 -0
  39. data/web/views/_job_table.erb +37 -0
  40. data/web/views/_job_timings.erb +10 -0
  41. data/web/views/_progress_bar.erb +8 -0
  42. data/web/views/_search_bar.erb +17 -0
  43. data/web/views/_summary_bar.erb +30 -0
  44. data/web/views/_workflow_progress_bar.erb +24 -0
  45. data/web/views/_workflow_set_clear.erb +7 -0
  46. data/web/views/_workflow_table.erb +33 -0
  47. data/web/views/_workflow_timings.erb +14 -0
  48. data/web/views/_workflow_tree.erb +82 -0
  49. data/web/views/_workflow_tree_node.erb +18 -0
  50. data/web/views/job.erb +12 -0
  51. data/web/views/not_found.erb +1 -0
  52. data/web/views/status.erb +120 -0
  53. data/web/views/workflow.erb +45 -0
  54. data/web/views/workflow_set.erb +3 -0
  55. metadata +225 -0
data/Rakefile ADDED
@@ -0,0 +1,6 @@
1
+ require "bundler/gem_tasks"
2
+ require "rspec/core/rake_task"
3
+
4
+ RSpec::Core::RakeTask.new(:spec)
5
+
6
+ task :default => :spec
data/bin/console ADDED
@@ -0,0 +1,14 @@
1
+ #!/usr/bin/env ruby
2
+
3
+ require "bundler/setup"
4
+ require "sidekiq/hierarchy"
5
+
6
+ # You can add fixtures and/or initialization code here to make experimenting
7
+ # with your gem easier. You can also use a different console, if you like.
8
+
9
+ # (If you use this, don't forget to add pry to your Gemfile!)
10
+ # require "pry"
11
+ # Pry.start
12
+
13
+ require "irb"
14
+ IRB.start
data/bin/setup ADDED
@@ -0,0 +1,7 @@
1
+ #!/bin/bash
2
+ set -euo pipefail
3
+ IFS=$'\n\t'
4
+
5
+ bundle install
6
+
7
+ # Do any other automated setup that you need to do here
data/img/dashboard.png ADDED
Binary file
Binary file
Binary file
data/img/job.png ADDED
Binary file
Binary file
@@ -0,0 +1 @@
1
+ require 'sidekiq/hierarchy'
@@ -0,0 +1,105 @@
1
+ require 'sidekiq'
2
+ require 'sidekiq/hierarchy/version'
3
+
4
+ require 'sidekiq/hierarchy/job'
5
+ require 'sidekiq/hierarchy/workflow'
6
+ require 'sidekiq/hierarchy/workflow_set'
7
+
8
+ require 'sidekiq/hierarchy/callback_registry'
9
+ require 'sidekiq/hierarchy/notifications'
10
+ require 'sidekiq/hierarchy/observers'
11
+
12
+ require 'sidekiq/hierarchy/server/middleware'
13
+ require 'sidekiq/hierarchy/client/middleware'
14
+
15
+ module Sidekiq
16
+ module Hierarchy
17
+ class << self
18
+
19
+ ### Per-thread context tracking
20
+
21
+ # Checks if tracking is enabled based on whether the workflow is known
22
+ # If disabled, all methods are no-ops
23
+ def enabled?
24
+ !!current_workflow # without a workflow, we can't do anything
25
+ end
26
+
27
+ # Sets the workflow object for the current fiber/worker
28
+ def current_workflow=(workflow)
29
+ Thread.current[:workflow] = workflow
30
+ end
31
+
32
+ # Retrieves the current Sidekiq workflow if previously set
33
+ def current_workflow
34
+ Thread.current[:workflow]
35
+ end
36
+
37
+ # Sets the jid for the current fiber/worker
38
+ def current_jid=(jid)
39
+ Thread.current[:jid] = jid
40
+ end
41
+
42
+ # Retrieves jid for the current Sidekiq job if previously set
43
+ def current_jid
44
+ Thread.current[:jid]
45
+ end
46
+
47
+
48
+ ### Workflow execution updates
49
+
50
+ def record_job_enqueued(job, redis_pool=nil)
51
+ return unless !!job['workflow']
52
+ if current_jid.nil?
53
+ # this is a root-level job, i.e., start of a workflow
54
+ queued_job = Sidekiq::Hierarchy::Job.create(job['jid'], job, redis_pool)
55
+ queued_job.enqueue! # initial status: enqueued
56
+ elsif current_jid == job['jid']
57
+ # this is a job requeuing itself, ignore it
58
+ else
59
+ # this is an intermediate job, having both parent and children
60
+ current_job = Sidekiq::Hierarchy::Job.find(current_jid, redis_pool)
61
+ queued_job = Sidekiq::Hierarchy::Job.create(job['jid'], job, redis_pool)
62
+ current_job.add_child(queued_job)
63
+ queued_job.enqueue! # initial status: enqueued
64
+ end
65
+ end
66
+
67
+ def record_job_running
68
+ return unless enabled? && current_jid
69
+ Sidekiq::Hierarchy::Job.find(current_jid).run!
70
+ end
71
+
72
+ def record_job_complete
73
+ return unless enabled? && current_jid
74
+ Sidekiq::Hierarchy::Job.find(current_jid).complete!
75
+ end
76
+
77
+ def record_job_requeued
78
+ return unless enabled? && current_jid
79
+ Sidekiq::Hierarchy::Job.find(current_jid).requeue!
80
+ end
81
+
82
+ def record_job_failed
83
+ return unless enabled? && current_jid
84
+ Sidekiq::Hierarchy::Job.find(current_jid).fail!
85
+ end
86
+
87
+
88
+ ### Callbacks
89
+
90
+ attr_accessor :callback_registry
91
+
92
+ def subscribe(event, callback)
93
+ callback_registry.subscribe(event, callback)
94
+ end
95
+
96
+ def publish(event, *args)
97
+ callback_registry.publish(event, *args)
98
+ end
99
+ end
100
+
101
+ self.callback_registry = CallbackRegistry.new
102
+ Observers::JobUpdate.new.register(callback_registry)
103
+ Observers::WorkflowUpdate.new.register(callback_registry)
104
+ end
105
+ end
@@ -0,0 +1,33 @@
1
+ require 'mutex_m'
2
+
3
+ module Sidekiq
4
+ module Hierarchy
5
+ class CallbackRegistry
6
+ include Mutex_m
7
+
8
+ def initialize
9
+ @callbacks = {}
10
+ super
11
+ end
12
+
13
+ # Thread-safe to prevent clobbering, though this should
14
+ # probably never be called outside initialization anyway.
15
+ # callback is a proc/lambda that implements #call
16
+ def subscribe(event, callback)
17
+ synchronize do
18
+ @callbacks[event] ||= []
19
+ @callbacks[event] << callback
20
+ end
21
+ self
22
+ end
23
+
24
+ # Call listeners for a given event one by one.
25
+ # Note that no method signature contracts are enforced.
26
+ def publish(event, *args)
27
+ if to_notify = @callbacks[event]
28
+ to_notify.each { |callback| callback.call(*args) rescue nil }
29
+ end
30
+ end
31
+ end
32
+ end
33
+ end
@@ -0,0 +1,23 @@
1
+ module Sidekiq
2
+ module Hierarchy
3
+ module Client
4
+ class Middleware
5
+ def initialize(options={})
6
+ end
7
+
8
+ # Wraps around the method used to push a job to Redis. Takes params:
9
+ # worker_class - the class of the worker, as an object
10
+ # msg - the hash of job info, something like {'class' => 'HardWorker', 'args' => [1, 2, 'foo'], 'retry' => true}
11
+ # queue - the named queue to use
12
+ # redis_pool - a redis-like connection/conn-pool
13
+ # Must propagate return value upwards.
14
+ # May return false/nil to stop the job from going to redis.
15
+ def call(worker_class, msg, queue, redis_pool)
16
+ msg['workflow'] = Sidekiq::Hierarchy.current_workflow.jid if Sidekiq::Hierarchy.current_workflow
17
+ # if block returns nil/false, job was cancelled before queueing by middleware
18
+ yield.tap { |job| Sidekiq::Hierarchy.record_job_enqueued(job) if job }
19
+ end
20
+ end
21
+ end
22
+ end
23
+ end
@@ -0,0 +1,16 @@
1
+ require 'faraday'
2
+ require 'sidekiq/hierarchy/http'
3
+
4
+ module Sidekiq
5
+ module Hierarchy
6
+ module Faraday
7
+ class Middleware < ::Faraday::Middleware
8
+ def call(env)
9
+ env[:request_headers][Sidekiq::Hierarchy::Http::JID_HEADER] = Sidekiq::Hierarchy.current_jid if Sidekiq::Hierarchy.current_jid
10
+ env[:request_headers][Sidekiq::Hierarchy::Http::WORKFLOW_HEADER] = Sidekiq::Hierarchy.current_workflow.jid if Sidekiq::Hierarchy.current_workflow
11
+ @app.call(env)
12
+ end
13
+ end
14
+ end
15
+ end
16
+ end
@@ -0,0 +1,8 @@
1
+ module Sidekiq
2
+ module Hierarchy
3
+ module Http
4
+ JID_HEADER = 'Sidekiq-Jid'.freeze
5
+ WORKFLOW_HEADER = 'Sidekiq-Workflow'.freeze
6
+ end
7
+ end
8
+ end
@@ -0,0 +1,290 @@
1
+ module Sidekiq
2
+ module Hierarchy
3
+ class Job
4
+ # Job hash keys
5
+ INFO_FIELD = 'i'.freeze
6
+ PARENT_FIELD = 'p'.freeze
7
+ STATUS_FIELD = 's'.freeze
8
+ WORKFLOW_STATUS_FIELD = 'w'.freeze
9
+ ENQUEUED_AT_FIELD = 'e'.freeze
10
+ RUN_AT_FIELD = 'r'.freeze
11
+ COMPLETED_AT_FIELD = 'c'.freeze
12
+
13
+ # Values for STATUS_FIELD
14
+ STATUS_ENQUEUED = '0'.freeze
15
+ STATUS_RUNNING = '1'.freeze
16
+ STATUS_COMPLETE = '2'.freeze
17
+ STATUS_REQUEUED = '3'.freeze
18
+ STATUS_FAILED = '4'.freeze
19
+
20
+ ONE_MONTH = 60 * 60 * 24 * 30 # key expiration
21
+ INFO_KEYS = ['class'.freeze, 'queue'.freeze] # default keys to keep
22
+
23
+
24
+ ### Class definition
25
+
26
+ attr_reader :jid
27
+
28
+ def initialize(jid, redis_pool=nil)
29
+ @jid = jid
30
+ @redis_pool = redis_pool
31
+ end
32
+
33
+ class << self
34
+ alias_method :find, :new
35
+
36
+ def create(jid, job_hash, redis_pool=nil)
37
+ new(jid, redis_pool).tap do |job|
38
+ job[INFO_FIELD] = Sidekiq.dump_json(filtered_job_hash(job_hash))
39
+ end
40
+ end
41
+
42
+ # saves INFO_KEYS as well as whatever keys are specified
43
+ # in the worker's sidekiq options under :workflow_keys
44
+ def filtered_job_hash(job_hash)
45
+ keys_to_keep = (INFO_KEYS + Array(job_hash['workflow_keys'])).uniq
46
+ job_hash.select { |k, _| keys_to_keep.include?(k) }
47
+ end
48
+ private :filtered_job_hash
49
+ end
50
+
51
+ def delete
52
+ children.each(&:delete)
53
+ redis { |conn| conn.del(redis_children_lkey, redis_job_hkey) }
54
+ end
55
+
56
+ def exists?
57
+ redis do |conn|
58
+ conn.exists(redis_job_hkey)
59
+ end
60
+ end
61
+
62
+ def ==(other_job)
63
+ other_job.instance_of?(self.class) &&
64
+ self.jid == other_job.jid
65
+ end
66
+
67
+ # Magic getter backed by redis hash
68
+ def [](key)
69
+ redis do |conn|
70
+ conn.hget(redis_job_hkey, key)
71
+ end
72
+ end
73
+
74
+ # Magic setter backed by redis hash
75
+ def []=(key, value)
76
+ redis do |conn|
77
+ conn.multi do
78
+ conn.hset(redis_job_hkey, key, value)
79
+ conn.expire(redis_job_hkey, ONE_MONTH)
80
+ end
81
+ end
82
+ value
83
+ end
84
+
85
+ def info
86
+ Sidekiq.load_json(self[INFO_FIELD])
87
+ end
88
+
89
+
90
+ ### Tree exploration and manipulation
91
+
92
+ def parent
93
+ if parent_jid = self[PARENT_FIELD]
94
+ self.class.find(parent_jid, @redis_pool)
95
+ end
96
+ end
97
+
98
+ def children
99
+ redis do |conn|
100
+ conn.lrange(redis_children_lkey, 0, -1).map { |jid| self.class.find(jid, @redis_pool) }
101
+ end
102
+ end
103
+
104
+ def root?
105
+ parent.nil?
106
+ end
107
+
108
+ def leaf?
109
+ children.none?
110
+ end
111
+
112
+ # Walks up the workflow tree and returns its root job node
113
+ # Warning: recursive!
114
+ def root
115
+ # This could be done in a single Lua script server-side...
116
+ self.root? ? self : self.parent.root
117
+ end
118
+
119
+ # Walks down the workflow tree and returns all its leaf nodes
120
+ # If called on a leaf, returns an array containing only itself
121
+ # Warning: recursive!
122
+ def leaves
123
+ # This could be done in a single Lua script server-side...
124
+ self.leaf? ? [self] : children.flat_map(&:leaves)
125
+ end
126
+
127
+ # Draws a new doubly-linked parent-child relationship in redis
128
+ def add_child(child_job)
129
+ redis do |conn|
130
+ conn.multi do
131
+ # draw child->parent relationship
132
+ conn.hset(child_job.redis_job_hkey, PARENT_FIELD, self.jid)
133
+ conn.expire(child_job.redis_job_hkey, ONE_MONTH)
134
+ # draw parent->child relationship
135
+ conn.rpush(redis_children_lkey, child_job.jid)
136
+ conn.expire(redis_children_lkey, ONE_MONTH)
137
+ end
138
+ end
139
+ true # will never fail w/o raising an exception
140
+ end
141
+
142
+ def workflow
143
+ Workflow.find(root)
144
+ end
145
+
146
+
147
+ ### Status get/set
148
+
149
+ def status
150
+ case self[STATUS_FIELD]
151
+ when STATUS_ENQUEUED
152
+ :enqueued
153
+ when STATUS_RUNNING
154
+ :running
155
+ when STATUS_COMPLETE
156
+ :complete
157
+ when STATUS_REQUEUED
158
+ :requeued
159
+ when STATUS_FAILED
160
+ :failed
161
+ else
162
+ :unknown
163
+ end
164
+ end
165
+
166
+ def update_status(new_status)
167
+ old_status = status
168
+ return if new_status == old_status
169
+
170
+ case new_status
171
+ when :enqueued
172
+ s_val, t_field = STATUS_ENQUEUED, ENQUEUED_AT_FIELD
173
+ when :running
174
+ s_val, t_field = STATUS_RUNNING, RUN_AT_FIELD
175
+ when :complete
176
+ s_val, t_field = STATUS_COMPLETE, COMPLETED_AT_FIELD
177
+ when :requeued
178
+ s_val, t_field = STATUS_REQUEUED, nil
179
+ when :failed
180
+ s_val, t_field = STATUS_FAILED, COMPLETED_AT_FIELD
181
+ end
182
+
183
+ self[STATUS_FIELD] = s_val
184
+ self[t_field] = Time.now.to_f.to_s if t_field
185
+
186
+ Sidekiq::Hierarchy.publish(Notifications::JOB_UPDATE, self, new_status, old_status)
187
+ end
188
+
189
+ # Status update: mark as enqueued (step 1)
190
+ def enqueue!
191
+ update_status :enqueued
192
+ end
193
+
194
+ def enqueued?
195
+ status == :enqueued
196
+ end
197
+
198
+ def enqueued_at
199
+ if t = self[ENQUEUED_AT_FIELD]
200
+ Time.at(t.to_f)
201
+ end
202
+ end
203
+
204
+ # Status update: mark as running (step 2)
205
+ def run!
206
+ update_status :running
207
+ end
208
+
209
+ def running?
210
+ status == :running
211
+ end
212
+
213
+ def run_at
214
+ if t = self[RUN_AT_FIELD]
215
+ Time.at(t.to_f)
216
+ end
217
+ end
218
+
219
+ # Status update: mark as complete (step 3)
220
+ def complete!
221
+ update_status :complete
222
+ end
223
+
224
+ def complete?
225
+ status == :complete
226
+ end
227
+
228
+ def complete_at
229
+ if complete? && t = self[COMPLETED_AT_FIELD]
230
+ Time.at(t.to_f)
231
+ end
232
+ end
233
+
234
+ def requeue!
235
+ update_status :requeued
236
+ end
237
+
238
+ def requeued?
239
+ status == :requeued
240
+ end
241
+
242
+ def fail!
243
+ update_status :failed
244
+ end
245
+
246
+ def failed?
247
+ status == :failed
248
+ end
249
+
250
+ def failed_at
251
+ if failed? && t = self[COMPLETED_AT_FIELD]
252
+ Time.at(t.to_f)
253
+ end
254
+ end
255
+
256
+ def finished_at
257
+ if t = self[COMPLETED_AT_FIELD]
258
+ Time.at(t.to_f)
259
+ end
260
+ end
261
+
262
+
263
+ ### Serialisation
264
+
265
+ def as_json(options={})
266
+ {k: info['class'], c: children.sort_by {|c| c.info['class']}.map(&:as_json)}
267
+ end
268
+
269
+
270
+ ### Redis backend
271
+
272
+ def redis_job_hkey
273
+ "hierarchy:job:#{jid}"
274
+ end
275
+
276
+ def redis_children_lkey
277
+ "#{redis_job_hkey}:children"
278
+ end
279
+
280
+ def redis(&blk)
281
+ if @redis_pool
282
+ @redis_pool.with(&blk)
283
+ else
284
+ Sidekiq.redis(&blk)
285
+ end
286
+ end
287
+ private :redis
288
+ end
289
+ end
290
+ end