kthxbye 1.0.0

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 (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
+ }