@mark1russell7/harness 0.14.3 → 0.14.4

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.
package/build/schema.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { T as ImageSource, i as ClientImage, r as Client, st as Subject } from "./cell-B3-BSTit.js";
1
+ import { E as ImageSource, _t as Subject, i as ClientImage, r as Client } from "./cell-DZm8zXN4.js";
2
2
  import { z } from "zod";
3
3
  //#region src/schema.d.ts
4
4
  /**
package/build/schema.js CHANGED
@@ -1,4 +1,4 @@
1
- import { E as subjectProblems, a as SHIPPED_NAMES, h as imageProblems, t as CLIENT_NAMES } from "./image-BMdV7yWD.js";
1
+ import { D as subjectProblems, a as SHIPPED_NAMES, h as imageProblems, t as CLIENT_NAMES } from "./image-EZ8ioZ3L.js";
2
2
  import { z } from "zod";
3
3
  //#region src/schema.ts
4
4
  /** This function adds each problem that a check finds as an issue of a schema. */
package/image/Dockerfile CHANGED
@@ -26,6 +26,6 @@ FROM alpine:3.23@sha256:865b95f46d98cf867a156fe4a135ad3fe50d2056aa3f25ed31662dff
26
26
  # tc for netem, ethtool for the offloads that decide whether netem acts on segments
27
27
  RUN apk add --no-cache iproute2 iproute2-tc iptables ethtool libelf zlib zstd-libs libgcc
28
28
  COPY --from=build /src/prober.bpf.o /src/prober/native/target/release/prober /opt/harness/
29
- COPY --from=build /src/harness/image/native/target/release/client /src/harness/image/native/target/release/inject /opt/harness/
29
+ COPY --from=build /src/harness/image/native/target/release/client /src/harness/image/native/target/release/inject /src/harness/image/native/target/release/queue /src/harness/image/native/target/release/ports /opt/harness/
30
30
  WORKDIR /opt/harness
31
31
  CMD ["sleep", "infinity"]
@@ -9,11 +9,13 @@
9
9
  // HOSTS is host:port,host:port,... in the sequence of the connects: the first is the node of the
10
10
  // client. amqplib connects to one node and does not connect again by itself. When the connection ends,
11
11
  // this client connects to the next node of the list after --reconnect-ms (1000 ms), as an application
12
- // does. Then it publishes again the sequence number that has no confirm.
12
+ // does. Then it publishes again each sequence number that has no confirm.
13
13
  //
14
14
  // The options (--options JSON): `heartbeat` in seconds goes in the URL. Without it, amqplib takes the
15
15
  // heartbeat that the broker proposes. `user` and `password` are the user of the broker: guest, as
16
- // amqplib gives it, when the options name none.
16
+ // amqplib gives it, when the options name none. `window` is the count of writers: each writer has one
17
+ // publish at a time on the one channel, thus the client has at most that count with no confirm. It is
18
+ // 1 when the options name none. Each other name is a socket option of amqplib, for example `noDelay`.
17
19
  //
18
20
  // The lines:
19
21
  // - start: the versions of amqplib and Node, the hosts and the options. clocks: the wall clock.
@@ -46,7 +48,7 @@ const interval = Number(flag("--interval-ms") ?? 50);
46
48
  const max = Number(flag("--max-s") ?? 120) * 1000;
47
49
  const reconnect = Number(flag("--reconnect-ms") ?? 1000);
48
50
  const options = JSON.parse(flag("--options") ?? "{}");
49
- const { heartbeat, user = "guest", password = "guest" } = options;
51
+ const { heartbeat, user = "guest", password = "guest", window: writers = 1, ...socketOptions } = options;
50
52
 
51
53
  const { default: amqp } = await import("amqplib");
52
54
  const versionOf = (name) => JSON.parse(readFileSync(new URL(`./node_modules/${name}/package.json`, import.meta.url), "utf8")).version;
@@ -83,7 +85,7 @@ const open = async () => {
83
85
  next += 1;
84
86
  emit("connecting", { at: node });
85
87
  try {
86
- connection = await amqp.connect(urlOf(node));
88
+ connection = await amqp.connect(urlOf(node), socketOptions);
87
89
  } catch (error) {
88
90
  emit("connection", { event: "connect-failed", at: node, errno: errnoOf(error), failure: failure(error) });
89
91
  return false;
@@ -156,36 +158,71 @@ setTimeout(() => {
156
158
  }, max);
157
159
 
158
160
  let ready = false;
159
- let n = 1;
160
- while (!stopped) {
161
- if (channel === null) {
162
- if (!(await open())) {
161
+ let nextNumber = 1;
162
+ // these promises keep one connect and one end of a connection for all the writers
163
+ let opening = null;
164
+ let leaving = null;
165
+
166
+ /** This function connects when no channel is open. Each writer waits for the same connect. */
167
+ const connected = () => {
168
+ if (channel !== null) return Promise.resolve();
169
+ opening ??= (async () => {
170
+ while (!stopped) {
171
+ if (await open()) {
172
+ if (!ready) {
173
+ ready = true;
174
+ emit("ready", { at });
175
+ }
176
+ break;
177
+ }
163
178
  channel = null;
164
179
  await sleep(reconnect);
180
+ }
181
+ opening = null;
182
+ })();
183
+ return opening;
184
+ };
185
+
186
+ /** This function ends the connection of a closed channel. Each writer of that channel waits for the same end. */
187
+ const left = (closed) => {
188
+ if (closed !== channel) return leaving ?? Promise.resolve();
189
+ leaving ??= (async () => {
190
+ // the channel closed: its connection ends with it, or after one more wait the client ends the connection
191
+ const closing = connection;
192
+ await Promise.race([ended, sleep(reconnect)]);
193
+ closing?.close().catch(() => undefined);
194
+ channel = null;
195
+ connection = null;
196
+ if (!stopped) await sleep(reconnect);
197
+ leaving = null;
198
+ })();
199
+ return leaving;
200
+ };
201
+
202
+ /** This function is one writer: it takes the next sequence number at each confirm. */
203
+ const writer = async () => {
204
+ let n = nextNumber;
205
+ nextNumber += 1;
206
+ while (!stopped) {
207
+ await leaving;
208
+ await connected();
209
+ if (stopped) return;
210
+ const on = channel;
211
+ // a publish ends at its confirm, at a nack, or at the close of its channel
212
+ const failed = await publish(n);
213
+ if (failed === null) {
214
+ n = nextNumber;
215
+ nextNumber += 1;
216
+ await sleep(interval);
165
217
  continue;
166
218
  }
167
- if (!ready) {
168
- ready = true;
169
- emit("ready", { at });
219
+ // the same sequence number goes again: on the same channel after a nack, or on the next node after an end
220
+ if (failed.message === "message nacked") {
221
+ await sleep(interval);
222
+ continue;
170
223
  }
224
+ await left(on);
171
225
  }
172
- // a publish ends at its confirm, at a nack, or at the close of its channel
173
- const failed = await publish(n);
174
- if (failed === null) {
175
- n += 1;
176
- await sleep(interval);
177
- continue;
178
- }
179
- // the same sequence number goes again: on the same channel after a nack, or on the next node after an end
180
- if (failed.message === "message nacked") {
181
- await sleep(interval);
182
- continue;
183
- }
184
- // the channel closed: its connection ends with it, or after one more wait the client ends the connection
185
- const closing = connection;
186
- await Promise.race([ended, sleep(reconnect)]);
187
- closing?.close().catch(() => undefined);
188
- channel = null;
189
- connection = null;
190
- if (!stopped) await sleep(reconnect);
191
- }
226
+ };
227
+
228
+ await Promise.all(Array.from({ length: writers }, writer));
@@ -1,5 +1,6 @@
1
1
  // client-hikari HOST PORT --mode idle|inflight|stream [--interval-ms N] [--max-s N] [--go PATH]
2
2
  // [--query TEXT] [--inflight-query TEXT] [--before] [--options JSON]
3
+ // [--burst N --burst-gap-ms N]
3
4
  //
4
5
  // The client of a HikariCP cell: HikariCP 7.1.0 over pgjdbc 42.7.13, on Java 21. Each event is a
5
6
  // JSON line with `ev` and `t`, the time on CLOCK_MONOTONIC in nanoseconds (System.nanoTime, which
@@ -18,6 +19,7 @@
18
19
  // - sent, ack, fail: each query by its sequence number, with its error.
19
20
  // - data, error: the end of the query in flight, as the TCP client of the harness tells it. In idle
20
21
  // mode, the close of the socket of the first connection is an error line too: the pool closed it.
22
+ // - burst: the end of a burst: its counts of replies and of failures, then the counts of the pool.
21
23
  // - done, deadline: the end of the client.
22
24
  //
23
25
  // The modes are those of the pg client. idle sends one query, closes its connection (the
@@ -27,9 +29,15 @@
27
29
  // already: the start of its pool meets it. --inflight-query is the text of the query in flight
28
30
  // (query 2 of inflight), and --query is the text of each other query.
29
31
  //
32
+ // --burst sends N queries in flight in place of one, each on its own thread, query 2 to query N + 1.
33
+ // The starts are --burst-gap-ms apart, thus the queue of the pool has the order of the numbers. The
34
+ // burst starts when each connection of the pool is idle.
35
+ //
30
36
  // The options are one flat JSON object. A name with the prefix `dataSource.` is a property of
31
- // pgjdbc. Each other name is a property of HikariConfig. A cell gives each option that it changes,
32
- // and HikariCP and pgjdbc give the others.
37
+ // pgjdbc. A name with the prefix `system.` is a system property of the JVM. The client sets it
38
+ // after its warm-up and before its pool, for example `system.com.zaxxer.hikari.aliveBypassWindowMs`,
39
+ // which HikariPool reads when the pool starts. Each other name is a property of HikariConfig. A cell
40
+ // gives each option that it changes, and HikariCP and pgjdbc give the others.
33
41
 
34
42
  import com.zaxxer.hikari.HikariConfig;
35
43
  import com.zaxxer.hikari.HikariDataSource;
@@ -46,12 +54,15 @@ import java.sql.Connection;
46
54
  import java.sql.ResultSet;
47
55
  import java.sql.SQLException;
48
56
  import java.sql.Statement;
57
+ import java.util.ArrayList;
49
58
  import java.util.LinkedHashMap;
50
59
  import java.util.List;
51
60
  import java.util.Map;
52
61
  import java.util.Properties;
53
62
  import java.util.Set;
54
63
  import java.util.concurrent.ConcurrentHashMap;
64
+ import java.util.concurrent.atomic.AtomicInteger;
65
+ import java.util.concurrent.locks.LockSupport;
55
66
  import org.postgresql.core.PGStream;
56
67
  import org.postgresql.jdbc.PgConnection;
57
68
  import org.postgresql.util.PSQLException;
@@ -61,6 +72,9 @@ public final class ClientHikari {
61
72
  private static final PrintStream OUT = new PrintStream(new FileOutputStream(FileDescriptor.out), false, StandardCharsets.UTF_8);
62
73
  private static final Object LOCK = new Object();
63
74
  private static final Set<String> SEEN = ConcurrentHashMap.newKeySet();
75
+ /** This time is the start of a burst after its threads start, in nanoseconds: each thread then waits for its own start. */
76
+ private static final long BURST_LEAD = 50_000_000L;
77
+ private static final String SYSTEM = "system.";
64
78
  private static volatile boolean stopped = false;
65
79
  private static volatile HikariDataSource pool;
66
80
  private static volatile String connected;
@@ -405,6 +419,58 @@ public final class ClientHikari {
405
419
  failure(cancelled);
406
420
  }
407
421
 
422
+ /**
423
+ * This function loads the code of a borrow that times out, before the first line of a burst. One
424
+ * borrow waits on a pool that cannot connect, for the 250 ms floor of connectionTimeout
425
+ * (HikariConfig.java:189-200). A new JVM would otherwise load that code in the band of the first
426
+ * failure.
427
+ */
428
+ private static void warmTimeout() {
429
+ HikariConfig empty = new HikariConfig();
430
+ empty.setJdbcUrl("jdbc:postgresql://127.0.0.1:1/warmup");
431
+ empty.setUsername("warmup");
432
+ empty.setPoolName("warmup");
433
+ empty.setInitializationFailTimeout(-1);
434
+ empty.setMinimumIdle(0);
435
+ empty.setConnectionTimeout(250);
436
+ try (HikariDataSource ds = new HikariDataSource(empty); Connection connection = ds.getConnection()) {
437
+ throw new IllegalStateException("the warm-up pool gave a connection");
438
+ } catch (SQLException expected) {
439
+ failure(expected);
440
+ }
441
+ }
442
+
443
+ /**
444
+ * This function sends the queries of a burst, each on its own thread, and writes the burst line
445
+ * when each query ended. Each thread waits for its start on the clock, thus a slow start of one
446
+ * thread does not move the next one.
447
+ */
448
+ private static void burst(int count, long gap) throws InterruptedException {
449
+ HikariPoolMXBean bean = pool.getHikariPoolMXBean();
450
+ while (bean.getIdleConnections() < config.getMaximumPoolSize()) Thread.sleep(2);
451
+ AtomicInteger acks = new AtomicInteger();
452
+ AtomicInteger fails = new AtomicInteger();
453
+ long first = now() + BURST_LEAD;
454
+ List<Thread> threads = new ArrayList<>();
455
+ for (int k = 0; k < count; k += 1) {
456
+ int n = k + 2;
457
+ long start = first + k * gap;
458
+ Thread thread = new Thread(() -> {
459
+ for (long left = start - now(); left > 0; left = start - now()) LockSupport.parkNanos(left);
460
+ (operate(n, inflightQuery) == null ? acks : fails).incrementAndGet();
461
+ }, "burst-" + n);
462
+ thread.setDaemon(true);
463
+ thread.start();
464
+ threads.add(thread);
465
+ }
466
+ for (Thread thread : threads) thread.join();
467
+ emit("burst", "\"acks\":" + acks.get() + ",\"fails\":" + fails.get()
468
+ + ",\"total\":" + bean.getTotalConnections()
469
+ + ",\"active\":" + bean.getActiveConnections()
470
+ + ",\"idle\":" + bean.getIdleConnections()
471
+ + ",\"waiting\":" + bean.getThreadsAwaitingConnection());
472
+ }
473
+
408
474
  public static void main(String[] args) throws Exception {
409
475
  List<String> argv = List.of(args);
410
476
  String mode = flag(argv, "--mode", null);
@@ -421,7 +487,10 @@ public final class ClientHikari {
421
487
  inflightQuery = flag(argv, "--inflight-query", query);
422
488
  boolean before = argv.contains("--before");
423
489
  Map<String, String> options = parseObject(flag(argv, "--options", "{}"));
490
+ int burst = Integer.parseInt(flag(argv, "--burst", "0"));
491
+ long burstGap = Long.parseLong(flag(argv, "--burst-gap-ms", "0")) * 1_000_000L;
424
492
  warmUp();
493
+ if (burst > 0) warmTimeout();
425
494
 
426
495
  String bootId;
427
496
  try {
@@ -443,7 +512,10 @@ public final class ClientHikari {
443
512
  + ",\"mode\":" + q(mode) + ",\"query\":" + q(query) + ",\"inflight_query\":" + q(inflightQuery) + ",\"options\":" + given);
444
513
 
445
514
  Properties properties = new Properties();
446
- for (Map.Entry<String, String> each : options.entrySet()) properties.setProperty(each.getKey(), each.getValue());
515
+ for (Map.Entry<String, String> each : options.entrySet()) {
516
+ if (each.getKey().startsWith(SYSTEM)) System.setProperty(each.getKey().substring(SYSTEM.length()), each.getValue());
517
+ else properties.setProperty(each.getKey(), each.getValue());
518
+ }
447
519
  config = new HikariConfig(properties);
448
520
  config.setJdbcUrl("jdbc:postgresql://" + host + ":" + port + "/postgres");
449
521
  // the user of a cell, when it gives one: Supavisor routes a client by its user, <db_user>.<tenant>
@@ -492,9 +564,12 @@ public final class ClientHikari {
492
564
  if (mode.equals("idle")) watchIdle();
493
565
  if (mode.equals("inflight")) {
494
566
  while (!Files.exists(go)) Thread.sleep(2);
495
- Throwable error = operate(2, inflightQuery);
496
- if (error == null) emit("data", "\"n\":2");
497
- else emit("error", "\"errno\":" + q(errnoOf(error)) + ",\"message\":" + q(error.getMessage()));
567
+ if (burst > 0) burst(burst, burstGap);
568
+ else {
569
+ Throwable error = operate(2, inflightQuery);
570
+ if (error == null) emit("data", "\"n\":2");
571
+ else emit("error", "\"errno\":" + q(errnoOf(error)) + ",\"message\":" + q(error.getMessage()));
572
+ }
498
573
  }
499
574
  Thread.sleep(Long.MAX_VALUE);
500
575
  }
@@ -0,0 +1,117 @@
1
+ // admin-kafka BOOTSTRAP create TOPIC MIN_INSYNC BROKER,BROKER,...
2
+ // admin-kafka BOOTSTRAP view [TOPIC]
3
+ // admin-kafka BOOTSTRAP holds TOPIC
4
+ //
5
+ // The admin of a Kafka lab, on the Admin client and the consumer of kafka-clients 4.3.2. Each command
6
+ // prints one JSON line. An error ends the command with exit code 1, and the lab then reads the error.
7
+ // - create: a topic with one partition, its replicas on the brokers in sequence, and its setting of
8
+ // min.insync.replicas. The first broker is the first leader.
9
+ // - view: the ids of the brokers that are not fenced. With a topic, also the leader, the in-sync
10
+ // replicas and the replicas of its partition. The controller lists only brokers that are not fenced.
11
+ // - holds: the value of each record of the partition from its first offset to its high watermark,
12
+ // one entry for each copy.
13
+
14
+ import java.time.Duration;
15
+ import java.util.ArrayList;
16
+ import java.util.List;
17
+ import java.util.Map;
18
+ import java.util.Properties;
19
+ import org.apache.kafka.clients.admin.Admin;
20
+ import org.apache.kafka.clients.admin.AdminClientConfig;
21
+ import org.apache.kafka.clients.admin.NewTopic;
22
+ import org.apache.kafka.clients.admin.TopicDescription;
23
+ import org.apache.kafka.clients.consumer.ConsumerConfig;
24
+ import org.apache.kafka.clients.consumer.ConsumerRecord;
25
+ import org.apache.kafka.clients.consumer.KafkaConsumer;
26
+ import org.apache.kafka.common.Node;
27
+ import org.apache.kafka.common.TopicPartition;
28
+ import org.apache.kafka.common.TopicPartitionInfo;
29
+ import org.apache.kafka.common.serialization.StringDeserializer;
30
+
31
+ public final class AdminKafka {
32
+ /** This time bounds each call of the admin, in ms. It has no source: a lab repeats a view that fails. */
33
+ private static final String CALL_MS = "10000";
34
+ /** This time bounds a whole read of the partition, in ms. It has no source: it is far above the read of a run. */
35
+ private static final long READ_MS = 60_000;
36
+
37
+ private AdminKafka() {}
38
+
39
+ private static Admin adminOf(String bootstrap) {
40
+ Properties properties = new Properties();
41
+ properties.put(AdminClientConfig.BOOTSTRAP_SERVERS_CONFIG, bootstrap);
42
+ properties.put(AdminClientConfig.REQUEST_TIMEOUT_MS_CONFIG, CALL_MS);
43
+ properties.put(AdminClientConfig.DEFAULT_API_TIMEOUT_MS_CONFIG, CALL_MS);
44
+ return Admin.create(properties);
45
+ }
46
+
47
+ private static String idsOf(List<Node> nodes) {
48
+ List<String> ids = new ArrayList<>();
49
+ for (Node node : nodes) ids.add(String.valueOf(node.id()));
50
+ return "[" + String.join(",", ids) + "]";
51
+ }
52
+
53
+ private static void create(String bootstrap, String topic, String minInsync, String brokers) throws Exception {
54
+ List<Integer> replicas = new ArrayList<>();
55
+ for (String id : brokers.split(",")) replicas.add(Integer.valueOf(id));
56
+ try (Admin admin = adminOf(bootstrap)) {
57
+ NewTopic created = new NewTopic(topic, Map.of(0, replicas)).configs(Map.of("min.insync.replicas", minInsync));
58
+ admin.createTopics(List.of(created)).all().get();
59
+ }
60
+ System.out.println("{\"ok\":true}");
61
+ }
62
+
63
+ private static void view(String bootstrap, String topic) throws Exception {
64
+ try (Admin admin = adminOf(bootstrap)) {
65
+ String brokers = idsOf(new ArrayList<>(admin.describeCluster().nodes().get()));
66
+ if (topic == null) {
67
+ System.out.println("{\"brokers\":" + brokers + "}");
68
+ return;
69
+ }
70
+ TopicDescription described = admin.describeTopics(List.of(topic)).allTopicNames().get().get(topic);
71
+ TopicPartitionInfo partition = described.partitions().get(0);
72
+ System.out.println("{\"brokers\":" + brokers
73
+ + ",\"leader\":" + (partition.leader() == null ? "null" : String.valueOf(partition.leader().id()))
74
+ + ",\"isr\":" + idsOf(partition.isr())
75
+ + ",\"replicas\":" + idsOf(partition.replicas()) + "}");
76
+ }
77
+ }
78
+
79
+ private static void holds(String bootstrap, String topic) {
80
+ Properties properties = new Properties();
81
+ properties.put(ConsumerConfig.BOOTSTRAP_SERVERS_CONFIG, bootstrap);
82
+ properties.put(ConsumerConfig.KEY_DESERIALIZER_CLASS_CONFIG, StringDeserializer.class.getName());
83
+ properties.put(ConsumerConfig.VALUE_DESERIALIZER_CLASS_CONFIG, StringDeserializer.class.getName());
84
+ properties.put(ConsumerConfig.ENABLE_AUTO_COMMIT_CONFIG, "false");
85
+ properties.put(ConsumerConfig.AUTO_OFFSET_RESET_CONFIG, "earliest");
86
+ TopicPartition partition = new TopicPartition(topic, 0);
87
+ List<String> held = new ArrayList<>();
88
+ try (KafkaConsumer<String, String> consumer = new KafkaConsumer<>(properties)) {
89
+ consumer.assign(List.of(partition));
90
+ consumer.seekToBeginning(List.of(partition));
91
+ long end = consumer.endOffsets(List.of(partition)).get(partition);
92
+ long begun = System.currentTimeMillis();
93
+ while (consumer.position(partition) < end) {
94
+ if (System.currentTimeMillis() - begun > READ_MS) throw new IllegalStateException("the read stopped at offset " + consumer.position(partition) + " of " + end);
95
+ for (ConsumerRecord<String, String> record : consumer.poll(Duration.ofMillis(500))) held.add(record.value());
96
+ }
97
+ System.out.println("{\"held\":[" + String.join(",", held) + "],\"end\":" + end + "}");
98
+ }
99
+ }
100
+
101
+ public static void main(String[] args) throws Exception {
102
+ if (args.length < 2) {
103
+ System.err.println("admin-kafka BOOTSTRAP create|view|holds ...");
104
+ System.exit(1);
105
+ }
106
+ switch (args[1]) {
107
+ case "create" -> create(args[0], args[2], args[3], args[4]);
108
+ case "view" -> view(args[0], args.length > 2 ? args[2] : null);
109
+ case "holds" -> holds(args[0], args[2]);
110
+ default -> {
111
+ System.err.println("no command " + args[1]);
112
+ System.exit(1);
113
+ }
114
+ }
115
+ System.exit(0);
116
+ }
117
+ }
@@ -0,0 +1,234 @@
1
+ // client-kafka BOOTSTRAP --topic NAME [--writers N] [--interval-ms N] [--max-s N] [--set name=value]...
2
+ //
3
+ // The client of a Kafka cell: the producer of kafka-clients 4.3.2, on Java 21. Each event is a JSON
4
+ // line with `ev` and `t`, the time on CLOCK_MONOTONIC in nanoseconds (System.nanoTime, which the JVM
5
+ // reads from it on Linux). The prober stamps the records of the kernel on the same clock, and the
6
+ // injector stamps each fault on it.
7
+ //
8
+ // Each writer is one thread with one write at a time. It sends the next sequence number to
9
+ // partition 0 of the topic, and it waits for the reply of that write. It then waits --interval-ms.
10
+ // The producer keeps its own retries: a write that the client sees as a fail is a write that the
11
+ // producer gave up on.
12
+ //
13
+ // The lines:
14
+ // - start: the versions of the client and of Java, the topic, the writers and the settings.
15
+ // - ready: the producer has the metadata of the topic. `at` is the address of the leader.
16
+ // - sent: a write goes to the producer, with its sequence number.
17
+ // - ack: the reply of a write: its offset, and `at`, the address of the leader in the metadata of the
18
+ // producer at that reply.
19
+ // - fail: the error of a write, with its sequence number.
20
+ // - done, deadline: the end of the client.
21
+ //
22
+ // Each --set is a setting of the producer. A cell gives each setting that it changes, and the
23
+ // producer gives the others.
24
+
25
+ import java.io.FileDescriptor;
26
+ import java.io.FileOutputStream;
27
+ import java.io.PrintStream;
28
+ import java.net.InetAddress;
29
+ import java.net.UnknownHostException;
30
+ import java.nio.charset.StandardCharsets;
31
+ import java.nio.file.Files;
32
+ import java.nio.file.Path;
33
+ import java.util.ArrayList;
34
+ import java.util.LinkedHashMap;
35
+ import java.util.List;
36
+ import java.util.Map;
37
+ import java.util.Properties;
38
+ import java.util.concurrent.ConcurrentHashMap;
39
+ import java.util.concurrent.ExecutionException;
40
+ import java.util.concurrent.atomic.AtomicInteger;
41
+ import org.apache.kafka.clients.producer.KafkaProducer;
42
+ import org.apache.kafka.clients.producer.Producer;
43
+ import org.apache.kafka.clients.producer.ProducerConfig;
44
+ import org.apache.kafka.clients.producer.ProducerRecord;
45
+ import org.apache.kafka.clients.producer.RecordMetadata;
46
+ import org.apache.kafka.common.Node;
47
+ import org.apache.kafka.common.PartitionInfo;
48
+ import org.apache.kafka.common.serialization.StringSerializer;
49
+ import org.apache.kafka.common.utils.AppInfoParser;
50
+
51
+ public final class ClientKafka {
52
+ private static final PrintStream OUT = new PrintStream(new FileOutputStream(FileDescriptor.out), false, StandardCharsets.UTF_8);
53
+ private static final Object LOCK = new Object();
54
+ private static final AtomicInteger NEXT = new AtomicInteger(1);
55
+ private static final Map<String, String> ADDRESSES = new ConcurrentHashMap<>();
56
+ private static volatile boolean stopped = false;
57
+ private static volatile String leader = null;
58
+
59
+ private ClientKafka() {}
60
+
61
+ private static long now() {
62
+ return System.nanoTime();
63
+ }
64
+
65
+ /** This function writes one line: its event, its stamp, and the JSON fields after them. */
66
+ private static void emit(String ev, long t, String fields) {
67
+ String line = "{\"ev\":" + q(ev) + ",\"t\":" + t + (fields.isEmpty() ? "" : "," + fields) + "}\n";
68
+ synchronized (LOCK) {
69
+ OUT.print(line);
70
+ OUT.flush();
71
+ }
72
+ }
73
+
74
+ private static void emit(String ev, String fields) {
75
+ emit(ev, now(), fields);
76
+ }
77
+
78
+ /** This function gives a text as a JSON string, or null. */
79
+ private static String q(String text) {
80
+ if (text == null) return "null";
81
+ StringBuilder out = new StringBuilder("\"");
82
+ for (char c : text.toCharArray()) {
83
+ switch (c) {
84
+ case '"' -> out.append("\\\"");
85
+ case '\\' -> out.append("\\\\");
86
+ case '\n' -> out.append("\\n");
87
+ case '\r' -> out.append("\\r");
88
+ case '\t' -> out.append("\\t");
89
+ default -> {
90
+ if (c < 0x20) out.append(String.format("\\u%04x", (int) c));
91
+ else out.append(c);
92
+ }
93
+ }
94
+ }
95
+ return out.append('"').toString();
96
+ }
97
+
98
+ private static String flag(List<String> argv, String name, String otherwise) {
99
+ int at = argv.indexOf(name);
100
+ return at == -1 || at + 1 >= argv.size() ? otherwise : argv.get(at + 1);
101
+ }
102
+
103
+ /** This function gives each value of a flag that repeats, in sequence. */
104
+ private static List<String> flags(List<String> argv, String name) {
105
+ List<String> out = new ArrayList<>();
106
+ for (int at = 0; at + 1 < argv.size(); at += 1) if (argv.get(at).equals(name)) out.add(argv.get(at + 1));
107
+ return out;
108
+ }
109
+
110
+ /** This function gives the address of a broker: `ip:port`. A lookup happens once for each host. */
111
+ private static String addressOf(Node node) {
112
+ String key = node.host() + ":" + node.port();
113
+ return ADDRESSES.computeIfAbsent(key, (absent) -> {
114
+ try {
115
+ return InetAddress.getByName(node.host()).getHostAddress() + ":" + node.port();
116
+ } catch (UnknownHostException error) {
117
+ return key;
118
+ }
119
+ });
120
+ }
121
+
122
+ /**
123
+ * This function gives the address of the leader of the partition in the metadata of the producer.
124
+ * The metadata has the topic after the ready line, thus partitionsFor does not wait. A partition
125
+ * with no leader keeps the last address.
126
+ */
127
+ private static String leaderOf(Producer<String, String> producer, String topic) {
128
+ try {
129
+ List<PartitionInfo> partitions = producer.partitionsFor(topic);
130
+ Node node = partitions.isEmpty() ? null : partitions.get(0).leader();
131
+ if (node != null) leader = addressOf(node);
132
+ } catch (RuntimeException absent) {
133
+ // the last address stays
134
+ }
135
+ return leader;
136
+ }
137
+
138
+ private static String failure(Throwable error) {
139
+ Throwable cause = error.getCause() == null ? error : error.getCause();
140
+ return "\"name\":" + q(cause.getClass().getSimpleName()) + ",\"message\":" + q(cause.getMessage());
141
+ }
142
+
143
+ /** This function is one writer: one write at a time, until the client stops. */
144
+ private static void write(Producer<String, String> producer, String topic, long interval) {
145
+ while (!stopped) {
146
+ int n = NEXT.getAndIncrement();
147
+ emit("sent", "\"n\":" + n);
148
+ try {
149
+ RecordMetadata record = producer.send(new ProducerRecord<>(topic, 0, null, String.valueOf(n))).get();
150
+ emit("ack", "\"n\":" + n + ",\"offset\":" + record.offset() + ",\"at\":" + q(leaderOf(producer, topic)));
151
+ } catch (ExecutionException | RuntimeException error) {
152
+ emit("fail", "\"n\":" + n + "," + failure(error));
153
+ } catch (InterruptedException stop) {
154
+ return;
155
+ }
156
+ try {
157
+ Thread.sleep(interval);
158
+ } catch (InterruptedException stop) {
159
+ return;
160
+ }
161
+ }
162
+ }
163
+
164
+ public static void main(String[] args) throws Exception {
165
+ List<String> argv = List.of(args);
166
+ String topic = flag(argv, "--topic", null);
167
+ if (args.length < 1 || topic == null) {
168
+ emit("fail", "\"why\":\"client-kafka BOOTSTRAP --topic NAME\"");
169
+ Runtime.getRuntime().halt(1);
170
+ }
171
+ int writers = Integer.parseInt(flag(argv, "--writers", "1"));
172
+ long interval = Long.parseLong(flag(argv, "--interval-ms", "50"));
173
+ long max = Long.parseLong(flag(argv, "--max-s", "120")) * 1000;
174
+
175
+ Map<String, String> settings = new LinkedHashMap<>();
176
+ for (String each : flags(argv, "--set")) {
177
+ int at = each.indexOf('=');
178
+ if (at == -1) throw new IllegalArgumentException("a setting is name=value: " + each);
179
+ settings.put(each.substring(0, at), each.substring(at + 1));
180
+ }
181
+ String bootId;
182
+ try {
183
+ bootId = Files.readString(Path.of("/proc/sys/kernel/random/boot_id")).trim();
184
+ } catch (Exception absent) {
185
+ bootId = null;
186
+ }
187
+ StringBuilder given = new StringBuilder("{");
188
+ for (Map.Entry<String, String> each : settings.entrySet()) {
189
+ if (given.length() > 1) given.append(',');
190
+ given.append(q(each.getKey())).append(':').append(q(each.getValue()));
191
+ }
192
+ given.append('}');
193
+ emit("start", "\"boot_id\":" + q(bootId)
194
+ + ",\"client\":\"kafka\",\"driver\":" + q(AppInfoParser.getVersion())
195
+ + ",\"java\":" + q(System.getProperty("java.version"))
196
+ + ",\"topic\":" + q(topic) + ",\"writers\":" + writers + ",\"settings\":" + given);
197
+
198
+ Properties properties = new Properties();
199
+ properties.put(ProducerConfig.BOOTSTRAP_SERVERS_CONFIG, args[0]);
200
+ properties.put(ProducerConfig.KEY_SERIALIZER_CLASS_CONFIG, StringSerializer.class.getName());
201
+ properties.put(ProducerConfig.VALUE_SERIALIZER_CLASS_CONFIG, StringSerializer.class.getName());
202
+ for (Map.Entry<String, String> each : settings.entrySet()) properties.put(each.getKey(), each.getValue());
203
+ Producer<String, String> producer = new KafkaProducer<>(properties);
204
+
205
+ Runtime.getRuntime().addShutdownHook(new Thread(() -> {
206
+ // SIGTERM: the end of the client, with exit code 0, as the other clients give
207
+ synchronized (LOCK) {
208
+ if (stopped) return;
209
+ stopped = true;
210
+ }
211
+ emit("done", "");
212
+ Runtime.getRuntime().halt(0);
213
+ }, "sigterm"));
214
+ Thread deadline = new Thread(() -> {
215
+ try {
216
+ Thread.sleep(max);
217
+ } catch (InterruptedException stop) {
218
+ return;
219
+ }
220
+ emit("deadline", "");
221
+ stopped = true;
222
+ emit("done", "");
223
+ Runtime.getRuntime().halt(0);
224
+ }, "deadline");
225
+ deadline.setDaemon(true);
226
+ deadline.start();
227
+
228
+ emit("ready", "\"at\":" + q(leaderOf(producer, topic)));
229
+ for (int at = 0; at < writers; at += 1) {
230
+ Thread writer = new Thread(() -> write(producer, topic, interval), "writer-" + at);
231
+ writer.start();
232
+ }
233
+ }
234
+ }