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.
- checksums.yaml +7 -0
- data/.circleci/config.yml +70 -0
- data/.gitignore +14 -0
- data/.rspec +3 -0
- data/.rubocop.yml +15 -0
- data/.travis.yml +7 -0
- data/CHANGELOG.md +5 -0
- data/CODE_OF_CONDUCT.md +74 -0
- data/Dockerfile +13 -0
- data/Gemfile +26 -0
- data/Gemfile.lock +160 -0
- data/Guardfile +18 -0
- data/LICENSE.txt +21 -0
- data/README.md +140 -0
- data/Rakefile +8 -0
- data/bin/console +14 -0
- data/bin/rspec +29 -0
- data/bin/setup +8 -0
- data/examples/basic/docker-compose.yml +28 -0
- data/examples/basic/example_publisher.rb +18 -0
- data/examples/basic/example_subscriber.rb +24 -0
- data/jstreams.gemspec +58 -0
- data/lib/jstreams.rb +9 -0
- data/lib/jstreams/consumer_group.rb +32 -0
- data/lib/jstreams/context.rb +106 -0
- data/lib/jstreams/publisher.rb +34 -0
- data/lib/jstreams/serializer.rb +37 -0
- data/lib/jstreams/serializers/json.rb +34 -0
- data/lib/jstreams/subscriber.rb +230 -0
- data/lib/jstreams/tagged_logging.rb +89 -0
- data/lib/jstreams/version.rb +5 -0
- data/package-lock.json +21 -0
- metadata +148 -0
data/README.md
ADDED
@@ -0,0 +1,140 @@
|
|
1
|
+
# jstreams
|
2
|
+
|
3
|
+
[](https://circleci.com/gh/jstotz/jstreams)
|
4
|
+
[](https://codeclimate.com/github/jstotz/jstreams/maintainability)
|
5
|
+
[](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).
|
data/Rakefile
ADDED
data/bin/console
ADDED
@@ -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__)
|
data/bin/rspec
ADDED
@@ -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")
|
data/bin/setup
ADDED
@@ -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
|
data/jstreams.gemspec
ADDED
@@ -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
|
data/lib/jstreams.rb
ADDED
@@ -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
|