updater 0.2.2 → 0.3.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.
@@ -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