/** * Licensed to the Apache Software Foundation (ASF) under one * or more contributor license agreements. See the NOTICE file * distributed with this work for additional information * regarding copyright ownership. The ASF licenses this file * to you 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 org.apache.avro.hadoop.file; import java.io.Closeable; import java.io.IOException; import java.io.OutputStream; import java.util.Iterator; import java.util.Map; import java.util.NavigableMap; import java.util.TreeMap; import org.apache.avro.Schema; import org.apache.avro.generic.GenericData; import org.apache.avro.generic.GenericRecord; import org.apache.avro.specific.SpecificData; import org.apache.avro.io.DatumReader; import org.apache.avro.io.DatumWriter; import org.apache.avro.file.CodecFactory; import org.apache.avro.file.DataFileReader; import org.apache.avro.file.DataFileWriter; import org.apache.avro.hadoop.util.AvroCharSequenceComparator; import org.apache.avro.hadoop.io.AvroKeyValue; import org.apache.avro.mapred.FsInput; import org.apache.hadoop.conf.Configuration; import org.apache.hadoop.fs.FileSystem; import org.apache.hadoop.fs.Path; import org.slf4j.Logger; import org.slf4j.LoggerFactory; /** * A SortedKeyValueFile is an indexed Avro container file of KeyValue records * sorted by key. * * <p>The SortedKeyValueFile is a directory with two files, named 'data' and * 'index'. The 'data' file is an ordinary Avro container file with * records. Each record has exactly two fields, 'key' and 'value'. The keys are * sorted lexicographically. The 'index' file is a small Avro container file * mapping keys in the 'data' file to their byte positions. The index file is * intended to fit in memory, so it should remain small. There is one entry in * the index file for each data block in the Avro container file.</p> * * <p>SortedKeyValueFile is to Avro container file as MapFile is to * SequenceFile.</p> */ public class SortedKeyValueFile { private static final Logger LOG = LoggerFactory.getLogger(SortedKeyValueFile.class); /** The name of the data file within the SortedKeyValueFile directory. */ public static final String DATA_FILENAME = "data"; /** The name of the index file within the SortedKeyValueFile directory. */ public static final String INDEX_FILENAME = "index"; /** * Reads a SortedKeyValueFile by loading the key index into memory. * * <p>When doing a lookup, this reader finds the correct block in the data file using * the key index. It performs a single disk seek to the block and loads the entire block * into memory. The block is scanned until the key is found or is determined not to * exist.</p> * * @param <K> The key type. * @param <V> The value type. */ public static class Reader<K, V> implements Closeable, Iterable<AvroKeyValue<K, V>> { /** The index from key to its byte offset into the data file. */ private final NavigableMap<K, Long> mIndex; /** The reader for the data file. */ private final DataFileReader<GenericRecord> mDataFileReader; /** The key schema for the data file. */ private final Schema mKeySchema; /** The model for the data. */ private GenericData model; /** A class to encapsulate the options of a Reader. */ public static class Options { /** The configuration. */ private Configuration mConf; /** The path to the SortedKeyValueFile to read. */ private Path mPath; /** The reader schema for the key. */ private Schema mKeySchema; /** The reader schema for the value. */ private Schema mValueSchema; /** The model for the data. */ private GenericData model = SpecificData.get(); /** * Sets the configuration. * * @param conf The configuration. * @return This options instance. */ public Options withConfiguration(Configuration conf) { mConf = conf; return this; } /** * Gets the configuration. * * @return The configuration. */ public Configuration getConfiguration() { return mConf; } /** * Sets the input path. * * @param path The input path. * @return This options instance. */ public Options withPath(Path path) { mPath = path; return this; } /** * Gets the input path. * * @return The input path. */ public Path getPath() { return mPath; } /** * Sets the reader schema for the key. * * @param keySchema The reader schema for the key. * @return This options instance. */ public Options withKeySchema(Schema keySchema) { mKeySchema = keySchema; return this; } /** * Gets the reader schema for the key. * * @return The reader schema for the key. */ public Schema getKeySchema() { return mKeySchema; } /** * Sets the reader schema for the value. * * @param valueSchema The reader schema for the value. * @return This options instance. */ public Options withValueSchema(Schema valueSchema) { mValueSchema = valueSchema; return this; } /** * Gets the reader schema for the value. * * @return The reader schema for the value. */ public Schema getValueSchema() { return mValueSchema; } /** Set the data model. */ public Options withDataModel(GenericData model) { this.model = model; return this; } /** Return the data model. */ public GenericData getDataModel() { return model; } } /** * Constructs a reader. * * @param options The options. * @throws IOException If there is an error. */ public Reader(Options options) throws IOException { mKeySchema = options.getKeySchema(); this.model = options.getDataModel(); // Load the whole index file into memory. Path indexFilePath = new Path(options.getPath(), INDEX_FILENAME); LOG.debug("Loading the index from " + indexFilePath); mIndex = loadIndexFile(options.getConfiguration(), indexFilePath, mKeySchema); // Open the data file. Path dataFilePath = new Path(options.getPath(), DATA_FILENAME); LOG.debug("Loading the data file " + dataFilePath); Schema recordSchema = AvroKeyValue.getSchema(mKeySchema, options.getValueSchema()); DatumReader<GenericRecord> datumReader = model.createDatumReader(recordSchema); mDataFileReader = new DataFileReader<GenericRecord> (new FsInput(dataFilePath, options.getConfiguration()), datumReader); } /** * Gets the first value associated with a given key, or null if it is not found. * * <p>This method will move the current position in the file to the record immediately * following the requested key.</p> * * @param key The key to look up. * @return The value associated with the key, or null if not found. * @throws IOException If there is an error. */ public V get(K key) throws IOException { // Look up the entry in the index. LOG.debug("Looking up key " + key + " in the index."); Map.Entry<K, Long> indexEntry = mIndex.floorEntry(key); if (null == indexEntry) { LOG.debug("Key " + key + " was not found in the index (it is before the first entry)"); return null; } LOG.debug("Key was found in the index, seeking to syncpoint " + indexEntry.getValue()); // Seek to the data block that would contain the entry. mDataFileReader.seek(indexEntry.getValue()); // Scan from this position of the file until we find it or pass it. Iterator<AvroKeyValue<K, V>> iter = iterator(); while (iter.hasNext()) { AvroKeyValue<K, V> record = iter.next(); int comparison = model.compare(record.getKey(), key, mKeySchema); if (0 == comparison) { // We've found it! LOG.debug("Found record for key " + key); return record.getValue(); } if (comparison > 0) { // We've passed it. LOG.debug("Searched beyond the point where key " + key + " would appear in the file."); return null; } } // We've reached the end of the file. LOG.debug("Searched to the end of the file but did not find key " + key); return null; } /** * Returns an iterator starting at the current position in the file. * * <p>Use the get() method to move the current position.</p> * * <p>Note that this iterator is shared with other clients of the file; it does not * contain a separate pointer into the file.</p> * * @return An iterator. */ public Iterator<AvroKeyValue<K, V>> iterator() { return new AvroKeyValue.Iterator<K, V>(mDataFileReader.iterator()); } /** {@inheritDoc} */ @Override public void close() throws IOException { mDataFileReader.close(); } /** * Loads an index file into an in-memory map, from key to file offset in bytes. * * @param conf The configuration. * @param path The path to the index file. * @param keySchema The reader schema for the key. * @throws IOException If there is an error. */ private <K> NavigableMap<K, Long> loadIndexFile( Configuration conf, Path path, Schema keySchema) throws IOException { DatumReader<GenericRecord> datumReader = model.createDatumReader( AvroKeyValue.getSchema(keySchema, Schema.create(Schema.Type.LONG))); DataFileReader<GenericRecord> fileReader = new DataFileReader<GenericRecord>( new FsInput(path, conf), datumReader); NavigableMap<K, Long> index; if (Schema.create(Schema.Type.STRING).equals(keySchema)) { // Because Avro STRING types are mapped to the Java CharSequence class that does not // mandate the implementation of Comparable, we need to specify a special // CharSequence comparator if the key type is a string. This hack only fixes the // problem for primitive string types. If, for example, you tried to use a record // type as the key, any string fields inside of it would not be compared correctly // against java.lang.Strings. index = new TreeMap<K, Long>(new AvroCharSequenceComparator<K>()); } else { index = new TreeMap<K, Long>(); } try { for (GenericRecord genericRecord : fileReader) { AvroKeyValue<K, Long> indexRecord = new AvroKeyValue<K, Long>(genericRecord); index.put(indexRecord.getKey(), indexRecord.getValue()); } } finally { fileReader.close(); } return index; } } /** * Writes a SortedKeyValueFile. * * @param <K> The key type. * @param <V> The value type. */ public static class Writer<K, V> implements Closeable { /** The key schema. */ private final Schema mKeySchema; /** The value schema. */ private final Schema mValueSchema; /** The schema of the data file records. */ private final Schema mRecordSchema; /** The schema of the index file records. */ private final Schema mIndexSchema; /** The model for the data. */ private GenericData model; /** The writer for the data file. */ private final DataFileWriter<GenericRecord> mDataFileWriter; /** The writer for the index file. */ private final DataFileWriter<GenericRecord> mIndexFileWriter; /** We store an indexed key for every mIndexInterval records written to the data file. */ private final int mIndexInterval; /** The number of records written to the file so far. */ private long mRecordsWritten; /** The most recent key that was appended to the file, or null. */ private K mPreviousKey; /** A class to encapsulate the various options of a SortedKeyValueFile.Writer. */ public static class Options { /** The key schema. */ private Schema mKeySchema; /** The value schema. */ private Schema mValueSchema; /** The configuration. */ private Configuration mConf; /** The path to the output file. */ private Path mPath; /** The number of records between indexed entries. */ private int mIndexInterval = 128; /** The model for the data. */ private GenericData model = SpecificData.get(); /** The compression codec for the data. */ private CodecFactory codec = CodecFactory.nullCodec(); /** * Sets the key schema. * * @param keySchema The key schema. * @return This options instance. */ public Options withKeySchema(Schema keySchema) { mKeySchema = keySchema; return this; } /** * Gets the key schema. * * @return The key schema. */ public Schema getKeySchema() { return mKeySchema; } /** * Sets the value schema. * * @param valueSchema The value schema. * @return This options instance. */ public Options withValueSchema(Schema valueSchema) { mValueSchema = valueSchema; return this; } /** * Gets the value schema. * * @return The value schema. */ public Schema getValueSchema() { return mValueSchema; } /** * Sets the configuration. * * @param conf The configuration. * @return This options instance. */ public Options withConfiguration(Configuration conf) { mConf = conf; return this; } /** * Gets the configuration. * * @return The configuration. */ public Configuration getConfiguration() { return mConf; } /** * Sets the output path. * * @param path The output path. * @return This options instance. */ public Options withPath(Path path) { mPath = path; return this; } /** * Gets the output path. * * @return The output path. */ public Path getPath() { return mPath; } /** * Sets the index interval. * * <p>If the index inverval is N, then every N records will be indexed into the * index file.</p> * * @param indexInterval The index interval. * @return This options instance. */ public Options withIndexInterval(int indexInterval) { mIndexInterval = indexInterval; return this; } /** * Gets the index interval. * * @return The index interval. */ public int getIndexInterval() { return mIndexInterval; } /** Set the data model. */ public Options withDataModel(GenericData model) { this.model = model; return this; } /** Return the data model. */ public GenericData getDataModel() { return model; } /** Set the compression codec. */ public Options withCodec(String codec) { this.codec = CodecFactory.fromString(codec); return this; } /** Set the compression codec. */ public Options withCodec(CodecFactory codec) { this.codec = codec; return this; } /** Return the compression codec. */ public CodecFactory getCodec() { return this.codec; } } /** * Creates a writer for a new file. * * @param options The options. * @throws IOException If there is an error. */ public Writer(Options options) throws IOException { this.model = options.getDataModel(); if (null == options.getConfiguration()) { throw new IllegalArgumentException("Configuration may not be null"); } FileSystem fileSystem = options.getPath().getFileSystem(options.getConfiguration()); // Save the key and value schemas. mKeySchema = options.getKeySchema(); if (null == mKeySchema) { throw new IllegalArgumentException("Key schema may not be null"); } mValueSchema = options.getValueSchema(); if (null == mValueSchema) { throw new IllegalArgumentException("Value schema may not be null"); } // Save the index interval. mIndexInterval = options.getIndexInterval(); // Create the directory. if (!fileSystem.mkdirs(options.getPath())) { throw new IOException( "Unable to create directory for SortedKeyValueFile: " + options.getPath()); } LOG.debug("Created directory " + options.getPath()); // Open a writer for the data file. Path dataFilePath = new Path(options.getPath(), DATA_FILENAME); LOG.debug("Creating writer for avro data file: " + dataFilePath); mRecordSchema = AvroKeyValue.getSchema(mKeySchema, mValueSchema); DatumWriter<GenericRecord> datumWriter = model.createDatumWriter(mRecordSchema); OutputStream dataOutputStream = fileSystem.create(dataFilePath); mDataFileWriter = new DataFileWriter<GenericRecord>(datumWriter) .setSyncInterval(1 << 20) // Set the auto-sync interval sufficiently large, since // we will manually sync every mIndexInterval records. .setCodec(options.getCodec()) .create(mRecordSchema, dataOutputStream); // Open a writer for the index file. Path indexFilePath = new Path(options.getPath(), INDEX_FILENAME); LOG.debug("Creating writer for avro index file: " + indexFilePath); mIndexSchema = AvroKeyValue.getSchema(mKeySchema, Schema.create(Schema.Type.LONG)); DatumWriter<GenericRecord> indexWriter = model.createDatumWriter(mIndexSchema); OutputStream indexOutputStream = fileSystem.create(indexFilePath); mIndexFileWriter = new DataFileWriter<GenericRecord>(indexWriter) .create(mIndexSchema, indexOutputStream); } /** * Appends a record to the SortedKeyValueFile. * * @param key The key. * @param value The value. * @throws IOException If there is an error. */ public void append(K key, V value) throws IOException { // Make sure the keys are inserted in sorted order. if (null != mPreviousKey && model.compare(key, mPreviousKey, mKeySchema) < 0) { throw new IllegalArgumentException("Records must be inserted in sorted key order." + " Attempted to insert key " + key + " after " + mPreviousKey + "."); } mPreviousKey = model.deepCopy(mKeySchema, key); // Construct the data record. AvroKeyValue<K, V> dataRecord = new AvroKeyValue<K, V>(new GenericData.Record(mRecordSchema)); dataRecord.setKey(key); dataRecord.setValue(value); // Index it if necessary. if (0 == mRecordsWritten++ % mIndexInterval) { // Force a sync to the data file writer, which closes the current data block (if // nonempty) and reports the current position in the file. long position = mDataFileWriter.sync(); // Construct the record to put in the index. AvroKeyValue<K, Long> indexRecord = new AvroKeyValue<K, Long>(new GenericData.Record(mIndexSchema)); indexRecord.setKey(key); indexRecord.setValue(position); mIndexFileWriter.append(indexRecord.get()); } // Write it to the data file. mDataFileWriter.append(dataRecord.get()); } /** {@inheritDoc} */ @Override public void close() throws IOException { mIndexFileWriter.close(); mDataFileWriter.close(); } } }