karafka-rdkafka 0.20.0.rc3-x86_64-linux-gnu
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/.github/CODEOWNERS +3 -0
- data/.github/FUNDING.yml +1 -0
- data/.github/workflows/ci_linux_x86_64_gnu.yml +248 -0
- data/.github/workflows/ci_macos_arm64.yml +301 -0
- data/.github/workflows/push_linux_x86_64_gnu.yml +60 -0
- data/.github/workflows/push_ruby.yml +37 -0
- data/.github/workflows/verify-action-pins.yml +16 -0
- data/.gitignore +15 -0
- data/.rspec +2 -0
- data/.ruby-gemset +1 -0
- data/.ruby-version +1 -0
- data/.yardopts +2 -0
- data/CHANGELOG.md +323 -0
- data/Gemfile +5 -0
- data/MIT-LICENSE +22 -0
- data/README.md +177 -0
- data/Rakefile +96 -0
- data/docker-compose.yml +25 -0
- data/ext/README.md +19 -0
- data/ext/Rakefile +131 -0
- data/ext/build_common.sh +361 -0
- data/ext/build_linux_x86_64_gnu.sh +306 -0
- data/ext/build_macos_arm64.sh +550 -0
- data/ext/librdkafka.so +0 -0
- data/karafka-rdkafka.gemspec +61 -0
- data/lib/rdkafka/abstract_handle.rb +116 -0
- data/lib/rdkafka/admin/acl_binding_result.rb +51 -0
- data/lib/rdkafka/admin/config_binding_result.rb +30 -0
- data/lib/rdkafka/admin/config_resource_binding_result.rb +18 -0
- data/lib/rdkafka/admin/create_acl_handle.rb +28 -0
- data/lib/rdkafka/admin/create_acl_report.rb +24 -0
- data/lib/rdkafka/admin/create_partitions_handle.rb +30 -0
- data/lib/rdkafka/admin/create_partitions_report.rb +6 -0
- data/lib/rdkafka/admin/create_topic_handle.rb +32 -0
- data/lib/rdkafka/admin/create_topic_report.rb +24 -0
- data/lib/rdkafka/admin/delete_acl_handle.rb +30 -0
- data/lib/rdkafka/admin/delete_acl_report.rb +23 -0
- data/lib/rdkafka/admin/delete_groups_handle.rb +28 -0
- data/lib/rdkafka/admin/delete_groups_report.rb +24 -0
- data/lib/rdkafka/admin/delete_topic_handle.rb +32 -0
- data/lib/rdkafka/admin/delete_topic_report.rb +24 -0
- data/lib/rdkafka/admin/describe_acl_handle.rb +30 -0
- data/lib/rdkafka/admin/describe_acl_report.rb +24 -0
- data/lib/rdkafka/admin/describe_configs_handle.rb +33 -0
- data/lib/rdkafka/admin/describe_configs_report.rb +48 -0
- data/lib/rdkafka/admin/incremental_alter_configs_handle.rb +33 -0
- data/lib/rdkafka/admin/incremental_alter_configs_report.rb +48 -0
- data/lib/rdkafka/admin.rb +832 -0
- data/lib/rdkafka/bindings.rb +582 -0
- data/lib/rdkafka/callbacks.rb +415 -0
- data/lib/rdkafka/config.rb +398 -0
- data/lib/rdkafka/consumer/headers.rb +79 -0
- data/lib/rdkafka/consumer/message.rb +86 -0
- data/lib/rdkafka/consumer/partition.rb +57 -0
- data/lib/rdkafka/consumer/topic_partition_list.rb +190 -0
- data/lib/rdkafka/consumer.rb +663 -0
- data/lib/rdkafka/error.rb +201 -0
- data/lib/rdkafka/helpers/oauth.rb +58 -0
- data/lib/rdkafka/helpers/time.rb +14 -0
- data/lib/rdkafka/metadata.rb +115 -0
- data/lib/rdkafka/native_kafka.rb +139 -0
- data/lib/rdkafka/producer/delivery_handle.rb +48 -0
- data/lib/rdkafka/producer/delivery_report.rb +45 -0
- data/lib/rdkafka/producer/partitions_count_cache.rb +216 -0
- data/lib/rdkafka/producer.rb +492 -0
- data/lib/rdkafka/version.rb +7 -0
- data/lib/rdkafka.rb +54 -0
- data/renovate.json +92 -0
- data/spec/rdkafka/abstract_handle_spec.rb +117 -0
- data/spec/rdkafka/admin/create_acl_handle_spec.rb +56 -0
- data/spec/rdkafka/admin/create_acl_report_spec.rb +18 -0
- data/spec/rdkafka/admin/create_topic_handle_spec.rb +54 -0
- data/spec/rdkafka/admin/create_topic_report_spec.rb +16 -0
- data/spec/rdkafka/admin/delete_acl_handle_spec.rb +85 -0
- data/spec/rdkafka/admin/delete_acl_report_spec.rb +72 -0
- data/spec/rdkafka/admin/delete_topic_handle_spec.rb +54 -0
- data/spec/rdkafka/admin/delete_topic_report_spec.rb +16 -0
- data/spec/rdkafka/admin/describe_acl_handle_spec.rb +85 -0
- data/spec/rdkafka/admin/describe_acl_report_spec.rb +73 -0
- data/spec/rdkafka/admin_spec.rb +769 -0
- data/spec/rdkafka/bindings_spec.rb +222 -0
- data/spec/rdkafka/callbacks_spec.rb +20 -0
- data/spec/rdkafka/config_spec.rb +258 -0
- data/spec/rdkafka/consumer/headers_spec.rb +73 -0
- data/spec/rdkafka/consumer/message_spec.rb +139 -0
- data/spec/rdkafka/consumer/partition_spec.rb +57 -0
- data/spec/rdkafka/consumer/topic_partition_list_spec.rb +248 -0
- data/spec/rdkafka/consumer_spec.rb +1299 -0
- data/spec/rdkafka/error_spec.rb +95 -0
- data/spec/rdkafka/metadata_spec.rb +79 -0
- data/spec/rdkafka/native_kafka_spec.rb +130 -0
- data/spec/rdkafka/producer/delivery_handle_spec.rb +60 -0
- data/spec/rdkafka/producer/delivery_report_spec.rb +25 -0
- data/spec/rdkafka/producer/partitions_count_cache_spec.rb +359 -0
- data/spec/rdkafka/producer/partitions_count_spec.rb +359 -0
- data/spec/rdkafka/producer_spec.rb +1234 -0
- data/spec/spec_helper.rb +181 -0
- metadata +244 -0
@@ -0,0 +1,95 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
describe Rdkafka::RdkafkaError do
|
4
|
+
it "should raise a type error for a nil response" do
|
5
|
+
expect {
|
6
|
+
Rdkafka::RdkafkaError.new(nil)
|
7
|
+
}.to raise_error TypeError
|
8
|
+
end
|
9
|
+
|
10
|
+
it "should create an error with a message prefix" do
|
11
|
+
expect(Rdkafka::RdkafkaError.new(10, "message prefix").message_prefix).to eq "message prefix"
|
12
|
+
end
|
13
|
+
|
14
|
+
it "should have empty frozen details by default" do
|
15
|
+
error = Rdkafka::RdkafkaError.new(10, "message prefix")
|
16
|
+
expect(error.details).to eq({})
|
17
|
+
expect(error.details).to be_frozen
|
18
|
+
end
|
19
|
+
|
20
|
+
it "should create an error with a broker message" do
|
21
|
+
expect(Rdkafka::RdkafkaError.new(10, broker_message: "broker message").broker_message).to eq "broker message"
|
22
|
+
end
|
23
|
+
|
24
|
+
describe "#code" do
|
25
|
+
it "should handle an invalid response" do
|
26
|
+
expect(Rdkafka::RdkafkaError.new(933975).code).to eq :err_933975?
|
27
|
+
end
|
28
|
+
|
29
|
+
it "should return error messages from rdkafka" do
|
30
|
+
expect(Rdkafka::RdkafkaError.new(10).code).to eq :msg_size_too_large
|
31
|
+
end
|
32
|
+
|
33
|
+
it "should strip a leading underscore" do
|
34
|
+
expect(Rdkafka::RdkafkaError.new(-191).code).to eq :partition_eof
|
35
|
+
end
|
36
|
+
end
|
37
|
+
|
38
|
+
describe "#to_s" do
|
39
|
+
it "should handle an invalid response" do
|
40
|
+
expect(Rdkafka::RdkafkaError.new(933975).to_s).to eq "Err-933975? (err_933975?)"
|
41
|
+
end
|
42
|
+
|
43
|
+
it "should return error messages from rdkafka" do
|
44
|
+
expect(Rdkafka::RdkafkaError.new(10).to_s).to eq "Broker: Message size too large (msg_size_too_large)"
|
45
|
+
end
|
46
|
+
|
47
|
+
it "should add the message prefix if present" do
|
48
|
+
expect(Rdkafka::RdkafkaError.new(10, "Error explanation").to_s).to eq "Error explanation - Broker: Message size too large (msg_size_too_large)"
|
49
|
+
end
|
50
|
+
end
|
51
|
+
|
52
|
+
describe "#message" do
|
53
|
+
it "should handle an invalid response" do
|
54
|
+
expect(Rdkafka::RdkafkaError.new(933975).message).to eq "Err-933975? (err_933975?)"
|
55
|
+
end
|
56
|
+
|
57
|
+
it "should return error messages from rdkafka" do
|
58
|
+
expect(Rdkafka::RdkafkaError.new(10).message).to eq "Broker: Message size too large (msg_size_too_large)"
|
59
|
+
end
|
60
|
+
|
61
|
+
it "should add the message prefix if present" do
|
62
|
+
expect(Rdkafka::RdkafkaError.new(10, "Error explanation").message).to eq "Error explanation - Broker: Message size too large (msg_size_too_large)"
|
63
|
+
end
|
64
|
+
end
|
65
|
+
|
66
|
+
describe "#is_partition_eof?" do
|
67
|
+
it "should be false when not partition eof" do
|
68
|
+
expect(Rdkafka::RdkafkaError.new(933975).is_partition_eof?).to be false
|
69
|
+
end
|
70
|
+
|
71
|
+
it "should be true when partition eof" do
|
72
|
+
expect(Rdkafka::RdkafkaError.new(-191).is_partition_eof?).to be true
|
73
|
+
end
|
74
|
+
end
|
75
|
+
|
76
|
+
describe "#==" do
|
77
|
+
subject { Rdkafka::RdkafkaError.new(10, "Error explanation") }
|
78
|
+
|
79
|
+
it "should equal another error with the same content" do
|
80
|
+
expect(subject).to eq Rdkafka::RdkafkaError.new(10, "Error explanation")
|
81
|
+
end
|
82
|
+
|
83
|
+
it "should not equal another error with a different error code" do
|
84
|
+
expect(subject).not_to eq Rdkafka::RdkafkaError.new(20, "Error explanation")
|
85
|
+
end
|
86
|
+
|
87
|
+
it "should not equal another error with a different message" do
|
88
|
+
expect(subject).not_to eq Rdkafka::RdkafkaError.new(10, "Different error explanation")
|
89
|
+
end
|
90
|
+
|
91
|
+
it "should not equal another error with no message" do
|
92
|
+
expect(subject).not_to eq Rdkafka::RdkafkaError.new(10)
|
93
|
+
end
|
94
|
+
end
|
95
|
+
end
|
@@ -0,0 +1,79 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "securerandom"
|
4
|
+
|
5
|
+
describe Rdkafka::Metadata do
|
6
|
+
let(:config) { rdkafka_consumer_config }
|
7
|
+
let(:native_config) { config.send(:native_config) }
|
8
|
+
let(:native_kafka) { config.send(:native_kafka, native_config, :rd_kafka_consumer) }
|
9
|
+
|
10
|
+
after do
|
11
|
+
Rdkafka::Bindings.rd_kafka_consumer_close(native_kafka)
|
12
|
+
Rdkafka::Bindings.rd_kafka_destroy(native_kafka)
|
13
|
+
end
|
14
|
+
|
15
|
+
context "passing in a topic name" do
|
16
|
+
context "that is non-existent topic" do
|
17
|
+
let(:topic_name) { SecureRandom.uuid.to_s }
|
18
|
+
|
19
|
+
it "raises an appropriate exception" do
|
20
|
+
expect {
|
21
|
+
described_class.new(native_kafka, topic_name)
|
22
|
+
}.to raise_exception(Rdkafka::RdkafkaError, "Broker: Unknown topic or partition (unknown_topic_or_part)")
|
23
|
+
end
|
24
|
+
end
|
25
|
+
|
26
|
+
context "that is one of our test topics" do
|
27
|
+
subject { described_class.new(native_kafka, topic_name) }
|
28
|
+
let(:topic_name) { "partitioner_test_topic" }
|
29
|
+
|
30
|
+
it "#brokers returns our single broker" do
|
31
|
+
expect(subject.brokers.length).to eq(1)
|
32
|
+
expect(subject.brokers[0][:broker_id]).to eq(1)
|
33
|
+
expect(%w[127.0.0.1 localhost]).to include(subject.brokers[0][:broker_name])
|
34
|
+
expect(subject.brokers[0][:broker_port]).to eq(9092)
|
35
|
+
end
|
36
|
+
|
37
|
+
it "#topics returns data on our test topic" do
|
38
|
+
expect(subject.topics.length).to eq(1)
|
39
|
+
expect(subject.topics[0][:partition_count]).to eq(25)
|
40
|
+
expect(subject.topics[0][:partitions].length).to eq(25)
|
41
|
+
expect(subject.topics[0][:topic_name]).to eq(topic_name)
|
42
|
+
end
|
43
|
+
end
|
44
|
+
end
|
45
|
+
|
46
|
+
context "not passing in a topic name" do
|
47
|
+
subject { described_class.new(native_kafka, topic_name) }
|
48
|
+
let(:topic_name) { nil }
|
49
|
+
let(:test_topics) {
|
50
|
+
%w(consume_test_topic empty_test_topic load_test_topic produce_test_topic rake_test_topic watermarks_test_topic partitioner_test_topic)
|
51
|
+
} # Test topics crated in spec_helper.rb
|
52
|
+
|
53
|
+
it "#brokers returns our single broker" do
|
54
|
+
expect(subject.brokers.length).to eq(1)
|
55
|
+
expect(subject.brokers[0][:broker_id]).to eq(1)
|
56
|
+
expect(%w[127.0.0.1 localhost]).to include(subject.brokers[0][:broker_name])
|
57
|
+
expect(subject.brokers[0][:broker_port]).to eq(9092)
|
58
|
+
end
|
59
|
+
|
60
|
+
it "#topics returns data about all of our test topics" do
|
61
|
+
result = subject.topics.map { |topic| topic[:topic_name] }
|
62
|
+
expect(result).to include(*test_topics)
|
63
|
+
end
|
64
|
+
end
|
65
|
+
|
66
|
+
context "when a non-zero error code is returned" do
|
67
|
+
let(:topic_name) { SecureRandom.uuid.to_s }
|
68
|
+
|
69
|
+
before do
|
70
|
+
allow(Rdkafka::Bindings).to receive(:rd_kafka_metadata).and_return(-165)
|
71
|
+
end
|
72
|
+
|
73
|
+
it "creating the instance raises an exception" do
|
74
|
+
expect {
|
75
|
+
described_class.new(native_kafka, topic_name)
|
76
|
+
}.to raise_error(Rdkafka::RdkafkaError, /Local: Required feature not supported by broker \(unsupported_feature\)/)
|
77
|
+
end
|
78
|
+
end
|
79
|
+
end
|
@@ -0,0 +1,130 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
describe Rdkafka::NativeKafka do
|
4
|
+
let(:config) { rdkafka_producer_config }
|
5
|
+
let(:native) { config.send(:native_kafka, config.send(:native_config), :rd_kafka_producer) }
|
6
|
+
let(:closing) { false }
|
7
|
+
let(:thread) { double(Thread) }
|
8
|
+
let(:opaque) { Rdkafka::Opaque.new }
|
9
|
+
|
10
|
+
subject(:client) { described_class.new(native, run_polling_thread: true, opaque: opaque) }
|
11
|
+
|
12
|
+
before do
|
13
|
+
allow(Rdkafka::Bindings).to receive(:rd_kafka_name).and_return('producer-1')
|
14
|
+
allow(Thread).to receive(:new).and_return(thread)
|
15
|
+
allow(thread).to receive(:name=).with("rdkafka.native_kafka#producer-1")
|
16
|
+
allow(thread).to receive(:[]=).with(:closing, anything)
|
17
|
+
allow(thread).to receive(:join)
|
18
|
+
allow(thread).to receive(:abort_on_exception=).with(anything)
|
19
|
+
end
|
20
|
+
|
21
|
+
after { client.close }
|
22
|
+
|
23
|
+
context "defaults" do
|
24
|
+
it "sets the thread name" do
|
25
|
+
expect(thread).to receive(:name=).with("rdkafka.native_kafka#producer-1")
|
26
|
+
|
27
|
+
client
|
28
|
+
end
|
29
|
+
|
30
|
+
it "sets the thread to abort on exception" do
|
31
|
+
expect(thread).to receive(:abort_on_exception=).with(true)
|
32
|
+
|
33
|
+
client
|
34
|
+
end
|
35
|
+
|
36
|
+
it "sets the thread `closing` flag to false" do
|
37
|
+
expect(thread).to receive(:[]=).with(:closing, false)
|
38
|
+
|
39
|
+
client
|
40
|
+
end
|
41
|
+
end
|
42
|
+
|
43
|
+
context "the polling thread" do
|
44
|
+
it "is created" do
|
45
|
+
expect(Thread).to receive(:new)
|
46
|
+
|
47
|
+
client
|
48
|
+
end
|
49
|
+
end
|
50
|
+
|
51
|
+
it "exposes the inner client" do
|
52
|
+
client.with_inner do |inner|
|
53
|
+
expect(inner).to eq(native)
|
54
|
+
end
|
55
|
+
end
|
56
|
+
|
57
|
+
context "when client was not yet closed (`nil`)" do
|
58
|
+
it "is not closed" do
|
59
|
+
expect(client.closed?).to eq(false)
|
60
|
+
end
|
61
|
+
|
62
|
+
context "and attempt to close" do
|
63
|
+
it "calls the `destroy` binding" do
|
64
|
+
expect(Rdkafka::Bindings).to receive(:rd_kafka_destroy).with(native).and_call_original
|
65
|
+
|
66
|
+
client.close
|
67
|
+
end
|
68
|
+
|
69
|
+
it "indicates to the polling thread that it is closing" do
|
70
|
+
expect(thread).to receive(:[]=).with(:closing, true)
|
71
|
+
|
72
|
+
client.close
|
73
|
+
end
|
74
|
+
|
75
|
+
it "joins the polling thread" do
|
76
|
+
expect(thread).to receive(:join)
|
77
|
+
|
78
|
+
client.close
|
79
|
+
end
|
80
|
+
|
81
|
+
it "closes and unassign the native client" do
|
82
|
+
client.close
|
83
|
+
|
84
|
+
expect(client.closed?).to eq(true)
|
85
|
+
end
|
86
|
+
end
|
87
|
+
end
|
88
|
+
|
89
|
+
context "when client was already closed" do
|
90
|
+
before { client.close }
|
91
|
+
|
92
|
+
it "is closed" do
|
93
|
+
expect(client.closed?).to eq(true)
|
94
|
+
end
|
95
|
+
|
96
|
+
context "and attempt to close again" do
|
97
|
+
it "does not call the `destroy` binding" do
|
98
|
+
expect(Rdkafka::Bindings).not_to receive(:rd_kafka_destroy_flags)
|
99
|
+
|
100
|
+
client.close
|
101
|
+
end
|
102
|
+
|
103
|
+
it "does not indicate to the polling thread that it is closing" do
|
104
|
+
expect(thread).not_to receive(:[]=).with(:closing, true)
|
105
|
+
|
106
|
+
client.close
|
107
|
+
end
|
108
|
+
|
109
|
+
it "does not join the polling thread" do
|
110
|
+
expect(thread).not_to receive(:join)
|
111
|
+
|
112
|
+
client.close
|
113
|
+
end
|
114
|
+
|
115
|
+
it "does not close and unassign the native client again" do
|
116
|
+
client.close
|
117
|
+
|
118
|
+
expect(client.closed?).to eq(true)
|
119
|
+
end
|
120
|
+
end
|
121
|
+
end
|
122
|
+
|
123
|
+
it "provides a finalizer that closes the native kafka client" do
|
124
|
+
expect(client.closed?).to eq(false)
|
125
|
+
|
126
|
+
client.finalizer.call("some-ignored-object-id")
|
127
|
+
|
128
|
+
expect(client.closed?).to eq(true)
|
129
|
+
end
|
130
|
+
end
|
@@ -0,0 +1,60 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
describe Rdkafka::Producer::DeliveryHandle do
|
4
|
+
let(:response) { 0 }
|
5
|
+
|
6
|
+
subject do
|
7
|
+
Rdkafka::Producer::DeliveryHandle.new.tap do |handle|
|
8
|
+
handle[:pending] = pending_handle
|
9
|
+
handle[:response] = response
|
10
|
+
handle[:partition] = 2
|
11
|
+
handle[:offset] = 100
|
12
|
+
handle.topic = "produce_test_topic"
|
13
|
+
end
|
14
|
+
end
|
15
|
+
|
16
|
+
describe "#wait" do
|
17
|
+
let(:pending_handle) { true }
|
18
|
+
|
19
|
+
it "should wait until the timeout and then raise an error" do
|
20
|
+
expect {
|
21
|
+
subject.wait(max_wait_timeout: 0.1)
|
22
|
+
}.to raise_error Rdkafka::Producer::DeliveryHandle::WaitTimeoutError, /delivery/
|
23
|
+
end
|
24
|
+
|
25
|
+
context "when not pending anymore and no error" do
|
26
|
+
let(:pending_handle) { false }
|
27
|
+
|
28
|
+
it "should return a delivery report" do
|
29
|
+
report = subject.wait
|
30
|
+
|
31
|
+
expect(report.partition).to eq(2)
|
32
|
+
expect(report.offset).to eq(100)
|
33
|
+
expect(report.topic_name).to eq("produce_test_topic")
|
34
|
+
end
|
35
|
+
|
36
|
+
it "should wait without a timeout" do
|
37
|
+
report = subject.wait(max_wait_timeout: nil)
|
38
|
+
|
39
|
+
expect(report.partition).to eq(2)
|
40
|
+
expect(report.offset).to eq(100)
|
41
|
+
expect(report.topic_name).to eq("produce_test_topic")
|
42
|
+
end
|
43
|
+
end
|
44
|
+
end
|
45
|
+
|
46
|
+
describe '#create_result' do
|
47
|
+
let(:pending_handle) { false }
|
48
|
+
let(:report) { subject.create_result }
|
49
|
+
|
50
|
+
context 'when response is 0' do
|
51
|
+
it { expect(report.error).to eq(nil) }
|
52
|
+
end
|
53
|
+
|
54
|
+
context 'when response is not 0' do
|
55
|
+
let(:response) { 1 }
|
56
|
+
|
57
|
+
it { expect(report.error).to eq(Rdkafka::RdkafkaError.new(response)) }
|
58
|
+
end
|
59
|
+
end
|
60
|
+
end
|
@@ -0,0 +1,25 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
describe Rdkafka::Producer::DeliveryReport do
|
4
|
+
subject { Rdkafka::Producer::DeliveryReport.new(2, 100, "topic", -1) }
|
5
|
+
|
6
|
+
it "should get the partition" do
|
7
|
+
expect(subject.partition).to eq 2
|
8
|
+
end
|
9
|
+
|
10
|
+
it "should get the offset" do
|
11
|
+
expect(subject.offset).to eq 100
|
12
|
+
end
|
13
|
+
|
14
|
+
it "should get the topic_name" do
|
15
|
+
expect(subject.topic_name).to eq "topic"
|
16
|
+
end
|
17
|
+
|
18
|
+
it "should get the same topic name under topic alias" do
|
19
|
+
expect(subject.topic).to eq "topic"
|
20
|
+
end
|
21
|
+
|
22
|
+
it "should get the error" do
|
23
|
+
expect(subject.error).to eq -1
|
24
|
+
end
|
25
|
+
end
|