jstreams 0.1.0.alpha

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,140 @@
1
+ # jstreams
2
+
3
+ [![CircleCI](https://circleci.com/gh/jstotz/jstreams.svg?style=svg)](https://circleci.com/gh/jstotz/jstreams)
4
+ [![Maintainability](https://api.codeclimate.com/v1/badges/f37990e1cb4727d2ae71/maintainability)](https://codeclimate.com/github/jstotz/jstreams/maintainability)
5
+ [![Test Coverage](https://api.codeclimate.com/v1/badges/f37990e1cb4727d2ae71/test_coverage)](https://codeclimate.com/github/jstotz/jstreams/test_coverage)
6
+
7
+ A distributed streaming platform for Ruby built on top of Redis Streams.
8
+
9
+ Provides a multi-threaded publisher/subscriber.
10
+
11
+ ## Project Status
12
+
13
+ This is alpha software and not suitable for production use.
14
+
15
+ ## Roadmap
16
+
17
+ - [X] Load balancing across named consumer groups
18
+ - [X] Automatically reclaim messages when consumers die
19
+ - [X] Multi-threaded subscribers
20
+ - [X] Automatic checkpoint storage
21
+ - [X] Configurable message serialization
22
+ - [ ] Configurable retry logic
23
+ - [ ] Replay streams from a given checkpoint
24
+ - [ ] Wildcard subscriptions
25
+
26
+ ## Installation
27
+
28
+ Add this line to your application's Gemfile:
29
+
30
+ ```ruby
31
+ gem 'jstreams'
32
+ ```
33
+
34
+ And then execute:
35
+
36
+ $ bundle
37
+
38
+ Or install it yourself as:
39
+
40
+ $ gem install jstreams
41
+
42
+ ## Usage
43
+
44
+ ### Publisher
45
+
46
+ ```ruby
47
+ jstreams = Jstreams::Context.new
48
+
49
+ jstreams.publish(
50
+ :users,
51
+ event: 'user_created',
52
+ user_id: 1,
53
+ name: 'King Buzzo'
54
+ )
55
+
56
+ jstreams.publish(
57
+ :users,
58
+ event: 'user_logged_in'
59
+ user_id: 1
60
+ )
61
+ ```
62
+
63
+ ### Subscriber
64
+
65
+ ```ruby
66
+ jstreams = Jstreams::Context.new
67
+
68
+ jstreams.subscribe(
69
+ :user_activity_logger,
70
+ :users
71
+ ) { |message, _stream, _subscriber| logger.info "User #{name}" }
72
+
73
+ jstreams.subscribe(
74
+ :subscriber,
75
+ %w[foo:* bar:*]
76
+ ) { |message, _stream, _subscriber| logger.info "User #{name}" }
77
+
78
+ # Spawns subscriber threads and blocks
79
+ jstreams.run
80
+ ```
81
+
82
+ ### Replay
83
+
84
+ Starts a temporary copy of the given subscriber until messages have been replayed up to the checkpoint stored at the time replay is called.
85
+
86
+ ```ruby
87
+ jstreams.replay(:user_activity_logger, from: message_id)
88
+ ```
89
+
90
+ ### Retries
91
+
92
+ By default subscribers will process messages indefinitely until successful.
93
+
94
+ ```ruby
95
+ # TODO
96
+ ```
97
+
98
+ ### Serialization
99
+
100
+ ```ruby
101
+ class Serializer
102
+ MESSAGE_TYPES = {
103
+ user_created: UserCreatedMessage, user_logged_in: UserLoggedInMessage
104
+ }
105
+
106
+ def serialize(type, message)
107
+ message_class(type).serialize(message)
108
+ end
109
+
110
+ def deserialize(type, message)
111
+ message_class(type).deserialize(message)
112
+ end
113
+
114
+ private
115
+
116
+ def message_class(type)
117
+ MESSAGE_TYPES.fetch(type) { raise "Unknown message type: #{type}" }
118
+ end
119
+ end
120
+
121
+ jstreams = Jstreams::Context.new(serializer: Serializer)
122
+ ```
123
+
124
+ ## Development
125
+
126
+ 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.
127
+
128
+ 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).
129
+
130
+ ## Contributing
131
+
132
+ Bug reports and pull requests are welcome on GitHub at https://github.com/[USERNAME]/jstreams. 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.
133
+
134
+ ## License
135
+
136
+ The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
137
+
138
+ ## Code of Conduct
139
+
140
+ Everyone interacting in the jstreams project’s codebases, issue trackers, chat rooms and mailing lists is expected to follow the [code of conduct](https://github.com/[USERNAME]/jstreams/blob/master/CODE_OF_CONDUCT.md).
@@ -0,0 +1,8 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'bundler/gem_tasks'
4
+ require 'rspec/core/rake_task'
5
+
6
+ RSpec::Core::RakeTask.new(:spec)
7
+
8
+ task default: :spec
@@ -0,0 +1,14 @@
1
+ #!/usr/bin/env ruby
2
+
3
+ require "bundler/setup"
4
+ require "jstreams"
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__)
@@ -0,0 +1,29 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ #
5
+ # This file was generated by Bundler.
6
+ #
7
+ # The application 'rspec' is installed as part of a gem, and
8
+ # this file is here to facilitate running it.
9
+ #
10
+
11
+ require "pathname"
12
+ ENV["BUNDLE_GEMFILE"] ||= File.expand_path("../../Gemfile",
13
+ Pathname.new(__FILE__).realpath)
14
+
15
+ bundle_binstub = File.expand_path("../bundle", __FILE__)
16
+
17
+ if File.file?(bundle_binstub)
18
+ if File.read(bundle_binstub, 300) =~ /This file was generated by Bundler/
19
+ load(bundle_binstub)
20
+ else
21
+ abort("Your `bin/bundle` was not generated by Bundler, so this binstub cannot run.
22
+ Replace `bin/bundle` by running `bundle binstubs bundler --force`, then run this command again.")
23
+ end
24
+ end
25
+
26
+ require "rubygems"
27
+ require "bundler/setup"
28
+
29
+ load Gem.bin_path("rspec-core", "rspec")
@@ -0,0 +1,8 @@
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
@@ -0,0 +1,28 @@
1
+ version: "3.7"
2
+
3
+ x-example: &example
4
+ working_dir: /opt/jstreams/examples/basic
5
+ build:
6
+ context: ../..
7
+ environment:
8
+ REDIS_URL: redis://redis
9
+ volumes:
10
+ - ../..:/opt/jstreams
11
+ depends_on:
12
+ - redis
13
+
14
+ services:
15
+ redis:
16
+ image: redis:5
17
+
18
+ publisher:
19
+ <<: *example
20
+ command: ["ruby", "example_publisher.rb"]
21
+
22
+ subscriber1:
23
+ <<: *example
24
+ command: ["ruby", "example_subscriber.rb", "subscriber1"]
25
+
26
+ subscriber2:
27
+ <<: *example
28
+ command: ["ruby", "example_subscriber.rb", "subscriber2"]
@@ -0,0 +1,18 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'bundler/setup'
4
+ require 'jstreams'
5
+
6
+ STDOUT.sync = true
7
+ logger = Logger.new(STDOUT)
8
+
9
+ logger.info 'Starting publisher...'
10
+
11
+ jstreams = Jstreams::Context.new logger: logger
12
+
13
+ loop do
14
+ body = "hello #{Time.now}"
15
+ id = jstreams.publish('mystream', body)
16
+ logger.info "published: #{id} - #{body}"
17
+ sleep(1)
18
+ end
@@ -0,0 +1,24 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'bundler/setup'
4
+ require 'logger'
5
+ require 'jstreams'
6
+
7
+ STDOUT.sync = true
8
+
9
+ USAGE = 'usage: ruby subscriber.rb [subscriber_key]'
10
+
11
+ subscriber_key = ARGV[0] || abort(USAGE)
12
+
13
+ puts "Starting subscriber #{subscriber_key}..."
14
+
15
+ logger = Logger.new(STDOUT)
16
+ jstreams = Jstreams::Context.new(logger: logger)
17
+
18
+ jstreams.subscribe(
19
+ 'mysubscriber',
20
+ 'mystream',
21
+ key: subscriber_key
22
+ ) { |message| logger.info "Subscriber got a message: #{message.inspect}" }
23
+
24
+ jstreams.run
@@ -0,0 +1,58 @@
1
+ # frozen_string_literal: true
2
+
3
+ if ENV['DOCKER_BUILD']
4
+ version = '0.0.0'
5
+ else
6
+ lib = File.expand_path('lib', __dir__)
7
+ $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib)
8
+ require 'jstreams/version'
9
+ version = Jstreams::VERSION
10
+ end
11
+
12
+ Gem::Specification.new do |spec|
13
+ spec.name = 'jstreams'
14
+ spec.version = version
15
+ spec.authors = ['Jay Stotz']
16
+ spec.email = %w[jason.stotz@gmail.com]
17
+
18
+ spec.summary =
19
+ 'A distributed pub/sub messaging system for Ruby based on Redis Streams'
20
+ spec.homepage = 'https://github.com/jstotz/jstreams'
21
+ spec.license = 'MIT'
22
+
23
+ # Prevent pushing this gem to RubyGems.org. To allow pushes either set the 'allowed_push_host'
24
+ # to allow pushing to a single host or delete this section to allow pushing to any host.
25
+ if spec.respond_to?(:metadata)
26
+ spec.metadata['allowed_push_host'] = 'https://rubygems.org'
27
+
28
+ spec.metadata['homepage_uri'] = spec.homepage
29
+ spec.metadata['source_code_uri'] = 'https://github.com/jstotz/jstreams'
30
+ spec.metadata['changelog_uri'] =
31
+ 'https://github.com/jstotz/jstreams/blob/master/CHANGELOG.md'
32
+ else
33
+ raise 'RubyGems 2.0 or newer is required to protect against ' \
34
+ 'public gem pushes.'
35
+ end
36
+
37
+ # Specify which files should be added to the gem when it is released.
38
+ # The `git ls-files -z` loads the files in the RubyGem that have been added into git.
39
+ spec.files =
40
+ Dir.chdir(File.expand_path(__dir__)) do
41
+ unless ENV['DOCKER_BUILD']
42
+ `git ls-files -z`.split("\x0").reject do |f|
43
+ f.match(%r{^(test|spec|features)/})
44
+ end
45
+ end
46
+ end
47
+
48
+ spec.bindir = 'exe'
49
+ spec.executables = spec.files.grep(%r{^exe/}) { |f| File.basename(f) }
50
+ spec.require_paths = %w[lib]
51
+
52
+ spec.add_dependency 'concurrent-ruby'
53
+ spec.add_dependency 'connection_pool'
54
+ spec.add_dependency 'redis', '>= 4.1.0'
55
+
56
+ spec.add_development_dependency 'bundler', '~> 2.0'
57
+ spec.add_development_dependency 'rake', '~> 10.0'
58
+ end
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'redis'
4
+ require 'jstreams/version'
5
+ require 'jstreams/context'
6
+
7
+ module Jstreams
8
+ class Error < StandardError; end
9
+ end
@@ -0,0 +1,32 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'redis'
4
+
5
+ # :nodoc:
6
+ module Jstreams
7
+ ##
8
+ # A Redis streams Consumer Group
9
+ class ConsumerGroup
10
+ ##
11
+ # @param [String] name Consumer group name
12
+ # @param [String] stream Stream name
13
+ # @param [Redis] redis Redis connection
14
+ def initialize(name:, stream:, redis:)
15
+ @name = name
16
+ @stream = stream
17
+ @redis = redis
18
+ end
19
+
20
+ ##
21
+ # Returns true if the group was created and false if it already existed
22
+ def create_if_not_exists(start_id: 0)
23
+ @redis.xgroup(:create, @stream, @name, start_id, mkstream: true)
24
+ true
25
+ rescue ::Redis::CommandError => e
26
+ raise e unless /BUSYGROUP/ =~ e.message
27
+ false
28
+ end
29
+ end
30
+
31
+ private_constant :ConsumerGroup
32
+ end
@@ -0,0 +1,106 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+ require 'connection_pool'
5
+
6
+ require_relative 'serializers/json'
7
+ require_relative 'publisher'
8
+ require_relative 'subscriber'
9
+ require_relative 'tagged_logging'
10
+
11
+ module Jstreams
12
+ ##
13
+ # A collection of jstreams subscribers, their associated threads, and an interface for
14
+ # publishing messages.
15
+ class Context
16
+ attr_reader :redis_pool, :serializer, :logger
17
+
18
+ ##
19
+ # Initializes a jstreams context
20
+ #
21
+ # @param [String] redis_url Redis URL
22
+ # @param [Serializer] serializer Message serializer
23
+ # @param [Logger] logger Logger
24
+ def initialize(
25
+ redis_url: nil,
26
+ serializer: Serializers::JSON.new,
27
+ logger: Logger.new(ENV['JSTREAMS_VERBOSE'] ? STDOUT : File::NULL)
28
+ )
29
+ # TODO: configurable/smart default pool size
30
+ @redis_pool =
31
+ ::ConnectionPool.new(size: 10, timeout: 5) { Redis.new(url: redis_url) }
32
+ @serializer = serializer
33
+ @logger = TaggedLogging.new(logger)
34
+ @publisher =
35
+ Publisher.new(
36
+ redis_pool: @redis_pool, serializer: serializer, logger: @logger
37
+ )
38
+ @subscribers = []
39
+ end
40
+
41
+ ##
42
+ # Publishes a message to the given stream
43
+ #
44
+ # @param [String] stream Stream name
45
+ # @param [Hash] message Message to publish
46
+ def publish(stream, message)
47
+ @publisher.publish(stream, message)
48
+ end
49
+
50
+ ##
51
+ # Publishes a message to the given stream
52
+ #
53
+ # @param [String] name Subscriber name
54
+ # @param [String] streams Stream name or array of stream names to follow
55
+ # @param [String] key Unique consumer name
56
+ #
57
+ # @return [Subscriber] Handle to the registered subscriber. Can be used to #unsubscribe
58
+ def subscribe(name, streams, key: name, **kwargs, &block)
59
+ subscriber =
60
+ Subscriber.new(
61
+ redis_pool: @redis_pool,
62
+ logger: @logger,
63
+ serializer: @serializer,
64
+ name: name,
65
+ key: key,
66
+ streams: Array(streams),
67
+ handler: block,
68
+ **kwargs
69
+ )
70
+ @subscribers << subscriber
71
+ subscriber
72
+ end
73
+
74
+ ##
75
+ # Unsubscribes the given subscriber
76
+ #
77
+ # @param [Subscriber] subscriber Subscriber to unsubscribe
78
+ def unsubscribe(subscriber)
79
+ @subscribers.delete(subscriber)
80
+ end
81
+
82
+ ##
83
+ # Starts each registered subscriber
84
+ #
85
+ # @param [Boolean] wait Whether or not to block until subscribers shut down
86
+ def run(wait: true)
87
+ trap('INT') { shutdown }
88
+ Thread.abort_on_exception = true
89
+ @subscriber_threads =
90
+ @subscribers.map { |subscriber| Thread.new { subscriber.run } }
91
+ wait_for_shutdown if wait
92
+ end
93
+
94
+ ##
95
+ # Blocks until all subscribers are shut down
96
+ def wait_for_shutdown
97
+ @subscriber_threads.each(&:join)
98
+ end
99
+
100
+ ##
101
+ # Shuts down each registered subscriber
102
+ def shutdown
103
+ @subscribers.each(&:stop)
104
+ end
105
+ end
106
+ end