fluent-plugin-gcloud-pubsub-custom-subscriber 1.3.1
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +7 -0
- data/.gitignore +11 -0
- data/.travis.yml +25 -0
- data/CHANGELOG.md +134 -0
- data/Gemfile +3 -0
- data/LICENSE +22 -0
- data/README.md +178 -0
- data/Rakefile +12 -0
- data/fluent-plugin-gcloud-pubsub-custom.gemspec +24 -0
- data/lib/fluent/plugin/gcloud_pubsub/client.rb +80 -0
- data/lib/fluent/plugin/in_gcloud_pubsub.rb +253 -0
- data/lib/fluent/plugin/out_gcloud_pubsub.rb +110 -0
- data/test/plugin/test_in_gcloud_pubsub.rb +387 -0
- data/test/plugin/test_out_gcloud_pubsub.rb +226 -0
- data/test/test_helper.rb +31 -0
- metadata +148 -0
@@ -0,0 +1,253 @@
|
|
1
|
+
require 'json'
|
2
|
+
require 'webrick'
|
3
|
+
|
4
|
+
require 'fluent/plugin/input'
|
5
|
+
require 'fluent/plugin/parser'
|
6
|
+
|
7
|
+
require 'fluent/plugin/gcloud_pubsub/client'
|
8
|
+
|
9
|
+
module Fluent::Plugin
|
10
|
+
class GcloudPubSubInput < Input
|
11
|
+
Fluent::Plugin.register_input('gcloud_pubsub', self)
|
12
|
+
|
13
|
+
helpers :compat_parameters, :parser, :thread
|
14
|
+
|
15
|
+
DEFAULT_PARSER_TYPE = 'json'
|
16
|
+
|
17
|
+
class FailedParseError < StandardError
|
18
|
+
end
|
19
|
+
|
20
|
+
desc 'Set tag of messages.'
|
21
|
+
config_param :tag, :string
|
22
|
+
desc 'Set key to be used as tag.'
|
23
|
+
config_param :tag_key, :string, default: nil
|
24
|
+
desc 'Set your GCP project.'
|
25
|
+
config_param :project, :string, default: nil
|
26
|
+
desc 'Set your credential file path.'
|
27
|
+
config_param :key, :string, default: nil
|
28
|
+
desc 'Set topic name to pull.'
|
29
|
+
config_param :topic, :string
|
30
|
+
desc 'Set subscription name to pull.'
|
31
|
+
config_param :subscription, :string
|
32
|
+
desc 'Pulling messages by intervals of specified seconds.'
|
33
|
+
config_param :pull_interval, :float, default: 5.0
|
34
|
+
desc 'Max messages pulling at once.'
|
35
|
+
config_param :max_messages, :integer, default: 100
|
36
|
+
desc 'Setting `true`, keepalive connection to wait for new messages.'
|
37
|
+
config_param :return_immediately, :bool, default: true
|
38
|
+
desc 'Set number of threads to pull messages.'
|
39
|
+
config_param :pull_threads, :integer, default: 1
|
40
|
+
desc 'Specify the key of the attribute to be acquired as a record'
|
41
|
+
config_param :attribute_keys, :array, default: []
|
42
|
+
desc 'Set error type when parsing messages fails.'
|
43
|
+
config_param :parse_error_action, :enum, default: :exception, list: [:exception, :warning]
|
44
|
+
# for HTTP RPC
|
45
|
+
desc 'If `true` is specified, HTTP RPC to stop or start pulling message is enabled.'
|
46
|
+
config_param :enable_rpc, :bool, default: false
|
47
|
+
desc 'Bind IP address for HTTP RPC.'
|
48
|
+
config_param :rpc_bind, :string, default: '0.0.0.0'
|
49
|
+
desc 'Port for HTTP RPC.'
|
50
|
+
config_param :rpc_port, :integer, default: 24680
|
51
|
+
|
52
|
+
config_section :parse do
|
53
|
+
config_set_default :@type, DEFAULT_PARSER_TYPE
|
54
|
+
end
|
55
|
+
|
56
|
+
class RPCServlet < WEBrick::HTTPServlet::AbstractServlet
|
57
|
+
class Error < StandardError; end
|
58
|
+
|
59
|
+
def initialize(server, plugin)
|
60
|
+
super
|
61
|
+
@plugin = plugin
|
62
|
+
end
|
63
|
+
|
64
|
+
def do_GET(req, res)
|
65
|
+
begin
|
66
|
+
code, header, body = process(req, res)
|
67
|
+
rescue
|
68
|
+
code, header, body = render_json(500, {
|
69
|
+
'ok' => false,
|
70
|
+
'message' => 'Internal Server Error',
|
71
|
+
'error' => "#{$!}",
|
72
|
+
'backtrace'=> $!.backtrace
|
73
|
+
})
|
74
|
+
end
|
75
|
+
|
76
|
+
res.status = code
|
77
|
+
header.each_pair {|k,v|
|
78
|
+
res[k] = v
|
79
|
+
}
|
80
|
+
res.body = body
|
81
|
+
end
|
82
|
+
|
83
|
+
def render_json(code, obj)
|
84
|
+
[code, {'Content-Type' => 'application/json'}, obj.to_json]
|
85
|
+
end
|
86
|
+
|
87
|
+
def process(req, res)
|
88
|
+
ret = {'ok' => true}
|
89
|
+
case req.path_info
|
90
|
+
when '/stop'
|
91
|
+
@plugin.stop_pull
|
92
|
+
when '/start'
|
93
|
+
@plugin.start_pull
|
94
|
+
when '/status'
|
95
|
+
ret['status'] = @plugin.status_of_pull
|
96
|
+
else
|
97
|
+
raise Error.new "Invalid path_info: #{req.path_info}"
|
98
|
+
end
|
99
|
+
render_json(200, ret)
|
100
|
+
end
|
101
|
+
end
|
102
|
+
|
103
|
+
def configure(conf)
|
104
|
+
compat_parameters_convert(conf, :parser)
|
105
|
+
super
|
106
|
+
@rpc_srv = nil
|
107
|
+
@rpc_thread = nil
|
108
|
+
@stop_pull = false
|
109
|
+
|
110
|
+
@extract_tag = if @tag_key.nil?
|
111
|
+
method(:static_tag)
|
112
|
+
else
|
113
|
+
method(:dynamic_tag)
|
114
|
+
end
|
115
|
+
|
116
|
+
@parser = parser_create
|
117
|
+
end
|
118
|
+
|
119
|
+
def start
|
120
|
+
super
|
121
|
+
start_rpc if @enable_rpc
|
122
|
+
|
123
|
+
@subscriber = Fluent::GcloudPubSub::Subscriber.new @project, @key, @topic, @subscription
|
124
|
+
log.debug "connected subscription:#{@subscription} in project #{@project}"
|
125
|
+
|
126
|
+
@emit_guard = Mutex.new
|
127
|
+
@stop_subscribing = false
|
128
|
+
@subscribe_threads = []
|
129
|
+
@pull_threads.times do |idx|
|
130
|
+
@subscribe_threads.push thread_create("in_gcloud_pubsub_subscribe_#{idx}".to_sym, &method(:subscribe))
|
131
|
+
end
|
132
|
+
end
|
133
|
+
|
134
|
+
def shutdown
|
135
|
+
if @rpc_srv
|
136
|
+
@rpc_srv.shutdown
|
137
|
+
@rpc_srv = nil
|
138
|
+
end
|
139
|
+
if @rpc_thread
|
140
|
+
@rpc_thread = nil
|
141
|
+
end
|
142
|
+
@stop_subscribing = true
|
143
|
+
@subscribe_threads.each(&:join)
|
144
|
+
super
|
145
|
+
end
|
146
|
+
|
147
|
+
def stop_pull
|
148
|
+
@stop_pull = true
|
149
|
+
log.info "stop pull from subscription:#{@subscription}"
|
150
|
+
end
|
151
|
+
|
152
|
+
def start_pull
|
153
|
+
@stop_pull = false
|
154
|
+
log.info "start pull from subscription:#{@subscription}"
|
155
|
+
end
|
156
|
+
|
157
|
+
def status_of_pull
|
158
|
+
@stop_pull ? 'stopped' : 'started'
|
159
|
+
end
|
160
|
+
|
161
|
+
private
|
162
|
+
|
163
|
+
def static_tag(record)
|
164
|
+
@tag
|
165
|
+
end
|
166
|
+
|
167
|
+
def dynamic_tag(record)
|
168
|
+
record.delete(@tag_key) || @tag
|
169
|
+
end
|
170
|
+
|
171
|
+
def start_rpc
|
172
|
+
log.info "listening http rpc server on http://#{@rpc_bind}:#{@rpc_port}/"
|
173
|
+
@rpc_srv = WEBrick::HTTPServer.new(
|
174
|
+
{
|
175
|
+
BindAddress: @rpc_bind,
|
176
|
+
Port: @rpc_port,
|
177
|
+
Logger: WEBrick::Log.new(STDERR, WEBrick::Log::FATAL),
|
178
|
+
AccessLog: []
|
179
|
+
}
|
180
|
+
)
|
181
|
+
@rpc_srv.mount('/api/in_gcloud_pubsub/pull/', RPCServlet, self)
|
182
|
+
@rpc_thread = thread_create(:in_gcloud_pubsub_rpc_thread){
|
183
|
+
@rpc_srv.start
|
184
|
+
}
|
185
|
+
end
|
186
|
+
|
187
|
+
def subscribe
|
188
|
+
until @stop_subscribing
|
189
|
+
_subscribe unless @stop_pull
|
190
|
+
|
191
|
+
if @return_immediately || @stop_pull
|
192
|
+
sleep @pull_interval
|
193
|
+
end
|
194
|
+
end
|
195
|
+
rescue => ex
|
196
|
+
log.error "unexpected error", error_message: ex.to_s, error_class: ex.class.to_s
|
197
|
+
log.error_backtrace ex.backtrace
|
198
|
+
end
|
199
|
+
|
200
|
+
def _subscribe
|
201
|
+
messages = @subscriber.pull @return_immediately, @max_messages
|
202
|
+
if messages.length == 0
|
203
|
+
log.debug "no messages are pulled"
|
204
|
+
return
|
205
|
+
end
|
206
|
+
|
207
|
+
process messages
|
208
|
+
@subscriber.acknowledge messages
|
209
|
+
|
210
|
+
log.debug "#{messages.length} message(s) processed"
|
211
|
+
rescue Fluent::GcloudPubSub::RetryableError => ex
|
212
|
+
log.warn "Retryable error occurs. Fluentd will retry.", error_message: ex.to_s, error_class: ex.class.to_s
|
213
|
+
rescue => ex
|
214
|
+
log.error "unexpected error", error_message: ex.to_s, error_class: ex.class.to_s
|
215
|
+
log.error_backtrace ex.backtrace
|
216
|
+
end
|
217
|
+
|
218
|
+
def process(messages)
|
219
|
+
event_streams = Hash.new do |hsh, key|
|
220
|
+
hsh[key] = Fluent::MultiEventStream.new
|
221
|
+
end
|
222
|
+
|
223
|
+
messages.each do |m|
|
224
|
+
line = m.message.data.chomp
|
225
|
+
attributes = m.attributes
|
226
|
+
@parser.parse(line) do |time, record|
|
227
|
+
if time && record
|
228
|
+
@attribute_keys.each do |key|
|
229
|
+
record[key] = attributes[key]
|
230
|
+
end
|
231
|
+
|
232
|
+
event_streams[@extract_tag.call(record)].add(time, record)
|
233
|
+
else
|
234
|
+
case @parse_error_action
|
235
|
+
when :exception
|
236
|
+
raise FailedParseError.new "pattern not match: #{line}"
|
237
|
+
else
|
238
|
+
log.warn 'pattern not match', record: line
|
239
|
+
end
|
240
|
+
end
|
241
|
+
end
|
242
|
+
end
|
243
|
+
|
244
|
+
event_streams.each do |tag, es|
|
245
|
+
# There are some output plugins not to supposed to be called with multi-threading.
|
246
|
+
# Maybe remove in the future.
|
247
|
+
@emit_guard.synchronize do
|
248
|
+
router.emit_stream(tag, es)
|
249
|
+
end
|
250
|
+
end
|
251
|
+
end
|
252
|
+
end
|
253
|
+
end
|
@@ -0,0 +1,110 @@
|
|
1
|
+
require 'fluent/plugin/output'
|
2
|
+
require 'fluent/plugin/gcloud_pubsub/client'
|
3
|
+
require 'fluent/plugin_helper/inject'
|
4
|
+
|
5
|
+
module Fluent::Plugin
|
6
|
+
class GcloudPubSubOutput < Output
|
7
|
+
include Fluent::PluginHelper::Inject
|
8
|
+
|
9
|
+
Fluent::Plugin.register_output('gcloud_pubsub', self)
|
10
|
+
|
11
|
+
helpers :compat_parameters, :formatter
|
12
|
+
|
13
|
+
DEFAULT_BUFFER_TYPE = "memory"
|
14
|
+
DEFAULT_FORMATTER_TYPE = "json"
|
15
|
+
|
16
|
+
desc 'Set your GCP project.'
|
17
|
+
config_param :project, :string, :default => nil
|
18
|
+
desc 'Set your credential file path.'
|
19
|
+
config_param :key, :string, :default => nil
|
20
|
+
desc 'Set topic name to publish.'
|
21
|
+
config_param :topic, :string
|
22
|
+
desc "If set to `true`, specified topic will be created when it doesn't exist."
|
23
|
+
config_param :autocreate_topic, :bool, :default => false
|
24
|
+
desc 'Publishing messages count per request to Cloud Pub/Sub.'
|
25
|
+
config_param :max_messages, :integer, :default => 1000
|
26
|
+
desc 'Publishing messages bytesize per request to Cloud Pub/Sub.'
|
27
|
+
config_param :max_total_size, :integer, :default => 9800000 # 9.8MB
|
28
|
+
desc 'Limit bytesize per message.'
|
29
|
+
config_param :max_message_size, :integer, :default => 4000000 # 4MB
|
30
|
+
desc 'Publishing the set field as an attribute'
|
31
|
+
config_param :attribute_keys, :array, :default => []
|
32
|
+
|
33
|
+
config_section :buffer do
|
34
|
+
config_set_default :@type, DEFAULT_BUFFER_TYPE
|
35
|
+
end
|
36
|
+
|
37
|
+
config_section :format do
|
38
|
+
config_set_default :@type, DEFAULT_FORMATTER_TYPE
|
39
|
+
end
|
40
|
+
|
41
|
+
def configure(conf)
|
42
|
+
compat_parameters_convert(conf, :buffer, :formatter)
|
43
|
+
super
|
44
|
+
placeholder_validate!(:topic, @topic)
|
45
|
+
@formatter = formatter_create
|
46
|
+
end
|
47
|
+
|
48
|
+
def start
|
49
|
+
super
|
50
|
+
@publisher = Fluent::GcloudPubSub::Publisher.new @project, @key, @autocreate_topic
|
51
|
+
end
|
52
|
+
|
53
|
+
def format(tag, time, record)
|
54
|
+
record = inject_values_to_record(tag, time, record)
|
55
|
+
attributes = {}
|
56
|
+
@attribute_keys.each do |key|
|
57
|
+
attributes[key] = record.delete(key)
|
58
|
+
end
|
59
|
+
[@formatter.format(tag, time, record), attributes].to_msgpack
|
60
|
+
end
|
61
|
+
|
62
|
+
def formatted_to_msgpack_binary?
|
63
|
+
true
|
64
|
+
end
|
65
|
+
|
66
|
+
def multi_workers_ready?
|
67
|
+
true
|
68
|
+
end
|
69
|
+
|
70
|
+
def write(chunk)
|
71
|
+
topic = extract_placeholders(@topic, chunk.metadata)
|
72
|
+
|
73
|
+
messages = []
|
74
|
+
size = 0
|
75
|
+
|
76
|
+
chunk.msgpack_each do |msg, attr|
|
77
|
+
msg = Fluent::GcloudPubSub::Message.new(msg, attr)
|
78
|
+
if msg.bytesize > @max_message_size
|
79
|
+
log.warn 'Drop a message because its size exceeds `max_message_size`', size: msg.bytesize
|
80
|
+
next
|
81
|
+
end
|
82
|
+
if messages.length + 1 > @max_messages || size + msg.bytesize > @max_total_size
|
83
|
+
publish(topic, messages)
|
84
|
+
messages = []
|
85
|
+
size = 0
|
86
|
+
end
|
87
|
+
messages << msg
|
88
|
+
size += msg.bytesize
|
89
|
+
end
|
90
|
+
|
91
|
+
if messages.length > 0
|
92
|
+
publish(topic, messages)
|
93
|
+
end
|
94
|
+
rescue Fluent::GcloudPubSub::RetryableError => ex
|
95
|
+
log.warn "Retryable error occurs. Fluentd will retry.", error_message: ex.to_s, error_class: ex.class.to_s
|
96
|
+
raise ex
|
97
|
+
rescue => ex
|
98
|
+
log.error "unexpected error", error_message: ex.to_s, error_class: ex.class.to_s
|
99
|
+
log.error_backtrace
|
100
|
+
raise ex
|
101
|
+
end
|
102
|
+
|
103
|
+
private
|
104
|
+
|
105
|
+
def publish(topic, messages)
|
106
|
+
log.debug "send message topic:#{topic} length:#{messages.length} size:#{messages.map(&:bytesize).inject(:+)}"
|
107
|
+
@publisher.publish(topic, messages)
|
108
|
+
end
|
109
|
+
end
|
110
|
+
end
|
@@ -0,0 +1,387 @@
|
|
1
|
+
require 'net/http'
|
2
|
+
require 'json'
|
3
|
+
|
4
|
+
require_relative "../test_helper"
|
5
|
+
require "fluent/test/driver/input"
|
6
|
+
|
7
|
+
class GcloudPubSubInputTest < Test::Unit::TestCase
|
8
|
+
CONFIG = %[
|
9
|
+
tag test
|
10
|
+
project project-test
|
11
|
+
topic topic-test
|
12
|
+
subscription subscription-test
|
13
|
+
key key-test
|
14
|
+
]
|
15
|
+
|
16
|
+
DEFAULT_HOST = '127.0.0.1'
|
17
|
+
DEFAULT_PORT = 24680
|
18
|
+
|
19
|
+
class DummyInvalidMsgData
|
20
|
+
def data
|
21
|
+
'foo:bar'
|
22
|
+
end
|
23
|
+
end
|
24
|
+
class DummyInvalidMessage
|
25
|
+
def message
|
26
|
+
DummyInvalidMsgData.new
|
27
|
+
end
|
28
|
+
def attributes
|
29
|
+
{"attr_1" => "a", "attr_2" => "b"}
|
30
|
+
end
|
31
|
+
end
|
32
|
+
|
33
|
+
def create_driver(conf=CONFIG)
|
34
|
+
Fluent::Test::Driver::Input.new(Fluent::Plugin::GcloudPubSubInput).configure(conf)
|
35
|
+
end
|
36
|
+
|
37
|
+
def http_get(path)
|
38
|
+
http = Net::HTTP.new(DEFAULT_HOST, DEFAULT_PORT)
|
39
|
+
req = Net::HTTP::Get.new(path, {'Content-Type' => 'application/x-www-form-urlencoded'})
|
40
|
+
http.request(req)
|
41
|
+
end
|
42
|
+
|
43
|
+
setup do
|
44
|
+
Fluent::Test.setup
|
45
|
+
end
|
46
|
+
|
47
|
+
sub_test_case 'configure' do
|
48
|
+
test 'all params are configured' do
|
49
|
+
d = create_driver(%[
|
50
|
+
tag test
|
51
|
+
project project-test
|
52
|
+
topic topic-test
|
53
|
+
subscription subscription-test
|
54
|
+
key key-test
|
55
|
+
max_messages 1000
|
56
|
+
return_immediately true
|
57
|
+
pull_interval 2
|
58
|
+
pull_threads 3
|
59
|
+
attribute_keys attr-test
|
60
|
+
enable_rpc true
|
61
|
+
rpc_bind 127.0.0.1
|
62
|
+
rpc_port 24681
|
63
|
+
])
|
64
|
+
|
65
|
+
assert_equal('test', d.instance.tag)
|
66
|
+
assert_equal('project-test', d.instance.project)
|
67
|
+
assert_equal('topic-test', d.instance.topic)
|
68
|
+
assert_equal('subscription-test', d.instance.subscription)
|
69
|
+
assert_equal('key-test', d.instance.key)
|
70
|
+
assert_equal(2.0, d.instance.pull_interval)
|
71
|
+
assert_equal(1000, d.instance.max_messages)
|
72
|
+
assert_equal(true, d.instance.return_immediately)
|
73
|
+
assert_equal(3, d.instance.pull_threads)
|
74
|
+
assert_equal(['attr-test'], d.instance.attribute_keys)
|
75
|
+
assert_equal(true, d.instance.enable_rpc)
|
76
|
+
assert_equal('127.0.0.1', d.instance.rpc_bind)
|
77
|
+
assert_equal(24681, d.instance.rpc_port)
|
78
|
+
end
|
79
|
+
|
80
|
+
test 'default values are configured' do
|
81
|
+
d = create_driver
|
82
|
+
assert_equal(5.0, d.instance.pull_interval)
|
83
|
+
assert_equal(100, d.instance.max_messages)
|
84
|
+
assert_equal(true, d.instance.return_immediately)
|
85
|
+
assert_equal(1, d.instance.pull_threads)
|
86
|
+
assert_equal([], d.instance.attribute_keys)
|
87
|
+
assert_equal(false, d.instance.enable_rpc)
|
88
|
+
assert_equal('0.0.0.0', d.instance.rpc_bind)
|
89
|
+
assert_equal(24680, d.instance.rpc_port)
|
90
|
+
end
|
91
|
+
end
|
92
|
+
|
93
|
+
sub_test_case 'start' do
|
94
|
+
setup do
|
95
|
+
@topic_mock = mock!
|
96
|
+
@pubsub_mock = mock!.topic('topic-test').at_least(1) { @topic_mock }
|
97
|
+
stub(Google::Cloud::Pubsub).new { @pubsub_mock }
|
98
|
+
end
|
99
|
+
|
100
|
+
test '40x error occurred on connecting to Pub/Sub' do
|
101
|
+
@topic_mock.subscription('subscription-test').once do
|
102
|
+
raise Google::Cloud::NotFoundError.new('TEST')
|
103
|
+
end
|
104
|
+
|
105
|
+
d = create_driver
|
106
|
+
assert_raise Google::Cloud::NotFoundError do
|
107
|
+
d.run {}
|
108
|
+
end
|
109
|
+
end
|
110
|
+
|
111
|
+
test '50x error occurred on connecting to Pub/Sub' do
|
112
|
+
@topic_mock.subscription('subscription-test').once do
|
113
|
+
raise Google::Cloud::UnavailableError.new('TEST')
|
114
|
+
end
|
115
|
+
|
116
|
+
d = create_driver
|
117
|
+
assert_raise Google::Cloud::UnavailableError do
|
118
|
+
d.run {}
|
119
|
+
end
|
120
|
+
end
|
121
|
+
|
122
|
+
test 'subscription is nil' do
|
123
|
+
@topic_mock.subscription('subscription-test').once { nil }
|
124
|
+
|
125
|
+
d = create_driver
|
126
|
+
assert_raise Fluent::GcloudPubSub::Error do
|
127
|
+
d.run {}
|
128
|
+
end
|
129
|
+
end
|
130
|
+
end
|
131
|
+
|
132
|
+
sub_test_case 'emit' do
|
133
|
+
class DummyMsgData
|
134
|
+
def data
|
135
|
+
'{"foo": "bar"}'
|
136
|
+
end
|
137
|
+
end
|
138
|
+
class DummyMessage
|
139
|
+
def message
|
140
|
+
DummyMsgData.new
|
141
|
+
end
|
142
|
+
def attributes
|
143
|
+
{"attr_1" => "a", "attr_2" => "b"}
|
144
|
+
end
|
145
|
+
end
|
146
|
+
|
147
|
+
class DummyMsgDataWithTagKey
|
148
|
+
def initialize(tag)
|
149
|
+
@tag = tag
|
150
|
+
end
|
151
|
+
def data
|
152
|
+
'{"foo": "bar", "test_tag_key": "' + @tag + '"}'
|
153
|
+
end
|
154
|
+
end
|
155
|
+
class DummyMessageWithTagKey
|
156
|
+
def initialize(tag)
|
157
|
+
@tag = tag
|
158
|
+
end
|
159
|
+
def message
|
160
|
+
DummyMsgDataWithTagKey.new @tag
|
161
|
+
end
|
162
|
+
def attributes
|
163
|
+
{"attr_1" => "a", "attr_2" => "b"}
|
164
|
+
end
|
165
|
+
end
|
166
|
+
|
167
|
+
setup do
|
168
|
+
@subscriber = mock!
|
169
|
+
@topic_mock = mock!.subscription('subscription-test') { @subscriber }
|
170
|
+
@pubsub_mock = mock!.topic('topic-test') { @topic_mock }
|
171
|
+
stub(Google::Cloud::Pubsub).new { @pubsub_mock }
|
172
|
+
end
|
173
|
+
|
174
|
+
test 'empty' do
|
175
|
+
@subscriber.pull(immediate: true, max: 100).at_least(1) { [] }
|
176
|
+
@subscriber.acknowledge.times(0)
|
177
|
+
|
178
|
+
d = create_driver
|
179
|
+
d.run(expect_emits: 1, timeout: 3)
|
180
|
+
|
181
|
+
assert_true d.events.empty?
|
182
|
+
end
|
183
|
+
|
184
|
+
test 'simple' do
|
185
|
+
messages = Array.new(1, DummyMessage.new)
|
186
|
+
@subscriber.pull(immediate: true, max: 100).at_least(1) { messages }
|
187
|
+
@subscriber.acknowledge(messages).at_least(1)
|
188
|
+
|
189
|
+
d = create_driver
|
190
|
+
d.run(expect_emits: 1, timeout: 3)
|
191
|
+
emits = d.events
|
192
|
+
|
193
|
+
assert(1 <= emits.length)
|
194
|
+
emits.each do |tag, time, record|
|
195
|
+
assert_equal("test", tag)
|
196
|
+
assert_equal({"foo" => "bar"}, record)
|
197
|
+
end
|
198
|
+
end
|
199
|
+
|
200
|
+
test 'multithread' do
|
201
|
+
messages = Array.new(1, DummyMessage.new)
|
202
|
+
@subscriber.pull(immediate: true, max: 100).at_least(2) { messages }
|
203
|
+
@subscriber.acknowledge(messages).at_least(2)
|
204
|
+
|
205
|
+
d = create_driver("#{CONFIG}\npull_threads 2")
|
206
|
+
d.run(expect_emits: 2, timeout: 1)
|
207
|
+
emits = d.events
|
208
|
+
|
209
|
+
assert(2 <= emits.length)
|
210
|
+
emits.each do |tag, time, record|
|
211
|
+
assert_equal("test", tag)
|
212
|
+
assert_equal({"foo" => "bar"}, record)
|
213
|
+
end
|
214
|
+
end
|
215
|
+
|
216
|
+
test 'with tag_key' do
|
217
|
+
messages = [
|
218
|
+
DummyMessageWithTagKey.new('tag1'),
|
219
|
+
DummyMessageWithTagKey.new('tag2'),
|
220
|
+
DummyMessage.new
|
221
|
+
]
|
222
|
+
@subscriber.pull(immediate: true, max: 100).at_least(1) { messages }
|
223
|
+
@subscriber.acknowledge(messages).at_least(1)
|
224
|
+
|
225
|
+
d = create_driver("#{CONFIG}\ntag_key test_tag_key")
|
226
|
+
d.run(expect_emits: 1, timeout: 3)
|
227
|
+
emits = d.events
|
228
|
+
|
229
|
+
assert(3 <= emits.length)
|
230
|
+
# test tag
|
231
|
+
assert_equal("tag1", emits[0][0])
|
232
|
+
assert_equal("tag2", emits[1][0])
|
233
|
+
assert_equal("test", emits[2][0])
|
234
|
+
# test record
|
235
|
+
emits.each do |tag, time, record|
|
236
|
+
assert_equal({"foo" => "bar"}, record)
|
237
|
+
end
|
238
|
+
end
|
239
|
+
|
240
|
+
test 'invalid messages with parse_error_action exception ' do
|
241
|
+
messages = Array.new(1, DummyInvalidMessage.new)
|
242
|
+
@subscriber.pull(immediate: true, max: 100).at_least(1) { messages }
|
243
|
+
@subscriber.acknowledge.times(0)
|
244
|
+
|
245
|
+
d = create_driver
|
246
|
+
d.run(expect_emits: 1, timeout: 3)
|
247
|
+
assert_true d.events.empty?
|
248
|
+
end
|
249
|
+
|
250
|
+
test 'with attributes' do
|
251
|
+
messages = Array.new(1, DummyMessage.new)
|
252
|
+
@subscriber.pull(immediate: true, max: 100).at_least(1) { messages }
|
253
|
+
@subscriber.acknowledge(messages).at_least(1)
|
254
|
+
|
255
|
+
d = create_driver("#{CONFIG}\nattribute_keys attr_1")
|
256
|
+
d.run(expect_emits: 1, timeout: 3)
|
257
|
+
emits = d.events
|
258
|
+
|
259
|
+
assert(1 <= emits.length)
|
260
|
+
emits.each do |tag, time, record|
|
261
|
+
assert_equal("test", tag)
|
262
|
+
assert_equal({"foo" => "bar", "attr_1" => "a"}, record)
|
263
|
+
end
|
264
|
+
end
|
265
|
+
|
266
|
+
test 'invalid messages with parse_error_action warning' do
|
267
|
+
messages = Array.new(1, DummyInvalidMessage.new)
|
268
|
+
@subscriber.pull(immediate: true, max: 100).at_least(1) { messages }
|
269
|
+
@subscriber.acknowledge(messages).at_least(1)
|
270
|
+
|
271
|
+
d = create_driver("#{CONFIG}\nparse_error_action warning")
|
272
|
+
d.run(expect_emits: 1, timeout: 3)
|
273
|
+
assert_true d.events.empty?
|
274
|
+
end
|
275
|
+
|
276
|
+
test 'retry if raised error' do
|
277
|
+
class UnknownError < StandardError
|
278
|
+
end
|
279
|
+
@subscriber.pull(immediate: true, max: 100).at_least(2) { raise UnknownError.new('test') }
|
280
|
+
@subscriber.acknowledge.times(0)
|
281
|
+
|
282
|
+
d = create_driver(CONFIG + 'pull_interval 0.5')
|
283
|
+
d.run(expect_emits: 1, timeout: 0.8)
|
284
|
+
|
285
|
+
assert_equal(0.5, d.instance.pull_interval)
|
286
|
+
assert_true d.events.empty?
|
287
|
+
end
|
288
|
+
|
289
|
+
test 'retry if raised RetryableError on pull' do
|
290
|
+
@subscriber.pull(immediate: true, max: 100).at_least(2) { raise Google::Cloud::UnavailableError.new('TEST') }
|
291
|
+
@subscriber.acknowledge.times(0)
|
292
|
+
|
293
|
+
d = create_driver("#{CONFIG}\npull_interval 0.5")
|
294
|
+
d.run(expect_emits: 1, timeout: 0.8)
|
295
|
+
|
296
|
+
assert_equal(0.5, d.instance.pull_interval)
|
297
|
+
assert_true d.events.empty?
|
298
|
+
end
|
299
|
+
|
300
|
+
test 'retry if raised RetryableError on acknowledge' do
|
301
|
+
messages = Array.new(1, DummyMessage.new)
|
302
|
+
@subscriber.pull(immediate: true, max: 100).at_least(2) { messages }
|
303
|
+
@subscriber.acknowledge(messages).at_least(2) { raise Google::Cloud::UnavailableError.new('TEST') }
|
304
|
+
|
305
|
+
d = create_driver("#{CONFIG}\npull_interval 0.5")
|
306
|
+
d.run(expect_emits: 2, timeout: 3)
|
307
|
+
emits = d.events
|
308
|
+
|
309
|
+
# not acknowledged, but already emitted to engine.
|
310
|
+
assert(2 <= emits.length)
|
311
|
+
emits.each do |tag, time, record|
|
312
|
+
assert_equal("test", tag)
|
313
|
+
assert_equal({"foo" => "bar"}, record)
|
314
|
+
end
|
315
|
+
end
|
316
|
+
|
317
|
+
test 'stop by http rpc' do
|
318
|
+
messages = Array.new(1, DummyMessage.new)
|
319
|
+
@subscriber.pull(immediate: true, max: 100).once { messages }
|
320
|
+
@subscriber.acknowledge(messages).once
|
321
|
+
|
322
|
+
d = create_driver("#{CONFIG}\npull_interval 1.0\nenable_rpc true")
|
323
|
+
assert_equal(false, d.instance.instance_variable_get(:@stop_pull))
|
324
|
+
|
325
|
+
d.run {
|
326
|
+
http_get('/api/in_gcloud_pubsub/pull/stop')
|
327
|
+
sleep 0.75
|
328
|
+
# d.run sleeps 0.5 sec
|
329
|
+
}
|
330
|
+
emits = d.events
|
331
|
+
|
332
|
+
assert_equal(1, emits.length)
|
333
|
+
assert_true d.instance.instance_variable_get(:@stop_pull)
|
334
|
+
|
335
|
+
emits.each do |tag, time, record|
|
336
|
+
assert_equal("test", tag)
|
337
|
+
assert_equal({"foo" => "bar"}, record)
|
338
|
+
end
|
339
|
+
end
|
340
|
+
|
341
|
+
test 'start by http rpc' do
|
342
|
+
messages = Array.new(1, DummyMessage.new)
|
343
|
+
@subscriber.pull(immediate: true, max: 100).at_least(1) { messages }
|
344
|
+
@subscriber.acknowledge(messages).at_least(1)
|
345
|
+
|
346
|
+
d = create_driver("#{CONFIG}\npull_interval 1.0\nenable_rpc true")
|
347
|
+
d.instance.stop_pull
|
348
|
+
assert_equal(true, d.instance.instance_variable_get(:@stop_pull))
|
349
|
+
|
350
|
+
d.run(expect_emits: 1, timeout: 3) {
|
351
|
+
http_get('/api/in_gcloud_pubsub/pull/start')
|
352
|
+
sleep 0.75
|
353
|
+
# d.run sleeps 0.5 sec
|
354
|
+
}
|
355
|
+
emits = d.events
|
356
|
+
|
357
|
+
assert_equal(true, emits.length > 0)
|
358
|
+
assert_false d.instance.instance_variable_get(:@stop_pull)
|
359
|
+
|
360
|
+
emits.each do |tag, time, record|
|
361
|
+
assert_equal("test", tag)
|
362
|
+
assert_equal({"foo" => "bar"}, record)
|
363
|
+
end
|
364
|
+
end
|
365
|
+
|
366
|
+
test 'get status by http rpc when started' do
|
367
|
+
d = create_driver("#{CONFIG}\npull_interval 1.0\nenable_rpc true")
|
368
|
+
assert_false d.instance.instance_variable_get(:@stop_pull)
|
369
|
+
|
370
|
+
d.run {
|
371
|
+
res = http_get('/api/in_gcloud_pubsub/pull/status')
|
372
|
+
assert_equal({"ok" => true, "status" => "started"}, JSON.parse(res.body))
|
373
|
+
}
|
374
|
+
end
|
375
|
+
|
376
|
+
test 'get status by http rpc when stopped' do
|
377
|
+
d = create_driver("#{CONFIG}\npull_interval 1.0\nenable_rpc true")
|
378
|
+
d.instance.stop_pull
|
379
|
+
assert_true d.instance.instance_variable_get(:@stop_pull)
|
380
|
+
|
381
|
+
d.run {
|
382
|
+
res = http_get('/api/in_gcloud_pubsub/pull/status')
|
383
|
+
assert_equal({"ok" => true, "status" => "stopped"}, JSON.parse(res.body))
|
384
|
+
}
|
385
|
+
end
|
386
|
+
end
|
387
|
+
end
|