| // Copyright 2016 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.remote; | |
| import static com.google.common.base.Preconditions.checkNotNull; | |
| import static com.google.common.base.Preconditions.checkState; | |
| import static com.google.common.base.Throwables.throwIfInstanceOf; | |
| import static com.google.common.base.Throwables.throwIfUnchecked; | |
| import static java.lang.Math.min; | |
| import com.google.common.annotations.VisibleForTesting; | |
| import com.google.common.io.ByteStreams; | |
| import com.google.devtools.build.lib.remote.common.RemoteCacheClient.Blob; | |
| import com.google.devtools.build.lib.remote.zstd.ZstdCompressingInputStream; | |
| import com.google.errorprone.annotations.CanIgnoreReturnValue; | |
| import com.google.protobuf.ByteString; | |
| import java.io.ByteArrayInputStream; | |
| import java.io.IOException; | |
| import java.io.InputStream; | |
| import java.io.PushbackInputStream; | |
| import java.util.NoSuchElementException; | |
| import java.util.Objects; | |
| /** | |
| * Splits a data source into one or more {@link Chunk}s of at most {@code chunkSize} bytes. | |
| * | |
| * <p>After a data source has been fully consumed, that is until {@link #hasNext()} returns {@code | |
| * false}, the chunker closes the underlying data source (i.e. file) itself. However, in case of | |
| * error or when a data source does not get fully consumed, a user must call {@link #reset()} | |
| * manually. | |
| * | |
| * <p>This class should not be extended - it's only non-final for testing. | |
| */ | |
| public class Chunker implements AutoCloseable { | |
| private static int defaultChunkSize = 1024 * 16; | |
| /** This method must only be called in tests! */ | |
| @VisibleForTesting | |
| static void setDefaultChunkSizeForTesting(int value) { | |
| defaultChunkSize = value; | |
| } | |
| static int getDefaultChunkSize() { | |
| return defaultChunkSize; | |
| } | |
| public boolean isCompressed() { | |
| return compressed; | |
| } | |
| /** A piece of a byte[] blob. */ | |
| public static final class Chunk { | |
| private final long offset; | |
| private final ByteString data; | |
| private Chunk(ByteString data, long offset) { | |
| this.data = data; | |
| this.offset = offset; | |
| } | |
| public long getOffset() { | |
| return offset; | |
| } | |
| public ByteString getData() { | |
| return data; | |
| } | |
| @Override | |
| public boolean equals(Object o) { | |
| if (o == this) { | |
| return true; | |
| } | |
| if (!(o instanceof Chunk other)) { | |
| return false; | |
| } | |
| return other.offset == offset && other.data.equals(data); | |
| } | |
| @Override | |
| public int hashCode() { | |
| return Objects.hash(offset, data); | |
| } | |
| } | |
| private final Blob blob; | |
| private final long uncompressedSize; | |
| private final int chunkSize; | |
| private final Chunk emptyChunk; | |
| @VisibleForTesting protected ChunkerInputStream data; | |
| private long offset; | |
| private byte[] chunkCache; | |
| private final boolean compressed; | |
| // Set to true on the first call to next(). This is so that the Chunker can open its data source | |
| // lazily on the first call to next(), as opposed to opening it in the constructor or on reset(). | |
| private boolean initialized; | |
| Chunker(Blob blob, long uncompressedSize, int chunkSize, boolean compressed) { | |
| this.blob = checkNotNull(blob); | |
| this.uncompressedSize = uncompressedSize; | |
| this.chunkSize = chunkSize; | |
| this.emptyChunk = new Chunk(ByteString.EMPTY, 0); | |
| this.compressed = compressed; | |
| } | |
| public long getOffset() { | |
| return offset; | |
| } | |
| public long getUncompressedSize() { | |
| return uncompressedSize; | |
| } | |
| /** | |
| * Reset the {@link Chunker} state to when it was newly constructed. | |
| * | |
| * <p>Closes any open resources (file handles, ...). | |
| */ | |
| public void reset() throws IOException { | |
| closeInput(); | |
| offset = 0; | |
| initialized = false; | |
| } | |
| /** | |
| * Seek to an offset in the source stream. | |
| * | |
| * <p>May close and reopen resources in order to seek to an earlier offset. | |
| * | |
| * @param toOffset the offset from beginning of the source stream. If the source stream is | |
| * compressed, it refers to the offset in the uncompressed form to align with `write_offset` | |
| * in REAPI. | |
| */ | |
| public void seek(long toOffset) throws IOException { | |
| // For compressed stream, we need to reinitialize the stream since the offset refers to the | |
| // uncompressed form. | |
| if (initialized && uncompressedSize > 0 && toOffset >= offset && !compressed) { | |
| ByteStreams.skipFully(data, toOffset - offset); | |
| offset = toOffset; | |
| } else { | |
| reset(); | |
| initialize(toOffset); | |
| } | |
| if (uncompressedSize > 0 && data.finished()) { | |
| closeInput(); | |
| } | |
| } | |
| /** | |
| * Returns {@code true} if a subsequent call to {@link #next()} returns a {@link Chunk} object; | |
| */ | |
| public boolean hasNext() { | |
| return data != null || !initialized; | |
| } | |
| /** Closes the input stream and reset chunk cache */ | |
| private void closeInput() throws IOException { | |
| if (data != null) { | |
| data.close(); | |
| data = null; | |
| } | |
| chunkCache = null; | |
| } | |
| @Override | |
| public void close() throws IOException { | |
| reset(); | |
| } | |
| /** Attempts reading at most a full chunk and stores it in the chunkCache buffer */ | |
| private int read() throws IOException { | |
| int count = 0; | |
| while (count < chunkCache.length) { | |
| int c = data.read(chunkCache, count, chunkCache.length - count); | |
| if (c < 0) { | |
| break; | |
| } | |
| count += c; | |
| } | |
| return count; | |
| } | |
| /** | |
| * Returns the next {@link Chunk} or throws a {@link NoSuchElementException} if no data is left. | |
| * | |
| * <p>Always call {@link #hasNext()} before calling this method. | |
| * | |
| * <p>Zero byte inputs are treated special. Instead of throwing a {@link NoSuchElementException} | |
| * on the first call to {@link #next()}, a {@link Chunk} with an empty {@link ByteString} is | |
| * returned. | |
| */ | |
| public Chunk next() throws IOException { | |
| if (!hasNext()) { | |
| throw new NoSuchElementException(); | |
| } | |
| maybeInitialize(); | |
| if (uncompressedSize == 0) { | |
| closeInput(); | |
| return emptyChunk; | |
| } | |
| if (data.finished()) { | |
| chunkCache = null; | |
| data = null; | |
| throw new NoSuchElementException(); | |
| } | |
| if (chunkCache == null) { | |
| // If the output is compressed we can't know how many bytes there are yet to read, | |
| // so we allocate the whole chunkSize, otherwise we try to compute the smallest possible value | |
| // The cast to int is safe, because the return value is capped at chunkSize. | |
| int cacheSize = compressed ? chunkSize : (int) min(uncompressedSize - getOffset(), chunkSize); | |
| // Lazily allocate it in order to save memory on small data. | |
| // 1) bytesToRead < chunkSize: There will only ever be one next() call. | |
| // 2) bytesToRead == chunkSize: chunkCache will be set to its biggest possible value. | |
| // 3) bytestoRead > chunkSize: Not possible, due to Math.min above. | |
| chunkCache = new byte[cacheSize]; | |
| } | |
| long offsetBefore = offset; | |
| int bytesRead = read(); | |
| ByteString blob = ByteString.copyFrom(chunkCache, 0, bytesRead); | |
| // This has to happen after actualSize has been updated | |
| // or the guard in getActualSize won't work. | |
| offset += bytesRead; | |
| if (data.finished()) { | |
| closeInput(); | |
| } | |
| return new Chunk(blob, offsetBefore); | |
| } | |
| private void maybeInitialize() throws IOException { | |
| if (initialized) { | |
| return; | |
| } | |
| initialize(0); | |
| } | |
| private void initialize(long srcPos) throws IOException { | |
| checkState(!initialized); | |
| checkState(data == null); | |
| checkState(offset == 0); | |
| checkState(chunkCache == null); | |
| try { | |
| var src = blob.get(); | |
| ByteStreams.skipFully(src, srcPos); | |
| data = | |
| compressed | |
| ? new ChunkerInputStream(new ZstdCompressingInputStream(src)) | |
| : new ChunkerInputStream(src); | |
| } catch (RuntimeException e) { | |
| if (e.getCause() != null) { | |
| throwIfInstanceOf(e.getCause(), IOException.class); | |
| throwIfUnchecked(e.getCause()); | |
| } | |
| throw e; | |
| } | |
| offset = srcPos; | |
| initialized = true; | |
| } | |
| public static Builder builder() { | |
| return new Builder(); | |
| } | |
| /** Builder class for the Chunker */ | |
| public static class Builder { | |
| private int chunkSize = getDefaultChunkSize(); | |
| protected long size; | |
| private boolean compressed; | |
| protected Blob inputStream; | |
| @CanIgnoreReturnValue | |
| public Builder setInput(long size, Blob in) { | |
| checkState(inputStream == null); | |
| checkNotNull(in); | |
| this.size = size; | |
| inputStream = in; | |
| return this; | |
| } | |
| @CanIgnoreReturnValue | |
| @VisibleForTesting | |
| public Builder setInput(byte[] data) { | |
| checkState(inputStream == null); | |
| size = data.length; | |
| this.inputStream = () -> new ByteArrayInputStream(data); | |
| return this; | |
| } | |
| @CanIgnoreReturnValue | |
| public Builder setCompressed(boolean compressed) { | |
| this.compressed = compressed; | |
| return this; | |
| } | |
| @CanIgnoreReturnValue | |
| public Builder setChunkSize(int chunkSize) { | |
| this.chunkSize = chunkSize; | |
| return this; | |
| } | |
| public Chunker build() { | |
| checkNotNull(inputStream); | |
| return new Chunker(inputStream, size, chunkSize, compressed); | |
| } | |
| } | |
| static class ChunkerInputStream extends PushbackInputStream { | |
| ChunkerInputStream(InputStream in) { | |
| super(in); | |
| } | |
| public boolean finished() throws IOException { | |
| int c = super.read(); | |
| if (c == -1) { | |
| return true; | |
| } | |
| super.unread(c); | |
| return false; | |
| } | |
| } | |
| } |