serially 0.3.1 → 0.4.0

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml CHANGED
@@ -1,15 +1,15 @@
1
1
  ---
2
2
  !binary "U0hBMQ==":
3
3
  metadata.gz: !binary |-
4
- ZWY0MjUzYTA4ODcwYjY1NmM3NDliZjZlZDIzY2NkMDBkZjNjNGM1NQ==
4
+ NzlmMzhlZjEwYWU1ZjRmMmUyYjA5MGVmZjZiMDQ2NTAyYzQwMzMyNQ==
5
5
  data.tar.gz: !binary |-
6
- YjJlZGE3YjEwYzQwYTUwYTc3ZDczZTg5OWM1YTdjZDc4M2IwMTEyYw==
6
+ YjU1YjBlNGM4NjQ2ZmUxZDVkNTQyZGY2MzVkNTdlYjIyODQ4NTYwZQ==
7
7
  SHA512:
8
8
  metadata.gz: !binary |-
9
- NTkwZmQ5NzVkNDczM2Y1ZDliYjUxOWFmYzlhNjdhZGY3N2Y5ZjJkMjdhM2Q1
10
- NzI0Yjg4ZWZkZjM4MWE4MTlkMWI4ODEyOTQ1MmUyZDJkMTBkMTliZDQ3NDBk
11
- MzU5MTk0ZmIxOTJhZDZhN2E3ZmJmN2UyN2MxNTRlNjQxZTM1ZDc=
9
+ MjFlYjZkOTcxODg5NjBiYjUzOTU0YWZmNjljZjc1NTA2OWZkYTgxNGE1ODhl
10
+ NmNiYTdhYzI5MzNmYzY5MGE0YmNlMDc4MmNlZjZlYTU2YmU3MTA2MzYwN2Uy
11
+ ODYwNDQ1YTFmOGUxMDQyMDBkNzZlYjdiYTY5N2M3OTk4NDhhNDI=
12
12
  data.tar.gz: !binary |-
13
- YjEyNzMxNWYwODIyMThjYWY5NjE2Y2JkZDM5OTllM2NjYjUxNjg0YjAxZjU2
14
- YzQzNTBhOWRlYWY4ZDBkMzAyZDMxMjcwMGJlODE0MjA5ODg2NTNlNDhhODVl
15
- Y2JjNzdjMWMzN2E3NmQ0Yjc1NDQ5NTQ3ZjZiZDA5YWY3MWQxMzk=
13
+ OGJhOTg2NDJjMzNjZDljZjY0YWRkNTE5MGE4MmEzMGVjZTM1MmUxMzNjMzA1
14
+ ZjljNTFhZjJhODg5ZDU5ZjQ5ZmQ4M2ZjYzRkMGI3OWFjZmFmNTcwNDIxOGU3
15
+ MzdiZDU4MjVlMGQ0OTdlMjRkODMxODhkYjUyOWQ5ZDliYjJiZGQ=
data/README.md CHANGED
@@ -50,13 +50,15 @@ class Post < ActiveRecord::Base
50
50
  end
51
51
 
52
52
  def draft
53
+ # each task must return a boolean, signifying whether it has succeeded or not
53
54
  puts "Post #{self.id} drafted"
54
55
  true
55
56
  end
56
57
 
57
58
  def review
58
59
  puts "Post #{self.id} reviewed by staff"
59
- [true, 'reviewed by staff']
60
+ # in addition to a boolean status, task can also return a string and an arbitrary object
61
+ [true, 'reviewed by staff', {reviewed_by: 'Mike', reviewed_at: Date.today}]
60
62
  end
61
63
 
62
64
  def publish
@@ -70,7 +72,7 @@ class Post < ActiveRecord::Base
70
72
  end
71
73
  end
72
74
  ```
73
- ### Start for Instance
75
+ ### Schedule for a Single Instance
74
76
 
75
77
  After creating an instance of a Post, you can run `post.serially.start!` to schedule your Post tasks to run serially. They will run one after the other in the scope of the same `Serially::Job`
76
78
  in the default `serially` queue.
@@ -90,7 +92,7 @@ Post 1 not published - bibliography is missing
90
92
  Post 2 reviewed by staff
91
93
  Post 2 not published - bibliography is missing
92
94
  ```
93
- ### Start for Batch
95
+ ### Schedule Batch
94
96
  If you want to schedule serially tasks for multiple instances, you can do it in a single call:
95
97
  ```ruby
96
98
  Post.start_batch!([post1.id, post2.id, post3.id])
@@ -99,7 +101,7 @@ Post.start_batch!([post1.id, post2.id, post3.id])
99
101
  ### Task Return Values
100
102
 
101
103
  * A task should at minimum return a boolean value, signifying whether that task finished successfully or not
102
- * A task can also return a string with details of the task completion
104
+ * A task can also return a string with details of the task completion and an arbitrary object
103
105
  * If a task returns _false_, the execution stops and the next tasks in the chain won't be performed for current instance
104
106
 
105
107
  ### Inspection
@@ -141,7 +143,9 @@ end
141
143
  ```
142
144
  Jobs for different instances of Post will all be scheduled in 'posts' queue, without any interference to each other.
143
145
 
144
- ### Blocks
146
+ ### Callbacks
147
+
148
+ #### Blocks
145
149
  In addition to instance methods, you can pass a block as a task callback, and you can mix both syntaxes in your class:
146
150
 
147
151
  ```ruby
@@ -151,21 +155,52 @@ class Post < ActiveRecord::Base
151
155
  serially do
152
156
  task :draft
153
157
  task :review do |post|
154
- puts "Reviewing #{post.id}"
158
+ # finished successfully
155
159
  true
156
160
  end
157
161
  task :publish do |post|
158
- puts "Publishing #{post.id}"
159
- true
162
+ # block can return message and result object in addition to boolean status, just like the instance method
163
+ [true, 'published ok', {author: 'Mike}]
160
164
  end
161
165
  end
162
166
 
163
167
  def draft
164
- puts "Drafting #{self.id}"
168
+ # using instance methods makes sense when your callback is more than 1 or 2 lines of code
165
169
  [false, 'drafting failed']
166
170
  end
167
171
  end
168
172
  ```
173
+ #### On Error Callback
174
+ You can provide an error handling callback for each task, which will be called if a task fails to finish successfully. If the error handling
175
+ callback returns `true`, the execution will continue to next task, despite the failure of the previous one, otherwise tasks
176
+ execution will stop as expected.
177
+
178
+ ```ruby
179
+ class Post < ActiveRecord::Base
180
+ include Serially
181
+
182
+ serially do
183
+ task :draft, on_error: handle_draft_error
184
+ ...
185
+ end
186
+
187
+ def draft
188
+ # something happened here that caused draft to fail
189
+ result_obj = {author: 'Mike'}
190
+ [false, 'drafting failed', result_obj]
191
+ end
192
+
193
+ def handle_draft_error(msg, result_obj)
194
+ if result_obj[:author] == 'Mike'
195
+ # let's continue to next task
196
+ true
197
+ else
198
+ # can't continue executing tasks like nothing happened, have to stop
199
+ false
200
+ end
201
+ end
202
+ end
203
+ ```
169
204
 
170
205
  ## Customize Plain Ruby Class Instantiation
171
206
  Before the first task runs, Serially creates an instance of your class, on which your task callbacks are then called. By default, instances of plain ruby classes
@@ -7,7 +7,9 @@ class CreateSeriallyTaskRuns < ActiveRecord::Migration
7
7
  t.integer :task_order, null: false
8
8
  t.integer :status, default: 0
9
9
  t.datetime :finished_at
10
- t.text :result_message
10
+ t.text :result_message
11
+ t.text :result_object
12
+ t.boolean :error_handled, default: false
11
13
  t.timestamps null: false
12
14
  end
13
15
 
data/lib/serially.rb CHANGED
@@ -1,5 +1,6 @@
1
1
  require "serially/version"
2
2
  require 'serially/errors'
3
+ require 'serially/options'
3
4
  require 'serially/serially'
4
5
  require 'serially/base'
5
6
  require 'serially/instance_base'
@@ -8,4 +9,4 @@ require 'serially/task_manager'
8
9
  require 'serially/task_runner'
9
10
  require 'serially/task_run'
10
11
  require 'serially/task_run_writer'
11
- require 'serially/job'
12
+ require 'serially/job'
data/lib/serially/job.rb CHANGED
@@ -9,7 +9,7 @@ module Serially
9
9
  extend Resque::Plugins::LonelyJob
10
10
 
11
11
  def self.queue
12
- Serially::Options.default_queue
12
+ Serially::GlobalOptions.default_queue
13
13
  end
14
14
 
15
15
  # this ensures that for item_class=Invoice, and item_id=34500, only one job will run at a time
@@ -0,0 +1,40 @@
1
+ module Serially
2
+
3
+ class Options
4
+ # this should be overridden in sub-classes
5
+ def self.allowed
6
+ []
7
+ end
8
+
9
+ def self.validate(options)
10
+ invalid_options = {}
11
+
12
+ valid_options = options.select{ |k,v| allowed.include?(k) }
13
+ invalid_keys = options.keys.select{ |k| !allowed.include?(k) }
14
+ empty_values = valid_options.select{ |k, v| v.blank? }.keys
15
+
16
+ invalid_options['Unrecognized Keys'] = invalid_keys if invalid_keys.present?
17
+ invalid_options['Empty Values'] = empty_values if empty_values.present?
18
+
19
+ invalid_options
20
+ end
21
+ end
22
+
23
+ class GlobalOptions < Options
24
+ def self.allowed
25
+ [:in_queue]
26
+ end
27
+
28
+ def self.default_queue
29
+ 'serially'
30
+ end
31
+ end
32
+
33
+ class TaskOptions < Options
34
+ def self.allowed
35
+ [:on_error]
36
+ end
37
+
38
+ end
39
+
40
+ end
@@ -16,7 +16,7 @@ module Serially
16
16
 
17
17
  def serially(*args, &block)
18
18
  options = args[0] || {}
19
- invalid_options = Serially::Options.validate(options)
19
+ invalid_options = Serially::GlobalOptions.validate(options)
20
20
  raise Serially::ConfigurationError.new("Serially received the following invalid options: #{invalid_options}") if invalid_options.present?
21
21
 
22
22
  # If TaskManager for current including class doesn't exist, create it
@@ -48,20 +48,23 @@ module Serially
48
48
 
49
49
  # override this to provide a custom way of creating instances of your class
50
50
  def create_instance(*args)
51
+ instance = nil
51
52
  args = args.flatten
52
53
  if self.is_active_record?
53
54
  if args.count == 1
54
- args[0].is_a?(Fixnum) ? self.where(id: args[0]).first : self.where(args[0]).first
55
+ instance = args[0].is_a?(Fixnum) ? self.where(id: args[0]).first : self.where(args[0]).first
56
+ raise Serially::ArgumentError.new("Serially: couldn't create ActiveRecord instance with provided arguments: #{args[0]}") if instance.blank?
55
57
  else
56
58
  raise Serially::ArgumentError.new("Serially: default implementation of ::create_instance expects to receive either id or hash")
57
59
  end
58
60
  else
59
61
  begin
60
- args.blank? ? new : new(*args)
62
+ instance = args.blank? ? new : new(*args)
61
63
  rescue StandardError => exc
62
64
  raise Serially::ArgumentError.new("Serially: since no implementation of ::create_instance is provided in #{self}, tried to call new, but failed with provided arguments: #{args}")
63
65
  end
64
66
  end
67
+ instance
65
68
  end
66
69
  end
67
70
 
@@ -72,30 +75,11 @@ module Serially
72
75
 
73
76
  # override this to provide a custom way of fetching id of your class' instance
74
77
  def instance_id
75
- self.respond_to?(:id) ? self.id : self.object_id
76
- end
77
-
78
-
79
-
80
-
81
- class Options
82
- ALLOWED = [:in_queue]
83
-
84
- def self.default_queue
85
- 'serially'
86
- end
87
-
88
- def self.validate(options)
89
- invalid_options = {}
90
-
91
- valid_options = options.select{ |k,v| ALLOWED.include?(k) }
92
- invalid_keys = options.keys.select{ |k| !ALLOWED.include?(k) }
93
- empty_values = valid_options.select{ |k, v| v.blank? }.keys
94
-
95
- invalid_options['Unrecognized Keys'] = invalid_keys if invalid_keys.present?
96
- invalid_options['Empty Values'] = empty_values if empty_values.present?
97
-
98
- invalid_options
78
+ if self.respond_to?(:id)
79
+ self.id
80
+ else
81
+ raise Serially::ArgumentError.new("Serially: default implementation of ::instance_id is not defined for plain Ruby class, please provide one")
99
82
  end
100
83
  end
84
+
101
85
  end
data/lib/serially/task.rb CHANGED
@@ -34,23 +34,40 @@ module Serially
34
34
 
35
35
  # <i>args</i> - arguments needed to create an instance of your class. If you don't provide custom implementation for create_instance,
36
36
  # pass instance_id or hash of arguments,
37
- def run!(*args)
38
- instance = args[0].blank? ? instance = @klass.send(:create_instance) : @klass.send(:create_instance, *args)
37
+ def run!(instance)
39
38
  if instance
40
39
  if !@run_block && !instance.respond_to?(@name)
41
40
  raise Serially::ConfigurationError.new("Serially task #{@name} in class #{@klass} doesn't have an implementation method or a block to run")
42
41
  end
43
42
  begin
44
- status, msg = @run_block ? @run_block.call(instance) : instance.send(@name)
43
+ status, msg, result_obj = @run_block ? @run_block.call(instance) : instance.send(@name)
45
44
  rescue StandardError => exc
46
- return [false, "Serially: task '#{@name}' raised exception: #{exc.message}"]
45
+ return [false, "Serially: task '#{@name}' raised exception: #{exc.message}", exc]
47
46
  end
48
47
  else
49
48
  return [false, "Serially: instance couldn't be created, task '#{@name}'' not started"]
50
49
  end
51
- # returns true (unless status == nil/false/[]) and '' (unless msg is a not empty string)
52
- [status.present?, msg.to_s]
50
+ # returns true (unless status == nil/false/[]), '' (unless msg is a not empty string) and result_obj, which might be nil
51
+ # if task doesn't return it
52
+ [status.present?, msg.to_s, result_obj]
53
+ end
54
+
55
+ def on_error!(instance, result_msg, result_obj)
56
+ if options[:on_error]
57
+ if !klass.method_defined?(options[:on_error])
58
+ raise Serially::ConfigurationError.new("Serially: error handler #{options[:on_error]} not found for task #{self.name}")
59
+ end
60
+
61
+ begin
62
+ status = instance.send(options[:on_error], result_msg, result_obj)
63
+ rescue StandardError => exc
64
+ Resque.logger.error("Serially: error handler for task '#{@name}' raised exception: #{exc.message}")
65
+ status = false
66
+ end
67
+ status
68
+ end
53
69
  end
54
70
 
55
71
  end
72
+
56
73
  end
@@ -34,6 +34,10 @@ module Serially
34
34
  def add_task(task_name, task_options, &block)
35
35
  raise Serially::ConfigurationError.new("Task #{task_name} is already defined in class #{@klass}") if @tasks.include?(task_name)
36
36
  raise Serially::ConfigurationError.new("Task name #{task_name} defined in class #{@klass} is not a symbol") if !task_name.is_a?(Symbol)
37
+
38
+ invalid_options = Serially::TaskOptions.validate(task_options)
39
+ raise Serially::ConfigurationError.new("Task #{task_name} received the following invalid options: #{invalid_options}") if invalid_options.present?
40
+
37
41
  @tasks[task_name] = Serially::Task.new(task_name, next_task_order!, task_options, self, &block)
38
42
  end
39
43
 
@@ -3,6 +3,7 @@ require 'active_record'
3
3
  module Serially
4
4
  class TaskRun < ActiveRecord::Base
5
5
  enum status: { pending: 0, started: 1, started_async: 2, finished_ok: 3, finished_error: 4 }
6
+ serialize :result_object
6
7
 
7
8
  self.table_name = 'serially_task_runs'
8
9
 
@@ -19,7 +20,7 @@ module Serially
19
20
  finished_ok? || finished_error?
20
21
  end
21
22
 
22
- def self.write_task_run(task, item_id, success, msg)
23
+ def self.write_task_run(task, item_id, success, result_msg, result_obj, error_handled)
23
24
  task_run = TaskRun.where(item_class: task.klass, item_id: item_id, task_name: task.name).first_or_initialize
24
25
  if task_run.finished?
25
26
  Resque.logger.warn("Serially: task '#{task.name}' for #{task.klass}/#{item_id} finished already, not saving this task run")
@@ -28,7 +29,9 @@ module Serially
28
29
  saved = task_run.tap {|t|
29
30
  t.task_order = task.task_order
30
31
  t.status = success ? TaskRun.statuses[:finished_ok] : TaskRun.statuses[:finished_error]
31
- t.result_message = msg
32
+ t.result_message = result_msg
33
+ t.result_object = result_obj
34
+ t.error_handled = error_handled
32
35
  t.finished_at = DateTime.now
33
36
  }.save
34
37
  saved
@@ -1,9 +1,9 @@
1
1
  module Serially
2
2
  class TaskRunWriter
3
3
 
4
- # called by TaskRunner, after each task has finished running
5
- def update(task, item_id, success, msg)
6
- TaskRun.write_task_run(task, item_id, success, msg)
4
+ # called by TaskRunner, after each task and its error handler have finished running
5
+ def update(task, instance, success, msg, result_obj, error_handled)
6
+ TaskRun.write_task_run(task, instance.instance_id, success, msg, result_obj, error_handled)
7
7
  end
8
8
 
9
9
  end
@@ -10,6 +10,7 @@ module Serially
10
10
 
11
11
  def run!(item_class, item_id = nil)
12
12
  last_run = []
13
+ instance = item_id.blank? ? item_class.send(:create_instance) : item_class.send(:create_instance, item_id)
13
14
  Serially::TaskManager[item_class].each do |task|
14
15
  # if task.async?
15
16
  # started_async_task = SerialTasksManager.begin_task(task)
@@ -18,14 +19,15 @@ module Serially
18
19
  # else
19
20
  #started_async_task = false
20
21
 
21
- success, msg = task.run!(item_id)
22
- last_run = [task, success, msg]
22
+ success, msg, result_obj = task.run!(instance)
23
+ error_handled = task.on_error!(instance, msg, result_obj) if !success
24
+ last_run = [task, success, msg, result_obj]
23
25
 
24
26
  # write result log to DB
25
27
  changed
26
- notify_observers(task, item_id, success, msg)
27
- # if task didn't complete successfully, exit
28
- break if !success
28
+ notify_observers(task, instance, success, msg, result_obj, error_handled)
29
+ # if task didn't complete successfully, and error handler didn't return true, exit
30
+ break if !success && !error_handled
29
31
  end
30
32
  # if started_async_task
31
33
  # msg = "SerialTasksManager: started async task for #{item_class}/#{item_id}. Worker is exiting..."
@@ -1,3 +1,3 @@
1
1
  module Serially
2
- VERSION = "0.3.1"
2
+ VERSION = "0.4.0"
3
3
  end
data/serially.gemspec CHANGED
@@ -35,9 +35,8 @@ Gem::Specification.new do |spec|
35
35
  spec.add_development_dependency 'sqlite3'
36
36
 
37
37
  spec.description = <<desc
38
- Have you ever had a class that required a series of background tasks to run serially, strictly one after another? Than Serially is for you.
39
- Declare the tasks using a simple DSL in the order you want them to to run. Serially will wrap them in a single job, and schedule it using Resque
40
- in a queue you specify (or a default one). The next task will run only if previous one finished successfully. All task runs are written to DB and can be inspected (if
38
+ Have you ever had a class whose instances required a series of background tasks to run serially, strictly one after another? Than Serially is for you.
39
+ Declare the tasks using a simple DSL in the order you want them to to run. The tasks for each instance will run inside a separate Resque job, in a queue you specify. The next task will run only if the previous one has finished successfully. All task runs are written to DB and can be inspected (if
41
40
  your class is an ActiveRecord object).
42
41
  desc
43
42
 
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: serially
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.3.1
4
+ version: 0.4.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Mike Polischuk
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2016-01-09 00:00:00.000000000 Z
11
+ date: 2016-01-16 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: resque
@@ -164,15 +164,13 @@ dependencies:
164
164
  - - ! '>='
165
165
  - !ruby/object:Gem::Version
166
166
  version: '0'
167
- description: ! 'Have you ever had a class that required a series of background tasks
168
- to run serially, strictly one after another? Than Serially is for you.
167
+ description: ! 'Have you ever had a class whose instances required a series of background
168
+ tasks to run serially, strictly one after another? Than Serially is for you.
169
169
 
170
- Declare the tasks using a simple DSL in the order you want them to to run. Serially
171
- will wrap them in a single job, and schedule it using Resque
172
-
173
- in a queue you specify (or a default one). The next task will run only if previous
174
- one finished successfully. All task runs are written to DB and can be inspected
175
- (if
170
+ Declare the tasks using a simple DSL in the order you want them to to run. The tasks
171
+ for each instance will run inside a separate Resque job, in a queue you specify.
172
+ The next task will run only if the previous one has finished successfully. All task
173
+ runs are written to DB and can be inspected (if
176
174
 
177
175
  your class is an ActiveRecord object).
178
176
 
@@ -202,6 +200,7 @@ files:
202
200
  - lib/serially/errors.rb
203
201
  - lib/serially/instance_base.rb
204
202
  - lib/serially/job.rb
203
+ - lib/serially/options.rb
205
204
  - lib/serially/serially.rb
206
205
  - lib/serially/task.rb
207
206
  - lib/serially/task_manager.rb