sidekiq-hierarchy 0.1.1
Sign up to get free protection for your applications and to get access to all the features.
- 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
|