// 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.assertEquals; import static org.junit.Assert.assertFalse; import static org.junit.Assert.assertTrue; import static org.junit.Assert.fail; import com.google.devtools.build.lib.testutil.Scratch; import com.google.devtools.build.lib.testutil.TestThread; import com.google.devtools.build.lib.util.Clock; import com.google.devtools.build.lib.vfs.FileSystemUtils; import com.google.devtools.build.lib.vfs.Path; import org.junit.Before; import org.junit.Test; import org.junit.runner.RunWith; import org.junit.runners.JUnit4; 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; /** * Test for the PersistentStringIndexer class. */ @RunWith(JUnit4.class) public 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 PersistentStringIndexer psi; private Map<Integer, String> mappings = new ConcurrentHashMap<>(); private Scratch scratch = new Scratch(); private ManualClock clock = new ManualClock(); private Path dataPath; private Path journalPath; @Before public final void createIndexer() throws Exception { dataPath = scratch.resolve("/cache/test.dat"); journalPath = scratch.resolve("/cache/test.journal"); psi = PersistentStringIndexer.newPersistentStringIndexer(dataPath, clock); } private void assertSize(int expected) { assertEquals(expected, psi.size()); } private void assertIndex(int expected, String s) { int index = psi.getOrCreateIndex(s); assertEquals(expected, index); mappings.put(expected, s); } private void assertContent() { for (int i = 0; i < psi.size(); i++) { if(mappings.get(i) != null) { assertThat(mappings).containsEntry(i, psi.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(final int numToWrite) throws InterruptedException { final int NUM_THREADS = 10; final CountDownLatch synchronizerLatch = new CountDownLatch(NUM_THREADS); class IndexAdder extends TestThread { @Override public void runTest() throws Exception { for (int i = 0; i < numToWrite; i++) { synchronizerLatch.countDown(); synchronizerLatch.await(); String value = "fooconcurrent" + i; mappings.put(psi.getOrCreateIndex(value), value); } } } Collection<TestThread> threads = new ArrayList<>(); for (int i = 0; i < NUM_THREADS; i++) { TestThread thread = new IndexAdder(); thread.start(); threads.add(thread); } for (TestThread thread : threads) { thread.joinAndAssertState(0); } } @Test public void testNormalOperation() throws Exception { assertFalse(dataPath.exists()); assertFalse(journalPath.exists()); setupTestContent(); assertFalse(dataPath.exists()); assertFalse(journalPath.exists()); clock.advance(4); assertIndex(9, "xyzqwerty"); // This should flush journal to disk. assertFalse(dataPath.exists()); assertTrue(journalPath.exists()); psi.save(); // Successful save will remove journal file. assertTrue(dataPath.exists()); assertFalse(journalPath.exists()); // Now restore data from file and verify it. psi = PersistentStringIndexer.newPersistentStringIndexer(dataPath, clock); assertFalse(journalPath.exists()); clock.advance(4); assertSize(10); assertContent(); assertFalse(journalPath.exists()); } @Test public void testJournalRecoveryWithoutMainDataFile() throws Exception { assertFalse(dataPath.exists()); assertFalse(journalPath.exists()); setupTestContent(); assertFalse(dataPath.exists()); assertFalse(journalPath.exists()); clock.advance(4); assertIndex(9, "abc1234"); // This should flush journal to disk. assertFalse(dataPath.exists()); assertTrue(journalPath.exists()); // Now restore data from file and verify it. All data should be restored from journal; psi = PersistentStringIndexer.newPersistentStringIndexer(dataPath, clock); assertTrue(dataPath.exists()); assertFalse(journalPath.exists()); clock.advance(4); assertSize(10); assertContent(); assertFalse(journalPath.exists()); } @Test public void testJournalRecovery() throws Exception { assertFalse(dataPath.exists()); assertFalse(journalPath.exists()); setupTestContent(); psi.save(); assertTrue(dataPath.exists()); assertFalse(journalPath.exists()); long oldDataFileLen = dataPath.getFileSize(); clock.advance(4); assertIndex(9, "another record"); // This should flush journal to disk. assertSize(10); assertTrue(dataPath.exists()); assertTrue(journalPath.exists()); // Now restore data from file and verify it. All data should be restored from journal; psi = PersistentStringIndexer.newPersistentStringIndexer(dataPath, clock); assertTrue(dataPath.exists()); assertFalse(journalPath.exists()); assertTrue(dataPath.getFileSize() > oldDataFileLen); // data file should have been updated clock.advance(4); assertSize(10); assertContent(); assertFalse(journalPath.exists()); } @Test public void testConcurrentWritesJournalRecovery() throws Exception { assertFalse(dataPath.exists()); assertFalse(journalPath.exists()); setupTestContent(); psi.save(); assertTrue(dataPath.exists()); assertFalse(journalPath.exists()); long oldDataFileLen = dataPath.getFileSize(); int size = psi.size(); int numToWrite = 50000; writeLotsOfEntriesConcurrently(numToWrite); assertFalse(journalPath.exists()); clock.advance(4); assertIndex(size + numToWrite, "another record"); // This should flush journal to disk. assertSize(size + numToWrite + 1); assertTrue(dataPath.exists()); assertTrue(journalPath.exists()); // Now restore data from file and verify it. All data should be restored from journal; psi = PersistentStringIndexer.newPersistentStringIndexer(dataPath, clock); assertTrue(dataPath.exists()); assertFalse(journalPath.exists()); assertTrue(dataPath.getFileSize() > oldDataFileLen); // data file should have been updated clock.advance(4); assertSize(size + numToWrite + 1); assertContent(); assertFalse(journalPath.exists()); } @Test public void testCorruptedJournal() throws Exception { FileSystemUtils.createDirectoryAndParents(journalPath.getParentDirectory()); FileSystemUtils.writeContentAsLatin1(journalPath, "bogus content"); try { psi = PersistentStringIndexer.newPersistentStringIndexer(dataPath, clock); fail(); } catch (IOException e) { assertThat(e.getMessage()).contains("too short: Only 13 bytes"); } journalPath.delete(); setupTestContent(); assertFalse(dataPath.exists()); assertFalse(journalPath.exists()); clock.advance(4); assertIndex(9, "abc1234"); // This should flush journal to disk. assertFalse(dataPath.exists()); assertTrue(journalPath.exists()); byte[] journalContent = FileSystemUtils.readContent(journalPath); // Now restore data from file and verify it. All data should be restored from journal; psi = PersistentStringIndexer.newPersistentStringIndexer(dataPath, clock); assertTrue(dataPath.exists()); assertFalse(journalPath.exists()); // Now put back truncated journal. We should get an error. assertTrue(dataPath.delete()); FileSystemUtils.writeContent(journalPath, Arrays.copyOf(journalContent, journalContent.length - 1)); try { psi = PersistentStringIndexer.newPersistentStringIndexer(dataPath, clock); fail(); } catch (EOFException e) { // Expected. } // 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); try { psi = PersistentStringIndexer.newPersistentStringIndexer(dataPath, clock); fail(); } catch (IOException e) { // Expected. assertThat(e.getMessage()).contains("corrupt key length"); } // Now put back corrupted journal. We should get an error. journalContent[journalContent.length - 13] = 100; FileSystemUtils.writeContent(journalPath, journalContent); try { psi = PersistentStringIndexer.newPersistentStringIndexer(dataPath, clock); fail(); } catch (IOException e) { // Expected. } } @Test public void testDupeIndexCorruption() throws Exception { setupTestContent(); assertFalse(dataPath.exists()); assertFalse(journalPath.exists()); assertIndex(9, "abc1234"); // This should flush journal to disk. psi.save(); assertTrue(dataPath.exists()); assertFalse(journalPath.exists()); 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. assertTrue(dataPath.delete()); content[content.length - 1] = content[content.length - 1] == 1 ? (byte) 2 : (byte) 1; FileSystemUtils.writeContent(journalPath, content); try { psi = PersistentStringIndexer.newPersistentStringIndexer(dataPath, clock); fail(); } catch (IOException e) { // Expected. assertThat(e.getMessage()).contains("Corrupted filename index has duplicate entry"); } } @Test public void testDeferredIOFailure() throws Exception { assertFalse(dataPath.exists()); assertFalse(journalPath.exists()); setupTestContent(); assertFalse(dataPath.exists()); assertFalse(journalPath.exists()); // Ensure that journal cannot be saved. FileSystemUtils.createDirectoryAndParents(journalPath); clock.advance(4); assertIndex(9, "abc1234"); // This should flush journal to disk (and fail at that). assertFalse(dataPath.exists()); // Subsequent updates should succeed even though journaling is disabled at this point. clock.advance(4); assertIndex(10, "another record"); try { // Save should actually save main data file but then return us deferred IO failure // from failed journal write. psi.save(); fail(); } catch(IOException e) { assertThat(e.getMessage()).contains(journalPath.getPathString() + " (Is a directory)"); } } }