kthxbye 1.0.0

Sign up to get free protection for your applications and to get access to all the features.
Files changed (41) hide show
  1. data/.document +5 -0
  2. data/.gitignore +33 -0
  3. data/DESIGN.textile +81 -0
  4. data/Gemfile +21 -0
  5. data/Gemfile.lock +42 -0
  6. data/LICENSE +20 -0
  7. data/README.textile +91 -0
  8. data/Rakefile +53 -0
  9. data/VERSION +1 -0
  10. data/config.ru +7 -0
  11. data/lib/kthxbye.rb +151 -0
  12. data/lib/kthxbye/config.rb +35 -0
  13. data/lib/kthxbye/exceptions.rb +4 -0
  14. data/lib/kthxbye/failure.rb +62 -0
  15. data/lib/kthxbye/helper.rb +42 -0
  16. data/lib/kthxbye/job.rb +127 -0
  17. data/lib/kthxbye/version.rb +5 -0
  18. data/lib/kthxbye/web_interface.rb +117 -0
  19. data/lib/kthxbye/web_interface/public/application.js +16 -0
  20. data/lib/kthxbye/web_interface/public/awesome-buttons.css +108 -0
  21. data/lib/kthxbye/web_interface/public/jquery.js +154 -0
  22. data/lib/kthxbye/web_interface/public/style.css +128 -0
  23. data/lib/kthxbye/web_interface/views/error.haml +5 -0
  24. data/lib/kthxbye/web_interface/views/failed.haml +26 -0
  25. data/lib/kthxbye/web_interface/views/hash.haml +6 -0
  26. data/lib/kthxbye/web_interface/views/layout.haml +33 -0
  27. data/lib/kthxbye/web_interface/views/overview.haml +2 -0
  28. data/lib/kthxbye/web_interface/views/queues.haml +31 -0
  29. data/lib/kthxbye/web_interface/views/set.haml +4 -0
  30. data/lib/kthxbye/web_interface/views/stats.haml +32 -0
  31. data/lib/kthxbye/web_interface/views/view_backtrace.haml +8 -0
  32. data/lib/kthxbye/web_interface/views/workers.haml +24 -0
  33. data/lib/kthxbye/web_interface/views/working.haml +19 -0
  34. data/lib/kthxbye/worker.rb +221 -0
  35. data/test/helper.rb +18 -0
  36. data/test/redis-test.conf +115 -0
  37. data/test/test_failure.rb +51 -0
  38. data/test/test_helper.rb +86 -0
  39. data/test/test_kthxbye.rb +213 -0
  40. data/test/test_worker.rb +148 -0
  41. metadata +364 -0
@@ -0,0 +1,35 @@
1
+ module Kthxbye
2
+ module Config
3
+
4
+ # default options for Kthxbye
5
+ #
6
+ # redis_server = the ip to connect to by defaut
7
+ #
8
+ # redis_port = default redis port
9
+ #
10
+ # attempts = default number of attempts on a failing job
11
+ # before moving to the failed job store
12
+ #
13
+ # vervose = more output
14
+ #
15
+ DEFAULT = {:redis_server => '127.0.0.1',
16
+ :redis_port => 9876,
17
+ :attempts => 1,
18
+ :verbose => false}.freeze
19
+
20
+ # configures any other args input by the user.
21
+ # can pull from a config.yaml file as well.
22
+ #
23
+ def self.setup( args=nil )
24
+ @options = DEFAULT.dup
25
+ @options.merge!( YAML.load('config.yaml') ) if File.exist?( 'config.yaml' )
26
+ @options.merge!( args ) if args
27
+ end
28
+
29
+ def self.options
30
+ return @options if @options
31
+ Config.setup
32
+ end
33
+
34
+ end
35
+ end
@@ -0,0 +1,4 @@
1
+ module Kthxbye
2
+ # raised when a worker is killed during processing
3
+ class ActiveWorkerKilled < RuntimeError; end
4
+ end
@@ -0,0 +1,62 @@
1
+ module Kthxbye
2
+ module Failure
3
+ include Helper
4
+ extend Helper
5
+
6
+ def self.all
7
+ redis.hvals( :failed ).sort.map{|x| decode( x )}
8
+ end
9
+
10
+ # returns a specified failed job data
11
+ def self.find(id)
12
+ decode( redis.hget( :failed, id ) )
13
+ end
14
+
15
+ # gets count of all errors
16
+ def self.count
17
+ redis.hkeys( :failed ).size
18
+ end
19
+
20
+ # gets count of all errors of a specific type
21
+ def self.count_type(type)
22
+ vals = redis.hvals( :failed )
23
+ vals.each {|x| o = decode(x); vals.delete x if o['type'] !~ /#{type.to_s}/}
24
+ vals.size
25
+ end
26
+
27
+ # creates a Failure object.
28
+ def self.create(job, exception)
29
+ failed_attempts = (Failure.find(job.id)['attempts'].to_i + 1) if redis.hexists( :failed, job.id )
30
+
31
+ error = {
32
+ :type => exception.class.to_s,
33
+ :error => exception.to_s,
34
+ :job => job.id,
35
+ :queue => job.queue,
36
+ :time => Time.now,
37
+ :backtrace => Array( exception.backtrace ),
38
+ :attempts => (failed_attempts || 1)
39
+ }
40
+
41
+ redis.hset( :failed, job.id, encode( error ) )
42
+
43
+ job.dequeue
44
+ end
45
+
46
+ def self.rerun(id)
47
+ failure = Failure.find(id)
48
+ Job.find(id, failure['queue']).rerun
49
+ end
50
+
51
+ def self.fails_for_job(id)
52
+ failure = decode( redis.hget( :failed, id ) )
53
+ return failure ? failure['attempts'] : 0
54
+ end
55
+
56
+ # the only method allowed to clear exceptions out of the exception store
57
+ def self.clear_exception(id)
58
+ redis.hdel( :failed, id )
59
+ end
60
+
61
+ end
62
+ end
@@ -0,0 +1,42 @@
1
+ module Kthxbye
2
+ module Helper
3
+
4
+ def redis
5
+ Kthxbye.redis
6
+ end
7
+
8
+ def log(msg)
9
+ if Kthxbye::Config.options[:verbose]
10
+ puts "!! #{msg} - #{Time.now.strftime("%I:%M%p")}"
11
+ end
12
+ end
13
+
14
+ #
15
+ # encode/decode code taken and modified from Resque
16
+ # (http://github.com/defunkt/resque/blob/master/lib/resque/helpers.rb)
17
+ #
18
+ def encode( data )
19
+ if defined? Yajl
20
+ Yajl::Encoder.encode(data)
21
+ else
22
+ data.to_json
23
+ end
24
+ end
25
+
26
+ def decode( data )
27
+ return unless data
28
+
29
+ if defined? Yajl
30
+ begin
31
+ Yajl::Parser.parse( data, :check_utf8 => false )
32
+ rescue Yajl::ParseError
33
+ end
34
+ else
35
+ begin
36
+ JSON.parse( data )
37
+ rescue JSON::ParseError
38
+ end
39
+ end
40
+ end
41
+ end
42
+ end
@@ -0,0 +1,127 @@
1
+ module Kthxbye
2
+ class Job
3
+ include Helper
4
+ extend Helper
5
+
6
+ attr_accessor :id, :queue, :data, :worker
7
+ attr_reader :failed_attempts
8
+
9
+ def self.add_to_queue(queue, id)
10
+ redis.rpush( "queue:#{queue}", id )
11
+ end
12
+
13
+ # insert a job into the queue
14
+ def self.create(queue, klass, *args)
15
+ raise "Need a queue to store job in" if queue.to_s.empty?
16
+ raise "No class to reference job type by" if klass.nil?
17
+
18
+ redis.incr :uniq_id
19
+ id = redis.get :uniq_id
20
+
21
+ Job.add_to_queue( queue, id )
22
+ Kthxbye.register_queue( queue )
23
+
24
+ redis.hset( "data-store:#{queue}", id, encode( {:klass => klass, :payload => args} ) )
25
+ log "Created job in queue #{queue} with an unique key of #{id}"
26
+
27
+ return id.to_i
28
+ end
29
+
30
+ def self.find(id, queue)
31
+ data = decode( redis.hget( "data-store:#{queue}", id ) )
32
+ data ? Job.new(id, queue, data) : nil
33
+ end
34
+
35
+ # removes all existence of this job and its data
36
+ # returns the last known status of the job
37
+ def self.destroy(id, queue)
38
+ ret = Job.find(id, queue).status
39
+
40
+ # remove the element from the active queue
41
+ redis.lrem("queue:#{queue}", 0, id)
42
+ # be sure we also remove it from the inactive queue
43
+ redis.srem("queue:#{queue}:inactive", id)
44
+ # remove the job's data as well
45
+ redis.hdel("data-store:#{queue}", id)
46
+ redis.hdel("result-store:#{queue}", id)
47
+ redis.hdel( :faulure, id )
48
+
49
+ return ret
50
+ end
51
+
52
+ # instantiates a job for the worker to run
53
+ def initialize(id, queue, data)
54
+ @id = id.to_i
55
+ @queue = queue
56
+ @data = data
57
+ @failed_attempts = Failure.fails_for_job(@id) # local tracking only, for rerun purposes
58
+ end
59
+
60
+ # simply requeues a job
61
+ def rerun
62
+ Job.add_to_queue( @queue, @id )
63
+ end
64
+
65
+ def status
66
+ if result
67
+ :succeeded
68
+ elsif Failure.find(@id)
69
+ :failed
70
+ elsif active?
71
+ :active
72
+ else
73
+ :inactive
74
+ end
75
+ end
76
+
77
+ def result
78
+ decode( redis.hget("result-store:#{@queue}", @id) )
79
+ end
80
+
81
+ # simply removes this job from the active queue and places it
82
+ # on the inactive list.
83
+ # does not remove job payload
84
+ def dequeue
85
+ redis.lrem("queue:#{@queue}", 0, @id)
86
+ inactive
87
+ end
88
+
89
+ # does the heavy lifting of running a job
90
+ def perform
91
+ begin
92
+ @klass = Object.const_get(@data['klass'])
93
+ @payload = @data['payload']
94
+
95
+ result = @klass.send(:perform, *@payload)
96
+ redis.hset( "result-store:#{@queue}", @id, encode( result ) )
97
+ return result
98
+ rescue Exception => ex
99
+ @failed_attempts += 1
100
+ log "Error occured: #{ex.message}. Try: #{@failed_attempts}/#{Kthxbye::Config.options[:attempts]}"
101
+ return Kthxbye::Failure.create( self, ex ) if @failed_attempts >= Kthxbye::Config.options[:attempts]
102
+ perform
103
+ end
104
+ end
105
+
106
+ # will allow us to track when this job is being worked
107
+ def active
108
+ redis.srem("jobs:inactive", @id)
109
+ end
110
+
111
+ def active?
112
+ !redis.sismember("jobs:inactive", @id)
113
+ end
114
+
115
+ def inactive
116
+ redis.sadd("jobs:inactive", @id)
117
+ end
118
+
119
+ def ==(obj)
120
+ return false if obj.nil?
121
+ @data == obj.data &&
122
+ @id == obj.id &&
123
+ @queue == obj.queue
124
+ end
125
+
126
+ end
127
+ end
@@ -0,0 +1,5 @@
1
+ module Kthxbye
2
+ # may be a bad idea if only lib dir is installed...
3
+ Version = VERSION = File.read(File.dirname(__FILE__)+'/../../VERSION').chomp
4
+ end
5
+
@@ -0,0 +1,117 @@
1
+ require 'sinatra/base'
2
+ require 'haml'
3
+ require 'kthxbye'
4
+
5
+ module Kthxbye
6
+ class WebInterface < Sinatra::Base
7
+ base = File.dirname( File.expand_path(__FILE__) )
8
+
9
+ set :views, "#{base}/web_interface/views"
10
+ set :public, "#{base}/web_interface/public"
11
+ set :static, true
12
+ set :environment, :development
13
+
14
+ helpers do
15
+ include Rack::Utils
16
+
17
+ def toggle_poll
18
+ if @active
19
+ text = "Last update: #{Time.now.strftime("%H:%M:%S")}"
20
+ else
21
+ text = "<a rel='poll_link' class='awesome button small' href='#{url(request.path_info)}.poll'>Live Poll</a>"
22
+ end
23
+ "<div id='poll_status'>#{text}</div>"
24
+ end
25
+
26
+ def url(*path_parts)
27
+ [ path_prefix, path_parts ].join("/").squeeze('/')
28
+ end
29
+
30
+ def path_prefix
31
+ request.env['SCRIPT_NAME']
32
+ end
33
+
34
+ def show(page, layout=true)
35
+ begin
36
+ haml page.to_sym, {:layout => layout}, :kthxbye => Kthxbye
37
+ rescue Errno::ECONNREFUSED
38
+ haml :error, {:layout => false}, :error => "Can't connect to Redis at #{Kthxbye::Config.options[:redis_server]}:#{Kthxbye::Config.options[:redis_port]}"
39
+ end
40
+ end
41
+
42
+ def partial(template, local_vars = {})
43
+ haml template.to_sym, {:layout => false}, local_vars
44
+ end
45
+
46
+ def current_url
47
+ url request.path_info.split("/")
48
+ end
49
+
50
+ def mark_current(page)
51
+ 'current' if current_url[1..-1] == page.split(" ").first
52
+ end
53
+
54
+ def tab(name)
55
+ name.to_s.downcase!
56
+ path = url(name)
57
+ "<a href=#{path}><span class='tab #{mark_current(name)}'>#{name}</span></a>"
58
+ end
59
+
60
+ def tabs
61
+ @tabs ||= ["Overview", "Workers (#{Kthxbye.workers.size})", "Failed (#{Kthxbye::Failure.all.size})", "Queues (#{Kthxbye.queues.size})", "Stats"]
62
+ end
63
+ end
64
+
65
+ # default route
66
+ get "/?" do
67
+ redirect url(:overview)
68
+ end
69
+
70
+ %w(overview workers failed queues stats).each do |page|
71
+ get "/#{page}" do
72
+ show page
73
+ end
74
+ end
75
+
76
+ get "/stats/:id" do
77
+ show :stats
78
+ end
79
+
80
+ get "/stats/keys/:key" do
81
+ show :stats
82
+ end
83
+
84
+ %w(overview workers).each do |page|
85
+ get "/#{page}.poll" do
86
+ @active = true
87
+ content_type "text/plain"
88
+ show(page.to_sym, false)
89
+ end
90
+ end
91
+
92
+ get "/view_backtrace" do
93
+ @backtrace = Kthxbye::Failure.find(params[:id])['backtrace']
94
+ haml :view_backtrace, {:layout => :layout}
95
+ end
96
+
97
+ post "/queues/:q/remove" do
98
+ Kthxbye.unregister_queue(params[:q])
99
+ redirect url(:overview)
100
+ end
101
+
102
+ post "/failed/:job/rerun" do
103
+ Kthxbye::Failure.rerun(params[:job])
104
+ redirect url(:failed)
105
+ end
106
+
107
+ post "/failed/:job/clear" do
108
+ job = Kthxbye::Failure.clear_exception(params[:job])
109
+ redirect url(:failed)
110
+ end
111
+
112
+ post "/toggle_polling" do
113
+ redirect url(:overview)
114
+ end
115
+
116
+ end
117
+ end
@@ -0,0 +1,16 @@
1
+ $(function() {
2
+ var poll_interval = 2
3
+
4
+ $('a[rel="poll_link"]').click(function(e) {
5
+ e.preventDefault()
6
+ var action = this.href
7
+
8
+ $('div#poll_status').html("Starting...")
9
+
10
+ setInterval(function() {
11
+ $("#content").load(action)
12
+ }, 1000 * poll_interval)
13
+
14
+ return false
15
+ })
16
+ })
@@ -0,0 +1,108 @@
1
+ /*
2
+ awesome buttons are based on a blog post by ZERB
3
+ Read there blog post for more information:
4
+ "Super awesome Buttons with CSS3 and RGBA":http://www.zurb.com/article/266/super-awesome-buttons-with-css3-and-rgba
5
+
6
+ this buttons are even more awesome, as the need only one color for all three states,
7
+ and have an super awesome onclick state
8
+ */
9
+
10
+ /* set an awesome color for the buttons, feel free to add new colors like an .awesome.green or .awesome.secondary */
11
+ .awesome {
12
+ background-color: #5D6F72 !important;
13
+ color: #fff !important;
14
+ }
15
+
16
+ /* the awesome size gets set here. Feel free to add new sizes, like .awesome.small or .small.large */
17
+ .awesome { padding: 5px 10px 6px !important; font-size: 13px !important; }
18
+ .awesome:active { padding: 6px 10px 5px !important; }
19
+
20
+ .awesome.small { padding: 2px, 5px, 3px !important; font-size: 10px !important; }
21
+
22
+ /* Touch the rest at your onw risk. */
23
+ .awesome {
24
+ border: 0 !important;
25
+ cursor: pointer !important;
26
+ font-style: normal !important;
27
+ font-weight: bold !important;
28
+ font: inherit !important;
29
+ line-height: 1 !important;
30
+ position: relative !important;
31
+ text-align: center !important;
32
+ text-decoration: none !important;
33
+
34
+ /* vertical margin is the opposite of button's awesomeness */
35
+ margin-top: 0 !important;
36
+ margin-bottom: 0 !important;
37
+
38
+ /* not all browser support these, but who cares? */
39
+ text-shadow: 0 -1px 1px rgba(0,0,0,0.25), -2px 0 1px rgba(0,0,0,0.25) !important;
40
+
41
+ border-radius: 5px !important;
42
+ -moz-border-radius: 5px !important;
43
+ -webkit-border-radius: 5px !important;
44
+ box-shadow: 0 1px 2px rgba(0,0,0,0.5) !important;
45
+ -moz-box-shadow: 0 1px 2px rgba(0,0,0,0.5) !important;
46
+ -webkit-box-shadow: 0 1px 2px rgba(0,0,0,0.5) !important;
47
+
48
+ /* who needs images these days? */
49
+ background-image: -moz-linear-gradient(top, rgba(255,255,255,.2), rgba(150,150,150,.2), rgba(0,0,0,.0)) !important;
50
+ background-image: -webkit-gradient(linear, 0% 0%, 0% 100%, from(rgba(255,255,255,.2)), color-stop(0.5, rgba(150,150,150,.2)), to(rgba(0,0,0,.0))) !important;
51
+ filter: progid:DXImageTransform.Microsoft.gradient(startColorstr=#30ffffff,endColorstr=#10000000) progid:DXImageTransform.Microsoft.Shadow(color=#000000,direction=135,strength=2);
52
+
53
+ /* cross browser inline block hack
54
+ see http://blog.mozilla.com/webdev/2009/02/20/cross-browser-inline-block/ */
55
+ display: -moz-inline-stack;
56
+ display: inline-block;
57
+ vertical-align: middle !important;
58
+ *display: inline !important;
59
+ position: relative;
60
+
61
+ /* IE luv */
62
+ zoom: 1;
63
+
64
+ /* disable text selection (Firefox only) */
65
+ -moz-user-select: none;
66
+ }
67
+
68
+ /* OPERA only
69
+ if there is no border set, Opera shows a transparent background-color if border-radius is set. */
70
+ @media all and (-webkit-min-device-pixel-ratio:10000),not all and (-webkit-min-device-pixel-ratio:0) {
71
+ input.awesome {
72
+ border: 1px solid RGBa(0,0,0,0) !important;
73
+ }
74
+ }
75
+
76
+ /* hide selection background color */
77
+ .awesome::selection {
78
+ background: transparent;
79
+ }
80
+
81
+ .awesome {
82
+ outline: 0; /* remove dotted line, works for links only */
83
+ }
84
+ .awesome::-moz-focus-inner {
85
+ border: none; /* remove dotted lines for buttons */
86
+ }
87
+ .awesome:focus,
88
+ .awesome:hover {
89
+ background-image: -moz-linear-gradient(top, rgba(255,255,255,.4), rgba(150,150,150,.3), rgba(0,0,0,.0)) !important;
90
+ background-image: -webkit-gradient(linear, 0% 0%, 0% 100%, from(rgba(255,255,255,.4)), color-stop(0.5, rgba(150,150,150,.3)), to(rgba(0,0,0,.0))) !important;
91
+ #filter: progid:DXImageTransform.Microsoft.gradient(startColorstr=#50ffffff,endColorstr=#20000000) progid:DXImageTransform.Microsoft.Shadow(color=#000000,direction=135,strength=2);
92
+ }
93
+ .awesome:active {
94
+ background-image: -moz-linear-gradient(top, rgba(0,0,0,.2), rgba(150,150,150,.2), rgba(255,255,255,.2)) !important;
95
+ background-image: -webkit-gradient(linear, 0% 0%, 0% 100%, from(rgba(0,0,0,.2)), color-stop(0.5, rgba(150,150,150,.2)), to(rgba(255,255,255,.2))) !important;
96
+ filter: progid:DXImageTransform.Microsoft.gradient(startColorstr=#20000000,endColorstr=#50ffffff) progid:DXImageTransform.Microsoft.Shadow(color=#000000,direction=315,strength=1);
97
+
98
+ box-shadow: inset 0 1px 2px rgba(0,0,0,0.7) !important;
99
+ -moz-box-shadow: inset 0 1px 2px rgba(0,0,0,0.7) !important;
100
+ -webkit-box-shadow: inset 0 1px 2px rgba(0,0,0,0.7) !important;
101
+ }
102
+
103
+ /* Safari doesn't support inset box shadow, so we better remove it */
104
+ @media screen and (-webkit-min-device-pixel-ratio:0){
105
+ .awesome:active {
106
+ -webkit-box-shadow: none;
107
+ }
108
+ }