forked from linkedin/venice
-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathAvroSupersetSchemaUtils.java
More file actions
346 lines (323 loc) · 15.7 KB
/
Copy pathAvroSupersetSchemaUtils.java
File metadata and controls
346 lines (323 loc) · 15.7 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
package com.linkedin.venice.utils;
import static com.linkedin.venice.utils.AvroSchemaUtils.getFieldDefault;
import com.linkedin.avroutil1.compatibility.AvroCompatibilityHelper;
import com.linkedin.avroutil1.compatibility.FieldBuilder;
import com.linkedin.venice.controllerapi.MultiSchemaResponse;
import com.linkedin.venice.exceptions.VeniceException;
import com.linkedin.venice.schema.AvroSchemaParseUtils;
import com.linkedin.venice.schema.SchemaData;
import java.util.ArrayList;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Set;
import java.util.stream.Collectors;
import org.apache.avro.Schema;
import org.apache.commons.lang.StringUtils;
public class AvroSupersetSchemaUtils {
private AvroSupersetSchemaUtils() {
// Utility class.
}
/**
* @return True if {@param s1} is {@param s2}'s superset schema and false otherwise.
*/
public static boolean isSupersetSchema(Schema s1, Schema s2) {
final Schema supersetSchema = generateSupersetSchema(s1, s2);
return AvroSchemaUtils.compareSchemaIgnoreFieldOrder(s1, supersetSchema);
}
/**
* Generate super-set schema of two Schemas. If we have {A,B,C} and {A,B,D} it will generate {A,B,C,D}, where
* C/D could be nested record change as well eg, array/map of records, or record of records.
* Prerequisite: The top-level schema are of type RECORD only and each field have default values. ie they are compatible
* schemas and the generated schema will pick the default value from new value schema.
* @param rawExistingSchema schema existing in the repo
* @param rawNewSchema schema to be added.
* @return super-set schema of rawExistingSchema and rawNewSchema
*/
public static Schema generateSupersetSchema(Schema rawExistingSchema, Schema rawNewSchema) {
// Normalize single-element unions [T] to their inner type T so that [T] and T
// are treated equivalently. Skip normalization when both inputs are unions —
// the multi-element union case is handled by unionSchema() below and must not
// be disrupted (e.g. [T] vs ["null", T] must remain a UNION-vs-UNION merge).
final boolean bothUnions =
rawExistingSchema.getType() == Schema.Type.UNION && rawNewSchema.getType() == Schema.Type.UNION;
final Schema existingSchema = bothUnions ? rawExistingSchema : unwrapSingleElementUnion(rawExistingSchema);
final Schema newSchema = bothUnions ? rawNewSchema : unwrapSingleElementUnion(rawNewSchema);
if (existingSchema.getType() != newSchema.getType()) {
throw new VeniceException("Incompatible schema");
}
if (Objects.equals(existingSchema, newSchema)) {
return existingSchema;
}
// Special handling for String vs Avro string comparison,
// return the schema with avro.java.string property for string type
if (existingSchema.getType() == Schema.Type.STRING) {
return AvroCompatibilityHelper.getSchemaPropAsJsonString(existingSchema, "avro.java.string") == null
? newSchema
: existingSchema;
}
switch (existingSchema.getType()) {
case RECORD:
if (!StringUtils.equals(existingSchema.getNamespace(), newSchema.getNamespace())) {
throw new VeniceException(
String.format(
"Trying to merge record schemas with different namespace. "
+ "Got existing schema namespace: %s and new schema namespace: %s",
existingSchema.getNamespace(),
newSchema.getNamespace()));
}
if (!StringUtils.equals(existingSchema.getName(), newSchema.getName())) {
throw new VeniceException(
String.format(
"Trying to merge record schemas with different name. "
+ "Got existing schema name: %s and new schema name: %s",
existingSchema.getName(),
newSchema.getName()));
}
Schema superSetSchema = Schema
.createRecord(existingSchema.getName(), existingSchema.getDoc(), existingSchema.getNamespace(), false);
superSetSchema.setFields(mergeFieldSchemas(existingSchema, newSchema));
return superSetSchema;
case ARRAY:
return Schema.createArray(generateSupersetSchema(existingSchema.getElementType(), newSchema.getElementType()));
case MAP:
return Schema.createMap(generateSupersetSchema(existingSchema.getValueType(), newSchema.getValueType()));
case UNION:
return unionSchema(existingSchema, newSchema);
case ENUM: {
// Build a superset symbol list: all symbols from existingSchema (preserving their order),
// followed by any symbols present only in newSchema. This ensures no symbol is lost when
// the two schemas have diverged (e.g. existing has ["A","B","C"], new has ["A","B","D"]).
LinkedHashSet<String> supersetSymbols = new LinkedHashSet<>(existingSchema.getEnumSymbols());
supersetSymbols.addAll(newSchema.getEnumSymbols());
// Always construct a new enum schema so that properties from both schemas are merged
// consistently, regardless of whether symbols grew or diverged. (Truly-equal schemas are
// already short-circuited by the Objects.equals check at the top of this method.)
// newSchema takes priority on conflicts. Avro 1.10+ forbids overwriting an already-set
// property, so each prop is written exactly once: existingSchema-only props first, then
// all newSchema props.
Schema supersetEnum = Schema.createEnum(
newSchema.getName(),
newSchema.getDoc(),
newSchema.getNamespace(),
new ArrayList<>(supersetSymbols),
newSchema.getEnumDefault());
Set<String> newSchemaPropNames = getSchemaPropNames(newSchema);
getSchemaPropNames(existingSchema).stream()
.filter(prop -> !newSchemaPropNames.contains(prop))
.forEach(
prop -> AvroCompatibilityHelper.setSchemaPropFromJsonString(
supersetEnum,
prop,
AvroCompatibilityHelper.getSchemaPropAsJsonString(existingSchema, prop),
false));
getSchemaPropNames(newSchema).forEach(
prop -> AvroCompatibilityHelper.setSchemaPropFromJsonString(
supersetEnum,
prop,
AvroCompatibilityHelper.getSchemaPropAsJsonString(newSchema, prop),
false));
return supersetEnum;
}
case FIXED: {
// FIXED schemas are structurally compatible only when their size attributes are identical.
// A size mismatch is an irreconcilable schema incompatibility — unlike custom properties,
// the size is part of the binary encoding and cannot be silently promoted.
if (existingSchema.getFixedSize() != newSchema.getFixedSize()) {
throw new VeniceException(
String.format(
"Incompatible FIXED schemas for '%s': existing size %d does not match new size %d",
existingSchema.getFullName(),
existingSchema.getFixedSize(),
newSchema.getFixedSize()));
}
// Sizes match — merge properties from both schemas, same convention as ENUM:
// existingSchema-only props first, then all newSchema props (newSchema wins on conflicts).
Schema supersetFixed = Schema
.createFixed(newSchema.getName(), newSchema.getDoc(), newSchema.getNamespace(), newSchema.getFixedSize());
Set<String> newFixedPropNames = getSchemaPropNames(newSchema);
getSchemaPropNames(existingSchema).stream()
.filter(prop -> !newFixedPropNames.contains(prop))
.forEach(
prop -> AvroCompatibilityHelper.setSchemaPropFromJsonString(
supersetFixed,
prop,
AvroCompatibilityHelper.getSchemaPropAsJsonString(existingSchema, prop),
false));
getSchemaPropNames(newSchema).forEach(
prop -> AvroCompatibilityHelper.setSchemaPropFromJsonString(
supersetFixed,
prop,
AvroCompatibilityHelper.getSchemaPropAsJsonString(newSchema, prop),
false));
return supersetFixed;
}
case INT:
case LONG:
case FLOAT:
case DOUBLE:
case BOOLEAN:
case BYTES:
case NULL:
// Primitive types cannot differ structurally; schemas are equal in type but differ only in
// custom properties (e.g. "li.data.proto.numberFieldType"). Return newSchema so its
// properties take priority, consistent with the convention used elsewhere in this method.
return newSchema;
default:
throw new VeniceException("Super set schema not supported");
}
}
/**
* Merge union schema from two schema object. The rule is: If a field exist in both new schema and old schema, we should
* generate the superset schema of these two versions of the same field, with new schema's information taking higher
* priority.
*/
private static Schema unionSchema(Schema existingSchema, Schema newSchema) {
List<Schema> combinedSchema = new ArrayList<>();
Map<String, Schema> existingSchemaTypeMap =
existingSchema.getTypes().stream().collect(Collectors.toMap(Schema::getName, s -> s));
for (Schema subSchemaInNewSchema: newSchema.getTypes()) {
final String fieldName = subSchemaInNewSchema.getName();
final Schema subSchemaInExistingSchema = existingSchemaTypeMap.get(fieldName);
if (subSchemaInExistingSchema == null) {
combinedSchema.add(subSchemaInNewSchema);
} else {
combinedSchema.add(generateSupersetSchema(subSchemaInExistingSchema, subSchemaInNewSchema));
existingSchemaTypeMap.remove(fieldName);
}
}
existingSchemaTypeMap.forEach((k, v) -> combinedSchema.add(v));
return Schema.createUnion(combinedSchema);
}
/**
* If the schema is a UNION with exactly one member type, return that inner type.
* A single-element union {@code [T]} is semantically equivalent to {@code T} in Avro,
* but {@link Schema#getType()} returns {@link Schema.Type#UNION} for the wrapper form.
* Unwrapping normalizes both representations so they compare as the same type.
*/
private static Schema unwrapSingleElementUnion(Schema schema) {
if (schema.getType() == Schema.Type.UNION && schema.getTypes().size() == 1) {
return schema.getTypes().get(0);
}
return schema;
}
/**
* Returns all custom property names for a {@link Schema} using {@link Schema#getObjectProps()} (available since
* Avro 1.8) instead of {@link AvroCompatibilityHelper#getAllPropNames(Schema)}. The latter routes through
* {@code Avro16Adapter.getAllPropNames} which calls the removed {@code Schema.getProps()} and breaks on Avro 1.11+.
*/
private static Set<String> getSchemaPropNames(Schema schema) {
return schema.getObjectProps().keySet();
}
private static void copyFieldProperties(FieldBuilder fieldBuilder, Schema.Field field) {
AvroCompatibilityHelper.getAllPropNames(field).forEach(k -> {
String propValue = AvroCompatibilityHelper.getFieldPropAsJsonString(field, k);
if (propValue != null) {
fieldBuilder.addProp(k, propValue);
}
});
}
private static FieldBuilder deepCopySchemaFieldWithoutFieldProps(Schema.Field field) {
FieldBuilder fieldBuilder = AvroCompatibilityHelper.newField(null)
.setName(field.name())
.setSchema(field.schema())
.setDoc(field.doc())
.setOrder(field.order());
// set default as AvroCompatibilityHelper builder might drop defaults if there is type mismatch
if (field.hasDefaultValue()) {
fieldBuilder.setDefault(getFieldDefault(field));
}
return fieldBuilder;
}
private static FieldBuilder deepCopySchemaField(Schema.Field field) {
FieldBuilder fieldBuilder = deepCopySchemaFieldWithoutFieldProps(field);
copyFieldProperties(fieldBuilder, field);
return fieldBuilder;
}
/**
* Merge field schema from two schema object.
* The rule is: If a field exist in both new schema and old schema, we should generate the superset schema of these
* two versions of the same field, with new schema's information taking higher priority.
* For default value, if new schema does not have default value, we will still preserve the old default value.
* @param newSchema new schema
* @param existingSchema old schema
* @return merged schema field
*/
private static List<Schema.Field> mergeFieldSchemas(Schema existingSchema, Schema newSchema) {
List<Schema.Field> fields = new ArrayList<>();
for (Schema.Field fieldInNewSchema: newSchema.getFields()) {
Schema.Field fieldInExistingSchema = existingSchema.getField(fieldInNewSchema.name());
FieldBuilder fieldBuilder = deepCopySchemaField(fieldInNewSchema);
if (fieldInExistingSchema != null) {
fieldBuilder.setSchema(generateSupersetSchema(fieldInExistingSchema.schema(), fieldInNewSchema.schema()))
.setDoc(fieldInNewSchema.doc() != null ? fieldInNewSchema.doc() : fieldInExistingSchema.doc());
if (!fieldInNewSchema.hasDefaultValue() && fieldInExistingSchema.hasDefaultValue()) {
fieldBuilder.setDefault(getFieldDefault(fieldInExistingSchema));
}
}
Schema.Field generatedField = fieldBuilder.build();
fields.add(generatedField);
}
for (Schema.Field fieldInExistingSchema: existingSchema.getFields()) {
if (newSchema.getField(fieldInExistingSchema.name()) == null) {
fields.add(deepCopySchemaField(fieldInExistingSchema).build());
}
}
return fields;
}
public static MultiSchemaResponse.Schema getSupersetSchemaFromSchemaResponse(
MultiSchemaResponse schemaResponse,
int supersetSchemaId) {
for (MultiSchemaResponse.Schema schema: schemaResponse.getSchemas()) {
if (schema.getId() != supersetSchemaId) {
continue;
}
if (schema.getDerivedSchemaId() != SchemaData.INVALID_VALUE_SCHEMA_ID) {
continue;
}
if (schema.getRmdValueSchemaId() != SchemaData.INVALID_VALUE_SCHEMA_ID) {
continue;
}
return schema;
}
return null;
}
public static MultiSchemaResponse.Schema getLatestUpdateSchemaFromSchemaResponse(
MultiSchemaResponse schemaResponse,
int supersetSchemaId) {
MultiSchemaResponse.Schema updateSchema = null;
for (MultiSchemaResponse.Schema schema: schemaResponse.getSchemas()) {
if (schema.getId() != supersetSchemaId) {
continue;
}
if (schema.getDerivedSchemaId() == SchemaData.INVALID_VALUE_SCHEMA_ID) {
continue;
}
if (updateSchema == null || schema.getDerivedSchemaId() > updateSchema.getDerivedSchemaId()) {
updateSchema = schema;
}
}
return updateSchema;
}
/**
* * Validate if the Subset Value Schema is a subset of the Superset Value Schema, here the field props are not used to
* check if the field is same or not.
*/
public static boolean validateSubsetValueSchema(Schema subsetValueSchema, String supersetSchemaStr) {
Schema supersetSchema = AvroSchemaParseUtils.parseSchemaFromJSONLooseValidation(supersetSchemaStr);
for (Schema.Field field: subsetValueSchema.getFields()) {
Schema.Field fieldInSupersetSchema = supersetSchema.getField(field.name());
if (fieldInSupersetSchema == null) {
return false;
}
Schema.Field subsetValueSchemaWithoutFieldProps = deepCopySchemaFieldWithoutFieldProps(field).build();
Schema.Field fieldInSupersetSchemaWithoutFieldProps =
deepCopySchemaFieldWithoutFieldProps(fieldInSupersetSchema).build();
if (!subsetValueSchemaWithoutFieldProps.equals(fieldInSupersetSchemaWithoutFieldProps)) {
return false;
}
}
return true;
}
}