Skip to content

Commit 444b913

Browse files
committed
feat: add produce and consume commands
1 parent aaf6729 commit 444b913

7 files changed

Lines changed: 644 additions & 55 deletions

File tree

CMakeLists.txt

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,9 @@ include_directories("${CMAKE_SOURCE_DIR}/include" ${SIMPLEINI_INCLUDE_DIRS})
3737

3838
find_package(RdKafka CONFIG REQUIRED)
3939
find_package(argparse CONFIG REQUIRED)
40+
find_package(Threads REQUIRED)
4041

4142
add_executable(snctl-cpp src/main.cc)
4243
target_compile_definitions(snctl-cpp PUBLIC VERSION_STR="${VERSION_STR}")
43-
target_link_libraries(snctl-cpp PRIVATE argparse::argparse RdKafka::rdkafka)
44+
target_link_libraries(snctl-cpp PRIVATE argparse::argparse RdKafka::rdkafka
45+
Threads::Threads)

README.md

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -156,6 +156,38 @@ Offsets info for group 'sub' with 4 topic-partitions:
156156
| test-3 | 0 | 0 | 0 |
157157
```
158158
159+
## Traffic
160+
161+
### Produce messages
162+
163+
Create multiple producers on a topic and split the configured total rate across
164+
them:
165+
166+
```bash
167+
$ snctl-cpp produce my-topic -n 4 --rate 1000
168+
Started 4 producers on topic "my-topic" with total rate 1000 msg/s. Press Ctrl+C to stop.
169+
Produced 1002 messages (1002 msg/s), failures: 0
170+
...
171+
```
172+
173+
Use `--message-size` to control the payload size in bytes. The default is 1024
174+
bytes.
175+
176+
### Consume messages
177+
178+
Create multiple consumers on a topic:
179+
180+
```bash
181+
$ snctl-cpp consume my-topic -n 4 --group my-group
182+
Started 4 consumers on topic "my-topic" in group "my-group". Press Ctrl+C to stop.
183+
Consumed 1000 messages (1000 msg/s), bytes: 1024000, poll errors: 0
184+
...
185+
```
186+
187+
If `--group` is not provided, `snctl-cpp` generates one automatically. The
188+
default offset reset policy is `earliest`, which can be changed with
189+
`--offset-reset latest`.
190+
159191
## Logging
160192
161193
By default, rdkafka will generate logs to the standard output. `snctl-cpp` can redirect the logs to a file. For example, with the following configs in `sncloud.ini`:

include/snctl-cpp/consume.h

Lines changed: 213 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,213 @@
1+
/**
2+
* Copyright 2025 Yunze Xu
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
#pragma once
17+
18+
#include "snctl-cpp/kafka_client.h"
19+
#include "snctl-cpp/raii_helper.h"
20+
#include "snctl-cpp/stop_signal.h"
21+
22+
#include <argparse/argparse.hpp>
23+
#include <atomic>
24+
#include <chrono>
25+
#include <cstdint>
26+
#include <ctime>
27+
#include <iostream>
28+
#include <mutex>
29+
#include <optional>
30+
#include <stdexcept>
31+
#include <string>
32+
#include <thread>
33+
#include <unordered_map>
34+
#include <vector>
35+
36+
class ConsumeCommand final {
37+
public:
38+
explicit ConsumeCommand(argparse::ArgumentParser &parent) {
39+
command_.add_description("Create N consumers on a topic");
40+
command_.add_argument("topic").help("Topic to consume from").required();
41+
command_.add_argument("-n", "--consumers")
42+
.help("Number of consumers")
43+
.scan<'i', int>()
44+
.default_value(1);
45+
command_.add_argument("--group").help("Consumer group id");
46+
command_.add_argument("--offset-reset")
47+
.help("Offset reset policy for new groups: earliest or latest")
48+
.default_value(std::string("earliest"));
49+
command_.add_argument("--report-interval-ms")
50+
.help("Stats report interval in milliseconds")
51+
.scan<'i', int>()
52+
.default_value(1000);
53+
54+
parent.add_subparser(command_);
55+
}
56+
57+
bool used_by_parent(argparse::ArgumentParser &parent) const {
58+
return parent.is_subcommand_used(command_);
59+
}
60+
61+
void run(const std::unordered_map<std::string, std::string> &base_configs,
62+
const LogConfigs &log_configs,
63+
const std::optional<std::string> &client_id_base) {
64+
const auto topic = command_.get("topic");
65+
const auto consumer_count = command_.get<int>("--consumers");
66+
const auto offset_reset = command_.get("--offset-reset");
67+
const auto report_interval_ms = command_.get<int>("--report-interval-ms");
68+
69+
if (consumer_count <= 0) {
70+
throw std::invalid_argument(
71+
"The number of consumers must be greater than 0");
72+
}
73+
if (offset_reset != "earliest" && offset_reset != "latest") {
74+
throw std::invalid_argument(
75+
"The offset reset policy must be either earliest or latest");
76+
}
77+
if (report_interval_ms <= 0) {
78+
throw std::invalid_argument(
79+
"The report interval must be greater than 0 milliseconds");
80+
}
81+
82+
const auto group_id =
83+
command_.present("--group").value_or(default_group_id(topic));
84+
85+
std::cout << "Started " << consumer_count << " consumer"
86+
<< (consumer_count == 1 ? "" : "s") << " on topic \"" << topic
87+
<< "\" in group \"" << group_id << "\". Press Ctrl+C to stop."
88+
<< std::endl;
89+
90+
StopSignalGuard stop_signal_guard;
91+
std::atomic<uint64_t> consumed_messages = 0;
92+
std::atomic<uint64_t> consumed_bytes = 0;
93+
std::atomic<uint64_t> poll_errors = 0;
94+
std::vector<std::thread> threads;
95+
std::mutex errors_mu;
96+
std::vector<std::string> errors;
97+
98+
auto add_error = [&errors_mu, &errors](std::string message) {
99+
std::lock_guard<std::mutex> lock(errors_mu);
100+
errors.emplace_back(std::move(message));
101+
};
102+
103+
threads.reserve(consumer_count);
104+
for (int i = 0; i < consumer_count; i++) {
105+
threads.emplace_back([&, consumer_index = i]() {
106+
try {
107+
auto client_configs = base_configs;
108+
client_configs["group.id"] = group_id;
109+
client_configs["client.id"] =
110+
make_client_id(client_id_base, group_id, consumer_index);
111+
client_configs["auto.offset.reset"] = offset_reset;
112+
KafkaClient client(RD_KAFKA_CONSUMER, client_configs, log_configs);
113+
114+
auto *subscription = rd_kafka_topic_partition_list_new(1);
115+
if (subscription == nullptr) {
116+
throw std::runtime_error("consumer[" +
117+
std::to_string(consumer_index) +
118+
"] failed to create subscription list");
119+
}
120+
GUARD(subscription, rd_kafka_topic_partition_list_destroy);
121+
rd_kafka_topic_partition_list_add(subscription, topic.c_str(),
122+
RD_KAFKA_PARTITION_UA);
123+
124+
const auto subscribe_err =
125+
rd_kafka_subscribe(client.rk(), subscription);
126+
if (subscribe_err != RD_KAFKA_RESP_ERR_NO_ERROR) {
127+
throw std::runtime_error(
128+
"consumer[" + std::to_string(consumer_index) +
129+
"] failed to subscribe: " + rd_kafka_err2str(subscribe_err));
130+
}
131+
132+
while (!StopSignalGuard::is_stop_requested()) {
133+
auto *message = rd_kafka_consumer_poll(client.rk(), 250);
134+
if (message == nullptr) {
135+
continue;
136+
}
137+
138+
if (message->err == RD_KAFKA_RESP_ERR_NO_ERROR) {
139+
consumed_messages++;
140+
consumed_bytes += static_cast<uint64_t>(message->len);
141+
} else if (message->err != RD_KAFKA_RESP_ERR__PARTITION_EOF) {
142+
poll_errors++;
143+
}
144+
rd_kafka_message_destroy(message);
145+
}
146+
147+
const auto close_err = rd_kafka_consumer_close(client.rk());
148+
if (close_err != RD_KAFKA_RESP_ERR_NO_ERROR) {
149+
throw std::runtime_error(
150+
"consumer[" + std::to_string(consumer_index) +
151+
"] failed to close: " + rd_kafka_err2str(close_err));
152+
}
153+
} catch (const std::exception &e) {
154+
add_error(e.what());
155+
StopSignalGuard::request_stop();
156+
}
157+
});
158+
}
159+
160+
const auto report_interval = std::chrono::milliseconds(report_interval_ms);
161+
uint64_t previous_consumed = 0;
162+
while (!StopSignalGuard::is_stop_requested()) {
163+
std::this_thread::sleep_for(report_interval);
164+
165+
const auto current_consumed = consumed_messages.load();
166+
const auto current_bytes = consumed_bytes.load();
167+
const auto current_errors = poll_errors.load();
168+
const auto delta = current_consumed - previous_consumed;
169+
const auto rate = static_cast<double>(delta) * 1000.0 /
170+
static_cast<double>(report_interval_ms);
171+
172+
std::cout << "Consumed " << current_consumed << " messages (" << rate
173+
<< " msg/s), bytes: " << current_bytes
174+
<< ", poll errors: " << current_errors << std::endl;
175+
previous_consumed = current_consumed;
176+
177+
{
178+
std::lock_guard<std::mutex> lock(errors_mu);
179+
if (!errors.empty()) {
180+
break;
181+
}
182+
}
183+
}
184+
185+
for (auto &thread : threads) {
186+
thread.join();
187+
}
188+
189+
std::cout << "Stopped consumers. Consumed " << consumed_messages.load()
190+
<< " messages, bytes: " << consumed_bytes.load()
191+
<< ", poll errors: " << poll_errors.load() << std::endl;
192+
193+
if (!errors.empty()) {
194+
throw std::runtime_error(errors.front());
195+
}
196+
}
197+
198+
private:
199+
argparse::ArgumentParser command_{"consume"};
200+
201+
static std::string default_group_id(const std::string &topic) {
202+
return "snctl-cpp-" + topic + "-" + std::to_string(std::time(nullptr));
203+
}
204+
205+
static std::string
206+
make_client_id(const std::optional<std::string> &client_id_base,
207+
const std::string &group_id, int consumer_index) {
208+
if (client_id_base.has_value() && !client_id_base->empty()) {
209+
return *client_id_base + "-consumer-" + std::to_string(consumer_index);
210+
}
211+
return group_id + "-consumer-" + std::to_string(consumer_index);
212+
}
213+
};

include/snctl-cpp/kafka_client.h

Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
/**
2+
* Copyright 2025 Yunze Xu
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
#pragma once
17+
18+
#include "snctl-cpp/configs.h"
19+
20+
#include <array>
21+
#include <cstdio>
22+
#include <librdkafka/rdkafka.h>
23+
#include <memory>
24+
#include <stdexcept>
25+
#include <string>
26+
#include <unordered_map>
27+
28+
class KafkaClient final {
29+
public:
30+
KafkaClient(rd_kafka_type_t type,
31+
const std::unordered_map<std::string, std::string> &configs,
32+
const LogConfigs &log_configs, bool with_queue = false)
33+
: log_file_(nullptr, &fclose), rk_(nullptr, &rd_kafka_destroy),
34+
queue_(nullptr, &rd_kafka_queue_destroy) {
35+
std::array<char, 512> errstr;
36+
auto fail = [&errstr](const std::string &action) {
37+
throw std::runtime_error("Failed to " + action + ": " + errstr.data());
38+
};
39+
40+
auto *rk_conf = rd_kafka_conf_new();
41+
for (auto &&[key, value] : configs) {
42+
if (rd_kafka_conf_set(rk_conf, key.c_str(), value.c_str(), errstr.data(),
43+
errstr.size()) != RD_KAFKA_CONF_OK) {
44+
rd_kafka_conf_destroy(rk_conf);
45+
throw std::runtime_error("Failed to set " + key + " => " + value +
46+
": " + errstr.data());
47+
}
48+
}
49+
50+
if (log_configs.enabled) {
51+
FILE *log_output = stdout;
52+
if (!log_configs.path.empty()) {
53+
log_file_.reset(fopen(log_configs.path.c_str(), "a"));
54+
if (log_file_ == nullptr) {
55+
rd_kafka_conf_destroy(rk_conf);
56+
throw std::runtime_error("Failed to open log file: " +
57+
log_configs.path);
58+
}
59+
log_output = log_file_.get();
60+
}
61+
rd_kafka_conf_set_opaque(rk_conf, log_output);
62+
rd_kafka_conf_set_log_cb(
63+
rk_conf, +[](const rd_kafka_t *rk, int level, const char *fac,
64+
const char *buf) {
65+
auto *file = static_cast<FILE *>(rd_kafka_opaque(rk));
66+
fprintf(file, "[%d] %s: %s\n", level, fac, buf);
67+
fflush(file);
68+
});
69+
} else {
70+
rd_kafka_conf_set_log_cb(
71+
rk_conf, +[](const rd_kafka_t *rk, int level, const char *fac,
72+
const char *buf) {});
73+
}
74+
75+
auto *rk = rd_kafka_new(type, rk_conf, errstr.data(), errstr.size());
76+
if (rk == nullptr) {
77+
rd_kafka_conf_destroy(rk_conf);
78+
fail(type == RD_KAFKA_PRODUCER ? "create producer" : "create consumer");
79+
}
80+
rk_.reset(rk);
81+
82+
if (with_queue) {
83+
auto *rkqu = rd_kafka_queue_new(rk_.get());
84+
if (rkqu == nullptr) {
85+
fail("create queue");
86+
}
87+
queue_.reset(rkqu);
88+
}
89+
}
90+
91+
auto rk() const noexcept { return rk_.get(); }
92+
93+
auto queue() const noexcept { return queue_.get(); }
94+
95+
private:
96+
std::unique_ptr<FILE, decltype(&fclose)> log_file_;
97+
std::unique_ptr<rd_kafka_t, decltype(&rd_kafka_destroy)> rk_;
98+
std::unique_ptr<rd_kafka_queue_t, decltype(&rd_kafka_queue_destroy)> queue_;
99+
};

0 commit comments

Comments
 (0)