@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/README.md +20 -5
- package/build/{cell-B3-BSTit.d.ts → cell-DZm8zXN4.d.ts} +239 -8
- package/build/{image-BMdV7yWD.js → image-EZ8ioZ3L.js} +9 -2
- package/build/image-EZ8ioZ3L.js.map +1 -0
- package/build/index.d.ts +269 -4
- package/build/index.js +1284 -116
- package/build/index.js.map +1 -1
- package/build/schema.d.ts +1 -1
- package/build/schema.js +1 -1
- package/image/Dockerfile +1 -1
- package/image/amqp/client-cluster.mjs +68 -31
- package/image/hikari/ClientHikari.java +81 -6
- package/image/kafka/AdminKafka.java +117 -0
- package/image/kafka/ClientKafka.java +234 -0
- package/image/kafka/Dockerfile +23 -0
- package/image/kafka/jars.sha256 +5 -0
- package/image/native/src/bin/ports.rs +374 -0
- package/image/native/src/bin/queue.rs +553 -0
- package/image/node/client-sentinel.mjs +28 -12
- package/image/pg/client-pg.mjs +31 -4
- package/package.json +5 -1
- package/build/image-BMdV7yWD.js.map +0 -1
package/build/schema.d.ts
CHANGED
package/build/schema.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
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
|
|
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
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
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
|
-
|
|
168
|
-
|
|
169
|
-
|
|
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
|
-
|
|
173
|
-
|
|
174
|
-
|
|
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.
|
|
32
|
-
//
|
|
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())
|
|
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
|
-
|
|
496
|
-
|
|
497
|
-
|
|
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
|
+
}
|