pipes 0.1.0
Sign up to get free protection for your applications and to get access to all the features.
- data/.gitignore +24 -0
- data/.rspec +1 -0
- data/.rvmrc +1 -0
- data/Gemfile +3 -0
- data/LICENSE.txt +22 -0
- data/README.md +331 -0
- data/Rakefile +8 -0
- data/lib/pipes.rb +46 -0
- data/lib/pipes/resque_hooks.rb +18 -0
- data/lib/pipes/runner.rb +112 -0
- data/lib/pipes/stage_parser.rb +152 -0
- data/lib/pipes/store.rb +122 -0
- data/lib/pipes/utils.rb +7 -0
- data/lib/pipes/version.rb +3 -0
- data/pipes.gemspec +24 -0
- data/spec/mock_jobs.rb +58 -0
- data/spec/pipes/resque_hooks_spec.rb +22 -0
- data/spec/pipes/runner_spec.rb +110 -0
- data/spec/pipes/stage_parser_spec.rb +169 -0
- data/spec/pipes/store_spec.rb +181 -0
- data/spec/pipes/utils_spec.rb +14 -0
- data/spec/pipes_spec.rb +46 -0
- data/spec/spec_helper.rb +13 -0
- metadata +140 -0
data/lib/pipes/runner.rb
ADDED
@@ -0,0 +1,112 @@
|
|
1
|
+
module Pipes
|
2
|
+
# This is the entry point to running jobs.
|
3
|
+
#
|
4
|
+
# In most cases, this is the sole API used to start up some jobs and run
|
5
|
+
# a series of stages (a pipe).
|
6
|
+
#
|
7
|
+
class Runner
|
8
|
+
|
9
|
+
# Entry point to begin running jobs.
|
10
|
+
#
|
11
|
+
# eg, Pipes::Runner.run(MyStrategies::ContentWriter)
|
12
|
+
# ie, You want to run a single job from somewhere in the app.
|
13
|
+
# Pipes::Runner.run('MyStrategies::ContentWriter')
|
14
|
+
# ie, Params were passed for a single job
|
15
|
+
# Pipes::Runner.run([MyStrategies::ContentWriter, YourStrategies::Publisher])
|
16
|
+
# ie, You want to run multiple jobs from somewhere in the app.
|
17
|
+
# Pipes::Runner.run(['MyStrategies::ContentWriter', 'YourStrategies::Publisher'])
|
18
|
+
# ie, Params were passed for multiple jobs
|
19
|
+
# Pipes::Runner.run(:content_writers)
|
20
|
+
# ie, You want to run an entire stage
|
21
|
+
# Pipes::Runner.run([:content_writers, :publishers])
|
22
|
+
# ie, You want to run multiple stages
|
23
|
+
#
|
24
|
+
def self.run(jobs, *args)
|
25
|
+
options = args.last.is_a?(Hash) ? args.pop : {}
|
26
|
+
self.new(jobs, args, options)
|
27
|
+
end
|
28
|
+
|
29
|
+
# Set up the runner.
|
30
|
+
#
|
31
|
+
def initialize(jobs, job_args, options)
|
32
|
+
@job_args, @options = job_args, options
|
33
|
+
|
34
|
+
@requested = normalize_jobs(jobs)
|
35
|
+
|
36
|
+
# Resolve if the option has been explicitly passed or it's specified in the config.
|
37
|
+
if @options[:resolve] or (@options[:resolve] != false and Pipes.resolve)
|
38
|
+
@requested = include_dependencies(@requested)
|
39
|
+
end
|
40
|
+
|
41
|
+
Store.add_pipe(construct_pipe, options)
|
42
|
+
end
|
43
|
+
|
44
|
+
private
|
45
|
+
|
46
|
+
# Normalize requested jobs into an array of classes.
|
47
|
+
#
|
48
|
+
def normalize_jobs(jobs)
|
49
|
+
if jobs.is_a?(Array)
|
50
|
+
jobs.map { |job| normalize_job(job) }
|
51
|
+
else
|
52
|
+
[normalize_job(jobs)]
|
53
|
+
end.flatten
|
54
|
+
end
|
55
|
+
|
56
|
+
# Normalize requested job, based on type requested
|
57
|
+
#
|
58
|
+
def normalize_job(job)
|
59
|
+
if job.is_a?(String)
|
60
|
+
Utils.constantize(job)
|
61
|
+
elsif job.is_a?(Symbol)
|
62
|
+
stage_parser.jobs_in_stage(job)
|
63
|
+
else
|
64
|
+
job
|
65
|
+
end
|
66
|
+
end
|
67
|
+
|
68
|
+
# Given a list of jobs, include dependencies of those jobs in
|
69
|
+
# the returned array.
|
70
|
+
#
|
71
|
+
def include_dependencies(jobs)
|
72
|
+
jobs.inject([]) do |resolved, job|
|
73
|
+
resolved << [job] + stage_parser.dependents_for(job)
|
74
|
+
end.flatten
|
75
|
+
end
|
76
|
+
|
77
|
+
# Filter jobs by only the ones being requested and build out the pipe
|
78
|
+
# array, including options.
|
79
|
+
#
|
80
|
+
def construct_pipe
|
81
|
+
# Of all the stages listed in the config...
|
82
|
+
stages.inject([]) do |filtered_stages, (stage_name, jobs)|
|
83
|
+
filtered = filtered_jobs(stage_parser.jobs_in_stage(stage_name))
|
84
|
+
|
85
|
+
# Add it unless all jobs have been filtered out
|
86
|
+
if !filtered.empty?
|
87
|
+
filtered_stages << {name: stage_name, jobs: filtered}
|
88
|
+
else; filtered_stages; end
|
89
|
+
end
|
90
|
+
end
|
91
|
+
|
92
|
+
# Construct an array of jobs that have been requested.
|
93
|
+
#
|
94
|
+
def filtered_jobs(jobs)
|
95
|
+
jobs.inject([]) do |filtered_jobs, registered_job|
|
96
|
+
# Is the configured job being requested for this pipe?
|
97
|
+
if @requested.include?(registered_job)
|
98
|
+
filtered_jobs << {class: registered_job, args: @job_args}
|
99
|
+
else; filtered_jobs; end
|
100
|
+
end
|
101
|
+
end
|
102
|
+
|
103
|
+
def stage_parser
|
104
|
+
@parser ||= StageParser.new
|
105
|
+
end
|
106
|
+
|
107
|
+
def stages
|
108
|
+
@stages ||= stage_parser.stages_with_resolved_dependencies
|
109
|
+
end
|
110
|
+
|
111
|
+
end
|
112
|
+
end
|
@@ -0,0 +1,152 @@
|
|
1
|
+
require 'abyss'
|
2
|
+
|
3
|
+
module Pipes
|
4
|
+
class StageParser
|
5
|
+
def initialize(stages = nil)
|
6
|
+
@stages = stages || Abyss.configuration.stages.configurations
|
7
|
+
resolve_dependencies
|
8
|
+
end
|
9
|
+
|
10
|
+
# Grab all stage names.
|
11
|
+
#
|
12
|
+
def stage_names
|
13
|
+
@stages.keys
|
14
|
+
end
|
15
|
+
|
16
|
+
# Grab all jobs for the given stage.
|
17
|
+
#
|
18
|
+
def jobs_in_stage(stage)
|
19
|
+
array_for_stage(@stages[stage])
|
20
|
+
end
|
21
|
+
|
22
|
+
# Recursively grab dependencies for a given job.
|
23
|
+
#
|
24
|
+
def dependents_for(job)
|
25
|
+
if !@dependencies[job] or @dependencies[job].empty?
|
26
|
+
[]
|
27
|
+
else
|
28
|
+
recursive_dependencies = @dependencies[job].map{ |strat| dependents_for(strat) }
|
29
|
+
(@dependencies[job] + recursive_dependencies).flatten.uniq
|
30
|
+
end
|
31
|
+
end
|
32
|
+
|
33
|
+
# Normalize configured stages so they have a consistent form.
|
34
|
+
#
|
35
|
+
# This will return a structure exactly the same as that defined in the config,
|
36
|
+
# except, all the "magic" dependencies (symbols to other stages, references
|
37
|
+
# to classes, and arrays of both) have been replaced with the name of the actual
|
38
|
+
# dependency, ie the class.
|
39
|
+
#
|
40
|
+
# Further, each job has been converted to a hash, with the job as the
|
41
|
+
# key and the dependencies as the the values.
|
42
|
+
#
|
43
|
+
# This data is normalized so that it can be used within the interface, and what
|
44
|
+
# to do about the dependencies is up to the implementation.
|
45
|
+
#
|
46
|
+
def stages_with_resolved_dependencies
|
47
|
+
# Looping over all stages...
|
48
|
+
@stages.inject({}) do |resolved_stages, (name, stage)|
|
49
|
+
# Looping over all jobs...
|
50
|
+
resolved_stages[name] = stage.inject([]) do |resolved_stage, job|
|
51
|
+
job = job.keys[0] if job.is_a? Hash
|
52
|
+
# Normalze to new hash form
|
53
|
+
resolved_stage << {job => @dependencies[job]}
|
54
|
+
end
|
55
|
+
resolved_stages
|
56
|
+
end
|
57
|
+
end
|
58
|
+
|
59
|
+
private
|
60
|
+
|
61
|
+
# Populates @dependencies hash in the form of:
|
62
|
+
# {
|
63
|
+
# SomeClass => [OtherClass, AnotherClass],
|
64
|
+
# ...
|
65
|
+
# }
|
66
|
+
#
|
67
|
+
# Loop over and resolve dependencies on a job-by-job basis.
|
68
|
+
#
|
69
|
+
# Work from the bottom up since dependencies can only be specified for
|
70
|
+
# lower-priority stages (ie lower stages won't reference higher ones)
|
71
|
+
#
|
72
|
+
def resolve_dependencies
|
73
|
+
@dependencies = {}
|
74
|
+
|
75
|
+
reversed = Hash[@stages.to_a.reverse]
|
76
|
+
reversed.each do |name, stage|
|
77
|
+
stage.each do |job|
|
78
|
+
# Does the job have dependents?
|
79
|
+
if job.is_a? Hash
|
80
|
+
strat, dependents = job.to_a.first
|
81
|
+
@dependencies[strat] = dependencies_for_job(dependents)
|
82
|
+
else
|
83
|
+
# Defined job is a simple class (eg Publisher)
|
84
|
+
@dependencies[job] = []
|
85
|
+
end
|
86
|
+
end
|
87
|
+
end
|
88
|
+
end
|
89
|
+
|
90
|
+
# If the job has dependents, figure out how to resolve.
|
91
|
+
#
|
92
|
+
def dependencies_for_job(dependents)
|
93
|
+
if dependents.is_a? Symbol
|
94
|
+
# Referring to another stage (eg :publishers)
|
95
|
+
dependents_for_stage(dependents)
|
96
|
+
elsif dependents.is_a? Array
|
97
|
+
# Referring to an array of dependencies (eg [:publishers, Publisher2])
|
98
|
+
dependencies_from_array(dependents)
|
99
|
+
else
|
100
|
+
# Referring to another job (eg Publisher1)
|
101
|
+
[dependents] + dependents_for(dependents)
|
102
|
+
end
|
103
|
+
end
|
104
|
+
|
105
|
+
# Iterate over all jobs for this stage and find dependents.
|
106
|
+
#
|
107
|
+
def dependents_for_stage(stage_name)
|
108
|
+
stage = @stages[stage_name.to_sym]
|
109
|
+
|
110
|
+
stage.inject([]) do |klasses, job|
|
111
|
+
# Does the job have dependents?
|
112
|
+
if job.is_a? Hash
|
113
|
+
strat, dependents = job.to_a.first
|
114
|
+
klasses << strat
|
115
|
+
klasses << dependencies_for_job(dependents)
|
116
|
+
else
|
117
|
+
# Defined job is a simple class (eg Publisher)
|
118
|
+
klasses << [job] + dependents_for(job)
|
119
|
+
end
|
120
|
+
end.flatten.uniq
|
121
|
+
end
|
122
|
+
|
123
|
+
# When dependencies are defined as an array, loop over the array and resolve.
|
124
|
+
#
|
125
|
+
def dependencies_from_array(dependents)
|
126
|
+
# Referring to an array of dependents
|
127
|
+
# Can be a mixed array (eg [:publishers, Publisher2])
|
128
|
+
dependents.inject([]) do |klasses, dependent|
|
129
|
+
if dependent.is_a? Symbol
|
130
|
+
# Referring to an array of stages (eg [:publishers, :emailers])
|
131
|
+
klasses << dependents_for_stage(dependent)
|
132
|
+
else
|
133
|
+
# Referring to an array of jobs (eg [Publisher1, Publisher2])
|
134
|
+
klasses << [dependent] + dependents_for(dependent)
|
135
|
+
end
|
136
|
+
end.flatten.uniq
|
137
|
+
end
|
138
|
+
|
139
|
+
# Just list the jobs in the stage, ignoring dependencies.
|
140
|
+
#
|
141
|
+
def array_for_stage(stage)
|
142
|
+
stage.inject([]) do |arr, job|
|
143
|
+
arr << if job.is_a? Hash
|
144
|
+
# Take just the job class, without any dependents
|
145
|
+
job.keys[0]
|
146
|
+
else
|
147
|
+
job
|
148
|
+
end
|
149
|
+
end
|
150
|
+
end
|
151
|
+
end
|
152
|
+
end
|
data/lib/pipes/store.rb
ADDED
@@ -0,0 +1,122 @@
|
|
1
|
+
require 'redis/objects'
|
2
|
+
require 'redis/list'
|
3
|
+
require 'redis/counter'
|
4
|
+
|
5
|
+
module Pipes
|
6
|
+
# Stages are stored in Redis in the following manner:
|
7
|
+
# pipes:stages:stage_1 [{class: 'ContentWriterStrategy', args: ['en-US']}, ...]
|
8
|
+
# pipes:stages:stage_2 [{class: 'PublisherStrategy', args: ['en-US']}]
|
9
|
+
#
|
10
|
+
# The jobs stored in Redis are Marshalled Ruby objects, so the structure is
|
11
|
+
# more-or-less arbitrary, though at a performance cost.
|
12
|
+
#
|
13
|
+
# Jobs are queued up in the following steps
|
14
|
+
# 1. Strategies in stage n? No, look in stage n+1 until last stage.
|
15
|
+
# Yes, shift off the next stage and queue up its jobs
|
16
|
+
# 2. Strategies run concurrently. Keep track of how many are currently running to
|
17
|
+
# know when the next stage should be started.
|
18
|
+
#
|
19
|
+
class Store
|
20
|
+
|
21
|
+
# Add a new set of stages to Redis.
|
22
|
+
#
|
23
|
+
def self.add_pipe(stages, options = {})
|
24
|
+
stages.each do |stage|
|
25
|
+
stage[:jobs].each do |job|
|
26
|
+
pending = pending_jobs(stage[:name])
|
27
|
+
pending << job if valid_for_queue?(stage[:name], pending, job, options)
|
28
|
+
end
|
29
|
+
end
|
30
|
+
next_stage
|
31
|
+
end
|
32
|
+
|
33
|
+
# Fire off the next available stage, if available.
|
34
|
+
#
|
35
|
+
def self.next_stage
|
36
|
+
return unless remaining_jobs == 0
|
37
|
+
|
38
|
+
# Always start at the first stage, in case new stragies have been added mid-pipe
|
39
|
+
stages.each do |stage|
|
40
|
+
if !(jobs = pending_jobs(stage)).empty?
|
41
|
+
run_stage(jobs)
|
42
|
+
clear(stage)
|
43
|
+
return
|
44
|
+
end
|
45
|
+
end
|
46
|
+
end
|
47
|
+
|
48
|
+
# Actually enqueue the jobs.
|
49
|
+
#
|
50
|
+
def self.run_stage(jobs)
|
51
|
+
remaining_jobs.clear
|
52
|
+
remaining_jobs.incr(jobs.count)
|
53
|
+
|
54
|
+
jobs.each do |job|
|
55
|
+
Resque.enqueue(job[:class], *job[:args])
|
56
|
+
end
|
57
|
+
end
|
58
|
+
|
59
|
+
# Register that a job has finished.
|
60
|
+
#
|
61
|
+
def self.done
|
62
|
+
if remaining_jobs.decrement == 0
|
63
|
+
next_stage
|
64
|
+
end
|
65
|
+
end
|
66
|
+
|
67
|
+
# Clear a specific stage queue.
|
68
|
+
#
|
69
|
+
def self.clear(stage)
|
70
|
+
pending_jobs(stage).clear
|
71
|
+
end
|
72
|
+
|
73
|
+
# Find all stage queues in Redis (even ones not configured), and clear them.
|
74
|
+
#
|
75
|
+
def self.clear_all
|
76
|
+
stage_keys = Redis.current.keys "#{@redis_stages_key}:*"
|
77
|
+
Redis.current.del *stage_keys unless stage_keys.empty?
|
78
|
+
|
79
|
+
remaining_jobs.clear
|
80
|
+
end
|
81
|
+
|
82
|
+
private
|
83
|
+
|
84
|
+
def self.valid_for_queue?(stage, pending, job, options)
|
85
|
+
# allow_duplicates checks just the class for duplication
|
86
|
+
if options[:allow_duplicates] and !Array(options[:allow_duplicates]).include?(stage)
|
87
|
+
pending_classes = pending.map { |job| job[:class] }
|
88
|
+
return false if pending_classes.include?(job[:class])
|
89
|
+
end
|
90
|
+
|
91
|
+
# Is this exact job already queued up?
|
92
|
+
!pending.include?(job)
|
93
|
+
end
|
94
|
+
|
95
|
+
def self.stages
|
96
|
+
StageParser.new.stage_names
|
97
|
+
end
|
98
|
+
|
99
|
+
def self.stage_key(name)
|
100
|
+
"#{@redis_stages_key}:#{name}"
|
101
|
+
end
|
102
|
+
|
103
|
+
def self.pending_jobs(stage)
|
104
|
+
Redis::List.new(stage_key(stage), marshal: true)
|
105
|
+
end
|
106
|
+
|
107
|
+
def self.remaining_jobs
|
108
|
+
@remaining_jobs ||= Redis::Counter.new(@redis_remaining_key)
|
109
|
+
end
|
110
|
+
|
111
|
+
def self.namespace
|
112
|
+
"#{Pipes.namespace + ':' if Pipes.namespace}#{@namespace}"
|
113
|
+
end
|
114
|
+
|
115
|
+
@namespace = 'pipes'
|
116
|
+
# All pending stages for the current job
|
117
|
+
@redis_stages_key = "#{namespace}:stages"
|
118
|
+
# Remaining jobs to call .done, ie jobs still in the workers
|
119
|
+
@redis_remaining_key = "#{namespace}:stage_remaining"
|
120
|
+
|
121
|
+
end
|
122
|
+
end
|
data/lib/pipes/utils.rb
ADDED
data/pipes.gemspec
ADDED
@@ -0,0 +1,24 @@
|
|
1
|
+
# -*- encoding: utf-8 -*-
|
2
|
+
lib = File.expand_path('../lib', __FILE__)
|
3
|
+
$LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib)
|
4
|
+
require 'pipes/version'
|
5
|
+
|
6
|
+
Gem::Specification.new do |gem|
|
7
|
+
gem.name = "pipes"
|
8
|
+
gem.version = Pipes::VERSION
|
9
|
+
gem.authors = ["Mike Pack"]
|
10
|
+
gem.email = ["mikepackdev@gmail.com"]
|
11
|
+
gem.description = %q{A Redis-backed concurrency management system}
|
12
|
+
gem.summary = %q{A Redis-backed concurrency management system}
|
13
|
+
gem.homepage = "http://www.github.com/mikepack/pipes"
|
14
|
+
|
15
|
+
gem.files = `git ls-files`.split($/)
|
16
|
+
gem.test_files = gem.files.grep(%r{^(spec)/})
|
17
|
+
gem.require_paths = ["lib"]
|
18
|
+
|
19
|
+
gem.add_dependency 'resque', '~> 1.22.0'
|
20
|
+
gem.add_dependency 'redis-objects', '~> 0.5.3'
|
21
|
+
gem.add_dependency 'abyss', '~> 0.4.0'
|
22
|
+
|
23
|
+
gem.add_development_dependency 'rspec'
|
24
|
+
end
|
data/spec/mock_jobs.rb
ADDED
@@ -0,0 +1,58 @@
|
|
1
|
+
# These are Resque jobs
|
2
|
+
module Writers
|
3
|
+
class ContentWriter
|
4
|
+
@queue = :content_writers
|
5
|
+
def self.perform(locale)
|
6
|
+
sleep 5
|
7
|
+
end
|
8
|
+
end
|
9
|
+
|
10
|
+
class AnotherContentWriter
|
11
|
+
@queue = :content_writers
|
12
|
+
def self.perform(locale)
|
13
|
+
sleep 5
|
14
|
+
end
|
15
|
+
end
|
16
|
+
|
17
|
+
class UnregisteredStrategy
|
18
|
+
@queue = :content_writers
|
19
|
+
def self.perform; end
|
20
|
+
end
|
21
|
+
end
|
22
|
+
|
23
|
+
module Publishers
|
24
|
+
class Publisher
|
25
|
+
@queue = :publishers
|
26
|
+
def self.perform(locale)
|
27
|
+
sleep 5
|
28
|
+
end
|
29
|
+
end
|
30
|
+
end
|
31
|
+
|
32
|
+
module Messengers
|
33
|
+
class SMS
|
34
|
+
def self.perform; end
|
35
|
+
end
|
36
|
+
end
|
37
|
+
|
38
|
+
module Uploaders
|
39
|
+
class Rsync
|
40
|
+
def self.perform; end
|
41
|
+
end
|
42
|
+
end
|
43
|
+
|
44
|
+
module Emailers
|
45
|
+
class Email
|
46
|
+
def self.perform; end
|
47
|
+
end
|
48
|
+
|
49
|
+
class AnotherEmail
|
50
|
+
def self.perform; end
|
51
|
+
end
|
52
|
+
end
|
53
|
+
|
54
|
+
module Notifiers
|
55
|
+
class Twitter
|
56
|
+
def self.perform; end
|
57
|
+
end
|
58
|
+
end
|