Introduce read/write API for archived content

StorageService always had an archive() method to create
a history trail for entities, e.g. projects, but there was
no way to retrieve archived content, or backup/restore
the archive via REST API.

This patch add a couple of new methods to StorageService:

* readFromArchive() retrieves the history of an entity and
passes it to a consumer function (StorageConsumer). This
allows the JPA implementation to make use of database
cursors for minimal memory consumption.

* writeToArchive() allows to push new entries to the
history bypassing the archive() function. This will be
used in a subsequent patch to implement a restore API.

All known implementations of StorageService are adapted
in this patch, and some more archiving tests have been
added.

Change-Id: I1adbe767efb58c99f91ce12daaf88ed64dc62117
Signed-off-by: Michael Ochmann <michael.ochmann@sap.com>
diff --git a/org.eclipse.skalli.api/src/main/java/org/eclipse/skalli/services/persistence/StorageConsumer.java b/org.eclipse.skalli.api/src/main/java/org/eclipse/skalli/services/persistence/StorageConsumer.java
new file mode 100644
index 0000000..65d27b1
--- /dev/null
+++ b/org.eclipse.skalli.api/src/main/java/org/eclipse/skalli/services/persistence/StorageConsumer.java
@@ -0,0 +1,32 @@
+/*******************************************************************************
+ * Copyright (c) 2010-2016 SAP AG and others.
+ * All rights reserved. This program and the accompanying materials
+ * are made available under the terms of the Eclipse Public License v1.0
+ * which accompanies this distribution, and is available at
+ * http://www.eclipse.org/legal/epl-v10.html
+ *
+ * Contributors:
+ *     SAP AG - initial API and implementation
+ *******************************************************************************/
+package org.eclipse.skalli.services.persistence;
+
+import java.io.IOException;
+import java.io.InputStream;
+
+/**
+ * Represent a consumer of stored items.
+ */
+public interface StorageConsumer {
+
+    /**
+     * Consumes the provided content.
+     *
+     * @param category
+     * @param key
+     * @param lastModified
+     * @param blob
+     *
+     * @throws IOException
+     */
+    public void consume(String category, String key, long lastModified, InputStream blob) throws IOException;
+}
diff --git a/org.eclipse.skalli.api/src/main/java/org/eclipse/skalli/services/persistence/StorageService.java b/org.eclipse.skalli.api/src/main/java/org/eclipse/skalli/services/persistence/StorageService.java
index 1290d28..d2397fd 100644
--- a/org.eclipse.skalli.api/src/main/java/org/eclipse/skalli/services/persistence/StorageService.java
+++ b/org.eclipse.skalli.api/src/main/java/org/eclipse/skalli/services/persistence/StorageService.java
@@ -56,6 +56,26 @@
     public void archive(String category, String key) throws IOException;
 
     /**
+     * Writes the given content to the archive
+     *
+     * @param category
+     * @param key
+     * @param blob
+     * @throws IOException if an i/o error occured while eriting to the archive.
+     */
+    public void writeToArchive(String category, String key, long timestamp, InputStream blob) throws IOException;
+
+    /**
+     * Provides all archived content for the given category and key to the specified consumer.
+     *
+     * @param category  category of archived items to provide.
+     * @param key  the unique key of the content within its category.
+     * @param consumer consumer for archived items.
+     * @throws IOException  if an i/o error occured while reading content from the store.
+     */
+    public void readFromArchive(String category, String key, StorageConsumer consumer) throws IOException;
+
+    /**
      * Returns the keys of storage entries for the given category.
      *
      * @param category  category or type of the content.
diff --git a/org.eclipse.skalli.core/src/main/java/org/eclipse/skalli/core/storage/FileStorageComponent.java b/org.eclipse.skalli.core/src/main/java/org/eclipse/skalli/core/storage/FileStorageComponent.java
index cf4f657..4870e5b 100644
--- a/org.eclipse.skalli.core/src/main/java/org/eclipse/skalli/core/storage/FileStorageComponent.java
+++ b/org.eclipse.skalli.core/src/main/java/org/eclipse/skalli/core/storage/FileStorageComponent.java
@@ -23,7 +23,10 @@
 
 import org.apache.commons.io.FileUtils;
 import org.apache.commons.io.IOUtils;
+import org.eclipse.skalli.core.storage.Historian.HistoryEntry;
+import org.eclipse.skalli.core.storage.Historian.HistoryIterator;
 import org.eclipse.skalli.services.BundleProperties;
+import org.eclipse.skalli.services.persistence.StorageConsumer;
 import org.eclipse.skalli.services.persistence.StorageService;
 import org.osgi.service.component.ComponentConstants;
 import org.osgi.service.component.ComponentContext;
@@ -68,18 +71,6 @@
                 (String) context.getProperties().get(ComponentConstants.COMPONENT_NAME)));
     }
 
-    private String getPath(String category, String key) {
-        return category + "/" + key; //$NON-NLS-1$
-    }
-
-    private File getFile(String category, String key) {
-        File path = new File(storageBase, category);
-        if (!path.exists()) {
-            path.mkdirs();
-        }
-        return new File(path, key + ".xml"); //$NON-NLS-1$
-    }
-
     @Override
    public void write(String category, String key, InputStream blob) throws IOException {
         File file = getFile(category, key);
@@ -100,34 +91,27 @@
     }
 
     @Override
-    public InputStream read(String category, String key) throws IOException {
-        File file = getFile(category, key);
+    public void writeToArchive(String category, String id, long timestamp, InputStream blob) throws IOException {
+        new Historian(new File(storageBase, category)).historize(id, timestamp, blob);
+    }
+
+    @Override
+    public void readFromArchive(String category, String key, StorageConsumer consumer) throws IOException {
+        HistoryIterator history = null;
         try {
-            return new FileInputStream(file);
-        } catch (FileNotFoundException e) {
-            return null;
+            history = new Historian(new File(storageBase, category)).getHistory(key);
+            while (history.hasNext()) {
+                HistoryEntry next = history.next();
+                consumer.consume(category, key, next.getTimestamp(), IOUtils.toInputStream(next.getContent()));
+            }
+        } finally {
+            history.close();
         }
     }
 
-    private final File getDefaultStorageDirectory() {
-        File storageDirectory = null;
-        String workdir = BundleProperties.getProperty(BundleProperties.PROPERTY_WORKDIR);
-        if (workdir != null) {
-            File workingDirectory = new File(workdir);
-            if (workingDirectory.exists() && workingDirectory.isDirectory()) {
-                storageDirectory = new File(workingDirectory, STORAGE_BASE);
-            } else {
-                LOG.warn("Working directory '" + workingDirectory.getAbsolutePath()
-                        + "' not found - falling back to current directory");
-            }
-        }
-        if (storageDirectory == null) {
-            // fall back: use current directory as working directory
-            storageDirectory = new File(STORAGE_BASE);
-        }
-
-        LOG.info("Using storage directory '" + storageDirectory.getAbsolutePath() + "'");
-        return storageDirectory;
+    @Override
+    public InputStream read(String category, String key) throws IOException {
+        return toStream(getFile(category, key));
     }
 
     @Override
@@ -149,4 +133,45 @@
 
         return list;
     }
+
+    private static File getDefaultStorageDirectory() {
+        File storageDirectory = null;
+        String workdir = BundleProperties.getProperty(BundleProperties.PROPERTY_WORKDIR);
+        if (workdir != null) {
+            File workingDirectory = new File(workdir);
+            if (workingDirectory.exists() && workingDirectory.isDirectory()) {
+                storageDirectory = new File(workingDirectory, STORAGE_BASE);
+            } else {
+                LOG.warn("Working directory '" + workingDirectory.getAbsolutePath()
+                        + "' not found - falling back to current directory");
+            }
+        }
+        if (storageDirectory == null) {
+            // fall back: use current directory as working directory
+            storageDirectory = new File(STORAGE_BASE);
+        }
+
+        LOG.info("Using storage directory '" + storageDirectory.getAbsolutePath() + "'");
+        return storageDirectory;
+    }
+
+    private File getFile(String category, String key) {
+        File path = new File(storageBase, category);
+        if (!path.exists()) {
+            path.mkdirs();
+        }
+        return new File(path, key + ".xml"); //$NON-NLS-1$
+    }
+
+    private static String getPath(String category, String key) {
+        return category + "/" + key; //$NON-NLS-1$
+    }
+
+    private static InputStream toStream(File file) {
+        try {
+            return file != null && file.exists() && file.isFile() && file.canRead() ? new FileInputStream(file) : null;
+        } catch (FileNotFoundException e) {
+            return null;
+        }
+    }
 }
diff --git a/org.eclipse.skalli.core/src/main/java/org/eclipse/skalli/core/storage/Historian.java b/org.eclipse.skalli.core/src/main/java/org/eclipse/skalli/core/storage/Historian.java
index 5d1d816..10f2a71 100644
--- a/org.eclipse.skalli.core/src/main/java/org/eclipse/skalli/core/storage/Historian.java
+++ b/org.eclipse.skalli.core/src/main/java/org/eclipse/skalli/core/storage/Historian.java
@@ -58,6 +58,24 @@
         }
     }
 
+    void historize(String id, long timestamp, InputStream blob) throws IOException {
+        OutputStream out = null;
+        try {
+            byte[] buf = IOUtils.toByteArray(blob);
+            out = new BufferedOutputStream(new FileOutputStream(historyFile, historyFile.exists()));
+            String header = MessageFormat.format("{0}:{1}:{2}", //$NON-NLS-1$
+                    getNextEntryName(id), 
+                    Long.toString(buf.length),
+                    Long.toString(timestamp));
+            out.write(header.getBytes("UTF-8")); //$NON-NLS-1$
+            out.write(CRLF.getBytes("UTF-8")); //$NON-NLS-1$
+            out.write(buf);
+            out.write(CRLF.getBytes("UTF-8")); //$NON-NLS-1$
+        } finally {
+            IOUtils.closeQuietly(out);
+        }
+    }
+
     String getNextEntryName(String fileName) throws IOException {
         int count = 0;
         InputStream in = null;
diff --git a/org.eclipse.skalli.jpa.test/src/main/test/org/eclipse/skalli/core/storage/jpa/JPAStorageComponentTest.java b/org.eclipse.skalli.jpa.test/src/main/test/org/eclipse/skalli/core/storage/jpa/JPAStorageComponentTest.java
index 9a76195..c958481 100644
--- a/org.eclipse.skalli.jpa.test/src/main/test/org/eclipse/skalli/core/storage/jpa/JPAStorageComponentTest.java
+++ b/org.eclipse.skalli.jpa.test/src/main/test/org/eclipse/skalli/core/storage/jpa/JPAStorageComponentTest.java
@@ -10,15 +10,11 @@
  *******************************************************************************/
 package org.eclipse.skalli.core.storage.jpa;
 
-import static org.junit.Assert.*;
-
-import java.io.ByteArrayInputStream;
-import java.util.List;
+import static org.junit.Assert.fail;
 
 import org.eclipse.skalli.services.persistence.StorageService;
 import org.eclipse.skalli.testutil.BundleManager;
 import org.eclipse.skalli.testutil.StorageServiceTestBase;
-import org.junit.Test;
 
 @SuppressWarnings("nls")
 public class JPAStorageComponentTest extends StorageServiceTestBase {
@@ -32,46 +28,4 @@
         }
         return jpaStorageService;
     }
-
-    @Test
-    public void testArchive() throws Exception {
-        final String TEST_CATEGORY = "test_archive";
-
-        StorageService service = getStorageService();
-        if (!(service instanceof JPAStorageComponent)) {
-            return;
-        }
-
-        JPAStorageComponent pdb = (JPAStorageComponent)service;
-
-        // initially empty
-        List<HistoryStorageItem> items = pdb.getHistory(TEST_CATEGORY, TEST_ID);
-        assertTrue(items.isEmpty());
-
-        // archive non existing element, should do nothing
-        pdb.archive(TEST_CATEGORY, TEST_ID);
-        items = pdb.getHistory(TEST_CATEGORY, TEST_ID);
-        assertTrue(items.isEmpty());
-
-        // create item
-        ByteArrayInputStream is = new ByteArrayInputStream(TEST_CONTENT.getBytes("UTF-8"));
-        pdb.write(TEST_CATEGORY, TEST_ID, is);
-
-        // first archive step
-        pdb.archive(TEST_CATEGORY, TEST_ID);
-        items = pdb.getHistory(TEST_CATEGORY, TEST_ID);
-        assertTrue(items.size() == 1);
-        assertTrue(items.get(0).getDateCreated() != null);
-        assertEquals(TEST_ID, items.get(0).getId());
-
-        // second archive step
-        pdb.archive(TEST_CATEGORY, TEST_ID);
-        items = pdb.getHistory(TEST_CATEGORY, TEST_ID);
-        assertTrue(items.size() == 2);
-        assertTrue(items.get(0).getDateCreated() != null);
-        assertEquals(TEST_ID, items.get(0).getId());
-        assertTrue(items.get(1).getDateCreated() != null);
-        assertEquals(TEST_ID, items.get(1).getId());
-    }
-
 }
\ No newline at end of file
diff --git a/org.eclipse.skalli.jpa/META-INF/MANIFEST.MF b/org.eclipse.skalli.jpa/META-INF/MANIFEST.MF
index 7370816..c9dae0a 100644
--- a/org.eclipse.skalli.jpa/META-INF/MANIFEST.MF
+++ b/org.eclipse.skalli.jpa/META-INF/MANIFEST.MF
@@ -9,6 +9,8 @@
  org.apache.commons.io,
  org.apache.commons.lang,
  org.eclipse.persistence.annotations,
+ org.eclipse.persistence.config,
+ org.eclipse.persistence.queries,
  org.eclipse.skalli.commons,
  org.eclipse.skalli.services.feed,
  org.eclipse.skalli.services.persistence,
diff --git a/org.eclipse.skalli.jpa/src/main/java/org/eclipse/skalli/core/storage/jpa/HistoryStorageItem.java b/org.eclipse.skalli.jpa/src/main/java/org/eclipse/skalli/core/storage/jpa/HistoryStorageItem.java
index 92abe21..377666e 100644
--- a/org.eclipse.skalli.jpa/src/main/java/org/eclipse/skalli/core/storage/jpa/HistoryStorageItem.java
+++ b/org.eclipse.skalli.jpa/src/main/java/org/eclipse/skalli/core/storage/jpa/HistoryStorageItem.java
@@ -46,49 +46,35 @@
     @Column(length = 100000)
     private String content;
 
-    public HistoryStorageItem() {
-        // just needed for JPA
-    }
-
-    /**
-     * Creates a HistoryStorageItem using the current date.
-     *
-     */
-    public HistoryStorageItem(StorageItem item) {
-        this.category = item.getCategory();
-        this.id = item.getId();
-        this.content = item.getContent();
-
-        this.dateCreated = new Date();
-    }
-
-    /**
-     * Creates a HistoryStorageItem using the supplied date.
-     *
-     */
-    public HistoryStorageItem(StorageItem item, Date dateCreated) {
-        this(item);
-
-        this.dateCreated = dateCreated;
-    }
-
-    public int getAutoId() {
-        return autoId;
-    }
-
     public String getCategory() {
         return category;
     }
 
+    public void setCategory(String category) {
+        this.category = category;
+    }
+
     public String getId() {
         return id;
     }
 
-    public String getContent() {
-        return content;
+    public void setId(String id) {
+        this.id = id;
     }
 
     public Date getDateCreated() {
         return dateCreated;
     }
+
+    public void setDateCreated(Date dateCreated) {
+        this.dateCreated = dateCreated;
+    }
+
+    public String getContent() {
+        return content;
+    }
+
+    public void setContent(String content) {
+        this.content = content;
+    }
 }
diff --git a/org.eclipse.skalli.jpa/src/main/java/org/eclipse/skalli/core/storage/jpa/JPAStorageComponent.java b/org.eclipse.skalli.jpa/src/main/java/org/eclipse/skalli/core/storage/jpa/JPAStorageComponent.java
index 341c493..573d834 100644
--- a/org.eclipse.skalli.jpa/src/main/java/org/eclipse/skalli/core/storage/jpa/JPAStorageComponent.java
+++ b/org.eclipse.skalli.jpa/src/main/java/org/eclipse/skalli/core/storage/jpa/JPAStorageComponent.java
@@ -13,18 +13,22 @@
 import java.io.ByteArrayInputStream;
 import java.io.IOException;
 import java.io.InputStream;
-import java.io.UnsupportedEncodingException;
 import java.text.MessageFormat;
 import java.util.ArrayList;
 import java.util.Date;
 import java.util.List;
 
 import javax.persistence.EntityManager;
+import javax.persistence.Query;
 import javax.persistence.TypedQuery;
 
 import org.apache.commons.io.IOUtils;
+import org.eclipse.persistence.config.HintValues;
+import org.eclipse.persistence.config.QueryHints;
+import org.eclipse.persistence.queries.CursoredStream;
 import org.eclipse.skalli.services.persistence.EntityManagerService;
 import org.eclipse.skalli.services.persistence.EntityManagerServiceBase;
+import org.eclipse.skalli.services.persistence.StorageConsumer;
 import org.eclipse.skalli.services.persistence.StorageService;
 import org.osgi.service.component.ComponentConstants;
 import org.osgi.service.component.ComponentContext;
@@ -35,6 +39,9 @@
 
     private static final Logger LOG = LoggerFactory.getLogger(JPAStorageComponent.class);
 
+    // page size for mass operations
+    private static final int PAGE_SIZE = 100;
+
     @Override
     protected void activate(ComponentContext context) {
         super.activate(context);
@@ -52,7 +59,6 @@
     @Override
     public void write(String category, String id, InputStream blob) throws IOException {
         EntityManager em = getEntityManager();
-
         em.getTransaction().begin();
         try {
             StorageItem item = findStorageItem(category, id, em);
@@ -61,15 +67,13 @@
                 newItem.setId(id);
                 newItem.setCategory(category);
                 newItem.setDateModified(new Date());
-                newItem.setContent(IOUtils.toString(blob, "UTF-8"));
+                newItem.setContent(IOUtils.toString(blob, "UTF-8")); //$NON-NLS-1$
                 em.persist(newItem);
             } else { //update
                 item.setDateModified(new Date());
-                item.setContent(IOUtils.toString(blob, "UTF-8"));
+                item.setContent(IOUtils.toString(blob, "UTF-8")); //$NON-NLS-1$
             }
             em.getTransaction().commit();
-        } catch (Exception e) {
-            throw new IOException("Failed to write data", e);
         } finally {
             em.close();
         }
@@ -78,42 +82,79 @@
     @Override
     public InputStream read(String category, String id) throws IOException {
         EntityManager em = getEntityManager();
-
         ByteArrayInputStream returnStream = null;
         try {
             StorageItem item = findStorageItem(category, id, em);
             if (item != null) {
-                returnStream = new ByteArrayInputStream(item.getContent().getBytes("UTF-8"));
+                returnStream = new ByteArrayInputStream(item.getContent().getBytes("UTF-8")); //$NON-NLS-1$
             }
-        } catch (UnsupportedEncodingException e) {
-            throw new IOException("Failed to read data", e);
         } finally {
             em.close();
         }
-
         return returnStream;
     }
 
     @Override
     public void archive(String category, String id) throws IOException {
         EntityManager em = getEntityManager();
-
         try {
             em.getTransaction().begin();
-
-            // find original StorageItem
             StorageItem item = findStorageItem(category, id, em);
             if (item == null) {
-                // nothing to archive
                 return;
             }
-
-            // write to HistoryStorage
-            HistoryStorageItem histItem = new HistoryStorageItem(item);
+            HistoryStorageItem histItem = new HistoryStorageItem();
+            histItem.setCategory(category);
+            histItem.setId(id);
+            histItem.setContent(item.getContent());
+            histItem.setDateCreated(new Date());
             em.persist(histItem);
             em.getTransaction().commit();
-        } catch (Exception e) {
-            throw new IOException("Failed to archive data", e);
+        } finally {
+            em.close();
+        }
+    }
+
+    @Override
+    public void writeToArchive(String category, String id, long timestamp, InputStream blob) throws IOException {
+        EntityManager em = getEntityManager();
+        try {
+            em.getTransaction().begin();
+            HistoryStorageItem histItem = new HistoryStorageItem();
+            histItem.setCategory(category);
+            histItem.setId(id);
+            histItem.setContent(IOUtils.toString(blob, "UTF-8")); //$NON-NLS-1$
+            histItem.setDateCreated(new Date(timestamp));
+            em.persist(histItem);
+            em.getTransaction().commit();
+        } finally {
+            em.close();
+        }
+    }
+
+    @Override
+    public void readFromArchive(String category, String id, StorageConsumer consumer) throws IOException {
+        EntityManager em = getEntityManager();
+        try {
+            Query query = em.createNamedQuery("getItemsByCompositeKey", HistoryStorageItem.class); //$NON-NLS-1$
+            query.setHint(QueryHints.CURSOR, HintValues.TRUE);
+            query.setHint(QueryHints.CURSOR_INITIAL_SIZE, PAGE_SIZE);
+            query.setHint(QueryHints.CURSOR_PAGE_SIZE, PAGE_SIZE);
+            query.setParameter("category", category); //$NON-NLS-1$
+            query.setParameter("id", id); //$NON-NLS-1$
+            CursoredStream cursor = null;
+            try {
+                cursor = (CursoredStream) query.getSingleResult();
+                while (cursor.hasNext()) {
+                    HistoryStorageItem next = (HistoryStorageItem)cursor.next();
+                    consumer.consume(next.getCategory(), next.getId(), next.getDateCreated().getTime(),
+                            asStream(next.getContent()));
+                }
+            } finally {
+                if (cursor != null) {
+                    cursor.close();
+                }
+            }
         } finally {
             em.close();
         }
@@ -122,47 +163,22 @@
     @Override
     public List<String> keys(String category) throws IOException {
         EntityManager em = getEntityManager();
-
         List<String> resultList = new ArrayList<String>();
         try {
-            TypedQuery<String> query = em.createNamedQuery("getIdsByCategory", String.class);
-            query.setParameter("category", category);
+            TypedQuery<String> query = em.createNamedQuery("getIdsByCategory", String.class); //$NON-NLS-1$
+            query.setParameter("category", category); //$NON-NLS-1$
             resultList = query.getResultList();
-        } catch (Exception e) {
-            throw new IOException("Failed to retrieve IDs", e);
         } finally {
             em.close();
         }
-
         return resultList;
     }
 
-    public List<HistoryStorageItem> getHistory(String category, String id) throws IOException {
-        EntityManager em = getEntityManager();
-
-        List<HistoryStorageItem> resultList;
-        try {
-            TypedQuery<HistoryStorageItem> query = em.createNamedQuery("getItemsByCompositeKey",
-                    HistoryStorageItem.class);
-            query.setParameter("category", category);
-            query.setParameter("id", id);
-            resultList = query.getResultList();
-        } catch (Exception e) {
-            throw new IOException("Failed to retrieve historical data", e);
-        } finally {
-            em.close();
-        }
-
-        return resultList;
+    private static StorageItem findStorageItem(String category, String id, EntityManager em) {
+        return em.find(StorageItem.class, new StorageId(category, id));
     }
 
-    private StorageItem findStorageItem(String category, String id, EntityManager em) throws IOException {
-        StorageItem item;
-        try {
-            item = em.find(StorageItem.class, new StorageId(category, id));
-        } catch (Exception e) {
-            throw new IOException("Failed to find item", e);
-        }
-        return item;
+    private static InputStream asStream(String content) throws IOException {
+        return new ByteArrayInputStream(content.getBytes("UTF-8")); //$NON-NLS-1$
     }
 }
diff --git a/org.eclipse.skalli.jpa/src/main/java/org/eclipse/skalli/core/storage/jpa/StorageItem.java b/org.eclipse.skalli.jpa/src/main/java/org/eclipse/skalli/core/storage/jpa/StorageItem.java
index 4ae7ad7..25d8a98 100644
--- a/org.eclipse.skalli.jpa/src/main/java/org/eclipse/skalli/core/storage/jpa/StorageItem.java
+++ b/org.eclipse.skalli.jpa/src/main/java/org/eclipse/skalli/core/storage/jpa/StorageItem.java
@@ -69,5 +69,4 @@
     public void setDateModified(Date dateModified) {
         this.dateModified = dateModified;
     }
-
 }
\ No newline at end of file
diff --git a/org.eclipse.skalli.testutil/src/main/java/org/eclipse/skalli/testutil/HashMapStorageService.java b/org.eclipse.skalli.testutil/src/main/java/org/eclipse/skalli/testutil/HashMapStorageService.java
index 6e09b3d..0c85d17 100644
--- a/org.eclipse.skalli.testutil/src/main/java/org/eclipse/skalli/testutil/HashMapStorageService.java
+++ b/org.eclipse.skalli.testutil/src/main/java/org/eclipse/skalli/testutil/HashMapStorageService.java
@@ -22,6 +22,7 @@
 import java.util.Set;
 
 import org.apache.commons.io.IOUtils;
+import org.eclipse.skalli.services.persistence.StorageConsumer;
 import org.eclipse.skalli.services.persistence.StorageService;
 
 /**
@@ -59,17 +60,27 @@
         StorageKey key = keyOf(category, id);
         ByteArrayStorageItem item = store.get(key);
         if (item != null) {
-            List<ByteArrayStorageItem> items = archive.get(key);
-            if (items == null) {
-                items = new ArrayList<ByteArrayStorageItem>();
-                archive.put(key, items);
-            }
-            items.add(new ByteArrayStorageItem(key, IOUtils.toByteArray(item.getContent())));
+            writeToArchive(key, System.currentTimeMillis(), item.getContent());
         }
         return;
     }
 
     @Override
+    public void writeToArchive(String category, String id, long timestamp, InputStream blob) throws IOException {
+        writeToArchive(keyOf(category, id), timestamp, blob);
+    }
+
+    @Override
+    public void readFromArchive(String category, String id, StorageConsumer consumer) throws IOException {
+        List<ByteArrayStorageItem> items = archive.get(keyOf(category, id));
+        if (items != null) {
+            for (ByteArrayStorageItem next: items) {
+                consumer.consume(category, next.getId(), next.lastModified(), next.getContent());
+            }
+        }
+    }
+
+    @Override
     public List<String> keys(String category) throws IOException {
         List<String> result = new ArrayList<String>();
         Set<StorageKey> allKeys = store.keySet();
@@ -85,4 +96,13 @@
     public String toString() {
         return "HashMapStorageService [blobStore=" + store + "]";
     }
-}
\ No newline at end of file
+
+    private void writeToArchive(StorageKey key, long timestamp, InputStream blob) throws IOException {
+        List<ByteArrayStorageItem> items = archive.get(key);
+        if (items == null) {
+            items = new ArrayList<ByteArrayStorageItem>();
+            archive.put(key, items);
+        }
+        items.add(new ByteArrayStorageItem(key, timestamp, IOUtils.toByteArray(blob)));
+    }
+}
diff --git a/org.eclipse.skalli.testutil/src/main/java/org/eclipse/skalli/testutil/StorageServiceTestBase.java b/org.eclipse.skalli.testutil/src/main/java/org/eclipse/skalli/testutil/StorageServiceTestBase.java
index 3d42c05..fbdaee7 100644
--- a/org.eclipse.skalli.testutil/src/main/java/org/eclipse/skalli/testutil/StorageServiceTestBase.java
+++ b/org.eclipse.skalli.testutil/src/main/java/org/eclipse/skalli/testutil/StorageServiceTestBase.java
@@ -13,11 +13,14 @@
 import static org.junit.Assert.*;
 
 import java.io.ByteArrayInputStream;
+import java.io.IOException;
 import java.io.InputStream;
+import java.util.ArrayList;
 import java.util.Arrays;
 import java.util.List;
 
 import org.apache.commons.io.IOUtils;
+import org.eclipse.skalli.services.persistence.StorageConsumer;
 import org.eclipse.skalli.services.persistence.StorageService;
 import org.junit.Test;
 
@@ -43,29 +46,9 @@
     @Test
     public void testReadWrite() throws Exception {
         final String TEST_CATEGORY = "test_readwrite";
-
         writeContent(TEST_CATEGORY, TEST_ID, TEST_CONTENT);
-
         String outputText = readContent(TEST_CATEGORY);
-
         assertEquals(TEST_CONTENT, outputText);
-
-    }
-
-    private String readContent(final String TEST_CATEGORY) throws Exception {
-        StorageService storageService = getStorageService();
-        InputStream stream = storageService.read(TEST_CATEGORY, TEST_ID);
-        String outputText = IOUtils.toString(stream, "UTF-8");
-        stream.close();
-        return outputText;
-    }
-
-    private void writeContent(final String TEST_CATEGORY, String id, String content) throws Exception {
-        StorageService storageService = getStorageService();
-        ByteArrayInputStream is = new ByteArrayInputStream(content.getBytes("UTF-8"));
-        storageService.write(TEST_CATEGORY, TEST_ID, is);
-        is.close();
-        return;
     }
 
     @Test
@@ -127,4 +110,109 @@
 
         stream.close();
     }
+
+    @Test
+    public void testArchive() throws Exception {
+        final String TEST_CATEGORY = "test_archive";
+
+        StorageService storageService = getStorageService();
+        // initially empty
+        List<ByteArrayStorageItem> items = getHistory(storageService, TEST_CATEGORY, TEST_ID);
+        assertTrue(items.isEmpty());
+
+        // archive non existing element, should do nothing
+        storageService.archive(TEST_CATEGORY, TEST_ID);
+        items = getHistory(storageService, TEST_CATEGORY, TEST_ID);
+        assertTrue(items.isEmpty());
+
+        // create item
+        ByteArrayInputStream is = new ByteArrayInputStream(TEST_CONTENT.getBytes("UTF-8"));
+        storageService.write(TEST_CATEGORY, TEST_ID, is);
+
+        // first archive step
+        storageService.archive(TEST_CATEGORY, TEST_ID);
+        items = getHistory(storageService, TEST_CATEGORY, TEST_ID);
+        assertEquals(1, items.size());
+        assertEquals(TEST_CATEGORY, items.get(0).getCategory());
+        assertEquals(TEST_ID, items.get(0).getId());
+        assertEquals(TEST_CONTENT, IOUtils.toString(items.get(0).getContent()));
+        assertTrue(items.get(0).lastModified() != 0);
+
+        // second archive step
+        storageService.archive(TEST_CATEGORY, TEST_ID);
+        items = getHistory(storageService, TEST_CATEGORY, TEST_ID);
+        assertEquals(2, items.size());
+        assertTrue(items.get(0).lastModified() != 0);
+        assertEquals(TEST_ID, items.get(0).getId());
+        assertTrue(items.get(1).lastModified() != 0);
+        assertTrue(items.get(1).lastModified() >= items.get(0).lastModified());
+        assertEquals(TEST_ID, items.get(1).getId());
+    }
+
+    @Test
+    public void testReadWriteArchive() throws Exception {
+        final String TEST_CATEGORY = "test_large_archive";
+
+        StorageService storageService = getStorageService();
+
+        // create item
+        ByteArrayInputStream is = new ByteArrayInputStream(TEST_CONTENT.getBytes("UTF-8"));
+        storageService.write(TEST_CATEGORY, TEST_ID, is);
+
+        // create some archive entries
+        for (int i = 0; i < 10; ++i) {
+            storageService.archive(TEST_CATEGORY, TEST_ID);
+        }
+
+        // read these entries and check that the timestamps form a series
+        List<ByteArrayStorageItem> items = getHistory(storageService, TEST_CATEGORY, TEST_ID);
+        assertEquals(10, items.size());
+        for (int i = 0; i < 10; ++i) {
+            assertEquals(TEST_CATEGORY, items.get(i).getCategory());
+            assertEquals(TEST_ID, items.get(i).getId());
+            assertEquals(TEST_CONTENT, IOUtils.toString(items.get(i).getContent()));
+            if (i > 0) {
+                assertTrue(items.get(i).lastModified() >= items.get(i-1).lastModified());
+            }
+        }
+
+        // write an additional entry
+        long now = System.currentTimeMillis() + 4711;
+        storageService.writeToArchive(TEST_CATEGORY, TEST_ID, now, IOUtils.toInputStream(TEST_CONTENT_UPDATED));
+        items = getHistory(storageService, TEST_CATEGORY, TEST_ID);
+        assertEquals(11, items.size());
+
+        assertEquals(TEST_CATEGORY, items.get(10).getCategory());
+        assertEquals(TEST_ID, items.get(10).getId());
+        assertEquals(TEST_CONTENT_UPDATED, IOUtils.toString(items.get(10).getContent()));
+        assertEquals(now, items.get(10).lastModified());
+    }
+
+    private String readContent(final String TEST_CATEGORY) throws Exception {
+        StorageService storageService = getStorageService();
+        InputStream stream = storageService.read(TEST_CATEGORY, TEST_ID);
+        String outputText = IOUtils.toString(stream, "UTF-8");
+        stream.close();
+        return outputText;
+    }
+
+    private void writeContent(final String TEST_CATEGORY, String id, String content) throws Exception {
+        StorageService storageService = getStorageService();
+        ByteArrayInputStream is = new ByteArrayInputStream(content.getBytes("UTF-8"));
+        storageService.write(TEST_CATEGORY, TEST_ID, is);
+        is.close();
+        return;
+    }
+
+    private List<ByteArrayStorageItem> getHistory(StorageService storageService, String category, String id)
+            throws IOException {
+        final List<ByteArrayStorageItem> items = new ArrayList<ByteArrayStorageItem>();
+        storageService.readFromArchive(category, id, new StorageConsumer(){
+            @Override
+            public void consume(String category, String key, long lastModified, InputStream blob) throws IOException {
+                items.add(new ByteArrayStorageItem(category, key, lastModified, IOUtils.toByteArray(blob)));
+            }
+        });
+        return items;
+    }
 }