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.
@@ -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