captainu-tincan 0.7.0
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 +22 -0
- data/.rspec +2 -0
- data/.rubocop.yml +8 -0
- data/.ruby-version +1 -0
- data/.travis.yml +22 -0
- data/Gemfile +4 -0
- data/Rakefile +10 -0
- data/bin/tincan +14 -0
- data/bin/tincanctl +91 -0
- data/lib/tincan.rb +8 -0
- data/lib/tincan/cli.rb +305 -0
- data/lib/tincan/failure.rb +67 -0
- data/lib/tincan/message.rb +79 -0
- data/lib/tincan/receiver.rb +167 -0
- data/lib/tincan/sender.rb +101 -0
- data/lib/tincan/version.rb +3 -0
- data/license.markdown +22 -0
- data/readme.markdown +120 -0
- data/spec/failure_spec.rb +58 -0
- data/spec/fixtures/failure.json +1 -0
- data/spec/fixtures/message.json +1 -0
- data/spec/message_spec.rb +68 -0
- data/spec/receiver_spec.rb +202 -0
- data/spec/sender_spec.rb +121 -0
- data/spec/spec_helper.rb +15 -0
- data/spec/support/dummy.rb +8 -0
- data/spec/support/futuristic.rb +61 -0
- data/spec/tincan_spec.rb +7 -0
- data/tincan.gemspec +35 -0
- metadata +213 -0
@@ -0,0 +1,58 @@
|
|
1
|
+
require 'spec_helper'
|
2
|
+
|
3
|
+
describe Tincan::Failure do
|
4
|
+
let(:dummy) do
|
5
|
+
instance = DummyClass.new
|
6
|
+
instance.name = 'Some Idiot'
|
7
|
+
instance
|
8
|
+
end
|
9
|
+
|
10
|
+
# let(:message_fixture) { IO.read('spec/fixtures/message.json').strip }
|
11
|
+
# let(:message) { Tincan::Message.from_json(message_fixture) }
|
12
|
+
let(:failure) { Tincan::Failure.new(100, 'some_queue') }
|
13
|
+
let(:fixture) { IO.read('spec/fixtures/failure.json').strip }
|
14
|
+
|
15
|
+
describe :initialize do
|
16
|
+
it 'takes an message object, sets attempt count to 0' do
|
17
|
+
expect(failure).to be_a(Tincan::Failure)
|
18
|
+
expect(failure.attempt_count).to eq(0)
|
19
|
+
expect(failure.failed_at).to be_a(DateTime)
|
20
|
+
end
|
21
|
+
end
|
22
|
+
|
23
|
+
describe :attempt_after do
|
24
|
+
it 'takes failed date/time and extends it by a number of seconds' do
|
25
|
+
failure.attempt_count = 1
|
26
|
+
expected = Rational(10, 86400)
|
27
|
+
expect(failure.attempt_after).to eq(failure.failed_at + expected)
|
28
|
+
end
|
29
|
+
|
30
|
+
it 'extends the next attempt based on attempt_count' do
|
31
|
+
failure.attempt_count = 2
|
32
|
+
expected = Rational(20, 86400)
|
33
|
+
expect(failure.attempt_after).to eq(failure.failed_at + expected)
|
34
|
+
end
|
35
|
+
end
|
36
|
+
|
37
|
+
describe :to_json do
|
38
|
+
it 'converts the failure to a serialized JSON string' do
|
39
|
+
expect(failure.to_json).to be_a(String)
|
40
|
+
expect { JSON.parse(failure.to_json) }.to_not raise_error
|
41
|
+
end
|
42
|
+
end
|
43
|
+
|
44
|
+
describe :from_json do
|
45
|
+
let(:from_json) { Tincan::Failure.from_json(fixture) }
|
46
|
+
|
47
|
+
it 'deserializes an object from a JSON string' do
|
48
|
+
expect(from_json).to be_a(Tincan::Failure)
|
49
|
+
end
|
50
|
+
|
51
|
+
it 'sets everything up properly' do
|
52
|
+
expect(from_json.message_id).to eq('55')
|
53
|
+
expect(from_json.attempt_count).to eq(1)
|
54
|
+
expect(from_json.failed_at).to be_a(DateTime)
|
55
|
+
expect(from_json.queue_name).to eq('data:object_one:client:messages')
|
56
|
+
end
|
57
|
+
end
|
58
|
+
end
|
@@ -0,0 +1 @@
|
|
1
|
+
{"failed_at":"2014-06-02T09:30:47-05:00","attempt_count":1,"message_id":"55","queue_name":"data:object_one:client:messages"}
|
@@ -0,0 +1 @@
|
|
1
|
+
{"object_name":"dummy_class","change_type":"create","object_data":{"name":"Some Idiot"},"published_at":"2014-06-02T09:43:36-05:00"}
|
@@ -0,0 +1,68 @@
|
|
1
|
+
require 'spec_helper'
|
2
|
+
|
3
|
+
describe Tincan::Message do
|
4
|
+
let(:dummy) do
|
5
|
+
instance = DummyClass.new
|
6
|
+
instance.name = 'Some Idiot'
|
7
|
+
instance
|
8
|
+
end
|
9
|
+
|
10
|
+
let(:message) do
|
11
|
+
Tincan::Message.new do |m|
|
12
|
+
m.object_name = 'dummy_class'
|
13
|
+
m.object_data = { name: dummy.name }
|
14
|
+
m.change_type = :create
|
15
|
+
end
|
16
|
+
end
|
17
|
+
let(:fixture) { IO.read('spec/fixtures/message.json').strip }
|
18
|
+
|
19
|
+
describe :initialize do
|
20
|
+
it 'takes an object and a change type symbol' do
|
21
|
+
expect(message).to be_a(Tincan::Message)
|
22
|
+
end
|
23
|
+
|
24
|
+
it 'fails with an invalid symbol type' do
|
25
|
+
process = -> { Tincan::Message.new(DummyClass.new, :bork) }
|
26
|
+
expect(process).to raise_error(ArgumentError)
|
27
|
+
end
|
28
|
+
|
29
|
+
it 'stores these as properties' do
|
30
|
+
dummy = DummyClass.new
|
31
|
+
msg = Tincan::Message.new do |m|
|
32
|
+
m.object_name = 'dummy_class'
|
33
|
+
m.object_data = { name: dummy.name }
|
34
|
+
m.change_type = :create
|
35
|
+
end
|
36
|
+
expect(msg.object_name).to eq('dummy_class')
|
37
|
+
expect(msg.change_type).to eq(:create)
|
38
|
+
expect(msg.object_data).to eq(name: dummy.name)
|
39
|
+
expect(msg.published_at).to be_a(DateTime)
|
40
|
+
end
|
41
|
+
end
|
42
|
+
|
43
|
+
describe :to_json do
|
44
|
+
it 'converts the message to a serialized JSON string' do
|
45
|
+
expect(message.to_json).to be_a(String)
|
46
|
+
expect { JSON.parse(message.to_json) }.to_not raise_error
|
47
|
+
end
|
48
|
+
|
49
|
+
it 'includes the proper data' do
|
50
|
+
fixture.slice!('"2014-06-02T09:43:36-05:00"}')
|
51
|
+
expect(message.to_json).to start_with(fixture)
|
52
|
+
end
|
53
|
+
end
|
54
|
+
|
55
|
+
describe :from_json do
|
56
|
+
let(:from_json) { Tincan::Message.from_json(fixture) }
|
57
|
+
it 'deserializes an object from a JSON string' do
|
58
|
+
expect(from_json).to be_a(Tincan::Message)
|
59
|
+
end
|
60
|
+
|
61
|
+
it 'sets everything up properly' do
|
62
|
+
expect(from_json.object_name).to eq('dummy_class')
|
63
|
+
expect(from_json.change_type).to eq(:create)
|
64
|
+
expect(from_json.object_data).to eq('name' => 'Some Idiot')
|
65
|
+
expect(from_json.published_at).to be_a(DateTime)
|
66
|
+
end
|
67
|
+
end
|
68
|
+
end
|
@@ -0,0 +1,202 @@
|
|
1
|
+
require 'spec_helper'
|
2
|
+
|
3
|
+
# A test helper for handling messages.
|
4
|
+
class Handler
|
5
|
+
attr_accessor :data, :context
|
6
|
+
end
|
7
|
+
|
8
|
+
describe Tincan::Receiver do
|
9
|
+
let(:fixture) { IO.read('spec/fixtures/message.json').strip }
|
10
|
+
let(:receiver) { Tincan::Receiver.new(options) }
|
11
|
+
let(:redis) { ::Redis.new(host: options[:redis_host]) }
|
12
|
+
let(:handler_one_alpha) { Handler.new }
|
13
|
+
let(:handler_one_beta) { Handler.new }
|
14
|
+
let(:handler_two_alpha) { Handler.new }
|
15
|
+
let(:exception_handler) { Handler.new }
|
16
|
+
let(:options) do
|
17
|
+
{
|
18
|
+
redis_host: 'localhost',
|
19
|
+
redis_port: 6379,
|
20
|
+
client_name: 'bork',
|
21
|
+
namespace: 'data',
|
22
|
+
logger: ::Logger.new(STDOUT),
|
23
|
+
listen_to: {
|
24
|
+
object_one: [
|
25
|
+
-> (data) { handler_one_alpha.data = data },
|
26
|
+
-> (data) { handler_one_beta.data = data }
|
27
|
+
],
|
28
|
+
object_two: [-> (data) { handler_two_alpha.data = data }]
|
29
|
+
},
|
30
|
+
on_exception: (lambda do |ex, context|
|
31
|
+
exception_handler.data = ex
|
32
|
+
exception_handler.context = context
|
33
|
+
end)
|
34
|
+
}
|
35
|
+
end
|
36
|
+
|
37
|
+
before { redis.flushdb }
|
38
|
+
|
39
|
+
describe :lifecycle do
|
40
|
+
it 'can be setup with a block' do
|
41
|
+
instance = Tincan::Receiver.new do |config|
|
42
|
+
options.keys.each do |key|
|
43
|
+
config.send("#{key}=", options[key])
|
44
|
+
end
|
45
|
+
end
|
46
|
+
|
47
|
+
options.keys.each do |key|
|
48
|
+
expect(instance.send(key)).to eq(options[key])
|
49
|
+
end
|
50
|
+
end
|
51
|
+
|
52
|
+
it 'can be setup with an options hash' do
|
53
|
+
options.keys.each do |key|
|
54
|
+
expect(receiver.send(key)).to eq(options[key])
|
55
|
+
end
|
56
|
+
end
|
57
|
+
end
|
58
|
+
|
59
|
+
describe :related_objects do
|
60
|
+
it 'memoizes a redis client' do
|
61
|
+
expect(receiver.redis_client).to be_a(Redis)
|
62
|
+
expect(receiver.redis_client.client.host).to eq(receiver.redis_host)
|
63
|
+
expect(receiver.redis_client.client.port).to eq(receiver.redis_port)
|
64
|
+
end
|
65
|
+
end
|
66
|
+
|
67
|
+
describe :transactional_methods do
|
68
|
+
describe :register do
|
69
|
+
it 'registers itself as a receiver for supplied channels' do
|
70
|
+
receiver.register
|
71
|
+
receivers = redis.smembers('data:object_one:receivers')
|
72
|
+
expect(receivers).to include(receiver.client_name)
|
73
|
+
end
|
74
|
+
|
75
|
+
it 'returns self' do
|
76
|
+
expect(receiver.register).to eq(receiver)
|
77
|
+
end
|
78
|
+
end
|
79
|
+
|
80
|
+
describe :store_failure do
|
81
|
+
let(:failure) do
|
82
|
+
Tincan::Failure.new('55', 'data:object_one:client:messages')
|
83
|
+
end
|
84
|
+
|
85
|
+
it 'stores a message ID in a specialized failures list' do
|
86
|
+
failure.attempt_count = 1
|
87
|
+
receiver.store_failure(failure)
|
88
|
+
failures = redis.lrange('data:object_one:client:failures', 0, -1)
|
89
|
+
expected = '"attempt_count":1,"message_id":"55","queue_name":'
|
90
|
+
expected << '"data:object_one:client:messages"}'
|
91
|
+
expect(failures.first).to include(expected)
|
92
|
+
end
|
93
|
+
|
94
|
+
it 'returns the message count in the failures queue' do
|
95
|
+
result = receiver.store_failure(failure)
|
96
|
+
expect(result).to eq(1)
|
97
|
+
|
98
|
+
result = receiver.store_failure(failure)
|
99
|
+
expect(result).to eq(2)
|
100
|
+
|
101
|
+
failure.message_id = 100
|
102
|
+
result = receiver.store_failure(failure)
|
103
|
+
expect(result).to eq(3)
|
104
|
+
end
|
105
|
+
end
|
106
|
+
end
|
107
|
+
|
108
|
+
describe :message_handling_methods do
|
109
|
+
describe :handle_message_for_object do
|
110
|
+
it 'iterates through the channel dict and calls lambdas' do
|
111
|
+
msg = OpenStruct.new(object_data: 'hello world')
|
112
|
+
receiver.handle_message_for_object('object_one', msg)
|
113
|
+
msg2 = OpenStruct.new(object_data: 'goodbye world')
|
114
|
+
receiver.handle_message_for_object('object_two', msg2)
|
115
|
+
|
116
|
+
expect(handler_one_alpha.data).to eq(msg)
|
117
|
+
expect(handler_one_beta.data).to eq(msg)
|
118
|
+
expect(handler_two_alpha.data).to eq(msg2)
|
119
|
+
end
|
120
|
+
end
|
121
|
+
end
|
122
|
+
|
123
|
+
describe :loop_methods do
|
124
|
+
# These are very much "integration" tests as they test the main entry point
|
125
|
+
# and the conditions of the receiver from top to bottom.
|
126
|
+
describe :listen do
|
127
|
+
it 'registers, subscribes, calls methods, and DOES ALL THE THINGS' do
|
128
|
+
thread = Thread.new { receiver.listen }
|
129
|
+
|
130
|
+
get_receivers = -> { redis.smembers('data:object_one:receivers') }
|
131
|
+
expect(get_receivers).to eventually_equal(%w(bork))
|
132
|
+
|
133
|
+
redis.set('data:object_one:messages:1', fixture)
|
134
|
+
receivers = redis.smembers('data:object_one:receivers')
|
135
|
+
receivers.each do |receiver|
|
136
|
+
redis.rpush("data:object_one:#{receiver}:messages", '1')
|
137
|
+
end
|
138
|
+
|
139
|
+
message = Tincan::Message.from_json(fixture)
|
140
|
+
expect { handler_one_alpha.data }.to eventually_equal(message)
|
141
|
+
expect { handler_one_beta.data }.to eventually_equal(message)
|
142
|
+
|
143
|
+
thread.kill
|
144
|
+
end
|
145
|
+
|
146
|
+
xit 'calls a stored exception block on failure and keeps on ticking' do
|
147
|
+
pending 'Fails when part of an entire run, but not by itself.'
|
148
|
+
thread = Thread.new { receiver.listen }
|
149
|
+
|
150
|
+
get_receivers = -> { redis.smembers('data:object_one:receivers') }
|
151
|
+
expect(get_receivers).to eventually_equal(%w(bork))
|
152
|
+
|
153
|
+
bad_data = { name: 'this data sucks' }.to_json
|
154
|
+
redis.set('data:object_one:messages:2', bad_data)
|
155
|
+
receivers = redis.smembers('data:object_one:receivers')
|
156
|
+
receivers.each do |receiver|
|
157
|
+
redis.rpush("data:object_one:#{receiver}:messages", '2')
|
158
|
+
end
|
159
|
+
|
160
|
+
expect { exception_handler.data }.to eventually_be_a(NoMethodError)
|
161
|
+
expect { exception_handler.context }.to eventually_be_a(Hash)
|
162
|
+
|
163
|
+
thread.kill
|
164
|
+
end
|
165
|
+
end
|
166
|
+
end
|
167
|
+
|
168
|
+
describe :formatting_helper_methods do
|
169
|
+
describe :message_for_id do
|
170
|
+
let(:result) { receiver.message_for_id(1, 'bobsyouruncle') }
|
171
|
+
let(:message) { Tincan::Message.from_json(fixture) }
|
172
|
+
before do
|
173
|
+
redis.set('data:bobsyouruncle:messages:1', fixture)
|
174
|
+
end
|
175
|
+
|
176
|
+
it 'retrieves a message from Redis based on an ID and object' do
|
177
|
+
expect(result).to eq(message)
|
178
|
+
end
|
179
|
+
|
180
|
+
it 'is in the form of a Tincan::Message object' do
|
181
|
+
expect(result).to be_a(Tincan::Message)
|
182
|
+
end
|
183
|
+
|
184
|
+
it 'returns nil if the object was not found' do
|
185
|
+
redis.del('data:bobsyouruncle:messages:1')
|
186
|
+
expect(result).to be_nil
|
187
|
+
end
|
188
|
+
end
|
189
|
+
|
190
|
+
describe :message_list_keys do
|
191
|
+
it 'converts the listen_to ivar into properly-formatted Redis keys' do
|
192
|
+
expected = %w(one two).map do |i|
|
193
|
+
[
|
194
|
+
"data:object_#{i}:bork:messages",
|
195
|
+
"data:object_#{i}:bork:failures"
|
196
|
+
]
|
197
|
+
end.flatten
|
198
|
+
expect(receiver.message_list_keys).to eq(expected)
|
199
|
+
end
|
200
|
+
end
|
201
|
+
end
|
202
|
+
end
|
data/spec/sender_spec.rb
ADDED
@@ -0,0 +1,121 @@
|
|
1
|
+
require 'spec_helper'
|
2
|
+
|
3
|
+
describe Tincan::Sender do
|
4
|
+
let(:sender) { Tincan::Sender.new(options) }
|
5
|
+
let(:redis) { ::Redis.new(host: options[:redis_host]) }
|
6
|
+
let(:fixture) { IO.read('spec/fixtures/message.json').strip }
|
7
|
+
let(:message) { Tincan::Message.from_json(fixture) }
|
8
|
+
let(:options) do
|
9
|
+
{
|
10
|
+
redis_host: 'localhost',
|
11
|
+
redis_port: 6379,
|
12
|
+
namespace: 'data'
|
13
|
+
}
|
14
|
+
end
|
15
|
+
|
16
|
+
before { redis.flushdb }
|
17
|
+
|
18
|
+
describe :lifecycle do
|
19
|
+
it 'can be setup with a block' do
|
20
|
+
instance = Tincan::Sender.new do |config|
|
21
|
+
options.keys.each do |key|
|
22
|
+
config.send("#{key}=", options[key])
|
23
|
+
end
|
24
|
+
end
|
25
|
+
|
26
|
+
options.keys.each do |key|
|
27
|
+
expect(instance.send(key)).to eq(options[key])
|
28
|
+
end
|
29
|
+
end
|
30
|
+
|
31
|
+
it 'can be setup with an options hash' do
|
32
|
+
options.keys.each do |key|
|
33
|
+
expect(sender.send(key)).to eq(options[key])
|
34
|
+
end
|
35
|
+
end
|
36
|
+
end
|
37
|
+
|
38
|
+
describe :related_objects do
|
39
|
+
it 'memoizes a redis client' do
|
40
|
+
expect(sender.redis_client).to be_a(Redis)
|
41
|
+
expect(sender.redis_client.client.host).to eq(sender.redis_host)
|
42
|
+
expect(sender.redis_client.client.port).to eq(sender.redis_port)
|
43
|
+
end
|
44
|
+
end
|
45
|
+
|
46
|
+
describe :transactional_methods do
|
47
|
+
before { redis.sadd('data:object:receivers', 'some_client') }
|
48
|
+
|
49
|
+
describe :keys_for_receivers do
|
50
|
+
it 'grabs receivers from Redis, maps them into message list keys' do
|
51
|
+
result = sender.keys_for_receivers('object')
|
52
|
+
expect(result).to eq(%w(data:object:some_client:messages))
|
53
|
+
end
|
54
|
+
end
|
55
|
+
|
56
|
+
describe :flush_all_queues_for_object do
|
57
|
+
it 'deletes all receiver object keys using keys_for_receivers' do
|
58
|
+
redis.rpush('data:object:some_client:messages', 'message!!!')
|
59
|
+
expect(redis.llen('data:object:some_client:messages')).to eq(1)
|
60
|
+
sender.flush_all_queues_for_object('object')
|
61
|
+
expect(redis.llen('data:object:some_client:messages')).to eq(0)
|
62
|
+
result = redis.lpop('data:object:some_client:messages')
|
63
|
+
expect(result).to be_nil
|
64
|
+
end
|
65
|
+
end
|
66
|
+
end
|
67
|
+
|
68
|
+
describe :communication_methods do
|
69
|
+
# These are very much "integration" tests as they test the main entry point
|
70
|
+
# and the conditions of the sender from top to bottom.
|
71
|
+
describe :publish do
|
72
|
+
let(:dummy) do
|
73
|
+
instance = DummyClass.new
|
74
|
+
instance.name = 'Some Idiot'
|
75
|
+
instance
|
76
|
+
end
|
77
|
+
|
78
|
+
let(:message) do
|
79
|
+
Tincan::Message.new do |m|
|
80
|
+
m.object_name = 'dummy_class'
|
81
|
+
m.object_data = { name: dummy.name }
|
82
|
+
m.change_type = :create
|
83
|
+
end
|
84
|
+
end
|
85
|
+
|
86
|
+
before do
|
87
|
+
redis.sadd('data:dummy_class:receivers', 'some_client')
|
88
|
+
sender.publish(message)
|
89
|
+
@timestamp = Time.now.to_i
|
90
|
+
end
|
91
|
+
|
92
|
+
it 'publishes a message to Redis at a specific key' do
|
93
|
+
message = redis.get("data:dummy_class:messages:#{@timestamp}")
|
94
|
+
expect(message).to be_a(String)
|
95
|
+
expected = '{"object_name":"dummy_class","change_type":"create",'
|
96
|
+
expected += '"object_data":{"name":"Some Idiot"},"published_at":'
|
97
|
+
expect(message).to start_with(expected)
|
98
|
+
end
|
99
|
+
|
100
|
+
it 'also publishes a message ID to client-specific receiver lists' do
|
101
|
+
identifier = redis.lpop('data:dummy_class:some_client:messages')
|
102
|
+
expect(identifier).to eq(@timestamp.to_s)
|
103
|
+
end
|
104
|
+
end
|
105
|
+
end
|
106
|
+
|
107
|
+
describe :formatting_helper_methods do
|
108
|
+
describe :identifier_for_message do
|
109
|
+
it 'generates a timestamp from the passed-in message' do
|
110
|
+
expect(sender.identifier_for_message(message)).to eq(1401720216)
|
111
|
+
end
|
112
|
+
end
|
113
|
+
|
114
|
+
describe :primary_key_for_message do
|
115
|
+
it 'joins namespace, object name, and more to create a unique key' do
|
116
|
+
expected = 'data:dummy_class:messages:1401720216'
|
117
|
+
expect(sender.primary_key_for_message(message)).to eq(expected)
|
118
|
+
end
|
119
|
+
end
|
120
|
+
end
|
121
|
+
end
|