@mark1russell7/harness 0.14.4 → 0.15.0
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 +1 -0
- package/build/{cell-DZm8zXN4.d.ts → cell-biffSiEJ.d.ts} +178 -4
- package/build/{image-EZ8ioZ3L.js → image-BxVTFZ1o.js} +23 -2
- package/build/image-BxVTFZ1o.js.map +1 -0
- package/build/index.d.ts +244 -5
- package/build/index.js +823 -28
- package/build/index.js.map +1 -1
- package/build/schema.d.ts +5 -2
- package/build/schema.js +7 -3
- package/build/schema.js.map +1 -1
- package/image/Dockerfile +3 -3
- package/image/kafka/AdminKafka.java +44 -9
- package/image/kafka/ConsumerKafka.java +359 -0
- package/image/kafka/Dockerfile +2 -2
- package/image/native/Cargo.toml +1 -1
- package/image/native/src/bin/client.rs +164 -28
- package/image/native/src/bin/ctfull.rs +633 -0
- package/image/native/src/bin/inject.rs +16 -0
- package/image/native/src/bin/load.rs +481 -0
- package/image/native/src/bin/ports.rs +1 -8
- package/image/native/src/bin/queue.rs +1 -12
- package/image/native/src/lib.rs +51 -0
- package/package.json +5 -2
- package/build/image-EZ8ioZ3L.js.map +0 -1
package/build/schema.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { A as ImageSource, At as Subject, i as Client, o as ClientImage } from "./cell-biffSiEJ.js";
|
|
2
2
|
import { z } from "zod";
|
|
3
3
|
//#region src/schema.d.ts
|
|
4
4
|
/**
|
|
@@ -6,7 +6,10 @@ import { z } from "zod";
|
|
|
6
6
|
* a build context whose paths stay in it, or an image that the harness ships.
|
|
7
7
|
*/
|
|
8
8
|
export declare const imageSourceSchema: z.ZodType<ImageSource>;
|
|
9
|
-
/**
|
|
9
|
+
/**
|
|
10
|
+
* This schema accepts a client image (`ClientImage`): a stock image by its name or the source of an
|
|
11
|
+
* image, and its command. It also accepts the capabilities that its container adds.
|
|
12
|
+
*/
|
|
10
13
|
export declare const clientImageSchema: z.ZodType<ClientImage>;
|
|
11
14
|
/** This schema accepts the client of a cell (`Cell.client`): a client that the harness ships by its name, or a client image. */
|
|
12
15
|
export declare const clientSchema: z.ZodType<Client | ClientImage>;
|
package/build/schema.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { D as subjectProblems, a as SHIPPED_NAMES, h as imageProblems, t as CLIENT_NAMES } from "./image-
|
|
1
|
+
import { D as subjectProblems, a as SHIPPED_NAMES, h as imageProblems, t as CLIENT_NAMES, z as CLIENT_CAPABILITIES } from "./image-BxVTFZ1o.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. */
|
|
@@ -33,10 +33,14 @@ const sourceShape = z.discriminatedUnion("kind", [
|
|
|
33
33
|
* a build context whose paths stay in it, or an image that the harness ships.
|
|
34
34
|
*/
|
|
35
35
|
const imageSourceSchema = sourceShape.superRefine(issues(imageProblems));
|
|
36
|
-
/**
|
|
36
|
+
/**
|
|
37
|
+
* This schema accepts a client image (`ClientImage`): a stock image by its name or the source of an
|
|
38
|
+
* image, and its command. It also accepts the capabilities that its container adds.
|
|
39
|
+
*/
|
|
37
40
|
const clientImageSchema = z.strictObject({
|
|
38
41
|
image: z.union([z.string().min(1), imageSourceSchema]),
|
|
39
|
-
command
|
|
42
|
+
command,
|
|
43
|
+
capabilities: z.array(z.enum(CLIENT_CAPABILITIES)).optional()
|
|
40
44
|
});
|
|
41
45
|
/** This schema accepts the client of a cell (`Cell.client`): a client that the harness ships by its name, or a client image. */
|
|
42
46
|
const clientSchema = z.union([z.enum(CLIENT_NAMES), clientImageSchema]);
|
package/build/schema.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"schema.js","names":[],"sources":["../src/schema.ts"],"sourcesContent":["// The entry \"@mark1russell7/harness/schema\": the zod schemas of the data that a caller gives the\n// harness as JSON. These are the source of an image, a client and a subject. The root entry of\n// the package does not import zod.\nimport { z } from \"zod\";\nimport type { Client, ClientImage, ImageSource, Subject } from \"./cell.ts\";\nimport { CLIENT_NAMES, SHIPPED_NAMES, imageProblems, subjectProblems } from \"./image.ts\";\n\n/** This function adds each problem that a check finds as an issue of a schema. */\nconst issues =\n <T>(check: (value: T) => string[]) =>\n (value: T, ctx: z.RefinementCtx): void => {\n for (const message of check(value)) ctx.addIssue({ code: \"custom\", message });\n };\n\n/** This schema accepts a command: argv of one text or more. */\nconst command = z.array(z.string().min(1)).min(1);\n\n/** This schema accepts the shape of each kind of image source. The checks of its values come after it. */\nconst sourceShape = z.discriminatedUnion(\"kind\", [\n z.strictObject({ kind: z.literal(\"pinned\"), image: z.string() }),\n z.strictObject({\n kind: z.literal(\"build\"),\n name: z.string(),\n context: z.string(),\n dockerfile: z.string().optional(),\n files: z.array(z.string()).optional(),\n }),\n z.strictObject({ kind: z.literal(\"shipped\"), name: z.enum(SHIPPED_NAMES) }),\n]);\n\n/**\n * This schema accepts the source of an image (`ImageSource`). It is a pinned image with its digest,\n * a build context whose paths stay in it, or an image that the harness ships.\n */\nexport const imageSourceSchema: z.ZodType<ImageSource> = sourceShape.superRefine(issues(imageProblems));\n\n
|
|
1
|
+
{"version":3,"file":"schema.js","names":[],"sources":["../src/schema.ts"],"sourcesContent":["// The entry \"@mark1russell7/harness/schema\": the zod schemas of the data that a caller gives the\n// harness as JSON. These are the source of an image, a client and a subject. The root entry of\n// the package does not import zod.\nimport { z } from \"zod\";\nimport { CLIENT_CAPABILITIES } from \"./cell.ts\";\nimport type { Client, ClientImage, ImageSource, Subject } from \"./cell.ts\";\nimport { CLIENT_NAMES, SHIPPED_NAMES, imageProblems, subjectProblems } from \"./image.ts\";\n\n/** This function adds each problem that a check finds as an issue of a schema. */\nconst issues =\n <T>(check: (value: T) => string[]) =>\n (value: T, ctx: z.RefinementCtx): void => {\n for (const message of check(value)) ctx.addIssue({ code: \"custom\", message });\n };\n\n/** This schema accepts a command: argv of one text or more. */\nconst command = z.array(z.string().min(1)).min(1);\n\n/** This schema accepts the shape of each kind of image source. The checks of its values come after it. */\nconst sourceShape = z.discriminatedUnion(\"kind\", [\n z.strictObject({ kind: z.literal(\"pinned\"), image: z.string() }),\n z.strictObject({\n kind: z.literal(\"build\"),\n name: z.string(),\n context: z.string(),\n dockerfile: z.string().optional(),\n files: z.array(z.string()).optional(),\n }),\n z.strictObject({ kind: z.literal(\"shipped\"), name: z.enum(SHIPPED_NAMES) }),\n]);\n\n/**\n * This schema accepts the source of an image (`ImageSource`). It is a pinned image with its digest,\n * a build context whose paths stay in it, or an image that the harness ships.\n */\nexport const imageSourceSchema: z.ZodType<ImageSource> = sourceShape.superRefine(issues(imageProblems));\n\n/**\n * This schema accepts a client image (`ClientImage`): a stock image by its name or the source of an\n * image, and its command. It also accepts the capabilities that its container adds.\n */\nexport const clientImageSchema: z.ZodType<ClientImage> = z.strictObject({\n image: z.union([z.string().min(1), imageSourceSchema]),\n command,\n capabilities: z.array(z.enum(CLIENT_CAPABILITIES)).optional(),\n});\n\n/** This schema accepts the client of a cell (`Cell.client`): a client that the harness ships by its name, or a client image. */\nexport const clientSchema: z.ZodType<Client | ClientImage> = z.union([z.enum(CLIENT_NAMES), clientImageSchema]);\n\n/**\n * This schema accepts a subject (`Subject`): its name, the source of its image and its stock base.\n * It also accepts the command of each of its programs.\n */\nexport const subjectSchema: z.ZodType<Subject> = z\n .strictObject({\n name: z.string().min(1),\n image: sourceShape,\n base: z.string(),\n programs: z.record(z.string(), command),\n })\n .superRefine(issues(subjectProblems));\n"],"mappings":";;;;AASA,MAAM,UACA,WACH,OAAU,QAA+B;CACxC,KAAK,MAAM,WAAW,MAAM,KAAK,GAAG,IAAI,SAAS;EAAE,MAAM;EAAU;CAAQ,CAAC;AAC9E;;AAGF,MAAM,UAAU,EAAE,MAAM,EAAE,OAAO,CAAC,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC,IAAI,CAAC;;AAGhD,MAAM,cAAc,EAAE,mBAAmB,QAAQ;CAC/C,EAAE,aAAa;EAAE,MAAM,EAAE,QAAQ,QAAQ;EAAG,OAAO,EAAE,OAAO;CAAE,CAAC;CAC/D,EAAE,aAAa;EACb,MAAM,EAAE,QAAQ,OAAO;EACvB,MAAM,EAAE,OAAO;EACf,SAAS,EAAE,OAAO;EAClB,YAAY,EAAE,OAAO,CAAC,CAAC,SAAS;EAChC,OAAO,EAAE,MAAM,EAAE,OAAO,CAAC,CAAC,CAAC,SAAS;CACtC,CAAC;CACD,EAAE,aAAa;EAAE,MAAM,EAAE,QAAQ,SAAS;EAAG,MAAM,EAAE,KAAK,aAAa;CAAE,CAAC;AAC5E,CAAC;;;;;AAMD,MAAa,oBAA4C,YAAY,YAAY,OAAO,aAAa,CAAC;;;;;AAMtG,MAAa,oBAA4C,EAAE,aAAa;CACtE,OAAO,EAAE,MAAM,CAAC,EAAE,OAAO,CAAC,CAAC,IAAI,CAAC,GAAG,iBAAiB,CAAC;CACrD;CACA,cAAc,EAAE,MAAM,EAAE,KAAK,mBAAmB,CAAC,CAAC,CAAC,SAAS;AAC9D,CAAC;;AAGD,MAAa,eAAgD,EAAE,MAAM,CAAC,EAAE,KAAK,YAAY,GAAG,iBAAiB,CAAC;;;;;AAM9G,MAAa,gBAAoC,EAC9C,aAAa;CACZ,MAAM,EAAE,OAAO,CAAC,CAAC,IAAI,CAAC;CACtB,OAAO;CACP,MAAM,EAAE,OAAO;CACf,UAAU,EAAE,OAAO,EAAE,OAAO,GAAG,OAAO;AACxC,CAAC,CAAC,CACD,YAAY,OAAO,eAAe,CAAC"}
|
package/image/Dockerfile
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
# The harness's own image: the prober, the cell client, the
|
|
2
|
-
# run from it. A target's image stays stock. Both stages are pinned by digest,
|
|
1
|
+
# The harness's own image: the prober, the cell client, the load driver of HTTP, the injector and
|
|
2
|
+
# the namespace admin all run from it. A target's image stays stock. Both stages are pinned by digest,
|
|
3
3
|
# the crates by Cargo.lock, and the packages apk installs are recorded in each run's manifest.
|
|
4
4
|
# lab.ts stages the build context: the prober's and the harness's native sources, and the prober's
|
|
5
5
|
# identity fixture, each under its package's name.
|
|
@@ -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 /src/harness/image/native/target/release/queue /src/harness/image/native/target/release/ports /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 /src/harness/image/native/target/release/ctfull /src/harness/image/native/target/release/load /opt/harness/
|
|
30
30
|
WORKDIR /opt/harness
|
|
31
31
|
CMD ["sleep", "infinity"]
|
|
@@ -1,11 +1,15 @@
|
|
|
1
|
-
// admin-kafka BOOTSTRAP create TOPIC MIN_INSYNC BROKER,BROKER,...
|
|
1
|
+
// admin-kafka BOOTSTRAP create TOPIC MIN_INSYNC BROKER,BROKER,... [PARTITIONS]
|
|
2
2
|
// admin-kafka BOOTSTRAP view [TOPIC]
|
|
3
3
|
// admin-kafka BOOTSTRAP holds TOPIC
|
|
4
|
+
// admin-kafka BOOTSTRAP fill TOPIC PARTITIONS COUNT
|
|
4
5
|
//
|
|
5
|
-
// The admin of a Kafka lab, on the Admin client and the
|
|
6
|
-
// prints one JSON line. An error ends the command with exit code 1, and the lab then
|
|
7
|
-
//
|
|
8
|
-
//
|
|
6
|
+
// The admin of a Kafka lab, on the Admin client, the consumer and the producer of kafka-clients 4.3.2.
|
|
7
|
+
// Each command prints one JSON line. An error ends the command with exit code 1, and the lab then
|
|
8
|
+
// reads the error.
|
|
9
|
+
// - create: a topic with one partition or more, its replicas on the brokers in sequence, and its
|
|
10
|
+
// setting of min.insync.replicas. The first broker is the first leader of partition 0. Each next
|
|
11
|
+
// partition starts its replicas one broker later.
|
|
12
|
+
// - fill: COUNT records in each partition, each acknowledged by each in-sync replica.
|
|
9
13
|
// - view: the ids of the brokers that are not fenced. With a topic, also the leader, the in-sync
|
|
10
14
|
// replicas and the replicas of its partition. The controller lists only brokers that are not fenced.
|
|
11
15
|
// - holds: the value of each record of the partition from its first offset to its high watermark,
|
|
@@ -13,9 +17,12 @@
|
|
|
13
17
|
|
|
14
18
|
import java.time.Duration;
|
|
15
19
|
import java.util.ArrayList;
|
|
20
|
+
import java.util.Collections;
|
|
21
|
+
import java.util.HashMap;
|
|
16
22
|
import java.util.List;
|
|
17
23
|
import java.util.Map;
|
|
18
24
|
import java.util.Properties;
|
|
25
|
+
import java.util.concurrent.Future;
|
|
19
26
|
import org.apache.kafka.clients.admin.Admin;
|
|
20
27
|
import org.apache.kafka.clients.admin.AdminClientConfig;
|
|
21
28
|
import org.apache.kafka.clients.admin.NewTopic;
|
|
@@ -23,10 +30,15 @@ import org.apache.kafka.clients.admin.TopicDescription;
|
|
|
23
30
|
import org.apache.kafka.clients.consumer.ConsumerConfig;
|
|
24
31
|
import org.apache.kafka.clients.consumer.ConsumerRecord;
|
|
25
32
|
import org.apache.kafka.clients.consumer.KafkaConsumer;
|
|
33
|
+
import org.apache.kafka.clients.producer.KafkaProducer;
|
|
34
|
+
import org.apache.kafka.clients.producer.ProducerConfig;
|
|
35
|
+
import org.apache.kafka.clients.producer.ProducerRecord;
|
|
36
|
+
import org.apache.kafka.clients.producer.RecordMetadata;
|
|
26
37
|
import org.apache.kafka.common.Node;
|
|
27
38
|
import org.apache.kafka.common.TopicPartition;
|
|
28
39
|
import org.apache.kafka.common.TopicPartitionInfo;
|
|
29
40
|
import org.apache.kafka.common.serialization.StringDeserializer;
|
|
41
|
+
import org.apache.kafka.common.serialization.StringSerializer;
|
|
30
42
|
|
|
31
43
|
public final class AdminKafka {
|
|
32
44
|
/** This time bounds each call of the admin, in ms. It has no source: a lab repeats a view that fails. */
|
|
@@ -50,16 +62,38 @@ public final class AdminKafka {
|
|
|
50
62
|
return "[" + String.join(",", ids) + "]";
|
|
51
63
|
}
|
|
52
64
|
|
|
53
|
-
private static void create(String bootstrap, String topic, String minInsync, String brokers) throws Exception {
|
|
65
|
+
private static void create(String bootstrap, String topic, String minInsync, String brokers, int partitions) throws Exception {
|
|
54
66
|
List<Integer> replicas = new ArrayList<>();
|
|
55
67
|
for (String id : brokers.split(",")) replicas.add(Integer.valueOf(id));
|
|
68
|
+
Map<Integer, List<Integer>> assignments = new HashMap<>();
|
|
69
|
+
for (int partition = 0; partition < partitions; partition += 1) {
|
|
70
|
+
assignments.put(partition, new ArrayList<>(replicas));
|
|
71
|
+
Collections.rotate(replicas, -1);
|
|
72
|
+
}
|
|
56
73
|
try (Admin admin = adminOf(bootstrap)) {
|
|
57
|
-
NewTopic created = new NewTopic(topic,
|
|
74
|
+
NewTopic created = new NewTopic(topic, assignments).configs(Map.of("min.insync.replicas", minInsync));
|
|
58
75
|
admin.createTopics(List.of(created)).all().get();
|
|
59
76
|
}
|
|
60
77
|
System.out.println("{\"ok\":true}");
|
|
61
78
|
}
|
|
62
79
|
|
|
80
|
+
private static void fill(String bootstrap, String topic, int partitions, int count) throws Exception {
|
|
81
|
+
Properties properties = new Properties();
|
|
82
|
+
properties.put(ProducerConfig.BOOTSTRAP_SERVERS_CONFIG, bootstrap);
|
|
83
|
+
properties.put(ProducerConfig.KEY_SERIALIZER_CLASS_CONFIG, StringSerializer.class.getName());
|
|
84
|
+
properties.put(ProducerConfig.VALUE_SERIALIZER_CLASS_CONFIG, StringSerializer.class.getName());
|
|
85
|
+
properties.put(ProducerConfig.ACKS_CONFIG, "all");
|
|
86
|
+
try (KafkaProducer<String, String> producer = new KafkaProducer<>(properties)) {
|
|
87
|
+
List<Future<RecordMetadata>> sent = new ArrayList<>();
|
|
88
|
+
for (int partition = 0; partition < partitions; partition += 1) {
|
|
89
|
+
for (int at = 0; at < count; at += 1) sent.add(producer.send(new ProducerRecord<>(topic, partition, null, String.valueOf(at))));
|
|
90
|
+
}
|
|
91
|
+
producer.flush();
|
|
92
|
+
for (Future<RecordMetadata> each : sent) each.get();
|
|
93
|
+
}
|
|
94
|
+
System.out.println("{\"ok\":true}");
|
|
95
|
+
}
|
|
96
|
+
|
|
63
97
|
private static void view(String bootstrap, String topic) throws Exception {
|
|
64
98
|
try (Admin admin = adminOf(bootstrap)) {
|
|
65
99
|
String brokers = idsOf(new ArrayList<>(admin.describeCluster().nodes().get()));
|
|
@@ -100,13 +134,14 @@ public final class AdminKafka {
|
|
|
100
134
|
|
|
101
135
|
public static void main(String[] args) throws Exception {
|
|
102
136
|
if (args.length < 2) {
|
|
103
|
-
System.err.println("admin-kafka BOOTSTRAP create|view|holds ...");
|
|
137
|
+
System.err.println("admin-kafka BOOTSTRAP create|view|holds|fill ...");
|
|
104
138
|
System.exit(1);
|
|
105
139
|
}
|
|
106
140
|
switch (args[1]) {
|
|
107
|
-
case "create" -> create(args[0], args[2], args[3], args[4]);
|
|
141
|
+
case "create" -> create(args[0], args[2], args[3], args[4], args.length > 5 ? Integer.parseInt(args[5]) : 1);
|
|
108
142
|
case "view" -> view(args[0], args.length > 2 ? args[2] : null);
|
|
109
143
|
case "holds" -> holds(args[0], args[2]);
|
|
144
|
+
case "fill" -> fill(args[0], args[2], Integer.parseInt(args[3]), Integer.parseInt(args[4]));
|
|
110
145
|
default -> {
|
|
111
146
|
System.err.println("no command " + args[1]);
|
|
112
147
|
System.exit(1);
|
|
@@ -0,0 +1,359 @@
|
|
|
1
|
+
// consumer-kafka BOOTSTRAP --topic NAME --group NAME --protocol consumer|classic --process-ms N
|
|
2
|
+
// --after-pause commit|rejoin [--max-s N] [--set name=value]...
|
|
3
|
+
//
|
|
4
|
+
// One member of a group of a Kafka cell: the consumer of kafka-clients 4.3.2, on Java 21. Each event
|
|
5
|
+
// is a JSON line with `ev` and `t`, the time on CLOCK_MONOTONIC in nanoseconds (System.nanoTime, which
|
|
6
|
+
// the JVM reads from it on Linux). The injector stamps each fault on the same clock.
|
|
7
|
+
//
|
|
8
|
+
// The member polls, holds the records for --process-ms, and commits the next offset of each partition
|
|
9
|
+
// with an explicit map. A commitSync with no map uses the positions of the partitions that the
|
|
10
|
+
// consumer holds. After a fence it holds none, thus that commit succeeds and tells nothing.
|
|
11
|
+
//
|
|
12
|
+
// The member sees a stop of its process as a gap in its own clock. It then does one of two things:
|
|
13
|
+
// - commit: it commits the map of the batch at once, before any poll.
|
|
14
|
+
// - rejoin: it polls until its epoch changes, and it then commits the same map. It then reads the
|
|
15
|
+
// committed offsets back.
|
|
16
|
+
//
|
|
17
|
+
// The lines:
|
|
18
|
+
// - start: the versions, the group, the settings, and `wall`, the clock of the wall next to `t`.
|
|
19
|
+
// The broker logs on the clock of the wall, thus this pair converts a log line.
|
|
20
|
+
// - assigned, revoked, lost: a call of the rebalance listener: `partitions`, `held` (each partition
|
|
21
|
+
// after the call), `epoch` and `member`.
|
|
22
|
+
// - read: the records of one partition in one poll: `partition`, `first`, `last` and `count`.
|
|
23
|
+
// - processing: the member holds a batch from this instant.
|
|
24
|
+
// - pause: a gap in the clock of the member: `gap` in nanoseconds, and `in`, the part of its loop.
|
|
25
|
+
// - rejoined: the epoch changed after a pause: `before` and `epoch`.
|
|
26
|
+
// - rejoin-timeout: the epoch did not change in REJOIN_MS.
|
|
27
|
+
// - commit: the broker acknowledged a commit: `offsets` and `epoch`.
|
|
28
|
+
// - commit-fail: the error of a commit: `name`, `message`, `offsets` and `epoch`.
|
|
29
|
+
// - committed: the committed offsets that the member reads back: `offsets`.
|
|
30
|
+
// - poll-fail, read-fail: the error of a poll or of the read back.
|
|
31
|
+
// - done, deadline: the end of the member.
|
|
32
|
+
//
|
|
33
|
+
// A member with a rebalance listener runs the callback of a lost partition in its next poll. Thus a
|
|
34
|
+
// member with no poll stays fenced (ConsumerMembershipManager.java:367-374). The listener is always
|
|
35
|
+
// present, as in an application that keeps state for each partition.
|
|
36
|
+
|
|
37
|
+
import java.io.FileDescriptor;
|
|
38
|
+
import java.io.FileOutputStream;
|
|
39
|
+
import java.io.PrintStream;
|
|
40
|
+
import java.nio.charset.StandardCharsets;
|
|
41
|
+
import java.time.Duration;
|
|
42
|
+
import java.util.ArrayList;
|
|
43
|
+
import java.util.Collection;
|
|
44
|
+
import java.util.LinkedHashMap;
|
|
45
|
+
import java.util.List;
|
|
46
|
+
import java.util.Map;
|
|
47
|
+
import java.util.Properties;
|
|
48
|
+
import java.util.Set;
|
|
49
|
+
import java.util.TreeSet;
|
|
50
|
+
import org.apache.kafka.clients.consumer.ConsumerConfig;
|
|
51
|
+
import org.apache.kafka.clients.consumer.ConsumerRebalanceListener;
|
|
52
|
+
import org.apache.kafka.clients.consumer.ConsumerRecord;
|
|
53
|
+
import org.apache.kafka.clients.consumer.ConsumerRecords;
|
|
54
|
+
import org.apache.kafka.clients.consumer.KafkaConsumer;
|
|
55
|
+
import org.apache.kafka.clients.consumer.OffsetAndMetadata;
|
|
56
|
+
import org.apache.kafka.common.TopicPartition;
|
|
57
|
+
import org.apache.kafka.common.serialization.StringDeserializer;
|
|
58
|
+
import org.apache.kafka.common.utils.AppInfoParser;
|
|
59
|
+
|
|
60
|
+
public final class ConsumerKafka {
|
|
61
|
+
private static final PrintStream OUT = new PrintStream(new FileOutputStream(FileDescriptor.out), false, StandardCharsets.UTF_8);
|
|
62
|
+
private static final Object LOCK = new Object();
|
|
63
|
+
/** This time is the gap in the clock of the member that tells a stop, in ns. It has no source: it is far above a tick of the loop. */
|
|
64
|
+
private static final long GAP_NS = 1_000_000_000L;
|
|
65
|
+
/** This time is the tick of the processing, in ms. It has no source. */
|
|
66
|
+
private static final long TICK_MS = 10;
|
|
67
|
+
/** This time is the poll of the loop, in ms. It has no source. */
|
|
68
|
+
private static final long POLL_MS = 100;
|
|
69
|
+
/** This time is the poll of the wait for a new epoch, in ms. It has no source. */
|
|
70
|
+
private static final long REJOIN_POLL_MS = 20;
|
|
71
|
+
/** This time bounds the wait for a new epoch, in ms. It has no source: it is far above one heartbeat. */
|
|
72
|
+
private static final long REJOIN_MS = 20_000;
|
|
73
|
+
/** This time bounds a commit and a read back, in s. It has no source: it is far above a round trip of the group. */
|
|
74
|
+
private static final long CALL_S = 20;
|
|
75
|
+
|
|
76
|
+
private static final Set<Integer> HELD = new TreeSet<>();
|
|
77
|
+
private static volatile boolean stopped = false;
|
|
78
|
+
|
|
79
|
+
private ConsumerKafka() {}
|
|
80
|
+
|
|
81
|
+
private static long now() {
|
|
82
|
+
return System.nanoTime();
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** This function writes one line: its event, its stamp, and the JSON fields after them. */
|
|
86
|
+
private static void emit(String ev, long t, String fields) {
|
|
87
|
+
String line = "{\"ev\":" + q(ev) + ",\"t\":" + t + (fields.isEmpty() ? "" : "," + fields) + "}\n";
|
|
88
|
+
synchronized (LOCK) {
|
|
89
|
+
OUT.print(line);
|
|
90
|
+
OUT.flush();
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
private static void emit(String ev, String fields) {
|
|
95
|
+
emit(ev, now(), fields);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** This function gives a text as a JSON string, or null. */
|
|
99
|
+
private static String q(String text) {
|
|
100
|
+
if (text == null) return "null";
|
|
101
|
+
StringBuilder out = new StringBuilder("\"");
|
|
102
|
+
for (char c : text.toCharArray()) {
|
|
103
|
+
switch (c) {
|
|
104
|
+
case '"' -> out.append("\\\"");
|
|
105
|
+
case '\\' -> out.append("\\\\");
|
|
106
|
+
case '\n' -> out.append("\\n");
|
|
107
|
+
case '\r' -> out.append("\\r");
|
|
108
|
+
case '\t' -> out.append("\\t");
|
|
109
|
+
default -> {
|
|
110
|
+
if (c < 0x20) out.append(String.format("\\u%04x", (int) c));
|
|
111
|
+
else out.append(c);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
return out.append('"').toString();
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
private static String flag(List<String> argv, String name, String otherwise) {
|
|
119
|
+
int at = argv.indexOf(name);
|
|
120
|
+
return at == -1 || at + 1 >= argv.size() ? otherwise : argv.get(at + 1);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** This function gives each value of a flag that repeats, in sequence. */
|
|
124
|
+
private static List<String> flags(List<String> argv, String name) {
|
|
125
|
+
List<String> out = new ArrayList<>();
|
|
126
|
+
for (int at = 0; at + 1 < argv.size(); at += 1) if (argv.get(at).equals(name)) out.add(argv.get(at + 1));
|
|
127
|
+
return out;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
private static String numbers(Collection<Integer> values) {
|
|
131
|
+
List<String> out = new ArrayList<>();
|
|
132
|
+
for (int value : values) out.add(String.valueOf(value));
|
|
133
|
+
return "[" + String.join(",", out) + "]";
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** This function gives the offsets of a map as a JSON object, by partition. */
|
|
137
|
+
private static String offsetsOf(Map<TopicPartition, OffsetAndMetadata> offsets) {
|
|
138
|
+
List<String> out = new ArrayList<>();
|
|
139
|
+
for (Map.Entry<TopicPartition, OffsetAndMetadata> each : offsets.entrySet()) {
|
|
140
|
+
out.add(q(String.valueOf(each.getKey().partition())) + ":" + (each.getValue() == null ? "null" : String.valueOf(each.getValue().offset())));
|
|
141
|
+
}
|
|
142
|
+
return "{" + String.join(",", out) + "}";
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
private static String failure(Throwable error) {
|
|
146
|
+
return "\"name\":" + q(error.getClass().getSimpleName()) + ",\"message\":" + q(error.getMessage());
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
private static final class Member {
|
|
150
|
+
private final KafkaConsumer<String, String> consumer;
|
|
151
|
+
private final String topic;
|
|
152
|
+
private final long processMs;
|
|
153
|
+
private final boolean rejoin;
|
|
154
|
+
private boolean rejoined = false;
|
|
155
|
+
|
|
156
|
+
Member(KafkaConsumer<String, String> consumer, String topic, long processMs, boolean rejoin) {
|
|
157
|
+
this.consumer = consumer;
|
|
158
|
+
this.topic = topic;
|
|
159
|
+
this.processMs = processMs;
|
|
160
|
+
this.rejoin = rejoin;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
private int epoch() {
|
|
164
|
+
return consumer.groupMetadata().generationId();
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/** This function writes the line of one call of the rebalance listener. */
|
|
168
|
+
private void change(String ev, Collection<TopicPartition> partitions, boolean add) {
|
|
169
|
+
List<Integer> named = new ArrayList<>();
|
|
170
|
+
for (TopicPartition each : partitions) {
|
|
171
|
+
named.add(each.partition());
|
|
172
|
+
if (add) HELD.add(each.partition());
|
|
173
|
+
else HELD.remove(each.partition());
|
|
174
|
+
}
|
|
175
|
+
emit(ev, "\"partitions\":" + numbers(named) + ",\"held\":" + numbers(HELD) + ",\"epoch\":" + epoch()
|
|
176
|
+
+ ",\"member\":" + q(consumer.groupMetadata().memberId()));
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
void subscribe() {
|
|
180
|
+
consumer.subscribe(List.of(topic), new ConsumerRebalanceListener() {
|
|
181
|
+
@Override
|
|
182
|
+
public void onPartitionsRevoked(Collection<TopicPartition> partitions) {
|
|
183
|
+
change("revoked", partitions, false);
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
@Override
|
|
187
|
+
public void onPartitionsAssigned(Collection<TopicPartition> partitions) {
|
|
188
|
+
change("assigned", partitions, true);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
@Override
|
|
192
|
+
public void onPartitionsLost(Collection<TopicPartition> partitions) {
|
|
193
|
+
change("lost", partitions, false);
|
|
194
|
+
}
|
|
195
|
+
});
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/** This function gives the records of one poll, with a line for each partition. A failure of the poll gives no records. */
|
|
199
|
+
private ConsumerRecords<String, String> poll(long ms) {
|
|
200
|
+
try {
|
|
201
|
+
ConsumerRecords<String, String> records = consumer.poll(Duration.ofMillis(ms));
|
|
202
|
+
for (TopicPartition partition : records.partitions()) {
|
|
203
|
+
List<ConsumerRecord<String, String>> each = records.records(partition);
|
|
204
|
+
emit("read", "\"partition\":" + partition.partition() + ",\"first\":" + each.get(0).offset()
|
|
205
|
+
+ ",\"last\":" + each.get(each.size() - 1).offset() + ",\"count\":" + each.size());
|
|
206
|
+
}
|
|
207
|
+
return records;
|
|
208
|
+
} catch (RuntimeException error) {
|
|
209
|
+
emit("poll-fail", failure(error));
|
|
210
|
+
return ConsumerRecords.empty();
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/** This function holds a batch for the time of the processing, and it tells if a gap came in that time. */
|
|
215
|
+
private boolean process() throws InterruptedException {
|
|
216
|
+
emit("processing", "");
|
|
217
|
+
long begun = now();
|
|
218
|
+
long last = begun;
|
|
219
|
+
long end = begun + processMs * 1_000_000L;
|
|
220
|
+
while (last < end) {
|
|
221
|
+
Thread.sleep(TICK_MS);
|
|
222
|
+
long tick = now();
|
|
223
|
+
if (tick - last > GAP_NS) {
|
|
224
|
+
emit("pause", tick, "\"gap\":" + (tick - last) + ",\"in\":\"process\"");
|
|
225
|
+
return true;
|
|
226
|
+
}
|
|
227
|
+
last = tick;
|
|
228
|
+
}
|
|
229
|
+
return false;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
private void commit(Map<TopicPartition, OffsetAndMetadata> offsets) {
|
|
233
|
+
try {
|
|
234
|
+
consumer.commitSync(offsets, Duration.ofSeconds(CALL_S));
|
|
235
|
+
emit("commit", "\"offsets\":" + offsetsOf(offsets) + ",\"epoch\":" + epoch());
|
|
236
|
+
} catch (RuntimeException error) {
|
|
237
|
+
emit("commit-fail", failure(error) + ",\"offsets\":" + offsetsOf(offsets) + ",\"epoch\":" + epoch());
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/** This function polls until the epoch changes, commits the old map, and reads the committed offsets back. */
|
|
242
|
+
private void rejoinThenCommit(Map<TopicPartition, OffsetAndMetadata> offsets) {
|
|
243
|
+
int before = epoch();
|
|
244
|
+
long begun = System.currentTimeMillis();
|
|
245
|
+
while (epoch() == before) {
|
|
246
|
+
if (System.currentTimeMillis() - begun > REJOIN_MS) {
|
|
247
|
+
emit("rejoin-timeout", "\"before\":" + before);
|
|
248
|
+
return;
|
|
249
|
+
}
|
|
250
|
+
poll(REJOIN_POLL_MS);
|
|
251
|
+
}
|
|
252
|
+
emit("rejoined", "\"before\":" + before + ",\"epoch\":" + epoch());
|
|
253
|
+
commit(offsets);
|
|
254
|
+
try {
|
|
255
|
+
emit("committed", "\"offsets\":" + offsetsOf(consumer.committed(offsets.keySet(), Duration.ofSeconds(CALL_S))));
|
|
256
|
+
} catch (RuntimeException error) {
|
|
257
|
+
emit("read-fail", failure(error));
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
void run() throws InterruptedException {
|
|
262
|
+
long before = now();
|
|
263
|
+
while (!stopped) {
|
|
264
|
+
ConsumerRecords<String, String> records = poll(POLL_MS);
|
|
265
|
+
long after = now();
|
|
266
|
+
if (after - before > POLL_MS * 1_000_000L + GAP_NS) emit("pause", after, "\"gap\":" + (after - before) + ",\"in\":\"poll\"");
|
|
267
|
+
if (records.isEmpty()) {
|
|
268
|
+
before = now();
|
|
269
|
+
continue;
|
|
270
|
+
}
|
|
271
|
+
Map<TopicPartition, OffsetAndMetadata> offsets = new LinkedHashMap<>();
|
|
272
|
+
for (TopicPartition partition : records.partitions()) {
|
|
273
|
+
List<ConsumerRecord<String, String>> each = records.records(partition);
|
|
274
|
+
offsets.put(partition, new OffsetAndMetadata(each.get(each.size() - 1).offset() + 1));
|
|
275
|
+
}
|
|
276
|
+
boolean paused = process();
|
|
277
|
+
if (paused && rejoin && !rejoined) {
|
|
278
|
+
rejoined = true;
|
|
279
|
+
rejoinThenCommit(offsets);
|
|
280
|
+
} else {
|
|
281
|
+
commit(offsets);
|
|
282
|
+
}
|
|
283
|
+
before = now();
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
public static void main(String[] args) throws Exception {
|
|
289
|
+
List<String> argv = List.of(args);
|
|
290
|
+
String topic = flag(argv, "--topic", null);
|
|
291
|
+
String group = flag(argv, "--group", null);
|
|
292
|
+
if (args.length < 1 || topic == null || group == null) {
|
|
293
|
+
emit("fail", "\"why\":\"consumer-kafka BOOTSTRAP --topic NAME --group NAME\"");
|
|
294
|
+
Runtime.getRuntime().halt(1);
|
|
295
|
+
}
|
|
296
|
+
String protocol = flag(argv, "--protocol", "consumer");
|
|
297
|
+
long processMs = Long.parseLong(flag(argv, "--process-ms", "0"));
|
|
298
|
+
String afterPause = flag(argv, "--after-pause", "commit");
|
|
299
|
+
long max = Long.parseLong(flag(argv, "--max-s", "300")) * 1000;
|
|
300
|
+
|
|
301
|
+
Map<String, String> settings = new LinkedHashMap<>();
|
|
302
|
+
for (String each : flags(argv, "--set")) {
|
|
303
|
+
int at = each.indexOf('=');
|
|
304
|
+
if (at == -1) throw new IllegalArgumentException("a setting is name=value: " + each);
|
|
305
|
+
settings.put(each.substring(0, at), each.substring(at + 1));
|
|
306
|
+
}
|
|
307
|
+
StringBuilder given = new StringBuilder("{");
|
|
308
|
+
for (Map.Entry<String, String> each : settings.entrySet()) {
|
|
309
|
+
if (given.length() > 1) given.append(',');
|
|
310
|
+
given.append(q(each.getKey())).append(':').append(q(each.getValue()));
|
|
311
|
+
}
|
|
312
|
+
given.append('}');
|
|
313
|
+
long t = now();
|
|
314
|
+
long wall = System.currentTimeMillis();
|
|
315
|
+
emit("start", t, "\"wall\":" + wall
|
|
316
|
+
+ ",\"client\":\"kafka-consumer\",\"driver\":" + q(AppInfoParser.getVersion())
|
|
317
|
+
+ ",\"java\":" + q(System.getProperty("java.version"))
|
|
318
|
+
+ ",\"topic\":" + q(topic) + ",\"group\":" + q(group) + ",\"protocol\":" + q(protocol)
|
|
319
|
+
+ ",\"process_ms\":" + processMs + ",\"after_pause\":" + q(afterPause) + ",\"settings\":" + given);
|
|
320
|
+
|
|
321
|
+
Properties properties = new Properties();
|
|
322
|
+
properties.put(ConsumerConfig.BOOTSTRAP_SERVERS_CONFIG, args[0]);
|
|
323
|
+
properties.put(ConsumerConfig.GROUP_ID_CONFIG, group);
|
|
324
|
+
properties.put(ConsumerConfig.GROUP_PROTOCOL_CONFIG, protocol);
|
|
325
|
+
properties.put(ConsumerConfig.KEY_DESERIALIZER_CLASS_CONFIG, StringDeserializer.class.getName());
|
|
326
|
+
properties.put(ConsumerConfig.VALUE_DESERIALIZER_CLASS_CONFIG, StringDeserializer.class.getName());
|
|
327
|
+
properties.put(ConsumerConfig.ENABLE_AUTO_COMMIT_CONFIG, "false");
|
|
328
|
+
properties.put(ConsumerConfig.AUTO_OFFSET_RESET_CONFIG, "earliest");
|
|
329
|
+
for (Map.Entry<String, String> each : settings.entrySet()) properties.put(each.getKey(), each.getValue());
|
|
330
|
+
|
|
331
|
+
Runtime.getRuntime().addShutdownHook(new Thread(() -> {
|
|
332
|
+
// SIGTERM: the end of the member, with exit code 0, as the other clients give
|
|
333
|
+
synchronized (LOCK) {
|
|
334
|
+
if (stopped) return;
|
|
335
|
+
stopped = true;
|
|
336
|
+
}
|
|
337
|
+
emit("done", "");
|
|
338
|
+
Runtime.getRuntime().halt(0);
|
|
339
|
+
}, "sigterm"));
|
|
340
|
+
Thread deadline = new Thread(() -> {
|
|
341
|
+
try {
|
|
342
|
+
Thread.sleep(max);
|
|
343
|
+
} catch (InterruptedException stop) {
|
|
344
|
+
return;
|
|
345
|
+
}
|
|
346
|
+
emit("deadline", "");
|
|
347
|
+
stopped = true;
|
|
348
|
+
emit("done", "");
|
|
349
|
+
Runtime.getRuntime().halt(0);
|
|
350
|
+
}, "deadline");
|
|
351
|
+
deadline.setDaemon(true);
|
|
352
|
+
deadline.start();
|
|
353
|
+
|
|
354
|
+
KafkaConsumer<String, String> consumer = new KafkaConsumer<>(properties);
|
|
355
|
+
Member member = new Member(consumer, topic, processMs, afterPause.equals("rejoin"));
|
|
356
|
+
member.subscribe();
|
|
357
|
+
member.run();
|
|
358
|
+
}
|
|
359
|
+
}
|
package/image/kafka/Dockerfile
CHANGED
|
@@ -14,8 +14,8 @@ RUN wget -q https://repo1.maven.org/maven2/org/apache/kafka/kafka-clients/4.3.2/
|
|
|
14
14
|
&& wget -q https://repo1.maven.org/maven2/at/yawk/lz4/lz4-java/1.11.4/lz4-java-1.11.4.jar \
|
|
15
15
|
&& wget -q https://repo1.maven.org/maven2/org/xerial/snappy/snappy-java/1.1.10.11/snappy-java-1.1.10.11.jar \
|
|
16
16
|
&& sha256sum -c jars.sha256
|
|
17
|
-
COPY harness/image/kafka/ClientKafka.java harness/image/kafka/AdminKafka.java ./
|
|
18
|
-
RUN javac --release 21 -cp "kafka-clients-4.3.2.jar:slf4j-api-1.7.36.jar" -d classes ClientKafka.java AdminKafka.java
|
|
17
|
+
COPY harness/image/kafka/ClientKafka.java harness/image/kafka/AdminKafka.java harness/image/kafka/ConsumerKafka.java ./
|
|
18
|
+
RUN javac --release 21 -cp "kafka-clients-4.3.2.jar:slf4j-api-1.7.36.jar" -d classes ClientKafka.java AdminKafka.java ConsumerKafka.java
|
|
19
19
|
|
|
20
20
|
FROM eclipse-temurin:21.0.12.1_1-jre-alpine@sha256:51ab5e3302e7141ce665ca3ea85e8b5cd648eafbc3c0c90dd79d6537684e4555
|
|
21
21
|
WORKDIR /opt/harness/kafka
|
package/image/native/Cargo.toml
CHANGED
|
@@ -3,7 +3,7 @@ name = "harness-native"
|
|
|
3
3
|
version = "0.1.0"
|
|
4
4
|
edition = "2021"
|
|
5
5
|
publish = false
|
|
6
|
-
description = "The harness's native side: the cell client and the
|
|
6
|
+
description = "The harness's native side: the cell client, the injector and the HTTP load driver"
|
|
7
7
|
|
|
8
8
|
[dependencies]
|
|
9
9
|
# the clock, the quantities and the errno names, without the collector
|