redstream 0.0.1
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/.gitignore +14 -0
- data/.travis.yml +10 -0
- data/Gemfile +5 -0
- data/LICENSE.txt +22 -0
- data/README.md +253 -0
- data/Rakefile +9 -0
- data/docker-compose.yml +6 -0
- data/lib/redstream.rb +134 -0
- data/lib/redstream/consumer.rb +115 -0
- data/lib/redstream/delayer.rb +100 -0
- data/lib/redstream/lock.rb +80 -0
- data/lib/redstream/message.rb +52 -0
- data/lib/redstream/model.rb +57 -0
- data/lib/redstream/producer.rb +145 -0
- data/lib/redstream/trimmer.rb +91 -0
- data/lib/redstream/version.rb +5 -0
- data/redstream.gemspec +38 -0
- data/spec/redstream/consumer_spec.rb +90 -0
- data/spec/redstream/delayer_spec.rb +53 -0
- data/spec/redstream/lock_spec.rb +68 -0
- data/spec/redstream/model_spec.rb +57 -0
- data/spec/redstream/producer_spec.rb +79 -0
- data/spec/redstream/trimmer_spec.rb +32 -0
- data/spec/redstream_spec.rb +117 -0
- data/spec/spec_helper.rb +66 -0
- metadata +289 -0
@@ -0,0 +1,91 @@
|
|
1
|
+
|
2
|
+
module Redstream
|
3
|
+
# The Redstream::Trimmer class is neccessary to clean up messsages after all
|
4
|
+
# consumers have successfully processed and committed them. Otherwise they
|
5
|
+
# would fill up redis and finally bring redis down due to out of memory
|
6
|
+
# issues. The Trimmer will sleep for the specified interval in case there is
|
7
|
+
# nothing to trim. Please note that you must pass an array containing all
|
8
|
+
# consumer names reading from the stream which is about to be trimmed.
|
9
|
+
# Otherwise the Trimmer could trim messages from the stream before all
|
10
|
+
# consumers received the respective messages.
|
11
|
+
#
|
12
|
+
# @example
|
13
|
+
# trimmer = Redstream::Trimmer.new(
|
14
|
+
# interval: 30,
|
15
|
+
# stream_name: "users",
|
16
|
+
# consumer_names: ["indexer", "cacher"]
|
17
|
+
# )
|
18
|
+
#
|
19
|
+
# trimmer.run
|
20
|
+
|
21
|
+
class Trimmer
|
22
|
+
# Initializes a new trimmer. Accepts an interval to sleep for in case there
|
23
|
+
# is nothing to trim, the actual stream name, the consumer names as well as
|
24
|
+
# a logger for debug log messages.
|
25
|
+
#
|
26
|
+
# @param interval [Fixnum, Float] Specifies a time to sleep in case there is
|
27
|
+
# nothing to trim.
|
28
|
+
# @param stream_name [String] The name of the stream that should be trimmed.
|
29
|
+
# Please note, that redstream adds a prefix to the redis keys. However,
|
30
|
+
# the stream_name param must be specified without any prefixes here. When
|
31
|
+
# using Redstream::Model, the stream name is the downcased, pluralized
|
32
|
+
# and underscored version of the model name. I.e., the stream name for a
|
33
|
+
# 'User' model will be 'users'
|
34
|
+
# @params consumer_names [Array] The list of all consumers reading from the
|
35
|
+
# specified stream
|
36
|
+
# @param logger [Logger] A logger used for debug messages
|
37
|
+
|
38
|
+
def initialize(interval:, stream_name:, consumer_names:, logger: Logger.new("/dev/null"))
|
39
|
+
@interval = interval
|
40
|
+
@stream_name = stream_name
|
41
|
+
@consumer_names = consumer_names
|
42
|
+
@logger = logger
|
43
|
+
@lock = Lock.new(name: "trimmer:#{stream_name}")
|
44
|
+
end
|
45
|
+
|
46
|
+
# Loops and blocks forever trimming messages from the specified redis
|
47
|
+
# stream.
|
48
|
+
|
49
|
+
def run
|
50
|
+
loop { run_once }
|
51
|
+
end
|
52
|
+
|
53
|
+
# Runs the trimming a single time. You usually want to use the #run method
|
54
|
+
# instead, which loops/blocks forever.
|
55
|
+
|
56
|
+
def run_once
|
57
|
+
got_lock = @lock.acquire do
|
58
|
+
min_committed_id = Redstream.connection_pool.with do |redis|
|
59
|
+
offset_key_names = @consumer_names.map do |consumer_name|
|
60
|
+
Redstream.offset_key_name(stream_name: @stream_name, consumer_name: consumer_name)
|
61
|
+
end
|
62
|
+
|
63
|
+
redis.mget(offset_key_names).map(&:to_s).reject(&:empty?).min
|
64
|
+
end
|
65
|
+
|
66
|
+
return sleep(@interval) unless min_committed_id
|
67
|
+
|
68
|
+
loop do
|
69
|
+
messages = Redstream.connection_pool.with do |redis|
|
70
|
+
redis.xrange(Redstream.stream_key_name(@stream_name), "-", min_committed_id, count: 1_000)
|
71
|
+
end
|
72
|
+
|
73
|
+
return sleep(@interval) if messages.nil? || messages.empty?
|
74
|
+
|
75
|
+
Redstream.connection_pool.with { |redis| redis.xdel Redstream.stream_key_name(@stream_name), messages.map(&:first) }
|
76
|
+
|
77
|
+
@logger.debug "Trimmed #{messages.size} messages from #{@stream_name}"
|
78
|
+
end
|
79
|
+
end
|
80
|
+
|
81
|
+
sleep(5) unless got_lock
|
82
|
+
rescue => e
|
83
|
+
@logger.error e
|
84
|
+
|
85
|
+
sleep 5
|
86
|
+
|
87
|
+
retry
|
88
|
+
end
|
89
|
+
end
|
90
|
+
end
|
91
|
+
|
data/redstream.gemspec
ADDED
@@ -0,0 +1,38 @@
|
|
1
|
+
# coding: utf-8
|
2
|
+
lib = File.expand_path('../lib', __FILE__)
|
3
|
+
$LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib)
|
4
|
+
require 'redstream/version'
|
5
|
+
|
6
|
+
Gem::Specification.new do |spec|
|
7
|
+
spec.name = "redstream"
|
8
|
+
spec.version = Redstream::VERSION
|
9
|
+
spec.authors = ["Benjamin Vetter"]
|
10
|
+
spec.email = ["vetter@plainpicture.de"]
|
11
|
+
spec.summary = %q{Using redis streams to keep your primary database in sync with secondary datastores}
|
12
|
+
spec.description = %q{Using redis streams to keep your primary database in sync with secondary datastores}
|
13
|
+
spec.homepage = "https://github.com/mrkamel/redstream"
|
14
|
+
spec.license = "MIT"
|
15
|
+
|
16
|
+
spec.files = `git ls-files -z`.split("\x0")
|
17
|
+
spec.executables = spec.files.grep(%r{^bin/}) { |f| File.basename(f) }
|
18
|
+
spec.test_files = spec.files.grep(%r{^(test|spec|features)/})
|
19
|
+
spec.require_paths = ["lib"]
|
20
|
+
|
21
|
+
spec.add_development_dependency "bundler"
|
22
|
+
spec.add_development_dependency "rake"
|
23
|
+
spec.add_development_dependency "rspec"
|
24
|
+
spec.add_development_dependency "activerecord"
|
25
|
+
spec.add_development_dependency "database_cleaner"
|
26
|
+
spec.add_development_dependency "sqlite3", "1.3.13"
|
27
|
+
spec.add_development_dependency "factory_bot"
|
28
|
+
spec.add_development_dependency "timecop"
|
29
|
+
spec.add_development_dependency "concurrent-ruby"
|
30
|
+
spec.add_development_dependency "rspec-instafail"
|
31
|
+
spec.add_development_dependency "mocha"
|
32
|
+
|
33
|
+
spec.add_dependency "connection_pool"
|
34
|
+
spec.add_dependency "activesupport"
|
35
|
+
spec.add_dependency "redis", ">= 4.1.0"
|
36
|
+
spec.add_dependency "json"
|
37
|
+
end
|
38
|
+
|
@@ -0,0 +1,90 @@
|
|
1
|
+
|
2
|
+
require File.expand_path("../spec_helper", __dir__)
|
3
|
+
|
4
|
+
RSpec.describe Redstream::Consumer do
|
5
|
+
describe "#run_once" do
|
6
|
+
it "doesn't call the block without messages" do
|
7
|
+
called = false
|
8
|
+
|
9
|
+
Redstream::Consumer.new(name: "consumer", stream_name: "products", batch_size: 5).run_once do |batch|
|
10
|
+
called = true
|
11
|
+
end
|
12
|
+
|
13
|
+
expect(called).to eq(false)
|
14
|
+
end
|
15
|
+
|
16
|
+
it "is mutually exclusive" do
|
17
|
+
create :product
|
18
|
+
|
19
|
+
calls = Concurrent::AtomicFixnum.new(0)
|
20
|
+
|
21
|
+
threads = Array.new(2) do |i|
|
22
|
+
Thread.new do
|
23
|
+
Redstream::Consumer.new(name: "consumer", stream_name: "products", batch_size: 5).run_once do |batch|
|
24
|
+
calls.increment
|
25
|
+
|
26
|
+
sleep 1
|
27
|
+
end
|
28
|
+
end
|
29
|
+
end
|
30
|
+
|
31
|
+
threads.each(&:join)
|
32
|
+
|
33
|
+
expect(calls.value).to eq(1)
|
34
|
+
end
|
35
|
+
|
36
|
+
it "is using the existing offset" do
|
37
|
+
create_list(:product, 2)
|
38
|
+
|
39
|
+
all_messages = redis.xrange(Redstream.stream_key_name("products"), "-", "+")
|
40
|
+
|
41
|
+
expect(all_messages.size).to eq(2)
|
42
|
+
|
43
|
+
redis.set(Redstream.offset_key_name(stream_name: "products", consumer_name: "consumer"), all_messages.first[0])
|
44
|
+
|
45
|
+
messages = nil
|
46
|
+
|
47
|
+
consumer = Redstream::Consumer.new(name: "consumer", stream_name: "products")
|
48
|
+
|
49
|
+
consumer.run_once do |batch|
|
50
|
+
messages = batch
|
51
|
+
end
|
52
|
+
|
53
|
+
expect(messages.size).to eq(1)
|
54
|
+
expect(messages.first.raw_message).to eq(all_messages.last)
|
55
|
+
end
|
56
|
+
|
57
|
+
it "yields messages in batches" do
|
58
|
+
products = create_list(:product, 15)
|
59
|
+
|
60
|
+
consumer = Redstream::Consumer.new(name: "consumer", stream_name: "products", batch_size: 10)
|
61
|
+
|
62
|
+
messages = nil
|
63
|
+
|
64
|
+
consumer.run_once do |batch|
|
65
|
+
messages = batch
|
66
|
+
end
|
67
|
+
|
68
|
+
expect(messages.size).to eq(10)
|
69
|
+
|
70
|
+
consumer.run_once do |batch|
|
71
|
+
messages = batch
|
72
|
+
end
|
73
|
+
|
74
|
+
expect(messages.size).to eq(5)
|
75
|
+
end
|
76
|
+
|
77
|
+
it "updates the offset" do
|
78
|
+
create :product
|
79
|
+
|
80
|
+
expect(redis.get(Redstream.offset_key_name(stream_name: "products", consumer_name: "consumer"))).to be(nil)
|
81
|
+
|
82
|
+
all_messages = redis.xrange(Redstream.stream_key_name("products"), "-", "+")
|
83
|
+
|
84
|
+
Redstream::Consumer.new(name: "consumer", stream_name: "products").run_once {}
|
85
|
+
|
86
|
+
expect(redis.get(Redstream.offset_key_name(stream_name: "products", consumer_name: "consumer"))).to eq(all_messages.last[0])
|
87
|
+
end
|
88
|
+
end
|
89
|
+
end
|
90
|
+
|
@@ -0,0 +1,53 @@
|
|
1
|
+
|
2
|
+
require File.expand_path("../spec_helper", __dir__)
|
3
|
+
|
4
|
+
RSpec.describe Redstream::Delayer do
|
5
|
+
describe "#run_once" do
|
6
|
+
it "copies expired messages to their target streams" do
|
7
|
+
redis.xadd Redstream.stream_key_name("target.delay"), payload: JSON.dump(value: "message")
|
8
|
+
|
9
|
+
expect(redis.xlen(Redstream.stream_key_name("target"))).to eq(0)
|
10
|
+
|
11
|
+
Redstream::Delayer.new(stream_name: "target", delay: 0).run_once
|
12
|
+
|
13
|
+
expect(redis.xlen(Redstream.stream_key_name("target"))).to eq(1)
|
14
|
+
expect(redis.xrange(Redstream.stream_key_name("target")).last[1]).to eq("payload" => JSON.dump(value: "message"))
|
15
|
+
end
|
16
|
+
|
17
|
+
it "delivers and commit before falling asleep" do
|
18
|
+
redis.xadd Redstream.stream_key_name("target.delay"), payload: JSON.dump(value: "message")
|
19
|
+
sleep 3
|
20
|
+
redis.xadd Redstream.stream_key_name("target.delay"), payload: JSON.dump(value: "message")
|
21
|
+
|
22
|
+
thread = Thread.new do
|
23
|
+
Redstream::Delayer.new(stream_name: "target", delay: 1).run_once
|
24
|
+
end
|
25
|
+
|
26
|
+
sleep 1
|
27
|
+
|
28
|
+
expect(redis.xlen(Redstream.stream_key_name("target"))).to eq(1)
|
29
|
+
expect(redis.get(Redstream.offset_key_name(stream_name: "target.delay", consumer_name: "delayer"))).not_to be_nil
|
30
|
+
|
31
|
+
thread.join
|
32
|
+
|
33
|
+
expect(redis.xlen(Redstream.stream_key_name("target"))).to eq(2)
|
34
|
+
end
|
35
|
+
|
36
|
+
it "does not copy not yet expired messages" do
|
37
|
+
redis.xadd Redstream.stream_key_name("target.delay"), payload: JSON.dump(value: "message")
|
38
|
+
|
39
|
+
thread = Thread.new do
|
40
|
+
Redstream::Delayer.new(stream_name: "target", delay: 2).run_once
|
41
|
+
end
|
42
|
+
|
43
|
+
sleep 1
|
44
|
+
|
45
|
+
expect(redis.xlen(Redstream.stream_key_name("target"))).to eq(0)
|
46
|
+
|
47
|
+
thread.join
|
48
|
+
|
49
|
+
expect(redis.xlen(Redstream.stream_key_name("target"))).to eq(1)
|
50
|
+
end
|
51
|
+
end
|
52
|
+
end
|
53
|
+
|
@@ -0,0 +1,68 @@
|
|
1
|
+
|
2
|
+
require File.expand_path("../spec_helper", __dir__)
|
3
|
+
|
4
|
+
RSpec.describe Redstream::Lock do
|
5
|
+
describe "#acquire" do
|
6
|
+
it "gets a lock" do
|
7
|
+
lock_results = Concurrent::Array.new
|
8
|
+
calls = Concurrent::AtomicFixnum.new(0)
|
9
|
+
|
10
|
+
threads = Array.new(2) do |i|
|
11
|
+
Thread.new do
|
12
|
+
lock_results << Redstream::Lock.new(name: "lock").acquire do
|
13
|
+
calls.increment
|
14
|
+
|
15
|
+
sleep 1
|
16
|
+
end
|
17
|
+
end
|
18
|
+
end
|
19
|
+
|
20
|
+
threads.each(&:join)
|
21
|
+
|
22
|
+
expect(calls.value).to eq(1)
|
23
|
+
expect(lock_results.to_set).to eq([1, nil].to_set)
|
24
|
+
end
|
25
|
+
|
26
|
+
it "keeps the lock" do
|
27
|
+
threads = []
|
28
|
+
calls = Concurrent::Array.new
|
29
|
+
|
30
|
+
threads << Thread.new do
|
31
|
+
Redstream::Lock.new(name: "lock").acquire do
|
32
|
+
calls << "thread-1"
|
33
|
+
|
34
|
+
sleep 6
|
35
|
+
end
|
36
|
+
end
|
37
|
+
|
38
|
+
sleep 6
|
39
|
+
|
40
|
+
threads << Thread.new do
|
41
|
+
Redstream::Lock.new(name: "lock").acquire do
|
42
|
+
calls << "thread-2"
|
43
|
+
end
|
44
|
+
end
|
45
|
+
|
46
|
+
threads.each(&:join)
|
47
|
+
|
48
|
+
expect(calls).to eq(["thread-1"])
|
49
|
+
end
|
50
|
+
|
51
|
+
it "does not lock itself" do
|
52
|
+
lock = Redstream::Lock.new(name: "lock")
|
53
|
+
|
54
|
+
lock_results = []
|
55
|
+
calls = 0
|
56
|
+
|
57
|
+
2.times do
|
58
|
+
lock_results << lock.acquire do
|
59
|
+
calls += 1
|
60
|
+
end
|
61
|
+
end
|
62
|
+
|
63
|
+
expect(calls).to eq(2)
|
64
|
+
expect(lock_results).to eq([1, 1])
|
65
|
+
end
|
66
|
+
end
|
67
|
+
end
|
68
|
+
|
@@ -0,0 +1,57 @@
|
|
1
|
+
|
2
|
+
require File.expand_path("../spec_helper", __dir__)
|
3
|
+
|
4
|
+
RSpec.describe Redstream::Model do
|
5
|
+
it "adds a delay message after save" do
|
6
|
+
expect(redis.xlen(Redstream.stream_key_name("products.delay"))).to eq(0)
|
7
|
+
|
8
|
+
time = Time.now
|
9
|
+
|
10
|
+
product = Timecop.freeze(time) do
|
11
|
+
create(:product)
|
12
|
+
end
|
13
|
+
|
14
|
+
expect(redis.xlen(Redstream.stream_key_name("products.delay"))).to eq(1)
|
15
|
+
expect(redis.xrange(Redstream.stream_key_name("products.delay"), "-", "+").first[1]).to eq("payload" => JSON.dump(product.redstream_payload))
|
16
|
+
end
|
17
|
+
|
18
|
+
it "adds a delay message after touch" do
|
19
|
+
expect(redis.xlen(Redstream.stream_key_name("products.delay"))).to eq(0)
|
20
|
+
|
21
|
+
product = create(:product)
|
22
|
+
|
23
|
+
time = Time.now
|
24
|
+
|
25
|
+
Timecop.freeze(time) do
|
26
|
+
product.touch
|
27
|
+
end
|
28
|
+
|
29
|
+
expect(redis.xlen(Redstream.stream_key_name("products.delay"))).to eq(2)
|
30
|
+
expect(redis.xrange(Redstream.stream_key_name("products.delay"), "-", "+").last[1]).to eq("payload" => JSON.dump(product.redstream_payload))
|
31
|
+
end
|
32
|
+
|
33
|
+
it "adds a delay message after destroy" do
|
34
|
+
expect(redis.xlen(Redstream.stream_key_name("products.delay"))).to eq(0)
|
35
|
+
|
36
|
+
product = create(:product)
|
37
|
+
|
38
|
+
time = Time.now
|
39
|
+
|
40
|
+
Timecop.freeze(time) do
|
41
|
+
product.touch
|
42
|
+
end
|
43
|
+
|
44
|
+
expect(redis.xlen(Redstream.stream_key_name("products.delay"))).to eq(2)
|
45
|
+
expect(redis.xrange(Redstream.stream_key_name("products.delay"), "-", "+").last[1]).to eq("payload" => JSON.dump(product.redstream_payload))
|
46
|
+
end
|
47
|
+
|
48
|
+
it "adds a queue message after commit" do
|
49
|
+
expect(redis.xlen(Redstream.stream_key_name("products"))).to eq(0)
|
50
|
+
|
51
|
+
product = create(:product)
|
52
|
+
|
53
|
+
expect(redis.xlen(Redstream.stream_key_name("products"))).to eq(1)
|
54
|
+
expect(redis.xrange(Redstream.stream_key_name("products"), "-", "+").first[1]).to eq("payload" => JSON.dump(product.redstream_payload))
|
55
|
+
end
|
56
|
+
end
|
57
|
+
|
@@ -0,0 +1,79 @@
|
|
1
|
+
|
2
|
+
require File.expand_path("../spec_helper", __dir__)
|
3
|
+
|
4
|
+
RSpec.describe Redstream::Producer do
|
5
|
+
describe "#queue" do
|
6
|
+
it "adds a queue message for individual objects" do
|
7
|
+
product = create(:product)
|
8
|
+
|
9
|
+
stream_key_name = Redstream.stream_key_name("products")
|
10
|
+
|
11
|
+
expect { Redstream::Producer.new.queue(product) }.to change { redis.xlen(stream_key_name) }.by(1)
|
12
|
+
expect(redis.xrange(stream_key_name, "-", "+").last[1]).to eq("payload" => JSON.dump(product.redstream_payload))
|
13
|
+
end
|
14
|
+
end
|
15
|
+
|
16
|
+
describe "#delay" do
|
17
|
+
it "adds a delay message for individual objects" do
|
18
|
+
product = create(:product)
|
19
|
+
|
20
|
+
stream_key_name = Redstream.stream_key_name("products.delay")
|
21
|
+
|
22
|
+
expect { Redstream::Producer.new.delay(product) }.to change { redis.xlen(stream_key_name) }.by(1)
|
23
|
+
expect(redis.xrange(stream_key_name, "-", "+").last[1]).to eq("payload" => JSON.dump(product.redstream_payload))
|
24
|
+
end
|
25
|
+
|
26
|
+
it "resepects wait" do
|
27
|
+
product = create(:product)
|
28
|
+
|
29
|
+
stream_key_name = Redstream.stream_key_name("products.delay")
|
30
|
+
|
31
|
+
expect { Redstream::Producer.new(wait: 0).delay(product) }.to change { redis.xlen(stream_key_name) }.by(1)
|
32
|
+
end
|
33
|
+
end
|
34
|
+
|
35
|
+
describe "#bulk_queue" do
|
36
|
+
it "adds bulk queue messages for scopes" do
|
37
|
+
products = create_list(:product, 2)
|
38
|
+
|
39
|
+
stream_key_name = Redstream.stream_key_name("products")
|
40
|
+
|
41
|
+
expect { Redstream::Producer.new.bulk_queue(Product.all) }.to change { redis.xlen(stream_key_name) }.by(2)
|
42
|
+
|
43
|
+
messages = redis.xrange(stream_key_name, "-", "+").last(2).map { |message| message[1] }
|
44
|
+
|
45
|
+
expect(messages).to eq([
|
46
|
+
{ "payload" => JSON.dump(products[0].redstream_payload) },
|
47
|
+
{ "payload" => JSON.dump(products[1].redstream_payload) }
|
48
|
+
])
|
49
|
+
end
|
50
|
+
end
|
51
|
+
|
52
|
+
describe "#bulk_delay" do
|
53
|
+
it "adds bulk delay messages for scopes" do
|
54
|
+
products = create_list(:product, 2)
|
55
|
+
|
56
|
+
stream_key_name = Redstream.stream_key_name("products.delay")
|
57
|
+
|
58
|
+
expect { Redstream::Producer.new.bulk_delay(Product.all) }.to change { redis.xlen(stream_key_name) }.by(2)
|
59
|
+
|
60
|
+
messages = redis.xrange(stream_key_name, "-", "+").last(2).map { |message| message[1] }
|
61
|
+
|
62
|
+
expect(messages).to eq([
|
63
|
+
{ "payload" => JSON.dump(products[0].redstream_payload) },
|
64
|
+
{ "payload" => JSON.dump(products[1].redstream_payload) }
|
65
|
+
])
|
66
|
+
end
|
67
|
+
|
68
|
+
it "should resepect wait for delay" do
|
69
|
+
product = create(:product)
|
70
|
+
|
71
|
+
stream_key_name = Redstream.stream_key_name("products.delay")
|
72
|
+
|
73
|
+
products = create_list(:product, 2)
|
74
|
+
|
75
|
+
expect { Redstream::Producer.new(wait: 0).bulk_delay(products) }.to change { redis.xlen(stream_key_name) }.by(2)
|
76
|
+
end
|
77
|
+
end
|
78
|
+
end
|
79
|
+
|