├── .gitignore ├── .rspec ├── .travis.yml ├── CODE_OF_CONDUCT.md ├── Gemfile ├── Gemfile.lock ├── LICENSE.txt ├── README.md ├── Rakefile ├── bin ├── console └── setup ├── erd.png ├── lib ├── rocketman.rb └── rocketman │ ├── config.rb │ ├── consumer.rb │ ├── event.rb │ ├── job_queue.rb │ ├── pool.rb │ ├── producer.rb │ ├── registry.rb │ ├── relay │ └── redis.rb │ └── version.rb ├── rocketman.gemspec ├── rocketman.jpg └── spec ├── relays_spec.rb ├── rocketman_spec.rb ├── spec_helper.rb └── support └── test_pool.rb /.gitignore: -------------------------------------------------------------------------------- 1 | /.bundle/ 2 | /.yardoc 3 | /_yardoc/ 4 | /coverage/ 5 | /doc/ 6 | /pkg/ 7 | /spec/reports/ 8 | /tmp/ 9 | 10 | # rspec failure tracking 11 | .rspec_status 12 | -------------------------------------------------------------------------------- /.rspec: -------------------------------------------------------------------------------- 1 | --format documentation 2 | --color 3 | --require spec_helper 4 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | --- 2 | sudo: false 3 | language: ruby 4 | cache: bundler 5 | rvm: 6 | - 2.3.1 7 | before_install: gem install bundler -v 1.17.3 8 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | In the interest of fostering an open and welcoming environment, we as 6 | contributors and maintainers pledge to making participation in our project and 7 | our community a harassment-free experience for everyone, regardless of age, body 8 | size, disability, ethnicity, gender identity and expression, level of experience, 9 | nationality, personal appearance, race, religion, or sexual identity and 10 | orientation. 11 | 12 | ## Our Standards 13 | 14 | Examples of behavior that contributes to creating a positive environment 15 | include: 16 | 17 | * Using welcoming and inclusive language 18 | * Being respectful of differing viewpoints and experiences 19 | * Gracefully accepting constructive criticism 20 | * Focusing on what is best for the community 21 | * Showing empathy towards other community members 22 | 23 | Examples of unacceptable behavior by participants include: 24 | 25 | * The use of sexualized language or imagery and unwelcome sexual attention or 26 | advances 27 | * Trolling, insulting/derogatory comments, and personal or political attacks 28 | * Public or private harassment 29 | * Publishing others' private information, such as a physical or electronic 30 | address, without explicit permission 31 | * Other conduct which could reasonably be considered inappropriate in a 32 | professional setting 33 | 34 | ## Our Responsibilities 35 | 36 | Project maintainers are responsible for clarifying the standards of acceptable 37 | behavior and are expected to take appropriate and fair corrective action in 38 | response to any instances of unacceptable behavior. 39 | 40 | Project maintainers have the right and responsibility to remove, edit, or 41 | reject comments, commits, code, wiki edits, issues, and other contributions 42 | that are not aligned to this Code of Conduct, or to ban temporarily or 43 | permanently any contributor for other behaviors that they deem inappropriate, 44 | threatening, offensive, or harmful. 45 | 46 | ## Scope 47 | 48 | This Code of Conduct applies both within project spaces and in public spaces 49 | when an individual is representing the project or its community. Examples of 50 | representing a project or community include using an official project e-mail 51 | address, posting via an official social media account, or acting as an appointed 52 | representative at an online or offline event. Representation of a project may be 53 | further defined and clarified by project maintainers. 54 | 55 | ## Enforcement 56 | 57 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 58 | reported by contacting the project team at edisonywh@gmail.com. All 59 | complaints will be reviewed and investigated and will result in a response that 60 | is deemed necessary and appropriate to the circumstances. The project team is 61 | obligated to maintain confidentiality with regard to the reporter of an incident. 62 | Further details of specific enforcement policies may be posted separately. 63 | 64 | Project maintainers who do not follow or enforce the Code of Conduct in good 65 | faith may face temporary or permanent repercussions as determined by other 66 | members of the project's leadership. 67 | 68 | ## Attribution 69 | 70 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, 71 | available at [http://contributor-covenant.org/version/1/4][version] 72 | 73 | [homepage]: http://contributor-covenant.org 74 | [version]: http://contributor-covenant.org/version/1/4/ 75 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | source "https://rubygems.org" 2 | 3 | git_source(:github) {|repo_name| "https://github.com/#{repo_name}" } 4 | 5 | # Specify your gem's dependencies in rocketman.gemspec 6 | gemspec 7 | -------------------------------------------------------------------------------- /Gemfile.lock: -------------------------------------------------------------------------------- 1 | PATH 2 | remote: . 3 | specs: 4 | rocketman (0.3.0) 5 | 6 | GEM 7 | remote: https://rubygems.org/ 8 | specs: 9 | coderay (1.1.2) 10 | diff-lcs (1.3) 11 | method_source (0.9.2) 12 | pry (0.12.2) 13 | coderay (~> 1.1.0) 14 | method_source (~> 0.9.0) 15 | rake (10.5.0) 16 | redis (4.1.2) 17 | rspec (3.8.0) 18 | rspec-core (~> 3.8.0) 19 | rspec-expectations (~> 3.8.0) 20 | rspec-mocks (~> 3.8.0) 21 | rspec-core (3.8.2) 22 | rspec-support (~> 3.8.0) 23 | rspec-expectations (3.8.4) 24 | diff-lcs (>= 1.2.0, < 2.0) 25 | rspec-support (~> 3.8.0) 26 | rspec-mocks (3.8.1) 27 | diff-lcs (>= 1.2.0, < 2.0) 28 | rspec-support (~> 3.8.0) 29 | rspec-support (3.8.2) 30 | 31 | PLATFORMS 32 | ruby 33 | 34 | DEPENDENCIES 35 | bundler (~> 1.17) 36 | pry 37 | rake (~> 10.0) 38 | redis 39 | rocketman! 40 | rspec (~> 3.0) 41 | 42 | BUNDLED WITH 43 | 1.17.3 44 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2019 Edison Yap 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Rocketman 2 | ![rocketman](./rocketman.jpg) 3 | *yes, I know it says Starman on the image* 4 | > *🎶 And I think it's gonna be a long long time 'Till touch down brings me round again to find 🎶* 5 | 6 | Rocketman is a gem that introduces Pub-Sub mechanism within your Ruby code. 7 | 8 | The main goal of Rocketman is not to replace proper message buses like Redis PubSub/Kafka, but rather be a stepping stone. You can read more about the [rationale behind the project](https://github.com/edisonywh/rocketman#why-use-rocketman-rather-than-a-proper-message-bus-eg-redis-pubsubkafka) down below. 9 | 10 | As with all Pub-Sub mechanism, this greatly decouples your upstream producer and downstream consumer, allowing for scalability, and easier refactor when you decide to move Pub-Sub to a separate service. 11 | 12 | Rocketman also works without Rails. 13 | 14 | ## Installation 15 | 16 | Add this line to your application's Gemfile: 17 | 18 | ```ruby 19 | gem 'rocketman' 20 | ``` 21 | 22 | And then execute: 23 | 24 | $ bundle 25 | 26 | Or install it yourself as: 27 | 28 | $ gem install rocketman 29 | 30 | ## Usage 31 | 32 | Rocketman exposes two module, `Rocketman::Producer` and `Rocketman::Consumer`. They do exactly as what their name implies. All you need to do is `include Rocketman::Producer` and `extend Rocketman::Consumer` into your code. 33 | 34 | ### Producer 35 | Producer exposes one **instance** method to you: `:emit`. `:emit` takes in the event name and an optional payload and publishes it to the consumers. There's nothing more you need to do. The producer do not have to know who its consumers are. 36 | 37 | ```ruby 38 | class Producer 39 | include Rocketman::Producer 40 | 41 | def hello_world 42 | emit :hello, payload: {"one" => 1, "two" => 2} 43 | end 44 | end 45 | ``` 46 | 47 | Note that Producer emit events with threads that run in a thread pool. The default number of worker is 5, and the workers default to checking the job with a 3 seconds interval. You can tweak these to your liking, refer to the [`Configuration` section](https://github.com/edisonywh/rocketman#configuration) below for more informations. 48 | 49 | ### Consumer 50 | Consumer exposes a **class** method, `:on_event`. `:on_event` takes in the event name, and also an additional block, which gets executed whenever a message is received. If an additional `payload` is emitted along with the event, you can get access to it in the form of block argument. 51 | 52 | ```ruby 53 | class Consumer 54 | extend Rocketman::Consumer 55 | 56 | on_event :hello do |payload| 57 | puts "I've received #{payload} here!" 58 | # => I've received {:payload=>{"one"=>1, "two"=>2}} here! 59 | end 60 | end 61 | ``` 62 | 63 | Simple isn't it? 64 | 65 | #### Consume events from external services (Relays) 66 | 67 | If you want to also consume events from external services, you can do that too. 68 | 69 | Rocketman has the concept of a `Relay`. The cool thing is, `Relay`s are just `Producer`s that understand how to relay messages from external services (like `Redis`) into Rocketman events. 70 | 71 | Rocketman ships with a `Redis` relay, which you can use it like so (assuming you have Redis installed): 72 | 73 | ```ruby 74 | require 'rocketman/relay/redis' # This is not required by default, so you need to explicitly require it. 75 | Rocketman::Relay::Redis.new.start(Redis.new) 76 | ``` 77 | 78 | > **NOTE**: You should always pass in a **new, dedicated** connection to `Redis` to the Redis relay. This is because `redis.psubscribe` will hog the whole Redis connection (not just Ruby process), so `Relay` expects a dedicated connection for itself. 79 | 80 | That's it, the `Redis` relay service will now listen for events from external services on behalf of you, and then it'll push those events onto the internal `Registry`. 81 | 82 | It'll translate the following: 83 | 84 | ``` 85 | redis-cli> PUBLISH hello payload 86 | ``` 87 | 88 | to something understandable by your consumer, so a consumer only has to do: 89 | 90 | ```ruby 91 | on_event :hello do |payload| 92 | puts payload 93 | end 94 | ``` 95 | 96 | Notice how it behaves exactly the same as if the events did not come from Redis :) 97 | 98 | **This pattern is powerful because this means your consumers do not have to know where the events are coming from, as long as they're registed onto `Registry`.** 99 | 100 | Right now, only `Redis` is supported, but it is extremely easy to add a `Relay` yourself since it's just a `Producer`. Checkout the implementation of `rocketman/relay/redis` for reference, upstream contributions for services are very welcomed too. 101 | 102 | ## Persisting emitted events 103 | 104 | By default, the events emitted from your app will be stored in an in-memory `Queue`, which will get processed by Rocketman threaded workers. 105 | 106 | However this also means that if your app dies with events still in your job queue, your emitted events which are stored in-memory will be lost. 107 | 108 | That is obviously not desirable, so that's why **Rocketman ships with an option to use `Redis` as your backing storage mechanism.** 109 | 110 | All you need to do is pass in a `Redis` connection to Rocketman. Refer to the [`Configuration` section below](https://github.com/edisonywh/rocketman#configuration) for more information. 111 | 112 | ## Configuration 113 | 114 | Here are the available options to tweak for Rocketman. 115 | 116 | ```ruby 117 | # config/initializers/rocketman.rb 118 | 119 | Rocketman.configure do |config| 120 | config.worker_count = 10 # defaults to 5 121 | config.latency = 1 # defaults to 3, unit is :seconds 122 | config.storage = Redis.new # defaults to `nil` 123 | config.debug = true # defaults to `false` 124 | end 125 | ``` 126 | 127 | Currently `storage` only supports `Redis`, suggestions for alternative backing mechanisms are welcomed. 128 | 129 | `debug` mode enables some debugging `puts` statements, and also tweak the `Thread` workers to `abort_on_exception = true`. So if you have failing jobs, this is how you can figure out what's happening inside your workers. 130 | 131 | ## Why use `Rocketman`, rather than a proper message bus (e.g Redis PubSub/Kafka)? 132 | 133 | It is worth noting that `Rocketman` is not meant to be a replacement for the aforementioned projects -- both Redis PubSub and Kafka are battle-tested and I highly encourage to use them if you can. 134 | 135 | **But**, `Rocketman` recognizes that it's not an easy task to spin up an external message bus to support event-driven architecture, and that's what it's trying to do - to be a stepping stone for eventual greatness. 136 | 137 | Moving onto a event-driven architecture is not an easy task - your team has to agree on a message bus, the DevOps team needs the capacity to manage the message bus, and then what about clustering? failovers? 138 | 139 | So what Rocketman offers you is that you can start writing your dream-state event-driven code **today**, and when the time comes and your team has the capacity to move to a different message bus, then it should be a minimal change. 140 | 141 | ## Architecture 142 | 143 | Here's a very crude drawing of the architecture of Rocketman 144 | 145 | ![erd](./erd.png) 146 | 147 | ## Roadmap 148 | 149 | Right now events are using a `fire-and-forget` mechanism, which is designed to not cause issue to producers. However, this also means that if a consumer fail to consume an event, it'll be lost forever. **Next thing on the roadmap is look into a retry strategy + persistence mechanism.** 150 | 151 | ~~Emitted events are also stored in memory in `Rocketman::Pool`, which means that there's a chance that you'll lose all emitted jobs. Something to think about is to perhaps move the emitted events/job queue onto a persistent storage, like Redis for example.~~ **Redis support is now available!** 152 | 153 | The interface could also probably be better defined, as one of the goal of Rocketman is to be the stepping stone before migrating off to a real, proper message queue/pub-sub mechanism like Kafka. **I want to revisit and think about how can we make that transition more seamless.** 154 | 155 | ## Development 156 | 157 | After checking out the repo, run `bin/setup` to install dependencies. Then, run `rake spec` to run the tests. You can also run `bin/console` for an interactive prompt that will allow you to experiment. 158 | 159 | To install this gem onto your local machine, run `bundle exec rake install`. To release a new version, update the version number in `version.rb`, and then run `bundle exec rake release`, which will create a git tag for the version, push git commits and tags, and push the `.gem` file to [rubygems.org](https://rubygems.org). 160 | 161 | ## Contributing 162 | 163 | Bug reports and pull requests are **very welcomed** on GitHub at https://github.com/edisonywh/rocketman, but before a pull request is submitted, **please first open up an issue** for discussion. 164 | 165 | This project is intended to be a safe, welcoming space for collaboration, and contributors are expected to adhere to the [Contributor Covenant](http://contributor-covenant.org) code of conduct. 166 | 167 | ## License 168 | 169 | The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT). 170 | 171 | ## Code of Conduct 172 | 173 | Everyone interacting in the Rocketman project’s codebases, issue trackers, chat rooms and mailing lists is expected to follow the [code of conduct](https://github.com/[USERNAME]/rocketman/blob/master/CODE_OF_CONDUCT.md). 174 | 175 | ## Why is it called Rocketman? 176 | 177 | Uh.. well it's named after the song by Elton John, but really, it has nothing to do with an actual Rocketman. 178 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | require "bundler/gem_tasks" 2 | require "rspec/core/rake_task" 3 | 4 | RSpec::Core::RakeTask.new(:spec) 5 | 6 | task :default => :spec 7 | -------------------------------------------------------------------------------- /bin/console: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | 3 | require "bundler/setup" 4 | require "rocketman" 5 | 6 | # You can add fixtures and/or initialization code here to make experimenting 7 | # with your gem easier. You can also use a different console, if you like. 8 | 9 | # (If you use this, don't forget to add pry to your Gemfile!) 10 | # require "pry" 11 | # Pry.start 12 | 13 | require "irb" 14 | IRB.start(__FILE__) 15 | -------------------------------------------------------------------------------- /bin/setup: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | set -euo pipefail 3 | IFS=$'\n\t' 4 | set -vx 5 | 6 | bundle install 7 | 8 | # Do any other automated setup that you need to do here 9 | -------------------------------------------------------------------------------- /erd.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/edisonywh/rocketman/8518b9436438a7f68448126c64831f739782b60b/erd.png -------------------------------------------------------------------------------- /lib/rocketman.rb: -------------------------------------------------------------------------------- 1 | require 'rocketman/config.rb' 2 | require 'rocketman/pool.rb' 3 | require 'rocketman/registry.rb' 4 | require 'rocketman/event.rb' 5 | require 'rocketman/producer.rb' 6 | require 'rocketman/consumer.rb' 7 | -------------------------------------------------------------------------------- /lib/rocketman/config.rb: -------------------------------------------------------------------------------- 1 | module Rocketman 2 | def self.configuration 3 | @_configuration ||= Configuration.new 4 | end 5 | 6 | def self.configure 7 | yield(configuration) 8 | end 9 | 10 | class Configuration 11 | attr_accessor :worker_count, :latency, :storage, :debug 12 | 13 | def initialize 14 | @worker_count = 5 15 | @latency = 3 16 | @storage= nil 17 | @debug = false 18 | end 19 | end 20 | end 21 | -------------------------------------------------------------------------------- /lib/rocketman/consumer.rb: -------------------------------------------------------------------------------- 1 | module Rocketman 2 | module Consumer 3 | def on_event(event, &action) 4 | consumer = self 5 | Rocketman::Registry.register_event(event) 6 | Rocketman::Registry.register_consumer(event, consumer, action) 7 | end 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /lib/rocketman/event.rb: -------------------------------------------------------------------------------- 1 | module Rocketman 2 | class Event 3 | def initialize(event, payload) 4 | @event = event 5 | @payload = payload 6 | Rocketman::Registry.register_event(event) 7 | end 8 | 9 | def notify_consumers 10 | consumers = Rocketman::Registry.get_consumers_for(@event) 11 | 12 | return if consumers.nil? || consumers.empty? 13 | 14 | consumers.each do |consumer, action| 15 | consumer.instance_exec(@payload, &action) 16 | end 17 | end 18 | end 19 | end 20 | -------------------------------------------------------------------------------- /lib/rocketman/job_queue.rb: -------------------------------------------------------------------------------- 1 | require 'forwardable' 2 | require 'json' 3 | 4 | module Rocketman 5 | class JobQueue 6 | extend Forwardable 7 | 8 | QUEUE_KEY = "rocketman".freeze 9 | 10 | def_delegators :@jobs, :<<, :empty?, :size, :clear, :push, :pop 11 | 12 | def initialize 13 | @storage = Rocketman.configuration.storage 14 | @jobs = get_job_queue 15 | 16 | at_exit { persist_events } if @storage.class.to_s == "Redis" 17 | end 18 | 19 | def schedule(job) 20 | @jobs << job 21 | end 22 | 23 | private 24 | 25 | def get_job_queue 26 | case @storage.class.to_s 27 | when "Redis" 28 | rehydrate_events 29 | else 30 | Queue.new 31 | end 32 | end 33 | 34 | def rehydrate_events 35 | queue = Queue.new 36 | 37 | if raw_data = @storage.get(QUEUE_KEY) 38 | puts "Rehydrating Rocketman events from #{@storage.class}" if Rocketman.configuration.debug 39 | 40 | rehydrate = JSON.restore(raw_data) # For security measure to prevent remote code execution (only allow contents valid in JSON) 41 | jobs = Marshal.load(rehydrate) 42 | event_count = 0 43 | 44 | until jobs.empty? 45 | queue << jobs.shift 46 | event_count += 1 47 | end 48 | 49 | puts "Rehydrated #{event_count} events from #{@storage.class}" if Rocketman.configuration.debug 50 | 51 | @storage.del(QUEUE_KEY) # After rehydration, delete it off Redis 52 | end 53 | 54 | queue 55 | end 56 | 57 | def persist_events 58 | return if @jobs.empty? 59 | 60 | puts "Persisting Rocketman events to #{@storage.class}" if Rocketman.configuration.debug 61 | intermediary = [] 62 | event_count = 0 63 | 64 | until @jobs.empty? 65 | intermediary << @jobs.pop 66 | event_count += 1 67 | end 68 | @jobs.close 69 | 70 | marshalled_json = Marshal.dump(intermediary).to_json # For security measure to prevent remote code execution (only allow contents valid in JSON) 71 | 72 | @storage.set(QUEUE_KEY, marshalled_json) 73 | puts "Persisted #{event_count} events to #{@storage.class}" if Rocketman.configuration.debug 74 | end 75 | end 76 | end 77 | -------------------------------------------------------------------------------- /lib/rocketman/pool.rb: -------------------------------------------------------------------------------- 1 | require 'singleton' 2 | require 'rocketman/job_queue' 3 | 4 | module Rocketman 5 | class Pool 6 | include Singleton 7 | 8 | attr_reader :jobs 9 | 10 | def initialize 11 | worker_count = Rocketman.configuration.worker_count 12 | latency = Rocketman.configuration.latency 13 | 14 | @latency = latency 15 | @jobs = Rocketman::JobQueue.new 16 | @workers = [] 17 | 18 | worker_count.times do 19 | @workers << spawn_worker 20 | end 21 | 22 | # spawn_supervisor # TODO: Write a supervisor to monitor workers health, and restart if necessary 23 | end 24 | 25 | private 26 | 27 | def spawn_worker 28 | Thread.abort_on_exception if Rocketman.configuration.debug 29 | 30 | Thread.new do 31 | loop do 32 | job = @jobs.pop 33 | job.notify_consumers # Job is an instance of Rocketman::Event 34 | sleep @latency 35 | end 36 | end 37 | end 38 | end 39 | end 40 | -------------------------------------------------------------------------------- /lib/rocketman/producer.rb: -------------------------------------------------------------------------------- 1 | module Rocketman 2 | module Producer 3 | def emit(event, payload = {}) 4 | event = Rocketman::Event.new(event.to_sym, payload) 5 | Rocketman::Pool.instance.jobs.schedule(event) 6 | end 7 | end 8 | end 9 | -------------------------------------------------------------------------------- /lib/rocketman/registry.rb: -------------------------------------------------------------------------------- 1 | require 'singleton' 2 | require 'forwardable' 3 | 4 | module Rocketman 5 | class Registry 6 | include Singleton 7 | 8 | def initialize 9 | @registry = {} 10 | end 11 | 12 | def register_event(event) 13 | if @registry[event] 14 | return @registry[event] 15 | else 16 | @registry[event] = {} 17 | end 18 | end 19 | 20 | def register_consumer(event, consumer, action) 21 | @registry[event][consumer] = action 22 | end 23 | 24 | def get_events 25 | @registry.keys 26 | end 27 | 28 | def get_consumers_for(event) 29 | @registry[event] 30 | end 31 | 32 | def event_exists?(event) 33 | !@registry[event].nil? 34 | end 35 | 36 | # This is to help hide the Singleton interface from the rest of the code 37 | class << self 38 | extend Forwardable 39 | def_delegators :instance, *Rocketman::Registry.instance_methods(false) 40 | end 41 | end 42 | end 43 | -------------------------------------------------------------------------------- /lib/rocketman/relay/redis.rb: -------------------------------------------------------------------------------- 1 | require 'redis' 2 | 3 | module Rocketman 4 | module Relay 5 | class Redis 6 | include Rocketman::Producer 7 | 8 | # You should always pass in a new, dedicated connection to `Redis`. 9 | # This is because `redis.psubscribe` will hog the whole Redis connection, thus if you pass in an existing Redis connection, you won't be able to do anything with that connection anymore. 10 | def start(service) 11 | puts "Rocketman> Using Redis as external producer".freeze if Rocketman.configuration.debug 12 | 13 | Thread.abort_on_exception = Rocketman.configuration.debug 14 | Thread.new do 15 | service.psubscribe("*") do |on| 16 | on.pmessage do |_pattern, event, payload| 17 | emit(event, payload) 18 | end 19 | end 20 | end 21 | end 22 | end 23 | end 24 | end 25 | -------------------------------------------------------------------------------- /lib/rocketman/version.rb: -------------------------------------------------------------------------------- 1 | module Rocketman 2 | VERSION = "0.3.0" 3 | end 4 | -------------------------------------------------------------------------------- /rocketman.gemspec: -------------------------------------------------------------------------------- 1 | 2 | lib = File.expand_path("../lib", __FILE__) 3 | $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib) 4 | require "rocketman/version" 5 | 6 | Gem::Specification.new do |spec| 7 | spec.name = "rocketman" 8 | spec.version = Rocketman::VERSION 9 | spec.authors = ["Edison Yap"] 10 | spec.email = ["edisonywh@gmail.com"] 11 | 12 | spec.summary = "A gem to help build event-based code in Ruby" 13 | spec.homepage = "https://github.com/edisonywh/rocketman" 14 | spec.license = "MIT" 15 | 16 | # Specify which files should be added to the gem when it is released. 17 | # The `git ls-files -z` loads the files in the RubyGem that have been added into git. 18 | spec.files = Dir.chdir(File.expand_path('..', __FILE__)) do 19 | `git ls-files -z`.split("\x0").reject { |f| f.match(%r{^(test|spec|features)/}) } 20 | end 21 | spec.bindir = "exe" 22 | spec.executables = spec.files.grep(%r{^exe/}) { |f| File.basename(f) } 23 | spec.require_paths = ["lib"] 24 | 25 | spec.add_development_dependency "pry" 26 | spec.add_development_dependency "redis" 27 | spec.add_development_dependency "bundler", "~> 1.17" 28 | spec.add_development_dependency "rake", "~> 10.0" 29 | spec.add_development_dependency "rspec", "~> 3.0" 30 | end 31 | -------------------------------------------------------------------------------- /rocketman.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/edisonywh/rocketman/8518b9436438a7f68448126c64831f739782b60b/rocketman.jpg -------------------------------------------------------------------------------- /spec/relays_spec.rb: -------------------------------------------------------------------------------- 1 | RSpec.describe "Relays" do 2 | describe "Redis" do 3 | before do 4 | require 'rocketman/relay/redis' 5 | Rocketman::Relay::Redis.new.start(Redis.new) 6 | end 7 | 8 | after do 9 | class_cleaner(Consumer) 10 | end 11 | 12 | context "when events are emitted from Redis" do 13 | it "should notify consumers" do 14 | Consumer = Class.new 15 | 16 | acknowledged = 0 17 | 18 | Consumer.class_eval do 19 | extend Rocketman::Consumer 20 | 21 | on_event :hello_from_redis do 22 | acknowledged += 1 23 | end 24 | end 25 | 26 | # BUG: Intermittent failure here due to race condition. This is because publish is an async request, it does not guarantee the event gets called in time 27 | expect { Redis.new.publish("hello_from_redis", {}.to_json); sleep 1 }.to change { acknowledged }.from(0).to(1) 28 | end 29 | end 30 | end 31 | end 32 | -------------------------------------------------------------------------------- /spec/rocketman_spec.rb: -------------------------------------------------------------------------------- 1 | RSpec.describe Rocketman do 2 | describe "Producer" do 3 | before do 4 | Producer = Class.new 5 | 6 | Producer.class_eval do 7 | include Rocketman::Producer 8 | 9 | def produce 10 | emit :hello 11 | end 12 | end 13 | end 14 | 15 | after do 16 | class_cleaner(Producer) 17 | end 18 | 19 | it 'should register event when emitting' do 20 | expect { Producer.new.produce }.to change { Rocketman::Registry.event_exists?(:hello) }.from(false).to(true) 21 | end 22 | 23 | it 'should notify all downstream consumers' do 24 | acknowledged = 0 25 | 26 | ConsumerOne = Class.new 27 | ConsumerOne.class_eval do 28 | extend Rocketman::Consumer 29 | 30 | on_event :hello do 31 | acknowledged += 1 32 | end 33 | end 34 | 35 | ConsumerTwo = Class.new 36 | ConsumerTwo.class_eval do 37 | extend Rocketman::Consumer 38 | 39 | on_event :hello do 40 | acknowledged += 1 41 | end 42 | end 43 | 44 | expect { Producer.new.produce }.to change { acknowledged }.from(0).to(2) 45 | end 46 | end 47 | 48 | describe "Consumer" do 49 | after do 50 | class_cleaner(Consumer) 51 | end 52 | it "should register itself as consumer when implementing on_event" do 53 | Consumer = Class.new 54 | 55 | Consumer.class_eval do 56 | extend Rocketman::Consumer 57 | 58 | on_event :hello do 59 | nil 60 | end 61 | end 62 | 63 | expect(Rocketman::Registry.get_consumers_for(:hello).keys).to include Consumer 64 | end 65 | 66 | it "should run on_event when event is emitted" do 67 | Consumer = Class.new 68 | 69 | acknowledged = 0 70 | 71 | Consumer.class_eval do 72 | extend Rocketman::Consumer 73 | 74 | on_event :hello do 75 | acknowledged += 1 76 | end 77 | end 78 | 79 | Producer = Class.new 80 | Producer.class_eval do 81 | include Rocketman::Producer 82 | 83 | def produce 84 | emit :hello, test: true 85 | end 86 | end 87 | 88 | expect { Producer.new.produce }.to change { acknowledged }.from(0).to(1) 89 | end 90 | end 91 | end 92 | -------------------------------------------------------------------------------- /spec/spec_helper.rb: -------------------------------------------------------------------------------- 1 | require "bundler/setup" 2 | require "pry" 3 | require "rocketman" 4 | require "redis" 5 | require "support/test_pool" 6 | 7 | RSpec.configure do |config| 8 | # Enable flags like --only-failures and --next-failure 9 | config.example_status_persistence_file_path = ".rspec_status" 10 | 11 | # Disable RSpec exposing methods globally on `Module` and `main` 12 | config.disable_monkey_patching! 13 | 14 | # Swap it out to a stub implementation because Thread environment is hard to test 15 | Rocketman.send(:remove_const, "Pool") 16 | Rocketman::Pool = Rocketman::TestPool 17 | 18 | config.expect_with :rspec do |c| 19 | c.syntax = :expect 20 | end 21 | end 22 | 23 | Rocketman.configure do |config| 24 | config.debug = true 25 | end 26 | 27 | def class_cleaner(*klasses) 28 | klasses.each { |klass| Object.send(:remove_const, "#{klass}") if defined? klass } 29 | end 30 | -------------------------------------------------------------------------------- /spec/support/test_pool.rb: -------------------------------------------------------------------------------- 1 | module Rocketman 2 | class TestPool 3 | def self.instance 4 | self 5 | end 6 | 7 | def self.jobs 8 | self 9 | end 10 | 11 | def self.schedule(job) 12 | job.notify_consumers 13 | end 14 | end 15 | end 16 | --------------------------------------------------------------------------------