sidekiq-hierarchy 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 (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