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
@@ -0,0 +1,8 @@
1
+ module Sidekiq
2
+ module Hierarchy
3
+ module Notifications
4
+ JOB_UPDATE = :job_update
5
+ WORKFLOW_UPDATE = :workflow_update
6
+ end
7
+ end
8
+ end
@@ -0,0 +1,9 @@
1
+ require 'sidekiq/hierarchy/observers/job_update'
2
+ require 'sidekiq/hierarchy/observers/workflow_update'
3
+
4
+ module Sidekiq
5
+ module Hierarchy
6
+ module Observers
7
+ end
8
+ end
9
+ end
@@ -0,0 +1,15 @@
1
+ module Sidekiq
2
+ module Hierarchy
3
+ module Observers
4
+ class JobUpdate
5
+ def register(callback_registry)
6
+ callback_registry.subscribe(Notifications::JOB_UPDATE, self)
7
+ end
8
+
9
+ def call(job, status, old_status)
10
+ job.workflow.update_status(status)
11
+ end
12
+ end
13
+ end
14
+ end
15
+ end
@@ -0,0 +1,18 @@
1
+ module Sidekiq
2
+ module Hierarchy
3
+ module Observers
4
+ class WorkflowUpdate
5
+ def register(callback_registry)
6
+ callback_registry.subscribe(Notifications::WORKFLOW_UPDATE, self)
7
+ end
8
+
9
+ def call(workflow, status, old_status)
10
+ from_set = WorkflowSet.for_status(old_status)
11
+ to_set = WorkflowSet.for_status(status)
12
+
13
+ to_set.move(workflow, from_set) # Move/add to the new status set
14
+ end
15
+ end
16
+ end
17
+ end
18
+ end
@@ -0,0 +1,27 @@
1
+ require 'rack'
2
+ require 'sidekiq/hierarchy/http'
3
+
4
+ module Sidekiq
5
+ module Hierarchy
6
+ module Rack
7
+ class Middleware
8
+ # transform from http header to rack names
9
+ JID_HEADER_KEY = "HTTP_#{Sidekiq::Hierarchy::Http::JID_HEADER.upcase.gsub('-','_')}".freeze
10
+ WORKFLOW_HEADER_KEY = "HTTP_#{Sidekiq::Hierarchy::Http::WORKFLOW_HEADER.upcase.gsub('-','_')}".freeze
11
+
12
+ def initialize(app)
13
+ @app = app
14
+ end
15
+
16
+ def call(env)
17
+ Sidekiq::Hierarchy.current_jid = env[JID_HEADER_KEY]
18
+ Sidekiq::Hierarchy.current_workflow = Workflow.find_by_jid(env[WORKFLOW_HEADER_KEY]) if env[WORKFLOW_HEADER_KEY]
19
+ @app.call(env)
20
+ ensure
21
+ Sidekiq::Hierarchy.current_workflow = nil
22
+ Sidekiq::Hierarchy.current_jid = nil
23
+ end
24
+ end
25
+ end
26
+ end
27
+ end
@@ -0,0 +1,62 @@
1
+ module Sidekiq
2
+ module Hierarchy
3
+ module Server
4
+ class Middleware
5
+ def initialize(options={})
6
+ end
7
+
8
+ # Wraps around the actual execution of a job. Takes params:
9
+ # worker - the instance of the worker to be used for execution
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
+ # Must propagate return value upwards.
13
+ # Since jobs raise errors for signalling, those must be propagated as well.
14
+ def call(worker, msg, queue)
15
+ if msg['workflow'] == true # root job -- start of a new workflow
16
+ Sidekiq::Hierarchy.current_workflow = Workflow.find_by_jid(worker.jid)
17
+ elsif msg['workflow'].is_a?(String) # child job -- inherit parent's workflow
18
+ Sidekiq::Hierarchy.current_workflow = Workflow.find_by_jid(msg['workflow'])
19
+ end
20
+ Sidekiq::Hierarchy.current_jid = worker.jid
21
+
22
+ Sidekiq::Hierarchy.record_job_running
23
+ ret = yield
24
+ Sidekiq::Hierarchy.record_job_complete
25
+
26
+ ret
27
+ rescue Exception => e
28
+ if exception_caused_by_shutdown?(e) || retries_remaining?(msg)
29
+ # job will be pushed back onto queue during hard_shutdown or if retries are permitted
30
+ Sidekiq::Hierarchy.record_job_requeued
31
+ else
32
+ Sidekiq::Hierarchy.record_job_failed
33
+ end
34
+
35
+ raise
36
+ end
37
+
38
+ def retries_remaining?(msg)
39
+ return false unless msg['retry']
40
+
41
+ retry_count = msg['retry_count'] || 0
42
+ max_retries = if msg['retry'].is_a?(Fixnum)
43
+ msg['retry']
44
+ else
45
+ Sidekiq::Middleware::Server::RetryJobs::DEFAULT_MAX_RETRY_ATTEMPTS
46
+ end
47
+
48
+ # this check requires prepending the middleware before sidekiq's builtin retry
49
+ retry_count < max_retries
50
+ end
51
+ private :retries_remaining?
52
+
53
+ def exception_caused_by_shutdown?(e)
54
+ e.instance_of?(Sidekiq::Shutdown) ||
55
+ # In Ruby 2.1+, check if original exception was Shutdown
56
+ (defined?(e.cause) && exception_caused_by_shutdown?(e.cause))
57
+ end
58
+ private :exception_caused_by_shutdown?
59
+ end
60
+ end
61
+ end
62
+ end
@@ -0,0 +1,5 @@
1
+ module Sidekiq
2
+ module Hierarchy
3
+ VERSION = '0.1.1'
4
+ end
5
+ end
@@ -0,0 +1,149 @@
1
+ # Web interface to Sidekiq-hierarchy
2
+ # Optimised for ease-of-use, not efficiency; it's probably best
3
+ # not to leave this open in a tab forever.
4
+ # Sidekiq seems to use Bootstrap 3.0.0 currently; find docs at
5
+ # http://bootstrapdocs.com/v3.0.0/docs/
6
+ module Sidekiq
7
+ module Hierarchy
8
+ module Web
9
+ module Helpers
10
+ def sidekiq_hierarchy_template(template_name)
11
+ File.read(File.join(VIEW_PATH, "#{template_name}.erb"))
12
+ end
13
+
14
+ def job_url(job=nil)
15
+ "#{root_path}hierarchy/jobs/#{job.jid if job}"
16
+ end
17
+
18
+ def workflow_url(workflow=nil)
19
+ "#{root_path}hierarchy/workflows/#{workflow.jid if workflow}"
20
+ end
21
+
22
+ def workflow_set_url(status)
23
+ "#{root_path}hierarchy/workflow_sets/#{status}"
24
+ end
25
+
26
+ def status_updated_at(job)
27
+ case job.status
28
+ when :enqueued
29
+ job.enqueued_at
30
+ when :running, :requeued
31
+ job.run_at
32
+ when :complete
33
+ job.complete_at
34
+ when :failed
35
+ job.failed_at
36
+ end
37
+ end
38
+
39
+ def bootstrap_status(status)
40
+ case status
41
+ when :enqueued, :requeued
42
+ 'warning'
43
+ when :running
44
+ 'info'
45
+ when :complete
46
+ 'success'
47
+ when :failed
48
+ 'danger'
49
+ end
50
+ end
51
+ end
52
+
53
+ VIEW_PATH = File.expand_path('../../../../web/views', __FILE__)
54
+ PER_PAGE = 20
55
+
56
+ def self.registered(app)
57
+ app.helpers Helpers
58
+
59
+ app.not_found do
60
+ erb sidekiq_hierarchy_template(:not_found)
61
+ end
62
+
63
+ app.get '/hierarchy/?' do
64
+ running_set = RunningSet.new
65
+ complete_set = CompleteSet.new
66
+ failed_set = FailedSet.new
67
+
68
+ @running = running_set.each.take(PER_PAGE)
69
+ @complete = complete_set.each.take(PER_PAGE)
70
+ @failed = failed_set.each.take(PER_PAGE)
71
+
72
+ erb sidekiq_hierarchy_template(:status)
73
+ end
74
+
75
+ app.delete '/hierarchy/?' do
76
+ [RunningSet.new, CompleteSet.new, FailedSet.new].each(&:remove_all)
77
+ redirect back
78
+ end
79
+
80
+ app.get '/hierarchy/workflow_sets/:status' do |status|
81
+ @status = status.to_sym
82
+ if workflow_set = WorkflowSet.for_status(@status)
83
+ @workflows = workflow_set.each.take(PER_PAGE)
84
+ erb sidekiq_hierarchy_template(:workflow_set)
85
+ else
86
+ halt 404
87
+ end
88
+ end
89
+
90
+ app.delete '/hierarchy/workflow_sets/:status' do |status|
91
+ @status = status.to_sym
92
+ if workflow_set = WorkflowSet.for_status(@status)
93
+ workflow_set.each(&:delete)
94
+ redirect back
95
+ else
96
+ halt 404
97
+ end
98
+ end
99
+
100
+ app.get '/hierarchy/workflows/?' do
101
+ if params['workflow_jid'] =~ /\A\h{24}\z/
102
+ redirect to("/hierarchy/workflows/#{params['workflow_jid']}")
103
+ else
104
+ redirect to(:hierarchy)
105
+ end
106
+ end
107
+
108
+ app.get %r{\A/hierarchy/workflows/(\h{24})\z} do |workflow_jid|
109
+ @workflow = Workflow.find_by_jid(workflow_jid)
110
+ if @workflow.exists?
111
+ erb sidekiq_hierarchy_template(:workflow)
112
+ else
113
+ halt 404
114
+ end
115
+ end
116
+
117
+ app.delete %r{\A/hierarchy/workflows/(\h{24})\z} do |workflow_jid|
118
+ workflow = Workflow.find_by_jid(workflow_jid)
119
+ redirect_url = "/hierarchy/workflow_sets/#{workflow.status}"
120
+ workflow.delete
121
+
122
+ redirect to(redirect_url)
123
+ end
124
+
125
+ app.get '/hierarchy/jobs/?' do
126
+ if params['jid'] =~ /\A\h{24}\z/
127
+ redirect to("/hierarchy/jobs/#{params['jid']}")
128
+ else
129
+ redirect back
130
+ end
131
+ end
132
+
133
+ app.get %r{\A/hierarchy/jobs/(\h{24})\z} do |jid|
134
+ @job = Job.find(jid)
135
+ @workflow = @job.workflow
136
+ if @job.exists? && @workflow.exists?
137
+ erb sidekiq_hierarchy_template(:job)
138
+ else
139
+ halt 404
140
+ end
141
+ end
142
+ end
143
+ end
144
+ end
145
+ end
146
+
147
+ require 'sidekiq/web' unless defined?(Sidekiq::Web)
148
+ Sidekiq::Web.register(Sidekiq::Hierarchy::Web)
149
+ Sidekiq::Web.tabs['Hierarchy'] = 'hierarchy'
@@ -0,0 +1,130 @@
1
+ module Sidekiq
2
+ module Hierarchy
3
+ class Workflow
4
+ extend Forwardable
5
+
6
+ attr_reader :root
7
+
8
+ def initialize(root)
9
+ @root = root
10
+ end
11
+
12
+ class << self
13
+ alias_method :find, :new
14
+
15
+ def find_by_jid(root_jid)
16
+ find(Job.find(root_jid))
17
+ end
18
+ end
19
+
20
+ delegate [:jid, :[], :[]=, :exists?] => :@root
21
+
22
+ def ==(other_workflow)
23
+ other_workflow.instance_of?(self.class) &&
24
+ self.jid == other_workflow.jid
25
+ end
26
+
27
+ def workflow_set
28
+ WorkflowSet.for_status(status)
29
+ end
30
+
31
+ def delete
32
+ wset = workflow_set # save it for later
33
+ root.delete # deleting nodes is more important than a dangling reference
34
+ wset.remove(self) if wset # now we can clear out from the set
35
+ end
36
+
37
+ # Walks the tree in DFS order (for optimal completion checking)
38
+ # Returns an Enumerator; use #to_a to get an array instead
39
+ def jobs
40
+ to_visit = [root]
41
+ Enumerator.new do |y|
42
+ while node = to_visit.pop
43
+ y << node # sugar for yielding a value
44
+ to_visit += node.children
45
+ end
46
+ end
47
+ end
48
+
49
+
50
+ ### Status
51
+
52
+ def status
53
+ case self[Job::WORKFLOW_STATUS_FIELD]
54
+ when Job::STATUS_RUNNING
55
+ :running
56
+ when Job::STATUS_COMPLETE
57
+ :complete
58
+ when Job::STATUS_FAILED
59
+ :failed
60
+ else
61
+ :unknown
62
+ end
63
+ end
64
+
65
+ def update_status(from_job_status)
66
+ old_status = status
67
+ return if [:failed, :complete].include?(old_status) # these states are final
68
+
69
+ if [:enqueued, :running, :requeued].include?(from_job_status)
70
+ new_status, s_val = :running, Job::STATUS_RUNNING
71
+ elsif from_job_status == :failed
72
+ new_status, s_val = :failed, Job::STATUS_FAILED
73
+ elsif from_job_status == :complete && jobs.all?(&:complete?)
74
+ new_status, s_val = :complete, Job::STATUS_COMPLETE
75
+ end
76
+
77
+ return if !new_status || new_status == old_status # don't publish null updates
78
+ self[Job::WORKFLOW_STATUS_FIELD] = s_val
79
+
80
+ Sidekiq::Hierarchy.publish(Notifications::WORKFLOW_UPDATE, self, new_status, old_status)
81
+ end
82
+
83
+ def running?
84
+ status == :running
85
+ end
86
+
87
+ def complete?
88
+ status == :complete
89
+ end
90
+
91
+ def failed?
92
+ status == :failed
93
+ end
94
+
95
+
96
+ ### Calculated metrics
97
+
98
+ def enqueued_at
99
+ root.enqueued_at
100
+ end
101
+
102
+ def run_at
103
+ root.run_at
104
+ end
105
+
106
+ # Returns the time at which all jobs were complete;
107
+ # nil if any jobs are still incomplete
108
+ def complete_at
109
+ jobs.map(&:complete_at).max if complete?
110
+ end
111
+
112
+ # Returns the earliest time at which a job failed;
113
+ # nil if none did
114
+ def failed_at
115
+ jobs.map(&:failed_at).compact.min if failed?
116
+ end
117
+
118
+
119
+ ### Serialisation
120
+
121
+ def as_json(options={})
122
+ root.as_json(options)
123
+ end
124
+
125
+ def to_s
126
+ Sidekiq.dump_json(self.as_json)
127
+ end
128
+ end
129
+ end
130
+ end
@@ -0,0 +1,134 @@
1
+ module Sidekiq
2
+ module Hierarchy
3
+
4
+ ### Implementations
5
+
6
+ # A sorted set of Workflows that permits enumeration
7
+ class WorkflowSet
8
+ PAGE_SIZE = 100
9
+
10
+ def self.for_status(status)
11
+ case status
12
+ when :running
13
+ RunningSet.new
14
+ when :complete
15
+ CompleteSet.new
16
+ when :failed
17
+ FailedSet.new
18
+ end
19
+ end
20
+
21
+ def initialize(status)
22
+ raise ArgumentError, 'status cannot be nil' if status.nil?
23
+ @status = status
24
+ end
25
+
26
+ def ==(other_workflow_set)
27
+ other_workflow_set.instance_of?(self.class)
28
+ end
29
+
30
+ def size
31
+ Sidekiq.redis { |conn| conn.zcard(redis_zkey) }
32
+ end
33
+
34
+ def add(workflow)
35
+ Sidekiq.redis { |conn| conn.zadd(redis_zkey, Time.now.to_f, workflow.jid) }
36
+ end
37
+
38
+ def contains?(workflow)
39
+ !!Sidekiq.redis { |conn| conn.zscore(redis_zkey, workflow.jid) }
40
+ end
41
+
42
+ # Remove a workflow from the set if it is present. This operation can
43
+ # only be executed as cleanup (i.e., on a workflow that has been
44
+ # unpersisted/deleted); otherwise it will fail in order to avoid
45
+ # memory leaks.
46
+ def remove(workflow)
47
+ raise 'Workflow still exists' if workflow.exists?
48
+ Sidekiq.redis { |conn| conn.zrem(redis_zkey, workflow.jid) }
49
+ end
50
+
51
+ # Move a workflow to this set from its current one
52
+ # This should really be done in Lua, but unit testing support is just not there,
53
+ # so there is a potential race condition in which a workflow could end up in
54
+ # multiple sets. the effect of this is minimal, so we'll fix it later.
55
+ def move(workflow, from_set=nil)
56
+ Sidekiq.redis do |conn|
57
+ conn.multi do
58
+ conn.zrem(from_set.redis_zkey, workflow.jid) if from_set
59
+ conn.zadd(redis_zkey, Time.now.to_f, workflow.jid)
60
+ end.last
61
+ end
62
+ end
63
+
64
+ def each
65
+ return enum_for(:each) unless block_given?
66
+
67
+ elements = []
68
+ last_max_score = Time.now.to_f
69
+ loop do
70
+ elements = Sidekiq.redis do |conn|
71
+ conn.zrevrangebyscore(redis_zkey, last_max_score, '-inf', limit: [0, PAGE_SIZE], with_scores: true)
72
+ .drop_while { |elt| elements.include?(elt) }
73
+ end
74
+ break if elements.empty?
75
+ elements.each { |jid, _| yield Workflow.find_by_jid(jid) }
76
+ _, last_max_score = elements.last # timestamp of last element
77
+ end
78
+ end
79
+
80
+ def redis_zkey
81
+ "hierarchy:set:#{@status}"
82
+ end
83
+ end
84
+
85
+ # An implementation of WorkflowSet that auto-prunes by time & size
86
+ # to stay within space constraints. Do _not_ use for workflows that
87
+ # cannot be lost (i.e., are in any state of progress, or require followup)
88
+ class PruningSet < WorkflowSet
89
+ def self.timeout
90
+ Sidekiq.options[:dead_timeout_in_seconds]
91
+ end
92
+
93
+ def self.max_workflows
94
+ Sidekiq.options[:dead_max_workflows] || Sidekiq.options[:dead_max_jobs]
95
+ end
96
+
97
+ def add(workflow)
98
+ prune
99
+ super
100
+ end
101
+
102
+ def prune
103
+ Sidekiq.redis do |conn|
104
+ conn.multi do
105
+ conn.zrangebyscore(redis_zkey, '-inf', Time.now.to_f - self.class.timeout) # old workflows
106
+ conn.zrevrange(redis_zkey, self.class.max_workflows, -1) # excess workflows
107
+ end.flatten.uniq # take the union of both pruning strategies
108
+ .tap { |to_remove| conn.zrem(redis_zkey, to_remove) if to_remove.any? }
109
+ end.each { |jid| Workflow.find_by_jid(jid).delete }
110
+ end
111
+ end
112
+
113
+
114
+ ### Instances
115
+
116
+ class RunningSet < WorkflowSet
117
+ def initialize
118
+ super 'running'
119
+ end
120
+ end
121
+
122
+ class CompleteSet < PruningSet
123
+ def initialize
124
+ super 'complete'
125
+ end
126
+ end
127
+
128
+ class FailedSet < PruningSet
129
+ def initialize
130
+ super 'failed'
131
+ end
132
+ end
133
+ end
134
+ end