dtsato-pipeline 0.0.1

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/.gitignore ADDED
@@ -0,0 +1,7 @@
1
+ *.sw?
2
+ .DS_Store
3
+ coverage
4
+ rdoc
5
+ pkg
6
+ pipeline.log
7
+ pipeline.sqlite
data/CHANGELOG ADDED
@@ -0,0 +1,12 @@
1
+ 0.0.1
2
+ =====
3
+
4
+ Initial Release:
5
+ * Execution of sequential user-defined stages in an asynchronous pipeline
6
+ * Persistence of pipeline instances and stages
7
+ * Error recovery strategies:
8
+ * Irrecoverable errors fail the entire pipeline
9
+ * Recoverable errors are automatically retried (using dj's exponential retry strategy)
10
+ * Recoverable errors that require user input pause the pipeline for further retry
11
+ * Cancelling of a paused pipeline
12
+
data/LICENSE ADDED
@@ -0,0 +1,20 @@
1
+ Copyright (c) 2009 dtsato.com, Danilo Sato
2
+
3
+ Permission is hereby granted, free of charge, to any person obtaining
4
+ a copy of this software and associated documentation files (the
5
+ "Software"), to deal in the Software without restriction, including
6
+ without limitation the rights to use, copy, modify, merge, publish,
7
+ distribute, sublicense, and/or sell copies of the Software, and to
8
+ permit persons to whom the Software is furnished to do so, subject to
9
+ the following conditions:
10
+
11
+ The above copyright notice and this permission notice shall be
12
+ included in all copies or substantial portions of the Software.
13
+
14
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
15
+ EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
16
+ MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
17
+ NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
18
+ LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
19
+ OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
20
+ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
data/README.rdoc ADDED
@@ -0,0 +1,67 @@
1
+ = Pipeline
2
+
3
+ == Description
4
+
5
+ Pipeline is a Rails plugin/gem to run asynchronous processes in a configurable pipeline.
6
+
7
+ == Features
8
+
9
+ * Execution of sequential user-defined stages in an asynchronous pipeline
10
+ * Persistence of pipeline instances and stages
11
+ * Error recovery strategies:
12
+ * Irrecoverable errors fail the entire pipeline
13
+ * Recoverable errors are automatically retried (using dj's exponential retry strategy)
14
+ * Recoverable errors that require user input pause the pipeline for further retry
15
+ * Cancelling of a paused pipeline
16
+
17
+ == Installation
18
+
19
+ Add the following lines to your config/environment.rb file:
20
+
21
+ config.gem "dtsato-pipeline", :version => ">= 0.0.1", :source => "http://gems.github.com"
22
+
23
+ Run the following:
24
+
25
+ rake gems:install
26
+ rake gems:unpack # Optional, if you want to vendor the gem
27
+ script/generate pipeline # To generate the migration scripts that will store pipelines
28
+ rake db:migrate
29
+
30
+ You will also need to run your Delayed Job workers that will process the pipeline jobs in the background:
31
+
32
+ rake jobs:work
33
+
34
+ == Dependencies
35
+
36
+ * Rails
37
+ * Delayed job (http://github.com/collectiveidea/delayed_job/tree/master)
38
+
39
+ == Usage
40
+
41
+ Check <tt>examples</tt> for more examples (including error-recovery and cancelling)
42
+
43
+ class Step1 < Pipeline::Stage::Base
44
+ def perform
45
+ puts("Started step 1")
46
+ sleep 2
47
+ puts("Finished step 1")
48
+ end
49
+ end
50
+
51
+ class Step2 < Pipeline::Stage::Base
52
+ def perform
53
+ puts("Started step 2")
54
+ sleep 3
55
+ puts("Finished step 2")
56
+ end
57
+ end
58
+
59
+ class TwoStepPipeline < Pipeline::Base
60
+ define_stages Step1 >> Step2
61
+ end
62
+
63
+ Pipeline.start(TwoStepPipeline.new)
64
+
65
+ == Copyright
66
+
67
+ Copyright (c) 2009 Danilo Sato. See LICENSE for details.
data/Rakefile ADDED
@@ -0,0 +1,104 @@
1
+ require 'rubygems'
2
+ require 'rake'
3
+
4
+ begin
5
+ require 'jeweler'
6
+ Jeweler::Tasks.new do |gem|
7
+ gem.name = "pipeline"
8
+ gem.summary = "A Rails plugin/gem to run asynchronous processes in a configurable pipeline"
9
+ gem.email = "danilo@dtsato.com"
10
+ gem.homepage = "http://github.com/dtsato/pipeline"
11
+ gem.authors = ["Danilo Sato"]
12
+ gem.description = "Pipeline is a Rails plugin/gem to run asynchronous processes in a configurable pipeline."
13
+
14
+ gem.has_rdoc = true
15
+ gem.rdoc_options = ["--main", "README.rdoc", "--inline-source", "--line-numbers"]
16
+ gem.extra_rdoc_files = ["README.rdoc"]
17
+
18
+ gem.test_files = Dir['spec/**/*']
19
+
20
+ gem.add_dependency('activerecord', '>= 2.0')
21
+ gem.add_dependency('collectiveidea-delayed_job', '>= 1.8.0')
22
+
23
+ gem.rubyforge_project = "pipeline"
24
+ end
25
+
26
+ rescue LoadError
27
+ puts "Jeweler (or a dependency) not available. Install it with: sudo gem install jeweler"
28
+ end
29
+
30
+ # These are new tasks
31
+ begin
32
+ require 'rake/contrib/sshpublisher'
33
+ namespace :rubyforge do
34
+
35
+ desc "Release gem and RDoc documentation to RubyForge"
36
+ task :release => ["rubyforge:release:gem", "rubyforge:release:docs"]
37
+
38
+ namespace :release do
39
+ desc "Publish RDoc to RubyForge."
40
+ task :docs => [:rdoc] do
41
+ config = YAML.load(
42
+ File.read(File.expand_path('~/.rubyforge/user-config.yml'))
43
+ )
44
+
45
+ host = "#{config['username']}@rubyforge.org"
46
+ remote_dir = "/var/www/gforge-projects/pipeline/"
47
+ local_dir = 'rdoc'
48
+
49
+ Rake::SshDirPublisher.new(host, remote_dir, local_dir).upload
50
+ end
51
+ end
52
+ end
53
+ rescue LoadError
54
+ puts "Rake SshDirPublisher is unavailable or your rubyforge environment is not configured."
55
+ end
56
+
57
+ require 'spec/rake/spectask'
58
+ Spec::Rake::SpecTask.new(:spec) do |spec|
59
+ spec.libs << 'lib' << 'spec'
60
+ spec.spec_files = FileList['spec/**/*_spec.rb']
61
+ spec.spec_opts = ['--options', "\"spec/spec.opts\""]
62
+ end
63
+
64
+ Spec::Rake::SpecTask.new(:rcov) do |spec|
65
+ spec.libs << 'lib' << 'spec'
66
+ spec.pattern = 'spec/**/*_spec.rb'
67
+ spec.rcov_opts = lambda do
68
+ IO.readlines("spec/rcov.opts").map {|l| l.chomp.split " "}.flatten
69
+ end
70
+ spec.rcov = true
71
+ end
72
+
73
+ begin
74
+ require "synthesis/task"
75
+
76
+ desc "Run Synthesis on specs"
77
+ Synthesis::Task.new("spec:synthesis") do |t|
78
+ t.adapter = :rspec
79
+ t.pattern = 'spec/**/*_spec.rb'
80
+ t.ignored = ['Pipeline::FakePipeline', 'Delayed::Job']
81
+ end
82
+ rescue LoadError
83
+ desc 'Synthesis rake task not available'
84
+ task "spec:synthesis" do
85
+ abort 'Synthesis rake task is not available. Be sure to install synthesis as a gem'
86
+ end
87
+ end
88
+
89
+ require 'rake/rdoctask'
90
+ Rake::RDocTask.new do |rdoc|
91
+ if File.exist?('VERSION.yml')
92
+ config = YAML.load(File.read('VERSION.yml'))
93
+ version = "#{config[:major]}.#{config[:minor]}.#{config[:patch]}"
94
+ else
95
+ version = ""
96
+ end
97
+
98
+ rdoc.rdoc_dir = 'rdoc'
99
+ rdoc.title = "pipeline #{version}"
100
+ rdoc.rdoc_files.include('README*')
101
+ rdoc.rdoc_files.include('lib/**/*.rb')
102
+ end
103
+
104
+ task :default => :spec
data/VERSION ADDED
@@ -0,0 +1 @@
1
+ 0.0.1
@@ -0,0 +1,35 @@
1
+ require File.join(File.dirname(__FILE__), '..', 'init')
2
+ require File.join(File.dirname(__FILE__), '..', 'spec', 'database_integration_helper')
3
+ ActiveRecord::Base.logger = Logger.new(STDOUT)
4
+
5
+ class Step1 < Pipeline::Stage::Base
6
+ def run
7
+ puts("Started step 1")
8
+ # Will fail on the first time, but pass on the second
9
+ if attempts == 1
10
+ puts("Raising auto-recoverable error")
11
+ raise Pipeline::RecoverableError.new
12
+ end
13
+ puts("Finished step 1")
14
+ end
15
+ end
16
+
17
+ class Step2 < Pipeline::Stage::Base
18
+ def run
19
+ puts("Started step 2")
20
+ # Will fail on the first time, but pass on the second
21
+ if attempts == 1
22
+ puts("Raising another auto-recoverable error")
23
+ raise Pipeline::RecoverableError.new
24
+ end
25
+ puts("Finished step 2")
26
+ end
27
+ end
28
+
29
+ class TwoStepPipeline < Pipeline::Base
30
+ define_stages Step1 >> Step2
31
+ end
32
+
33
+ Pipeline.start(TwoStepPipeline.new)
34
+
35
+ Delayed::Worker.new.start
@@ -0,0 +1,32 @@
1
+ require File.join(File.dirname(__FILE__), '..', 'init')
2
+ require File.join(File.dirname(__FILE__), '..', 'spec', 'database_integration_helper')
3
+ ActiveRecord::Base.logger = Logger.new(STDOUT)
4
+
5
+ class Step1 < Pipeline::Stage::Base
6
+ def run
7
+ puts("Started step 1")
8
+ puts("Raising user-recoverable error")
9
+ raise Pipeline::RecoverableError.new("require your action", true)
10
+ end
11
+ end
12
+
13
+ class Step2 < Pipeline::Stage::Base
14
+ def run
15
+ puts("Started step 2")
16
+ sleep 3
17
+ puts("Finished step 2")
18
+ end
19
+ end
20
+
21
+ class TwoStepPipeline < Pipeline::Base
22
+ define_stages Step1 >> Step2
23
+ end
24
+
25
+ id = Pipeline.start(TwoStepPipeline.new)
26
+
27
+ Delayed::Worker.new.start
28
+
29
+ # CTRL-C to execute the cancelling, since we want to cancel after the stage failed, but
30
+ # Worker is blocking the process on the previous line
31
+ Pipeline.cancel(id)
32
+ p Pipeline::Base.find(id)
@@ -0,0 +1,27 @@
1
+ require File.join(File.dirname(__FILE__), '..', 'init')
2
+ require File.join(File.dirname(__FILE__), '..', 'spec', 'database_integration_helper')
3
+ ActiveRecord::Base.logger = Logger.new(STDOUT)
4
+
5
+ class Step1 < Pipeline::Stage::Base
6
+ def run
7
+ puts("Started step 1")
8
+ sleep 2
9
+ puts("Finished step 1")
10
+ end
11
+ end
12
+
13
+ class Step2 < Pipeline::Stage::Base
14
+ def run
15
+ puts("Started step 2")
16
+ sleep 3
17
+ puts("Finished step 2")
18
+ end
19
+ end
20
+
21
+ class TwoStepPipeline < Pipeline::Base
22
+ define_stages Step1 >> Step2
23
+ end
24
+
25
+ Pipeline.start(TwoStepPipeline.new)
26
+
27
+ Delayed::Worker.new.start
@@ -0,0 +1,33 @@
1
+ require File.join(File.dirname(__FILE__), '..', 'init')
2
+ require File.join(File.dirname(__FILE__), '..', 'spec', 'database_integration_helper')
3
+ ActiveRecord::Base.logger = Logger.new(STDOUT)
4
+
5
+ class Step1 < Pipeline::Stage::Base
6
+ def run
7
+ puts("Started step 1")
8
+ sleep 2
9
+ puts("Finished step 1")
10
+ end
11
+ end
12
+
13
+ class Step2 < Pipeline::Stage::Base
14
+ def run
15
+ puts("Started step 2")
16
+ # Will fail on the first time, but pass on the second
17
+ if attempts == 1
18
+ puts("Raising user-recoverable error")
19
+ raise Pipeline::RecoverableError.new("require your action", true)
20
+ end
21
+ puts("Finished step 2")
22
+ end
23
+ end
24
+
25
+ class TwoStepPipeline < Pipeline::Base
26
+ define_stages Step1 >> Step2
27
+ end
28
+
29
+ id = Pipeline.start(TwoStepPipeline.new)
30
+
31
+ Pipeline.resume(id)
32
+
33
+ Delayed::Worker.new.start
@@ -0,0 +1,12 @@
1
+ class PipelineGenerator < Rails::Generator::Base
2
+
3
+ def manifest
4
+ record do |m|
5
+ m.migration_template "pipeline_instances_migration.rb", 'db/migrate',
6
+ :migration_file_name => "create_pipeline_instances"
7
+ m.migration_template "pipeline_stages_migration.rb", 'db/migrate',
8
+ :migration_file_name => "create_pipeline_stages"
9
+ end
10
+ end
11
+
12
+ end
@@ -0,0 +1,16 @@
1
+ class CreatePipelineInstances < ActiveRecord::Migration
2
+ def self.up
3
+ create_table :pipeline_instances, :force => true do |t|
4
+ t.string :type # For single table inheritance
5
+ t.string :status # Current status of the pipeline
6
+ t.integer :attempts, :default => 0 # Number of times this pipeline was executed
7
+
8
+ t.timestamps
9
+ end
10
+
11
+ end
12
+
13
+ def self.down
14
+ drop_table :pipeline_instances
15
+ end
16
+ end
@@ -0,0 +1,19 @@
1
+ class CreatePipelineStages < ActiveRecord::Migration
2
+ def self.up
3
+ create_table :pipeline_stages, :force => true do |t|
4
+ t.references :pipeline_instance # Pipeline that holds this stage
5
+ t.string :type # For single table inheritance
6
+ t.string :name # Name of the stage
7
+ t.string :status # Current status of the stage
8
+ t.text :message # Message that describes current status
9
+ t.integer :attempts, :default => 0 # Number of times this stage was executed
10
+
11
+ t.timestamps
12
+ end
13
+
14
+ end
15
+
16
+ def self.down
17
+ drop_table :pipeline_stages
18
+ end
19
+ end
data/init.rb ADDED
@@ -0,0 +1 @@
1
+ require File.join(File.dirname(__FILE__), 'lib', 'pipeline')
@@ -0,0 +1,23 @@
1
+ module Pipeline
2
+ module ApiMethods
3
+ def start(pipeline)
4
+ raise InvalidPipelineError.new("Invalid pipeline") unless pipeline.is_a?(Pipeline::Base)
5
+ pipeline.save!
6
+ Delayed::Job.enqueue(pipeline)
7
+ pipeline.id
8
+ end
9
+
10
+ def resume(id)
11
+ pipeline = Base.find(id)
12
+ raise InvalidPipelineError.new("Invalid pipeline") unless pipeline
13
+ raise InvalidStatusError.new(pipeline.status) unless pipeline.ok_to_resume?
14
+ Delayed::Job.enqueue(pipeline)
15
+ end
16
+
17
+ def cancel(id)
18
+ pipeline = Base.find(id)
19
+ raise InvalidPipelineError.new("Invalid pipeline") unless pipeline
20
+ pipeline.cancel
21
+ end
22
+ end
23
+ end
@@ -0,0 +1,67 @@
1
+ module Pipeline
2
+ class Base < ActiveRecord::Base
3
+ set_table_name :pipeline_instances
4
+
5
+ # :not_started ---> :in_progress ---> :completed
6
+ # ^ | \-> :failed
7
+ # | v
8
+ # :paused
9
+ symbol_attr :status
10
+ transactional_attr :status
11
+ private :status=
12
+
13
+ has_many :stages, :class_name => 'Pipeline::Stage::Base', :foreign_key => 'pipeline_instance_id', :dependent => :destroy
14
+
15
+ class_inheritable_accessor :defined_stages, :instance_writer => false
16
+ self.defined_stages = []
17
+
18
+ def self.define_stages(stages)
19
+ self.defined_stages = stages.build_chain
20
+ end
21
+
22
+ def after_initialize
23
+ self[:status] = :not_started if new_record?
24
+ if new_record?
25
+ self.class.defined_stages.each do |stage_class|
26
+ stages << stage_class.new(:pipeline => self)
27
+ end
28
+ end
29
+ end
30
+
31
+ def perform
32
+ raise InvalidStatusError.new(status) unless ok_to_resume?
33
+ begin
34
+ _setup
35
+ stages.each do |stage|
36
+ stage.perform unless stage.completed?
37
+ end
38
+ self.status = :completed
39
+ rescue IrrecoverableError
40
+ self.status = :failed
41
+ rescue RecoverableError => e
42
+ if e.input_required?
43
+ self.status = :paused
44
+ else
45
+ raise e
46
+ end
47
+ rescue
48
+ self.status = :paused
49
+ end
50
+ end
51
+
52
+ def cancel
53
+ raise InvalidStatusError.new(status) unless ok_to_resume?
54
+ self.status = :failed
55
+ end
56
+
57
+ def ok_to_resume?
58
+ [:not_started, :paused].include?(status)
59
+ end
60
+
61
+ private
62
+ def _setup
63
+ self.attempts += 1
64
+ self.status = :in_progress
65
+ end
66
+ end
67
+ end
@@ -0,0 +1,29 @@
1
+ module Pipeline
2
+ module SymbolAttribute
3
+ def self.included(base)
4
+ base.extend(ClassMethods)
5
+ end
6
+
7
+ module ClassMethods
8
+ def symbol_attrs(*attributes)
9
+ attributes.each do |attribute|
10
+ class_eval <<-EOD
11
+ def #{attribute.to_s}
12
+ read_attribute('#{attribute.to_s}').to_sym rescue nil
13
+ end
14
+ EOD
15
+ end
16
+ end
17
+
18
+ alias_method :symbol_attr, :symbol_attrs
19
+ end
20
+ end
21
+ end
22
+
23
+ class Symbol
24
+ def quoted_id
25
+ "'#{ActiveRecord::Base.connection.quote_string(self.to_s)}'"
26
+ end
27
+ end
28
+
29
+ ActiveRecord::Base.send(:include, Pipeline::SymbolAttribute)
@@ -0,0 +1,26 @@
1
+ module Pipeline
2
+ module TransactionalAttribute
3
+ def self.included (base)
4
+ base.extend(ClassMethods)
5
+ end
6
+
7
+ module ClassMethods
8
+ def transactional_attrs(*attributes)
9
+ attributes.each do |attribute|
10
+ class_eval <<-EOD
11
+ def #{attribute.to_s}=(value)
12
+ ActiveRecord::Base.transaction(:requires_new => true) do
13
+ write_attribute('#{attribute.to_s}', value)
14
+ save!
15
+ end
16
+ end
17
+ EOD
18
+ end
19
+ end
20
+
21
+ alias_method :transactional_attr, :transactional_attrs
22
+ end
23
+ end
24
+ end
25
+
26
+ ActiveRecord::Base.send(:include, Pipeline::TransactionalAttribute)
@@ -0,0 +1,24 @@
1
+ module Pipeline
2
+ class InvalidPipelineError < StandardError; end
3
+
4
+ class InvalidStatusError < StandardError
5
+ def initialize(status)
6
+ super("Status is already #{status.to_s.gsub(/_/, ' ')}")
7
+ end
8
+ end
9
+
10
+ class IrrecoverableError < StandardError; end
11
+
12
+ class RecoverableError < StandardError
13
+ def initialize(msg = nil, input_required = false)
14
+ super(msg)
15
+ @input_required = input_required
16
+ end
17
+
18
+ def input_required?
19
+ @input_required
20
+ end
21
+ end
22
+
23
+ extend(ApiMethods)
24
+ end
@@ -0,0 +1,60 @@
1
+ module Pipeline
2
+ module Stage
3
+ class Base < ActiveRecord::Base
4
+ set_table_name :pipeline_stages
5
+
6
+ # :not_started ---> :in_progress ---> :completed
7
+ # ^ |
8
+ # | v
9
+ # :failed
10
+ symbol_attr :status
11
+ transactional_attr :status
12
+ private :status=
13
+
14
+ belongs_to :pipeline, :class_name => "Pipeline::Base"
15
+
16
+ @@chain = []
17
+ def self.>>(next_stage)
18
+ @@chain << self
19
+ next_stage
20
+ end
21
+
22
+ def self.build_chain
23
+ chain = @@chain + [self]
24
+ @@chain = []
25
+ chain
26
+ end
27
+
28
+ def after_initialize
29
+ self.name ||= self.class.to_s
30
+ self[:status] = :not_started if new_record?
31
+ end
32
+
33
+ def completed?
34
+ status == :completed
35
+ end
36
+
37
+ def perform
38
+ raise InvalidStatusError.new(status) unless [:not_started, :failed].include?(status)
39
+ begin
40
+ _setup
41
+ run
42
+ self.status = :completed
43
+ rescue => e
44
+ self.message = e.message
45
+ self.status = :failed
46
+ raise e
47
+ end
48
+ end
49
+
50
+ # Subclass must implement this as part of the contract
51
+ def run; end
52
+
53
+ private
54
+ def _setup
55
+ self.attempts += 1
56
+ self.status = :in_progress
57
+ end
58
+ end
59
+ end
60
+ end
data/lib/pipeline.rb ADDED
@@ -0,0 +1,16 @@
1
+ require 'rubygems'
2
+ require 'activerecord'
3
+ gem 'collectiveidea-delayed_job'
4
+ autoload :Delayed, 'delayed_job'
5
+
6
+ $: << File.dirname(__FILE__)
7
+ require 'pipeline/core_ext/symbol_attribute'
8
+ require 'pipeline/core_ext/transactional_attribute'
9
+ require 'pipeline/api_methods'
10
+ require 'pipeline/base'
11
+ require 'pipeline/errors'
12
+ require 'pipeline/stage/base'
13
+
14
+ module Pipeline
15
+ extend(ApiMethods)
16
+ end