updater 0.2.2 → 0.3.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,298 @@
1
+ module Updater
2
+
3
+ #the basic class that drives updater
4
+ class Update
5
+ # Contains the Error class after an error is caught in +run+. Not stored to the database.
6
+ attr_reader :error
7
+
8
+ include DataMapper::Resource
9
+
10
+ property :id, Serial
11
+ property :target, Class
12
+ property :ident, Yaml
13
+ property :method, String
14
+ property :finder, String
15
+ property :args, Object, :lazy=>false
16
+ property :time, Integer
17
+ property :name, String
18
+ property :lock_name, String
19
+
20
+ #will be called if an error occurs
21
+ belongs_to :failure, :model=>'Update', :child_key=>[:failure_id], :nullable=>true
22
+
23
+ # Returns the Class or instance that will recieve the method call. See +Updater.at+ for
24
+ # information about how a target is derived.
25
+ def target
26
+ return @target if @ident.nil?
27
+ @target.send(@finder||:get, @ident)
28
+ end
29
+
30
+ # Send the method with args to the target.
31
+ def run(job=nil)
32
+ t = target #do not trap errors here
33
+ final_args = job ? sub_args(job,args.dup) : args
34
+ begin
35
+ t.send(@method.to_sym,*final_args)
36
+ rescue => e
37
+ @error = e
38
+ failure.run(self) if failure
39
+ destroy unless nil == time
40
+ return false
41
+ end
42
+ destroy unless nil == time
43
+ true
44
+ end
45
+
46
+ def sub_args(job,a)
47
+ a.map {|e| :__job__ == e ? job : e}
48
+ end
49
+
50
+ #atempt to lock this record for the worker
51
+ def lock(worker)
52
+ return true if locked? && locked_by == worker.name
53
+ #all this to make sure the check and the lock are simultanious:
54
+ cnt = repository.update({properties[:lock_name]=>worker.name},self.class.all(:id=>self.id,:lock_name=>nil))
55
+ if 0 != cnt
56
+ @lock_name = worker.name
57
+ true
58
+ else
59
+ worker.say( "Worker #{worker.name} Failed to aquire lock on job #{id}" )
60
+ false
61
+ end
62
+ end
63
+
64
+ def locked?
65
+ not @lock_name.nil?
66
+ end
67
+
68
+ def locked_by
69
+ @lock_name
70
+ end
71
+
72
+ #Like run but first aquires a lock for the worker. Will return the result of run or nil
73
+ #if the record could not be locked
74
+ def run_with_lock(worker)
75
+ run if lock(worker)
76
+ end
77
+
78
+ class << self
79
+
80
+ # Request that the target be sent the method with args at the given time.
81
+ #
82
+ # == Parameters
83
+ # time <Integer | Object responding to to_i>, by default the number of seconds sence the epoch.
84
+ #What 'time' references can be set by sending the a substitute class to the time= method.
85
+ #
86
+ # target <Class | instance> . If target is a class then 'method' will be sent to that class (unless the
87
+ # finder option is used. Otherwise, the target will be assumed to be the result of
88
+ # (target.class).get(target.id). The finder method (:get by default) and the finder_args
89
+ # (target.id by default) can be set in the options. A DataMapper instance passed as the target
90
+ # will "just work." Any object can be found in this mannor is known as a 'conforming instance'.
91
+ #
92
+ # method <Symbol>. The method that will be sent to the calculated target.
93
+ #
94
+ # args <Array> a list of arguments to be sent to with the method call. Note: 'args' must be seirialiable
95
+ # with Marshal.dump. Defaults to []
96
+ #
97
+ # options <Hash> Addational options that will be used to configure the request. see Options
98
+ # section below.
99
+ #
100
+ # == Options
101
+ #
102
+ # :finder <Symbol> This method will be sent to the stored target class (either target or target.class)
103
+ # inorder to extract the instance on which to preform the request. By default :get is used. For
104
+ # example to use on an ActiveRecord class
105
+ # :finder=>:find
106
+ #
107
+ # :finder_args <Array> | <Object>. This is passed to the finder function. By default it is
108
+ # target.id. Note that by setting :finder_args you will force Updater to calculate in instance
109
+ # as the computed target even if you pass a Class as the target.
110
+ #
111
+ # :name <String> A string sent by the requesting class to identify the request. 'name' must be
112
+ # unique for a given computed target. Names cannot be used effectivally when a Class has non-
113
+ # conforming instances as there is no way predict the results of a finder call. 'name' can be used
114
+ # in conjunction with the +for+ method to manipulate requests effecting an object or class after
115
+ # they are set. See +for+ for examples
116
+ #
117
+ # :failure <Updater> an other request to be run if this request raises an error. Usually the
118
+ # failure request will be created with the +chane+ method.
119
+ #
120
+ # == Examples
121
+ #
122
+ # Updater.at(Chronic.parse('tomorrow'),Foo,:bar,[]) # will run Foo.bar() tomorrow at midnight
123
+ #
124
+ # f = Foo.create
125
+ # u = Updater.at(Chronic.parse('2 hours form now'),f,:bar,[]) # will run Foo.get(f.id).bar in 2 hours
126
+ def at(time,target,method,args=[],options={})
127
+ finder, finder_args = [:finder,:finder_args].map {|key| options.delete(key)}
128
+ hash = {:method=>method.to_s,:args=>args}
129
+ hash[:target] = target_for(target)
130
+ hash[:ident] = ident_for(target,finder,finder_args)
131
+ hash[:finder] = finder || :get
132
+ hash[:time] = time
133
+ ret = create(hash.merge(options))
134
+ Process.kill('USR2',pid) if pid
135
+ ret
136
+ rescue Errno::ESRCH
137
+ @pid = nil
138
+ puts "PID invalid"
139
+ #log this as well
140
+ end
141
+
142
+ # like +at+ but with time as time.now. Generally this will be used to run a long running operation in
143
+ # asyncronously in a differen process. See +at+ for details
144
+ def immidiate(*args)
145
+ at(time.now,*args)
146
+ end
147
+
148
+ # like +at+ but without a time to run. This is used to create requests that run in responce to the
149
+ # failure of other requests. See +at+ for details
150
+ def chain(*args)
151
+ at(nil,*args)
152
+ end
153
+
154
+ # Retrieves all updates for a conforming target possibly limiting the results to the named
155
+ # request.
156
+ #
157
+ # == Parameters
158
+ #
159
+ # target <Class | Object> a class or conforming object that postentially is the calculated target
160
+ # of a request.
161
+ #
162
+ # name(optional) <String> If a name is sent, the first request with fot this target with this name
163
+ # will be returned.
164
+ #
165
+ # ==Returns
166
+ #
167
+ # <Array[Updater]> unless name is given then only a single [Updater] instance.
168
+ def for(target,name=nil)
169
+ ident = ident_for(target)
170
+ target = target_for(target)
171
+ if name
172
+ first(:target=>target,:ident=>ident,:name=>name)
173
+ else
174
+ all(:target=>target,:ident=>ident)
175
+ end
176
+ end
177
+
178
+ #The time class used by Updater. See time=
179
+ def time
180
+ @@time ||= Time
181
+ end
182
+
183
+ # By default Updater will use the system time (Time class) to get the current time. The application
184
+ # that Updater was developed for used a game clock that could be paused or restarted. This method
185
+ # allows us to substitute a custom class for Time. This class must respond with in interger or Time to
186
+ # the #now method.
187
+ def time=(klass)
188
+ @@time = klass
189
+ end
190
+
191
+ #A filter for all requests that are ready to run, that is they requested to be run before or at time.now
192
+ def current
193
+ all(:time.lte=>time.now.to_i, :lock_name=>nil)
194
+ end
195
+
196
+ #A filter for all requests that are not yet ready to run, that is time is after time.now
197
+ def delayed
198
+ all(:time.gt=>time.now.to_i)
199
+ end
200
+
201
+ #how many jobs will happen in the next n seconds
202
+ def future(n)
203
+ ct = time.now.to_i
204
+ all(:time.gt=>ct,:time.lt=>ct+n)
205
+ end
206
+
207
+ #Sets the process id of the worker process if known. If this
208
+ #is set then an attempt will be made to signal the worker any
209
+ #time a new update is made.
210
+ #
211
+ #If pid is not set, or is set to nil then the scheduleing program
212
+ #is responcible for waking-up a potentially sleeping worker process
213
+ #in another way.
214
+ def pid=(p)
215
+ return @pid = nil unless p #tricky assignment in return
216
+ @pid = Integer("#{p}")
217
+ Process::kill 0, @pid
218
+ @pid
219
+ rescue Errno::ESRCH, ArgumentError
220
+ raise ArgumentError "PID was invalid"
221
+ end
222
+
223
+ def pid
224
+ @pid
225
+ end
226
+
227
+ ####################
228
+ # Worker Functions #
229
+ ####################
230
+
231
+ #This returns a set of update requests.
232
+ #The first parameter is the maximum number to return (get a few other workers may be in compitition)
233
+ #The second optional parameter is a list of options to be past to DataMapper.
234
+ def worker_set(limit = 5, options={})
235
+ #TODO: add priority to this.
236
+ options = {:lock_name=>nil,:limit=>limit, :order=>[:time.asc]}.merge(options)
237
+ current.all(options)
238
+ end
239
+
240
+ #Gets a single job form the queue, locks and runs it.
241
+ def work_off(worker)
242
+ updates = worker_set
243
+ unless updates.empty?
244
+ #concept copied form delayed_job. If there are a number of
245
+ #different processes working on the queue, the niave approch
246
+ #would result in every instance trying to lock the same record.
247
+ #by shuffleing our results we greatly reduce the chances that
248
+ #multilpe workers try to lock the same process
249
+ updates = updates.to_a.sort_by{rand()}
250
+ updates.each do |u|
251
+ t = u.run_with_lock(worker)
252
+ break unless nil == t
253
+ end
254
+ end
255
+ rescue DataObjects::ConnectionError
256
+ sleep 0.1
257
+ retry
258
+ ensure
259
+ clear_locks(worker)
260
+ return queue_time
261
+ end
262
+
263
+ def queue_time
264
+ nxt = self.first(:time.not=>nil,:lock_name=>nil, :order=>[:time.asc])
265
+ return nil unless nxt
266
+ return 0 if nxt.time <= time.now.to_i
267
+ return nxt.time - time.now.to_i
268
+ end
269
+
270
+ def clear_locks(worker)
271
+ all(:lock_name=>worker.name).update(:lock_name=>nil)
272
+ end
273
+
274
+ private
275
+
276
+ # Computes the stored class an instance or class
277
+ def target_for(inst)
278
+ return inst if inst.kind_of? Class
279
+ inst.class
280
+ end
281
+
282
+ # Compute the agrument sent to the finder method
283
+ def ident_for(target,finder=nil,args=nil)
284
+ if !(target.kind_of?(Class)) || finder
285
+ args || target.id
286
+ end
287
+ #Otherwize the target is the class and ident should be nil
288
+ end
289
+
290
+ end
291
+
292
+ #:nodoc:
293
+ def inspect
294
+ "#<Updater id=#{id} target=#{target.inspect} time=#{time}>"
295
+ end
296
+ end
297
+
298
+ end
@@ -0,0 +1,22 @@
1
+ require 'tmpdir'
2
+
3
+ module Updater
4
+ class Util
5
+ class << self
6
+ def tempio
7
+ fp = begin
8
+ File.open("#{Dir::tmpdir}/#{rand}",
9
+ File::RDWR|File::CREAT|File::EXCL, 0600)
10
+ rescue Errno::EEXIST
11
+ retry
12
+ end
13
+ File.unlink(fp.path)
14
+ fp.binmode
15
+ fp.sync = true
16
+ fp
17
+ end
18
+
19
+
20
+ end
21
+ end
22
+ end
data/lib/updater.rb CHANGED
@@ -1,8 +1,5 @@
1
1
  require "rubygems"
2
2
 
3
- require 'dm-core'
4
- require 'dm-types'
5
-
6
3
  module Updater
7
4
  VERSION = File.read(File.join(File.dirname(__FILE__),'..','VERSION')).strip
8
5
  end
@@ -0,0 +1,81 @@
1
+ require File.join( File.dirname(__FILE__), "spec_helper" )
2
+
3
+ include Updater
4
+
5
+ require File.join( File.dirname(__FILE__), "fooclass" )
6
+
7
+ describe "Chained Methods:" do
8
+
9
+ before :each do
10
+ Update.clear_all
11
+ Foo.all.destroy!
12
+ @u = Update.chain(Foo,:chained,[:__job__,:__params__])
13
+ @v = Update.chain(Foo,:chained2,[:__job__,:__params__])
14
+ end
15
+
16
+ [:failure, :success, :ensure].each do |mode|
17
+ specify "adding '#{mode.to_s}' chain" do
18
+ v = Update.immidiate(Foo,:method1,[],mode=>@u)
19
+ v.orm.send(mode).should_not be_empty
20
+ end
21
+ end
22
+
23
+ specify "'failure' should run after an error" do
24
+ v = Update.immidiate(Foo,:method1,[],:failure=>@u)
25
+ Foo.should_receive(:method1).and_raise(RuntimeError)
26
+ Foo.should_receive(:chained).with(v,anything())
27
+ v.run
28
+ end
29
+
30
+ specify "'failure' should NOT run if their is no error" do
31
+ v = Update.immidiate(Foo,:method1,[],:failure=>@u)
32
+ Foo.should_receive(:method1).and_return(:anything)
33
+ Foo.should_not_receive(:chained)
34
+ v.run
35
+ end
36
+
37
+ specify "'success' should NOT run after an error" do
38
+ v = Update.immidiate(Foo,:method1,[],:success=>@u)
39
+ Foo.should_receive(:method1).and_raise(RuntimeError)
40
+ Foo.should_not_receive(:chained)
41
+ v.run
42
+ end
43
+
44
+ specify "'success' should run if their is no error" do
45
+ v = Update.immidiate(Foo,:method1,[],:success=>@u)
46
+ Foo.should_receive(:method1).and_return(:anything)
47
+ Foo.should_receive(:chained).with(v,anything())
48
+ v.run
49
+ end
50
+
51
+ specify "'ensure' should run after an error" do
52
+ v = Update.immidiate(Foo,:method1,[],:ensure=>@u)
53
+ Foo.should_receive(:method1).and_raise(RuntimeError)
54
+ Foo.should_receive(:chained).with(v,anything())
55
+ v.run
56
+ end
57
+
58
+ specify "'ensure' should run if their is no error" do
59
+ v = Update.immidiate(Foo,:method1,[],:ensure=>@u)
60
+ Foo.should_receive(:method1).and_return(:anything)
61
+ Foo.should_receive(:chained).with(v,anything())
62
+ v.run
63
+ end
64
+
65
+ specify "params should be availible" do
66
+ v = Update.immidiate(Foo,:method1,[],:ensure=>{@u=>'hi', @v=>'bye'})
67
+ Foo.should_receive(:method1).and_return(:anything)
68
+ Foo.should_receive(:chained).with(anything(), 'hi')
69
+ Foo.should_receive(:chained2).with(anything(), 'bye')
70
+ v.run
71
+ end
72
+
73
+ specify "add an Array" do
74
+ v = Update.immidiate(Foo,:method1,[],:ensure=>[@u,@v])
75
+ Foo.should_receive(:method1).and_return(:anything)
76
+ Foo.should_receive(:chained).with(anything(), anything())
77
+ Foo.should_receive(:chained2).with(anything(), anything())
78
+ v.run
79
+ end
80
+
81
+ end
@@ -0,0 +1,31 @@
1
+ require File.join( File.dirname(__FILE__), "spec_helper" )
2
+
3
+ include Updater
4
+
5
+ require File.join( File.dirname(__FILE__), "fooclass" )
6
+
7
+ describe "Job Error Handeling" do
8
+ before(:each) do
9
+ Foo.all.destroy!
10
+ end
11
+
12
+ it "should return false when run" do
13
+ u = Update.immidiate(Foo,:bar,[:arg1,:arg2])
14
+ Foo.should_receive(:bar).with(:arg1,:arg2).and_raise(RuntimeError)
15
+ u.run.should be_false
16
+ end
17
+
18
+ it "should trap errors" do
19
+ u = Update.immidiate(Foo,:bar,[:arg1,:arg2])
20
+ Foo.should_receive(:bar).with(:arg1,:arg2).and_raise(RuntimeError)
21
+ lambda {u.run}.should_not raise_error
22
+ end
23
+
24
+ it "should run the failure task" do
25
+ err = Update.chain(Foo,:bar,[:error])
26
+ u = Update.immidiate(Foo,:bar,[:arg1,:arg2],:failure=>err)
27
+ Foo.should_receive(:bar).with(:arg1,:arg2).and_raise(RuntimeError)
28
+ Foo.should_receive(:bar).with(:error)
29
+ u.run
30
+ end
31
+ end
data/spec/fooclass.rb ADDED
@@ -0,0 +1,14 @@
1
+
2
+ class Foo
3
+ include DataMapper::Resource
4
+
5
+ property :id, Serial
6
+ property :name, String
7
+
8
+ def bar(*args)
9
+ Foo.bar(:instance,*args)
10
+ end
11
+
12
+ end
13
+
14
+ Foo.auto_migrate!
@@ -0,0 +1,56 @@
1
+ require File.join( File.dirname(__FILE__), "spec_helper" )
2
+
3
+ include Updater
4
+
5
+ describe "Fork Worker Instance" do
6
+
7
+ before :each do
8
+ @worker = ForkWorker::WorkerMonitor.new(1,Updater::Util.tempio)
9
+ @w = ForkWorker.new(IO.pipe,@worker)
10
+ end
11
+
12
+ it "should have a heartbeat" do
13
+ @w.instance_variable_set(:@continue, true) #otherwise heartbeat is skipped
14
+ mode = @worker.heartbeat.stat.mode
15
+ @w.heartbeat
16
+ @worker.heartbeat.stat.mode.should_not == mode
17
+ end
18
+
19
+ describe "#smoke_pipe" do
20
+
21
+ before :each do
22
+ @pipe = IO.pipe
23
+ end
24
+
25
+ it "should remove exactly 1 char from a pipe" do
26
+ @pipe.last.write '..'
27
+ @w.smoke_pipe(@pipe.first).should be_true
28
+ @pipe.first.read_nonblock(2).should == '.'
29
+ end
30
+
31
+ it "should not raise errors or block on empty pipes" do
32
+ lambda { @w.smoke_pipe(@pipe.first) }.should_not raise_error
33
+ end
34
+
35
+ end
36
+
37
+ describe "#wait_for" do
38
+
39
+ it "should have specs"
40
+
41
+ describe "when there are pending jobs" do
42
+
43
+ it "should NOT wait for a signal"
44
+
45
+ it "should smoke the pipe"
46
+
47
+ end
48
+
49
+ it "should wake as soon as a new job signal is placed on the pipe"
50
+
51
+
52
+ it "should run the heartbeat every 'timeout' seconds"
53
+
54
+ end
55
+
56
+ end