pipes 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -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
@@ -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
@@ -0,0 +1,7 @@
1
+ module Pipes
2
+ module Utils
3
+ def self.constantize(str)
4
+ str.split('::').reject(&:empty?).inject(Kernel) { |const, name| const.const_get(name) }
5
+ end
6
+ end
7
+ end
@@ -0,0 +1,3 @@
1
+ module Pipes
2
+ VERSION = '0.1.0'
3
+ end
@@ -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
@@ -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