@mark1russell7/harness 0.14.4 → 0.15.1

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 { E as ImageSource, _t as Subject, i as ClientImage, r as Client } from "./cell-DZm8zXN4.js";
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
- /** This schema accepts a client image (`ClientImage`): a stock image by its name or the source of an image, and its command. */
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-EZ8ioZ3L.js";
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
- /** This schema accepts a client image (`ClientImage`): a stock image by its name or the source of an image, and its command. */
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]);
@@ -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/** This schema accepts a client image (`ClientImage`): a stock image by its name or the source of an image, and its command. */\nexport const clientImageSchema: z.ZodType<ClientImage> = z.strictObject({\n image: z.union([z.string().min(1), imageSourceSchema]),\n command,\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":";;;;AAQA,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;;AAGtG,MAAa,oBAA4C,EAAE,aAAa;CACtE,OAAO,EAAE,MAAM,CAAC,EAAE,OAAO,CAAC,CAAC,IAAI,CAAC,GAAG,iBAAiB,CAAC;CACrD;AACF,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"}
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 injector and the namespace admin all
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 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.
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, Map.of(0, replicas)).configs(Map.of("min.insync.replicas", minInsync));
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
+ }
@@ -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
@@ -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 injector"
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