pipes 0.1.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.
- 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
|