diff --git a/hadoop-ozone/freon/src/main/java/org/apache/hadoop/ozone/freon/HadoopFsReadWriteValidator.java b/hadoop-ozone/freon/src/main/java/org/apache/hadoop/ozone/freon/HadoopFsReadWriteValidator.java index 86ab8192d6b9..4f393bc29fe2 100644 --- a/hadoop-ozone/freon/src/main/java/org/apache/hadoop/ozone/freon/HadoopFsReadWriteValidator.java +++ b/hadoop-ozone/freon/src/main/java/org/apache/hadoop/ozone/freon/HadoopFsReadWriteValidator.java @@ -49,6 +49,12 @@ * concurrent load, including in time-based (--duration) runs where paths are * reused. *

+ * --read-percent tunes the mix: every operation of the run independently draws + * whether to write a file or to read one back, so a run can be made read-heavy + * or write-heavy without changing what any one read validates. -n therefore + * counts operations of both kinds rather than writes alone, and the split holds + * on average rather than exactly. + *

* CRC32 keeps the validation off the critical path of the measured throughput. * Successive writes of a path differ in the marker only, and markers less than * 2^32 apart never share a CRC32, so a stale read is always detected. @@ -89,6 +95,15 @@ public class HadoopFsReadWriteValidator extends HadoopBaseFreonGenerator defaultValue = "10000") private int maxFilesPerThread; + @Option(names = {"--read-percent"}, + description = "Percentage of the operations that read a file back and validate it instead of writing one. " + + "0 only writes, 50 pairs a read with every write on average, and 90 makes the run read-heavy. Every " + + "operation draws on its own, so the split holds over a run rather than on any particular pair of " + + "operations. A read validates a file the thread wrote, picked at random; a thread that has written " + + "nothing yet has nothing to read and writes instead.", + defaultValue = "50") + private double readPercent; + private ContentGenerator contentGenerator; private Timer writeTimer; @@ -113,6 +128,11 @@ public Void call() throws Exception { throw new IllegalArgumentException( "--max-files-per-thread must be positive"); } + // negated so that NaN, which fails every comparison, is rejected too + if (!(readPercent >= 0 && readPercent <= 100)) { + throw new IllegalArgumentException( + "--read-percent must be between 0 and 100"); + } super.init(); @@ -129,7 +149,7 @@ public Void call() throws Exception { writeTimer = getMetrics().timer("file-write"); readTimer = getMetrics().timer("file-read-validate"); - runTests(this::writeAndValidate); + runTests(this::readOrWrite); } finally { org.apache.hadoop.hdds.utils.IOUtils.closeQuietly(fileSystem); } @@ -137,11 +157,29 @@ public Void call() throws Exception { return null; } - private void writeAndValidate(long counter) throws Exception { + /** + * One operation of the run, a read or a write, drawn per --read-percent. A + * thread that has written nothing yet has nothing to read back and writes. + */ + private void readOrWrite(long counter) throws Exception { ThreadHistory history = threadHistory.get(); + if (history.isEmpty() || !readsNext()) { + writeAndRecord(history, counter); + } else { + validateRandomFile(history); + } + } + + /** Draws whether this operation reads, see --read-percent. */ + private boolean readsNext() { + return ThreadLocalRandom.current().nextDouble(100) < readPercent; + } + + private void writeAndRecord(ThreadHistory history, long counter) + throws Exception { + long marker = history.nextMarker(); long fileId = counter % maxFilesPerThread; Path file = objectPath(fileId); - long marker = history.nextMarker(); long checksum; try { @@ -154,7 +192,14 @@ private void writeAndValidate(long counter) throws Exception { throw e; } history.record(fileId, checksum); + } + /** + * Read back one of the files this thread wrote, picked at random, and verify + * that its content still matches the checksum of the latest write of that + * path. + */ + private void validateRandomFile(ThreadHistory history) throws Exception { long readId = history.randomFileId(); Path target = objectPath(readId); long expected = history.checksumOf(readId); @@ -168,10 +213,13 @@ private void writeAndValidate(long counter) throws Exception { } /** - * Path of the file for the given counter. The thread sequence id is part of - * the path so each worker owns a private namespace; paths are reused once a - * thread has written --max-files-per-thread of them, and this keeps one - * thread from overwriting a file another thread is reading back. + * Path of the file for the given id. The thread sequence id is part of the + * path so each worker owns a private namespace, which keeps one thread from + * overwriting a file another thread is reading back. The id comes from the + * task counter, which the framework recycles within -n, so a time-based run + * writes over its paths rather than growing without bound; the modulo caps a + * count-based run at --max-files-per-thread distinct ids, and with it the + * checksums a thread has to remember. */ private Path objectPath(long fileId) { return new Path(getRootPath() + "/" + generateObjectName(fileId) @@ -252,6 +300,11 @@ private void forget(long fileId) { } } + /** Whether the thread has written anything it could read back yet. */ + private boolean isEmpty() { + return fileIds.isEmpty(); + } + private long randomFileId() { return fileIds.get(ThreadLocalRandom.current().nextInt(fileIds.size())); } diff --git a/hadoop-ozone/freon/src/test/java/org/apache/hadoop/ozone/freon/TestHadoopFsReadPercent.java b/hadoop-ozone/freon/src/test/java/org/apache/hadoop/ozone/freon/TestHadoopFsReadPercent.java new file mode 100644 index 000000000000..3ab3bfacde54 --- /dev/null +++ b/hadoop-ozone/freon/src/test/java/org/apache/hadoop/ozone/freon/TestHadoopFsReadPercent.java @@ -0,0 +1,280 @@ +/* + * 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.hadoop.ozone.freon; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.within; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotEquals; + +import java.io.IOException; +import java.util.concurrent.atomic.AtomicInteger; +import org.apache.hadoop.fs.FSDataInputStream; +import org.apache.hadoop.fs.FSInputStream; +import org.apache.hadoop.fs.LocalFileSystem; +import org.apache.hadoop.fs.Path; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; +import picocli.CommandLine; + +/** + * Verifies that {@code --read-percent} of {@link HadoopFsReadWriteValidator} + * (dfsrw) controls how the operations of a run split between reads and writes. + * The workload only needs a Hadoop {@code FileSystem}, so these run against the + * local one instead of a cluster. + */ +public class TestHadoopFsReadPercent { + + /** + * Operations per run of the tests that assert a split. Each one draws on its + * own, so a split is only exact at 0 and 100 percent; a run of this many + * leaves the share of reads far closer to the percentage than the tolerance + * of {@link #assertSplit} allows for. + */ + private static final int OPS = 1000; + + /** Operations of the runs that assert an exact count. */ + private static final int FEW_OPS = 8; + + @TempDir + private java.nio.file.Path tempDir; + + private String rootPath; + + @BeforeEach + void setUp() { + // toUri() rather than the path itself: on Windows the latter is not a valid + // URI, it has backslashes and a drive letter where the authority goes + String uri = tempDir.toUri().toString(); + rootPath = uri.endsWith("/") ? uri.substring(0, uri.length() - 1) : uri; + CorruptingLocalFileSystem.corruptFromRead(Integer.MAX_VALUE); + } + + /** Half of the operations read, unless the run asks for another split. */ + @Test + void splitsOperationsEvenlyByDefault() { + CommandLine cmd = runValidator(1, OPS); + + assertSplit(cmd, 50); + } + + /** + * The requested share of the operations reads, whether that leaves the run + * write-heavy or read-heavy. + */ + @ParameterizedTest + @ValueSource(ints = {10, 75, 90}) + void splitsOperationsByPercent(int percent) { + CommandLine cmd = + runValidator(1, OPS, "--read-percent", String.valueOf(percent)); + + assertSplit(cmd, percent); + } + + /** Nothing is read back at 0, which leaves a pure write load. */ + @Test + void writesEveryOperationAtZeroPercent() { + CommandLine cmd = runValidator(1, FEW_OPS, "--read-percent", "0"); + + assertEquals(FEW_OPS, writeCount(cmd)); + assertEquals(0, readCount(cmd)); + } + + /** + * Even at 100 a thread writes once: a read validates a file the thread wrote, + * and its first operation has nothing to read back yet. + */ + @Test + void readsEveryOperationButTheFirstAtFullPercent() { + CommandLine cmd = runValidator(1, FEW_OPS, "--read-percent", "100"); + + assertEquals(1, writeCount(cmd)); + assertEquals(FEW_OPS - 1, readCount(cmd)); + } + + /** + * Every read validates, not only the first or the last one: a run whose + * single write is read back three times fails wherever among the three the + * content is corrupted. + */ + @ParameterizedTest + @ValueSource(ints = {1, 2, 3}) + void everyReadValidatesContent(int corruptedRead) { + CorruptingLocalFileSystem.corruptFromRead(corruptedRead); + + int exitCode = new Freon().getCmd().execute( + "-D", "fs.file.impl=" + CorruptingLocalFileSystem.class.getName(), + "dfsrw", + "-r", rootPath, + "-p", "dfsrw-corrupt", + // the first operation writes, so the other three all read that file + "-n", "4", + "-t", "1", + "-s", "1KB", + "--buffer", "1024", + "--copy-buffer", "1024", + "--read-percent", "100"); + + assertNotEquals(0, exitCode, + "Corrupted content of read " + corruptedRead + " was not detected"); + } + + @Test + void rejectsPercentOutsideRange() { + assertThat(execute(new Freon().getCmd(), 1, FEW_OPS, "--read-percent", "-1")) + .isNotZero(); + assertThat(execute(new Freon().getCmd(), 1, FEW_OPS, "--read-percent", "101")) + .isNotZero(); + } + + /** + * Every operation was a read or a write, and the reads are close enough to + * the requested share of them. The tolerance is six standard deviations of + * the draw: wide enough that a passing run is not chance, narrow enough to + * catch a split that ignores the percentage. + */ + private static void assertSplit(CommandLine cmd, int percent) { + long reads = readCount(cmd); + assertEquals(OPS, reads + writeCount(cmd), + "every operation is either a read or a write"); + + double fraction = percent / 100.0; + long tolerance = + (long) Math.ceil(6 * Math.sqrt(OPS * fraction * (1 - fraction))); + assertThat(reads).isCloseTo(Math.round(OPS * fraction), within(tolerance)); + } + + private CommandLine runValidator(int threads, int ops, String... args) { + CommandLine cmd = new Freon().getCmd(); + assertEquals(0, execute(cmd, threads, ops, args), + "Freon dfsrw command failed"); + return cmd; + } + + private int execute(CommandLine cmd, int threads, int ops, String... args) { + String[] fixed = { + "dfsrw", + "-r", rootPath, + "-p", "dfsrw", + "-n", String.valueOf(ops), + "-t", String.valueOf(threads), + "-s", "1KB", + "--buffer", "1024", + "--copy-buffer", "1024"}; + String[] argv = new String[fixed.length + args.length]; + System.arraycopy(fixed, 0, argv, 0, fixed.length); + System.arraycopy(args, 0, argv, fixed.length, args.length); + return cmd.execute(argv); + } + + private static long writeCount(CommandLine cmd) { + return timerCount(cmd, "file-write"); + } + + private static long readCount(CommandLine cmd) { + return timerCount(cmd, "file-read-validate"); + } + + private static long timerCount(CommandLine cmd, String name) { + BaseFreonGenerator subject = (BaseFreonGenerator) + cmd.getParseResult().subcommand().commandSpec().userObject(); + return subject.getMetrics().timer(name).getCount(); + } + + /** + * A {@link LocalFileSystem} that alters the content it hands out from the + * n-th {@code open()} on. The bytes are changed above the checksum + * verification of {@link org.apache.hadoop.fs.ChecksumFileSystem}, so it is + * the workload, not the file system, that has to notice. + */ + public static final class CorruptingLocalFileSystem extends LocalFileSystem { + + private static final AtomicInteger READS = new AtomicInteger(); + private static int corruptFrom = Integer.MAX_VALUE; + + static void corruptFromRead(int read) { + READS.set(0); + corruptFrom = read; + } + + @Override + public FSDataInputStream open(Path f, int bufferSize) throws IOException { + FSDataInputStream input = super.open(f, bufferSize); + // the hidden .crc companions are the checksum bookkeeping of the file + // system itself, only the reads of the workload are to be counted + if (f.getName().startsWith(".") || READS.incrementAndGet() < corruptFrom) { + return input; + } + return new FSDataInputStream(new FlippingInputStream(input)); + } + } + + /** + * Passes the wrapped stream through, with the first byte of the file flipped. + * {@link FSInputStream} supplies the positioned reads on top of seek and + * read, so only those two and the plain reads are forwarded here. + */ + private static final class FlippingInputStream extends FSInputStream { + + private final FSDataInputStream input; + + private FlippingInputStream(FSDataInputStream input) { + this.input = input; + } + + @Override + public int read(byte[] b, int off, int len) throws IOException { + long pos = input.getPos(); + int read = input.read(b, off, len); + if (read > 0 && pos == 0) { + b[off] ^= 0xff; + } + return read; + } + + @Override + public int read() throws IOException { + long pos = input.getPos(); + int b = input.read(); + return b >= 0 && pos == 0 ? b ^ 0xff : b; + } + + @Override + public void close() throws IOException { + input.close(); + } + + @Override + public void seek(long pos) throws IOException { + input.seek(pos); + } + + @Override + public long getPos() throws IOException { + return input.getPos(); + } + + @Override + public boolean seekToNewSource(long targetPos) throws IOException { + return input.seekToNewSource(targetPos); + } + } +} diff --git a/hadoop-ozone/integration-test/src/test/java/org/apache/hadoop/ozone/freon/TestHadoopFsReadWriteValidator.java b/hadoop-ozone/integration-test/src/test/java/org/apache/hadoop/ozone/freon/TestHadoopFsReadWriteValidator.java index 9bff0f416f27..236516170671 100644 --- a/hadoop-ozone/integration-test/src/test/java/org/apache/hadoop/ozone/freon/TestHadoopFsReadWriteValidator.java +++ b/hadoop-ozone/integration-test/src/test/java/org/apache/hadoop/ozone/freon/TestHadoopFsReadWriteValidator.java @@ -85,7 +85,8 @@ public void testWriteReadValidate(BucketLayout layout) throws Exception { String rootPath = OZONE_URI_SCHEME + "://" + bucketName + "." + volumeName; String om = cluster().getConf().get(OZONE_OM_ADDRESS_KEY); - int exitCode = new Freon().getCmd().execute( + CommandLine cmd = new Freon().getCmd(); + int exitCode = cmd.execute( "-D", OZONE_OM_ADDRESS_KEY + "=" + om, "dfsrw", "-n", String.valueOf(fileCount), @@ -96,12 +97,20 @@ public void testWriteReadValidate(BucketLayout layout) throws Exception { ); assertEquals(0, exitCode, "Freon dfsrw command failed"); + // -n counts operations, and --read-percent decides per operation whether it + // writes or reads one back, so how many of them wrote is drawn rather than + // fixed. Each write takes a path of its own here, so the run leaves + // somewhere between one file and one per operation. + BaseFreonGenerator subject = (BaseFreonGenerator) + cmd.getParseResult().subcommand().commandSpec().userObject(); + assertEquals(fileCount, subject.getSuccessCount()); + // verify all files were written with the requested size OzoneConfiguration conf = new OzoneConfiguration(cluster().getConf()); try (FileSystem fileSystem = FileSystem.get(URI.create(rootPath), conf)) { FileStatus[] files = fileSystem.listStatus(new Path(rootPath + "/" + prefix)); - assertEquals(fileCount, files.length, "Unexpected number of files"); + assertThat(files.length).isBetween(1, fileCount); Set checksums = new HashSet<>(); for (FileStatus file : files) { assertEquals(fileSize, file.getLen(), @@ -110,7 +119,7 @@ public void testWriteReadValidate(BucketLayout layout) throws Exception { } // distinct content across threads, otherwise reading the wrong file would // still validate - assertEquals(fileCount, checksums.size(), "Files share their content"); + assertEquals(files.length, checksums.size(), "Files share their content"); } } @@ -128,7 +137,9 @@ private static long checksumOf(FileSystem fileSystem, Path file) /** * Once a thread has written --max-files-per-thread files its paths wrap, so * the run keeps writing without leaving files it has no checksum for. The - * wrap is layout independent, so one layout covers it. + * wrap is layout independent, so one layout covers it. It is a pure write + * run: with reads drawn in, whether every path of the cycle got written at + * all would be a matter of chance, and the count below could not be exact. */ @Test public void testPathsWrapAtMaxFilesPerThread() throws Exception { @@ -154,6 +165,7 @@ public void testPathsWrapAtMaxFilesPerThread() throws Exception { "-t", "1", "-s", fileSize + "B", "--max-files-per-thread", String.valueOf(maxFilesPerThread), + "--read-percent", "0", "-p", prefix, "-r", rootPath );