| // Copyright 2015 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.actions.cache; |
| |
| import static com.google.common.truth.Truth.assertThat; |
| import static org.junit.Assert.assertThrows; |
| |
| import com.google.devtools.build.lib.clock.Clock; |
| import com.google.devtools.build.lib.testutil.Scratch; |
| import com.google.devtools.build.lib.testutil.TestThread; |
| import com.google.devtools.build.lib.testutil.TestThread.TestRunnable; |
| import com.google.devtools.build.lib.vfs.FileSystemUtils; |
| import com.google.devtools.build.lib.vfs.Path; |
| import java.io.EOFException; |
| import java.io.IOException; |
| import java.util.ArrayList; |
| import java.util.Arrays; |
| import java.util.Collection; |
| import java.util.Map; |
| import java.util.concurrent.ConcurrentHashMap; |
| import java.util.concurrent.CountDownLatch; |
| import org.junit.Before; |
| import org.junit.Test; |
| import org.junit.runner.RunWith; |
| import org.junit.runners.JUnit4; |
| |
| /** Tests for {@link PersistentStringIndexer}. */ |
| @RunWith(JUnit4.class) |
| public final class PersistentStringIndexerTest { |
| |
| private static class ManualClock implements Clock { |
| private long currentTime = 0L; |
| |
| ManualClock() {} |
| |
| @Override |
| public long currentTimeMillis() { |
| throw new AssertionError("unexpected method call"); |
| } |
| |
| @Override |
| public long nanoTime() { |
| return currentTime; |
| } |
| |
| void advance(long time) { |
| currentTime += time; |
| } |
| } |
| |
| private final Map<Integer, String> mappings = new ConcurrentHashMap<>(); |
| private final Scratch scratch = new Scratch(); |
| private final ManualClock clock = new ManualClock(); |
| private final Path dataPath = scratch.resolve("/cache/test.dat"); |
| private final Path journalPath = scratch.resolve("/cache/test.journal"); |
| |
| private PersistentStringIndexer indexer; |
| |
| @Before |
| public void createIndexer() throws Exception { |
| indexer = PersistentStringIndexer.create(dataPath, clock); |
| } |
| |
| private void assertSize(int expected) { |
| assertThat(indexer.size()).isEqualTo(expected); |
| } |
| |
| private void assertIndex(int expected, String s) { |
| int index = indexer.getOrCreateIndex(s); |
| assertThat(index).isEqualTo(expected); |
| mappings.put(expected, s); |
| } |
| |
| private void assertContent() { |
| for (int i = 0; i < indexer.size(); i++) { |
| if (mappings.get(i) != null) { |
| assertThat(mappings).containsEntry(i, indexer.getStringForIndex(i)); |
| } |
| } |
| } |
| |
| private void setupTestContent() { |
| assertSize(0); |
| assertIndex(0, "abcdefghi"); // Create leafs |
| assertIndex(1, "abcdefjkl"); |
| assertIndex(2, "abcdefmno"); |
| assertIndex(3, "abcdefjklpr"); |
| assertIndex(3, "abcdefjklpr"); |
| assertIndex(4, "abcdstr"); |
| assertIndex(5, "012345"); |
| assertSize(6); |
| assertIndex(6, "abcdef"); // Validate inner nodes |
| assertIndex(7, "abcd"); |
| assertIndex(8, ""); |
| assertSize(9); |
| assertContent(); |
| } |
| |
| /** |
| * Writes lots of entries with labels "fooconcurrent[int]" at the same time. The set of labels |
| * written is deterministic, but the label:index mapping is not. |
| */ |
| private void writeLotsOfEntriesConcurrently(int numToWrite) throws InterruptedException { |
| int numThreads = 10; |
| CountDownLatch synchronizerLatch = new CountDownLatch(numThreads); |
| |
| TestRunnable indexAdder = |
| () -> { |
| for (int i = 0; i < numToWrite; i++) { |
| synchronizerLatch.countDown(); |
| synchronizerLatch.await(); |
| |
| String value = "fooconcurrent" + i; |
| mappings.put(indexer.getOrCreateIndex(value), value); |
| } |
| }; |
| |
| Collection<TestThread> threads = new ArrayList<>(); |
| for (int i = 0; i < numThreads; i++) { |
| TestThread thread = new TestThread(indexAdder); |
| thread.start(); |
| threads.add(thread); |
| } |
| |
| for (TestThread thread : threads) { |
| thread.joinAndAssertState(0); |
| } |
| } |
| |
| @Test |
| public void returnsSameIntegerInstance() { |
| int n = 1000; // Greater than the default java.lang.Integer.IntegerCache.high of 127. |
| for (int i = 0; i < n; i++) { |
| String s = "a".repeat(i); |
| Integer index = indexer.getOrCreateIndex(s); |
| assertThat(indexer.getIndex(s)).isSameInstanceAs(index); |
| } |
| } |
| |
| @Test |
| public void unindexedStringReturnsNull() { |
| assertThat(indexer.getIndex("absent")).isNull(); |
| } |
| |
| @Test |
| public void testNormalOperation() throws Exception { |
| assertThat(dataPath.exists()).isFalse(); |
| assertThat(journalPath.exists()).isFalse(); |
| setupTestContent(); |
| assertThat(dataPath.exists()).isFalse(); |
| assertThat(journalPath.exists()).isFalse(); |
| |
| clock.advance(4); |
| assertIndex(9, "xyzqwerty"); // This should flush journal to disk. |
| assertThat(dataPath.exists()).isFalse(); |
| assertThat(journalPath.exists()).isTrue(); |
| |
| indexer.save(); // Successful save will remove journal file. |
| assertThat(dataPath.exists()).isTrue(); |
| assertThat(journalPath.exists()).isFalse(); |
| |
| // Now restore data from file and verify it. |
| indexer = PersistentStringIndexer.create(dataPath, clock); |
| assertThat(journalPath.exists()).isFalse(); |
| clock.advance(4); |
| assertSize(10); |
| assertContent(); |
| assertThat(journalPath.exists()).isFalse(); |
| } |
| |
| @Test |
| public void testJournalRecoveryWithoutMainDataFile() throws Exception { |
| assertThat(dataPath.exists()).isFalse(); |
| assertThat(journalPath.exists()).isFalse(); |
| setupTestContent(); |
| assertThat(dataPath.exists()).isFalse(); |
| assertThat(journalPath.exists()).isFalse(); |
| |
| clock.advance(4); |
| assertIndex(9, "abc1234"); // This should flush journal to disk. |
| assertThat(dataPath.exists()).isFalse(); |
| assertThat(journalPath.exists()).isTrue(); |
| |
| // Now restore data from file and verify it. All data should be restored from journal; |
| indexer = PersistentStringIndexer.create(dataPath, clock); |
| assertThat(dataPath.exists()).isTrue(); |
| assertThat(journalPath.exists()).isFalse(); |
| clock.advance(4); |
| assertSize(10); |
| assertContent(); |
| assertThat(journalPath.exists()).isFalse(); |
| } |
| |
| @Test |
| public void testJournalRecovery() throws Exception { |
| assertThat(dataPath.exists()).isFalse(); |
| assertThat(journalPath.exists()).isFalse(); |
| setupTestContent(); |
| indexer.save(); |
| assertThat(dataPath.exists()).isTrue(); |
| assertThat(journalPath.exists()).isFalse(); |
| long oldDataFileLen = dataPath.getFileSize(); |
| |
| clock.advance(4); |
| assertIndex(9, "another record"); // This should flush journal to disk. |
| assertSize(10); |
| assertThat(dataPath.exists()).isTrue(); |
| assertThat(journalPath.exists()).isTrue(); |
| |
| // Now restore data from file and verify it. All data should be restored from journal; |
| indexer = PersistentStringIndexer.create(dataPath, clock); |
| assertThat(dataPath.exists()).isTrue(); |
| assertThat(journalPath.exists()).isFalse(); |
| assertThat(dataPath.getFileSize()) |
| .isGreaterThan(oldDataFileLen); // data file should have been updated |
| clock.advance(4); |
| assertSize(10); |
| assertContent(); |
| assertThat(journalPath.exists()).isFalse(); |
| } |
| |
| @Test |
| public void testConcurrentWritesJournalRecovery() throws Exception { |
| assertThat(dataPath.exists()).isFalse(); |
| assertThat(journalPath.exists()).isFalse(); |
| setupTestContent(); |
| indexer.save(); |
| assertThat(dataPath.exists()).isTrue(); |
| assertThat(journalPath.exists()).isFalse(); |
| long oldDataFileLen = dataPath.getFileSize(); |
| |
| int size = indexer.size(); |
| int numToWrite = 50000; |
| writeLotsOfEntriesConcurrently(numToWrite); |
| assertThat(journalPath.exists()).isFalse(); |
| clock.advance(4); |
| assertIndex(size + numToWrite, "another record"); // This should flush journal to disk. |
| assertSize(size + numToWrite + 1); |
| assertThat(dataPath.exists()).isTrue(); |
| assertThat(journalPath.exists()).isTrue(); |
| |
| // Now restore data from file and verify it. All data should be restored from journal; |
| indexer = PersistentStringIndexer.create(dataPath, clock); |
| assertThat(dataPath.exists()).isTrue(); |
| assertThat(journalPath.exists()).isFalse(); |
| assertThat(dataPath.getFileSize()) |
| .isGreaterThan(oldDataFileLen); // data file should have been updated |
| clock.advance(4); |
| assertSize(size + numToWrite + 1); |
| assertContent(); |
| assertThat(journalPath.exists()).isFalse(); |
| } |
| |
| @Test |
| public void testCorruptedJournal() throws Exception { |
| journalPath.getParentDirectory().createDirectoryAndParents(); |
| FileSystemUtils.writeContentAsLatin1(journalPath, "bogus content"); |
| IOException e = |
| assertThrows( |
| IOException.class, () -> indexer = PersistentStringIndexer.create(dataPath, clock)); |
| assertThat(e).hasMessageThat().contains("too short: Only 13 bytes"); |
| |
| journalPath.delete(); |
| setupTestContent(); |
| assertThat(dataPath.exists()).isFalse(); |
| assertThat(journalPath.exists()).isFalse(); |
| |
| clock.advance(4); |
| assertIndex(9, "abc1234"); // This should flush journal to disk. |
| assertThat(dataPath.exists()).isFalse(); |
| assertThat(journalPath.exists()).isTrue(); |
| |
| byte[] journalContent = FileSystemUtils.readContent(journalPath); |
| |
| // Now restore data from file and verify it. All data should be restored from journal; |
| indexer = PersistentStringIndexer.create(dataPath, clock); |
| assertThat(dataPath.exists()).isTrue(); |
| assertThat(journalPath.exists()).isFalse(); |
| |
| // Now put back truncated journal. We should get an error. |
| assertThat(dataPath.delete()).isTrue(); |
| FileSystemUtils.writeContent( |
| journalPath, Arrays.copyOf(journalContent, journalContent.length - 1)); |
| assertThrows( |
| EOFException.class, () -> indexer = PersistentStringIndexer.create(dataPath, clock)); |
| |
| // Corrupt the journal with a negative size value. |
| byte[] journalCopy = journalContent.clone(); |
| // Flip this bit to make the key size negative. |
| journalCopy[95] = -2; |
| FileSystemUtils.writeContent(journalPath, journalCopy); |
| e = |
| assertThrows( |
| IOException.class, () -> indexer = PersistentStringIndexer.create(dataPath, clock)); |
| assertThat(e).hasMessageThat().contains("corrupt key length"); |
| |
| // Now put back corrupted journal. We should get an error. |
| journalContent[journalContent.length - 13] = 100; |
| FileSystemUtils.writeContent(journalPath, journalContent); |
| assertThrows( |
| IOException.class, () -> indexer = PersistentStringIndexer.create(dataPath, clock)); |
| } |
| |
| @Test |
| public void testDupeIndexCorruption() throws Exception { |
| setupTestContent(); |
| assertThat(dataPath.exists()).isFalse(); |
| assertThat(journalPath.exists()).isFalse(); |
| |
| assertIndex(9, "abc1234"); // This should flush journal to disk. |
| indexer.save(); |
| assertThat(dataPath.exists()).isTrue(); |
| assertThat(journalPath.exists()).isFalse(); |
| |
| byte[] content = FileSystemUtils.readContent(dataPath); |
| |
| // We remove the data file, and instead create a corrupt journal. |
| // |
| // The journal has a header followed by a sequence of (String, int) pairs, where each int is a |
| // unique value. The String is encoded by the length (as an int), and the int is simply encoded |
| // as an int. Note that the DataOutputStream class uses big endian by default, so the low-order |
| // bits are at the end. |
| // |
| // For the purpose of this test, we want to make the journal contain two entries with the same |
| // index (which is illegal). The PersistentStringIndexer assigns int values in the usual order, |
| // starting with zero, and it now contains 9 entries. We simply change the last entry to an |
| // index that is guaranteed to already exist. If it is the index 1, we change it to 2, otherwise |
| // we change it to 1 - in both cases, the code currently guarantees that the duplicate comes |
| // earlier in the stream. |
| assertThat(dataPath.delete()).isTrue(); |
| content[content.length - 1] = content[content.length - 1] == 1 ? (byte) 2 : (byte) 1; |
| FileSystemUtils.writeContent(journalPath, content); |
| |
| IOException e = |
| assertThrows( |
| IOException.class, () -> indexer = PersistentStringIndexer.create(dataPath, clock)); |
| assertThat(e).hasMessageThat().contains("Corrupted filename index has duplicate entry"); |
| } |
| |
| @Test |
| public void testDeferredIOFailure() throws Exception { |
| assertThat(dataPath.exists()).isFalse(); |
| assertThat(journalPath.exists()).isFalse(); |
| setupTestContent(); |
| assertThat(dataPath.exists()).isFalse(); |
| assertThat(journalPath.exists()).isFalse(); |
| |
| // Ensure that journal cannot be saved. |
| journalPath.createDirectoryAndParents(); |
| |
| clock.advance(4); |
| assertIndex(9, "abc1234"); // This should flush journal to disk (and fail at that). |
| assertThat(dataPath.exists()).isFalse(); |
| |
| // Subsequent updates should succeed even though journaling is disabled at this point. |
| clock.advance(4); |
| assertIndex(10, "another record"); |
| IOException e = assertThrows(IOException.class, () -> indexer.save()); |
| assertThat(e).hasMessageThat().contains(journalPath.getPathString() + " (Is a directory)"); |
| } |
| } |