candygram 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
data/.document ADDED
@@ -0,0 +1,5 @@
1
+ README.rdoc
2
+ lib/**/*.rb
3
+ bin/*
4
+ features/**/*.feature
5
+ LICENSE
data/.gitignore ADDED
@@ -0,0 +1,21 @@
1
+ ## MAC OS
2
+ .DS_Store
3
+
4
+ ## TEXTMATE
5
+ *.tmproj
6
+ tmtags
7
+
8
+ ## EMACS
9
+ *~
10
+ \#*
11
+ .\#*
12
+
13
+ ## VIM
14
+ *.swp
15
+
16
+ ## PROJECT::GENERAL
17
+ coverage
18
+ rdoc
19
+ pkg
20
+
21
+ ## PROJECT::SPECIFIC
data/LICENSE ADDED
@@ -0,0 +1,20 @@
1
+ Copyright (c) 2009 Stephen Eley
2
+
3
+ Permission is hereby granted, free of charge, to any person obtaining
4
+ a copy of this software and associated documentation files (the
5
+ "Software"), to deal in the Software without restriction, including
6
+ without limitation the rights to use, copy, modify, merge, publish,
7
+ distribute, sublicense, and/or sell copies of the Software, and to
8
+ permit persons to whom the Software is furnished to do so, subject to
9
+ the following conditions:
10
+
11
+ The above copyright notice and this permission notice shall be
12
+ included in all copies or substantial portions of the Software.
13
+
14
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
15
+ EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
16
+ MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
17
+ NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
18
+ LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
19
+ OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
20
+ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
data/README.markdown ADDED
@@ -0,0 +1,103 @@
1
+ # Candygram
2
+
3
+ __"Candygram for Mongo!"__ — _Blazing Saddles_
4
+
5
+ Candygram is a job queueing system for the MongoDB database. It is loosely based on the **delayed_job** gem for ActiveRecord and the **Resque** gem for Redis, with the following interesting distinctions:
6
+
7
+ * Delayed running can be added to any class with `include Candygram::Delivery`.
8
+ * Objects with the Delivery module included get magic _*\_later_ variants on every instance method to enqueue the method call. (_*\_in_ and _*\_at_ variants coming soon to specify a time of execution.)
9
+ * Object states and method arguments are serialized as BSON to take best advantage of Mongo's efficiency.
10
+ * A centralized dispatcher forks runners to handle each job, with maximum limits defined per class.
11
+ * The job queue is a capped collection; because jobs are never deleted, recent history can be analyzed and failure states reported.
12
+
13
+ ## Installation
14
+
15
+ Come on, you've done this before:
16
+
17
+ $ sudo gem install candygram
18
+
19
+ Candygram requires the **mongo** gem, and you'll probably be much happier if you install the **mongo\_ext** gem as well. The author uses only Ruby 1.9, but it _should_ work in Ruby 1.8. If it doesn't, please report a bug in Github's issue tracking system.
20
+
21
+ ## Configuration
22
+
23
+ Both the Delivery and the Dispatcher modules take some configuration
24
+ parameters to connect to the proper Mongo collection:
25
+
26
+ # Makes a default connection to 'localhost' if you don't override it
27
+ Candygram.connection = Mongo::Connection.new(_params_)
28
+
29
+ # Creates a default 'candygram' database if you don't override it
30
+ Candygram.database = 'my_database'
31
+
32
+ # Creates a default 'candygram_queue' collection if you don't -- you get the picture.
33
+ Candygram.queue = Candygram.database.collection('my_queue')
34
+ # Or, to make a brand new queue with the proper indexes:
35
+ Candygram.create_queue('my_queue', 1048576) # 1MB capped collection
36
+
37
+ ## Creating Jobs
38
+
39
+ You can set up any Ruby class to delay method executions by including the Delivery module:
40
+
41
+ require 'candygram'
42
+
43
+ class Explosive
44
+ include Candygram::Delivery
45
+ CANDYGRAM_MAX = 5 # Optional; limits simultaneous runners per dispatcher
46
+
47
+ def kaboom(planet)
48
+ "A #{planet}-shattering kaboom!"
49
+ end
50
+ end
51
+
52
+ You can continue to use the class as you normally would, of course. If you want to queue a method to run later, just add _\_later_ to the method name:
53
+
54
+ e = Explosive.new
55
+ e.kaboom_later('Mars')
56
+
57
+ This will serialize the object _e_ (including any instance variables) into a Mongo document, along with the method name and the argument. The Candygram dispatcher will find it the next time it looks for jobs to run. It will fork a separate process to unpack the object, call the `kaboom` method, and save the return value in the job document for later reference.
58
+
59
+ ## Dispatching
60
+
61
+ Nice Rake tasks and Rails generators and such are still pending. In the meantime, you can easily make your own dispatch script and call it with Rake or cron or trained beagle or what-have-you:
62
+
63
+ require 'candygram'
64
+ require 'my_environment' # Whatever else you need to make your classes visible
65
+
66
+ # Config parameters can be passed as a hash to `new` or by setting attributes.
67
+ d = Candygram::Dispatch.new
68
+ d.frequency = 10 # Check for jobs every 10 seconds (default 5)
69
+ d.max_per_class = 20 # Simultaneous runners per class (default 10)
70
+ d.quiet = true # Don't announce work on standard output (default false)
71
+
72
+ # This is the central method that loops and checks for work...
73
+ d.run
74
+
75
+ # You can kill the loop with CTRL-C or a 'kill' of course, or by
76
+ # calling 'd.finish' (but to make that work, you'd need to access
77
+ # it from a separate thread).
78
+
79
+ The dispatcher forks a separate process for each job in the queue, constrained by the per-class limits set by the CANDYGRAM_MAX constant in the class or by the **max\_per\_class** configuration variable. It keeps track of its child PIDs and will wait for them to finish if shut down gracefully. Jobs are locked so that dispatchers on multiple servers can be run against the same queue.
80
+
81
+ Job runners push status information onto the document to indicate time of running and completion. Future enhancements will likely include some reporting on this data, detection and rerunning on exception or timeout, and (possibly) optimization based on average run times.
82
+
83
+ ## Limitations
84
+
85
+ * You cannot pass blocks or procs to delayed methods. There's just no robust way to capture and serialize the things.
86
+ * Objects with singleton methods or module extensions outside their class will lose them.
87
+ * Object classes must accept the `.new` method without any parameters. It's probably a good idea not to pass objects that have complex initialization.
88
+ * In general, objects that maintain state in any way cleverer than their instance variables will become stupid and probably unpredictable.
89
+ * Circular object graphs will probably cause explosions.
90
+ * Because it uses `fork`, this won't run on Windows. (A limitation which bothers me not one iota.)
91
+
92
+ ## Big Important Disclaimer
93
+
94
+ This is still very very alpha software. I needed this for a couple of my own projects, but I'm pushing it into the wild _before_ proving it on those projects; if I don't, I'll probably lose energy and forget to do all the gem bundling and such. It's not nearly as robust yet as I hope to make it: there's a lot more I want to do for handling error cases and making the dispatcher easy to keep running.
95
+
96
+ I welcome your suggestions and bug reports using the Github issue tracker. I'm happy to take pull requests as well. If you use this for any interesting projects, please drop me a line and let me know about it -- eventually I may compile a list.
97
+
98
+ Have Fun.
99
+
100
+
101
+ ## Copyright
102
+
103
+ Copyright (c) 2009 Stephen Eley. See LICENSE for details.
data/Rakefile ADDED
@@ -0,0 +1,47 @@
1
+ require 'rubygems'
2
+ require 'rake'
3
+
4
+ begin
5
+ require 'jeweler'
6
+ Jeweler::Tasks.new do |gem|
7
+ gem.name = "candygram"
8
+ gem.summary = %Q{Delayed job queueing for MongoMapper}
9
+ gem.description = %Q{Candygram provides a job queue for the MongoDB document-oriented database, inspired by delayed_job and Resque. It uses the MongoMapper ORM and allows you to queue method calls for execution ASAP or at any time in the future.}
10
+ gem.email = "sfeley@gmail.com"
11
+ gem.homepage = "http://github.com/SFEley/candygram"
12
+ gem.authors = ["Stephen Eley"]
13
+ gem.add_dependency "mongo", ">= 0.18"
14
+ gem.add_development_dependency "rspec", ">= 1.2.9"
15
+ gem.add_development_dependency "yard", ">= 0.5.2"
16
+ gem.add_development_dependency "mocha", ">= 0.9.7"
17
+ # gem is a Gem::Specification... see http://www.rubygems.org/read/chapter/20 for additional settings
18
+ end
19
+ Jeweler::GemcutterTasks.new
20
+ rescue LoadError
21
+ puts "Jeweler (or a dependency) not available. Install it with: gem install jeweler"
22
+ end
23
+
24
+ require 'spec/rake/spectask'
25
+ Spec::Rake::SpecTask.new(:spec) do |spec|
26
+ spec.libs << 'lib' << 'spec'
27
+ spec.spec_files = FileList['spec/**/*_spec.rb']
28
+ end
29
+
30
+ Spec::Rake::SpecTask.new(:rcov) do |spec|
31
+ spec.libs << 'lib' << 'spec'
32
+ spec.pattern = 'spec/**/*_spec.rb'
33
+ spec.rcov = true
34
+ end
35
+
36
+ task :spec => :check_dependencies
37
+
38
+ task :default => :spec
39
+
40
+ begin
41
+ require 'yard'
42
+ YARD::Rake::YardocTask.new
43
+ rescue LoadError
44
+ task :yardoc do
45
+ abort "YARD is not available. In order to run yardoc, you must: sudo gem install yard"
46
+ end
47
+ end
data/VERSION ADDED
@@ -0,0 +1 @@
1
+ 0.1.0
data/lib/candygram.rb ADDED
@@ -0,0 +1,10 @@
1
+ module Candygram
2
+ DEFAULT_DATABASE = 'candygram'
3
+ DEFAULT_QUEUE = 'candygram_queue'
4
+ DEFAULT_QUEUE_SIZE = 10 * 1024 * 1024 # 10 MB
5
+ end
6
+
7
+ require 'mongo'
8
+ require 'candygram/connection'
9
+ require 'candygram/delivery'
10
+ require 'candygram/dispatch'
@@ -0,0 +1,64 @@
1
+ module Candygram
2
+
3
+ # The MongoDB connection object. Creates a default connection to localhost if not explicitly told otherwise.
4
+ def self.connection
5
+ @connection ||= Mongo::Connection.new
6
+ end
7
+
8
+ # Accepts a new MongoDB connection, closing any current ones
9
+ def self.connection=(val)
10
+ @connection.close if @connection
11
+ @connection = val
12
+ end
13
+
14
+ # The Mongo database object. If you just want the name, use #database instead.
15
+ def self.db
16
+ @db ||= Mongo::DB.new(DEFAULT_DATABASE, connection)
17
+ end
18
+
19
+ # Sets the Mongo database object. Unless you want to pass specific options or bypass the
20
+ # Candygram connection object completely, it's probably easier to use the #database= method
21
+ # and give it the name.
22
+ def self.db=(val)
23
+ @db = val
24
+ end
25
+
26
+ # The name of the Mongo database object.
27
+ def self.database
28
+ db.name
29
+ end
30
+
31
+ # Creates a Mongo database object with the given name and default options.
32
+ def self.database=(val)
33
+ self.db = Mongo::DB.new(val, connection)
34
+ end
35
+
36
+ # The delivery queue collection. If not set, creates a capped collection with a default
37
+ # name of 'candygram_queue' and a default cap size of 100MB.
38
+ def self.queue
39
+ @queue or begin
40
+ if db.collection_names.include?(DEFAULT_QUEUE)
41
+ @queue = db[DEFAULT_QUEUE]
42
+ else
43
+ @queue = create_queue
44
+ end
45
+ end
46
+ end
47
+
48
+ # Sets the delivery queue to an existing collection. Assumes you know what you're doing
49
+ # and have made all the proper indexes and such. If not, use the #create_queue method instead.
50
+ def self.queue=(val)
51
+ @queue = val
52
+ end
53
+
54
+ # Creates a new capped collection with the given name and cap size, and sets the indexes needed
55
+ # for efficient Candygram delivery.
56
+ def self.create_queue(name=DEFAULT_QUEUE, size=DEFAULT_QUEUE_SIZE)
57
+ @queue = db.create_collection(name, :capped => true, :size => size)
58
+ # Make indexes here...
59
+ @queue.create_index('deliver_at')
60
+ @queue.create_index('locked')
61
+ @queue.create_index('result')
62
+ @queue
63
+ end
64
+ end
@@ -0,0 +1,37 @@
1
+ require 'candygram/connection'
2
+ require 'candygram/wrapper'
3
+
4
+ module Candygram
5
+ # The special sauce that allows an object to place its method calls into the job queue.
6
+ module Delivery
7
+
8
+ # Lazily adds magic Candygram delivery methods to the class.
9
+ def method_missing(name, *args)
10
+ if name =~ /(\S+)_later$/
11
+ self.class.class_eval <<-LATER
12
+ def #{name}(*args)
13
+ send_candygram("#{$1}", *args)
14
+ end
15
+ LATER
16
+ send(name, *args)
17
+ else
18
+ super
19
+ end
20
+ end
21
+
22
+ protected
23
+ # Does the tricky work of adding the method call to the MongoDB queue. Dollars to donuts that
24
+ # this method name doesn't conflict with anything you're already using...
25
+ def send_candygram(method, *args)
26
+ gram = {
27
+ :class => self.class.name,
28
+ :package => Wrapper.wrap_object(self),
29
+ :method => method,
30
+ :arguments => Wrapper.wrap_array(args),
31
+ :created_at => Time.now.utc,
32
+ :deliver_at => Time.now.utc
33
+ }
34
+ Candygram.queue << gram
35
+ end
36
+ end
37
+ end
@@ -0,0 +1,136 @@
1
+ require 'candygram/connection'
2
+ require 'candygram/wrapper'
3
+ require 'candygram/utility'
4
+
5
+ module Candygram
6
+ # Pays attention to the Candygram work queue and forks runners to do the work as needed.
7
+ class Dispatch
8
+ include Utility
9
+
10
+ attr_accessor :frequency, :quiet, :max_per_class
11
+ attr_reader :runners
12
+
13
+ # Returns a Dispatch object that will keep checking the Candygram work queue and forking runners.
14
+ # @option options [Integer] :frequency How often to check the queue (in seconds). Defaults to 5.
15
+ def initialize(options={})
16
+ @frequency = options.delete(:frequency) || 5
17
+ @max_per_class = options.delete(:max_per_class) || 10
18
+ @quiet = options.delete(:quiet)
19
+ @runners = {}
20
+ @index = {}
21
+ end
22
+
23
+ # Loops over the work queue. You can stop it any time with the #finish method if running in a
24
+ # separate thread.
25
+ def run
26
+ Kernel.trap("CLD") do
27
+ pid = Process.wait
28
+ remove_runner(pid)
29
+ end
30
+
31
+ until @finish
32
+ deliveries = check_queue
33
+ deliveries.each do |del|
34
+ if slot_open?(del) && lock_delivery(del)
35
+ puts "Delivering #{del["class"]}\##{del["method"]} at #{Time.now}" unless quiet
36
+ # Close our connection so that we don't get too many weird copies
37
+ Candygram.connection = nil
38
+ child = fork do
39
+ # We're the runner
40
+ set_status(del, 'running')
41
+ package = Wrapper.unwrap(del["package"])
42
+ args = Wrapper.unwrap(del["arguments"])
43
+ result = package.send(del["method"].to_sym, *args)
44
+ finish_delivery(del, result)
45
+ Candygram.connection = nil
46
+ exit
47
+ end
48
+ # We're the parent
49
+ add_runner del["class"], child
50
+ sleep(0.2) # Give connections time to wrap up
51
+ end
52
+ end
53
+ sleep frequency
54
+ end
55
+ until @index.empty?
56
+ sleep(0.1) # We trust our trap
57
+ end
58
+ end
59
+
60
+ # Tells the #run method to stop running. It's a simple loop condition, not preemptive, so if the
61
+ # dispatcher is sleeping you may have to wait up to _frequency_ seconds before it really ends.
62
+ def finish
63
+ @finish = true
64
+ end
65
+
66
+ # Pushes a new PID onto the 'runners' hash.
67
+ def add_runner(klass, pid)
68
+ @runners[klass] ||= []
69
+ @runners[klass] << pid
70
+ @index[pid] = klass
71
+ end
72
+
73
+ # Takes a PID off of the 'runners' hash.
74
+ def remove_runner(pid)
75
+ klass = @index.delete(pid)
76
+ @runners[klass].delete(pid)
77
+ end
78
+
79
+ protected
80
+ # Looks for new work to do
81
+ def check_queue
82
+ # The interesting options hash for our new work query
83
+ check = {
84
+ :deliver_at => {'$lte' => Time.now.utc},
85
+ :result => {'$exists' => false},
86
+ :locked => {'$exists' => false}
87
+ }
88
+ Candygram.queue.find(check).to_a
89
+ end
90
+
91
+ # Sets the 'locked' value of the job to prevent anyone else from taking it.
92
+ # Returns true on success, false on failure.
93
+ def lock_delivery(del)
94
+ r = Candygram.queue.update({'_id' => del['_id'], 'locked' => {'$exists' => false}}, # query
95
+ {'$set' => {'locked' => dispatch_id}}, # update
96
+ :safe => true)
97
+ update_succeeded?(r)
98
+ rescue Mongo::OperationFailure
99
+ false
100
+ end
101
+
102
+ # Removes the 'locked' value of the job.
103
+ def unlock_delivery(del)
104
+ Candygram.queue.update({'_id' => del['_id']}, {'$set' => {'locked' => nil}})
105
+ end
106
+
107
+ # Logs the result of the method call to the delivery. If the result is nil it will be logged
108
+ # as the word "nil".
109
+ def finish_delivery(del, result)
110
+ Candygram.queue.update({'_id' => del['_id']}, {'$set' => {'result' => (result.nil? ? 'nil' : Wrapper.wrap(result))}})
111
+ unlock_delivery(del)
112
+ set_status(del,'completed')
113
+ end
114
+
115
+ # Checks whether we've hit the maximum number of simultaneous runners for this particular class.
116
+ # Limits are determined by (in order of precedence):
117
+ # 1. The value of a CANDYGRAM_MAX constant set in the class being delivered;
118
+ # 2. The max_per_class attribute of the Dispatch object;
119
+ # 3. The generic default of 10.
120
+ def slot_open?(del)
121
+ klass = Kernel.const_get(del["class"])
122
+ if klass.const_defined?(:CANDYGRAM_MAX)
123
+ limit = klass::CANDYGRAM_MAX
124
+ else
125
+ limit = max_per_class
126
+ end
127
+ (@runners[del["class"]] ? @runners[del["class"]].length < limit : true)
128
+ end
129
+
130
+ private
131
+ # A unique identifier for this dispatcher. Includes local IP address and PID.
132
+ def dispatch_id
133
+ local_ip + '/' + Process.pid.to_s
134
+ end
135
+ end
136
+ end
@@ -0,0 +1,47 @@
1
+ require 'socket'
2
+
3
+ module Candygram
4
+
5
+ # Various methods that may be useful to both delivery and dispatch.
6
+ module Utility
7
+
8
+ # Returns the IP address of this machine relative to the MongoDB server.
9
+ # From: http://coderrr.wordpress.com/2008/05/28/get-your-local-ip-address/
10
+ def local_ip
11
+ @local_ip or begin
12
+ orig, Socket.do_not_reverse_lookup = Socket.do_not_reverse_lookup, true # turn off reverse DNS resolution temporarily
13
+
14
+ @local_ip = UDPSocket.open do |s|
15
+ s.connect Candygram.connection.host, 1
16
+ s.addr.last
17
+ end
18
+ ensure
19
+ Socket.do_not_reverse_lookup = orig
20
+ end
21
+ end
22
+
23
+ # Parses Mongo's somewhat inscrutable results from Collection.update when :safe => true
24
+ # and returns whether or not the value was updated. The basic output looks like this:
25
+ # [[{"err"=>nil, "updatedExisting"=>false, "n"=>0, "ok"=>1.0}], 1, 0]
26
+ def update_succeeded?(result)
27
+ result[0][0]["updatedExisting"]
28
+ end
29
+
30
+ # Pushes a new status message onto the job. Includes an identifier for this process and a timestamp.
31
+ def set_status(del, state)
32
+ Candygram.queue.update({'_id' => del['_id']}, {'$push' => {'status' => status_hash(state)}})
33
+ end
34
+
35
+ protected
36
+ # Returns an embedded document suitable for pushing onto a delivery's status array.
37
+ def status_hash(state)
38
+ message = {
39
+ 'ip' => local_ip,
40
+ 'pid' => Process.pid,
41
+ 'state' => state,
42
+ 'at' => Time.now.utc
43
+ }
44
+ end
45
+ end
46
+ end
47
+
@@ -0,0 +1,116 @@
1
+ require 'date' # Only so we know what one is. Argh.
2
+
3
+ module Candygram
4
+ # Utility methods to serialize and unserialize objects into BSON
5
+ module Wrapper
6
+
7
+ BSON_SAFE = [String,
8
+ NilClass,
9
+ TrueClass,
10
+ FalseClass,
11
+ Fixnum,
12
+ Float,
13
+ Time,
14
+ Regexp,
15
+ ByteBuffer,
16
+ Mongo::ObjectID,
17
+ Mongo::Code,
18
+ Mongo::DBRef]
19
+
20
+ # Makes an object safe for the sharp pointy edges of MongoDB. Types properly serialized
21
+ # by the BSON.serialize call get passed through unmolested; others are unpacked and their
22
+ # pieces individually shrink-wrapped.
23
+ def self.wrap(thing)
24
+ # Pass the simple cases through
25
+ return thing if BSON_SAFE.include?(thing.class)
26
+ case thing
27
+ when Symbol
28
+ wrap_symbol(thing)
29
+ when Array
30
+ wrap_array(thing)
31
+ when Hash
32
+ wrap_hash(thing)
33
+ when Numeric # The most obvious are in BSON_SAFE, but not all
34
+ thing
35
+ when Date
36
+ thing.to_time
37
+ else
38
+ wrap_object(thing) # Our catchall machinery
39
+ end
40
+ end
41
+
42
+ # Takes an array and returns the same array with unsafe objects wrapped
43
+ def self.wrap_array(array)
44
+ array.map {|element| wrap(element)}
45
+ end
46
+
47
+ # Takes a hash and returns it with both keys and values wrapped
48
+ def self.wrap_hash(hash)
49
+ wrapped = {}
50
+ hash.each {|k, v| wrapped[wrap(k)] = wrap(v)}
51
+ wrapped
52
+ end
53
+
54
+ # Returns a string that's distinctive enough for us to unwrap later and produce the same symbol
55
+ def self.wrap_symbol(symbol)
56
+ "__sym_" + symbol.to_s
57
+ end
58
+
59
+ # Returns a nested hash containing the class and instance variables of the object. It's not the
60
+ # deepest we could ever go (it doesn't handle singleton methods, etc.) but it's a start.
61
+ def self.wrap_object(object)
62
+ wrapped = {"class" => object.class.name}
63
+ ivars = {}
64
+ object.instance_variables.each do |ivar|
65
+ # Different Ruby versions spit different things out for instance_variables. Annoying.
66
+ ivar_name = '@' + ivar.to_s.sub(/^@/,'')
67
+ ivars[ivar_name] = wrap(object.instance_variable_get(ivar_name))
68
+ end
69
+ wrapped["ivars"] = ivars unless ivars.empty?
70
+ {"__object_" => wrapped}
71
+ end
72
+
73
+ # Undoes any complicated magic from the Wrapper.wrap method. Almost everything falls through
74
+ # untouched except for symbol strings and hashed objects.
75
+ def self.unwrap(thing)
76
+ case thing
77
+ when Hash
78
+ if thing["__object_"]
79
+ unwrap_object(thing)
80
+ else
81
+ unwrap_hash(thing)
82
+ end
83
+ when Array
84
+ thing.map {|element| unwrap(element)}
85
+ when /^__sym_(.+)/
86
+ $1.to_sym
87
+ else
88
+ thing
89
+ end
90
+ end
91
+
92
+ # Traverses the hash, unwrapping both keys and values. Returns the hash that results.
93
+ def self.unwrap_hash(hash)
94
+ unwrapped = {}
95
+ hash.each {|k,v| unwrapped[unwrap(k)] = unwrap(v)}
96
+ unwrapped
97
+ end
98
+
99
+ # Turns a hashed object back into an object of the stated class, setting any captured instance
100
+ # variables. The main limitation is that the object's class *must* respond to Class.new without
101
+ # any parameters; we will not attempt to guess at any complex initialization behavior.
102
+ def self.unwrap_object(hash)
103
+ if innards = hash["__object_"]
104
+ klass = Kernel.const_get(innards["class"])
105
+ object = klass.new
106
+ if innards["ivars"]
107
+ innards["ivars"].each do |name, value|
108
+ object.instance_variable_set(name, unwrap(value))
109
+ end
110
+ end
111
+ object
112
+ end
113
+ end
114
+ end
115
+ end
116
+
@@ -0,0 +1,52 @@
1
+ require File.expand_path(File.dirname(__FILE__) + '/../spec_helper')
2
+
3
+ describe Candygram do
4
+ it "creates a default connection" do
5
+ Candygram.connection.host.should == 'localhost'
6
+ Candygram.connection.port.should == 27017
7
+ end
8
+
9
+ it "accepts an explicit connection" do
10
+ this = Mongo::Connection.new 'db.mongohq.com' # Works as of time of writing
11
+ Candygram.connection = this
12
+ Candygram.connection.host.should == 'db.mongohq.com'
13
+ end
14
+
15
+ it "has a default database" do
16
+ Candygram.database.should == 'candygram_test'
17
+ end
18
+
19
+ it "accepts a new database name" do
20
+ Candygram.database = 'candygram_foo'
21
+ Candygram.db.name.should == 'candygram_foo'
22
+ end
23
+
24
+ it "accepts an actual DB object" do
25
+ d = Mongo::DB.new('candygram_bar', Candygram.connection)
26
+ Candygram.db = d
27
+ Candygram.database.should == 'candygram_bar'
28
+ end
29
+
30
+ it "creates a default queue" do
31
+ Candygram.queue.name.should == "candygram_queue"
32
+ Candygram.queue.options['capped'].should be_true
33
+ end
34
+
35
+ it "accepts another collection for the queue" do
36
+ Candygram.queue = Candygram.db.create_collection('foo')
37
+ Candygram.queue.name.should == 'foo'
38
+ end
39
+
40
+ it "can create a collection for the queue" do
41
+ q = Candygram.create_queue('bar')
42
+ q.should be_a_kind_of(Mongo::Collection)
43
+ q.options['capped'].should be_true
44
+ end
45
+
46
+ after(:each) do # Clear state from our tests
47
+ Candygram.queue = nil
48
+ Candygram.db = nil
49
+ Candygram.connection = nil
50
+ end
51
+
52
+ end
@@ -0,0 +1,52 @@
1
+ require File.expand_path(File.dirname(__FILE__) + '/../spec_helper')
2
+
3
+ describe Candygram::Delivery do
4
+
5
+ before(:each) do
6
+ @this = Explosive.new
7
+ end
8
+
9
+ it "adds the method to the delivery queue" do
10
+ @this.kaboom_later
11
+ Candygram.queue.find(:class => /Explosive/, :method => "kaboom").count.should == 1
12
+ end
13
+
14
+ it "captures the arguments passed to the method" do
15
+ id = @this.repeated_kaboom_later('Venus', 15)
16
+ doc = Candygram.queue.find_one(id)
17
+ doc['arguments'].should == ['Venus', 15]
18
+ end
19
+
20
+ it "takes an object as an argument" do
21
+ m = Missile.new
22
+ id = @this.object_kaboom_later('Pluto', 6, m)
23
+ doc = Candygram.queue.find_one(id)
24
+ doc['arguments'][2].should be_a_kind_of(Hash)
25
+ end
26
+
27
+ it "sets the time it was created" do
28
+ id = @this.kaboom_later
29
+ (Time.now.utc - Candygram.queue.find_one(id)['created_at']).should < 2
30
+ end
31
+
32
+ it "wraps itself up as its own package" do
33
+ @this.weight = 15
34
+ id = @this.kaboom_later
35
+ unwrap = Candygram::Wrapper.unwrap(Candygram.queue.find_one(id)['package'])
36
+ unwrap.should be_an(Explosive)
37
+ unwrap.weight.should == 15
38
+ end
39
+
40
+ describe "for _later" do
41
+ it "can queue a method call using _later" do
42
+ @this.kaboom_later.should_not be_nil
43
+ end
44
+
45
+ it "sets the time for delivery to now" do
46
+ id = @this.kaboom_later
47
+ (Time.now.utc - Candygram.queue.find_one(id)['deliver_at']).should < 2
48
+ end
49
+
50
+ end
51
+
52
+ end
@@ -0,0 +1,93 @@
1
+ require File.expand_path(File.dirname(__FILE__) + '/../spec_helper')
2
+
3
+ module Candygram
4
+ describe Candygram::Dispatch do
5
+ before(:each) do
6
+ c = Mongo::Connection.new
7
+ d = Mongo::DB.new(DEFAULT_DATABASE, c)
8
+ @queue = d.collection('candygram_queue')
9
+ @dispatch = Dispatch.new(:frequency => 1)
10
+ @exp = Explosive.new
11
+ end
12
+
13
+ def run_dispatch(cycles=1)
14
+ t = Thread.new do
15
+ @dispatch.run
16
+ end
17
+ sleep(cycles)
18
+ yield if block_given?
19
+ @dispatch.finish
20
+ t.join(20) or raise "Dispatch never completed!"
21
+ end
22
+
23
+ it "knows how often to check the database" do
24
+ d = Dispatch.new(:frequency => 5)
25
+ d.frequency.should == 5
26
+ end
27
+
28
+ it "runs until aborted" do
29
+ run_dispatch.should_not be_nil
30
+ end
31
+
32
+ it "checks the queue for new work on each cycle" do
33
+ Candygram.queue.expects(:find).times(2..3)
34
+ run_dispatch(2.5)
35
+ end
36
+
37
+ it "locks any jobs it finds" do
38
+ @exp.slow_kaboom_later
39
+ run_dispatch(2) do
40
+ @queue.find_one(:locked => {"$exists" => true}).should_not be_nil
41
+ end
42
+ end
43
+
44
+ it "forks a runner for its work" do
45
+ pid = Process.pid
46
+ @exp.slow_kaboom_later
47
+ run_dispatch(2) do
48
+ j = @queue.find_one(:locked => {"$exists" => true})
49
+ j["status"][0]["pid"].should_not == pid
50
+ end
51
+ end
52
+
53
+ it "keeps track of its runners" do
54
+ 3.times { @exp.slow_kaboom_later }
55
+ run_dispatch(4) do
56
+ @dispatch.runners['Explosive'].length.should == 3
57
+ end
58
+ end
59
+
60
+ it "runs the method" do
61
+ @exp.kaboom_later
62
+ run_dispatch
63
+ j = @queue.find_one('result' => {'$exists' => true})
64
+ j["result"].should == "An earth-shattering kaboom!"
65
+ end
66
+
67
+ it "unlocks the delivery upon completion" do
68
+ @exp.kaboom_later
69
+ run_dispatch
70
+ j = @queue.find_one('result' => {'$exists' => true})
71
+ j["locked"].should be_nil
72
+ end
73
+
74
+ it "clears the runner record when it's done working" do
75
+ 3.times { @exp.kaboom_later }
76
+ run_dispatch
77
+ sleep(1)
78
+ @dispatch.runners['Explosive'].should be_empty
79
+ end
80
+
81
+ it "keeps track of maximum instances for each class" do
82
+ 10.times { @exp.slow_kaboom_later }
83
+ run_dispatch(3) do
84
+ a = @queue.find(:locked => {"$exists" => false})
85
+ a.count.should == 5
86
+ sleep(10)
87
+ a = @queue.find(:locked => {"$exists" => false})
88
+ a.count.should == 0
89
+ end
90
+ end
91
+
92
+ end
93
+ end
@@ -0,0 +1,178 @@
1
+ require File.expand_path(File.dirname(__FILE__) + '/../spec_helper')
2
+
3
+ module Candygram
4
+ describe Candygram::Wrapper do
5
+ describe "wrapping" do
6
+
7
+ it "can wrap an array of simple arguments" do
8
+ a = ["Hi", 1, nil, 17.536]
9
+ Wrapper.wrap_array(a).should == a
10
+ end
11
+
12
+ it "can wrap a string" do
13
+ Wrapper.wrap("Hi").should == "Hi"
14
+ end
15
+
16
+ it "can wrap nil" do
17
+ Wrapper.wrap(nil).should == nil
18
+ end
19
+
20
+ it "can wrap true" do
21
+ Wrapper.wrap(true).should be_true
22
+ end
23
+
24
+ it "can wrap false" do
25
+ Wrapper.wrap(false).should be_false
26
+ end
27
+
28
+ it "can wrap an integer" do
29
+ Wrapper.wrap(5).should == 5
30
+ end
31
+
32
+ it "can wrap a float" do
33
+ Wrapper.wrap(17.950).should == 17.950
34
+ end
35
+
36
+ it "can wrap an already serialized bytestream" do
37
+ b = BSON.serialize(:foo => 'bar')
38
+ Wrapper.wrap(b).should == b
39
+ end
40
+
41
+ it "can wrap an ObjectID" do
42
+ i = Mongo::ObjectID.new
43
+ Wrapper.wrap(i).should == i
44
+ end
45
+
46
+ it "can wrap the time" do
47
+ t = Time.now
48
+ Wrapper.wrap(t).should == t
49
+ end
50
+
51
+ it "can wrap a regular expression" do
52
+ r = /ha(l+)eluja(h?)/i
53
+ Wrapper.wrap(r).should == r
54
+ end
55
+
56
+ it "can wrap a Mongo code object (if we ever need to)" do
57
+ c = Mongo::Code.new('5')
58
+ Wrapper.wrap(c).should == c
59
+ end
60
+
61
+ it "can wrap a Mongo DBRef (if we ever need to)" do
62
+ d = Mongo::DBRef.new('foo', Mongo::ObjectID.new)
63
+ Wrapper.wrap(d).should == d
64
+ end
65
+
66
+ it "can wrap a date as a time" do
67
+ d = Date.today
68
+ Wrapper.wrap(d).should == Date.today.to_time
69
+ end
70
+
71
+ it "can wrap other numeric types (which might throw exceptions later but oh well)" do
72
+ c = Complex(2, 5)
73
+ Wrapper.wrap(c).should == c
74
+ end
75
+
76
+ it "can wrap a symbol in a way that preserves its symbolic nature" do
77
+ Wrapper.wrap(:oldglory).should == "__sym_oldglory"
78
+ end
79
+
80
+ it "wraps an array recursively" do
81
+ a = [5, 'hi', [':symbol', 0], nil]
82
+ Wrapper.wrap(a).should == a
83
+ end
84
+
85
+ it "wraps a hash's keys" do
86
+ h = {"foo" => "bar", :yoo => "yar"}
87
+ Wrapper.wrap(h).keys.should == ["foo", "__sym_yoo"]
88
+ end
89
+
90
+ it "wraps a hash's values" do
91
+ h = {:foo => :bar, :yoo => [:yar, 5]}
92
+ Wrapper.wrap(h).values.should == ["__sym_bar", ["__sym_yar", 5]]
93
+ end
94
+
95
+ it "rejects procs"
96
+
97
+ describe "objects" do
98
+ before(:each) do
99
+ @missile = Missile.new
100
+ @missile.payload = "15 megatons"
101
+ @missile.rocket = [2, Object.new]
102
+ @this = Wrapper.wrap(@missile)
103
+ end
104
+
105
+ it "returns a hash" do
106
+ @this.should be_a(Hash)
107
+ end
108
+
109
+ it "keys the hash to be an object" do
110
+ @this.keys.should == ["__object_"]
111
+ end
112
+
113
+ it "knows the object's class" do
114
+ @this["__object_"]["class"].should == "Missile"
115
+ end
116
+
117
+ it "captures all the instance variables" do
118
+ ivars = @this["__object_"]["ivars"]
119
+ ivars.should have(2).elements
120
+ ivars["@payload"].should == "15 megatons"
121
+ ivars["@rocket"][1]["__object_"]["class"].should == "Object"
122
+ end
123
+
124
+ it "avoids circular dependencies"
125
+
126
+ end
127
+ end
128
+
129
+ describe "unwrapping" do
130
+ before(:each) do
131
+ @wrapped = {"__object_" => {
132
+ "class" => "Missile",
133
+ "ivars" => {
134
+ "@payload" => "6 kilotons",
135
+ "@rocket" => [1, {"__object_" => {
136
+ "class" => "Object"
137
+ }}]
138
+ }
139
+ }}
140
+ end
141
+ it "passes most things through untouched" do
142
+ Wrapper.unwrap(5).should == 5
143
+ end
144
+
145
+ it "turns symbolized strings back into symbols" do
146
+ Wrapper.unwrap("__sym_blah").should == :blah
147
+ end
148
+
149
+ it "turns hashed objects back into objects" do
150
+ obj = Wrapper.unwrap(@wrapped)
151
+ obj.should be_a(Missile)
152
+ obj.payload.should == "6 kilotons"
153
+ obj.rocket[0].should == 1
154
+ obj.rocket[1].should be_an(Object)
155
+ end
156
+
157
+ it "traverses a hash and unwraps whatever it needs to" do
158
+ hash = {"__sym_foo" => "__sym_bar", "missile" => @wrapped}
159
+ unwrapped = Wrapper.unwrap(hash)
160
+ unwrapped[:foo].should == :bar
161
+ unwrapped["missile"].should be_a(Missile)
162
+ end
163
+
164
+ it "traverses an array and unwraps whatever it needs to" do
165
+ array = ["__sym_foo", 5, @wrapped, nil, "hi"]
166
+ unwrapped = Wrapper.unwrap(array)
167
+ unwrapped[0].should == :foo
168
+ unwrapped[1].should == 5
169
+ unwrapped[2].should be_a(Missile)
170
+ unwrapped[3].should be_nil
171
+ unwrapped[4].should == "hi"
172
+ end
173
+
174
+ end
175
+
176
+
177
+ end
178
+ end
@@ -0,0 +1,2 @@
1
+ require File.expand_path(File.dirname(__FILE__) + '/spec_helper')
2
+
data/spec/spec.opts ADDED
@@ -0,0 +1 @@
1
+ --color
data/spec/spec.watchr ADDED
@@ -0,0 +1,12 @@
1
+ # See: http://github.com/mynyml/watchr/
2
+
3
+ require 'redgreen'
4
+ require 'autowatchr'
5
+
6
+ Autowatchr.new(self) do |config|
7
+ config.test_dir = 'spec'
8
+ config.test_re = "^#{config.test_dir}/(.*)_spec\.rb$"
9
+ config.test_file = '%s_spec.rb'
10
+ end
11
+ # watch ( 'spec/.*_spec\.rb' ) { |spec| system("ruby #{spec[0]}")}
12
+ # watch ( 'lib/(.*).rb' ) { |lib| system("ruby spec/#{spec[1]}_spec.rb")}
@@ -0,0 +1,20 @@
1
+ $LOAD_PATH.unshift(File.dirname(__FILE__))
2
+ $LOAD_PATH.unshift(File.join(File.dirname(__FILE__), '..', 'lib'))
3
+ require 'candygram'
4
+ require 'mocha'
5
+ require 'spec'
6
+ require 'spec/autorun'
7
+
8
+ # Override the default database so that we don't clobber any production queues by chance
9
+ Candygram.const_set(:DEFAULT_DATABASE, "candygram_test")
10
+
11
+ # Requires supporting files with custom matchers and macros, etc,
12
+ # in ./support/ and its subdirectories.
13
+ Dir[File.expand_path(File.join(File.dirname(__FILE__),'support','**','*.rb'))].each {|f| require f}
14
+
15
+ Spec::Runner.configure do |config|
16
+ config.mock_with :mocha
17
+
18
+ # "I say we take off and nuke the place from orbit. It's the only way to be sure."
19
+ config.after(:each) {Candygram.connection.drop_database(Candygram::DEFAULT_DATABASE)}
20
+ end
@@ -0,0 +1,25 @@
1
+ # A test class that we can make deliveries with and easily check results.
2
+ class Explosive
3
+ include Candygram::Delivery
4
+ CANDYGRAM_MAX = 5
5
+
6
+ attr_accessor :weight
7
+
8
+ def kaboom
9
+ "An earth-shattering kaboom!"
10
+ end
11
+
12
+ def repeated_kaboom(planet, repeat)
13
+ "A #{planet}-shattering kaboom #{repeat} times!"
14
+ end
15
+
16
+ def object_kaboom(planet, repeat, object)
17
+ "A #{planet}-shattering kaboom #{repeat} times using a #{object.class.name}!"
18
+ end
19
+
20
+ # To test that locking is performed properly
21
+ def slow_kaboom
22
+ sleep(5)
23
+ "Waited 5 seconds... NOW we kaboom."
24
+ end
25
+ end
@@ -0,0 +1,10 @@
1
+ # A simple, non-Candygrammed class to test argument encoding
2
+ class Missile
3
+ attr_accessor :payload
4
+ attr_accessor :rocket
5
+
6
+ def explode
7
+ "Dropped the #{payload}."
8
+ end
9
+ end
10
+
metadata ADDED
@@ -0,0 +1,123 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: candygram
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Stephen Eley
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+
12
+ date: 2009-12-31 00:00:00 -05:00
13
+ default_executable:
14
+ dependencies:
15
+ - !ruby/object:Gem::Dependency
16
+ name: mongo
17
+ type: :runtime
18
+ version_requirement:
19
+ version_requirements: !ruby/object:Gem::Requirement
20
+ requirements:
21
+ - - ">="
22
+ - !ruby/object:Gem::Version
23
+ version: "0.18"
24
+ version:
25
+ - !ruby/object:Gem::Dependency
26
+ name: rspec
27
+ type: :development
28
+ version_requirement:
29
+ version_requirements: !ruby/object:Gem::Requirement
30
+ requirements:
31
+ - - ">="
32
+ - !ruby/object:Gem::Version
33
+ version: 1.2.9
34
+ version:
35
+ - !ruby/object:Gem::Dependency
36
+ name: yard
37
+ type: :development
38
+ version_requirement:
39
+ version_requirements: !ruby/object:Gem::Requirement
40
+ requirements:
41
+ - - ">="
42
+ - !ruby/object:Gem::Version
43
+ version: 0.5.2
44
+ version:
45
+ - !ruby/object:Gem::Dependency
46
+ name: mocha
47
+ type: :development
48
+ version_requirement:
49
+ version_requirements: !ruby/object:Gem::Requirement
50
+ requirements:
51
+ - - ">="
52
+ - !ruby/object:Gem::Version
53
+ version: 0.9.7
54
+ version:
55
+ description: Candygram provides a job queue for the MongoDB document-oriented database, inspired by delayed_job and Resque. It uses the MongoMapper ORM and allows you to queue method calls for execution ASAP or at any time in the future.
56
+ email: sfeley@gmail.com
57
+ executables: []
58
+
59
+ extensions: []
60
+
61
+ extra_rdoc_files:
62
+ - LICENSE
63
+ - README.markdown
64
+ files:
65
+ - .document
66
+ - .gitignore
67
+ - LICENSE
68
+ - README.markdown
69
+ - Rakefile
70
+ - VERSION
71
+ - lib/candygram.rb
72
+ - lib/candygram/connection.rb
73
+ - lib/candygram/delivery.rb
74
+ - lib/candygram/dispatch.rb
75
+ - lib/candygram/utility.rb
76
+ - lib/candygram/wrapper.rb
77
+ - spec/candygram/connection_spec.rb
78
+ - spec/candygram/delivery_spec.rb
79
+ - spec/candygram/dispatch_spec.rb
80
+ - spec/candygram/wrapper_spec.rb
81
+ - spec/candygram_spec.rb
82
+ - spec/spec.opts
83
+ - spec/spec.watchr
84
+ - spec/spec_helper.rb
85
+ - spec/support/explosive.rb
86
+ - spec/support/missile.rb
87
+ has_rdoc: true
88
+ homepage: http://github.com/SFEley/candygram
89
+ licenses: []
90
+
91
+ post_install_message:
92
+ rdoc_options:
93
+ - --charset=UTF-8
94
+ require_paths:
95
+ - lib
96
+ required_ruby_version: !ruby/object:Gem::Requirement
97
+ requirements:
98
+ - - ">="
99
+ - !ruby/object:Gem::Version
100
+ version: "0"
101
+ version:
102
+ required_rubygems_version: !ruby/object:Gem::Requirement
103
+ requirements:
104
+ - - ">="
105
+ - !ruby/object:Gem::Version
106
+ version: "0"
107
+ version:
108
+ requirements: []
109
+
110
+ rubyforge_project:
111
+ rubygems_version: 1.3.5
112
+ signing_key:
113
+ specification_version: 3
114
+ summary: Delayed job queueing for MongoMapper
115
+ test_files:
116
+ - spec/candygram/connection_spec.rb
117
+ - spec/candygram/delivery_spec.rb
118
+ - spec/candygram/dispatch_spec.rb
119
+ - spec/candygram/wrapper_spec.rb
120
+ - spec/candygram_spec.rb
121
+ - spec/spec_helper.rb
122
+ - spec/support/explosive.rb
123
+ - spec/support/missile.rb