cyclop 0.1.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.
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