You are viewing a plain text version of this content. The canonical link for it is here.
Posted to oak-commits@jackrabbit.apache.org by th...@apache.org on 2024/09/09 12:09:08 UTC

(jackrabbit-oak) 01/01: OAK-11099 Tree Store: support indexing from a pack file (without unpacking)

This is an automated email from the ASF dual-hosted git repository.

thomasm pushed a commit to branch OAK-11099
in repository https://gitbox.apache.org/repos/asf/jackrabbit-oak.git

commit 09f5ab8850233c727af5fa24b6cb7b98b52b9aba
Author: Thomas Mueller <th...@apache.org>
AuthorDate: Mon Sep 9 14:08:54 2024 +0200

    OAK-11099 Tree Store: support indexing from a pack file (without unpacking)
---
 .../flatfile/FlatFileNodeStoreBuilder.java         |   9 +-
 .../oak/index/indexer/document/tree/TreeStore.java |  34 +++--
 .../indexer/document/tree/store/PackStore.java     | 149 +++++++++++++++++++++
 .../indexer/document/tree/store/StoreBuilder.java  |   5 +-
 .../document/tree/store/utils/FilePacker.java      |  73 ++++++----
 .../document/tree/store/PackedStoreTest.java       |  87 ++++++++++++
 .../document/tree/store/utils/FilePackerTest.java  |  30 ++++-
 7 files changed, 343 insertions(+), 44 deletions(-)

diff --git a/oak-run-commons/src/main/java/org/apache/jackrabbit/oak/index/indexer/document/flatfile/FlatFileNodeStoreBuilder.java b/oak-run-commons/src/main/java/org/apache/jackrabbit/oak/index/indexer/document/flatfile/FlatFileNodeStoreBuilder.java
index caf6cd70d8..f84aefd933 100644
--- a/oak-run-commons/src/main/java/org/apache/jackrabbit/oak/index/indexer/document/flatfile/FlatFileNodeStoreBuilder.java
+++ b/oak-run-commons/src/main/java/org/apache/jackrabbit/oak/index/indexer/document/flatfile/FlatFileNodeStoreBuilder.java
@@ -37,6 +37,7 @@ import org.apache.jackrabbit.oak.index.indexer.document.indexstore.IndexStoreSor
 import org.apache.jackrabbit.oak.index.indexer.document.indexstore.IndexStoreUtils;
 import org.apache.jackrabbit.oak.index.indexer.document.tree.Prefetcher;
 import org.apache.jackrabbit.oak.index.indexer.document.tree.TreeStore;
+import org.apache.jackrabbit.oak.index.indexer.document.tree.store.utils.FilePacker;
 import org.apache.jackrabbit.oak.plugins.document.DocumentNodeStore;
 import org.apache.jackrabbit.oak.plugins.document.RevisionVector;
 import org.apache.jackrabbit.oak.plugins.document.mongo.MongoDocumentStore;
@@ -234,7 +235,7 @@ public class FlatFileNodeStoreBuilder {
         File metadataFile = indexStoreFiles.metadataFile;
         File file = indexStoreFiles.storeFiles.get(0);
         IndexStore store;
-        if (file.isDirectory()) {
+        if (file.isDirectory() || FilePacker.isPackFile(file)) {
             store = buildTreeStoreForIndexing(indexHelper, file);
         } else {
             store = new FlatFileStore(blobStore, file, metadataFile,
@@ -362,12 +363,12 @@ public class FlatFileNodeStoreBuilder {
                     (dir, name) -> name.endsWith(IndexStoreUtils.getMetadataFileName(algorithm)));
 
             if (storeFiles != null && storeFiles.length != 0) {
-                // Not throwing error for backward compatibility
                 if (metadataFiles == null || metadataFiles.length == 0) {
-                    log.error("Unable to find metadata file in directory:{}", sortedDir.getAbsolutePath());
+                    // the tree store doesn't need a metadata file
+                    log.info("No metadata file found in directory: {}", sortedDir.getAbsolutePath());
                     return new IndexStoreFiles(Arrays.asList(storeFiles), null);
                 } else {
-                    checkState(metadataFiles.length == 1, "Multiple metadata files available at path:{}, metadataFiles:{}", sortedDir.getAbsolutePath(),
+                    checkState(metadataFiles.length == 1, "Multiple metadata files available at path: {}, metadataFiles: {}", sortedDir.getAbsolutePath(),
                             Arrays.asList(metadataFiles));
                     return new IndexStoreFiles(Arrays.asList(storeFiles), metadataFiles[0]);
                 }
diff --git a/oak-run-commons/src/main/java/org/apache/jackrabbit/oak/index/indexer/document/tree/TreeStore.java b/oak-run-commons/src/main/java/org/apache/jackrabbit/oak/index/indexer/document/tree/TreeStore.java
index c3b4f63e50..50084c0f2e 100644
--- a/oak-run-commons/src/main/java/org/apache/jackrabbit/oak/index/indexer/document/tree/TreeStore.java
+++ b/oak-run-commons/src/main/java/org/apache/jackrabbit/oak/index/indexer/document/tree/TreeStore.java
@@ -36,6 +36,7 @@ import org.apache.jackrabbit.oak.index.indexer.document.tree.store.TreeSession;
 import org.apache.jackrabbit.oak.index.indexer.document.tree.store.Compression;
 import org.apache.jackrabbit.oak.index.indexer.document.tree.store.Store;
 import org.apache.jackrabbit.oak.index.indexer.document.tree.store.StoreBuilder;
+import org.apache.jackrabbit.oak.index.indexer.document.tree.store.utils.FilePacker;
 import org.apache.jackrabbit.oak.index.indexer.document.tree.store.utils.SieveCache;
 import org.apache.jackrabbit.oak.plugins.index.search.IndexDefinition;
 import org.apache.jackrabbit.oak.plugins.memory.EmptyNodeState;
@@ -67,7 +68,8 @@ public class TreeStore implements IndexStore {
     private final String name;
     private final Store store;
     private final long cacheSizeTreeStoreMB;
-    private final File directory;
+    private final File fileOrDirectory;
+    private final boolean readOnly;
     private final TreeSession session;
     private final NodeStateEntryReader entryReader;
     private final SieveCache<String, TreeStoreNodeState> nodeStateCache;
@@ -79,19 +81,30 @@ public class TreeStore implements IndexStore {
     private int iterationCount;
     private PathIteratorFilter filter = new PathIteratorFilter();
 
-    public TreeStore(String name, File directory, NodeStateEntryReader entryReader, long cacheSizeFactor) {
+    public TreeStore(String name, File fileOrDirectory, NodeStateEntryReader entryReader, long cacheSizeFactor) {
         this.name = name;
-        this.directory = directory;
+        this.fileOrDirectory = fileOrDirectory;
         this.entryReader = entryReader;
         long cacheSizeNodeMB = cacheSizeFactor * CACHE_SIZE_NODE_MB;
         long cacheSizeTreeStoreMB = cacheSizeFactor * CACHE_SIZE_TREE_STORE_MB;
         this.cacheSizeTreeStoreMB = cacheSizeTreeStoreMB;
         nodeStateCache = new SieveCache<>(cacheSizeFactor * cacheSizeNodeMB * MB);
-        String storeConfig = System.getProperty(TREE_STORE_CONFIG,
-                "type=file\n" +
-                TreeSession.CACHE_SIZE_MB + "=" + cacheSizeTreeStoreMB + "\n" +
-                Store.MAX_FILE_SIZE_BYTES + "=" + MAX_FILE_SIZE_MB * MB + "\n" +
-                "dir=" + directory.getAbsolutePath());
+        String storeConfig;
+        if (FilePacker.isPackFile(fileOrDirectory)) {
+            readOnly = true;
+            storeConfig = System.getProperty(TREE_STORE_CONFIG,
+                    "type=pack\n" +
+                    TreeSession.CACHE_SIZE_MB + "=" + cacheSizeTreeStoreMB + "\n" +
+                    Store.MAX_FILE_SIZE_BYTES + "=" + MAX_FILE_SIZE_MB * MB + "\n" +
+                    "file=" + fileOrDirectory.getAbsolutePath());
+        } else {
+            readOnly = false;
+            storeConfig = System.getProperty(TREE_STORE_CONFIG,
+                    "type=file\n" +
+                    TreeSession.CACHE_SIZE_MB + "=" + cacheSizeTreeStoreMB + "\n" +
+                    Store.MAX_FILE_SIZE_BYTES + "=" + MAX_FILE_SIZE_MB * MB + "\n" +
+                    "dir=" + fileOrDirectory.getAbsolutePath());
+        }
         this.store = StoreBuilder.build(storeConfig);
         store.setWriteCompression(Compression.LZ4);
         this.session = new TreeSession(store);
@@ -329,6 +342,9 @@ public class TreeStore implements IndexStore {
     }
 
     public void putNode(String path, String json) {
+        if (readOnly) {
+            throw new IllegalStateException("Read only store");
+        }
         session.put(path, json);
         if (!path.equals("/")) {
             String nodeName = PathUtils.getName(path);
@@ -347,7 +363,7 @@ public class TreeStore implements IndexStore {
 
     @Override
     public String getStorePath() {
-        return directory.getAbsolutePath();
+        return fileOrDirectory.getAbsolutePath();
     }
 
     @Override
diff --git a/oak-run-commons/src/main/java/org/apache/jackrabbit/oak/index/indexer/document/tree/store/PackStore.java b/oak-run-commons/src/main/java/org/apache/jackrabbit/oak/index/indexer/document/tree/store/PackStore.java
new file mode 100644
index 0000000000..8dc12e3e8c
--- /dev/null
+++ b/oak-run-commons/src/main/java/org/apache/jackrabbit/oak/index/indexer/document/tree/store/PackStore.java
@@ -0,0 +1,149 @@
+/*
+ * 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.jackrabbit.oak.index.indexer.document.tree.store;
+
+import java.io.File;
+import java.io.IOException;
+import java.io.RandomAccessFile;
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.Properties;
+import java.util.Set;
+
+import org.apache.jackrabbit.oak.index.indexer.document.tree.store.utils.FilePacker;
+import org.apache.jackrabbit.oak.index.indexer.document.tree.store.utils.FilePacker.FileEntry;
+
+/**
+ * A store where all the entries are stored in a "Pack File" (see @FilePacker).
+ * The store is read-only.
+ */
+public class PackStore implements Store {
+
+    private final Properties config;
+    private final String fileName;
+    private final File file;
+    private final RandomAccessFile f;
+    private final HashMap<String, FileEntry> directory = new HashMap<>();
+    private final long maxFileSizeBytes;
+    private final long start;
+    private long readCount;
+
+    public PackStore(Properties config) {
+        this.config = config;
+        this.fileName = config.getProperty("file");
+        this.maxFileSizeBytes = Long.parseLong(config.getProperty(
+                Store.MAX_FILE_SIZE_BYTES, "" + Store.DEFAULT_MAX_FILE_SIZE_BYTES));
+        try {
+            file = new File(fileName);
+            f = new RandomAccessFile(fileName, "r");
+            ArrayList<FileEntry> list = FilePacker.readDirectoryListing(file, f);
+            start = f.getFilePointer();
+            for(FileEntry e : list) {
+                directory.put(e.fileName, e);
+            }
+        } catch (IOException e) {
+            throw new IllegalArgumentException("Error reading file " + fileName, e);
+        }
+    }
+
+    public String toString() {
+        return "pack(" + fileName + ")";
+    }
+
+    @Override
+    public PageFile getIfExists(String key) {
+        readCount++;
+        FileEntry fe = directory.get(key);
+        if (fe == null) {
+            return null;
+        }
+        synchronized (this) {
+            try {
+                f.seek(start + fe.offset);
+                byte[] data = new byte[(int) fe.length];
+                f.readFully(data);
+                Compression c = Compression.getCompressionFromData(data[0]);
+                data = c.expand(data);
+                return PageFile.fromBytes(data, maxFileSizeBytes);
+            } catch (IOException e) {
+                throw new IllegalArgumentException(e);
+            }
+        }
+    }
+
+    @Override
+    public void put(String key, PageFile value) {
+        throw new IllegalStateException("Store is read-only");
+    }
+
+    @Override
+    public String newFileName() {
+        throw new IllegalStateException("Store is read-only");
+    }
+
+    @Override
+    public Set<String> keySet() {
+        return directory.keySet();
+    }
+
+    @Override
+    public void remove(Set<String> set) {
+        throw new IllegalStateException("Store is read-only");
+    }
+
+    @Override
+    public void removeAll() {
+        throw new IllegalStateException("Store is read-only");
+    }
+
+    @Override
+    public long getWriteCount() {
+        return 0;
+    }
+
+    @Override
+    public long getReadCount() {
+        return readCount;
+    }
+
+    @Override
+    public void setWriteCompression(Compression compression) {
+        // ignore
+    }
+
+    @Override
+    public void close() {
+        try {
+            f.close();
+        } catch (IOException e) {
+            throw new IllegalStateException(e);
+        }
+    }
+
+    @Override
+    public Properties getConfig() {
+        return config;
+    }
+
+    @Override
+    public long getMaxFileSizeBytes() {
+        return maxFileSizeBytes;
+    }
+
+}
diff --git a/oak-run-commons/src/main/java/org/apache/jackrabbit/oak/index/indexer/document/tree/store/StoreBuilder.java b/oak-run-commons/src/main/java/org/apache/jackrabbit/oak/index/indexer/document/tree/store/StoreBuilder.java
index ba3b8322d4..755b8a0428 100644
--- a/oak-run-commons/src/main/java/org/apache/jackrabbit/oak/index/indexer/document/tree/store/StoreBuilder.java
+++ b/oak-run-commons/src/main/java/org/apache/jackrabbit/oak/index/indexer/document/tree/store/StoreBuilder.java
@@ -35,6 +35,7 @@ public class StoreBuilder {
      * - "type=file": file system, with "dir" directory
      * - "type=stats.another": statistics wrapper around another
      * - "type=slow.another": slow wrapper around another (to simulate slowness)
+     * - "type=pack": pack file, with "file" file name
      * - "type=log.another": log wrapper around another (to analyze problems)
      * - "maxFileSizeBytes=16000000": the maximum file size in bytes
      *
@@ -46,7 +47,7 @@ public class StoreBuilder {
         if (config == null || config.isEmpty()) {
             return new MemoryStore(new Properties());
         }
-        config = config.replace(' ', '\n').replace("\\", "\\\\");
+        config = config.replace("\\", "\\\\");
         Properties prop = new Properties();
         try {
             prop.load(new StringReader(config));
@@ -63,6 +64,8 @@ public class StoreBuilder {
             return new MemoryStore(config);
         case "file":
             return new FileStore(config);
+        case "pack":
+            return new PackStore(config);
         }
         if (type.startsWith("stats.")) {
             config.put("type", type.substring("stats.".length()));
diff --git a/oak-run-commons/src/main/java/org/apache/jackrabbit/oak/index/indexer/document/tree/store/utils/FilePacker.java b/oak-run-commons/src/main/java/org/apache/jackrabbit/oak/index/indexer/document/tree/store/utils/FilePacker.java
index 36c2038b66..6bc145e66c 100644
--- a/oak-run-commons/src/main/java/org/apache/jackrabbit/oak/index/indexer/document/tree/store/utils/FilePacker.java
+++ b/oak-run-commons/src/main/java/org/apache/jackrabbit/oak/index/indexer/document/tree/store/utils/FilePacker.java
@@ -29,6 +29,9 @@ import java.util.ArrayList;
 import java.util.List;
 import java.util.stream.Collectors;
 
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
 /**
  * A utility class that allows converting the files of a tree store into one
  * file (pack the files), and back from a file to a list of files (unpack the
@@ -42,6 +45,8 @@ import java.util.stream.Collectors;
  */
 public class FilePacker {
 
+    private static final Logger LOG = LoggerFactory.getLogger(FilePacker.class);
+
     /**
      * The header of pack files ("PACK").
      */
@@ -63,16 +68,22 @@ public class FilePacker {
 
     /**
      * Check whether the file starts with the magic header.
+     *
      * @param file
-     * @return
-     * @throws IOException
+     * @return if this is a pack file
      */
-    public static boolean isPackFile(File file) throws IOException {
-        RandomAccessFile source = new RandomAccessFile(file, "rw");
-        byte[] magic = new byte[4];
-        source.readFully(magic);
-        source.close();
-        return PACK_HEADER.equals(new String(magic, StandardCharsets.UTF_8));
+    public static boolean isPackFile(File file) {
+        if (!file.exists() || !file.isFile() || file.length() < 4) {
+            return false;
+        }
+        try (RandomAccessFile source = new RandomAccessFile(file, "r")) {
+            byte[] magic = new byte[4];
+            source.readFully(magic);
+            return PACK_HEADER.equals(new String(magic, StandardCharsets.UTF_8));
+        } catch (IOException e) {
+            LOG.warn("IOException reading a possible pack file", e);
+            return false;
+        }
     }
 
     /**
@@ -136,21 +147,7 @@ public class FilePacker {
             targetDirectory.mkdirs();
         }
         RandomAccessFile source = new RandomAccessFile(sourceFile, "rw");
-        byte[] magic = new byte[4];
-        source.readFully(magic);
-        if (!PACK_HEADER.equals(new String(magic, StandardCharsets.UTF_8))) {
-            source.close();
-            throw new IOException("File header is not '" + PACK_HEADER + "': " + sourceFile.getAbsolutePath());
-        }
-        ArrayList<FileEntry> list = new ArrayList<>();
-        long offset = 0;
-        while (source.read() > 0) {
-            byte[] name = new byte[source.readInt()];
-            source.readFully(name);
-            long length = source.readLong();
-            list.add(new FileEntry(new String(name, StandardCharsets.UTF_8), length, offset));
-            offset += length;
-        }
+        ArrayList<FileEntry> list = readDirectoryListing(sourceFile, source);
         long start = source.getFilePointer();
         for (int i = list.size() - 1; i >= 0; i--) {
             FileEntry f = list.get(i);
@@ -169,10 +166,29 @@ public class FilePacker {
         }
     }
 
-    static class FileEntry {
-        final String fileName;
-        final long length;
-        long offset;
+    public static ArrayList<FileEntry> readDirectoryListing(File sourceFile, RandomAccessFile source) throws IOException {
+        byte[] magic = new byte[4];
+        source.readFully(magic);
+        if (!PACK_HEADER.equals(new String(magic, StandardCharsets.UTF_8))) {
+            source.close();
+            throw new IOException("File header is not '" + PACK_HEADER + "': " + sourceFile.getAbsolutePath());
+        }
+        ArrayList<FileEntry> list = new ArrayList<>();
+        long offset = 0;
+        while (source.read() > 0) {
+            byte[] name = new byte[source.readInt()];
+            source.readFully(name);
+            long length = source.readLong();
+            list.add(new FileEntry(new String(name, StandardCharsets.UTF_8), length, offset));
+            offset += length;
+        }
+        return list;
+    }
+
+    public static class FileEntry {
+        public final String fileName;
+        public final long length;
+        public long offset;
         FileEntry(String fileName, long length, long offset) {
             this.fileName = fileName;
             this.length = length;
@@ -182,5 +198,8 @@ public class FilePacker {
             this.fileName = f.getName();
             this.length = f.length();
         }
+        public String toString() {
+            return fileName + "/" + length + "@" + offset;
+        }
     }
 }
diff --git a/oak-run-commons/src/test/java/org/apache/jackrabbit/oak/index/indexer/document/tree/store/PackedStoreTest.java b/oak-run-commons/src/test/java/org/apache/jackrabbit/oak/index/indexer/document/tree/store/PackedStoreTest.java
new file mode 100644
index 0000000000..7de3ec3860
--- /dev/null
+++ b/oak-run-commons/src/test/java/org/apache/jackrabbit/oak/index/indexer/document/tree/store/PackedStoreTest.java
@@ -0,0 +1,87 @@
+/*
+ * 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.jackrabbit.oak.index.indexer.document.tree.store;
+
+import static org.junit.Assert.assertEquals;
+
+import java.io.File;
+import java.io.IOException;
+import java.io.RandomAccessFile;
+import java.util.ArrayList;
+import java.util.Map.Entry;
+import java.util.TreeMap;
+
+import org.apache.jackrabbit.oak.index.indexer.document.tree.store.utils.FilePacker;
+import org.junit.ClassRule;
+import org.junit.Test;
+import org.junit.rules.TemporaryFolder;
+
+public class PackedStoreTest {
+
+    @ClassRule
+    public static TemporaryFolder temporaryFolder = new TemporaryFolder(new File("target"));
+
+    @Test
+    public void test() throws IOException {
+        File dir = temporaryFolder.newFolder("sourceDir");
+        Store store = StoreBuilder.build(
+                "type=file\n" +
+                "dir="+dir.getAbsolutePath()+"\n" +
+                "cacheSizeMB=1\n" +
+                "maxFileSizeBytes=100");
+        store.setWriteCompression(Compression.LZ4);
+        store.removeAll();
+        TreeSession session = new TreeSession(store);
+        session.init();
+        int count = 100;
+        TreeMap<String, String> verify = new TreeMap<>();
+        for (int i = 0; i < count; i++) {
+            session.put("hello" + i, "world" + i);
+            verify.put("hello" + i, "world" + i);
+        }
+        session.checkpoint();
+        for (int i = 0; i < count; i++) {
+            if (i % 3 == 0) {
+                session.put("hello" + i, null);
+                verify.put("hello" + i, null);
+            } else if (i % 3 == 1) {
+                session.put("hello" + i, "World" + i);
+                verify.put("hello" + i, "World" + i);
+            }
+        }
+        session.flush();
+        store.close();
+
+        File pack = temporaryFolder.newFile("pack");
+        FilePacker.pack(dir, TreeSession.getFileNameRegex(), pack, true);
+        Store packStore = StoreBuilder.build(
+                "type=pack\n" +
+                "file=" + pack.getAbsolutePath() + "\n" +
+                "maxFileSizeBytes=100");
+        RandomAccessFile f = new RandomAccessFile(pack, "r");
+        ArrayList<FilePacker.FileEntry> list = FilePacker.readDirectoryListing(pack, f);
+        assertEquals(152, list.size());
+        f.close();
+        TreeSession session2 = new TreeSession(packStore);
+        for(Entry<String, String> e : verify.entrySet()) {
+            String v = session2.get(e.getKey());
+            assertEquals(v, e.getValue());
+        }
+    }
+}
diff --git a/oak-run-commons/src/test/java/org/apache/jackrabbit/oak/index/indexer/document/tree/store/utils/FilePackerTest.java b/oak-run-commons/src/test/java/org/apache/jackrabbit/oak/index/indexer/document/tree/store/utils/FilePackerTest.java
index 25fdc19b67..2013a0094f 100644
--- a/oak-run-commons/src/test/java/org/apache/jackrabbit/oak/index/indexer/document/tree/store/utils/FilePackerTest.java
+++ b/oak-run-commons/src/test/java/org/apache/jackrabbit/oak/index/indexer/document/tree/store/utils/FilePackerTest.java
@@ -46,17 +46,36 @@ public class FilePackerTest {
     @ClassRule
     public static TemporaryFolder temporaryFolder = new TemporaryFolder(new File("target"));
 
+    @Test
+    public void tinyFile() throws IOException {
+        File tiny = temporaryFolder.newFile();
+        assertFalse(FilePacker.isPackFile(tiny));
+        RandomAccessFile f = new RandomAccessFile(tiny, "rw");
+        f.write('P');
+        f.close();
+        assertFalse(FilePacker.isPackFile(tiny));
+        f = new RandomAccessFile(tiny, "rw");
+        f.write('P');
+        f.write('A');
+        f.write('C');
+        f.write('K');
+        f.close();
+        assertTrue(FilePacker.isPackFile(tiny));
+    }
+
     @Test
     public void headerMismatch() throws IOException {
-        File pack = temporaryFolder.newFile("pack");
-        File dir = temporaryFolder.newFolder("sourceDir");
+        File pack = temporaryFolder.newFile();
+        File dir = temporaryFolder.newFolder();
         RandomAccessFile f = new RandomAccessFile(pack, "rw");
         f.writeUTF("test");
         try {
+            assertFalse(FilePacker.isPackFile(dir));
             FilePacker.unpack(pack, dir, false);
             fail();
         } catch (IOException e) {
-            assertTrue(e.getMessage().startsWith("File header is not 'PACK'"));
+            e.printStackTrace(System.out);
+            assertTrue(e.getMessage(), e.getMessage().startsWith("File header is not 'PACK'"));
         }
         f.close();
         pack.delete();
@@ -66,6 +85,7 @@ public class FilePackerTest {
     public void sourceIsDirectory() throws IOException {
         File dir = temporaryFolder.newFolder("source");
         try {
+            assertFalse(FilePacker.isPackFile(dir));
             FilePacker.unpack(dir, dir, false);
             fail();
         } catch (IOException e) {
@@ -78,6 +98,8 @@ public class FilePackerTest {
     public void sourceMissing() throws IOException {
         File dir = temporaryFolder.newFolder("source");
         try {
+            assertFalse(FilePacker.isPackFile(new File(dir, "missing")));
+            assertFalse(FilePacker.isPackFile(dir));
             FilePacker.unpack(new File(dir, "missing"), dir, false);
             fail();
         } catch (IOException e) {
@@ -90,6 +112,7 @@ public class FilePackerTest {
     public void targetDirectoryIsFile() throws IOException {
         File target = temporaryFolder.newFile();
         try {
+            assertFalse(FilePacker.isPackFile(target));
             FilePacker.unpack(target, target, false);
             fail();
         } catch (IOException e) {
@@ -130,6 +153,7 @@ public class FilePackerTest {
         // System.out.println(pack.getAbsolutePath());
         // System.out.println(dir.getAbsolutePath());
         FilePacker.pack(dir, TreeSession.getFileNameRegex(), pack, delete);
+        assertTrue(FilePacker.isPackFile(pack));
 
         for (FileEntry f : list) {
             if (delete) {