blob: 78b62376f1bb2d9d5f6b04e158778b1ee660687f [file] [log] [blame]
// Copyright 2025 The Bazel Authors. All rights reserved.
//
// 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 com.google.devtools.build.lib.util;
import com.google.devtools.build.lib.util.io.RecordOutputStream;
import com.google.devtools.build.lib.vfs.Path;
import java.io.BufferedOutputStream;
import java.io.ByteArrayInputStream;
import java.io.DataInput;
import java.io.DataInputStream;
import java.io.DataOutput;
import java.io.DataOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import javax.annotation.Nullable;
/** Converts map entries between their in-memory and on-disk representations. */
public abstract class MapCodec<K, V> {
/** Exception thrown when persisted data read back from disk is in an incompatible format. */
public static final class IncompatibleFormatException extends IOException {
public IncompatibleFormatException(String message) {
super(message);
}
}
private static final int MAGIC = 0x20071105;
private static final int MIN_MAPFILE_SIZE = 16;
private static final int MAX_ARRAY_SIZE = Integer.MAX_VALUE - 8;
private static final int ENTRY_MAGIC = 0xfe;
/** Reads a key from a {@link DataInput}. */
protected abstract K readKey(DataInput in) throws IOException;
/** Reads a value from a {@link DataInput}. */
protected abstract V readValue(DataInput in) throws IOException;
/** Writes a key into a {@link DataOutput}. */
protected abstract void writeKey(K key, DataOutput out) throws IOException;
/** Writes a value into a {@link DataOutput}. */
protected abstract void writeValue(V value, DataOutput out) throws IOException;
/**
* A key/value pair representing the presence or absence of a map entry.
*
* @param key the entry key
* @param value the entry value, or null if the entry is absent
*/
public record Entry<K, V>(K key, @Nullable V value) {}
/**
* Creates a new reader.
*
* <p>The file contents are eagerly read into memory, under the assumption that they will be
* iterated to completion in short order.
*
* @param path the path to the file to read
* @param version the expected version number
* @throws IncompatibleFormatException if the on-disk data is in an incompatible format
* @throws IOException if data corruption is detected or some other I/O error occurs
*/
public Reader createReader(Path path, long version) throws IOException {
long size = path.getFileSize();
if (size < MIN_MAPFILE_SIZE) {
throw new IncompatibleFormatException("%s is too short: %s bytes".formatted(path, size));
}
if (size > MAX_ARRAY_SIZE) {
throw new IncompatibleFormatException("%s is too long: %s bytes".formatted(path, size));
}
// Read the whole file upfront as a performance optimization (minimize syscalls).
byte[] bytes;
try (InputStream in = path.getInputStream()) {
bytes = in.readAllBytes();
}
DataInputStream in = new DataInputStream(new ByteArrayInputStream(bytes));
if (in.readLong() != MAGIC) {
// Not a PersistentMap.
throw new IncompatibleFormatException("Bad magic number");
}
long persistedVersion = in.readLong();
if (persistedVersion != version) {
// Incompatible version.
throw new IncompatibleFormatException(
"Incompatible version: want %d, got %d".formatted(version, persistedVersion));
}
return new Reader(in);
}
/** Reads key/value pairs from a {@link DataInputStream}. */
public final class Reader implements AutoCloseable {
private final DataInputStream in;
private Reader(DataInputStream in) {
this.in = in;
}
/** Closes the reader, releasing associated resources and rendering it unusable. */
@Override
public void close() throws IOException {
in.close();
}
/**
* Reads an {@link Entry}.
*
* @return the entry, or null if there are no more entries.
*/
@Nullable
public Entry<K, V> readEntry() throws IOException {
if (in.available() == 0) {
return null;
}
if (in.readUnsignedByte() != ENTRY_MAGIC) {
throw new IOException("Corrupted entry separator");
}
K key = readKey(in);
boolean hasValue = in.readBoolean();
V value = hasValue ? readValue(in) : null;
return new Entry<>(key, value);
}
}
/**
* Creates a new writer.
*
* @param path the path to the file to write
* @param version the version number to write
* @param overwrite whether to overwrite an existing file instead of appending to it
* @throws IOException if an I/O error occurs
*/
public Writer createWriter(Path path, int version, boolean overwrite) throws IOException {
boolean append = !overwrite && path.exists();
RecordOutputStream recordOut = new RecordOutputStream(path.getOutputStream(append));
DataOutputStream dataOut = new DataOutputStream(recordOut);
if (!append) {
dataOut.writeLong(MAGIC);
dataOut.writeLong(version);
recordOut.finishRecord();
}
return new Writer(recordOut, dataOut);
}
/**
* Writes key/value pairs to a {@link DataOutputStream} backed by a {@link RecordOutputStream}.
*
* <p>In a best-effort attempt to prevent data corruption in the event of an abrupt exit, use a
* {@link RecordOutputStream} instead of a {@link BufferedOutputStream} to ensure that only
* complete records are ever written to the underlying unbuffered {@link OutputStream}. While this
* can still be defeated by partial writes, experiments suggest they're rather unlikely for small
* buffer sizes.
*/
public final class Writer implements AutoCloseable {
private final RecordOutputStream recordOut;
private final DataOutputStream dataOut;
private Writer(RecordOutputStream recordOut, DataOutputStream dataOut) {
this.recordOut = recordOut;
this.dataOut = dataOut;
}
/** Flushes the writer, forcing any pending writes to be written to disk. */
public void flush() throws IOException {
recordOut.flush();
}
/** Closes the writer, releasing associated resources and rendering it unusable. */
@Override
public void close() throws IOException {
recordOut.close();
}
/**
* Writes a key/value pair.
*
* @param key the key to write.
* @param value the value to write, or null to write a tombstone.
*/
public void writeEntry(K key, @Nullable V value) throws IOException {
dataOut.writeByte(ENTRY_MAGIC);
writeKey(key, dataOut);
boolean hasValue = value != null;
dataOut.writeBoolean(hasValue);
if (hasValue) {
writeValue(value, dataOut);
}
recordOut.finishRecord();
}
}
}