/* * Copyright 2011 LinkedIn, Inc * * Licensed under the Apache License, Version 2.0 (the "License"); you may not * use this file except in compliance with the License. You may obtain a copy of * the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the * License for the specific language governing permissions and limitations under * the License. */ package voldemort.serialization.avro.versioned; import java.io.File; import java.util.ArrayList; import java.util.Iterator; import java.util.List; import java.util.Map; import org.apache.avro.Schema; import org.apache.avro.Schema.Field; import org.apache.avro.Schema.Type; import org.apache.log4j.Level; import org.codehaus.jackson.JsonNode; import voldemort.VoldemortException; import voldemort.serialization.SerializerDefinition; /** * Provides methods to compare schemas for schema evolution and indicate any * potential problems. * * @author Jemiah Westerman <jwesterman@linkedin.com> * * @version $Revision$ */ public class SchemaEvolutionValidator { private static final Schema NULL_TYPE_SCHEMA = Schema.create(Schema.Type.NULL); private final List<String> _recordStack = new ArrayList<String>(); /** * This main method provides an easy command line tool to compare two * schemas. */ public static void main(String[] args) { if(args.length != 2) { System.out.println("Usage: SchemaEvolutionValidator pathToOldSchema pathToNewSchema"); return; } Schema oldSchema; Schema newSchema; try { oldSchema = Schema.parse(new File(args[0])); } catch(Exception ex) { oldSchema = null; System.out.println("Could not open or parse the old schema (" + args[0] + ") due to " + ex); } try { newSchema = Schema.parse(new File(args[1])); } catch(Exception ex) { newSchema = null; System.out.println("Could not open or parse the new schema (" + args[1] + ") due to " + ex); } if(oldSchema == null || newSchema == null) { return; } System.out.println("Comparing: "); System.out.println("\t" + args[0]); System.out.println("\t" + args[1]); List<Message> messages = SchemaEvolutionValidator.checkBackwardCompatibility(oldSchema, newSchema, oldSchema.getName()); Level maxLevel = Level.ALL; for(Message message: messages) { System.out.println(message.getLevel() + ": " + message.getMessage()); if(message.getLevel().isGreaterOrEqual(maxLevel)) { maxLevel = message.getLevel(); } } if(maxLevel.isGreaterOrEqual(Level.ERROR)) { System.out.println(Level.ERROR + ": The schema is not backward compatible. New clients will not be able to read existing data."); } else if(maxLevel.isGreaterOrEqual(Level.WARN)) { System.out.println(Level.WARN + ": The schema is partially backward compatible, but old clients will not be able to read data serialized in the new format."); } else { System.out.println(Level.INFO + ": The schema is backward compatible. Old and new clients will be able to read records serialized by one another."); } } /** * Compare two schemas to see if they are backward compatible. Returns a * list of validation messages. <li>ERROR messages indicate the schemas are * not backward compatible and the new schema should not be allowed. If an * ERROR schema is uploaded, clients will not be able to read existing data. * <li>WARN messages indicate that the new schemas may cause problems for * existing clients. However, once all clients are updated to this version * of the schema they should be able to read new and existing data. <li>INFO * messages note changes to the schema, basically providing a friendly list * of what has changed from one version to the next. This includes changes * like the addition of fields, changes to default values, etc. * * @param oldSchema the original schema * @param newSchema the new schema * @param name the schema name * @return list of messages about the schema evolution */ public static List<Message> checkBackwardCompatibility(Schema oldSchema, Schema newSchema, String name) { SchemaEvolutionValidator validator = new SchemaEvolutionValidator(); List<Message> messages = new ArrayList<Message>(); validator.compareTypes(oldSchema, newSchema, messages, name); return messages; } /* package private */void compareTypes(Schema oldSchema, Schema newSchema, List<Message> messages, String name) { oldSchema = stripOptionalTypeUnion(oldSchema); newSchema = stripOptionalTypeUnion(newSchema); Schema.Type oldType = oldSchema.getType(); Schema.Type newType = newSchema.getType(); if(oldType != Type.UNION && newType == Type.UNION) { boolean compatibleTypeFound = false; for(Schema s: newSchema.getTypes()) { if((oldType != Type.RECORD && oldType == s.getType()) || (oldType == Type.RECORD && s.getType() == Type.RECORD && oldSchema.getName() .equals(s.getName()))) { compareTypes(oldSchema, s, messages, name); compatibleTypeFound = true; break; } } if(compatibleTypeFound) { messages.add(new Message(Level.INFO, "Type change from " + oldType + " to " + newType + " for field " + name + ". The new union includes the original type.")); } else { messages.add(new Message(Level.ERROR, "Incompatible type change from " + oldType + " to " + newType + " for field " + name + ". The new union does not include the original type.")); } } else if(oldType == Type.RECORD) { if(!_recordStack.contains(oldSchema.getName())) { _recordStack.add(oldSchema.getName()); compareRecordTypes(oldSchema, newSchema, messages, name); _recordStack.remove(oldSchema.getName()); } } else if(oldType == Type.ENUM) { compareEnumTypes(oldSchema, newSchema, messages, name); } else if(oldType == Type.ARRAY) { compareArrayTypes(oldSchema, newSchema, messages, name); } else if(oldType == Type.MAP) { compareMapTypes(oldSchema, newSchema, messages, name); } else if(oldType == Type.UNION) { compareUnionTypes(oldSchema, newSchema, messages, name); } else if(oldType == Type.FIXED) { compareFixedTypes(oldSchema, newSchema, messages, name); } else { comparePrimitiveTypes(oldSchema, newSchema, messages, name); } } /* package private */void compareRecordTypes(Schema oldSchema, Schema newSchema, List<Message> messages, String name) { if(oldSchema == null || newSchema == null || oldSchema.getType() != Schema.Type.RECORD) { throw new IllegalArgumentException("Old schema must be RECORD type. Name=" + name + ". Type=" + oldSchema); } if(newSchema.getType() != Schema.Type.RECORD) { messages.add(new Message(Level.ERROR, "Illegal type change from " + oldSchema.getType() + " to " + newSchema.getType() + " for field " + name)); return; } // Check all of the fields in the new schema for(Field newField: newSchema.getFields()) { String fieldName = newField.name(); Field oldField = oldSchema.getField(fieldName); if(oldField == null) { // This is a new field that did not exist in the original // schema. // Check if it is optional or has a default value. if(isOptional(newField)) { if(newField.defaultValue() == null) { messages.add(new Message(Level.INFO, "Added optional field " + name + "." + fieldName + " with no default value.")); } else { messages.add(new Message(Level.INFO, "Added optional field " + name + "." + fieldName + " with default value: " + newField.defaultValue())); } } else { if(newField.defaultValue() == null) { messages.add(new Message(Level.ERROR, "Added required field " + name + "." + fieldName + " with no default value.")); } else { messages.add(new Message(Level.INFO, "Added required field " + name + "." + fieldName + " with default value: " + newField.defaultValue())); } } } else { // This is a field that existed in the original schema. // Check if the field was changed from optional to required or // vice versa. boolean newFieldIsOptional = isOptional(newField); boolean oldFieldIsOptional = isOptional(oldField); if(oldFieldIsOptional != newFieldIsOptional) { if(oldFieldIsOptional) { messages.add(new Message(Level.ERROR, "Existing field " + name + "." + fieldName + " was optional and is now required.")); } else { messages.add(new Message(Level.WARN, "Existing field " + name + "." + fieldName + " was required and is now optional.")); } } // Recursively compare the nested field types compareTypes(oldField.schema(), newField.schema(), messages, name + "." + fieldName); // Check if the default value has been changed if(newField.defaultValue() == null) { if(oldField.defaultValue() != null) { messages.add(new Message(Level.WARN, "Removed default value for existing field " + name + "." + fieldName + ". The old default was: " + oldField.defaultValue())); } } else // newField.defaultValue() != null { if(oldField.defaultValue() == null) { messages.add(new Message(Level.WARN, "Added a default value for existing field " + name + "." + fieldName + ". The new default is: " + newField.defaultValue())); } else if(!newField.defaultValue().equals(oldField.defaultValue())) { messages.add(new Message(Level.INFO, "Changed the default value for existing field " + name + "." + fieldName + ". The old default was: " + oldField.defaultValue() + ". The new default is: " + newField.defaultValue())); } } } // For all fields in the new schema (whether or not it existed in // the old schema), if there is a default value for this field, make // sure it is legal. if(newField.defaultValue() != null) { checkDefaultValueIsLegal(newField, messages, name + "." + fieldName); } } // Check if any fields were removed. for(Field oldField: newSchema.getFields()) { String fieldName = oldField.name(); Field newField = newSchema.getField(fieldName); if(newField == null) { if(isOptional(oldField)) { messages.add(new Message(Level.INFO, "Removed optional field " + name + "." + fieldName)); } else { messages.add(new Message(Level.WARN, "Removed required field " + name + "." + fieldName)); } } } // Check if indexing was modified or added to any old fields. for(Field oldField: oldSchema.getFields()) { if(newSchema.getField(oldField.name()) != null) { String oldIndexType = oldField.getProp("indexType"); String newIndexType = newSchema.getField(oldField.name()).getProp("indexType"); // Check if added indexing. if(oldIndexType == null && newIndexType != null) { messages.add(new Message(Level.ERROR, "Cannot add indexing to " + oldField.name() + ". Adding indexing to fields created without indexing is not supported.")); } // Check if changed indexing if(oldIndexType != null && !oldIndexType.equals(newIndexType)) { messages.add(new Message(Level.ERROR, "Cannot change indexing from " + oldIndexType + " to " + newIndexType + " for " + oldField.name() + ". Changing indexing method is not supported.")); } } } } /* package private */void compareUnionTypes(Schema oldSchema, Schema newSchema, List<Message> messages, String name) { if(oldSchema == null || newSchema == null || oldSchema.getType() != Schema.Type.UNION) { throw new IllegalArgumentException("Old and new schema must both be UNION types. Name=" + name + ". Type=" + oldSchema); } // Build a list of type names, ignoring nulls which are checked // separately as optional/required fields List<Schema> newTypes = new ArrayList<Schema>(); List<String> newTypeNames = new ArrayList<String>(); List<Schema> oldTypes = new ArrayList<Schema>(); List<String> oldTypeNames = new ArrayList<String>(); if(newSchema.getType() == Type.UNION) { for(Schema schema: newSchema.getTypes()) { if(schema.getType() != Schema.Type.NULL) { newTypes.add(schema); newTypeNames.add(schema.getName()); } } } else { newTypes.add(newSchema); newTypeNames.add(newSchema.getName()); } for(Schema schema: oldSchema.getTypes()) { if(schema.getType() != Schema.Type.NULL) { oldTypes.add(schema); oldTypeNames.add(schema.getName()); } } if(!newTypeNames.containsAll(oldTypeNames)) { messages.add(new Message(Level.ERROR, "Existing UNION field " + name + " had one or more types removed. The old types are:" + oldTypeNames + ". The new types are: " + newTypeNames)); } if(!oldTypeNames.containsAll(newTypeNames)) { messages.add(new Message(Level.INFO, "Existing UNION field " + name + " had one or more types added. The old types are:" + oldTypeNames + ". The new types are: " + newTypeNames)); } if(newTypeNames.containsAll(oldTypeNames) && oldTypeNames.containsAll(newTypeNames) && !newTypeNames.equals(oldTypeNames)) { messages.add(new Message(Level.INFO, "Existing UNION field " + name + " had one or more types reordered. The old types are:" + oldTypeNames + ". The new types are: " + newTypeNames)); } for(int i = 0; i < newTypeNames.size(); i++) { String typeName = newTypeNames.get(i); int oldIndex = oldTypeNames.indexOf(typeName); if(oldIndex != -1) { compareTypes(oldTypes.get(oldIndex), newTypes.get(i), messages, name + "." + oldTypes.get(oldIndex).getName()); } } } /* package private */void compareEnumTypes(Schema oldSchema, Schema newSchema, List<Message> messages, String name) { if(oldSchema == null || newSchema == null || oldSchema.getType() != Schema.Type.ENUM) { throw new IllegalArgumentException("Old schema must be ENUM type. Name=" + name + ". Type=" + oldSchema); } if(newSchema.getType() != Schema.Type.ENUM) { messages.add(new Message(Level.ERROR, "Illegal type change from " + oldSchema.getType() + " to " + newSchema.getType() + " for field " + name)); return; } List<String> newEnumSymbols = newSchema.getEnumSymbols(); List<String> oldEnumSymbols = oldSchema.getEnumSymbols(); // Check if enum types were added or removed if(!newEnumSymbols.containsAll(oldEnumSymbols)) { messages.add(new Message(Level.ERROR, "Existing ENUM field " + name + " had one or more enum symbols removed. The old symbols are:" + oldEnumSymbols + ". The new symbols are: " + newEnumSymbols)); } if(!oldEnumSymbols.containsAll(newEnumSymbols)) { messages.add(new Message(Level.INFO, "Existing ENUM field " + name + " had one or more enum symbols added. The old symbols are:" + oldEnumSymbols + ". The new symbols are: " + newEnumSymbols)); } // Check if enum types were reordered. if(newEnumSymbols.containsAll(oldEnumSymbols)) { for(int i = 0; i < oldEnumSymbols.size(); i++) { if(!oldEnumSymbols.get(i).equals(newEnumSymbols.get(i))) { messages.add(new Message(Level.WARN, "Existing ENUM field " + name + " had one or more enum symbols reordered. The old symbols are:" + oldEnumSymbols + ". The new symbols are: " + newEnumSymbols)); break; } } } } /* package private */void compareArrayTypes(Schema oldSchema, Schema newSchema, List<Message> messages, String name) { if(oldSchema == null || newSchema == null || oldSchema.getType() != Schema.Type.ARRAY) { throw new IllegalArgumentException("Old schema must be ARRAY type. Name=" + name + ". Type=" + oldSchema); } if(newSchema.getType() != Schema.Type.ARRAY) { messages.add(new Message(Level.ERROR, "Illegal type change from " + oldSchema.getType() + " to " + newSchema.getType() + " for field " + name)); return; } // Compare the array element types compareTypes(oldSchema.getElementType(), newSchema.getElementType(), messages, name + ".<array element>"); } /* package private */void compareMapTypes(Schema oldSchema, Schema newSchema, List<Message> messages, String name) { if(oldSchema == null || newSchema == null || oldSchema.getType() != Schema.Type.MAP) { throw new IllegalArgumentException("Old schema must be MAP type. Name=" + name + ". Type=" + oldSchema); } if(newSchema.getType() != Schema.Type.MAP) { messages.add(new Message(Level.ERROR, "Illegal type change from " + oldSchema.getType() + " to " + newSchema.getType() + " for field " + name)); return; } // Compare the array element types compareTypes(oldSchema.getValueType(), newSchema.getValueType(), messages, name + ".<map element>"); } /* package private */void compareFixedTypes(Schema oldSchema, Schema newSchema, List<Message> messages, String name) { if(oldSchema == null || newSchema == null || oldSchema.getType() != Schema.Type.FIXED) { throw new IllegalArgumentException("Old schema must be FIXED type. Name=" + name + ". Type=" + oldSchema); } if(newSchema.getType() != Schema.Type.FIXED) { messages.add(new Message(Level.ERROR, "Illegal type change from " + oldSchema.getType() + " to " + newSchema.getType() + " for field " + name)); return; } if(newSchema.getFixedSize() != oldSchema.getFixedSize()) { messages.add(new Message(Level.ERROR, "Illegal size change for fixed type field " + name)); } } /* package private */void comparePrimitiveTypes(Schema oldSchema, Schema newSchema, List<Message> messages, String name) { if(oldSchema == null || newSchema == null) { throw new IllegalArgumentException("Old schema must both be a primitive type. Name=" + name + ". Type=" + oldSchema); } Schema.Type oldType = oldSchema.getType(); Schema.Type newType = newSchema.getType(); if(oldType != newType) { if(((oldType == Schema.Type.INT && (newType == Schema.Type.LONG || newType == Schema.Type.FLOAT || newType == Schema.Type.DOUBLE)) || (oldType == Schema.Type.LONG && (newType == Schema.Type.FLOAT || newType == Schema.Type.DOUBLE)) || (oldType == Schema.Type.FLOAT && (newType == Schema.Type.DOUBLE)))) { messages.add(new Message(Level.INFO, "Type change from " + oldSchema.getType() + " to " + newSchema.getType() + " for field " + name)); } else { messages.add(new Message(Level.ERROR, "Illegal type change from " + oldSchema.getType() + " to " + newSchema.getType() + " for field " + name)); } } } /** * Returns true if this field is optional. Optional fields are represented * as a type union containing the null type. * * @param field * @return */ /* package private */boolean isOptional(Field field) { if(field.schema().getType() == Type.UNION) { for(Schema nestedType: field.schema().getTypes()) { if(nestedType.getType() == Type.NULL) { return true; } } } return false; } /* package private */Schema stripOptionalTypeUnion(Schema schema) { if(schema.getType() == Schema.Type.UNION && schema.getTypes().size() == 2 && schema.getTypes().contains(NULL_TYPE_SCHEMA)) { return schema.getTypes().get(0).equals(NULL_TYPE_SCHEMA) ? schema.getTypes().get(1) : schema.getTypes().get(0); } return schema; } public static class Message { private final Level _level; private final String _message; public Message(Level level, String message) { super(); _level = level; _message = message; } public Level getLevel() { return _level; } public String getMessage() { return _message; } @Override public String toString() { return _level + ": " + _message; } @Override public int hashCode() { final int prime = 31; int result = 1; result = prime * result + ((_level == null) ? 0 : _level.toString().hashCode()); result = prime * result + ((_message == null) ? 0 : _message.hashCode()); return result; } @Override public boolean equals(Object obj) { if(this == obj) return true; if(obj == null) return false; if(getClass() != obj.getClass()) return false; Message other = (Message) obj; if(_level == null) { if(other._level != null) return false; } else if(!_level.equals(other._level)) return false; if(_message == null) { if(other._message != null) return false; } else if(!_message.equals(other._message)) return false; return true; } } /** * Makes sure the default value is good * * @param parent * @param field */ /* package private */static void checkDefaultValueIsLegal(Field field, List<Message> messages, String name) { if(field == null) { throw new IllegalArgumentException("Field must be non-null. Name=" + name); } if(field.defaultValue() != null) { // Get the type schema. If this is a UNION, the default must be of // the leading type Schema fieldSchema = field.schema(); if(fieldSchema.getType() == Schema.Type.UNION) { fieldSchema = fieldSchema.getTypes().get(0); } // Get the default value JsonNode defaultJson = field.defaultValue(); String expectedVal = checkDefaultJson(defaultJson, field.schema()); if(expectedVal != null) { messages.add(new Message(Level.ERROR, "Illegal default value for field " + name + ". The default must be of type " + expectedVal + ".")); } } } /** * Check that the default json node is a valid default value. If not, return * the expected type as a String. */ /* package private */static String checkDefaultJson(JsonNode defaultJson, Schema schema) { Schema.Type fieldType = schema.getType(); String expectedVal = null; switch(fieldType) { case NULL: if(!defaultJson.isNull()) { expectedVal = "null"; } break; case BOOLEAN: if(!defaultJson.isBoolean()) { expectedVal = "boolean"; } break; case INT: if(!defaultJson.isInt()) { expectedVal = "int"; } break; case LONG: if(!defaultJson.isInt() && !defaultJson.isLong()) { expectedVal = "long"; } break; case FLOAT: case DOUBLE: if(!defaultJson.isNumber()) { expectedVal = "number"; } break; case BYTES: if(defaultJson.isTextual()) { break; } expectedVal = "bytes (ex. \"\\u00FF\")"; break; case STRING: if(!defaultJson.isTextual()) { expectedVal = "string"; } break; case ENUM: if(defaultJson.isTextual()) { if(schema.hasEnumSymbol(defaultJson.getTextValue())) { break; } } expectedVal = "valid enum"; break; case FIXED: if(defaultJson.isTextual()) { byte[] fixed = defaultJson.getValueAsText().getBytes(); if(fixed.length == schema.getFixedSize()) { break; } expectedVal = "fixed size incorrect. Expected size: " + schema.getFixedSize() + " got size " + fixed.length; break; } expectedVal = "fixed (ex. \"\\u00FF\")"; break; case ARRAY: if(defaultJson.isArray()) { // Check all array variables boolean isGood = true; for(JsonNode node: defaultJson) { String val = checkDefaultJson(node, schema.getElementType()); if(val == null) { continue; } else { isGood = false; break; } } if(isGood) { break; } } expectedVal = "array of type " + schema.getElementType().toString(); break; case MAP: if(defaultJson.isObject()) { boolean isGood = true; for(JsonNode node: defaultJson) { String val = checkDefaultJson(node, schema.getValueType()); if(val == null) { continue; } else { isGood = false; break; } } if(isGood) { break; } } expectedVal = "map of type " + schema.getValueType().toString(); break; case RECORD: if(defaultJson.isObject()) { boolean isGood = true; for(Field field: schema.getFields()) { JsonNode jsonNode = defaultJson.get(field.name()); if(jsonNode == null) { jsonNode = field.defaultValue(); if(jsonNode == null) { isGood = false; break; } } String val = checkDefaultJson(jsonNode, field.schema()); if(val != null) { isGood = false; break; } } if(isGood) { break; } } expectedVal = "record of type " + schema.toString(); break; case UNION: // Avro spec states we only need to match with the first item expectedVal = checkDefaultJson(defaultJson, schema.getTypes().get(0)); break; } return expectedVal; } /** * Given an AVRO serializer definition, validates if all the avro schemas * are valid i.e parseable. * * @param avroSerDef */ public static void validateAllAvroSchemas(SerializerDefinition avroSerDef) { Map<Integer, String> schemaVersions = avroSerDef.getAllSchemaInfoVersions(); if(schemaVersions.size() < 1) { throw new VoldemortException("No schema specified"); } for(Map.Entry<Integer, String> entry: schemaVersions.entrySet()) { Integer schemaVersionNumber = entry.getKey(); String schemaStr = entry.getValue(); try { Schema.parse(schemaStr); } catch(Exception e) { throw new VoldemortException("Unable to parse Avro schema version :" + schemaVersionNumber + ", schema string :" + schemaStr); } } } public static void checkSchemaCompatibility(SerializerDefinition serDef) { Map<Integer, String> schemaVersions = serDef.getAllSchemaInfoVersions(); Iterator schemaIterator = schemaVersions.entrySet().iterator(); Schema firstSchema = null; Schema secondSchema = null; String firstSchemaStr; String secondSchemaStr; if(!schemaIterator.hasNext()) throw new VoldemortException("No schema specified"); Map.Entry schemaPair = (Map.Entry) schemaIterator.next(); firstSchemaStr = (String) schemaPair.getValue(); while(schemaIterator.hasNext()) { schemaPair = (Map.Entry) schemaIterator.next(); secondSchemaStr = (String) schemaPair.getValue(); Schema oldSchema = Schema.parse(firstSchemaStr); Schema newSchema = Schema.parse(secondSchemaStr); List<Message> messages = SchemaEvolutionValidator.checkBackwardCompatibility(oldSchema, newSchema, oldSchema.getName()); Level maxLevel = Level.ALL; for(Message message: messages) { System.out.println(message.getLevel() + ": " + message.getMessage()); if(message.getLevel().isGreaterOrEqual(maxLevel)) { maxLevel = message.getLevel(); } } if(maxLevel.isGreaterOrEqual(Level.ERROR)) { System.out.println(Level.ERROR + ": The schema is not backward compatible. New clients will not be able to read existing data."); throw new VoldemortException(" The schema is not backward compatible. New clients will not be able to read existing data."); } else if(maxLevel.isGreaterOrEqual(Level.WARN)) { System.out.println(Level.WARN + ": The schema is partially backward compatible, but old clients will not be able to read data serialized in the new format."); throw new VoldemortException("The schema is partially backward compatible, but old clients will not be able to read data serialized in the new format."); } else { System.out.println(Level.INFO + ": The schema is backward compatible. Old and new clients will be able to read records serialized by one another."); } firstSchemaStr = secondSchemaStr; } } }