jstreams 0.1.0.alpha

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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