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.
- checksums.yaml +7 -0
- data/.gitignore +10 -0
- data/.rspec +2 -0
- data/.ruby-gemset +1 -0
- data/.ruby-version +1 -0
- data/.travis.yml +6 -0
- data/CHANGELOG.md +14 -0
- data/CONTRIBUTING.md +57 -0
- data/Gemfile +7 -0
- data/LICENSE.txt +21 -0
- data/README.md +396 -0
- data/Rakefile +6 -0
- data/bin/console +14 -0
- data/bin/setup +7 -0
- data/img/dashboard.png +0 -0
- data/img/failed_workflow.png +0 -0
- data/img/in_progress_workflow.png +0 -0
- data/img/job.png +0 -0
- data/img/workflow_set.png +0 -0
- data/lib/sidekiq-hierarchy.rb +1 -0
- data/lib/sidekiq/hierarchy.rb +105 -0
- data/lib/sidekiq/hierarchy/callback_registry.rb +33 -0
- data/lib/sidekiq/hierarchy/client/middleware.rb +23 -0
- data/lib/sidekiq/hierarchy/faraday/middleware.rb +16 -0
- data/lib/sidekiq/hierarchy/http.rb +8 -0
- data/lib/sidekiq/hierarchy/job.rb +290 -0
- data/lib/sidekiq/hierarchy/notifications.rb +8 -0
- data/lib/sidekiq/hierarchy/observers.rb +9 -0
- data/lib/sidekiq/hierarchy/observers/job_update.rb +15 -0
- data/lib/sidekiq/hierarchy/observers/workflow_update.rb +18 -0
- data/lib/sidekiq/hierarchy/rack/middleware.rb +27 -0
- data/lib/sidekiq/hierarchy/server/middleware.rb +62 -0
- data/lib/sidekiq/hierarchy/version.rb +5 -0
- data/lib/sidekiq/hierarchy/web.rb +149 -0
- data/lib/sidekiq/hierarchy/workflow.rb +130 -0
- data/lib/sidekiq/hierarchy/workflow_set.rb +134 -0
- data/sidekiq-hierarchy.gemspec +33 -0
- data/web/views/_job_progress_bar.erb +28 -0
- data/web/views/_job_table.erb +37 -0
- data/web/views/_job_timings.erb +10 -0
- data/web/views/_progress_bar.erb +8 -0
- data/web/views/_search_bar.erb +17 -0
- data/web/views/_summary_bar.erb +30 -0
- data/web/views/_workflow_progress_bar.erb +24 -0
- data/web/views/_workflow_set_clear.erb +7 -0
- data/web/views/_workflow_table.erb +33 -0
- data/web/views/_workflow_timings.erb +14 -0
- data/web/views/_workflow_tree.erb +82 -0
- data/web/views/_workflow_tree_node.erb +18 -0
- data/web/views/job.erb +12 -0
- data/web/views/not_found.erb +1 -0
- data/web/views/status.erb +120 -0
- data/web/views/workflow.erb +45 -0
- data/web/views/workflow_set.erb +3 -0
- metadata +225 -0
data/Rakefile
ADDED
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
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,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
|