Implement ContentStore
diff --git a/src/main/java/com/intel/jndn/utils/ContentStore.java b/src/main/java/com/intel/jndn/utils/ContentStore.java
new file mode 100644
index 0000000..aad8a0d
--- /dev/null
+++ b/src/main/java/com/intel/jndn/utils/ContentStore.java
@@ -0,0 +1,66 @@
+/*
+ * jndn-utils
+ * Copyright (c) 2016, Intel Corporation.
+ *
+ * This program is free software; you can redistribute it and/or modify it
+ * under the terms and conditions of the GNU Lesser General Public License,
+ * version 3, as published by the Free Software Foundation.
+ *
+ * This program is distributed in the hope it will be useful, but WITHOUT ANY
+ * WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
+ * FOR A PARTICULAR PURPOSE.  See the GNU Lesser General Public License for
+ * more details.
+ */
+
+package com.intel.jndn.utils;
+
+import net.named_data.jndn.Face;
+import net.named_data.jndn.Name;
+import net.named_data.jndn.util.Blob;
+
+import java.io.IOException;
+
+/**
+ * TODO merge with Repository
+ *
+ * @author Andrew Brown, andrew.brown@intel.com
+ */
+public interface ContentStore {
+  /**
+   * Store some content under a name
+   *
+   * @param name the name of the content
+   * @param content the bytes of data
+   */
+  void put(Name name, Blob content);
+
+  /**
+   * Check if the content exists
+   *
+   * @param name the name of the content
+   * @return true if the content exists
+   */
+  boolean has(Name name);
+
+  /**
+   * Retrieve the content by name
+   *
+   * @param name the name of the content
+   * @return the content if it exists or null otherwise TODO throw instead? Optional?
+   */
+  Blob get(Name name);
+
+  /**
+   * Write the data under a given name to the face
+   *
+   * @param face the face to write to
+   * @param name the name of the data to write
+   * @throws IOException if the writing fails
+   */
+  void push(Face face, Name name) throws IOException;
+
+  /**
+   * Remove all stored content
+   */
+  void clear();
+}
diff --git a/src/main/java/com/intel/jndn/utils/Publisher.java b/src/main/java/com/intel/jndn/utils/Publisher.java
index 7755b5c..4a93084 100644
--- a/src/main/java/com/intel/jndn/utils/Publisher.java
+++ b/src/main/java/com/intel/jndn/utils/Publisher.java
@@ -16,9 +16,15 @@
 

 import net.named_data.jndn.util.Blob;

 

+import java.io.IOException;

+

 /**

  * @author Andrew Brown, andrew.brown@intel.com

  */

-public interface Publisher {

-  void publish(Blob message);

+public interface Publisher extends AutoCloseable {

+  /**

+   * @param message a binary blob to publish to a topic

+   * @throws IOException if the publication fails

+   */

+  void publish(Blob message) throws IOException;

 }

diff --git a/src/main/java/com/intel/jndn/utils/pubsub/ContentStore.java b/src/main/java/com/intel/jndn/utils/PushableRepository.java
similarity index 63%
rename from src/main/java/com/intel/jndn/utils/pubsub/ContentStore.java
rename to src/main/java/com/intel/jndn/utils/PushableRepository.java
index 34c6ac0..4da471a 100644
--- a/src/main/java/com/intel/jndn/utils/pubsub/ContentStore.java
+++ b/src/main/java/com/intel/jndn/utils/PushableRepository.java
@@ -1,35 +1,35 @@
-/*

- * jndn-utils

- * Copyright (c) 2016, Intel Corporation.

- *

- * This program is free software; you can redistribute it and/or modify it

- * under the terms and conditions of the GNU Lesser General Public License,

- * version 3, as published by the Free Software Foundation.

- *

- * This program is distributed in the hope it will be useful, but WITHOUT ANY

- * WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS

- * FOR A PARTICULAR PURPOSE.  See the GNU Lesser General Public License for

- * more details.

- */

-

-package com.intel.jndn.utils.pubsub;

-

-import net.named_data.jndn.Face;

-import net.named_data.jndn.Name;

-

-import java.io.IOException;

-

-/**

- * @author Andrew Brown, andrew.brown@intel.com

- */

-interface ContentStore<T> {

-  void put(Name name, T data);

-

-  boolean has(Name name);

-

-  T get(Name name);

-

-  void push(Face face, Name name) throws IOException;

-

-  void clear();

-}

+/*
+ * jndn-utils
+ * Copyright (c) 2016, Intel Corporation.
+ *
+ * This program is free software; you can redistribute it and/or modify it
+ * under the terms and conditions of the GNU Lesser General Public License,
+ * version 3, as published by the Free Software Foundation.
+ *
+ * This program is distributed in the hope it will be useful, but WITHOUT ANY
+ * WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
+ * FOR A PARTICULAR PURPOSE.  See the GNU Lesser General Public License for
+ * more details.
+ */
+
+package com.intel.jndn.utils;
+
+import net.named_data.jndn.Face;
+import net.named_data.jndn.Name;
+
+import java.io.IOException;
+
+/**
+ * @author Andrew Brown, andrew.brown@intel.com
+ */
+public interface PushableRepository extends Repository {
+  /**
+   * Write data to a face. Each name must correspond to one datum, but the implementation may choose to write these
+   * as separate data packets (e.g. as segments of a file).
+   *
+   * @param face the face to write the data to
+   * @param name the name of the data to write
+   * @throws IOException if the writing fails
+   */
+  void push(Face face, Name name) throws IOException;
+}
diff --git a/src/main/java/com/intel/jndn/utils/Subscriber.java b/src/main/java/com/intel/jndn/utils/Subscriber.java
index 4949826..89e5168 100644
--- a/src/main/java/com/intel/jndn/utils/Subscriber.java
+++ b/src/main/java/com/intel/jndn/utils/Subscriber.java
@@ -21,6 +21,11 @@
 /**

  * @author Andrew Brown, andrew.brown@intel.com

  */

-public interface Subscriber {

+public interface Subscriber extends AutoCloseable {

+  /**

+   * @param onMessage called every time a new message is received

+   * @param onError called every time an error occurs

+   * @return a cancellation token for stopping the subscription

+   */

   Cancellation subscribe(On<Blob> onMessage, On<Exception> onError);

 }

diff --git a/src/main/java/com/intel/jndn/utils/server/impl/SegmentedServerHelper.java b/src/main/java/com/intel/jndn/utils/impl/SegmentedServerHelper.java
similarity index 97%
rename from src/main/java/com/intel/jndn/utils/server/impl/SegmentedServerHelper.java
rename to src/main/java/com/intel/jndn/utils/impl/SegmentedServerHelper.java
index 68ab3eb..8fd4cdb 100644
--- a/src/main/java/com/intel/jndn/utils/server/impl/SegmentedServerHelper.java
+++ b/src/main/java/com/intel/jndn/utils/impl/SegmentedServerHelper.java
@@ -1,6 +1,6 @@
 /*
  * jndn-utils
- * Copyright (c) 2015, Intel Corporation.
+ * Copyright (c) 2016, Intel Corporation.
  *
  * This program is free software; you can redistribute it and/or modify it
  * under the terms and conditions of the GNU Lesser General Public License,
@@ -11,7 +11,8 @@
  * FOR A PARTICULAR PURPOSE.  See the GNU Lesser General Public License for
  * more details.
  */
-package com.intel.jndn.utils.server.impl;
+
+package com.intel.jndn.utils.impl;
 
 import net.named_data.jndn.Data;
 import net.named_data.jndn.Name;
diff --git a/src/main/java/com/intel/jndn/utils/pubsub/BlobContentStore.java b/src/main/java/com/intel/jndn/utils/pubsub/BlobContentStore.java
index 823bd85..375fae5 100644
--- a/src/main/java/com/intel/jndn/utils/pubsub/BlobContentStore.java
+++ b/src/main/java/com/intel/jndn/utils/pubsub/BlobContentStore.java
@@ -14,6 +14,7 @@
 

 package com.intel.jndn.utils.pubsub;

 

+import com.intel.jndn.utils.ContentStore;

 import net.named_data.jndn.Face;

 import net.named_data.jndn.Name;

 import net.named_data.jndn.util.Blob;

@@ -21,20 +22,26 @@
 /**

  * @author Andrew Brown, andrew.brown@intel.com

  */

-class BlobContentStore implements ContentStore<Blob> {

+class BlobContentStore implements ContentStore {

+  private final BoundedLinkedMap<Name, Blob> store;

+

+  BlobContentStore(int maxSize) {

+    this.store = new BoundedLinkedMap<>(maxSize);

+  }

+

   @Override

   public void put(Name name, Blob data) {

-    throw new UnsupportedOperationException();

+    store.put(name, data);

   }

 

   @Override

   public boolean has(Name name) {

-    return false;

+    return store.containsKey(name);

   }

 

   @Override

   public Blob get(Name name) {

-    throw new UnsupportedOperationException();

+    return store.get(name);

   }

 

   @Override

@@ -44,6 +51,6 @@
 

   @Override

   public void clear() {

-    throw new UnsupportedOperationException();

+    store.clear();

   }

 }

diff --git a/src/main/java/com/intel/jndn/utils/pubsub/BoundedLinkedMap.java b/src/main/java/com/intel/jndn/utils/pubsub/BoundedLinkedMap.java
new file mode 100644
index 0000000..8211fe1
--- /dev/null
+++ b/src/main/java/com/intel/jndn/utils/pubsub/BoundedLinkedMap.java
@@ -0,0 +1,159 @@
+/*
+ * jndn-utils
+ * Copyright (c) 2016, Intel Corporation.
+ *
+ * This program is free software; you can redistribute it and/or modify it
+ * under the terms and conditions of the GNU Lesser General Public License,
+ * version 3, as published by the Free Software Foundation.
+ *
+ * This program is distributed in the hope it will be useful, but WITHOUT ANY
+ * WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
+ * FOR A PARTICULAR PURPOSE.  See the GNU Lesser General Public License for
+ * more details.
+ */
+
+package com.intel.jndn.utils.pubsub;
+
+import java.util.Collection;
+import java.util.LinkedHashMap;
+import java.util.Map;
+import java.util.Set;
+
+/**
+ * Linked hash map exposing the earliest and latest entries added; it is bounded to a configurable size to save memory.
+ * When benchmarked against CircularBufferCache, this class had slower insertions but faster reads and was therefore
+ * retained for use.
+ * <p>
+ * It limits the amount of memory by replacing the oldest added element; this involves overriding the LinkedHashMap
+ * implementation's removeEldestEntry() method; see the <a href="https://docs.oracle.com/javase/8/docs/api/java/util/LinkedHashMap.html#removeEldestEntry-java.util.Map.Entry-">Javadoc
+ * entry</a> for more information.
+ * <p>
+ * Additionally, we implemented Josh Bloch's item 16 of Effective Java so that calls are forwarded to the underlying
+ * LinkedHashMap. This allows us to decorate with some custom behavior and synchronize as we need.
+ * <p>
+ * This class is coarsely thread-safe; every public method is synchronized for one-at-a-time access to the underlying
+ * map.
+ *
+ * @author Andrew Brown, andrew.brown@intel.com
+ */
+public class BoundedLinkedMap<K, V> implements Map<K, V> {
+  private final LinkedHashMap<K, V> map;
+  private final int maxSize;
+  private K latest;
+
+  /**
+   * @param maxSize the maximum allowed number of records to store
+   */
+  public BoundedLinkedMap(int maxSize) {
+    this.maxSize = maxSize;
+    this.map = new LinkedHashMap<K, V>(this.maxSize) {
+      @Override
+      public boolean removeEldestEntry(Map.Entry<K, V> eldest) {
+        return size() > maxSize;
+      }
+    };
+  }
+
+  /**
+   * @return the earliest key added to this set or null if none are added
+   */
+  public synchronized K earliest() {
+    for (K key : map.keySet()) {
+      return key; // the LinkedHashMap guarantees iteration in order of insertion
+    }
+    return null;
+  }
+
+  /**
+   * @return the latest key added to this set or null if none are added
+   */
+  public synchronized K latest() {
+    return latest;
+  }
+
+  @Override
+  public synchronized V put(K key, V value) {
+    latest = key;
+    return map.put(key, value);
+  }
+
+  @Override
+  public synchronized int size() {
+    return map.size();
+  }
+
+  @Override
+  public synchronized boolean isEmpty() {
+    return map.isEmpty();
+  }
+
+  @Override
+  public synchronized boolean containsKey(Object key) {
+    return map.containsKey(key);
+  }
+
+  @Override
+  public synchronized boolean containsValue(Object value) {
+    return map.containsValue(value);
+  }
+
+  @Override
+  public synchronized V get(Object key) {
+    return map.get(key);
+  }
+
+
+  @Override
+  public synchronized V remove(Object key) {
+    V value = map.remove(key);
+    if (key == latest) latest = findLatest(map);
+    return value;
+  }
+
+  @Override
+  public synchronized void putAll(Map<? extends K, ? extends V> m) {
+    map.putAll(m);
+    latest = findLatest(map);
+  }
+
+  @Override
+  public synchronized void clear() {
+    map.clear();
+    latest = null;
+  }
+
+  @Override
+  public synchronized Set<K> keySet() {
+    return map.keySet();
+  }
+
+  @Override
+  public synchronized Collection<V> values() {
+    return map.values();
+  }
+
+  @Override
+  public synchronized Set<java.util.Map.Entry<K, V>> entrySet() {
+    return map.entrySet();
+  }
+
+  @Override
+  public String toString() {
+    return map.toString();
+  }
+
+  /**
+   * To find the latest key in a LinkedHashMap, iterate and return the last one found. The LinkedHashMap guarantees
+   * iteration in order of insertion
+   *
+   * @param m the map to inspect
+   * @return the latest key added to the map
+   */
+  private K findLatest(LinkedHashMap<K, V> m) {
+    K newLatest = null;
+    for (K key : m.keySet()) {
+      newLatest = key;
+    }
+    return newLatest;
+  }
+}
diff --git a/src/main/java/com/intel/jndn/utils/pubsub/NdnPublisher.java b/src/main/java/com/intel/jndn/utils/pubsub/NdnPublisher.java
index 50469ea..f68e62b 100644
--- a/src/main/java/com/intel/jndn/utils/pubsub/NdnPublisher.java
+++ b/src/main/java/com/intel/jndn/utils/pubsub/NdnPublisher.java
@@ -1,11 +1,11 @@
 /*

  * jndn-utils

  * Copyright (c) 2016, Intel Corporation.

- *  

+ *

  * This program is free software; you can redistribute it and/or modify it

  * under the terms and conditions of the GNU Lesser General Public License,

  * version 3, as published by the Free Software Foundation.

- *  

+ *

  * This program is distributed in the hope it will be useful, but WITHOUT ANY

  * WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS

  * FOR A PARTICULAR PURPOSE.  See the GNU Lesser General Public License for

@@ -14,6 +14,7 @@
 

 package com.intel.jndn.utils.pubsub;

 

+import com.intel.jndn.utils.ContentStore;

 import com.intel.jndn.utils.Publisher;

 import net.named_data.jndn.Data;

 import net.named_data.jndn.Face;

@@ -39,17 +40,17 @@
  */

 class NdnPublisher implements Publisher, OnInterestCallback {

   private static final Logger LOGGER = Logger.getLogger(NdnPublisher.class.getName());

-  private final Face face; // TODO only needed in start, remove?

-  private final Name prefix; // TODO only needed in start, remove?

+  private final Face face;

+  private final Name prefix;

   private final AnnouncementService announcementService;

   private final PendingInterestTable pendingInterestTable;

-  private final ContentStore<Blob> contentStore;

+  private final ContentStore contentStore;

   private final long publisherId;

   private volatile long latestMessageId = 0;

   private long registrationId;

-  // TODO need pit

+  private boolean started = false;

 

-  NdnPublisher(Face face, Name prefix, long publisherId, AnnouncementService announcementService, PendingInterestTable pendingInterestTable, ContentStore<Blob> contentStore) {

+  NdnPublisher(Face face, Name prefix, long publisherId, AnnouncementService announcementService, PendingInterestTable pendingInterestTable, ContentStore contentStore) {

     this.face = face;

     this.prefix = prefix;

     this.publisherId = publisherId;

@@ -58,7 +59,8 @@
     this.contentStore = contentStore;

   }

 

-  public void start() throws RegistrationFailureException {

+  void start() throws RegistrationFailureException {

+    started = true;

     CompletableFuture<Void> future = new CompletableFuture<>();

     OnRegistration onRegistration = new OnRegistration(future);

 

@@ -71,7 +73,8 @@
     }

   }

 

-  public void stop() throws IOException {

+  @Override

+  public void close() throws IOException {

     face.removeRegisteredPrefix(registrationId);

     contentStore.clear();

     announcementService.announceExit(publisherId);

@@ -79,18 +82,26 @@
 

   // TODO should throw IOException?

   @Override

-  public void publish(Blob message) {

+  public void publish(Blob message) throws IOException {

+    if (!started) {

+      try {

+        start();

+      } catch (RegistrationFailureException e) {

+        throw new IOException(e);

+      }

+    }

+

     long id = latestMessageId++; // TODO synchronize?

     Name name = PubSubNamespace.toMessageName(prefix, publisherId, id);

 

     contentStore.put(name, message);

     LOGGER.log(Level.INFO, "Published message {0} to content store", id);

 

-    if(pendingInterestTable.has(new Interest(name))){

+    if (pendingInterestTable.has(new Interest(name))) {

       try {

         contentStore.push(face, name);

       } catch (IOException e) {

-        LOGGER.log(Level.SEVERE, "Failed to send message {0} for pending interests: {1}", new Object[]{id, name});

+        LOGGER.log(Level.SEVERE, "Failed to send message {0} for pending interests: {1}", new Object[]{id, name, e});

       }

     }

   }

@@ -98,7 +109,7 @@
   @Override

   public void onInterest(Name name, Interest interest, Face face, long registrationId, InterestFilter interestFilter) {

     try {

-      if(contentStore.has(interest.getName())){

+      if (contentStore.has(interest.getName())) {

         contentStore.push(face, interest.getName());

       } else {

         pendingInterestTable.add(interest);

@@ -116,5 +127,4 @@
       LOGGER.log(Level.SEVERE, "Failed to decode message ID for interest: " + interest.toUri(), e);

     }

   }

-

 }

diff --git a/src/main/java/com/intel/jndn/utils/pubsub/NdnSubscriber.java b/src/main/java/com/intel/jndn/utils/pubsub/NdnSubscriber.java
index 83e3f28..e2deebd 100644
--- a/src/main/java/com/intel/jndn/utils/pubsub/NdnSubscriber.java
+++ b/src/main/java/com/intel/jndn/utils/pubsub/NdnSubscriber.java
@@ -23,12 +23,12 @@
 import net.named_data.jndn.encoding.EncodingException;

 import net.named_data.jndn.util.Blob;

 

-import java.util.HashSet;

+import java.util.HashMap;

+import java.util.Map;

 import java.util.Set;

 import java.util.concurrent.CompletableFuture;

 import java.util.logging.Level;

 import java.util.logging.Logger;

-import java.util.stream.Collectors;

 

 /**

  * @author Andrew Brown, andrew.brown@intel.com

@@ -39,7 +39,7 @@
   private final Name prefix;

   private final AnnouncementService announcementService;

   private final Client client;

-  private final Set<Context> known = new HashSet<>();

+  private final Map<Long, Context> known = new HashMap<>();

   private Cancellation newAnnouncementCancellation;

   private Cancellation existingAnnouncementsCancellation;

   private volatile boolean started = false;

@@ -54,21 +54,26 @@
   private void start() throws RegistrationFailureException {

     LOGGER.log(Level.INFO, "Starting subscriber");

 

-    existingAnnouncementsCancellation = announcementService.discoverExistingAnnouncements(this::add, null, e -> stop());

-    newAnnouncementCancellation = announcementService.observeNewAnnouncements(this::add, this::remove, e -> stop());

+    existingAnnouncementsCancellation = announcementService.discoverExistingAnnouncements(this::add, null, e -> close());

+    newAnnouncementCancellation = announcementService.observeNewAnnouncements(this::add, this::remove, e -> close());

 

     started = true;

   }

 

   private void add(long publisherId) {

-    known.add(new Context(publisherId));

+    if (known.containsKey(publisherId)) {

+      LOGGER.log(Level.WARNING, "Duplicate publisher ID {} received from announcement service; this should not happen and will be ignored", publisherId);

+    } else {

+      known.put(publisherId, new Context(publisherId));

+    }

   }

 

   private void remove(long publisherId) {

-    known.remove(publisherId); // TODO incorrect

+    known.remove(publisherId);

   }

 

-  private void stop() {

+  @Override

+  public void close() {

     LOGGER.log(Level.INFO, "Stopping subscriber, knows of {0} publishers: {1} ", new Object[]{known.size(), known});

 

     if (newAnnouncementCancellation != null) {

@@ -79,7 +84,7 @@
       existingAnnouncementsCancellation.cancel();

     }

 

-    for (Context c : known) {

+    for (Context c : known.values()) {

       c.cancel();

     }

 

@@ -87,7 +92,7 @@
   }

 

   Set<Long> knownPublishers() {

-    return known.stream().map(c -> c.publisherId).collect(Collectors.toSet());

+    return known.keySet();

   }

 

   // TODO repeated calls?

@@ -103,11 +108,11 @@
       }

     }

 

-    for (Context c : known) {

+    for (Context c : known.values()) {

       c.subscribe(onMessage, onError);

     }

 

-    return this::stop;

+    return this::close;

   }

 

   private class Context implements Cancellation {

@@ -180,8 +185,12 @@
 

     @Override

     public boolean equals(Object o) {

-      if (this == o) return true;

-      if (o == null || getClass() != o.getClass()) return false;

+      if (this == o) {

+        return true;

+      }

+      if (o == null || getClass() != o.getClass()) {

+        return false;

+      }

       Context context = (Context) o;

       return publisherId == context.publisherId;

     }

diff --git a/src/main/java/com/intel/jndn/utils/pubsub/Topic.java b/src/main/java/com/intel/jndn/utils/pubsub/Topic.java
index 71c7d9f..4a60b1c 100644
--- a/src/main/java/com/intel/jndn/utils/pubsub/Topic.java
+++ b/src/main/java/com/intel/jndn/utils/pubsub/Topic.java
@@ -20,6 +20,8 @@
 import net.named_data.jndn.Face;

 import net.named_data.jndn.Name;

 

+import java.util.Random;

+

 /**

  * @author Andrew Brown, andrew.brown@intel.com

  */

@@ -41,6 +43,6 @@
 

   // TODO move to PubSubFactory?

   public Publisher newPublisher(Face face) {

-    return new NdnPublisher(face, name, 23, new NdnAnnouncementService(face, name), new ForLoopPendingInterestTable(), new BlobContentStore());

+    return new NdnPublisher(face, name, new Random().nextLong(), new NdnAnnouncementService(face, name), new ForLoopPendingInterestTable(), new BlobContentStore(1024));

   }

 }

diff --git a/src/test/java/com/intel/jndn/utils/pubsub/BoundedLinkedMapTest.java b/src/test/java/com/intel/jndn/utils/pubsub/BoundedLinkedMapTest.java
new file mode 100644
index 0000000..3107db6
--- /dev/null
+++ b/src/test/java/com/intel/jndn/utils/pubsub/BoundedLinkedMapTest.java
@@ -0,0 +1,177 @@
+/*
+ * jndn-utils
+ * Copyright (c) 2016, Intel Corporation.
+ *
+ * This program is free software; you can redistribute it and/or modify it
+ * under the terms and conditions of the GNU Lesser General Public License,
+ * version 3, as published by the Free Software Foundation.
+ *
+ * This program is distributed in the hope it will be useful, but WITHOUT ANY
+ * WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
+ * FOR A PARTICULAR PURPOSE.  See the GNU Lesser General Public License for
+ * more details.
+ */
+
+package com.intel.jndn.utils.pubsub;
+
+import org.junit.Before;
+import org.junit.Test;
+
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.function.Consumer;
+import java.util.logging.Level;
+import java.util.logging.Logger;
+
+import static org.junit.Assert.*;
+
+/**
+ * Test BoundedLinkedMapTest
+ *
+ * @author Andrew Brown, andrew.brown@intel.com
+ */
+public class BoundedLinkedMapTest {
+  private static final Logger LOGGER = Logger.getLogger(BoundedLinkedMapTest.class.getName());
+  private BoundedLinkedMap<String, Object> instance;
+
+  @Before
+  public void beforeTest() {
+    instance = new BoundedLinkedMap<>(2);
+  }
+
+  @Test
+  public void testUsage() {
+    Object object0 = new Object();
+    Object object1 = new Object();
+    Object object2 = new Object();
+
+    instance.put("0", object0);
+    assertEquals(1, instance.size());
+
+    instance.put("1", object1);
+    assertEquals(2, instance.size());
+
+    instance.put("2", object2);
+    assertEquals(2, instance.size());
+
+    assertNull(instance.get("0"));
+    assertEquals("2", instance.latest());
+  }
+
+  @Test
+  public void testEarliestLatest() {
+    assertNull(instance.earliest());
+    assertNull(instance.latest());
+
+    instance.put(".", new Object());
+    assertEquals(instance.earliest(), instance.latest());
+
+    instance.put("..", new Object());
+    assertEquals(".", instance.earliest());
+    assertEquals("..", instance.latest());
+
+    instance.put("...", new Object());
+    assertEquals("..", instance.earliest());
+    assertEquals("...", instance.latest());
+  }
+
+  @Test
+  public void testIsEmpty() {
+    assertTrue(instance.isEmpty());
+    instance.put("...", new Object());
+    assertFalse(instance.isEmpty());
+  }
+
+  @Test
+  public void testContainsKey() {
+    assertFalse(instance.containsKey("..."));
+    instance.put("...", new Object());
+    assertTrue(instance.containsKey("..."));
+  }
+
+  @Test
+  public void testContainsValue() {
+    Object o = new Object();
+    assertFalse(instance.containsValue(o));
+    instance.put("...", o);
+    assertTrue(instance.containsValue(o));
+  }
+
+  @Test
+  public void testRemove() {
+    Object o = new Object();
+    String key = "...";
+
+    instance.put(key, o);
+    assertTrue(instance.containsKey(key));
+    assertTrue(instance.containsValue(o));
+
+    instance.remove(key);
+    assertFalse(instance.containsKey(key));
+    assertFalse(instance.containsValue(o));
+    assertEquals(0, instance.size());
+  }
+
+  @Test
+  public void testPutAll() {
+    HashMap<String, Object> map = new HashMap<>();
+    map.put("1", new Object());
+    map.put("2", new Object());
+    map.put("99", new Object());
+
+    instance.putAll(map);
+    LOGGER.log(Level.FINE, "Map passed to putAll(): {0}", map.toString());
+    LOGGER.log(Level.FINE, "Resulting bounded map after putAll(): {0}", instance.toString());
+
+    assertEquals(2, instance.size()); // note: this is not 3 because the max size is bounded
+    assertEquals("2", instance.latest()); // note: put all doesn't do the FIFO replacement
+  }
+
+  @Test
+  public void testClear() {
+    instance.put("...", new Object());
+
+    instance.clear();
+
+    assertEquals(0, instance.size());
+    assertNull(instance.get("..."));
+    assertNull(instance.latest());
+    assertNull(instance.earliest());
+  }
+
+  @Test
+  public void testConversions() {
+    instance.put("...", new Object());
+
+    assertEquals(1, instance.keySet().size());
+    assertEquals(1, instance.values().size());
+    assertEquals(1, instance.entrySet().size());
+  }
+
+  @Test
+  public void testPerformanceAgainstArrayList() {
+    int numMessages = 10000;
+    BoundedLinkedMap<Integer, Object> map = new BoundedLinkedMap<>(numMessages);
+    ArrayList<Object> list = new ArrayList<>(numMessages);
+
+    long mapPutTime = measure(numMessages, i -> map.put(i, new Object()));
+    long listPutTime = measure(numMessages, i -> list.add(i, new Object()));
+    LOGGER.log(Level.FINE, "Custom map put has overhead of {0}% versus list put", toPercent((mapPutTime - listPutTime) / (double) listPutTime));
+
+    long mapGetTime = measure(numMessages, map::get);
+    long listGetTime = measure(numMessages, list::get);
+    LOGGER.log(Level.FINE, "Custom map get has overhead of {0}% versus list get", toPercent((mapGetTime - listGetTime) / (double) listPutTime));
+  }
+
+  private long measure(int numTimes, Consumer<Integer> work) {
+    long start = System.nanoTime();
+    for (int i = 0; i < numTimes; i++) {
+      work.accept(i);
+    }
+    return System.nanoTime() - start;
+  }
+
+  private double toPercent(double number) {
+    return Math.round(number * 100);
+  }
+}
\ No newline at end of file