collective 0.2.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 (61) hide show
  1. data/.autotest +13 -0
  2. data/.gitignore +5 -0
  3. data/.rspec +1 -0
  4. data/Gemfile +16 -0
  5. data/Guardfile +6 -0
  6. data/README +7 -0
  7. data/Rakefile +11 -0
  8. data/bin/collective +37 -0
  9. data/collective.gemspec +23 -0
  10. data/demo/demo +36 -0
  11. data/demo/demo.rb +30 -0
  12. data/demo/demo3 +36 -0
  13. data/demo/job1.rb +31 -0
  14. data/demo/job2.rb +42 -0
  15. data/demo/job3.rb +44 -0
  16. data/demo/populate.rb +22 -0
  17. data/lib/collective.rb +52 -0
  18. data/lib/collective/checker.rb +51 -0
  19. data/lib/collective/configuration.rb +219 -0
  20. data/lib/collective/idler.rb +81 -0
  21. data/lib/collective/key.rb +48 -0
  22. data/lib/collective/lifecycle_observer.rb +25 -0
  23. data/lib/collective/log.rb +29 -0
  24. data/lib/collective/messager.rb +218 -0
  25. data/lib/collective/mocks/storage.rb +108 -0
  26. data/lib/collective/monitor.rb +58 -0
  27. data/lib/collective/policy.rb +60 -0
  28. data/lib/collective/pool.rb +180 -0
  29. data/lib/collective/redis/storage.rb +142 -0
  30. data/lib/collective/registry.rb +123 -0
  31. data/lib/collective/squiggly.rb +20 -0
  32. data/lib/collective/utilities/airbrake_observer.rb +26 -0
  33. data/lib/collective/utilities/hoptoad_observer.rb +26 -0
  34. data/lib/collective/utilities/log_observer.rb +40 -0
  35. data/lib/collective/utilities/observeable.rb +18 -0
  36. data/lib/collective/utilities/observer_base.rb +59 -0
  37. data/lib/collective/utilities/process.rb +82 -0
  38. data/lib/collective/utilities/signal_hook.rb +47 -0
  39. data/lib/collective/utilities/storage_base.rb +41 -0
  40. data/lib/collective/version.rb +3 -0
  41. data/lib/collective/worker.rb +161 -0
  42. data/spec/checker_spec.rb +20 -0
  43. data/spec/configuration_spec.rb +24 -0
  44. data/spec/helper.rb +33 -0
  45. data/spec/idler_spec.rb +58 -0
  46. data/spec/key_spec.rb +41 -0
  47. data/spec/messager_spec.rb +131 -0
  48. data/spec/mocks/storage_spec.rb +108 -0
  49. data/spec/monitor_spec.rb +15 -0
  50. data/spec/policy_spec.rb +43 -0
  51. data/spec/pool_spec.rb +119 -0
  52. data/spec/redis/storage_spec.rb +133 -0
  53. data/spec/registry_spec.rb +52 -0
  54. data/spec/support/jobs.rb +58 -0
  55. data/spec/support/redis.rb +22 -0
  56. data/spec/support/timing.rb +32 -0
  57. data/spec/utilities/observer_base_spec.rb +50 -0
  58. data/spec/utilities/process_spec.rb +17 -0
  59. data/spec/worker_spec.rb +121 -0
  60. data/unused/times.rb +45 -0
  61. metadata +148 -0
@@ -0,0 +1,219 @@
1
+ # -*- encoding: utf-8 -*-
2
+
3
+ require "optparse"
4
+ require "ruby-debug"
5
+
6
+ =begin
7
+
8
+ Evaluate a ruby configuration file in the context of a Collective Configuration instance.
9
+ Offers a DSL to build the jobs as well as setting before/after-fork hooks.
10
+
11
+ Collective configuration:
12
+
13
+ env()
14
+ set_env(ENV)
15
+ --env=ENV
16
+ Sets the environment.
17
+ Used in pid file and log file naming.
18
+ Defaults to RAILS_ENV || RACK_ENV || "test".
19
+
20
+ chdir(DIR)
21
+ --chdir=DIR
22
+ Changes the working directory. Creates it if necessary.
23
+ Takes effect immediately.
24
+ Can only be set once. Has no effect if specified more than once.
25
+ Defaults to /tmp/$NAME
26
+
27
+ name()
28
+ name=(NAME)
29
+ --name=NAME
30
+ Sets the name of the process.
31
+ Defaults to the base name of the configuration file.
32
+ Used in pid file and log file naming.
33
+
34
+ --path=PATH
35
+ add_path(PATH)
36
+ Adds a path to the Ruby load path.
37
+ Can be used multiple times.
38
+
39
+ --require=LIB
40
+ Requires a library or Ruby gem.
41
+ Can be used multiple times.
42
+
43
+ =end
44
+
45
+ class Collective::Configuration
46
+
47
+ def self.parse( argv = ARGV )
48
+ us = new
49
+
50
+ optparse = OptionParser.new do |opts|
51
+ opts.banner = "Usage: #{__FILE__} [options]* configuration_file_rb"
52
+ opts.on( "-c", "--chdir DIR", "Change working directory." ) { |d| us.chdir(d) }
53
+ opts.on( "-e", "--env ENV", "Set environment (env).") { |e| us.set_env(e) }
54
+ opts.on( "-h", "--help", "Display this usage summary." ) { puts opts; exit }
55
+ opts.on( "-n", "--name NAME", "Set daemon's name.") { |n| us.set_name(n) }
56
+ opts.on( "-p", "--path PATH", "Add to load path.") { |d| us.add_path(d) }
57
+ opts.on( "-r", "--require LIB", "Require a library.") { |l| us.require_lib(l) }
58
+ opts.on( "-s", "--script DSL", "Include DSL script.") { |s| us.load_script(s) }
59
+ opts.on( "-v", "--verbose", "Print stuff out.") { |s| us.verbose += 1 }
60
+ opts.on( "--dry-run", "Don't launch the daemon.") { us.dry_run = true }
61
+ end.parse!(argv)
62
+
63
+ while argv.any? && File.exists?(argv.first) do
64
+ us.load_file( argv.shift )
65
+ end
66
+
67
+ us.args = argv
68
+ us.finalize
69
+ end
70
+
71
+ include Collective::Log
72
+
73
+ attr :env
74
+ attr :root
75
+ attr :name, true
76
+ attr :verbose, true
77
+ attr :dry_run, true
78
+ attr :args, true
79
+ attr :after_forks
80
+
81
+ attr :defaults
82
+ attr :jobs
83
+
84
+ def initialize( filename = nil )
85
+ @verbose = 0
86
+ @dry_run = false
87
+ @defaults = {}
88
+ @jobs = {}
89
+ load_file(filename) if filename
90
+ end
91
+
92
+ def load_script(string)
93
+ log "Loading #{string}" if verbose >= 2
94
+ instance_eval(string)
95
+ end
96
+
97
+ def load_file(filename)
98
+ log "Loading #{filename}" if verbose >= 1
99
+ instance_eval(File.read(filename),filename)
100
+ if ! name then
101
+ n = File.basename(filename).sub(/\.[^.]*$/,'')
102
+ @name = n if n.size > 0
103
+ end
104
+ end
105
+
106
+ def finalize()
107
+ if ! env then
108
+ @env = ( ENV["RAILS_ENV"] || ENV["RACK_ENV"] || "test" )
109
+ log "Defaulting env to #{env}" if verbose >= 1
110
+ end
111
+ if ! name then
112
+ @name = "collective"
113
+ log "Defaulting name to #{name}" if verbose >= 1
114
+ end
115
+ if ! @root then
116
+ chdir(default_root)
117
+ end
118
+ log inspect if verbose >= 2
119
+ freeze
120
+ self
121
+ end
122
+
123
+ def options_for_daemon_spawn
124
+ mkdirp root, "#{root}/log", "#{root}/tmp", "#{root}/tmp/pids" if ! dry_run
125
+ return {
126
+ working_dir: root,
127
+ log_file: "#{root}/log/#{name}_#{env}.log",
128
+ pid_file: "#{root}/tmp/pids/#{name}_#{env}.pid",
129
+ sync_log: local?
130
+ }
131
+ end
132
+
133
+ def args_for_daemon_spawn
134
+ args + [self]
135
+ end
136
+
137
+ # ----------------------------------------------------------------------------
138
+ # DSL
139
+ # ----------------------------------------------------------------------------
140
+
141
+ def set_env(env)
142
+ @env = env
143
+ end
144
+
145
+ def set_name(name)
146
+ @name = name
147
+ end
148
+
149
+ # takes effect immediately
150
+ def chdir(path)
151
+ if ! @root then
152
+ p = File.expand_path(path)
153
+ mkdirp(p) if ! dry_run
154
+ Dir.chdir(p)
155
+ log "Changed working directory (root) to #{p}" if verbose >= 1
156
+ @root = p
157
+ else
158
+ log "Warning: working directory already set to #{root}; not changing to #{path}"
159
+ end
160
+ end
161
+
162
+ # takes effect immediately
163
+ def add_path(path)
164
+ p = File.expand_path(path)
165
+ log "Added #{p} to load path" if verbose >= 2
166
+ $:.push(p) unless $:.member?(p)
167
+ end
168
+
169
+ # convenience for -r on the command line
170
+ def require_lib(r)
171
+ require(r)
172
+ log "Required #{r}" if verbose >= 2
173
+ end
174
+
175
+ def set_default(key,value)
176
+ # values which are arrays get merged, but nil will overwrite
177
+ case value
178
+ when Array
179
+ @defaults[key] = (@defaults[key] || []) + value
180
+ else
181
+ @defaults[key] = value
182
+ end
183
+ end
184
+
185
+ def set_defaults(options)
186
+ options.each { |k,v| set_default(k,v) }
187
+ end
188
+
189
+ def add_pool(name,options)
190
+ options = defaults.merge(options)
191
+ jobs[name] = options
192
+ log "Added pool for #{name}" if verbose == 1
193
+ log "Added pool for #{name} with #{options}" if verbose >= 2
194
+ end
195
+
196
+ def after_fork(&block)
197
+ @after_forks ||= []
198
+ @after_forks << block
199
+ end
200
+
201
+ # ----------------------------------------------------------------------------
202
+ private
203
+ # ----------------------------------------------------------------------------
204
+
205
+ LOCAL_ENVS = [ "development", "test" ]
206
+
207
+ def local?
208
+ LOCAL_ENVS.member?(env)
209
+ end
210
+
211
+ def default_root
212
+ local? ? "." : "/tmp/#{name}"
213
+ end
214
+
215
+ def mkdirp(*ps)
216
+ ps.each { |p| Dir.mkdir(p) if ! Dir.exists?(p) }
217
+ end
218
+
219
+ end
@@ -0,0 +1,81 @@
1
+ # -*- encoding: utf-8 -*-
2
+
3
+ =begin
4
+
5
+ Idler wraps some other callable (a proc or object which responds to #call)
6
+ The callable should return a falsy value when it did nothing,
7
+ or a truthy value when it did something.
8
+ The idler will sleep when there is nothing to do.
9
+
10
+ =end
11
+
12
+ class Collective::Idler
13
+
14
+ MIN_SLEEP = 0.125
15
+ MAX_SLEEP = 1.0
16
+
17
+ attr :sleep
18
+
19
+ def initialize( callable = nil, options = {}, &callable_block )
20
+ @callable = callable || callable_block
21
+ raise unless @callable.respond_to?(:call)
22
+
23
+ @max_sleep = options[:max_sleep] || MAX_SLEEP
24
+ raise if @max_sleep <= 0
25
+
26
+ @min_sleep = options[:min_sleep] || MIN_SLEEP
27
+ raise if @min_sleep <= 0
28
+ raise if @max_sleep < @min_sleep
29
+
30
+ @sleep = nil
31
+ end
32
+
33
+ def call( *args, &block )
34
+
35
+ result = call_with_wakefulness( @callable, *args, &block )
36
+
37
+ if result then
38
+ wake
39
+ else
40
+ sleep_more
41
+ end
42
+
43
+ return result
44
+ end
45
+
46
+ def call_with_wakefulness( callable, *args, &block )
47
+ begin
48
+ callable.call(*args,&block)
49
+ rescue Exception # when errors occur,
50
+ @sleep = @min_sleep # reduce sleeping almost all the way (but not to 0)
51
+ raise # do not consume any exceptions
52
+ end
53
+ end
54
+
55
+ def sleep_more
56
+ if @sleep then
57
+ @sleep = [ @sleep * 2, @max_sleep ].min
58
+ else
59
+ @sleep = @min_sleep
60
+ end
61
+ Kernel.sleep(@sleep) if @sleep # Interrupt will propogate through sleep().
62
+ end
63
+
64
+ def wake
65
+ @sleep = nil
66
+ end
67
+
68
+ module Utilities
69
+ # execute test repeatedly, until timeout, or until test returns true
70
+ def wait_until( timeout = 1, &test )
71
+ tester = Collective::Idler.new(test)
72
+ finish = Time.now.to_f + timeout
73
+ loop do
74
+ break if tester.call
75
+ break if finish < Time.now.to_f
76
+ end
77
+ end
78
+ end
79
+ extend Utilities
80
+
81
+ end # Collective::Idler
@@ -0,0 +1,48 @@
1
+ # -*- encoding: utf-8 -*-
2
+
3
+ =begin
4
+
5
+ A key uniquely identifies a worker.
6
+
7
+ =end
8
+
9
+ class Collective::Key
10
+
11
+ attr :name
12
+ attr :pid
13
+ attr :host
14
+
15
+ def initialize( name, pid, host = Collective::Key.local_host )
16
+ @name = name
17
+ @pid = pid.to_i
18
+ @host = host
19
+ end
20
+
21
+ def ==(other)
22
+ self.equal?(other) ||
23
+ ( name == other.name && pid == other.pid && host == other.host )
24
+ end
25
+
26
+ # e.g. processor-1234@foo.example.com
27
+ def to_s
28
+ "%s-%i@%s" % [ name, pid, host ]
29
+ end
30
+
31
+ def self.parse(key_string)
32
+ key_string =~ /^(.*)-([0-9]+)@([^@]+)$/ or raise MalformedKey.new(key_string)
33
+ new( $1, $2, $3 )
34
+ end
35
+
36
+ # ----------------------------------------------------------------------------
37
+ # Utilities
38
+ # ----------------------------------------------------------------------------
39
+
40
+ # @returns something like foo.example.com
41
+ def self.local_host
42
+ @local_host ||= `hostname`.chomp.strip
43
+ end
44
+
45
+ class MalformedKey < StandardError
46
+ end
47
+
48
+ end
@@ -0,0 +1,25 @@
1
+ # -*- encoding: utf-8 -*-
2
+
3
+ class Collective::LifecycleObserver < Collective::Utilities::ObserverBase
4
+
5
+ attr :key
6
+ attr :registry
7
+
8
+ def initialize( key, registry )
9
+ @key = key
10
+ @registry = registry
11
+ end
12
+
13
+ def worker_started
14
+ registry.register( key )
15
+ end
16
+
17
+ def worker_heartbeat( upcount = 0 )
18
+ registry.update( key )
19
+ end
20
+
21
+ def worker_stopped
22
+ registry.unregister( key )
23
+ end
24
+
25
+ end # Collective::LifecycleObserver
@@ -0,0 +1,29 @@
1
+ module Collective::Log
2
+
3
+ def log( *args )
4
+ logger.print(format_for_logging(*args))
5
+ logger.flush
6
+ end
7
+
8
+ def format_for_logging( *args )
9
+ message = [
10
+ #(Time.now.strftime "%Y%m%d%H%M%S"),
11
+ Time.now.to_i,
12
+ " [",
13
+ Process.pid,
14
+ (Thread.current[:name] || Thread.current.object_id unless Thread.current == Thread.main),
15
+ "] ",
16
+ args.join(", "),
17
+ "\n"
18
+ ].compact.join
19
+ end
20
+
21
+ def logger
22
+ @logger ||= STDOUT
23
+ end
24
+
25
+ def logger=( other )
26
+ @logger = other
27
+ end
28
+
29
+ end # Collective::Log
@@ -0,0 +1,218 @@
1
+ # -*- encoding: utf-8 -*-
2
+
3
+ require "digest/md5"
4
+ require "json"
5
+
6
+ =begin
7
+
8
+ Messager is used to send messages between processes, and receive responses.
9
+ Messager messages are asynchronous and not ordered.
10
+
11
+ =end
12
+
13
+ class Collective::Messager
14
+
15
+ attr :callbacks
16
+ attr :storage
17
+ attr :my_address
18
+ attr :to_address
19
+
20
+ # @param options[:to_address] is optional
21
+ # @param options[:my_address] is required
22
+ def initialize( storage, options = {} )
23
+ @callbacks = {}
24
+ @storage = storage
25
+ @to_address = options[:to_address]
26
+ @my_address = options[:my_address] or raise "must specify my address"
27
+ # type checking
28
+ storage.get("test")
29
+ end
30
+
31
+ # write to another queue
32
+ # @param options[:to] is required if :to_address was not given
33
+ # @returns an id
34
+ def send( body, options = {} )
35
+ to = options[:to] || to_address or raise "must specify to address"
36
+ from = options[:from] || my_address or raise "must specify from address"
37
+ now = options[:at] || Time.now
38
+ message = Message.new( options.merge( to: to, from: my_address, at: now, body: body ) )
39
+ blob = message.to_json
40
+
41
+ storage.queue_add( queue_name(to), blob, now.to_i )
42
+ message.id
43
+ end
44
+
45
+ # register a handler for a given id
46
+ # the handler is removed when it is called
47
+ def expect( match, &callback )
48
+ @callbacks[match] = callback
49
+ self
50
+ end
51
+
52
+ # sends a new message to the original message source and with reply_to_id from the original message
53
+ # @param options[:to] must be the original message
54
+ # @e.g. reply "Ok", to: question
55
+ def reply( body, options )
56
+ original = options[:to] or raise "must reply to: message"
57
+ send( body, to: original.from, reply_to_id: original.id )
58
+ end
59
+
60
+ # @param reply_block takes (body, headers)
61
+ def expect_reply( src_id, &reply_block )
62
+ raise
63
+ end
64
+
65
+ # read from my queue
66
+ # check to see if there are any messages, and dispatch them
67
+ # @returns true if processed a message, false otherwise
68
+ def receive()
69
+ now = Time.now.to_i
70
+ json = storage.queue_pop( queue_name, now )
71
+ if json then
72
+ message = Message.parse(json)
73
+ callback = find_callback( message )
74
+ callback.call( message )
75
+ true
76
+ else
77
+ false
78
+ end
79
+ end
80
+
81
+ # ----------------------------------------------------------------------------
82
+ # Message contains the body and critical headers for Messager
83
+ # ----------------------------------------------------------------------------
84
+
85
+ class Message
86
+
87
+ attr :to # destination host
88
+ attr :from # source host
89
+ attr :at # timestamp of message generation
90
+ attr :body # JSON-compatible
91
+ attr :id # autogenerated if not supplied
92
+ attr :reply_to_id # optional
93
+
94
+ def initialize( data )
95
+ data = ::Collective::Messager.symbolize(data)
96
+ @to = data[:to] or raise "must specify to address"
97
+ @from = data[:from] or raise "must specify from address"
98
+ @at = (data[:at] || Time.now).to_f
99
+ @body = data[:body]
100
+ @id = data[:id] || Digest::MD5.hexdigest([from,at,body].join)
101
+ @reply_to_id = data[:reply_to_id]
102
+ end
103
+
104
+ def to_hash
105
+ blob = { to: to, from: from, at: at, body: body, id: id }
106
+ blob[:reply_to_id] = reply_to_id if reply_to_id
107
+ blob
108
+ end
109
+
110
+ def to_json
111
+ to_hash.to_json
112
+ end
113
+
114
+ def to_s
115
+ to_json
116
+ end
117
+
118
+ def self.parse( json )
119
+ new( JSON.parse(json) )
120
+ end
121
+
122
+ end # Message
123
+
124
+ # ----------------------------------------------------------------------------
125
+ # Utilities
126
+ # ----------------------------------------------------------------------------
127
+
128
+ def self.stringify(map)
129
+ Hash[ map.map { |k,v| [ k.to_s, v ] } ]
130
+ end
131
+
132
+ def self.symbolize(map)
133
+ Hash[ map.map { |k,v| [ k.to_sym, v ] } ]
134
+ end
135
+
136
+ # ----------------------------------------------------------------------------
137
+ protected
138
+ # ----------------------------------------------------------------------------
139
+
140
+ def queue_name( other_address = nil )
141
+ if other_address then
142
+ "messages:#{other_address}"
143
+ else
144
+ @queue_name ||= "messages:#{my_address}"
145
+ end
146
+ end
147
+
148
+ # ----------------------------------------------------------------------------
149
+ # Match
150
+ # ----------------------------------------------------------------------------
151
+
152
+ class NoMatch < Exception
153
+ end
154
+
155
+ class Counter
156
+ def match
157
+ @value ||= 0
158
+ @value += 1
159
+ end
160
+ def fail
161
+ @value = nil
162
+ raise NoMatch
163
+ end
164
+ def value
165
+ raise NoMatch if !@value
166
+ @value
167
+ end
168
+ end
169
+
170
+ def find_callback( message )
171
+ best_result = nil
172
+ best_score = nil
173
+ callbacks.each do |match,callback|
174
+ begin
175
+ counter = Counter.new
176
+ compare_match( message, match, counter )
177
+ if !best_score || counter.value > best_score then
178
+ best_score = counter.value
179
+ best_result = callback
180
+ end
181
+ rescue NoMatch
182
+ # next
183
+ end
184
+ end
185
+ return best_result if best_result
186
+ debugger
187
+ raise NoMatch
188
+ end
189
+
190
+ def compare_match( message, match, counter )
191
+ case match
192
+ when String, Regexp
193
+ compare( message.body, match, counter )
194
+ when Hash
195
+ compare( message.to_hash, match, counter )
196
+ end
197
+ end
198
+
199
+ def compare( item, match, counter )
200
+ case match
201
+ when Numeric
202
+ return item.kind_of?(Numeric) && item == match ? counter.match : counter.fail
203
+ when String
204
+ return item.kind_of?(String) && item == match ? counter.match : counter.fail
205
+ when Regexp
206
+ return item.kind_of?(String) && item =~ match ? counter.match : counter.fail
207
+ when Hash
208
+ counter.fail if ! item.kind_of?(Hash)
209
+ match.each do |k,v|
210
+ counter.fail if ! item.has_key?(k)
211
+ compare( item[k], match[k], counter )
212
+ end
213
+ else
214
+ raise "Can not compare using #{match.inspect}"
215
+ end
216
+ end
217
+
218
+ end