├── .travis.yml ├── spec ├── spec_helper.cr ├── counter_spec.cr ├── dispatchable_spec.cr ├── configuration_spec.cr ├── job_spec.cr ├── dispatcher_spec.cr └── worker_spec.cr ├── shard.yml ├── src ├── dispatch │ ├── job_queue.cr │ ├── configuration.cr │ ├── job.cr │ ├── dispatchable.cr │ ├── worker.cr │ ├── counter.cr │ └── dispatcher.cr └── dispatch.cr ├── example.cr └── README.md /.travis.yml: -------------------------------------------------------------------------------- 1 | language: crystal 2 | -------------------------------------------------------------------------------- /spec/spec_helper.cr: -------------------------------------------------------------------------------- 1 | require "spec" 2 | require "../src/dispatch" 3 | -------------------------------------------------------------------------------- /shard.yml: -------------------------------------------------------------------------------- 1 | name: dispatch 2 | version: 0.1.0 3 | 4 | authors: 5 | - Bryan Mulvihill 6 | 7 | description: | 8 | In memory queue 9 | 10 | license: MIT 11 | -------------------------------------------------------------------------------- /src/dispatch/job_queue.cr: -------------------------------------------------------------------------------- 1 | class Channel 2 | def pop 3 | receive 4 | end 5 | 6 | def push(value) 7 | send(value) 8 | end 9 | end 10 | 11 | # A job queue holds Dispatch::Jobs waiting to be executed 12 | module Dispatch 13 | alias JobQueue = Channel(Dispatch::Job) 14 | end 15 | -------------------------------------------------------------------------------- /src/dispatch/configuration.cr: -------------------------------------------------------------------------------- 1 | module Dispatch 2 | class Configuration 3 | property num_workers : Int32 4 | property queue_size : Int32 5 | property logger : Logger 6 | 7 | def initialize 8 | @num_workers = 5 9 | @queue_size = 100 10 | @logger = Logger.new(STDOUT) 11 | end 12 | end 13 | end 14 | -------------------------------------------------------------------------------- /src/dispatch/job.cr: -------------------------------------------------------------------------------- 1 | # A Job wraps a unit of work 2 | module Dispatch 3 | class Job 4 | def initialize(&work) 5 | @work = work 6 | end 7 | 8 | def perform 9 | @work.call 10 | Dispatch::SuccessCounter.increment 11 | rescue ex 12 | Dispatch::FailureCounter.increment 13 | Dispatch.process_exception(ex, self) 14 | end 15 | end 16 | end 17 | -------------------------------------------------------------------------------- /src/dispatch/dispatchable.cr: -------------------------------------------------------------------------------- 1 | # Provides a class with the ability to be dispatched 2 | module Dispatchable 3 | macro included 4 | extend Dispatchable::ClassMethods 5 | end 6 | 7 | abstract def perform(*args) 8 | 9 | module ClassMethods 10 | def dispatch(*args) 11 | dispatcher.start unless dispatcher.running? 12 | work = Dispatch::Job.new { self.new.perform(*args) } 13 | dispatcher.dispatch(work) 14 | end 15 | 16 | def dispatch_in(interval, *args) 17 | dispatcher.start unless dispatcher.running? 18 | work = Dispatch::Job.new { self.new.perform(*args) } 19 | dispatcher.dispatch_in(interval, work) 20 | end 21 | 22 | def dispatcher 23 | Dispatch::Dispatcher 24 | end 25 | end 26 | end 27 | -------------------------------------------------------------------------------- /spec/counter_spec.cr: -------------------------------------------------------------------------------- 1 | require "./spec_helper" 2 | 3 | module Dispatch 4 | describe SuccessCounter do 5 | describe "#reset" do 6 | it "resets the counter to 0" do 7 | SuccessCounter.increment 8 | SuccessCounter.reset.should eq(0) 9 | end 10 | end 11 | 12 | describe "#increment" do 13 | it "increments the counter value by 1" do 14 | SuccessCounter.reset 15 | SuccessCounter.increment.should eq(1) 16 | end 17 | end 18 | 19 | describe "#decrement" do 20 | it "decrements the counter value by 1" do 21 | SuccessCounter.reset 22 | SuccessCounter.increment 23 | SuccessCounter.increment 24 | SuccessCounter.decrement.should eq(1) 25 | end 26 | end 27 | end 28 | end 29 | -------------------------------------------------------------------------------- /src/dispatch.cr: -------------------------------------------------------------------------------- 1 | require "./dispatch/*" 2 | require "logger" 3 | 4 | module Dispatch 5 | NAME = "Dispatch" 6 | VERSION = "0.1.0" 7 | 8 | def self.config 9 | @@config ||= Configuration.new 10 | end 11 | 12 | def self.configure 13 | yield(config) 14 | end 15 | 16 | def self.process_exception(ex, klass) 17 | error_msg = String.build do |io| 18 | io << "Dispatch error:\n" 19 | io << "#{ex.class} #{ex}\n" 20 | io << ex.backtrace.join("\n") if ex.backtrace 21 | end 22 | logger.error(error_msg) 23 | end 24 | 25 | def self.logger 26 | config.logger 27 | end 28 | 29 | def self.successes 30 | Dispatch::SuccessCounter.value 31 | end 32 | 33 | def self.failures 34 | Dispatch::FailureCounter.value 35 | end 36 | end 37 | -------------------------------------------------------------------------------- /example.cr: -------------------------------------------------------------------------------- 1 | require "./src/dispatch" 2 | 3 | # Simple example 4 | Dispatch.configure do |config| 5 | config.num_workers = 5 6 | config.queue_size = 10 7 | config.logger = Logger.new(IO::Memory.new) 8 | end 9 | 10 | class FakeJob 11 | include Dispatchable 12 | 13 | def perform(name) 14 | p "#{Time.now}: Hello, #{name}" 15 | end 16 | end 17 | 18 | class ErrorJob 19 | include Dispatchable 20 | 21 | def perform 22 | raise "Hello!" 23 | end 24 | end 25 | 26 | Dispatch.config # => 27 | FakeJob.dispatch("Bob") 28 | FakeJob.dispatch("Emily") 29 | FakeJob.dispatch_in(5.seconds, "Billy") 30 | FakeJob.dispatch("Maddy") 31 | ErrorJob.dispatch 32 | Dispatch.successes # => 0 33 | sleep 6 34 | 35 | Dispatch.successes # => 4 36 | Dispatch.failures # => 1 37 | -------------------------------------------------------------------------------- /spec/dispatchable_spec.cr: -------------------------------------------------------------------------------- 1 | require "./spec_helper" 2 | 3 | class FakeJob 4 | include Dispatchable 5 | 6 | def perform(x) 7 | x.value += 1 8 | end 9 | end 10 | 11 | describe Dispatchable do 12 | describe "#dispatch" do 13 | it "will execute a job asynchrously" do 14 | x = 1 15 | ptr_x = pointerof(x) 16 | FakeJob.dispatch(ptr_x) 17 | ptr_x.value.should eq(1) 18 | sleep 1 19 | ptr_x.value.should eq(2) 20 | end 21 | end 22 | 23 | describe "#dispatch_in" do 24 | it "will perform a job after a given interval" do 25 | x = 1 26 | ptr_x = pointerof(x) 27 | FakeJob.dispatch_in(1.seconds, ptr_x) 28 | ptr_x.value.should eq(1) 29 | sleep 0.5 30 | ptr_x.value.should eq(1) 31 | sleep 1 32 | ptr_x.value.should eq(2) 33 | end 34 | end 35 | end 36 | -------------------------------------------------------------------------------- /spec/configuration_spec.cr: -------------------------------------------------------------------------------- 1 | require "./spec_helper" 2 | 3 | module Dispatch 4 | describe Configuration do 5 | Spec.after_each do 6 | Dispatch.configure do |config| 7 | config.num_workers = 5 8 | config.queue_size = 100 9 | end 10 | end 11 | 12 | describe "#configure" do 13 | it "will set the default values" do 14 | Dispatch.config.num_workers.should eq(5) 15 | Dispatch.config.queue_size.should eq(100) 16 | end 17 | 18 | it "will allow the setting of configurable values" do 19 | Dispatch.configure do |config| 20 | config.num_workers = 1234 21 | config.queue_size = 4321 22 | end 23 | 24 | Dispatch.config.num_workers.should eq(1234) 25 | Dispatch.config.queue_size.should eq(4321) 26 | end 27 | end 28 | end 29 | end 30 | -------------------------------------------------------------------------------- /src/dispatch/worker.cr: -------------------------------------------------------------------------------- 1 | module Dispatch 2 | class Worker 3 | def initialize(dispatch_queue) 4 | @dispatch_queue = dispatch_queue 5 | @job_queue = JobQueue.new 6 | @stopped = true 7 | dispatch_queue.send(job_queue) 8 | end 9 | 10 | def start 11 | return if running? 12 | @stopped = false 13 | 14 | spawn do 15 | while (work = job_queue.pop) && !stopped? 16 | work.perform 17 | dispatch_queue.send(job_queue) 18 | end 19 | end 20 | true 21 | end 22 | 23 | def stop 24 | @stopped = true 25 | end 26 | 27 | def stopped? 28 | @stopped 29 | end 30 | 31 | def running? 32 | !@stopped 33 | end 34 | 35 | private getter job_queue : JobQueue 36 | private getter dispatch_queue : Channel(JobQueue) 37 | end 38 | end 39 | -------------------------------------------------------------------------------- /spec/job_spec.cr: -------------------------------------------------------------------------------- 1 | require "./spec_helper" 2 | 3 | module Dispatch 4 | describe Job do 5 | describe "#perform" do 6 | it "will execute the job's work block" do 7 | x = 1 8 | job = Job.new { x += 1 } 9 | job.perform 10 | x.should eq 2 11 | end 12 | 13 | it "updates the success counter" do 14 | Dispatch::SuccessCounter.reset 15 | job = Job.new { } 16 | job.perform 17 | Dispatch::SuccessCounter.value.should eq(1) 18 | end 19 | 20 | it "calls writes to the logger if a job fails" do 21 | result = String.build do |io| 22 | logger = Logger.new(io) 23 | Dispatch.configure { |c| c.logger = logger } 24 | job = Job.new { raise "Error!" } 25 | job.perform 26 | end 27 | result.should_not eq("") 28 | Dispatch::FailureCounter.value.should eq(1) 29 | end 30 | end 31 | end 32 | end 33 | -------------------------------------------------------------------------------- /src/dispatch/counter.cr: -------------------------------------------------------------------------------- 1 | module Dispatch 2 | macro counter(names) 3 | {% for name in names %} 4 | class {{name.id}}Counter 5 | def self.increment 6 | INSTANCE.increment 7 | end 8 | 9 | def self.decrement 10 | INSTANCE.decrement 11 | end 12 | 13 | def self.value 14 | INSTANCE.value 15 | end 16 | 17 | def self.reset 18 | INSTANCE.reset 19 | end 20 | 21 | private getter counter 22 | private INSTANCE = new 23 | 24 | private def initialize 25 | @counter = Atomic(Int32).new(0) 26 | end 27 | 28 | def increment 29 | counter.add(1) 30 | counter.get 31 | end 32 | 33 | def decrement 34 | counter.sub(1) 35 | counter.get 36 | end 37 | 38 | def value 39 | counter.get 40 | end 41 | 42 | def reset 43 | counter.set(0) 44 | end 45 | end 46 | {% end %} 47 | end 48 | 49 | counter(["Success", "Failure"]) 50 | end 51 | -------------------------------------------------------------------------------- /spec/dispatcher_spec.cr: -------------------------------------------------------------------------------- 1 | require "./spec_helper" 2 | 3 | module Dispatch 4 | describe Dispatcher do 5 | describe "#start and #stop" do 6 | it "can be started and stopped" do 7 | Dispatcher.start 8 | Dispatcher.running?.should eq(true) 9 | Dispatcher.stop 10 | Dispatcher.stopped?.should eq(true) 11 | end 12 | 13 | it "can be restarted" do 14 | Dispatcher.stop 15 | Dispatcher.running?.should eq(false) 16 | Dispatcher.start 17 | Dispatcher.running?.should eq(true) 18 | end 19 | 20 | it "will not start twice" do 21 | Dispatcher.start.should eq(false) 22 | end 23 | end 24 | 25 | describe "#workers" do 26 | it "will have 5 workers by default" do 27 | Dispatcher.workers.size.should eq(5) 28 | end 29 | end 30 | 31 | describe "#dispatch" do 32 | it "will dispatch a unit of work" do 33 | x = 1 34 | work = Job.new { x += 1 } 35 | Dispatcher.dispatch(work) 36 | sleep 1 37 | x.should eq(2) 38 | end 39 | end 40 | end 41 | end 42 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Dispatch [![Build Status](https://travis-ci.org/bmulvihill/dispatch.svg?branch=master)](https://travis-ci.org/bmulvihill/dispatch) 2 | 3 | 4 | ### In-memory job queuing 5 | ```crystal 6 | # example.cr 7 | require "./src/dispatch" 8 | 9 | Dispatch.configure do |config| 10 | config.num_workers = 5 11 | config.queue_size = 10 12 | config.logger = Logger.new(IO::Memory.new) 13 | end 14 | 15 | class FakeJob 16 | include Dispatchable 17 | 18 | def perform(name) 19 | p "#{Time.now}: Hello, #{name}" 20 | end 21 | end 22 | 23 | class ErrorJob 24 | include Dispatchable 25 | 26 | def perform 27 | raise "Hello!" 28 | end 29 | end 30 | 31 | Dispatch.config # => 32 | FakeJob.dispatch("Bob") 33 | FakeJob.dispatch("Emily") 34 | FakeJob.dispatch_in(5.seconds, "Billy") 35 | FakeJob.dispatch("Maddy") 36 | 37 | ErrorJob.dispatch 38 | 39 | Dispatch.successes # => 0 40 | 41 | sleep 6 42 | 43 | Dispatch.successes # => 4 44 | Dispatch.failures # => 1 45 | ``` 46 | 47 | Output: 48 | ``` 49 | "2016-12-13 14:23:53 -0500: Hello, Bob" 50 | "2016-12-13 14:23:53 -0500: Hello, Emily" 51 | "2016-12-13 14:23:53 -0500: Hello, Maddy" 52 | "2016-12-13 14:23:58 -0500: Hello, Billy" 53 | ``` 54 | -------------------------------------------------------------------------------- /spec/worker_spec.cr: -------------------------------------------------------------------------------- 1 | require "./spec_helper" 2 | 3 | module Dispatch 4 | describe Worker do 5 | describe "#start" do 6 | it "starts" do 7 | w = Worker.new(Channel(JobQueue).new(1)) 8 | w.start.should eq(true) 9 | end 10 | 11 | it "will perform work" do 12 | dispatch_queue = Channel(JobQueue).new(1) 13 | w = Worker.new(dispatch_queue).start 14 | job_queue = dispatch_queue.receive 15 | x = 1 16 | job_queue.push(Job.new { x += 1 }) 17 | sleep 1 18 | x.should eq(2) 19 | end 20 | end 21 | 22 | describe "#stop" do 23 | it "stops" do 24 | w = Worker.new(Channel(JobQueue).new(1)) 25 | w.start 26 | w.stopped?.should eq false 27 | w.stop 28 | w.stopped?.should eq true 29 | end 30 | 31 | it "wont start new work" do 32 | dispatch_queue = Channel(JobQueue).new(1) 33 | w = Worker.new(dispatch_queue) 34 | w.start 35 | job_queue = dispatch_queue.receive 36 | x = 1 37 | job = Job.new { x += 1 } 38 | job_queue.push(job) 39 | x.should eq(2) 40 | sleep 1 41 | w.stop 42 | job_queue.push(job) 43 | sleep 1 44 | x.should eq(2) 45 | end 46 | end 47 | end 48 | end 49 | -------------------------------------------------------------------------------- /src/dispatch/dispatcher.cr: -------------------------------------------------------------------------------- 1 | # A Dispatcher is responsible for pulling work off the job queue 2 | # It assigns the work to the next available worker 3 | module Dispatch 4 | class Dispatcher 5 | def self.instance 6 | @@instance ||= new(Dispatch.config) 7 | end 8 | 9 | def self.start 10 | instance.start 11 | end 12 | 13 | def self.stop 14 | instance.stop 15 | end 16 | 17 | def self.stopped? 18 | instance.stopped? 19 | end 20 | 21 | def self.running? 22 | instance.running? 23 | end 24 | 25 | def self.workers 26 | instance.workers 27 | end 28 | 29 | def self.dispatch(work) 30 | instance.job_queue.push(work) 31 | end 32 | 33 | def self.dispatch_in(interval, work) 34 | spawn do 35 | sleep interval 36 | instance.job_queue.push(work) 37 | end 38 | end 39 | 40 | getter job_queue 41 | getter workers 42 | 43 | private def initialize(config : Configuration) 44 | @job_queue = Dispatch::JobQueue.new(config.queue_size) 45 | @dispatch_queue = Channel(JobQueue).new(config.num_workers) 46 | @workers = Array(Worker).new(config.num_workers) 47 | @config = config 48 | create_workers 49 | end 50 | 51 | def start 52 | return false if running? 53 | workers.each &.start 54 | 55 | spawn do 56 | while running? 57 | work = job_queue.pop 58 | worker = dispatch_queue.receive 59 | worker.push(work) 60 | end 61 | end 62 | end 63 | 64 | def stopped? 65 | workers.all? &.stopped? 66 | end 67 | 68 | def running? 69 | !stopped? 70 | end 71 | 72 | def stop 73 | workers.each &.stop 74 | end 75 | 76 | private getter config 77 | private getter dispatch_queue 78 | 79 | private def create_workers 80 | config.num_workers.times do |i| 81 | worker = Worker.new(dispatch_queue) 82 | workers << worker 83 | end 84 | end 85 | end 86 | end 87 | --------------------------------------------------------------------------------