Skip to content

Commit 15bb463

Browse files
committed
Enforce deterministic field order in Schema generated from KafkaIO classes.
To prevent potential serialization issues, this change adds the `@SchemaFieldNumber` annotation to KafkaIO classes that use `DefaultSchema`. This guarantees a deterministic field order, which is not ensured when schemas are generated with `AutoValueSchema`, `JavaBeanSchema`, or `JavaFieldSchema`.
1 parent 9cd994e commit 15bb463

8 files changed

Lines changed: 267 additions & 3 deletions

File tree

sdks/java/core/src/main/java/org/apache/beam/sdk/schemas/transforms/providers/ErrorHandling.java

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,11 +22,13 @@
2222
import javax.annotation.Nullable;
2323
import org.apache.beam.sdk.schemas.Schema;
2424
import org.apache.beam.sdk.schemas.annotations.SchemaFieldDescription;
25+
import org.apache.beam.sdk.schemas.annotations.SchemaFieldNumber;
2526
import org.apache.beam.sdk.values.Row;
2627

2728
@AutoValue
2829
public abstract class ErrorHandling implements Serializable {
2930
@SchemaFieldDescription("The name of the output PCollection containing failed writes.")
31+
@SchemaFieldNumber("0")
3032
public abstract String getOutput();
3133

3234
public static Builder builder() {

sdks/java/io/kafka/src/main/java/org/apache/beam/sdk/io/kafka/KafkaIO.java

Lines changed: 23 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,7 @@
7070
import org.apache.beam.sdk.schemas.SchemaRegistry;
7171
import org.apache.beam.sdk.schemas.annotations.DefaultSchema;
7272
import org.apache.beam.sdk.schemas.annotations.SchemaCreate;
73+
import org.apache.beam.sdk.schemas.annotations.SchemaFieldNumber;
7374
import org.apache.beam.sdk.schemas.transforms.Convert;
7475
import org.apache.beam.sdk.transforms.Create;
7576
import org.apache.beam.sdk.transforms.DoFn;
@@ -2202,8 +2203,10 @@ public void populateDisplayData(DisplayData.Builder builder) {
22022203
* generating Rows.
22032204
*/
22042205
static class KafkaHeader {
2205-
2206+
@SchemaFieldNumber("0")
22062207
String key;
2208+
2209+
@SchemaFieldNumber("1")
22072210
byte @Nullable [] value;
22082211

22092212
@SchemaCreate
@@ -2222,15 +2225,32 @@ public KafkaHeader(String key, byte @Nullable [] value) {
22222225
* Schema inference supports generics.
22232226
*/
22242227
static class ByteArrayKafkaRecord {
2225-
2228+
@SchemaFieldNumber("0")
22262229
String topic;
2230+
2231+
@SchemaFieldNumber("1")
22272232
int partition;
2233+
2234+
@SchemaFieldNumber("2")
22282235
long offset;
2236+
2237+
@SchemaFieldNumber("3")
22292238
long timestamp;
2239+
2240+
@SchemaFieldNumber("4")
22302241
byte @Nullable [] key;
2242+
2243+
@SchemaFieldNumber("5")
22312244
byte @Nullable [] value;
2232-
@Nullable List<KafkaHeader> headers;
2245+
2246+
@SchemaFieldNumber("6")
2247+
@Nullable
2248+
List<KafkaHeader> headers;
2249+
2250+
@SchemaFieldNumber("7")
22332251
int timestampTypeId;
2252+
2253+
@SchemaFieldNumber("8")
22342254
String timestampTypeName;
22352255

22362256
@SchemaCreate

sdks/java/io/kafka/src/main/java/org/apache/beam/sdk/io/kafka/KafkaReadSchemaTransformConfiguration.java

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@
2727
import org.apache.beam.sdk.schemas.AutoValueSchema;
2828
import org.apache.beam.sdk.schemas.annotations.DefaultSchema;
2929
import org.apache.beam.sdk.schemas.annotations.SchemaFieldDescription;
30+
import org.apache.beam.sdk.schemas.annotations.SchemaFieldNumber;
3031
import org.apache.beam.sdk.schemas.transforms.providers.ErrorHandling;
3132
import org.apache.beam.vendor.guava.v32_1_2_jre.com.google.common.collect.Sets;
3233

@@ -98,16 +99,20 @@ public static Builder builder() {
9899
+ " Kafka cluster. The client will make use of all servers irrespective of which servers are specified"
99100
+ " here for bootstrapping—this list only impacts the initial hosts used to discover the full set"
100101
+ " of servers. This list should be in the form `host1:port1,host2:port2,...`")
102+
@SchemaFieldNumber("0")
101103
public abstract String getBootstrapServers();
102104

105+
@SchemaFieldNumber("1")
103106
@Nullable
104107
public abstract String getConfluentSchemaRegistryUrl();
105108

106109
@SchemaFieldDescription(
107110
"The encoding format for the data stored in Kafka. Valid options are: " + VALID_FORMATS_STR)
111+
@SchemaFieldNumber("2")
108112
@Nullable
109113
public abstract String getFormat();
110114

115+
@SchemaFieldNumber("3")
111116
@Nullable
112117
public abstract String getConfluentSchemaRegistrySubject();
113118

@@ -118,18 +123,21 @@ public static Builder builder() {
118123
+ "For JSON data, this is a schema defined with JSON-schema syntax (https://json-schema.org/). "
119124
+ "If a URL to Confluent Schema Registry is provided, then this field is ignored, and the schema "
120125
+ "is fetched from Confluent Schema Registry.")
126+
@SchemaFieldNumber("4")
121127
@Nullable
122128
public abstract String getSchema();
123129

124130
@SchemaFieldDescription(
125131
"The path to the Protocol Buffer File Descriptor Set file. This file is used for schema"
126132
+ " definition and message serialization.")
133+
@SchemaFieldNumber("5")
127134
@Nullable
128135
public abstract String getFileDescriptorPath();
129136

130137
@SchemaFieldDescription(
131138
"The name of the Protocol Buffer message to be used for schema"
132139
+ " extraction and data conversion.")
140+
@SchemaFieldNumber("6")
133141
@Nullable
134142
public abstract String getMessageName();
135143

@@ -138,6 +146,7 @@ public static Builder builder() {
138146
+ " does not exist any more on the server. (1) earliest: automatically reset the offset to the earliest"
139147
+ " offset. (2) latest: automatically reset the offset to the latest offset"
140148
+ " (3) none: throw exception to the consumer if no previous offset is found for the consumer’s group")
149+
@SchemaFieldNumber("7")
141150
@Nullable
142151
public abstract String getAutoOffsetResetConfig();
143152

@@ -146,17 +155,21 @@ public static Builder builder() {
146155
+ " Most of these configurations will not be needed, but if you need to customize your Kafka consumer,"
147156
+ " you may use this. See a detailed list:"
148157
+ " https://docs.confluent.io/platform/current/installation/configuration/consumer-configs.html")
158+
@SchemaFieldNumber("8")
149159
@Nullable
150160
public abstract Map<String, String> getConsumerConfigUpdates();
151161

152162
/** Sets the topic from which to read. */
163+
@SchemaFieldNumber("9")
153164
public abstract String getTopic();
154165

155166
@SchemaFieldDescription("Upper bound of how long to read from Kafka.")
167+
@SchemaFieldNumber("10")
156168
@Nullable
157169
public abstract Integer getMaxReadTimeSeconds();
158170

159171
@SchemaFieldDescription("This option specifies whether and where to output unwritable rows.")
172+
@SchemaFieldNumber("11")
160173
@Nullable
161174
public abstract ErrorHandling getErrorHandling();
162175

sdks/java/io/kafka/src/main/java/org/apache/beam/sdk/io/kafka/KafkaSourceDescriptor.java

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@
2626
import org.apache.beam.sdk.schemas.annotations.DefaultSchema;
2727
import org.apache.beam.sdk.schemas.annotations.SchemaCreate;
2828
import org.apache.beam.sdk.schemas.annotations.SchemaFieldName;
29+
import org.apache.beam.sdk.schemas.annotations.SchemaFieldNumber;
2930
import org.apache.beam.sdk.schemas.annotations.SchemaIgnore;
3031
import org.apache.kafka.common.TopicPartition;
3132
import org.checkerframework.checker.nullness.qual.Nullable;
@@ -38,30 +39,37 @@
3839
@AutoValue
3940
public abstract class KafkaSourceDescriptor implements Serializable {
4041
@SchemaFieldName("topic")
42+
@SchemaFieldNumber("0")
4143
@Pure
4244
abstract String getTopic();
4345

4446
@SchemaFieldName("partition")
47+
@SchemaFieldNumber("1")
4548
@Pure
4649
abstract Integer getPartition();
4750

4851
@SchemaFieldName("start_read_offset")
52+
@SchemaFieldNumber("2")
4953
@Pure
5054
abstract @Nullable Long getStartReadOffset();
5155

5256
@SchemaFieldName("start_read_time")
57+
@SchemaFieldNumber("3")
5358
@Pure
5459
abstract @Nullable Instant getStartReadTime();
5560

5661
@SchemaFieldName("stop_read_offset")
62+
@SchemaFieldNumber("4")
5763
@Pure
5864
abstract @Nullable Long getStopReadOffset();
5965

6066
@SchemaFieldName("stop_read_time")
67+
@SchemaFieldNumber("5")
6168
@Pure
6269
abstract @Nullable Instant getStopReadTime();
6370

6471
@SchemaFieldName("bootstrap_servers")
72+
@SchemaFieldNumber("6")
6573
@Pure
6674
abstract @Nullable List<String> getBootStrapServers();
6775

sdks/java/io/kafka/src/main/java/org/apache/beam/sdk/io/kafka/KafkaWriteSchemaTransformProvider.java

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@
4444
import org.apache.beam.sdk.schemas.SchemaRegistry;
4545
import org.apache.beam.sdk.schemas.annotations.DefaultSchema;
4646
import org.apache.beam.sdk.schemas.annotations.SchemaFieldDescription;
47+
import org.apache.beam.sdk.schemas.annotations.SchemaFieldNumber;
4748
import org.apache.beam.sdk.schemas.transforms.SchemaTransform;
4849
import org.apache.beam.sdk.schemas.transforms.SchemaTransformProvider;
4950
import org.apache.beam.sdk.schemas.transforms.TypedSchemaTransformProvider;
@@ -339,41 +340,49 @@ public abstract static class KafkaWriteSchemaTransformConfiguration implements S
339340
@SchemaFieldDescription(
340341
"The encoding format for the data stored in Kafka. Valid options are: "
341342
+ SUPPORTED_FORMATS_STR)
343+
@SchemaFieldNumber("0")
342344
public abstract String getFormat();
343345

346+
@SchemaFieldNumber("1")
344347
public abstract String getTopic();
345348

346349
@SchemaFieldDescription(
347350
"A list of host/port pairs to use for establishing the initial connection to the"
348351
+ " Kafka cluster. The client will make use of all servers irrespective of which servers are specified"
349352
+ " here for bootstrapping—this list only impacts the initial hosts used to discover the full set"
350353
+ " of servers. | Format: host1:port1,host2:port2,...")
354+
@SchemaFieldNumber("2")
351355
public abstract String getBootstrapServers();
352356

353357
@SchemaFieldDescription(
354358
"A list of key-value pairs that act as configuration parameters for Kafka producers."
355359
+ " Most of these configurations will not be needed, but if you need to customize your Kafka producer,"
356360
+ " you may use this. See a detailed list:"
357361
+ " https://docs.confluent.io/platform/current/installation/configuration/producer-configs.html")
362+
@SchemaFieldNumber("3")
358363
@Nullable
359364
public abstract Map<String, String> getProducerConfigUpdates();
360365

361366
@SchemaFieldDescription("This option specifies whether and where to output unwritable rows.")
367+
@SchemaFieldNumber("4")
362368
@Nullable
363369
public abstract ErrorHandling getErrorHandling();
364370

365371
@SchemaFieldDescription(
366372
"The path to the Protocol Buffer File Descriptor Set file. This file is used for schema"
367373
+ " definition and message serialization.")
374+
@SchemaFieldNumber("5")
368375
@Nullable
369376
public abstract String getFileDescriptorPath();
370377

371378
@SchemaFieldDescription(
372379
"The name of the Protocol Buffer message to be used for schema"
373380
+ " extraction and data conversion.")
381+
@SchemaFieldNumber("6")
374382
@Nullable
375383
public abstract String getMessageName();
376384

385+
@SchemaFieldNumber("7")
377386
@Nullable
378387
public abstract String getSchema();
379388

sdks/java/io/kafka/src/test/java/org/apache/beam/sdk/io/kafka/KafkaIOTest.java

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,9 @@
9191
import org.apache.beam.sdk.metrics.SourceMetrics;
9292
import org.apache.beam.sdk.options.PipelineOptionsFactory;
9393
import org.apache.beam.sdk.options.StreamingOptions;
94+
import org.apache.beam.sdk.schemas.NoSuchSchemaException;
95+
import org.apache.beam.sdk.schemas.Schema;
96+
import org.apache.beam.sdk.schemas.SchemaRegistry;
9497
import org.apache.beam.sdk.testing.ExpectedLogs;
9598
import org.apache.beam.sdk.testing.PAssert;
9699
import org.apache.beam.sdk.testing.TestPipeline;
@@ -2450,6 +2453,62 @@ public void testWithValidConsumerPollingTimeout() {
24502453
assertEquals(15, reader.getConsumerPollingTimeout());
24512454
}
24522455

2456+
// This test verifies that the schema for KafkaIO.ByteArrayKafkaRecord is correctly generated.
2457+
// This schema is used when Kafka records are serialized/deserialized with SchemaCoder.
2458+
@Test
2459+
public void testByteArrayKafkaRecordSchema() throws NoSuchSchemaException {
2460+
Schema schema = SchemaRegistry.createDefault().getSchema(KafkaIO.ByteArrayKafkaRecord.class);
2461+
2462+
assertEquals(9, schema.getFieldCount());
2463+
assertEquals(Schema.Field.of("topic", Schema.FieldType.STRING), schema.getField(0));
2464+
assertEquals(Schema.Field.of("partition", Schema.FieldType.INT32), schema.getField(1));
2465+
assertEquals(Schema.Field.of("offset", Schema.FieldType.INT64), schema.getField(2));
2466+
assertEquals(Schema.Field.of("timestamp", Schema.FieldType.INT64), schema.getField(3));
2467+
assertEquals(Schema.Field.nullable("key", Schema.FieldType.BYTES), schema.getField(4));
2468+
assertEquals(Schema.Field.nullable("value", Schema.FieldType.BYTES), schema.getField(5));
2469+
assertEquals(
2470+
Schema.Field.nullable(
2471+
"headers",
2472+
Schema.FieldType.array(
2473+
Schema.FieldType.row(
2474+
Schema.of(
2475+
Schema.Field.of("key", Schema.FieldType.STRING),
2476+
Schema.Field.nullable("value", Schema.FieldType.BYTES))))),
2477+
schema.getField(6));
2478+
assertEquals(Schema.Field.of("timestampTypeId", Schema.FieldType.INT32), schema.getField(7));
2479+
assertEquals(Schema.Field.of("timestampTypeName", Schema.FieldType.STRING), schema.getField(8));
2480+
}
2481+
2482+
// This test verifies that the schema for KafkaSourceDescriptor is correctly generated.
2483+
@Test
2484+
public void testKafkaSourceDescriptorSchema() throws NoSuchSchemaException {
2485+
Schema schema = SchemaRegistry.createDefault().getSchema(KafkaSourceDescriptor.class);
2486+
2487+
assertEquals(7, schema.getFieldCount());
2488+
assertEquals(Schema.Field.of("topic", Schema.FieldType.STRING), schema.getField(0));
2489+
assertEquals(Schema.Field.of("partition", Schema.FieldType.INT32), schema.getField(1));
2490+
assertEquals(
2491+
Schema.Field.nullable("start_read_offset", Schema.FieldType.INT64), schema.getField(2));
2492+
assertEquals(
2493+
Schema.Field.nullable("start_read_time", Schema.FieldType.DATETIME), schema.getField(3));
2494+
assertEquals(
2495+
Schema.Field.nullable("stop_read_offset", Schema.FieldType.INT64), schema.getField(4));
2496+
assertEquals(
2497+
Schema.Field.nullable("stop_read_time", Schema.FieldType.DATETIME), schema.getField(5));
2498+
assertEquals(
2499+
Schema.Field.nullable("bootstrap_servers", Schema.FieldType.array(Schema.FieldType.STRING)),
2500+
schema.getField(6));
2501+
}
2502+
2503+
@Test
2504+
public void testKafkaHeaderSchema() throws NoSuchSchemaException {
2505+
Schema schema = SchemaRegistry.createDefault().getSchema(KafkaIO.KafkaHeader.class);
2506+
2507+
assertEquals(2, schema.getFieldCount());
2508+
assertEquals(Schema.Field.of("key", Schema.FieldType.STRING), schema.getField(0));
2509+
assertEquals(Schema.Field.nullable("value", Schema.FieldType.BYTES), schema.getField(1));
2510+
}
2511+
24532512
private static void verifyProducerRecords(
24542513
MockProducer<Integer, Long> mockProducer,
24552514
String topic,

0 commit comments

Comments
 (0)