cyclop 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
data/lib/cyclop.rb ADDED
@@ -0,0 +1,131 @@
1
+ require "mongo"
2
+ require "logger"
3
+ require "socket"
4
+
5
+ require "cyclop/job"
6
+ require "cyclop/worker"
7
+ require "cyclop/action"
8
+ require "cyclop/version"
9
+
10
+ module Cyclop
11
+ extend self
12
+
13
+ # Raised if db not set or connection error
14
+ class DatabaseNotAvailable < StandardError; end
15
+ # Raised if two actions share the same queue
16
+ class ActionQueueClash < StandardError; end
17
+ # Raised if no action has been found
18
+ class NoActionFound < StandardError; end
19
+
20
+ # Set which `Mongo::DB` to use
21
+ def db=(db)
22
+ @db = db
23
+ end
24
+
25
+ # Get `Mongo::DB` to use
26
+ def db
27
+ @db
28
+ end
29
+
30
+ # Get memoized host
31
+ def host
32
+ @host ||= Socket.gethostname
33
+ end
34
+
35
+ # Get a unique identifier for current process
36
+ def master_id
37
+ @master_id ||= "#{host}-#{Process.pid}-#{Thread.current}"
38
+ end
39
+
40
+ # Queues a new job
41
+ #
42
+ # Minimum usage:
43
+ #
44
+ # Cyclop.push queue: "refresh_cache"
45
+ #
46
+ # With `:job_params`:
47
+ #
48
+ # # with an `Array`
49
+ # Cyclop.push queue: "email", job_params: ["1", :welcome]
50
+ #
51
+ # # with a `Hash`
52
+ # Cyclop.push queue: "email", job_params: {user_id: "1",
53
+ # type: "welcome"}
54
+ #
55
+ # With `:delay`:
56
+ #
57
+ # # Will not perform the task before a delay of 60 seconds
58
+ # Cyclop.push queue: "email", delay: 60
59
+ #
60
+ # With `:retries` and `:splay`:
61
+ #
62
+ # # Will mark the task as failed only after 3 retries
63
+ # Cyclop.push queue: "email", retries: 3
64
+ #
65
+ # # Will mark the task as failed only after 2 retries
66
+ # # and 30 seconds between each retry
67
+ # Cyclop.push queue: "email", retries: 2, splay: 30
68
+ #
69
+ # Parameters:
70
+ #
71
+ # * (Hash) opts (defaults to: {}) - a customizable set of options. The minimum required is :queue.
72
+ #
73
+ # Options Hash (opts):
74
+ #
75
+ # * (Symbol, String) :queue - name of the queue (required).
76
+ # * (Array, Hash) :job_params (nil) - parameters to send to the `Cyclop::Job#perform` method.
77
+ # * (Integer) :delay (0) - time to wait in `seconds` before the task should be performed.
78
+ # * (Integer) :retries (0) - number of retries before the `Cyclop::Job` is marked as failed.
79
+ # * (Integer) :splay (60) - time to wait in `seconds` between retry.
80
+ # * (String) :host (Cyclop.host) - host under which the `Cyclop::Job` should be added.
81
+ #
82
+ # Returns a `Cyclop::Job`
83
+ #
84
+ def push(opts={})
85
+ Cyclop::Job.create opts
86
+ end
87
+
88
+ # Get a `Cyclop::Job` to process
89
+ #
90
+ # Parameters:
91
+ #
92
+ # * (Symbol, String) queues - list of queues to get a `Cyclop::Job` from. Defaults to all.
93
+ # * (Hash) opts (defaults to: {}) - a customizable set of options.
94
+ #
95
+ # Options Hash (opts):
96
+ #
97
+ # * (String) :host - limit to `Cyclop::Job`s queued by this host.
98
+ #
99
+ # Returns a `Cyclop::Job` or `nil` if nothing to process
100
+ #
101
+ def next(*args)
102
+ opts = extract_opts! args
103
+ Cyclop::Job.next({queues: args, locked_by: master_id}.merge opts)
104
+ end
105
+
106
+ # Get failed `Cyclop::Job`s
107
+ #
108
+ # Parameters:
109
+ #
110
+ # * (Symbol, String) queues - list of queues to get a `Cyclop::Job` from. Defaults to all.
111
+ # * (Hash) opts (defaults to: {}) - a customizable set of options.
112
+ #
113
+ # Options Hash (opts):
114
+ #
115
+ # * (String) :host - limit to `Cyclop::Job`s queued by this host.
116
+ # * (Integer) :skip (0) - number of `Cyclop::Job`s to skip.
117
+ # * (Integer) :limit (nil) - maximum number of `Cyclop::Job`s to return.
118
+ #
119
+ # Returns an `Array` of failed `Cyclop::Job`
120
+ #
121
+ def failed(*args)
122
+ opts = extract_opts! args
123
+ Cyclop::Job.failed({queues: args}.merge opts)
124
+ end
125
+
126
+ private
127
+
128
+ def extract_opts!(args)
129
+ (args.pop if args.last.is_a?(Hash)) || {}
130
+ end
131
+ end
@@ -0,0 +1,46 @@
1
+ require "spec_helper"
2
+
3
+ describe Cyclop::Action do
4
+ describe ".find_by_queue(queue)" do
5
+ context "with no subclass" do
6
+ it "raises an Cyclop::NoActionFound" do
7
+ lambda {
8
+ Cyclop::Action.find_by_queue("email")
9
+ }.should raise_error Cyclop::NoActionFound, "No action defined"
10
+ end
11
+ end
12
+ context "with subclasses" do
13
+ before :all do
14
+ class EmailAction < Cyclop::Action
15
+ def self.queues
16
+ ["email", "email_welcome"]
17
+ end
18
+ end
19
+ class CacheAction < Cyclop::Action
20
+ def self.queues
21
+ ["cache"]
22
+ end
23
+ end
24
+ end
25
+ it "returns only action matching queue" do
26
+ Cyclop::Action.find_by_queue("email").should be EmailAction
27
+ end
28
+ it "returns nil if no action match queue" do
29
+ lambda {
30
+ Cyclop::Action.find_by_queue("compress")
31
+ }.should raise_error Cyclop::NoActionFound, 'No action found for "compress" queue. Valid queues: "email", "email_welcome", "cache"'
32
+ end
33
+ it "raises an error if two actions share the same queue" do
34
+ class EmailCacheAction < Cyclop::Action
35
+ def self.queues
36
+ ["email", "cache"]
37
+ end
38
+ end
39
+ lambda {
40
+ Cyclop::Action.find_by_queue "email"
41
+ }.should raise_error Cyclop::ActionQueueClash, '"email" queue belongs to multiple actions: EmailAction, EmailCacheAction'
42
+ Object.send :remove_const, :EmailCacheAction
43
+ end
44
+ end
45
+ end
46
+ end
@@ -0,0 +1,273 @@
1
+ require "spec_helper"
2
+
3
+ describe Cyclop::Job do
4
+ subject { Cyclop::Job.new queue: "demo" }
5
+
6
+ its(:queue){ should == "demo" }
7
+ its(:job_params){ should be_nil }
8
+ its(:delay){ should == 0 }
9
+ its(:delayed_until){ should be_nil }
10
+ its(:retries){ should == 0 }
11
+ its(:splay){ should == 60 }
12
+ its(:created_by){ should == Cyclop.host }
13
+ its(:created_at){ should be_nil }
14
+ its(:updated_at){ should be_nil }
15
+ its(:locked_by){ should be_nil }
16
+ its(:locked_at){ should be_nil }
17
+ its(:failed){ should be false }
18
+ its(:attempts){ should == 0 }
19
+ its(:errors){ should == [] }
20
+
21
+ describe "#save" do
22
+ it "raises a Cyclop::DatabaseNotAvailable if no db defined" do
23
+ old, Cyclop.db = Cyclop.db, nil
24
+ lambda {
25
+ Cyclop::Job.new(queue: "demo").save
26
+ }.should raise_error Cyclop::DatabaseNotAvailable
27
+ Cyclop.db = old
28
+ end
29
+ context "on create" do
30
+ it "saves the job" do
31
+ subject.save.should be_true
32
+ subject.should be_persisted
33
+ subject.reload.queue.should == "demo"
34
+ end
35
+ end
36
+ end
37
+
38
+ describe ".create(opts={})" do
39
+ it do
40
+ lambda {
41
+ Cyclop::Job.create
42
+ }.should raise_error ArgumentError, ":queue is required"
43
+ end
44
+ it "returns a persisted job on success" do
45
+ job = Cyclop::Job.create queue: "demo"
46
+ job.should be_persisted
47
+ end
48
+ end
49
+
50
+ describe ".next(opts={})" do
51
+ it do
52
+ lambda {
53
+ Cyclop::Job.next
54
+ }.should raise_error ArgumentError, "locked_by is required"
55
+ end
56
+ context "with no job in queue" do
57
+ it "returns nil" do
58
+ Cyclop::Job.next(locked_by: "myid").should be_nil
59
+ end
60
+ end
61
+ context "with jobs in queue" do
62
+ let(:email_job_delayed) { Cyclop::Job.create queue: "email", delay: 3600 }
63
+ let(:email_job_failed) { Cyclop::Job.create queue: "email", failed: true }
64
+ let(:email_job_no_retry) { Cyclop::Job.create queue: "email", attempts: 1 }
65
+ let(:email_job_locked) { Cyclop::Job.create queue: "email", locked_at: Time.now.utc-10 }
66
+ let(:email_job_next) { Cyclop::Job.create queue: "email" }
67
+ let(:email_job_other_host) { Cyclop::Job.create queue: "email", created_by: "demo.local" }
68
+ let(:snail_mail_job_stalling) { Cyclop::Job.create queue: "snail mail", locked_at: Time.now.utc-3000 }
69
+
70
+ before do
71
+ email_job_delayed
72
+ email_job_failed
73
+ email_job_no_retry
74
+ email_job_next
75
+ email_job_other_host
76
+ snail_mail_job_stalling
77
+ end
78
+
79
+ it "returns the first job to process when called without queue" do
80
+ Cyclop::Job.next(locked_by: "myid").should == email_job_next
81
+ end
82
+
83
+ it "increments attempts" do
84
+ Cyclop::Job.next(locked_by: "myid").attempts.should == 1
85
+ end
86
+
87
+ it "locks to given id" do
88
+ Cyclop::Job.next(locked_by: "myid").locked_by.should == "myid"
89
+ end
90
+
91
+ it "sets locked_at" do
92
+ Cyclop::Job.next(locked_by: "myid").locked_at.should_not be_nil
93
+ end
94
+
95
+ it "returns the first job to process when called with :email queue" do
96
+ Cyclop::Job.next(queues: [:email], locked_by: "myid").should == email_job_next
97
+ end
98
+
99
+ it "returns the first job to process for a given host" do
100
+ Cyclop::Job.next(queues: [:email], locked_by: "myid", host: "demo.local").should == email_job_other_host
101
+ end
102
+
103
+ it "returns nil when called with :cache queue" do
104
+ Cyclop::Job.next(queues: [:cache], locked_by: "myid").should be_nil
105
+ end
106
+
107
+ it "unlock and returns job stalled for a long time" do
108
+ Cyclop::Job.next(queues: ["snail mail"], locked_by: "myid").should == snail_mail_job_stalling
109
+ end
110
+ end
111
+ end
112
+ describe ".failed(opts={})" do
113
+ context "with no job in queue" do
114
+ it "returns an empty array" do
115
+ Cyclop::Job.failed.should == []
116
+ end
117
+ end
118
+ context "with jobs in queue" do
119
+ let(:email_job_failed) { Cyclop::Job.create queue: "email", failed: true }
120
+ let(:cache_job_no_retry) { Cyclop::Job.create queue: "cache", attempts: 1 }
121
+ let(:cache_job_failed) { Cyclop::Job.create queue: "cache", failed: true }
122
+ let(:email_job_next) { Cyclop::Job.create queue: "email" }
123
+
124
+ before do
125
+ email_job_failed
126
+ cache_job_no_retry
127
+ cache_job_failed
128
+ email_job_next
129
+ end
130
+
131
+ it "returns all failed jobs when called without queue" do
132
+ Cyclop::Job.failed.should == [email_job_failed, cache_job_no_retry, cache_job_failed]
133
+ end
134
+
135
+ it "returns all failed job to process when called with :email queue" do
136
+ Cyclop::Job.failed(queues: [:email]).should == [email_job_failed]
137
+ end
138
+
139
+ it "returns empty array when called with :snail_mail queue" do
140
+ Cyclop::Job.failed(queues: [:snail_mail]).should == []
141
+ end
142
+
143
+ it "respect :skip and :limit options" do
144
+ Cyclop::Job.failed(skip: 1, limit: 1).should == [cache_job_no_retry]
145
+ end
146
+ end
147
+ end
148
+ describe "#complete!" do
149
+ context "when locked by the same process" do
150
+ let(:email_job) { Cyclop::Job.create queue: "email", locked_by: Cyclop.master_id, locked_at: Time.now.utc }
151
+ it "removes the job" do
152
+ email_job.complete!
153
+ Cyclop::Job.find(email_job._id).should be_nil
154
+ end
155
+ end
156
+ context "when locked by another process" do
157
+ let(:email_job) { Cyclop::Job.create queue: "email", locked_by: "anotherid", locked_at: Time.now.utc }
158
+ it "keeps the job" do
159
+ email_job.complete!
160
+ Cyclop::Job.find(email_job._id).should == email_job
161
+ end
162
+ end
163
+ end
164
+ describe "#release!" do
165
+ context "without exception" do
166
+ context "when locked by the same process and no more retries to do" do
167
+ let(:email_job) { Cyclop::Job.create queue: "email", locked_by: Cyclop.master_id, locked_at: ::Time.at(Time.now.to_i).utc, attempts: 1 }
168
+ before :all do
169
+ email_job.release!
170
+ @reload = Cyclop::Job.find email_job._id
171
+ end
172
+ it "marks it as failed" do
173
+ @reload.failed.should be_true
174
+ end
175
+ it "keeps locked_at" do
176
+ @reload.locked_at.should == email_job.locked_at
177
+ end
178
+ it "keeps locked_by" do
179
+ @reload.locked_by.should == email_job.locked_by
180
+ end
181
+ end
182
+ context "when locked by the same process and more retries to do" do
183
+ let(:email_job) { Cyclop::Job.create queue: "email", locked_by: Cyclop.master_id, locked_at: ::Time.at(Time.now.to_i).utc, attempts: 1, retries: 1, splay: 1 }
184
+ before :all do
185
+ email_job.release!
186
+ @reload = Cyclop::Job.find email_job._id
187
+ end
188
+ it "marks it as failed" do
189
+ @reload.failed.should be_false
190
+ end
191
+ it "clears locked_at" do
192
+ @reload.locked_at.should be_nil
193
+ end
194
+ it "clears locked_by" do
195
+ @reload.locked_by.should be_nil
196
+ end
197
+ it "sets delayed_until based on splay" do
198
+ @reload.delayed_until.should == email_job.locked_at+1
199
+ end
200
+ end
201
+ context "when locked by another process and no more retries to do" do
202
+ let(:email_job) { Cyclop::Job.create queue: "email", locked_by: "anotherid", locked_at: Time.now.utc, attempts: 1 }
203
+ it "doesn't mark it as failed" do
204
+ email_job.release!
205
+ Cyclop::Job.find(email_job._id).failed.should be_false
206
+ end
207
+ end
208
+ end
209
+ context "with exception" do
210
+ let(:exception) { mock :class => Exception, :message => "Soft fail", :backtrace => "backtrace" }
211
+ before do
212
+ email_job.release! exception
213
+ @reload = Cyclop::Job.find email_job._id
214
+ end
215
+ context "when locked by the same process and no more retries to do" do
216
+ let(:email_job) { Cyclop::Job.create queue: "email", locked_by: Cyclop.master_id, locked_at: ::Time.at(Time.now.to_i).utc, attempts: 1 }
217
+ it "marks it as failed" do
218
+ @reload.failed.should be_true
219
+ end
220
+ it "keeps locked_at" do
221
+ @reload.locked_at.should == email_job.locked_at
222
+ end
223
+ it "keeps locked_by" do
224
+ @reload.locked_by.should == email_job.locked_by
225
+ end
226
+ it "has recorded the error" do
227
+ @reload.errors.should have(1).item
228
+ error = @reload.errors.first
229
+ error["locked_by"].should == email_job.locked_by
230
+ error["locked_at"].should == email_job.locked_at
231
+ error["class"].should == "Exception"
232
+ error["message"].should == "Soft fail"
233
+ error["backtrace"].should == "backtrace"
234
+ error["created_at"].should_not be_nil
235
+ end
236
+ end
237
+ context "when locked by the same process and more retries to do" do
238
+ let(:email_job) { Cyclop::Job.create queue: "email", locked_by: Cyclop.master_id, locked_at: ::Time.at(Time.now.to_i).utc, attempts: 1, retries: 2, splay: 1 }
239
+ it "marks it as failed" do
240
+ @reload.failed.should be_false
241
+ end
242
+ it "clears locked_at" do
243
+ @reload.locked_at.should be_nil
244
+ end
245
+ it "clears locked_by" do
246
+ @reload.locked_by.should be_nil
247
+ end
248
+ it "sets delayed_until based on splay" do
249
+ @reload.delayed_until.should == email_job.locked_at+1
250
+ end
251
+ it "has recorded the error" do
252
+ @reload.errors.should have(1).item
253
+ error = @reload.errors.first
254
+ error["locked_by"].should == email_job.locked_by
255
+ error["locked_at"].should == email_job.locked_at
256
+ error["class"].should == "Exception"
257
+ error["message"].should == "Soft fail"
258
+ error["backtrace"].should == "backtrace"
259
+ error["created_at"].should_not be_nil
260
+ end
261
+ end
262
+ context "when locked by another process and no more retries to do" do
263
+ let(:email_job) { Cyclop::Job.create queue: "email", locked_by: "anotherid", locked_at: Time.now.utc, attempts: 1 }
264
+ it "doesn't mark it as failed" do
265
+ @reload.failed.should be_false
266
+ end
267
+ it "doesn't add error" do
268
+ @reload.errors.should be_empty
269
+ end
270
+ end
271
+ end
272
+ end
273
+ end
@@ -0,0 +1,78 @@
1
+ require "spec_helper"
2
+
3
+ describe Cyclop::Worker do
4
+ subject { Cyclop::Worker.new({"mongo" => {"database" => "cyclop_test"}}) }
5
+
6
+ its(:queues){ should be_empty }
7
+ its(:logger){ should_not be_nil }
8
+ its(:sleep_interval){ should == 1 }
9
+ its(:actions){ should == "./actions" }
10
+
11
+ it "raise ArgumentError without mongo['database']" do
12
+ lambda {
13
+ Cyclop::Worker.new
14
+ }.should raise_error ArgumentError, 'mongo["database"] is required'
15
+ end
16
+
17
+ describe "#perform" do
18
+ let(:worker) do
19
+ Cyclop::Worker.new({
20
+ "mongo" => {"database" => "cyclop_test"},
21
+ "actions" => File.expand_path("../../fixtures/actions", __FILE__),
22
+ })
23
+ end
24
+ let(:job) { Cyclop.push queue: "slow", job_params: ["tony@starkenterprises.com", :welcome] }
25
+ it "loads actions files" do
26
+ lambda { Cyclop::Spec::Action::Email }.should raise_error NameError
27
+ worker.perform job
28
+ lambda { Cyclop::Spec::Action::Email }.should_not raise_error
29
+ end
30
+ it "calls perform on action class with specified params" do
31
+ require File.expand_path("../../fixtures/actions/email", __FILE__)
32
+ Cyclop::Spec::Action::Email.should_receive(:perform).with("tony@starkenterprises.com", :welcome)
33
+ worker.perform job
34
+ end
35
+ end
36
+
37
+ describe "#run" do
38
+ let(:worker) do
39
+ Cyclop::Worker.new({
40
+ "log_file" => File.expand_path("../../../test.log", __FILE__),
41
+ "mongo" => {"database" => "cyclop_test"},
42
+ "actions" => File.expand_path("../../fixtures/actions", __FILE__),
43
+ })
44
+ end
45
+ context "with successful action" do
46
+ it "remove the job" do
47
+ job = Cyclop.push queue: "slow", job_params: ["tony@starkenterprises.com", :welcome]
48
+ t = Thread.new { worker.run }
49
+ sleep 1
50
+ worker.stop
51
+ t.join
52
+ Cyclop::Job.find(job._id).should be_nil
53
+ end
54
+ end
55
+
56
+ context "with failing action" do
57
+ it "mark the job as failed" do
58
+ job = Cyclop.push queue: "slow", job_params: ["tony@starkenterprises.com"]
59
+ t = Thread.new { worker.run }
60
+ sleep 1
61
+ worker.stop
62
+ t.join
63
+ job.reload.failed.should be_true
64
+ end
65
+
66
+ it "mark the job as failed after retry" do
67
+ job = Cyclop.push queue: "slow", job_params: ["tony@starkenterprises.com"], retries: 1, splay: 0
68
+ t = Thread.new { worker.run }
69
+ sleep 1
70
+ worker.stop
71
+ t.join
72
+ job.reload
73
+ job.failed.should be_true
74
+ job.attempts.should == 2
75
+ end
76
+ end
77
+ end
78
+ end
@@ -0,0 +1,39 @@
1
+ require "spec_helper"
2
+
3
+ describe Cyclop do
4
+ describe ".push(opts={})" do
5
+ it "creates a new Cyclop::Job initialized with opts" do
6
+ opts = {}
7
+ Cyclop::Job.should_receive :create, with: opts
8
+ Cyclop.push opts
9
+ end
10
+ end
11
+ describe ".next(*args)" do
12
+ it "extracts queues from args" do
13
+ Cyclop::Job.should_receive(:next).with(queues: [:email, "cache"], locked_by: Cyclop.send(:master_id))
14
+ Cyclop.next :email, "cache"
15
+ end
16
+ it "extracts options from args" do
17
+ Cyclop::Job.should_receive(:next).with(queues: [:email, "cache"], locked_by: "myid")
18
+ Cyclop.next :email, "cache", locked_by: "myid"
19
+ end
20
+ it "extracts options from args even without queues" do
21
+ Cyclop::Job.should_receive(:next).with(queues: [], locked_by: "myid")
22
+ Cyclop.next locked_by: "myid"
23
+ end
24
+ end
25
+ describe ".failed(opts={})" do
26
+ it "extracts queues from args" do
27
+ Cyclop::Job.should_receive(:failed).with(queues: [:email, "cache"])
28
+ Cyclop.failed :email, "cache"
29
+ end
30
+ it "extracts options from args" do
31
+ Cyclop::Job.should_receive(:failed).with(queues: [:email, "cache"], limit: 10)
32
+ Cyclop.failed :email, "cache", limit: 10
33
+ end
34
+ it "extracts options from args even without queues" do
35
+ Cyclop::Job.should_receive(:failed).with(queues: [], limit: 10)
36
+ Cyclop.failed limit: 10
37
+ end
38
+ end
39
+ end
@@ -0,0 +1,14 @@
1
+ module Cyclop
2
+ module Spec
3
+ module Action
4
+ class Email < Cyclop::Action
5
+ def self.queues
6
+ ["slow"]
7
+ end
8
+
9
+ def self.perform(to, kind)
10
+ end
11
+ end
12
+ end
13
+ end
14
+ end
@@ -0,0 +1,15 @@
1
+ require "bundler/setup"
2
+ require "logger"
3
+ require "cyclop"
4
+
5
+ Dir[File.expand_path("../support/**/*.rb", __FILE__)].each{|f| require f}
6
+
7
+ RSpec.configure do |config|
8
+ config.before(:all) do
9
+ logger = Logger.new "test.log"
10
+ Cyclop.db = Mongo::Connection.new("localhost", nil, :logger => logger).db "cyclop_test"
11
+ end
12
+ config.before do
13
+ Cyclop.db.collections.each(&:remove)
14
+ end
15
+ end