creeper 1.0.9 → 2.0.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.
Files changed (145) hide show
  1. data/.gitignore +1 -0
  2. data/.rvmrc +48 -0
  3. data/Gemfile +17 -1
  4. data/Guardfile +32 -0
  5. data/Rakefile +9 -1
  6. data/bin/creeper +10 -58
  7. data/bin/creeperctl +74 -0
  8. data/config.ru +18 -0
  9. data/creeper.gemspec +19 -9
  10. data/lib/creeper.rb +108 -413
  11. data/lib/creeper/beanstalk_connection.rb +35 -0
  12. data/lib/creeper/cli.rb +225 -0
  13. data/lib/creeper/client.rb +93 -0
  14. data/lib/creeper/core_ext.rb +54 -0
  15. data/lib/creeper/exception_handler.rb +30 -0
  16. data/lib/creeper/extensions/action_mailer.rb +33 -0
  17. data/lib/creeper/extensions/active_record.rb +30 -0
  18. data/lib/creeper/extensions/generic_proxy.rb +26 -0
  19. data/lib/creeper/fetch.rb +94 -0
  20. data/lib/creeper/legacy.rb +46 -0
  21. data/lib/creeper/logging.rb +46 -0
  22. data/lib/creeper/manager.rb +164 -0
  23. data/lib/creeper/middleware/chain.rb +100 -0
  24. data/lib/creeper/middleware/server/active_record.rb +13 -0
  25. data/lib/creeper/middleware/server/logging.rb +31 -0
  26. data/lib/creeper/middleware/server/retry_jobs.rb +79 -0
  27. data/lib/creeper/middleware/server/timeout.rb +21 -0
  28. data/lib/creeper/paginator.rb +31 -0
  29. data/lib/creeper/processor.rb +116 -0
  30. data/lib/creeper/rails.rb +21 -0
  31. data/lib/creeper/redis_connection.rb +28 -0
  32. data/lib/creeper/testing.rb +44 -0
  33. data/lib/creeper/util.rb +45 -0
  34. data/lib/creeper/version.rb +1 -1
  35. data/lib/creeper/web.rb +248 -0
  36. data/lib/creeper/worker.rb +62 -313
  37. data/spec/dummy/.gitignore +15 -0
  38. data/spec/dummy/Gemfile +51 -0
  39. data/spec/dummy/README.rdoc +261 -0
  40. data/spec/dummy/Rakefile +7 -0
  41. data/spec/dummy/app/assets/images/rails.png +0 -0
  42. data/spec/dummy/app/assets/javascripts/application.js +15 -0
  43. data/spec/dummy/app/assets/stylesheets/application.css +13 -0
  44. data/spec/dummy/app/controllers/application_controller.rb +3 -0
  45. data/spec/dummy/app/controllers/work_controller.rb +71 -0
  46. data/spec/dummy/app/helpers/application_helper.rb +2 -0
  47. data/spec/dummy/app/mailers/.gitkeep +0 -0
  48. data/spec/dummy/app/mailers/user_mailer.rb +9 -0
  49. data/spec/dummy/app/models/.gitkeep +0 -0
  50. data/spec/dummy/app/models/post.rb +8 -0
  51. data/spec/dummy/app/views/layouts/application.html.erb +14 -0
  52. data/spec/dummy/app/views/user_mailer/greetings.html.erb +3 -0
  53. data/spec/dummy/app/views/work/index.html.erb +1 -0
  54. data/spec/dummy/app/workers/fast_worker.rb +10 -0
  55. data/spec/dummy/app/workers/hard_worker.rb +11 -0
  56. data/spec/dummy/app/workers/lazy_worker.rb +12 -0
  57. data/spec/dummy/app/workers/suicidal_worker.rb +33 -0
  58. data/spec/dummy/config.ru +4 -0
  59. data/spec/dummy/config/application.rb +68 -0
  60. data/spec/dummy/config/boot.rb +6 -0
  61. data/spec/dummy/config/creeper.yml +9 -0
  62. data/spec/dummy/config/database.yml +25 -0
  63. data/spec/dummy/config/environment.rb +5 -0
  64. data/spec/dummy/config/environments/development.rb +37 -0
  65. data/spec/dummy/config/environments/production.rb +67 -0
  66. data/spec/dummy/config/environments/test.rb +37 -0
  67. data/spec/dummy/config/initializers/backtrace_silencers.rb +7 -0
  68. data/spec/dummy/config/initializers/creeper.rb +8 -0
  69. data/spec/dummy/config/initializers/inflections.rb +15 -0
  70. data/spec/dummy/config/initializers/mime_types.rb +5 -0
  71. data/spec/dummy/config/initializers/secret_token.rb +7 -0
  72. data/spec/dummy/config/initializers/session_store.rb +8 -0
  73. data/spec/dummy/config/initializers/wrap_parameters.rb +14 -0
  74. data/spec/dummy/config/locales/en.yml +5 -0
  75. data/spec/dummy/config/routes.rb +13 -0
  76. data/spec/dummy/db/migrate/20120123214055_create_posts.rb +10 -0
  77. data/spec/dummy/db/schema.rb +23 -0
  78. data/spec/dummy/db/seeds.rb +7 -0
  79. data/spec/dummy/lib/assets/.gitkeep +0 -0
  80. data/spec/dummy/lib/tasks/.gitkeep +0 -0
  81. data/spec/dummy/log/.gitkeep +0 -0
  82. data/spec/dummy/public/404.html +26 -0
  83. data/spec/dummy/public/422.html +26 -0
  84. data/spec/dummy/public/500.html +25 -0
  85. data/spec/dummy/public/favicon.ico +0 -0
  86. data/spec/dummy/public/index.html +241 -0
  87. data/spec/dummy/public/robots.txt +5 -0
  88. data/spec/dummy/script/rails +6 -0
  89. data/spec/dummy/vendor/assets/javascripts/.gitkeep +0 -0
  90. data/spec/dummy/vendor/assets/stylesheets/.gitkeep +0 -0
  91. data/spec/dummy/vendor/plugins/.gitkeep +0 -0
  92. data/spec/lib/creeper/cli_spec.rb +208 -0
  93. data/spec/lib/creeper/client_spec.rb +110 -0
  94. data/spec/lib/creeper/exception_handler_spec.rb +110 -0
  95. data/spec/lib/creeper/processor_spec.rb +92 -0
  96. data/spec/lib/creeper/testing_spec.rb +105 -0
  97. data/spec/lib/creeper_spec.rb +54 -120
  98. data/spec/spec_helper.rb +81 -7
  99. data/spec/support/config.yml +9 -0
  100. data/spec/support/fake_env.rb +0 -0
  101. data/spec/support/workers/base_worker.rb +11 -0
  102. data/spec/support/workers/my_worker.rb +4 -0
  103. data/spec/support/workers/queued_worker.rb +5 -0
  104. data/spec/support/workers/real_worker.rb +10 -0
  105. data/web/assets/images/bootstrap/glyphicons-halflings-white.png +0 -0
  106. data/web/assets/images/bootstrap/glyphicons-halflings.png +0 -0
  107. data/web/assets/javascripts/application.js +49 -0
  108. data/web/assets/javascripts/vendor/bootstrap.js +12 -0
  109. data/web/assets/javascripts/vendor/bootstrap/bootstrap-alert.js +91 -0
  110. data/web/assets/javascripts/vendor/bootstrap/bootstrap-button.js +98 -0
  111. data/web/assets/javascripts/vendor/bootstrap/bootstrap-carousel.js +154 -0
  112. data/web/assets/javascripts/vendor/bootstrap/bootstrap-collapse.js +136 -0
  113. data/web/assets/javascripts/vendor/bootstrap/bootstrap-dropdown.js +92 -0
  114. data/web/assets/javascripts/vendor/bootstrap/bootstrap-modal.js +210 -0
  115. data/web/assets/javascripts/vendor/bootstrap/bootstrap-popover.js +95 -0
  116. data/web/assets/javascripts/vendor/bootstrap/bootstrap-scrollspy.js +125 -0
  117. data/web/assets/javascripts/vendor/bootstrap/bootstrap-tab.js +130 -0
  118. data/web/assets/javascripts/vendor/bootstrap/bootstrap-tooltip.js +270 -0
  119. data/web/assets/javascripts/vendor/bootstrap/bootstrap-transition.js +51 -0
  120. data/web/assets/javascripts/vendor/bootstrap/bootstrap-typeahead.js +271 -0
  121. data/web/assets/javascripts/vendor/jquery.js +9266 -0
  122. data/web/assets/javascripts/vendor/jquery.timeago.js +148 -0
  123. data/web/assets/stylesheets/application.css +6 -0
  124. data/web/assets/stylesheets/layout.css +26 -0
  125. data/web/assets/stylesheets/vendor/bootstrap-responsive.css +567 -0
  126. data/web/assets/stylesheets/vendor/bootstrap.css +3365 -0
  127. data/web/views/_paging.slim +15 -0
  128. data/web/views/_summary.slim +9 -0
  129. data/web/views/_workers.slim +14 -0
  130. data/web/views/index.slim +10 -0
  131. data/web/views/layout.slim +37 -0
  132. data/web/views/poll.slim +3 -0
  133. data/web/views/queue.slim +15 -0
  134. data/web/views/queues.slim +19 -0
  135. data/web/views/retries.slim +31 -0
  136. data/web/views/retry.slim +52 -0
  137. data/web/views/scheduled.slim +27 -0
  138. metadata +341 -23
  139. data/lib/creeper/celluloid_ext.rb +0 -42
  140. data/lib/creeper/creep.rb +0 -25
  141. data/lib/creeper/err_logger.rb +0 -37
  142. data/lib/creeper/launcher.rb +0 -44
  143. data/lib/creeper/out_logger.rb +0 -39
  144. data/spec/lib/creeper/session_spec.rb +0 -15
  145. data/spec/lib/creeper/worker_spec.rb +0 -21
@@ -0,0 +1,30 @@
1
+ require 'creeper/extensions/generic_proxy'
2
+
3
+ module Creeper
4
+ module Extensions
5
+ ##
6
+ # Adds a 'delay' method to ActiveRecord to offload arbitrary method
7
+ # execution to Creeper. Examples:
8
+ #
9
+ # User.delay.delete_inactive
10
+ # User.recent_signups.each { |user| user.delay.mark_as_awesome }
11
+ class DelayedModel
12
+ include Creeper::Worker
13
+
14
+ def perform(yml)
15
+ (target, method_name, args) = YAML.load(yml)
16
+ target.send(method_name, *args)
17
+ end
18
+ end
19
+
20
+ module ActiveRecord
21
+ def delay
22
+ Proxy.new(DelayedModel, self)
23
+ end
24
+ def delay_for(interval)
25
+ Proxy.new(DelayedModel, self, Time.now.to_f + interval.to_f)
26
+ end
27
+ end
28
+
29
+ end
30
+ end
@@ -0,0 +1,26 @@
1
+ module Creeper
2
+ module Extensions
3
+ class Proxy < BasicObject
4
+ def initialize(performable, target, at=nil)
5
+ @performable = performable
6
+ @target = target
7
+ @at = at
8
+ end
9
+
10
+ def method_missing(name, *args)
11
+ # Creeper has a limitation in that its message must be JSON.
12
+ # JSON can't round trip real Ruby objects so we use YAML to
13
+ # serialize the objects to a String. The YAML will be converted
14
+ # to JSON and then deserialized on the other side back into a
15
+ # Ruby object.
16
+ obj = [@target, name, args]
17
+ if @at
18
+ @performable.perform_at(@at, ::YAML.dump(obj))
19
+ else
20
+ @performable.perform_async(::YAML.dump(obj))
21
+ end
22
+ end
23
+ end
24
+
25
+ end
26
+ end
@@ -0,0 +1,94 @@
1
+ require 'creeper'
2
+ require 'celluloid'
3
+
4
+ module Creeper
5
+ ##
6
+ # The Fetcher blocks on Redis, waiting for a message to process
7
+ # from the queues. It gets the message and hands it to the Manager
8
+ # to assign to a ready Processor.
9
+ class Fetcher
10
+ include Celluloid
11
+ include Creeper::Util
12
+
13
+ TIMEOUT = 1
14
+
15
+ def initialize(mgr, queues, strict)
16
+ @mgr = mgr
17
+ @strictly_ordered_queues = strict
18
+ @queues = queues.map { |q| "queue:#{q}" }
19
+ @unique_queues = @queues.uniq
20
+ end
21
+
22
+ # Fetching is straightforward: the Manager makes a fetch
23
+ # request for each idle processor when Sidekiq starts and
24
+ # then issues a new fetch request every time a Processor
25
+ # finishes a message.
26
+ #
27
+ # Because we have to shut down cleanly, we can't block
28
+ # forever and we can't loop forever. Instead we reschedule
29
+ # a new fetch if the current fetch turned up nothing.
30
+ def fetch
31
+ watchdog('Fetcher#fetch died') do
32
+ return if Creeper::Fetcher.done?
33
+
34
+ begin
35
+ queue = nil
36
+ msg = nil
37
+ job = nil
38
+ conn = nil
39
+
40
+ conn = Creeper::BeanstalkConnection.create
41
+
42
+ begin
43
+ job = conn.reserve(TIMEOUT)
44
+ queue, msg = Creeper.load_json(job.body)
45
+ rescue Beanstalk::TimedOut
46
+ logger.debug("No message fetched after #{TIMEOUT} seconds") if $DEBUG
47
+ job.release rescue nil
48
+ conn.close rescue nil
49
+ sleep(TIMEOUT)
50
+ return after(0) { fetch }
51
+ end
52
+
53
+ if msg
54
+ @mgr.assign!(msg, queue.gsub(/.*queue:/, ''), job, conn)
55
+ else
56
+ after(0) { fetch }
57
+ end
58
+ rescue => ex
59
+ logger.error("Error fetching message: #{ex}")
60
+ logger.error(ex.backtrace.first)
61
+ job.release rescue nil
62
+ conn.close rescue nil
63
+ sleep(TIMEOUT)
64
+ after(0) { fetch }
65
+ end
66
+ end
67
+ end
68
+
69
+ # Ugh. Say hello to a bloody hack.
70
+ # Can't find a clean way to get the fetcher to just stop processing
71
+ # its mailbox when shutdown starts.
72
+ def self.done!
73
+ @done = true
74
+ end
75
+
76
+ def self.done?
77
+ @done
78
+ end
79
+
80
+ private
81
+
82
+ # Creating the Redis#blpop command takes into account any
83
+ # configured queue weights. By default Redis#blpop returns
84
+ # data from the first queue that has pending elements. We
85
+ # recreate the queue command each time we invoke Redis#blpop
86
+ # to honor weights and avoid queue starvation.
87
+ def queues_cmd
88
+ return @unique_queues.dup << TIMEOUT if @strictly_ordered_queues
89
+ queues = @queues.sample(@unique_queues.size).uniq
90
+ queues.concat(@unique_queues - queues)
91
+ queues << TIMEOUT
92
+ end
93
+ end
94
+ end
@@ -0,0 +1,46 @@
1
+ module Creeper
2
+ module Legacy
3
+
4
+ module ClassMethods
5
+
6
+ def job_descriptions
7
+ @job_descriptions ||= {}
8
+ end
9
+
10
+ def enqueue(job, *args)
11
+ enqueue!(job, *args)
12
+ end
13
+
14
+ def enqueue!(job, *args)
15
+ options = args.last.is_a?(Hash) ? args.last : {}
16
+ priority = options[:priority] || options[:pri] || 65536
17
+ delay = [ 0, options[:delay].to_i ].max
18
+ time_to_run = options[:time_to_run] || options[:ttr] || 120
19
+
20
+ klass = options[:class] || job_descriptions[job]
21
+
22
+ Creeper::Client.push({
23
+ 'queue' => job,
24
+ 'args' => args,
25
+ 'class' => klass,
26
+ 'delay' => delay,
27
+ 'priority' => priority,
28
+ 'time_to_run' => time_to_run
29
+ })
30
+ end
31
+
32
+ end
33
+
34
+ module WorkerMethods
35
+
36
+ def creeper_legacy_queue(tube = nil)
37
+ return @creeper_legacy_queue if tube.nil?
38
+ (@creeper_legacy_queue = tube).tap do
39
+ Creeper.job_descriptions[@creeper_legacy_queue] = self
40
+ end
41
+ end
42
+
43
+ end
44
+
45
+ end
46
+ end
@@ -0,0 +1,46 @@
1
+ require 'time'
2
+ require 'logger'
3
+
4
+ module Creeper
5
+ module Logging
6
+
7
+ class Pretty < Logger::Formatter
8
+ # Provide a call() method that returns the formatted message.
9
+ def call(severity, time, program_name, message)
10
+ "#{time.utc.iso8601} #{Process.pid} TID-#{Thread.current.object_id.to_s(36)}#{context} #{severity}: #{message}\n"
11
+ end
12
+
13
+ def context
14
+ c = Thread.current[:creeper_context]
15
+ c ? " #{c}" : ''
16
+ end
17
+ end
18
+
19
+ def self.with_context(msg)
20
+ begin
21
+ Thread.current[:creeper_context] = msg
22
+ yield
23
+ ensure
24
+ Thread.current[:creeper_context] = nil
25
+ end
26
+ end
27
+
28
+ def self.logger
29
+ @logger ||= begin
30
+ log = Logger.new(STDOUT)
31
+ log.level = Logger::INFO
32
+ log.formatter = Pretty.new
33
+ log
34
+ end
35
+ end
36
+
37
+ def self.logger=(log)
38
+ @logger = (log ? log : Logger.new('/dev/null'))
39
+ end
40
+
41
+ def logger
42
+ Creeper::Logging.logger
43
+ end
44
+
45
+ end
46
+ end
@@ -0,0 +1,164 @@
1
+ require 'celluloid'
2
+
3
+ require 'creeper/util'
4
+ require 'creeper/processor'
5
+ require 'creeper/fetch'
6
+
7
+ module Creeper
8
+
9
+ ##
10
+ # The main router in the system. This
11
+ # manages the processor state and accepts messages
12
+ # from Redis to be dispatched to an idle processor.
13
+ #
14
+ class Manager
15
+ include Util
16
+ include Celluloid
17
+
18
+ trap_exit :processor_died
19
+
20
+ def initialize(options={})
21
+ logger.info "Booting creeper #{Creeper::VERSION} with Beanstalk at #{beanstalk { |x| x.instance_variable_get(:@addrs).join(', ')}} and Redis at #{redis {|x| x.client.id}}"
22
+ logger.info "Running in #{RUBY_DESCRIPTION}"
23
+ logger.debug { options.inspect }
24
+ @count = options[:concurrency] || 25
25
+ @done_callback = nil
26
+
27
+ @in_progress = {}
28
+ @done = false
29
+ @busy = []
30
+ @fetcher = Fetcher.new(current_actor, options[:queues], !!options[:strict])
31
+ @ready = @count.times.map { Processor.new_link(current_actor) }
32
+ procline
33
+ end
34
+
35
+ def stop(options={})
36
+ watchdog('Manager#stop died') do
37
+ shutdown = options[:shutdown]
38
+ timeout = options[:timeout]
39
+
40
+ @done = true
41
+ Creeper::Fetcher.done!
42
+ @fetcher.terminate! if @fetcher.alive?
43
+
44
+ logger.info { "Shutting down #{@ready.size} quiet workers" }
45
+ @ready.each { |x| x.terminate if x.alive? }
46
+ @ready.clear
47
+
48
+ logger.debug { "Clearing workers in redis" }
49
+ Creeper.redis do |conn|
50
+ workers = conn.smembers('workers')
51
+ workers.each do |name|
52
+ conn.srem('workers', name) if name =~ /:#{process_id}-/
53
+ end
54
+ end
55
+
56
+ return after(0) { signal(:shutdown) } if @busy.empty?
57
+ logger.info { "Pausing up to #{timeout} seconds to allow workers to finish..." }
58
+ hard_shutdown_in timeout if shutdown
59
+ end
60
+ end
61
+
62
+ def start
63
+ @ready.each { dispatch }
64
+ end
65
+
66
+ def when_done(&blk)
67
+ @done_callback = blk
68
+ end
69
+
70
+ def processor_done(processor)
71
+ watchdog('Manager#processor_done died') do
72
+ @done_callback.call(processor) if @done_callback
73
+ @in_progress.delete(processor.object_id)
74
+ @busy.delete(processor)
75
+ if stopped?
76
+ processor.terminate if processor.alive?
77
+ signal(:shutdown) if @busy.empty?
78
+ else
79
+ @ready << processor if processor.alive?
80
+ end
81
+ dispatch
82
+ end
83
+ end
84
+
85
+ def processor_died(processor, reason)
86
+ watchdog("Manager#processor_died died") do
87
+ @in_progress.delete(processor.object_id)
88
+ @busy.delete(processor)
89
+
90
+ unless stopped?
91
+ @ready << Processor.new_link(current_actor)
92
+ dispatch
93
+ else
94
+ signal(:shutdown) if @busy.empty?
95
+ end
96
+ end
97
+ end
98
+
99
+ def assign(msg, queue, job, conn)
100
+ watchdog("Manager#assign died") do
101
+ if stopped?
102
+ # Race condition between Manager#stop if Fetcher
103
+ # is blocked on redis and gets a message after
104
+ # all the ready Processors have been stopped.
105
+ # Push the message back to redis.
106
+ job.release rescue nil
107
+ conn.close rescue nil
108
+ # Creeper.redis do |conn|
109
+ # conn.lpush("queue:#{queue}", msg)
110
+ # end
111
+ else
112
+ processor = @ready.pop
113
+ @in_progress[processor.object_id] = [msg, queue, job, conn]
114
+ @busy << processor
115
+ processor.process!(msg, queue, job, conn)
116
+ end
117
+ end
118
+ end
119
+
120
+ private
121
+
122
+ def hard_shutdown_in(delay)
123
+ after(delay) do
124
+ watchdog("Manager#watch_for_shutdown died") do
125
+ # We've reached the timeout and we still have busy workers.
126
+ # They must die but their messages shall live on.
127
+ logger.info("Still waiting for #{@busy.size} busy workers")
128
+
129
+ @busy.each do |processor|
130
+ # processor is an actor proxy and we can't call any methods
131
+ # that would go to the actor (since it's busy). Instead
132
+ # we'll use the object_id to track the worker's data here.
133
+ msg, queue, job, conn = @in_progress[processor.object_id]
134
+ job.release rescue nil
135
+ conn.close rescue nil
136
+ # conn.lpush("queue:#{queue}", msg)
137
+ end
138
+ logger.info("Released #{@busy.size} jobs back to Beanstalk")
139
+
140
+ after(0) { signal(:shutdown) }
141
+ end
142
+ end
143
+ end
144
+
145
+ def dispatch
146
+ return if stopped?
147
+ # This is a safety check to ensure we haven't leaked
148
+ # processors somehow.
149
+ raise "BUG: No processors, cannot continue!" if @ready.empty? && @busy.empty?
150
+ raise "No ready processor!?" if @ready.empty?
151
+
152
+ @fetcher.fetch!
153
+ end
154
+
155
+ def stopped?
156
+ @done
157
+ end
158
+
159
+ def procline
160
+ $0 = "creeper #{Creeper::VERSION} [#{@busy.size} of #{@count} busy]#{stopped? ? ' stopping' : ''}"
161
+ after(5) { procline }
162
+ end
163
+ end
164
+ end
@@ -0,0 +1,100 @@
1
+ module Creeper
2
+ # Middleware is code configured to run before/after
3
+ # a message is processed. It is patterned after Rack
4
+ # middleware. Middleware exists for the client side
5
+ # (pushing jobs onto the queue) as well as the server
6
+ # side (when jobs are actually processed).
7
+ #
8
+ # To add middleware for the client:
9
+ #
10
+ # Creeper.configure_client do |config|
11
+ # config.client_middleware do |chain|
12
+ # chain.add MyClientHook
13
+ # end
14
+ # end
15
+ #
16
+ # To modify middleware for the server, just call
17
+ # with another block:
18
+ #
19
+ # Creeper.configure_server do |config|
20
+ # config.server_middleware do |chain|
21
+ # chain.add MyServerHook
22
+ # chain.remove ActiveRecord
23
+ # end
24
+ # end
25
+ #
26
+ # This is an example of a minimal server middleware:
27
+ #
28
+ # class MyServerHook
29
+ # def call(worker_instance, msg, queue)
30
+ # puts "Before work"
31
+ # yield
32
+ # puts "After work"
33
+ # end
34
+ # end
35
+ #
36
+ # This is an example of a minimal client middleware:
37
+ #
38
+ # class MyClientHook
39
+ # def call(worker_class, msg, queue)
40
+ # puts "Before push"
41
+ # yield
42
+ # puts "After push"
43
+ # end
44
+ # end
45
+ #
46
+ module Middleware
47
+ class Chain
48
+ attr_reader :entries
49
+
50
+ def initialize
51
+ @entries = []
52
+ yield self if block_given?
53
+ end
54
+
55
+ def remove(klass)
56
+ entries.delete_if { |entry| entry.klass == klass }
57
+ end
58
+
59
+ def add(klass, *args)
60
+ entries << Entry.new(klass, *args) unless exists?(klass)
61
+ end
62
+
63
+ def exists?(klass)
64
+ entries.any? { |entry| entry.klass == klass }
65
+ end
66
+
67
+ def retrieve
68
+ entries.map(&:make_new)
69
+ end
70
+
71
+ def clear
72
+ entries.clear
73
+ end
74
+
75
+ def invoke(*args, &final_action)
76
+ chain = retrieve.dup
77
+ traverse_chain = lambda do
78
+ if chain.empty?
79
+ final_action.call
80
+ else
81
+ chain.shift.call(*args, &traverse_chain)
82
+ end
83
+ end
84
+ traverse_chain.call
85
+ end
86
+ end
87
+
88
+ class Entry
89
+ attr_reader :klass
90
+ def initialize(klass, *args)
91
+ @klass = klass
92
+ @args = args
93
+ end
94
+
95
+ def make_new
96
+ @klass.new(*@args)
97
+ end
98
+ end
99
+ end
100
+ end