diff --git a/bramble-api/src/test/java/org/briarproject/bramble/test/BrambleTestCase.java b/bramble-api/src/test/java/org/briarproject/bramble/test/BrambleTestCase.java
index 9fae780434640f1dbf9132a64135b48881108324..4467f2f4704c837060d8ecbe9321e01631c34e06 100644
--- a/bramble-api/src/test/java/org/briarproject/bramble/test/BrambleTestCase.java
+++ b/bramble-api/src/test/java/org/briarproject/bramble/test/BrambleTestCase.java
@@ -1,7 +1,9 @@
 package org.briarproject.bramble.test;
 
 import java.lang.Thread.UncaughtExceptionHandler;
+import java.util.logging.Logger;
 
+import static java.util.logging.Level.OFF;
 import static org.junit.Assert.fail;
 
 public abstract class BrambleTestCase {
@@ -13,5 +15,7 @@ public abstract class BrambleTestCase {
 			fail();
 		};
 		Thread.setDefaultUncaughtExceptionHandler(fail);
+		// Disable logging
+		Logger.getLogger("").setLevel(OFF);
 	}
 }
diff --git a/bramble-api/src/test/java/org/briarproject/bramble/test/TestUtils.java b/bramble-api/src/test/java/org/briarproject/bramble/test/TestUtils.java
index b07f79d5fc735a5425f0d6f9d383b65baccf8bdd..3d1aa37d117c9f3987d5eb181d63a53b0d128b9b 100644
--- a/bramble-api/src/test/java/org/briarproject/bramble/test/TestUtils.java
+++ b/bramble-api/src/test/java/org/briarproject/bramble/test/TestUtils.java
@@ -2,12 +2,27 @@ package org.briarproject.bramble.test;
 
 import org.briarproject.bramble.api.UniqueId;
 import org.briarproject.bramble.api.crypto.SecretKey;
+import org.briarproject.bramble.api.identity.Author;
+import org.briarproject.bramble.api.identity.AuthorId;
+import org.briarproject.bramble.api.identity.LocalAuthor;
+import org.briarproject.bramble.api.sync.ClientId;
+import org.briarproject.bramble.api.sync.Group;
+import org.briarproject.bramble.api.sync.GroupId;
 import org.briarproject.bramble.util.IoUtils;
 
 import java.io.File;
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.Collections;
+import java.util.List;
 import java.util.Random;
 import java.util.concurrent.atomic.AtomicInteger;
 
+import static org.briarproject.bramble.api.identity.AuthorConstants.MAX_AUTHOR_NAME_LENGTH;
+import static org.briarproject.bramble.api.identity.AuthorConstants.MAX_PUBLIC_KEY_LENGTH;
+import static org.briarproject.bramble.api.sync.SyncConstants.MAX_GROUP_DESCRIPTOR_LENGTH;
+import static org.briarproject.bramble.util.StringUtils.getRandomString;
+
 public class TestUtils {
 
 	private static final AtomicInteger nextTestDir =
@@ -38,4 +53,59 @@ public class TestUtils {
 		return new SecretKey(getRandomBytes(SecretKey.LENGTH));
 	}
 
+	public static LocalAuthor getLocalAuthor() {
+		AuthorId id = new AuthorId(getRandomId());
+		String name = getRandomString(MAX_AUTHOR_NAME_LENGTH);
+		byte[] publicKey = getRandomBytes(MAX_PUBLIC_KEY_LENGTH);
+		byte[] privateKey = getRandomBytes(MAX_PUBLIC_KEY_LENGTH);
+		long created = System.currentTimeMillis();
+		return new LocalAuthor(id, name, publicKey, privateKey, created);
+	}
+
+	public static Author getAuthor() {
+		AuthorId id = new AuthorId(getRandomId());
+		String name = getRandomString(MAX_AUTHOR_NAME_LENGTH);
+		byte[] publicKey = getRandomBytes(MAX_PUBLIC_KEY_LENGTH);
+		return new Author(id, name, publicKey);
+	}
+
+	public static Group getGroup(ClientId clientId) {
+		GroupId groupId = new GroupId(getRandomId());
+		byte[] descriptor = getRandomBytes(MAX_GROUP_DESCRIPTOR_LENGTH);
+		return new Group(groupId, clientId, descriptor);
+	}
+
+	public static double getMedian(Collection<? extends Number> samples) {
+		int size = samples.size();
+		if (size == 0) throw new IllegalArgumentException();
+		List<Double> sorted = new ArrayList<Double>(size);
+		for (Number n : samples) sorted.add(n.doubleValue());
+		Collections.sort(sorted);
+		if (size % 2 == 1) return sorted.get(size / 2);
+		double low = sorted.get(size / 2 - 1), high = sorted.get(size / 2);
+		return (low + high) / 2;
+	}
+
+	public static double getMean(Collection<? extends Number> samples) {
+		if (samples.isEmpty()) throw new IllegalArgumentException();
+		double sum = 0;
+		for (Number n : samples) sum += n.doubleValue();
+		return sum / samples.size();
+	}
+
+	public static double getVariance(Collection<? extends Number> samples) {
+		if (samples.size() < 2) throw new IllegalArgumentException();
+		double mean = getMean(samples);
+		double sumSquareDiff = 0;
+		for (Number n : samples) {
+			double diff = n.doubleValue() - mean;
+			sumSquareDiff += diff * diff;
+		}
+		return sumSquareDiff / (samples.size() - 1);
+	}
+
+	public static double getStandardDeviation(
+			Collection<? extends Number> samples) {
+		return Math.sqrt(getVariance(samples));
+	}
 }
diff --git a/bramble-core/src/test/java/org/briarproject/bramble/db/BenchmarkTask.java b/bramble-core/src/test/java/org/briarproject/bramble/db/BenchmarkTask.java
new file mode 100644
index 0000000000000000000000000000000000000000..a00a6c28c5113fbd1c40540f4b258c729712bfa0
--- /dev/null
+++ b/bramble-core/src/test/java/org/briarproject/bramble/db/BenchmarkTask.java
@@ -0,0 +1,10 @@
+package org.briarproject.bramble.db;
+
+import org.briarproject.bramble.api.db.DbException;
+
+interface BenchmarkTask<T> {
+
+	void prepareBenchmark(Database<T> db) throws DbException;
+
+	void runBenchmark(Database<T> db) throws DbException;
+}
diff --git a/bramble-core/src/test/java/org/briarproject/bramble/db/H2DatabasePerformanceTest.java b/bramble-core/src/test/java/org/briarproject/bramble/db/H2DatabasePerformanceTest.java
new file mode 100644
index 0000000000000000000000000000000000000000..86d9b11d50675065b079991983e34fecbfa5194e
--- /dev/null
+++ b/bramble-core/src/test/java/org/briarproject/bramble/db/H2DatabasePerformanceTest.java
@@ -0,0 +1,19 @@
+package org.briarproject.bramble.db;
+
+import org.briarproject.bramble.api.db.DatabaseConfig;
+import org.briarproject.bramble.api.system.Clock;
+import org.junit.Ignore;
+
+@Ignore
+public class H2DatabasePerformanceTest extends JdbcDatabasePerformanceTest {
+
+	@Override
+	protected String getTestName() {
+		return H2DatabasePerformanceTest.class.getSimpleName();
+	}
+
+	@Override
+	protected JdbcDatabase createDatabase(DatabaseConfig config, Clock clock) {
+		return new H2Database(config, clock);
+	}
+}
diff --git a/bramble-core/src/test/java/org/briarproject/bramble/db/HyperSqlDatabasePerformanceTest.java b/bramble-core/src/test/java/org/briarproject/bramble/db/HyperSqlDatabasePerformanceTest.java
new file mode 100644
index 0000000000000000000000000000000000000000..a1b48333c30b52a7968d3553a339b68d3cedb188
--- /dev/null
+++ b/bramble-core/src/test/java/org/briarproject/bramble/db/HyperSqlDatabasePerformanceTest.java
@@ -0,0 +1,20 @@
+package org.briarproject.bramble.db;
+
+import org.briarproject.bramble.api.db.DatabaseConfig;
+import org.briarproject.bramble.api.system.Clock;
+import org.junit.Ignore;
+
+@Ignore
+public class HyperSqlDatabasePerformanceTest
+		extends JdbcDatabasePerformanceTest {
+
+	@Override
+	protected String getTestName() {
+		return HyperSqlDatabasePerformanceTest.class.getSimpleName();
+	}
+
+	@Override
+	protected JdbcDatabase createDatabase(DatabaseConfig config, Clock clock) {
+		return new HyperSqlDatabase(config, clock);
+	}
+}
diff --git a/bramble-core/src/test/java/org/briarproject/bramble/db/JdbcDatabasePerformanceTest.java b/bramble-core/src/test/java/org/briarproject/bramble/db/JdbcDatabasePerformanceTest.java
new file mode 100644
index 0000000000000000000000000000000000000000..88172b4b301b2d6c34d3e1e5afb810abab5faa1d
--- /dev/null
+++ b/bramble-core/src/test/java/org/briarproject/bramble/db/JdbcDatabasePerformanceTest.java
@@ -0,0 +1,756 @@
+package org.briarproject.bramble.db;
+
+import org.briarproject.bramble.api.db.DatabaseConfig;
+import org.briarproject.bramble.api.db.DbException;
+import org.briarproject.bramble.api.db.Metadata;
+import org.briarproject.bramble.api.identity.LocalAuthor;
+import org.briarproject.bramble.api.sync.ClientId;
+import org.briarproject.bramble.api.sync.Group;
+import org.briarproject.bramble.api.sync.GroupId;
+import org.briarproject.bramble.api.sync.Message;
+import org.briarproject.bramble.api.sync.MessageId;
+import org.briarproject.bramble.api.sync.ValidationManager.State;
+import org.briarproject.bramble.api.system.Clock;
+import org.briarproject.bramble.system.SystemClock;
+import org.briarproject.bramble.test.BrambleTestCase;
+import org.briarproject.bramble.test.TestDatabaseConfig;
+import org.junit.After;
+import org.junit.Before;
+import org.junit.Test;
+
+import java.io.File;
+import java.io.FileOutputStream;
+import java.io.PrintWriter;
+import java.sql.Connection;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Random;
+
+import static org.briarproject.bramble.api.sync.SyncConstants.MAX_MESSAGE_LENGTH;
+import static org.briarproject.bramble.api.sync.ValidationManager.State.DELIVERED;
+import static org.briarproject.bramble.api.sync.ValidationManager.State.PENDING;
+import static org.briarproject.bramble.api.sync.ValidationManager.State.UNKNOWN;
+import static org.briarproject.bramble.test.TestUtils.deleteTestDirectory;
+import static org.briarproject.bramble.test.TestUtils.getAuthor;
+import static org.briarproject.bramble.test.TestUtils.getGroup;
+import static org.briarproject.bramble.test.TestUtils.getLocalAuthor;
+import static org.briarproject.bramble.test.TestUtils.getMean;
+import static org.briarproject.bramble.test.TestUtils.getMedian;
+import static org.briarproject.bramble.test.TestUtils.getRandomBytes;
+import static org.briarproject.bramble.test.TestUtils.getRandomId;
+import static org.briarproject.bramble.test.TestUtils.getStandardDeviation;
+import static org.briarproject.bramble.test.TestUtils.getTestDirectory;
+import static org.briarproject.bramble.util.StringUtils.getRandomString;
+import static org.junit.Assert.assertTrue;
+
+public abstract class JdbcDatabasePerformanceTest extends BrambleTestCase {
+
+	private static final int ONE_MEGABYTE = 1024 * 1024;
+	private static final int MAX_SIZE = 100 * ONE_MEGABYTE;
+	private static final int CLIENT_ID_LENGTH = 100;
+	private static final int METADATA_KEY_LENGTH = 100;
+	private static final int METADATA_VALUE_LENGTH = 100;
+
+	/**
+	 * Skip test cases that create more than this many rows.
+	 */
+	private static final int MAX_ROWS = 100 * 1000;
+
+	/**
+	 * How many times to run the benchmark before measuring, to warm up the JIT.
+	 */
+	private static final int WARMUP_ITERATIONS = 100;
+
+	/**
+	 * How many times to run the benchmark while measuring.
+	 */
+	private static final int MEASUREMENT_ITERATIONS = 100;
+
+	/**
+	 * How much time to allow for background operations to complete after
+	 * preparing the benchmark.
+	 */
+	private static final int SLEEP_BEFORE_MEASUREMENT_MS = 500;
+
+	private final File testDir = getTestDirectory();
+	private final File resultsFile = getResultsFile();
+
+	protected abstract String getTestName();
+
+	protected abstract JdbcDatabase createDatabase(DatabaseConfig config,
+			Clock clock);
+
+	@Before
+	public void setUp() {
+		assertTrue(testDir.mkdirs());
+	}
+
+	@After
+	public void tearDown() {
+		deleteTestDirectory(testDir);
+	}
+
+	private File getResultsFile() {
+		long timestamp = System.currentTimeMillis();
+		return new File(getTestName() + "-" + timestamp + ".tsv");
+	}
+
+	@Test
+	public void testAddContact() throws Exception {
+		for (int contacts : new int[] {0, 1, 10, 100, 1000}) {
+			testAddContact(contacts);
+		}
+	}
+
+	private void testAddContact(final int contacts) throws Exception {
+		String name = "addContact(T, Author, AuthorId, boolean, boolean)";
+		Map<String, Object> args =
+				Collections.<String, Object>singletonMap("contacts", contacts);
+
+		benchmark(name, args, new BenchmarkTask<Connection>() {
+
+			private final LocalAuthor localAuthor = getLocalAuthor();
+
+			@Override
+			public void prepareBenchmark(Database<Connection> db)
+					throws DbException {
+				Connection txn = db.startTransaction();
+				db.addLocalAuthor(txn, localAuthor);
+				for (int i = 0; i < contacts; i++) {
+					db.addContact(txn, getAuthor(), localAuthor.getId(), true,
+							true);
+				}
+				db.commitTransaction(txn);
+			}
+
+			@Override
+			public void runBenchmark(Database<Connection> db)
+					throws DbException {
+				Connection txn = db.startTransaction();
+				db.addContact(txn, getAuthor(), localAuthor.getId(), true,
+						true);
+				db.commitTransaction(txn);
+			}
+		});
+	}
+
+	@Test
+	public void testAddGroup() throws Exception {
+		for (int groups : new int[] {0, 1, 10, 100, 1000}) {
+			testAddGroup(groups);
+		}
+	}
+
+	private void testAddGroup(final int groups) throws Exception {
+		String name = "addGroup(T, group)";
+		Map<String, Object> args =
+				Collections.<String, Object>singletonMap("groups", groups);
+
+		benchmark(name, args, new BenchmarkTask<Connection>() {
+
+			private final ClientId clientId = getClientId();
+
+			@Override
+			public void prepareBenchmark(Database<Connection> db)
+					throws DbException {
+				Connection txn = db.startTransaction();
+				for (int i = 0; i < groups; i++)
+					db.addGroup(txn, getGroup(clientId));
+				db.commitTransaction(txn);
+			}
+
+			@Override
+			public void runBenchmark(Database<Connection> db)
+					throws DbException {
+				Connection txn = db.startTransaction();
+				db.addGroup(txn, getGroup(clientId));
+				db.commitTransaction(txn);
+			}
+		});
+	}
+
+	@Test
+	public void testGetContacts() throws Exception {
+		for (int contacts : new int[] {1, 10, 100, 1000}) {
+			testGetContacts(contacts);
+		}
+	}
+
+	private void testGetContacts(final int contacts) throws Exception {
+		String name = "getContacts(T)";
+		Map<String, Object> args =
+				Collections.<String, Object>singletonMap("contacts", contacts);
+
+		benchmark(name, args, new BenchmarkTask<Connection>() {
+
+			@Override
+			public void prepareBenchmark(Database<Connection> db)
+					throws DbException {
+				LocalAuthor localAuthor = getLocalAuthor();
+				Connection txn = db.startTransaction();
+				db.addLocalAuthor(txn, localAuthor);
+				for (int i = 0; i < contacts; i++) {
+					db.addContact(txn, getAuthor(), localAuthor.getId(), true,
+							true);
+				}
+				db.commitTransaction(txn);
+			}
+
+			@Override
+			public void runBenchmark(Database<Connection> db)
+					throws DbException {
+				Connection txn = db.startTransaction();
+				db.getContacts(txn);
+				db.commitTransaction(txn);
+			}
+		});
+	}
+
+	@Test
+	public void testGetRawMessage() throws Exception {
+		for (int messages : new int[] {1, 100, 10000}) {
+			testGetRawMessage(messages);
+		}
+	}
+
+	private void testGetRawMessage(final int messages) throws Exception {
+		String name = "getRawMessage(T, MessageId)";
+		Map<String, Object> args =
+				Collections.<String, Object>singletonMap("messages", messages);
+
+		benchmark(name, args, new BenchmarkTask<Connection>() {
+
+			private MessageId messageId;
+
+			@Override
+			public void prepareBenchmark(Database<Connection> db)
+					throws DbException {
+				Group group = getGroup(getClientId());
+				Connection txn = db.startTransaction();
+				db.addGroup(txn, group);
+				for (int i = 0; i < messages; i++) {
+					Message m = getMessage(group.getId());
+					if (i == 0) messageId = m.getId();
+					db.addMessage(txn, m, DELIVERED, false);
+				}
+				db.commitTransaction(txn);
+			}
+
+			@Override
+			public void runBenchmark(Database<Connection> db)
+					throws DbException {
+				Connection txn = db.startTransaction();
+				db.getRawMessage(txn, messageId);
+				db.commitTransaction(txn);
+			}
+		});
+	}
+
+	@Test
+	public void testGetMessageIds() throws Exception {
+		for (int groups : new int[] {1, 10, 100}) {
+			for (int messagesPerGroup : new int[] {1, 10, 100}) {
+				int rows = groups * messagesPerGroup;
+				if (rows > MAX_ROWS) continue;
+				testGetMessageIds(groups, messagesPerGroup);
+			}
+		}
+	}
+
+	private void testGetMessageIds(final int groups, final int messagesPerGroup)
+			throws Exception {
+		String name = "getMessageIds(T, GroupId)";
+		Map<String, Object> args = new LinkedHashMap<String, Object>();
+		args.put("groups", groups);
+		args.put("messagesPerGroup", messagesPerGroup);
+
+		benchmark(name, args, new BenchmarkTask<Connection>() {
+
+			private GroupId groupId;
+
+			@Override
+			public void prepareBenchmark(Database<Connection> db)
+					throws DbException {
+				ClientId clientId = getClientId();
+				Connection txn = db.startTransaction();
+				for (int i = 0; i < groups; i++) {
+					Group g = getGroup(clientId);
+					if (i == 0) groupId = g.getId();
+					db.addGroup(txn, g);
+					for (int j = 0; j < messagesPerGroup; j++) {
+						Message m = getMessage(g.getId());
+						db.addMessage(txn, m, DELIVERED, false);
+					}
+				}
+				db.commitTransaction(txn);
+			}
+
+			@Override
+			public void runBenchmark(Database<Connection> db)
+					throws DbException {
+				Connection txn = db.startTransaction();
+				db.getMessageIds(txn, groupId);
+				db.commitTransaction(txn);
+			}
+		});
+	}
+
+	@Test
+	public void testGetMessageIdsWithQuery() throws Exception {
+		for (int groups : new int[] {1, 10, 100}) {
+			for (int messagesPerGroup : new int[] {1, 10, 100}) {
+				for (int keysPerMessage : new int[] {1, 10, 100}) {
+					int rows = groups * messagesPerGroup * keysPerMessage;
+					if (rows > MAX_ROWS) continue;
+					for (int keysPerQuery : new int[] {1, 10}) {
+						testGetMessageIdsWithQuery(groups, messagesPerGroup,
+								keysPerMessage, keysPerQuery);
+					}
+				}
+			}
+		}
+	}
+
+	private void testGetMessageIdsWithQuery(final int groups,
+			final int messagesPerGroup, final int keysPerMessage,
+			final int keysPerQuery) throws Exception {
+		String name = "getMessageIds(T, GroupId, Metadata)";
+		Map<String, Object> args = new LinkedHashMap<String, Object>();
+		args.put("groups", groups);
+		args.put("messagesPerGroup", messagesPerGroup);
+		args.put("keysPerMessage", keysPerMessage);
+		args.put("keysPerQuery", keysPerQuery);
+
+		benchmark(name, args, new BenchmarkTask<Connection>() {
+
+			private final Metadata query = getMetadata(keysPerQuery);
+			private GroupId groupId;
+
+			@Override
+			public void prepareBenchmark(Database<Connection> db)
+					throws DbException {
+				ClientId clientId = getClientId();
+				Connection txn = db.startTransaction();
+				for (int i = 0; i < groups; i++) {
+					Group g = getGroup(clientId);
+					if (i == 0) groupId = g.getId();
+					db.addGroup(txn, g);
+					for (int j = 0; j < messagesPerGroup; j++) {
+						Message m = getMessage(g.getId());
+						db.addMessage(txn, m, DELIVERED, false);
+						Metadata meta = getMetadata(keysPerMessage);
+						db.mergeMessageMetadata(txn, m.getId(), meta);
+					}
+				}
+				db.commitTransaction(txn);
+			}
+
+			@Override
+			public void runBenchmark(Database<Connection> db)
+					throws DbException {
+				Connection txn = db.startTransaction();
+				db.getMessageIds(txn, groupId, query);
+				db.commitTransaction(txn);
+			}
+		});
+	}
+
+	@Test
+	public void testGetGroupMetadata() throws Exception {
+		for (int groups : new int[] {1, 10, 100, 1000}) {
+			for (int keysPerGroup : new int[] {1, 10, 100}) {
+				int rows = groups * keysPerGroup;
+				if (rows > MAX_ROWS) continue;
+				testGetGroupMetadata(groups, keysPerGroup);
+			}
+		}
+	}
+
+	private void testGetGroupMetadata(final int groups, final int keysPerGroup)
+			throws Exception {
+		String name = "getGroupMetadata(T, GroupId)";
+		Map<String, Object> args = new LinkedHashMap<String, Object>();
+		args.put("groups", groups);
+		args.put("keysPerGroup", keysPerGroup);
+
+		benchmark(name, args, new BenchmarkTask<Connection>() {
+
+			private GroupId groupId;
+
+			@Override
+			public void prepareBenchmark(Database<Connection> db)
+					throws DbException {
+				ClientId clientId = getClientId();
+				Connection txn = db.startTransaction();
+				for (int i = 0; i < groups; i++) {
+					Group g = getGroup(clientId);
+					if (i == 0) groupId = g.getId();
+					db.addGroup(txn, g);
+					Metadata meta = getMetadata(keysPerGroup);
+					db.mergeGroupMetadata(txn, g.getId(), meta);
+				}
+				db.commitTransaction(txn);
+			}
+
+			@Override
+			public void runBenchmark(Database<Connection> db)
+					throws DbException {
+				Connection txn = db.startTransaction();
+				db.getGroupMetadata(txn, groupId);
+				db.commitTransaction(txn);
+			}
+		});
+	}
+
+	@Test
+	public void testGetMessageMetadataForGroup() throws Exception {
+		for (int groups : new int[] {1, 10, 100}) {
+			for (int messagesPerGroup : new int[] {1, 10, 100}) {
+				for (int keysPerMessage : new int[] {1, 10, 100}) {
+					int rows = groups * messagesPerGroup * keysPerMessage;
+					if (rows > MAX_ROWS) continue;
+					testGetMessageMetadataForGroup(groups, messagesPerGroup,
+							keysPerMessage);
+				}
+			}
+		}
+	}
+
+	private void testGetMessageMetadataForGroup(final int groups,
+			final int messagesPerGroup, final int keysPerMessage)
+			throws Exception {
+		String name = "getMessageMetadata(T, GroupId)";
+		Map<String, Object> args = new LinkedHashMap<String, Object>();
+		args.put("groups", groups);
+		args.put("messagesPerGroup", messagesPerGroup);
+		args.put("keysPerMessage", keysPerMessage);
+
+		benchmark(name, args, new BenchmarkTask<Connection>() {
+
+			private GroupId groupId;
+
+			@Override
+			public void prepareBenchmark(Database<Connection> db)
+					throws DbException {
+				ClientId clientId = getClientId();
+				Connection txn = db.startTransaction();
+				for (int i = 0; i < groups; i++) {
+					Group g = getGroup(clientId);
+					if (i == 0) groupId = g.getId();
+					db.addGroup(txn, g);
+					for (int j = 0; j < messagesPerGroup; j++) {
+						Message m = getMessage(g.getId());
+						db.addMessage(txn, m, DELIVERED, false);
+						Metadata meta = getMetadata(keysPerMessage);
+						db.mergeMessageMetadata(txn, m.getId(), meta);
+					}
+				}
+				db.commitTransaction(txn);
+			}
+
+			@Override
+			public void runBenchmark(Database<Connection> db)
+					throws DbException {
+				Connection txn = db.startTransaction();
+				db.getMessageMetadata(txn, groupId);
+				db.commitTransaction(txn);
+			}
+		});
+	}
+
+	@Test
+	public void testGetMessageMetadataForMessage() throws Exception {
+		for (int messages : new int[] {1, 100, 10000}) {
+			for (int keysPerMessage : new int[] {1, 10, 100}) {
+				int rows = messages * keysPerMessage;
+				if (rows > MAX_ROWS) continue;
+				testGetMessageMetadataForMessage(messages, keysPerMessage);
+			}
+		}
+	}
+
+	private void testGetMessageMetadataForMessage(final int messages,
+			final int keysPerMessage) throws Exception {
+		String name = "getMessageMetadata(T, MessageId)";
+		Map<String, Object> args = new LinkedHashMap<String, Object>();
+		args.put("messages", messages);
+		args.put("keysPerMessage", keysPerMessage);
+
+		benchmark(name, args, new BenchmarkTask<Connection>() {
+
+			private MessageId messageId;
+
+			@Override
+			public void prepareBenchmark(Database<Connection> db)
+					throws DbException {
+				Group g = getGroup(getClientId());
+				Connection txn = db.startTransaction();
+				db.addGroup(txn, g);
+				for (int i = 0; i < messages; i++) {
+					Message m = getMessage(g.getId());
+					if (i == 0) messageId = m.getId();
+					db.addMessage(txn, m, DELIVERED, false);
+					Metadata meta = getMetadata(keysPerMessage);
+					db.mergeMessageMetadata(txn, m.getId(), meta);
+				}
+				db.commitTransaction(txn);
+			}
+
+			@Override
+			public void runBenchmark(Database<Connection> db)
+					throws DbException {
+				Connection txn = db.startTransaction();
+				db.getMessageMetadata(txn, messageId);
+				db.commitTransaction(txn);
+			}
+		});
+	}
+
+	@Test
+	public void testGetMessagesToShare() throws Exception {
+		for (int clients : new int[] {1, 10, 100}) {
+			for (int groupsPerClient : new int[] {1, 10, 100}) {
+				for (int messagesPerGroup : new int[] {1, 10, 100}) {
+					int rows = clients * groupsPerClient * messagesPerGroup;
+					if (rows > MAX_ROWS) continue;
+					testGetMessagesToShare(clients, groupsPerClient,
+							messagesPerGroup);
+				}
+			}
+		}
+	}
+
+	private void testGetMessagesToShare(final int clients,
+			final int groupsPerClient, final int messagesPerGroup)
+			throws Exception {
+		String name = "getMessagesToShare(T, ClientId)";
+		Map<String, Object> args = new LinkedHashMap<String, Object>();
+		args.put("clients", clients);
+		args.put("groupsPerClient", groupsPerClient);
+		args.put("messagesPerGroup", messagesPerGroup);
+
+		benchmark(name, args, new BenchmarkTask<Connection>() {
+
+			private ClientId clientId;
+
+			@Override
+			public void prepareBenchmark(Database<Connection> db)
+					throws DbException {
+				Random random = new Random();
+				Connection txn = db.startTransaction();
+				for (int i = 0; i < clients; i++) {
+					ClientId c = getClientId();
+					if (i == 0) clientId = c;
+					for (int j = 0; j < groupsPerClient; j++) {
+						Group g = getGroup(c);
+						db.addGroup(txn, g);
+						MessageId lastMessageId = null;
+						for (int k = 0; k < messagesPerGroup; k++) {
+							Message m = getMessage(g.getId());
+							boolean shared = random.nextBoolean();
+							db.addMessage(txn, m, DELIVERED, shared);
+							if (lastMessageId != null) {
+								db.addMessageDependency(txn, g.getId(),
+										m.getId(), lastMessageId);
+							}
+							lastMessageId = m.getId();
+						}
+					}
+				}
+				db.commitTransaction(txn);
+			}
+
+			@Override
+			public void runBenchmark(Database<Connection> db)
+					throws DbException {
+				Connection txn = db.startTransaction();
+				db.getMessagesToShare(txn, clientId);
+				db.commitTransaction(txn);
+			}
+		});
+	}
+
+	@Test
+	public void testGetMessagesToValidate() throws Exception {
+		for (int clients : new int[] {1, 10, 100}) {
+			for (int groupsPerClient : new int[] {1, 10, 100}) {
+				for (int messagesPerGroup : new int[] {1, 10, 100}) {
+					int rows = clients * groupsPerClient * messagesPerGroup;
+					if (rows > MAX_ROWS) continue;
+					testGetMessagesToValidate(clients, groupsPerClient,
+							messagesPerGroup);
+				}
+			}
+		}
+	}
+
+	private void testGetMessagesToValidate(final int clients,
+			final int groupsPerClient, final int messagesPerGroup)
+			throws Exception {
+		String name = "getMessagesToValidate(T, ClientId)";
+		Map<String, Object> args = new LinkedHashMap<String, Object>();
+		args.put("clients", clients);
+		args.put("groupsPerClient", groupsPerClient);
+		args.put("messagesPerGroup", messagesPerGroup);
+
+		benchmark(name, args, new BenchmarkTask<Connection>() {
+
+			private ClientId clientId;
+
+			@Override
+			public void prepareBenchmark(Database<Connection> db)
+					throws DbException {
+				Random random = new Random();
+				Connection txn = db.startTransaction();
+				for (int i = 0; i < clients; i++) {
+					ClientId c = getClientId();
+					if (i == 0) clientId = c;
+					for (int j = 0; j < groupsPerClient; j++) {
+						Group g = getGroup(c);
+						db.addGroup(txn, g);
+						for (int k = 0; k < messagesPerGroup; k++) {
+							Message m = getMessage(g.getId());
+							State s = random.nextBoolean() ? UNKNOWN : PENDING;
+							db.addMessage(txn, m, s, false);
+						}
+					}
+				}
+				db.commitTransaction(txn);
+			}
+
+			@Override
+			public void runBenchmark(Database<Connection> db)
+					throws DbException {
+				Connection txn = db.startTransaction();
+				db.getMessagesToValidate(txn, clientId);
+				db.commitTransaction(txn);
+			}
+		});
+	}
+
+	@Test
+	public void testGetPendingMessages() throws Exception {
+		for (int clients : new int[] {1, 10, 100}) {
+			for (int groupsPerClient : new int[] {1, 10, 100}) {
+				for (int messagesPerGroup : new int[] {1, 10, 100}) {
+					int rows = clients * groupsPerClient * messagesPerGroup;
+					if (rows > MAX_ROWS) continue;
+					testGetPendingMessages(clients, groupsPerClient,
+							messagesPerGroup);
+				}
+			}
+		}
+	}
+
+	private void testGetPendingMessages(final int clients,
+			final int groupsPerClient, final int messagesPerGroup)
+			throws Exception {
+		String name = "getPendingMessages(T, ClientId)";
+		Map<String, Object> args = new LinkedHashMap<String, Object>();
+		args.put("clients", clients);
+		args.put("groupsPerClient", groupsPerClient);
+		args.put("messagesPerGroup", messagesPerGroup);
+
+		benchmark(name, args, new BenchmarkTask<Connection>() {
+
+			private ClientId clientId;
+
+			@Override
+			public void prepareBenchmark(Database<Connection> db)
+					throws DbException {
+				Random random = new Random();
+				Connection txn = db.startTransaction();
+				for (int i = 0; i < clients; i++) {
+					ClientId c = getClientId();
+					if (i == 0) clientId = c;
+					for (int j = 0; j < groupsPerClient; j++) {
+						Group g = getGroup(c);
+						db.addGroup(txn, g);
+						for (int k = 0; k < messagesPerGroup; k++) {
+							Message m = getMessage(g.getId());
+							State s = random.nextBoolean() ? UNKNOWN : PENDING;
+							db.addMessage(txn, m, s, false);
+						}
+					}
+				}
+				db.commitTransaction(txn);
+			}
+
+			@Override
+			public void runBenchmark(Database<Connection> db)
+					throws DbException {
+				Connection txn = db.startTransaction();
+				db.getPendingMessages(txn, clientId);
+				db.commitTransaction(txn);
+			}
+		});
+	}
+
+	private void benchmark(String name, Map<String, Object> args,
+			BenchmarkTask<Connection> task) throws Exception {
+		for (int i = 0; i < WARMUP_ITERATIONS; i++) {
+			Database<Connection> db = open();
+			try {
+				task.prepareBenchmark(db);
+				task.runBenchmark(db);
+			} finally {
+				db.close();
+			}
+		}
+		List<Long> durations = new ArrayList<Long>(MEASUREMENT_ITERATIONS);
+		for (int i = 0; i < MEASUREMENT_ITERATIONS; i++) {
+			Database<Connection> db = open();
+			try {
+				task.prepareBenchmark(db);
+				Thread.sleep(SLEEP_BEFORE_MEASUREMENT_MS);
+				long start = System.nanoTime();
+				task.runBenchmark(db);
+				durations.add(System.nanoTime() - start);
+			} finally {
+				db.close();
+			}
+		}
+		double meanMillis = getMean(durations) / 1000 / 1000;
+		double medianMillis = getMedian(durations) / 1000 / 1000;
+		double stdDevMillis = getStandardDeviation(durations) / 1000 / 1000;
+		String result = name + '\t' + args + '\t' + meanMillis
+				+ '\t' + medianMillis + '\t' + stdDevMillis;
+		System.out.println(result);
+		PrintWriter out =
+				new PrintWriter(new FileOutputStream(resultsFile, true), true);
+		out.println(result);
+		out.close();
+	}
+
+	private Database<Connection> open() throws DbException {
+		deleteTestDirectory(testDir);
+		Database<Connection> db = createDatabase(
+				new TestDatabaseConfig(testDir, MAX_SIZE), new SystemClock());
+		db.open();
+		return db;
+	}
+
+	private ClientId getClientId() {
+		return new ClientId(getRandomString(CLIENT_ID_LENGTH));
+	}
+
+	private Message getMessage(GroupId groupId) {
+		MessageId id = new MessageId(getRandomId());
+		byte[] raw = getRandomBytes(MAX_MESSAGE_LENGTH);
+		long timestamp = System.currentTimeMillis();
+		return new Message(id, groupId, timestamp, raw);
+	}
+
+	private Metadata getMetadata(int keys) {
+		Metadata meta = new Metadata();
+		for (int i = 0; i < keys; i++) {
+			String key = getRandomString(METADATA_KEY_LENGTH);
+			byte[] value = getRandomBytes(METADATA_VALUE_LENGTH);
+			meta.put(key, value);
+		}
+		return meta;
+	}
+}