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
@@ -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