[java-plugin-storage-jdbc] 05/05: OSJ-342 Investigate Strategies to end of life our use of Hibernate in V5

Rod Widdowson rdw at steadingsoftware.com
Fri May 20 14:00:58 UTC 2022


This is an automated email from the git hooks/post-receive script.

rdw pushed a commit to branch main
in repository java-plugin-storage-jdbc.

View the commit online:
http://git.shibboleth.net/view/?p=java-plugin-storage-jdbc.git;a=commit;h=7b8a5b8577c6a8197f944d06b1ec8465ee66b9ef

commit 7b8a5b8577c6a8197f944d06b1ec8465ee66b9ef
Author: Rod Widdowson <rdw at steadingsoftware.com>
AuthorDate: Fri May 20 14:17:13 2022 +0100

    OSJ-342 Investigate Strategies to end of life our use of Hibernate in V5
    
    https://shibboleth.atlassian.net/browse/OSJ-342
    
    Initial populate and tests.
---
 jdbc-storage-impl/pom.xml                          |   36 +-
 .../storage/jdbc/impl/JDBCStorageRecord.java       |   48 +
 .../storage/jdbc/impl/JDBCStorageService.java      | 1047 ++++++++++++++++++++
 .../storage/jdbc/impl/JDBCStorageServiceTest.java  |  328 ++++++
 .../jdbc/impl/JDCBJPAMixedStorageServiceTest.java  |  316 ++++++
 .../src/test/resources/logback-test.xml            |   21 +
 pom.xml                                            |    8 +-
 7 files changed, 1797 insertions(+), 7 deletions(-)

diff --git a/jdbc-storage-impl/pom.xml b/jdbc-storage-impl/pom.xml
index 9492b36..7ad2940 100644
--- a/jdbc-storage-impl/pom.xml
+++ b/jdbc-storage-impl/pom.xml
@@ -21,8 +21,8 @@
     <dependencies>
         <!-- compile time intra project dependencies -->
         <dependency>
-            <groupId>net.shibboleth.idp.plugin.storage.jdbc</groupId>
-            <artifactId>idp-plugin-jdbc-storage-api</artifactId>
+            <groupId>net.shibboleth.plugin.storage.jdbc</groupId>
+            <artifactId>jdbc-storage-api</artifactId>
         </dependency>
         <!-- Service API and Plugin Description dependency -->
         <dependency>
@@ -35,7 +35,37 @@
             <artifactId>idp-admin-impl</artifactId>
             <scope>provided</scope>
         </dependency>
-        
+        <!--  Test dependencies -->
+        <dependency>
+            <groupId>${opensaml.groupId}</groupId>
+            <artifactId>opensaml-storage-api</artifactId>
+            <version>${opensaml.version}</version>
+            <type>test-jar</type>
+            <scope>test</scope>
+        </dependency>
+        <dependency>
+            <groupId>${opensaml.groupId}</groupId>
+            <artifactId>opensaml-storage-impl</artifactId>
+            <scope>test</scope>
+        </dependency>
+        <dependency>
+            <groupId>org.apache.commons</groupId>
+            <artifactId>commons-dbcp2</artifactId>
+            <scope>test</scope>
+        </dependency>
+        <dependency>
+            <groupId>org.hsqldb</groupId>
+            <artifactId>hsqldb</artifactId>
+            <scope>test</scope>
+        </dependency>
+        <!--
+        <dependency>
+            <groupId>com.microsoft.sqlserver</groupId>
+            <artifactId>mssql-jdbc</artifactId>
+            <version>10.2.0.jre11</version>
+            <scope>test</scope>
+        </dependency>
+        -->
     </dependencies>
 
 
diff --git a/jdbc-storage-impl/src/main/java/net/shibboleth/plugin/storage/jdbc/impl/JDBCStorageRecord.java b/jdbc-storage-impl/src/main/java/net/shibboleth/plugin/storage/jdbc/impl/JDBCStorageRecord.java
new file mode 100644
index 0000000..ff5be43
--- /dev/null
+++ b/jdbc-storage-impl/src/main/java/net/shibboleth/plugin/storage/jdbc/impl/JDBCStorageRecord.java
@@ -0,0 +1,48 @@
+/*
+ * Licensed to the University Corporation for Advanced Internet Development,
+ * Inc. (UCAID) under one or more contributor license agreements.  See the
+ * NOTICE file distributed with this work for additional information regarding
+ * copyright ownership. The UCAID licenses this file to You under the Apache
+ * License, Version 2.0 (the "License"); you may not use this file except in
+ * compliance with the License.  You may obtain a copy of the License at
+ *
+ *    http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package net.shibboleth.plugin.storage.jdbc.impl;
+
+import javax.annotation.Nonnull;
+import javax.annotation.Nullable;
+
+import org.opensaml.storage.MutableStorageRecord;
+
+import net.shibboleth.utilities.java.support.annotation.constraint.NotEmpty;
+
+/** Storage record used by {@link JDBCStorageService}.
+ * This is notable in that it allows creation with a specified version (from
+ * the database).
+ * @param <T> type of record
+ */
+class JDBCStorageRecord<T> extends MutableStorageRecord<T> {
+
+    /**
+     * Constructor.
+     *
+     * @param val The value to store
+     * @param exp The expiration
+     * @param version The version.
+     */
+    public JDBCStorageRecord(@Nonnull @NotEmpty final String val,
+            @Nullable final Long exp,  @Nullable final Long version) {
+        super(val, exp);
+        if (version != null) {
+            setVersion(version);
+        }
+    }
+}
\ No newline at end of file
diff --git a/jdbc-storage-impl/src/main/java/net/shibboleth/plugin/storage/jdbc/impl/JDBCStorageService.java b/jdbc-storage-impl/src/main/java/net/shibboleth/plugin/storage/jdbc/impl/JDBCStorageService.java
new file mode 100644
index 0000000..aeefb1d
--- /dev/null
+++ b/jdbc-storage-impl/src/main/java/net/shibboleth/plugin/storage/jdbc/impl/JDBCStorageService.java
@@ -0,0 +1,1047 @@
+// Checkstyle: FileLength|Header OFF
+/*
+ * Licensed to the University Corporation for Advanced Internet Development,
+ * Inc. (UCAID) under one or more contributor license agreements.  See the
+ * NOTICE file distributed with this work for additional information regarding
+ * copyright ownership. The UCAID licenses this file to You under the Apache
+ * License, Version 2.0 (the "License"); you may not use this file except in
+ * compliance with the License.  You may obtain a copy of the License at
+ *
+ *    http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package net.shibboleth.plugin.storage.jdbc.impl;
+
+import java.io.IOException;
+import java.sql.Connection;
+import java.sql.PreparedStatement;
+import java.sql.ResultSet;
+import java.sql.SQLException;
+import java.sql.Types;
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.Collections;
+import java.util.List;
+import java.util.TimerTask;
+
+import javax.annotation.Nonnull;
+import javax.annotation.Nullable;
+import javax.sql.DataSource;
+
+import org.opensaml.storage.AbstractStorageService;
+import org.opensaml.storage.MutableStorageRecord;
+import org.opensaml.storage.StorageCapabilitiesEx;
+import org.opensaml.storage.StorageRecord;
+import org.opensaml.storage.VersionMismatchException;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import net.shibboleth.utilities.java.support.annotation.constraint.NonnullAfterInit;
+import net.shibboleth.utilities.java.support.annotation.constraint.NonnullElements;
+import net.shibboleth.utilities.java.support.annotation.constraint.NotEmpty;
+import net.shibboleth.utilities.java.support.annotation.constraint.Positive;
+import net.shibboleth.utilities.java.support.collection.Pair;
+import net.shibboleth.utilities.java.support.component.ComponentInitializationException;
+import net.shibboleth.utilities.java.support.logic.Constraint;
+import net.shibboleth.utilities.java.support.logic.ConstraintViolationException;
+import net.shibboleth.utilities.java.support.primitive.StringSupport;
+
+
+/**
+ * Implementation of {@link org.opensaml.storage.StorageService} that uses native JDBC  to persist to a database.
+ */
+public final class JDBCStorageService extends AbstractStorageService implements StorageCapabilitiesEx {
+    
+    /* Default SQL.  Keep it here to keep it together and to allow isertion into javadoc */
+    /** The SQL to get all the contexts.*/
+    static final String DEFAULT_READ_CONTEXTS_SQL = "SELECT context FROM StorageRecords";
+
+    /** The SQL to get all the records.*/
+    static final String DEFAULT_READ_ALL_SQL = "SELECT context, id, expires, value, version FROM StorageRecords";
+
+    /** The SQL to get all the records for a specific context.*/
+    static final String DEFAULT_READ_ALL_BY_CONTEXT_SQL =
+            "SELECT id, expires, value, version FROM StorageRecords WHERE context = ?";
+
+    /** The SQL to get check whether this record already exists and is unexpired prior to a create.*/
+    static final String DEFAULT_PRE_CREATE_QUERY_SQL = "SELECT expires FROM StorageRecords WHERE context =? AND id=?";
+
+    /** The SQL to create a new record. */
+    static final String DEFAULT_CREATE_CREATE_RECORD_SQL = "INSERT INTO StorageRecords VALUES (?, ?, ?, ?, 1)";
+
+    /** The SQL to update an expired record as part of a create. */
+    static final String DEFAULT_CREATE_UPDATE_RECORD_SQL =
+            "UPDATE StorageRecords SET value=?, version=1, expires=? WHERE context=? AND id=?";
+
+    /** The SQL to read a single record.*/
+    static final String DEFAULT_READ_RECORD_SQL =
+            "SELECT version, expires, value FROM StorageRecords WHERE context =? AND id=?";
+
+    /** The SQL to check whether a record exists prior to updating it. */
+    static final String DEFAULT_PRE_UPDATE_QUERY_SQL =
+            "SELECT version, expires FROM StorageRecords WHERE context =? AND id=?";
+
+    /** The SQL to update a record. */
+    static final String DEFAULT_UPDATE_RECORD_SQL =
+            "UPDATE StorageRecords SET value=?, version=?, expires=? WHERE context=? AND id=?";
+
+    /** The SQL to check whether a record exists prior to deleting it.*/
+    static final String DEFAULT_PRE_DELETE_QUERY_SQL ="SELECT version FROM StorageRecords WHERE context =? AND id=?";
+
+    /** The SQL to delete a record. */
+    static final String DEFAULT_DELETE_RECORD_SQL = "DELETE FROM StorageRecords WHERE context=? AND id=?";
+
+    /** The SQL to delete all records by specified expiration.  */
+    static final String DEFAULT_DELETE_BY_EXPIRED_SQL = "DELETE FROM StorageRecords WHERE expires < ? ";
+
+    /** The SQL to delete all records by specified context and expiration.  */
+    static final String DEFAULT_DELETE_BY_CONTEXT_EXPIRED_SQL =
+            "DELETE FROM StorageRecords WHERE context = ? AND expires < ?";
+
+    /** The SQL to update the expiration for a given context.  */
+    static final String DEFAULT_UPDATE_EXPIRES_BY_CONTEXT_SQL =
+            "UPDATE StorageRecords SET expires = ? WHERE context = ? AND expires > ? ";
+
+    /** The SQL to delete a given context.  */
+    static final String DEFAULT_DELETE_BY_CONTEXT_SQL = "DELETE FROM StorageRecords WHERE context = ? ";
+
+
+    /** Class logger. */
+    @Nonnull private final Logger log = LoggerFactory.getLogger(JDBCStorageService.class);
+    
+    /** How many times do we try an operation before giving up? */
+    private int transactionRetry = 3;
+    
+    /** Error messages that signal a transaction should be retried. */
+    @Nonnull @NonnullElements private Collection<String> retryableErrors = Collections.emptyList();
+
+    /** The Data Source. */
+    @NonnullAfterInit private DataSource dataSource;
+    
+    /* SQL Statements */
+    /**
+     * The SQL to get all the contexts.
+     * Default: {@value #DEFAULT_READ_CONTEXTS_SQL}.
+     */
+    @Nonnull  @NotEmpty private String readContextsSQL = DEFAULT_READ_CONTEXTS_SQL;
+
+    /**
+     * The SQL to get all the records.
+     * Default: {@value #DEFAULT_READ_ALL_SQL}.
+     */
+    @Nonnull @NotEmpty private String readAllSQL = DEFAULT_READ_ALL_SQL;
+
+    /**
+     * The SQL to get all the records for a specific context.
+     * Default: {@value #DEFAULT_READ_ALL_BY_CONTEXT_SQL}
+     */
+    @Nonnull @NotEmpty private String readAllByContextSQL = DEFAULT_READ_ALL_BY_CONTEXT_SQL;
+
+    /**
+     * The SQL to get check whether this record already exists and is unexpired prior to a create.
+     * Default: {@value #DEFAULT_PRE_CREATE_QUERY_SQL}
+     */
+    @Nonnull @NotEmpty private String preCreateQuerySQL = DEFAULT_PRE_CREATE_QUERY_SQL;
+
+    /**
+     * The SQL to create a new record (transactional with {@link #preCreateQuerySQL}.
+     * Default: {@value #DEFAULT_CREATE_CREATE_RECORD_SQL}
+     */
+    @Nonnull @NotEmpty private String createCreateRecordSQL = DEFAULT_CREATE_CREATE_RECORD_SQL;
+
+    /**
+     * The SQL to update an expired record as part of a create (transactional with {@link #preCreateQuerySQL}.
+     * Default: {@value #DEFAULT_CREATE_UPDATE_RECORD_SQL}
+     */
+    @Nonnull @NotEmpty private String createUpdateRecordSQL = DEFAULT_CREATE_UPDATE_RECORD_SQL;
+
+    /**
+     * The SQL to read a single record.
+     * Default: {@value #DEFAULT_READ_RECORD_SQL}
+     */
+    @Nonnull @NotEmpty private String readRecordSQL = DEFAULT_READ_RECORD_SQL;
+    
+    /**
+     * The SQL to check whether a record exists prior to updating it.
+     * Default: {@value #DEFAULT_PRE_UPDATE_QUERY_SQL}
+     */
+    @Nonnull @NotEmpty private String preUpdateQuerySQL = DEFAULT_PRE_UPDATE_QUERY_SQL;
+
+    /** The SQL to update a record.  Transactional with {@link #preUpdateQuerySQL}.
+     * Default: {@value #DEFAULT_UPDATE_RECORD_SQL}
+     */
+    @Nonnull @NotEmpty private String updateRecordSQL = DEFAULT_UPDATE_RECORD_SQL;
+    
+    /**
+     * The SQL to check whether a record exists prior to deleting it.
+     * Default: {@value #DEFAULT_PRE_DELETE_QUERY_SQL}
+     */
+    @Nonnull @NotEmpty private String preDeleteQuerySQL = DEFAULT_PRE_DELETE_QUERY_SQL;
+
+    /**
+     * The SQL to delete a record.  Transactional with {@link #preDeleteQuerySQL}
+     * Default: {@value #DEFAULT_DELETE_RECORD_SQL}
+     */
+    @Nonnull @NotEmpty private String deleteRecordSQL = DEFAULT_DELETE_RECORD_SQL;
+
+    /**
+     * The SQL to delete expired record.  Used as part of the {@link #getCleanupTask()}.
+     * Default: {@value #DEFAULT_DELETE_BY_EXPIRED_SQL}
+     */
+    @Nonnull @NotEmpty private String deleteByExpiredSQL = DEFAULT_DELETE_BY_EXPIRED_SQL;
+
+    /**
+     * The SQL to delete expired record with specified context.  Used as part of {@link #reap(String)}.
+     * Default: {@value #DEFAULT_DELETE_BY_CONTEXT_EXPIRED_SQL}
+     */
+    @Nonnull @NotEmpty private String deleteByContextExpiredSQL = DEFAULT_DELETE_BY_CONTEXT_EXPIRED_SQL;
+
+    /**
+     * The SQL to update the expiration of a given context.
+     * Default: {@value #DEFAULT_UPDATE_EXPIRES_BY_CONTEXT_SQL}
+     */
+    @Nonnull @NotEmpty private String updateExpiresByContextSQL = DEFAULT_UPDATE_EXPIRES_BY_CONTEXT_SQL;
+
+    /**
+     * The SQL to to delete a given context.  .
+     * Default: {@value #DEFAULT_DELETE_BY_CONTEXT_SQL}
+     */
+    @Nonnull @NotEmpty private String deleteByContextSQL = DEFAULT_DELETE_BY_CONTEXT_SQL;
+
+    /* Bean Setters*/
+    /** set {@link #transactionRetry}.
+     * @param count how many time to try before we bail.
+     */
+    public void setTransactionRetry(@Positive final int count) {
+        transactionRetry = count;
+        if (count < 0) {
+            throw new ConstraintViolationException("transaction retry must be positive");
+        }
+    }
+    
+    /** Set the {@link DataSource}.
+     * @param source what to set.
+     */
+    public void setDataSource(@Nonnull final DataSource source) {
+        dataSource = Constraint.isNotNull(source, "DataSource should be non null");
+    }
+
+    /** What errors do we retry?
+     * @param errors what to set.
+     */
+    public void setRetryableErrors(@Nonnull @NonnullElements final Collection<String> errors) {
+        retryableErrors = Constraint.isNotNull(errors, "errors must not be null");
+        Constraint.noNullItems(errors, "errors must not have null members");
+    }
+
+    /** SQL to read contexts.
+     * @param what the SQL to set.
+     */
+    public void setReadContextsSQL(@Nonnull @NotEmpty final String what) {
+        readContextsSQL = Constraint.isNotNull(StringSupport.trimOrNull(what),
+                "Read Context SQL should be non-null and non empty");
+    }
+    
+    /** SQL to read all contexts.
+     * @param what the SQL to set.
+     */
+    public void setReadAllByContextSQL(@Nonnull @NotEmpty final String what) {
+        readAllByContextSQL = Constraint.isNotNull(StringSupport.trimOrNull(what),
+                "Read All By Context SQL should be non-null and non empty");
+    }
+
+    /** SQL to read all contexts.
+     * @param what the SQL to set.
+     */
+    public void setReadAllSQL(@Nonnull @NotEmpty final String what) {
+        readAllSQL = Constraint.isNotNull(StringSupport.trimOrNull(what),
+                "Read All SQL should be non-null and non empty");
+    }
+
+    /** Set the SQL to get check whether this record already exists and is unexpired prior to a create.
+     * This starts a transaction which will be completed after either the SQL in
+     * {@link #createCreateRecordSQL} or {@link #createUpdateRecordSQL}.
+     * @param what the SQL to set.
+     */
+    public void setPreCreateQuerySQL(@Nonnull @NotEmpty final String what) {
+        preCreateQuerySQL = Constraint.isNotNull(StringSupport.trimOrNull(what),
+                "PreCreateQuerySQL should be non-null and non empty");
+    }
+
+    /** Set the SQL to create a new record (transactional with {@link #preCreateQuerySQL}.
+     * @param what the SQl to set.
+     */
+    public void setCreateCreateRecordSQL(@Nonnull @NotEmpty final String what) {
+        createCreateRecordSQL = Constraint.isNotNull(StringSupport.trimOrNull(what),
+                "CreateCreateRecordSQL should be non-null and non empty");
+    }
+
+    /** Set the SQL to update an expired record as part of a create (transactional with {@link #preCreateQuerySQL}.
+     * @param what the SQL to set.
+     */
+    public void setCreateUpdateRecordSQL(@Nonnull @NotEmpty final String what) {
+        createUpdateRecordSQL = Constraint.isNotNull(StringSupport.trimOrNull(what),
+                "CreateUpdateRecordSQL should be non-null and non empty");
+    }
+
+    /** Set the SQL to read a single record.
+     * @param what what to set.
+     */
+    public void setReadRecordSQL(@Nonnull @NotEmpty final String what) {
+        readRecordSQL = Constraint.isNotNull(StringSupport.trimOrNull(what),
+                "ReadRecordSQL should be non-null and non empty");
+    }
+
+    /** Set the SQL to get check whether this record already exists and is unexpired prior to an update.
+     * @param what The preUpdateQuerySQL to set.
+     */
+    public void setPreUpdateQuerySQL(@Nonnull @NotEmpty final String what) {
+        preUpdateQuerySQL = Constraint.isNotNull(StringSupport.trimOrNull(what),
+                "PreUpdateQuerySQL should be non-null and non empty");
+    }
+
+    /** Set the SQL to update a record.  Transactional with {@link #preUpdateQuerySQL}
+     * @param what The updateQuerySQL to set.
+     */
+    public void setUpdateRecordSQL(@Nonnull @NotEmpty final String what) {
+        updateRecordSQL = Constraint.isNotNull(StringSupport.trimOrNull(what),
+                "UpdateRecordSQL should be non-null and non empty");
+    }
+
+    /** Set the SQL to check whether a record exists prior to deleting it.
+     * @param what The SQL to set.
+     */
+    public void setPreDeleteQuerySQL(final String what) {
+        preDeleteQuerySQL = Constraint.isNotNull(StringSupport.trimOrNull(what),
+                "PreDeleteSQL should be non-null and non empty");
+    }
+
+    /** Set the SQL to delete a record. Transactional with {@link #preDeleteQuerySQL}.
+     * @param what The SQL to set.
+     */
+    public void setDeleteRecordSQL(final String what) {
+        deleteRecordSQL = Constraint.isNotNull(StringSupport.trimOrNull(what),
+                "DeleteRecordSQL should be non-null and non empty");
+    }
+
+    /** Set the SQL to delete expired record.
+     * Used as part of the {@link #getCleanupTask()}.
+     * @param what The SQL to set.
+     */
+    public void setDeleteByExpiredSQL(final String what) {
+        deleteByExpiredSQL = Constraint.isNotNull(StringSupport.trimOrNull(what),
+                "DeleteByExpiredSQL should be non-null and non empty");
+    }
+
+    /** Set the SQL to delete expired record with specified context.
+     * Used as part of {@link #reap(String)}.
+     * @param what The SQL to set.
+     */
+    public void setDeleteByContextExpiredSQL(final String what) {
+        deleteByContextExpiredSQL = Constraint.isNotNull(StringSupport.trimOrNull(what),
+                "DeleteByContextExpiredSQL should be non-null and non empty");
+    }
+
+    /** Set the SQL to update the expiration of a record specified by context.
+     * @param what The SQL to set.
+     */
+    public void setUpdateExpiresByContextSQL(final String what) {
+        updateExpiresByContextSQL = Constraint.isNotNull(StringSupport.trimOrNull(what),
+                "UpdateExpiresByContextSQL should be non-null and non empty");
+    }
+
+    /** Set the SQL to Delete a specified Context.
+     * @param what The SQL to set.
+     */
+    public void setDeleteByContextSQL(final String what) {
+        deleteByContextSQL = Constraint.isNotNull(StringSupport.trimOrNull(what),
+                "DeleteByContextSQL should be non-null and non empty");
+    }
+
+    /** {@inheritDoc} */
+    protected void doInitialize() throws ComponentInitializationException {
+        Constraint.isNotNull(dataSource, "data source must be specified and non-null");
+        super.doInitialize();
+    }
+
+    /**
+     * Returns all contexts from the store (for testing only).
+     * 
+     * @return all contexts or an empty list
+     * @throws IOException if errors occur in the read process
+     */
+    @Nonnull @NonnullElements protected List<String> readContexts() throws IOException {
+        final List<String> result = new ArrayList<>();
+        try (final Connection connection = getConnection(true)) {
+            log.trace("ReadContexts:: ", readContextsSQL);
+            final PreparedStatement query = connection.prepareStatement(readContextsSQL);
+            final ResultSet results = query.executeQuery();
+            while (results.next()) {
+                final String context = results.getString(1);
+                log.trace("Context = '{}'", context);
+                result.add(context);
+            }
+            return result;
+            
+        } catch (final SQLException e) {
+            log.error("ReadContexts failed", e);
+            throw new IOException(e);
+        }
+    }
+
+    /**
+     * Returns all records from the store (for testing only).
+     * 
+     * @return all records or an empty list
+     * @throws IOException if errors occur in the read process
+     */
+    @Nonnull @NonnullElements protected List<?> readAll() throws IOException {
+        final List<JDBCStorageRecord<?>> result = new ArrayList<>();
+        try (final Connection connection = getConnection(true)) {
+            log.trace("ReadAll:: '{}' ", readAllSQL);
+            final PreparedStatement query = connection.prepareStatement(readAllSQL);
+            final ResultSet results = query.executeQuery();
+            while (results.next()) {
+                final String context = results.getString(1);
+                final String id = results.getString(2);
+                final Long expires = getExpires(results, 3);
+                final String value = results.getString(4);
+                final Long version = results.getLong(5);
+                log.trace("Record: Context = '{}', Id = '{}', value = '{}', verion = '{}', expires = '{}'",
+                        context, id, value, version, expires == null ? "<never>": expires);
+                result.add(new JDBCStorageRecord<>(value, expires, version));
+            }
+            return result;
+            
+        } catch (final SQLException e) {
+            log.error("ReadAll failed", e);
+            throw new IOException(e);
+        }
+    }
+    
+    /**
+     * Returns all records from the store for the supplied context (for testing only).
+     * 
+     * @param context a storage context label
+     * 
+     * @return all records in the context or an empty list
+     * @throws IOException if errors occur in the read process
+     */
+    @Nonnull @NonnullElements protected List<?> readAll(@Nonnull @NotEmpty final String context)
+            throws IOException {
+        final List<JDBCStorageRecord<?>> result = new ArrayList<>();
+        Constraint.isNotEmpty(Constraint.isNotNull(context, "ReadAll(String): context must not be null"),
+                             "ReadAll(String): context must not be empty");
+        try (final Connection connection = getConnection(true)) {
+            log.trace("ReadAll:: '{}' 1: '{}'  ", readAllByContextSQL, context);
+            final PreparedStatement query = connection.prepareStatement(readAllByContextSQL);
+            query.setString(1, context);
+            final ResultSet results = query.executeQuery();
+            while (results.next()) {
+                final String id = results.getString(1);
+                final Long expires = getExpires(results, 2);
+                final String value = results.getString(3);
+                final Long version = results.getLong(4);
+                log.trace("Record: Id = '{}', value = '{}', verion = '{}', expires = '{}'",
+                        id, value, version, expires == null ? "<never>": expires);
+                result.add(new JDBCStorageRecord<>(value, expires, version));
+            }
+            return result;
+            
+        } catch (final SQLException e) {
+            log.error("ReadAll()", e);
+            throw new IOException(e);
+        }
+    }
+
+    /** {@inheritDoc} */
+ // Checkstyle: CyclomaticComplexity OFF
+    public boolean create(@Nonnull @NotEmpty final String context, @Nonnull @NotEmpty final String key,
+            @Nonnull @NotEmpty final String value, @Nullable @Positive final Long expiration) throws IOException {
+        Constraint.isNotEmpty(Constraint.isNotNull(context, "create: context must not be null"),
+                "create: context must not be empty");
+        Constraint.isNotEmpty(Constraint.isNotNull(value, "create: value must not be null"),
+                "create: value must not be empty");
+        int retries = transactionRetry;
+        while(true) {
+            try (final Connection connection = getConnection(false)) {
+                log.trace("Create [Query]:: '{}'::  1: '{}' ; 2: '{}'", preCreateQuerySQL, context, key);
+                // Does it exist?
+                // If not insert
+                // If so check expiration
+                // If not expired complain
+                // otherwise update
+                final PreparedStatement query = connection.prepareStatement(preCreateQuerySQL);
+                query.setString(1, context);
+                query.setString(2, key);
+                final ResultSet resultSet = query.executeQuery();
+                if (!resultSet.next()) {
+                    log.trace("Create [Insert]:: '{}'  1: '{}' ; 2: '{}' ; 3 '{}' ; 4 '{}'", createCreateRecordSQL, 
+                            context, key, expiration, value);
+                    final PreparedStatement insert = connection.prepareStatement(createCreateRecordSQL);
+                    insert.setString(1, context);
+                    insert.setString(2, key);
+                    setExpires(insert, 3, expiration);
+                    insert.setString(4,value);
+                    insert.executeUpdate();
+                    connection.commit();
+                    return true;
+                }
+                final Long returnedExpiration = getExpires(resultSet, 1);
+                if (returnedExpiration == null || System.currentTimeMillis() < returnedExpiration) {
+                    log.debug("Duplicate record '{}' in context '{}'", key, context);
+                    return false;
+                }
+                final PreparedStatement update = connection.prepareStatement(createUpdateRecordSQL);
+                log.trace("Create [Update]:: '{}'  1: '{}' ; 2: '{}' ; 3 '{}' ; 4 '{}'", createUpdateRecordSQL, 
+                        value, expiration, context, key);
+                update.setString(1, value);
+                setExpires(update, 2, expiration);
+                update.setString(3,context);
+                update.setString(4,key);
+                update.executeUpdate();
+                connection.commit();
+                return true;
+            } catch (final SQLException e) {
+                boolean retry = false;
+                for (final String msg : retryableErrors) {
+                    if (e.getSQLState() != null && e.getSQLState().contains(msg)) {
+                        log.warn("Caught retryable SQL exception", e);
+                        retry = true;
+                        break;
+                    }
+                }
+                if (retry) {
+                    if (--retries < 0) {
+                        log.warn("Error retryable, but retry limit exceeded");
+                        throw new IOException(e);
+                    }
+                    log.info("Retrying JDBC Create operation");
+                } else {
+                    throw new IOException(e);
+                }
+            }
+        }
+    }
+    // Checkstyle: CyclomaticComplexity ON
+
+    /** {@inheritDoc} */
+    @Override @Nullable public <T> StorageRecord<T> read(@Nonnull @NotEmpty final String context,
+            @Nonnull @NotEmpty final String key) throws IOException {
+        return this.<T>readImpl(context, key, null).getSecond();
+    }
+
+    /** {@inheritDoc} */
+    @Override @Nonnull public <T> Pair<Long, StorageRecord<T>> read(@Nonnull @NotEmpty final String context,
+            @Nonnull @NotEmpty final String key, @Positive final long version) throws IOException {
+        return readImpl(context, key, version);
+    }
+    
+    /**
+     * Reads the record matching the supplied parameters. Returns an empty pair if the record cannot be found or is
+     * expired.
+     * 
+     * @param <T> type of object
+     * @param context to search for
+     * @param key to search for
+     * @param version to match
+     * 
+     * @return pair of version and storage record
+     * @throws IOException if errors occur in the read process
+     */
+    // Checkstyle: CyclomaticComplexity OFF
+    @Nonnull protected <T> Pair<Long, StorageRecord<T>> readImpl(@Nonnull @NotEmpty final String context,
+            @Nonnull @NotEmpty final String key, @Positive @Nullable final Long version) throws IOException {
+
+        Constraint.isNotEmpty(Constraint.isNotNull(context, "read: context must not be null"),
+                "read: context must not be empty");
+        Constraint.isNotEmpty(Constraint.isNotNull(context, "read: key must not be null"),
+                "read: key must not be empty");
+
+        int retries = transactionRetry;
+        while(true) {
+            try (final Connection connection = getConnection(true)) {
+                final PreparedStatement stmnt = connection.prepareStatement(readRecordSQL);
+                log.trace("Read:: '{}' 1: '{}' ; 2: '{}'", readRecordSQL, context, key);
+                stmnt.setString(1, context);
+                stmnt.setString(2, key);
+                final ResultSet resultSet = stmnt.executeQuery();
+                if (!resultSet.next()) {
+                    log.debug("Nothing returned");
+                    return new Pair<>();
+                }
+                final Long returnedVersion = resultSet.getLong(1);
+                final Long returnedExpires = getExpires(resultSet, 2);
+                final String returnedValue = resultSet.getString(3);
+                log.trace("Considering Version '{}', Expires '{}', Value '{}'",
+                        returnedVersion, returnedValue, returnedExpires);
+                if (returnedExpires != null && System.currentTimeMillis() >= returnedExpires) {
+                    log.debug("Read failed, key '{}' expired in context '{}'", key, context);
+                    return new Pair<>();
+                }
+                if (version != null && returnedVersion == version) {
+                    // Nothing's changed, so just echo back the version.
+                    return new Pair<>(version, null);
+                }
+                if (resultSet.next()) {
+                    log.error("Multiple values returned?");
+                }
+                final MutableStorageRecord<T> result =
+                        new JDBCStorageRecord<>(returnedValue, returnedExpires, returnedVersion);
+                return new Pair<>(version, result);
+            } catch (final SQLException e) {
+                boolean retry = false;
+                for (final String msg : retryableErrors) {
+                    if (e.getSQLState() != null && e.getSQLState().contains(msg)) {
+                        log.warn("Caught retryable SQL exception", e);
+                        retry = true;
+                        break;
+                    }
+                }
+                
+                if (retry) {
+                    if (--retries < 0) {
+                        log.warn("Error retryable, but retry limit exceeded");
+                        throw new IOException(e);
+                    }
+                    log.info("Retrying JDBC Read operation");
+                } else {
+                    throw new IOException(e);
+                }
+            }
+        }
+    }
+    // Checkstyle: CyclomaticComplexity ON
+
+    /** {@inheritDoc} */
+    @Override public boolean update(@Nonnull @NotEmpty final String context, @Nonnull @NotEmpty final String key,
+            @Nonnull @NotEmpty final String value, @Nullable @Positive final Long expiration) throws IOException {
+        try {
+            return updateImpl(null, context, key, value, expiration) != null;
+        } catch (final VersionMismatchException e) {
+            throw new IllegalStateException("Unexpected exception thrown by update.", e);
+        }
+    }
+
+    /** {@inheritDoc} */
+    @Override @Nullable public Long updateWithVersion(@Positive final long version,
+            @Nonnull @NotEmpty final String context, @Nonnull @NotEmpty final String key,
+            @Nonnull @NotEmpty final String value, @Nullable @Positive final Long expiration) throws IOException,
+            VersionMismatchException {
+        return updateImpl(version, context, key, value, expiration);
+    }
+
+    /** {@inheritDoc} */
+    @Override public boolean updateExpiration(@Nonnull @NotEmpty final String context,
+            @Nonnull @NotEmpty final String key, @Nullable @Positive final Long expiration) throws IOException {
+        try {
+            return updateImpl(null, context, key, null, expiration) != null;
+        } catch (final VersionMismatchException e) {
+            throw new IllegalStateException("Unexpected exception thrown by updateExpiration.", e);
+        }
+    }
+    
+    /**
+     * Updates the record matching the supplied parameters. Returns null if the record cannot be found or is expired.
+     * 
+     * @param version to check
+     * @param context to search for
+     * @param key to search for
+     * @param value to update
+     * @param expires to update
+     * 
+     * @return the version of the record after update, null if no record exists
+     * @throws IOException if errors occur in the update process
+     * @throws VersionMismatchException if the record found contains a version that does not match the parameter
+     */
+    // Checkstyle: CyclomaticComplexity OFF
+    @Nullable protected Long updateImpl(@Nullable final Long version, @Nonnull @NotEmpty final String context,
+            @Nonnull @NotEmpty final String key, @Nonnull @NotEmpty final String value,
+            @Nullable @Positive final Long expires) throws IOException, VersionMismatchException {
+        
+        Constraint.isNotEmpty(Constraint.isNotNull(context, "update: context must not be null"),
+                "update: context must not be empty");
+        Constraint.isNotEmpty(Constraint.isNotNull(key, "update: key must not be null"),
+                "update: key must not be empty");
+        Constraint.isNotEmpty(Constraint.isNotNull(value, "update: value must not be null"),
+                "update: value must not be empty");
+        int retries = transactionRetry;
+        while (true) {
+            try (Connection connection = getConnection(false)) {
+                final PreparedStatement selectStmnt = connection.prepareStatement(preUpdateQuerySQL);
+                log.trace("Update [Query]:: '{}'  1: '{}' ; 2: '{}'", preUpdateQuerySQL, context, key);
+
+                selectStmnt.setString(1, context);
+                selectStmnt.setString(2, key);
+
+                final ResultSet resultSet = selectStmnt.executeQuery();
+                if (!resultSet.next()) {
+                    log.debug("Nothing returned");
+                    return null;
+                }
+                final Long returnedExpires = getExpires(resultSet, 2);
+                final Long returnedVersion = resultSet.getLong(1);
+                if (returnedExpires != null && System.currentTimeMillis() >= returnedExpires) {
+                    log.debug("Update failed, key '{}' expired in context '{}'", key, context);
+                    return null;
+                }
+    
+                if (version != null && returnedVersion != version) {
+                    // Caller is out of sync.
+                    throw new VersionMismatchException();
+                }
+                final PreparedStatement updateStmnt = connection.prepareStatement(updateRecordSQL);
+                final Long newVersion = Long.valueOf(returnedVersion + 1);
+                log.trace("Update [Update]:: '{}':  1: '{}' ; 2: '{}' ; 3: '{}' ; 4: '{}' ; 5: '{}'", updateRecordSQL,
+                        value, newVersion, expires, context, key);
+                updateStmnt.setString(1, value);
+                updateStmnt.setLong(2, newVersion);
+                setExpires(updateStmnt, 3, expires);
+                updateStmnt.setString(4, context);
+                updateStmnt.setString(5, key);
+                updateStmnt.executeUpdate();
+                connection.commit();
+                return newVersion;
+            } catch (final SQLException e) {
+                boolean retry = false;
+                for (final String msg : retryableErrors) {
+                    if (e.getSQLState() != null && e.getSQLState().contains(msg)) {
+                        log.warn("Caught retryable SQL exception", e);
+                        retry = true;
+                        break;
+                    }
+                }
+                
+                if (retry) {
+                    if (--retries < 0) {
+                        log.warn("Error retryable, but retry limit exceeded");
+                        throw new IOException(e);
+                    }
+                    log.info("Retrying JDBC Update Operation");
+                } else {
+                    throw new IOException(e);
+                }
+            }
+        }
+    }
+    // Checkstyle: CyclomaticComplexity ON
+
+    /** {@inheritDoc} */
+    @Override public boolean deleteWithVersion(@Positive final long version, @Nonnull @NotEmpty final String context,
+            @Nonnull @NotEmpty final String key) throws IOException, VersionMismatchException {
+        return deleteImpl(version, context, key);
+    }
+
+    /** {@inheritDoc} */
+    @Override public boolean delete(@Nonnull @NotEmpty final String context, @Nonnull @NotEmpty final String key)
+            throws IOException {
+        try {
+            return deleteImpl(null, context, key);
+        } catch (final VersionMismatchException e) {
+            throw new IllegalStateException("Unexpected exception thrown by delete.", e);
+        }
+    }
+    
+    /**
+     * Deletes the record matching the supplied parameters.
+     * 
+     * @param version to check
+     * @param context to search for
+     * @param key to search for
+     * 
+     * @return whether the record was deleted
+     * @throws IOException if errors occur in the delete process
+     * @throws VersionMismatchException if the record found contains a version that does not match the parameter
+     */
+    // Checkstyle: CyclomaticComplexity OFF
+    protected boolean deleteImpl(@Nullable @Positive final Long version, @Nonnull @NotEmpty final String context,
+            @Nonnull @NotEmpty final String key) throws IOException, VersionMismatchException {
+        Constraint.isNotEmpty(Constraint.isNotNull(context, "delete: context must not be null"),
+                "delete: context must not be empty");
+        Constraint.isNotEmpty(Constraint.isNotNull(context, "delete: key must not be null"),
+                "delete: key must not be empty");
+        Constraint.isTrue(version == null || version > 0, "delete: version should be null of > 0");
+        int retries = transactionRetry;
+        while (true) {
+            try (Connection connection = getConnection(false)) {
+                final PreparedStatement selectStmnt = connection.prepareStatement(preDeleteQuerySQL);
+                selectStmnt.setString(1, context);
+                selectStmnt.setString(2, key);
+                log.trace("Delete [Query]:: '{}':  1: '{}' ; 2: '{}'", preDeleteQuerySQL, context, key);
+                final ResultSet resultSet = selectStmnt.executeQuery();
+                if (!resultSet.next()) {
+                    log.debug("Nothing returned");
+                    return false;
+                }
+                final Long returnedVersion = resultSet.getLong(1);
+                if (version != null && returnedVersion != version) {
+                    throw new VersionMismatchException();
+                }
+                final PreparedStatement deleteStmnt = connection.prepareStatement(deleteRecordSQL);
+                log.trace("Delete [Delete]:: '{}':  1: '{}' ; 2: '{}'", deleteRecordSQL, context, key);
+                deleteStmnt.setString(1, context);
+                deleteStmnt.setString(2, key);
+                deleteStmnt.execute();
+                connection.commit();
+                return true;
+            } catch (final SQLException e) {
+                boolean retry = false;
+                for (final String msg : retryableErrors) {
+                    if (e.getSQLState() != null && e.getSQLState().contains(msg)) {
+                        log.warn("Caught retryable SQL exception", e);
+                        retry = true;
+                        break;
+                    }
+                }
+                
+                if (retry) {
+                    if (--retries < 0) {
+                        log.warn("Error retryable, but retry limit exceeded");
+                        throw new IOException(e);
+                    }
+                    log.info("Retrying JDBC Delete Operation");
+                } else {
+                    throw new IOException(e);
+                }
+            }
+        }
+    }
+    // Checkstyle: CyclomaticComplexity ON
+    
+    /**
+     * Deletes every record with an expiration before the supplied expiration.
+     * 
+     * @param expiration of records to delete
+     * 
+     * @throws IOException if errors occur in the cleanup process
+     */
+    protected void deleteImpl(@Nonnull final Long expiration) throws IOException {
+        Constraint.isNotNull(expiration, "expiration: context must not be null");
+        int retries = transactionRetry;
+        while (true) {
+            try (Connection connection = getConnection(false)) {
+                final PreparedStatement updateStmnt = connection.prepareStatement(deleteByExpiredSQL);
+                updateStmnt.setLong(1, expiration);
+                log.trace("DeleteByExpired:: '{}':  1: '{}' ;", deleteByExpiredSQL, expiration);
+                updateStmnt.execute();
+                connection.commit();
+                return;
+            } catch (final SQLException e) {
+                boolean retry = false;
+                for (final String msg : retryableErrors) {
+                    if (e.getSQLState() != null && e.getSQLState().contains(msg)) {
+                        log.warn("Caught retryable SQL exception", e);
+                        retry = true;
+                        break;
+                    }
+                }
+                
+                if (retry) {
+                    if (--retries < 0) {
+                        log.warn("Error retryable, but retry limit exceeded");
+                        throw new IOException(e);
+                    }
+                    log.info("Retrying JDBC DeletebyExpiration Operation");
+                } else {
+                    throw new IOException(e);
+                }
+            }
+        }
+    }
+
+    /** {@inheritDoc} */
+    public void reap(@Nonnull @NotEmpty final String context) throws IOException {
+        Constraint.isNotEmpty(Constraint.isNotNull(context, "reap: context must not be null"),
+                "reap: context must not be empty");
+        int retries = transactionRetry;
+        while (true) {
+            try (Connection connection = getConnection(true)) {
+                final PreparedStatement updateStmnt = connection.prepareStatement(deleteByContextExpiredSQL);
+                updateStmnt.setString(1, context);
+                final Long expires = System.currentTimeMillis();
+                setExpires(updateStmnt, 2, expires);
+                log.trace("Reap:: '{}':  1: '{}' ; 2: '{}' ;", deleteByContextExpiredSQL, context, expires);
+                updateStmnt.execute();
+                connection.commit();
+                return;
+            } catch (final SQLException e) {
+                boolean retry = false;
+                for (final String msg : retryableErrors) {
+                    if (e.getSQLState() != null && e.getSQLState().contains(msg)) {
+                        log.warn("Caught retryable SQL exception", e);
+                        retry = true;
+                        break;
+                    }
+                }
+                if (retry) {
+                    if (--retries < 0) {
+                        log.warn("Error retryable, but retry limit exceeded");
+                        throw new IOException(e);
+                    }
+                    log.info("Retrying JDBC DeleteByContext Operation");
+                } else {
+                    throw new IOException(e);
+                }
+            }
+        }
+    }
+
+    /** {@inheritDoc} */
+    public void updateContextExpiration(@Nonnull @NotEmpty final String context, @Nullable final Long  expires)
+            throws IOException {
+        Constraint.isNotEmpty(Constraint.isNotNull(context, "updateContextExpiration: context must not be null"),
+                "updateContextExpiration: context must not be empty");
+        int retries = transactionRetry;
+        while (true) {
+            try (Connection connection = getConnection(true)) {
+                final PreparedStatement updateStmnt = connection.prepareStatement(updateExpiresByContextSQL);
+                setExpires(updateStmnt, 1, expires);
+                updateStmnt.setString(2, context);
+                final Long newExpires = System.currentTimeMillis();
+                setExpires(updateStmnt, 3, newExpires);
+                log.trace("UpdateContextExpiration:: '{}':  1: '{}' ; 2: '{}' ; 3: '{}' ;",
+                        updateExpiresByContextSQL, expires, context, newExpires);
+                updateStmnt.execute();
+                connection.commit();
+                return;
+            } catch (final SQLException e) {
+                boolean retry = false;
+                for (final String msg : retryableErrors) {
+                    if (e.getSQLState() != null && e.getSQLState().contains(msg)) {
+                        log.warn("Caught retryable SQL exception", e);
+                        retry = true;
+                        break;
+                    }
+                }
+                if (retry) {
+                    if (--retries < 0) {
+                        log.warn("Error retryable, but retry limit exceeded");
+                        throw new IOException(e);
+                    }
+                    log.info("Retrying JDBC DeleteByContext Operation");
+                } else {
+                    throw new IOException(e);
+                }
+            }
+        }
+    }
+
+    /** {@inheritDoc} */
+    public void deleteContext(@Nonnull @NotEmpty final String context) throws IOException {
+        Constraint.isNotEmpty(Constraint.isNotNull(context, "deleteContext: context must not be null"),
+                "deleteContext: context must not be empty");
+        int retries = transactionRetry;
+        while (true) {
+            try (Connection connection = getConnection(true)) {
+                final PreparedStatement updateStmnt = connection.prepareStatement(deleteByContextSQL);
+                updateStmnt.setString(1, context);
+                log.trace("UpdateContextExpiration:: '{}':  1: '{}'", deleteByContextSQL, context);
+
+                updateStmnt.execute();
+                connection.commit();
+                return;
+            } catch (final SQLException e) {
+                boolean retry = false;
+                for (final String msg : retryableErrors) {
+                    if (e.getSQLState() != null && e.getSQLState().contains(msg)) {
+                        log.warn("Caught retryable SQL exception", e);
+                        retry = true;
+                        break;
+                    }
+                }
+                
+                if (retry) {
+                    if (--retries < 0) {
+                        log.warn("Error retryable, but retry limit exceeded");
+                        throw new IOException(e);
+                    }
+                    log.info("Retrying JDBC DeleteByContext Operation");
+                } else {
+                    throw new IOException(e);
+                }
+            }
+        }
+    }
+    
+    /**
+     * Obtain a connection from the data source.
+     * 
+     * <p>The caller must close the connection.</p>
+     * 
+     * @param autoCommit auto-commit setting to apply to the connection
+     * 
+     * @return a fresh connection
+     * @throws SQLException if an error occurs
+     */
+    @Nonnull private Connection getConnection(final boolean autoCommit) throws SQLException {
+        final Connection conn = dataSource.getConnection();
+        conn.setAutoCommit(autoCommit);
+        conn.setTransactionIsolation(Connection.TRANSACTION_SERIALIZABLE);
+        return conn;
+    }
+    
+    /** Return the value of expires in the supplied column of the supplied {@link ResultSet}.
+     * @param results the results whose current row we want to inspect
+     * @param columm the column
+     * @return the expiration (converting an SQL null into a null)
+     * @throws SQLException if the results interrogation fails
+     */
+    @Nullable private static Long getExpires(@Nonnull final ResultSet results, final int columm) throws SQLException {
+        final long value = results.getLong(columm);
+        if (results.wasNull()) {
+            return null;
+        }
+        return value;
+    }
+    
+    /** Set the value of expiration into the prepared statement at the suppiled column
+     * converting java nulls into SQL nulls.
+     * 
+     * @param stmnt where to put it
+     * @param column which column to put it in
+     * @param expires
+     * @throws SQLException 
+     */
+    private static void setExpires(@Nonnull final PreparedStatement stmnt,
+            final int column, final @Nullable Long expires) throws SQLException {
+        if (expires == null) {
+            stmnt.setNull(column, Types.BIGINT);
+        } else {
+            stmnt.setLong(column, expires);
+        }
+    }
+
+    /** {@inheritDoc} */
+    public boolean isServerSide() {
+        return true;
+    }
+
+    /** {@inheritDoc} */
+    public boolean isClustered() {
+        return false;
+    }
+    
+    /** {@inheritDoc} */
+    @Override @Nullable protected TimerTask getCleanupTask() {
+        return new TimerTask() {
+
+            /** {@inheritDoc} */
+            @Override public void run() {
+                final Long now = System.currentTimeMillis();
+                log.debug("Running cleanup task at {}", now);
+                try {
+                    deleteImpl(now);
+                } catch (final IOException e) {
+                    log.error("Error running cleanup task for {}", now, e);
+                }
+                log.debug("Finished cleanup task for {}", now);
+            }
+        };
+    }
+}
diff --git a/jdbc-storage-impl/src/test/java/net/shibboleth/plugin/storage/jdbc/impl/JDBCStorageServiceTest.java b/jdbc-storage-impl/src/test/java/net/shibboleth/plugin/storage/jdbc/impl/JDBCStorageServiceTest.java
new file mode 100644
index 0000000..8f15084
--- /dev/null
+++ b/jdbc-storage-impl/src/test/java/net/shibboleth/plugin/storage/jdbc/impl/JDBCStorageServiceTest.java
@@ -0,0 +1,328 @@
+/*
+ * Licensed to the University Corporation for Advanced Internet Development,
+ * Inc. (UCAID) under one or more contributor license agreements.  See the
+ * NOTICE file distributed with this work for additional information regarding
+ * copyright ownership. The UCAID licenses this file to You under the Apache
+ * License, Version 2.0 (the "License"); you may not use this file except in
+ * compliance with the License.  You may obtain a copy of the License at
+ *
+ *    http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package net.shibboleth.plugin.storage.jdbc.impl;
+
+import static org.testng.Assert.assertTrue;
+
+import java.io.IOException;
+import java.security.SecureRandom;
+import java.sql.Connection;
+import java.sql.SQLException;
+import java.sql.Statement;
+import java.time.Duration;
+import java.util.List;
+import java.util.UUID;
+
+import javax.annotation.Nonnull;
+
+import org.apache.commons.dbcp2.BasicDataSource;
+import org.opensaml.storage.StorageRecord;
+import org.opensaml.storage.StorageService;
+import org.opensaml.storage.impl.JPAStorageService;
+import org.opensaml.storage.testing.StorageServiceTest;
+import org.testng.Assert;
+import org.testng.TestException;
+import org.testng.annotations.AfterClass;
+import org.testng.annotations.BeforeClass;
+import org.testng.annotations.DataProvider;
+import org.testng.annotations.Test;
+
+import net.shibboleth.utilities.java.support.component.ComponentInitializationException;
+
+/**
+ * Test of {@link JPAStorageService} implementation.
+ */
+//@Test(enabled = false)
+ at SuppressWarnings("javadoc")
+public class JDBCStorageServiceTest extends StorageServiceTest {
+
+    private JDBCStorageService storageService;
+
+    private final boolean USE_SQLSERVER = false;
+
+    private static final String INIT_SQL_SQLSERVER="CREATE TABLE StorageRecords (\r\n"
+   		+ "  context varchar(255) COLLATE  Latin1_General_100_CS_AS  NOT NULL,\n"
+   		+ "  id varchar(255) COLLATE Latin1_General_100_CS_AS NOT NULL,\n"
+   		+ "  expires bigint DEFAULT NULL,\n"
+   		+ "  value varchar(255) NOT NULL,\n"
+   		+ "  version bigint NOT NULL,\n"
+   		+ "  PRIMARY KEY (context,id)\n"
+   		+ ")";
+    
+    private static final String INIT_SQL_HSQLDB="CREATE TABLE StorageRecords (\r\n"
+   		+ "  context varchar(255) NOT NULL,\n"
+   		+ "  id varchar(255) NOT NULL,\n"
+   		+ "  expires bigint DEFAULT NULL,\n"
+   		+ "  value varchar(255) NOT NULL,\n"
+   		+ "  version bigint NOT NULL,\n"
+   		+ "  PRIMARY KEY (context,id)\n"
+   		+ ")";
+
+    private static final String CLEANUP_SQL = "DROP TABLE StorageRecords;";
+
+    /** Contexts used for testing. */
+    private Object[][] contexts;
+    
+    private BasicDataSource dataSource;
+
+    public JDBCStorageServiceTest() { 
+        final SecureRandom random1 = new SecureRandom();
+        contexts = new Object[10][1];
+        for (int i = 0; i < 10; i++) {
+            contexts[i] = new Object[] {Long.toString(random1.nextLong()), };
+        }
+    }
+
+    private void setupHSSQLDB() throws ClassNotFoundException, SQLException {
+        dataSource.setUrl("jdbc:hsqldb:mem:JPAStorageService;hsqldb.sqllog=3");
+        dataSource.setUsername("sa");
+        dataSource.setPassword("");
+        try (final Connection dbConn = dataSource.getConnection()) {
+            final Statement statement = dbConn.createStatement();
+            statement.executeUpdate(INIT_SQL_HSQLDB);
+        }
+    }
+
+    private void setupSQLServer() throws ClassNotFoundException, SQLException {
+        Class.forName("com.microsoft.sqlserver.jdbc.SQLServerDriver");
+        dataSource.setUrl("jdbc:sqlserver://10.0.0.125:1433;encrypt=false");
+        dataSource.setUsername("sa");
+        dataSource.setPassword("");
+        try (final Connection dbConn = dataSource.getConnection()) {
+            final Statement statement = dbConn.createStatement();
+            try {
+            	statement.executeUpdate(CLEANUP_SQL);
+            }  catch (final SQLException e) {
+            	System.out.println(e);
+            }
+            statement.executeUpdate(INIT_SQL_SQLSERVER);
+        }
+    }
+
+    /**
+     * Creates the shared instance of the entity manager factory.
+     */
+    @BeforeClass public void setUp() throws ComponentInitializationException {
+        try {
+            dataSource = new BasicDataSource();
+            if (USE_SQLSERVER) {
+                setupSQLServer();
+            } else {
+                setupHSSQLDB();
+            }
+
+            storageService = new JDBCStorageService();
+            storageService.setId("test");
+            storageService.setDataSource(dataSource);
+            storageService.setCleanupInterval(Duration.ofSeconds(5));
+            storageService.setTransactionRetry(12);
+            storageService.setRetryableErrors(List.of("40001"));
+        } catch (final SQLException | ClassNotFoundException e) {
+            throw new ComponentInitializationException(e);
+        }
+        super.setUp();
+    }
+    
+    @AfterClass
+    protected void tearDown() {
+        try {
+            List<String> contexts1 = storageService.readContexts();
+            for (String ctx : contexts1) {
+                storageService.deleteContext(ctx);
+            }
+            List<?> recs = storageService.readAll();
+            Assert.assertEquals(recs.size(), 0);
+        } catch (IOException e){ 
+            throw new RuntimeException(e);
+        }
+        super.tearDown();
+        try {
+            Statement statement = dataSource.getConnection().createStatement();
+            statement.executeUpdate(CLEANUP_SQL);
+            dataSource.close();
+        } catch (SQLException e) {
+            throw new TestException(e);
+        }
+    }
+
+    @Nonnull protected StorageService getStorageService() {
+        return storageService;
+    }
+    /*
+    @Test(enabled = false)
+    public void strings() throws IOException {
+    }*/
+
+    @Test
+    public void cleanup() throws ComponentInitializationException, IOException {
+        String context = Long.toString(random.nextLong());
+        for (int i = 1; i <= 100; i++) {
+            storageService.create(context, Integer.toString(i), Integer.toString(i + 1), System.currentTimeMillis() + 100);
+        }
+        try {
+            Thread.sleep(7500);
+        } catch (InterruptedException e) {
+            throw new IOException(e);
+        }
+        List<?> recs = storageService.readAll(context);
+        Assert.assertEquals(recs.size(), 0);
+    }
+
+    @DataProvider(name = "contexts")
+    public Object[][] contexts() throws Exception {
+        return contexts;
+    }
+
+    @Test(dataProvider = "contexts", singleThreaded = false, threadPoolSize = 25, invocationCount = 100, enabled = true)
+    public void multithread(final String context) throws IOException {
+        shared.create(context, "mt", "bar", System.currentTimeMillis() + 300000);
+        StorageRecord<?> rec = shared.read(context, "mt");
+        Assert.assertNotNull(rec);
+        shared.update(context, "mt", "baz", System.currentTimeMillis() + 300000);
+        rec = shared.read(context, "mt");
+        Assert.assertNotNull(rec);
+        boolean result = shared.create(context, "mt", "qux", null);
+        Assert.assertFalse(result, "createString should have failed");
+    }
+
+    @Test(singleThreaded = false, threadPoolSize = 25, invocationCount = 100, enabled = true)
+    public void multithreadCaseSensitiveKey() throws IOException {
+        shared.create("unit_test", "foo", "bar", null);
+        shared.create("unit_test", "FOO", "bar", null);
+        StorageRecord<?> rec1 = shared.read("unit_test", "foo");
+        StorageRecord<?> rec2 = shared.read("unit_test", "FOO");
+        Assert.assertNotNull(rec1);
+        Assert.assertNotNull(rec2);
+        Assert.assertNotEquals(rec1, rec2);
+    }
+
+    @Test
+    public void keyCollision() throws IOException {
+        shared.create("unit_test", "dlo1", "value", null);
+        shared.create("unit_test", "dn11", "value", null);
+        StorageRecord<?> rec1 = shared.read("unit_test", "dlo1");
+        StorageRecord<?> rec2 = shared.read("unit_test", "dn11");
+        Assert.assertNotNull(rec1);
+        Assert.assertNotNull(rec2);
+        Assert.assertNotEquals(rec1, rec2);
+
+        shared.update("unit_test", "dlo1", "value2", null);
+        shared.update("unit_test", "dn11", "value2", null);
+        rec1 = shared.read("unit_test", "dlo1");
+        rec2 = shared.read("unit_test", "dn11");
+        Assert.assertNotNull(rec1);
+        Assert.assertNotNull(rec2);
+        Assert.assertNotEquals(rec1, rec2);
+
+        Assert.assertEquals(2, storageService.readAll().size());
+        Assert.assertEquals(2, storageService.readAll("unit_test").size());
+
+        shared.delete("unit_test", "dlo1");
+        rec1 = shared.read("unit_test", "dlo1");
+        rec2 = shared.read("unit_test", "dn11");
+        Assert.assertNull(rec1);
+        Assert.assertNotNull(rec2);
+        shared.delete("unit_test", "dn11");
+        rec1 = shared.read("unit_test", "dlo1");
+        rec2 = shared.read("unit_test", "dn11");
+        Assert.assertNull(rec1);
+        Assert.assertNull(rec2);
+    }
+
+    @Test
+    public void caseSensitiveContext() throws IOException {
+    	assertTrue(shared.create("foo", "bar", "value", null));
+        assertTrue(shared.create("FOO", "bar", "value", null));
+        StorageRecord<?> rec1 = shared.read("foo", "bar");
+        StorageRecord<?> rec2 = shared.read("FOO", "bar");
+        Assert.assertNotNull(rec1);
+        Assert.assertNotNull(rec2);
+        Assert.assertNotEquals(rec1, rec2);
+        Assert.assertEquals(2, storageService.readAll().size());
+
+        shared.update("foo", "bar", "value2", null);
+        shared.update("FOO", "bar", "value2", null);
+        rec1 = shared.read("foo", "bar");
+        rec2 = shared.read("FOO", "bar");
+        Assert.assertNotNull(rec1);
+        Assert.assertNotNull(rec2);
+        Assert.assertNotEquals(rec1, rec2);
+
+        Assert.assertEquals(storageService.readAll().size(), 2);
+        Assert.assertEquals(storageService.readAll("foo").size(), 1);
+        Assert.assertEquals(storageService.readAll("FOO").size(), 1);
+
+        shared.delete("foo", "bar");
+        rec1 = shared.read("foo", "bar");
+        rec2 = shared.read("FOO", "bar");
+        Assert.assertNull(rec1);
+        Assert.assertNotNull(rec2);
+        shared.delete("FOO", "bar");
+        rec1 = shared.read("foo", "bar");
+        rec2 = shared.read("FOO", "bar");
+        Assert.assertNull(rec1);
+        Assert.assertNull(rec2);
+    }
+
+    @Test
+    public void caseSensitiveKey() throws IOException {
+        shared.create("unit_test", "foo", "value", null);
+        shared.create("unit_test", "FOO", "value", null);
+        StorageRecord<?> rec1 = shared.read("unit_test", "foo");
+        StorageRecord<?> rec2 = shared.read("unit_test", "FOO");
+        Assert.assertNotNull(rec1);
+        Assert.assertNotNull(rec2);
+        Assert.assertNotEquals(rec1, rec2);
+
+        shared.update("unit_test", "foo", "value2", null);
+        shared.update("unit_test", "FOO", "value2", null);
+        rec1 = shared.read("unit_test", "foo");
+        rec2 = shared.read("unit_test", "FOO");
+        Assert.assertNotNull(rec1);
+        Assert.assertNotNull(rec2);
+        Assert.assertNotEquals(rec1, rec2);
+
+        Assert.assertEquals(2, storageService.readAll().size());
+        Assert.assertEquals(2, storageService.readAll("unit_test").size());
+
+        shared.delete("unit_test", "foo");
+        rec1 = shared.read("unit_test", "foo");
+        rec2 = shared.read("unit_test", "FOO");
+        Assert.assertNull(rec1);
+        Assert.assertNotNull(rec2);
+        shared.delete("unit_test", "FOO");
+        rec1 = shared.read("unit_test", "foo");
+        rec2 = shared.read("unit_test", "FOO");
+        Assert.assertNull(rec1);
+        Assert.assertNull(rec2);
+    }
+
+    @Test(enabled = false)
+    public void largeValue() throws IOException {
+        // hsqldb defaults LOB length to 255 chars; disabled for now
+        StringBuilder sb = new StringBuilder(1000 * 36);
+        for (int i = 0; i < 1000; i++) {
+            sb.append(UUID.randomUUID());
+        }
+        shared.create("unit_test", "large", sb.toString(), System.currentTimeMillis() + 300000);
+        StorageRecord<?> rec = shared.read("unit_test", "large");
+        Assert.assertNotNull(rec);
+        Assert.assertEquals(sb.toString(), rec.getValue());
+    }
+    
+}
diff --git a/jdbc-storage-impl/src/test/java/net/shibboleth/plugin/storage/jdbc/impl/JDCBJPAMixedStorageServiceTest.java b/jdbc-storage-impl/src/test/java/net/shibboleth/plugin/storage/jdbc/impl/JDCBJPAMixedStorageServiceTest.java
new file mode 100644
index 0000000..f055baf
--- /dev/null
+++ b/jdbc-storage-impl/src/test/java/net/shibboleth/plugin/storage/jdbc/impl/JDCBJPAMixedStorageServiceTest.java
@@ -0,0 +1,316 @@
+/*
+ * Licensed to the University Corporation for Advanced Internet Development,
+ * Inc. (UCAID) under one or more contributor license agreements.  See the
+ * NOTICE file distributed with this work for additional information regarding
+ * copyright ownership. The UCAID licenses this file to You under the Apache
+ * License, Version 2.0 (the "License"); you may not use this file except in
+ * compliance with the License.  You may obtain a copy of the License at
+ *
+ *    http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package net.shibboleth.plugin.storage.jdbc.impl;
+
+import java.io.IOException;
+import java.security.SecureRandom;
+import java.time.Duration;
+import java.util.List;
+import java.util.UUID;
+
+import javax.annotation.Nonnull;
+import javax.persistence.EntityManagerFactory;
+
+import net.shibboleth.ext.spring.util.ApplicationContextBuilder;
+import net.shibboleth.utilities.java.support.component.ComponentInitializationException;
+
+import org.apache.commons.dbcp2.BasicDataSource;
+import org.opensaml.storage.StorageRecord;
+import org.opensaml.storage.StorageService;
+import org.opensaml.storage.impl.JPAStorageService;
+import org.opensaml.storage.testing.StorageServiceTest;
+import org.springframework.beans.factory.FactoryBean;
+import org.springframework.context.support.GenericApplicationContext;
+import org.springframework.core.io.ClassPathResource;
+import org.springframework.core.io.Resource;
+import org.testng.Assert;
+import org.testng.annotations.AfterClass;
+import org.testng.annotations.BeforeClass;
+import org.testng.annotations.DataProvider;
+import org.testng.annotations.Test;
+
+/**
+ * Test of {@link JPAStorageService} implementation.
+ */
+ at SuppressWarnings("javadoc")
+public class JDCBJPAMixedStorageServiceTest extends StorageServiceTest {
+
+    /** Storage service. */
+    private JPAStorageService storageService;
+    
+    private JDBCStorageService jdbmsService;
+
+    /** Contexts used for testing. */
+    private Object[][] contexts;
+
+    public JDCBJPAMixedStorageServiceTest() {
+        final SecureRandom random1 = new SecureRandom();
+        contexts = new Object[10][1];
+        for (int i = 0; i < 10; i++) {
+            contexts[i] = new Object[] {Long.toString(random1.nextLong()), };
+        }
+    }
+
+    /**
+     * Creates the shared instance of the entity manager factory.
+     */
+    @BeforeClass public void setUp() throws ComponentInitializationException {
+        storageService = new JPAStorageService(createEntityManagerFactory());
+        storageService.setId("test");
+        storageService.setCleanupInterval(Duration.ofSeconds(5));
+        storageService.setTransactionRetry(2);
+        super.setUp();
+    }
+
+    /**
+     * Creates an entity manager factory instance.
+     * 
+     * @return an entity manager factory instance
+     * 
+     * @throws ComponentInitializationException ...
+     */
+    private EntityManagerFactory createEntityManagerFactory() throws ComponentInitializationException
+    {
+        final Resource resource = new ClassPathResource("/org/opensaml/storage/impl/jpa-spring-context.xml");
+        final GenericApplicationContext context =
+                new ApplicationContextBuilder()
+                    .setName("JPAStorageService")
+                    .setServiceConfiguration(resource)
+                    .build();
+        final FactoryBean<EntityManagerFactory> factoryBean =
+                context.getBean(org.springframework.orm.jpa.LocalContainerEntityManagerFactoryBean.class);
+        final BasicDataSource source = context.getBean("hibernateDataSource", BasicDataSource.class);
+        jdbmsService = new JDBCStorageService();
+        jdbmsService.setDataSource(source);
+        jdbmsService.setId("TEstJDBCS");
+        jdbmsService.initialize();
+        
+        try {
+            return factoryBean.getObject();
+        } catch (Exception e) {
+            throw new ComponentInitializationException(e);
+        }
+    }
+
+    @AfterClass
+    protected void tearDown() {
+        try {
+            List<String> contexts1 = storageService.readContexts();
+            for (String ctx : contexts1) {
+                storageService.deleteContext(ctx);
+            }
+            List<?> recs = storageService.readAll();
+            Assert.assertEquals(recs.size(), 0);
+        } catch (IOException e){ 
+            throw new RuntimeException(e);
+        }
+        super.tearDown();
+    }
+
+    @Nonnull protected StorageService getStorageService() {
+        return storageService;
+    }
+
+    @Test
+    public void cleanup() throws ComponentInitializationException, IOException {
+        String context = Long.toString(random.nextLong());
+        for (int i = 1; i <= 100; i++) {
+            storageService.create(context, Integer.toString(i), Integer.toString(i + 1), System.currentTimeMillis() + 100);
+        }
+        try {
+            Thread.sleep(7500);
+        } catch (InterruptedException e) {
+            throw new IOException(e);
+        }
+        List<?> recs = storageService.readAll(context);
+        Assert.assertEquals(recs.size(), 0);
+    }
+
+    @DataProvider(name = "contexts")
+    public Object[][] contexts() throws Exception {
+        return contexts;
+    }
+
+    @Test(dataProvider = "contexts", singleThreaded = false, threadPoolSize = 25, invocationCount = 100)
+    public void multithread(final String context) throws IOException {
+        shared.create(context, "mt", "bar", System.currentTimeMillis() + 300000);
+        StorageRecord<?> rec = shared.read(context, "mt");
+        Assert.assertNotNull(rec);
+        shared.update(context, "mt", "baz", System.currentTimeMillis() + 300000);
+        rec = shared.read(context, "mt");
+        Assert.assertNotNull(rec);
+        boolean result = shared.create(context, "mt", "qux", null);
+        Assert.assertFalse(result, "createString should have failed");
+    }
+
+    @Test(singleThreaded = false, threadPoolSize = 25, invocationCount = 100)
+    public void multithreadCaseSensitiveKey() throws IOException {
+        shared.create("unit_test", "foo", "bar", null);
+        shared.create("unit_test", "FOO", "bar", null);
+        StorageRecord<?> rec1 = shared.read("unit_test", "foo");
+        StorageRecord<?> rec2 = shared.read("unit_test", "FOO");
+        Assert.assertNotNull(rec1);
+        Assert.assertNotNull(rec2);
+        Assert.assertNotEquals(rec1, rec2);
+    }
+
+    @Test
+    public void keyCollision() throws IOException {
+        shared.create("unit_test", "dlo1", "value", null);
+        shared.create("unit_test", "dn11", "value", null);
+        StorageRecord<?> rec1 = shared.read("unit_test", "dlo1");
+        StorageRecord<?> rec2 = shared.read("unit_test", "dn11");
+        Assert.assertNotNull(rec1);
+        Assert.assertNotNull(rec2);
+        Assert.assertNotEquals(rec1, rec2);
+
+        shared.update("unit_test", "dlo1", "value2", null);
+        shared.update("unit_test", "dn11", "value2", null);
+        rec1 = shared.read("unit_test", "dlo1");
+        rec2 = shared.read("unit_test", "dn11");
+        Assert.assertNotNull(rec1);
+        Assert.assertNotNull(rec2);
+        Assert.assertNotEquals(rec1, rec2);
+
+        Assert.assertEquals(2, storageService.readAll().size());
+        Assert.assertEquals(2, storageService.readAll("unit_test").size());
+
+        shared.delete("unit_test", "dlo1");
+        rec1 = shared.read("unit_test", "dlo1");
+        rec2 = shared.read("unit_test", "dn11");
+        Assert.assertNull(rec1);
+        Assert.assertNotNull(rec2);
+        shared.delete("unit_test", "dn11");
+        rec1 = shared.read("unit_test", "dlo1");
+        rec2 = shared.read("unit_test", "dn11");
+        Assert.assertNull(rec1);
+        Assert.assertNull(rec2);
+    }
+
+    @Test
+    public void caseSensitiveContext() throws IOException {
+        shared.create("foo", "bar", "value", null);
+        shared.create("FOO", "bar", "value", null);
+        StorageRecord<?> rec1 = shared.read("foo", "bar");
+        StorageRecord<?> rec2 = shared.read("FOO", "bar");
+        Assert.assertNotNull(rec1);
+        Assert.assertNotNull(rec2);
+        Assert.assertNotEquals(rec1, rec2);
+
+        shared.update("foo", "bar", "value2", null);
+        shared.update("FOO", "bar", "value2", null);
+        rec1 = shared.read("foo", "bar");
+        rec2 = shared.read("FOO", "bar");
+        Assert.assertNotNull(rec1);
+        Assert.assertNotNull(rec2);
+        Assert.assertNotEquals(rec1, rec2);
+
+        Assert.assertEquals(2, storageService.readAll().size());
+        Assert.assertEquals(1, storageService.readAll("foo").size());
+        Assert.assertEquals(1, storageService.readAll("FOO").size());
+
+        shared.delete("foo", "bar");
+        rec1 = shared.read("foo", "bar");
+        rec2 = shared.read("FOO", "bar");
+        Assert.assertNull(rec1);
+        Assert.assertNotNull(rec2);
+        shared.delete("FOO", "bar");
+        rec1 = shared.read("foo", "bar");
+        rec2 = shared.read("FOO", "bar");
+        Assert.assertNull(rec1);
+        Assert.assertNull(rec2);
+    }
+
+    @Test
+    public void caseSensitiveKey() throws IOException {
+        shared.create("unit_test", "foo", "value", null);
+        shared.create("unit_test", "FOO", "value", null);
+        StorageRecord<?> rec1 = shared.read("unit_test", "foo");
+        StorageRecord<?> rec2 = shared.read("unit_test", "FOO");
+        Assert.assertNotNull(rec1);
+        Assert.assertNotNull(rec2);
+        Assert.assertNotEquals(rec1, rec2);
+
+        shared.update("unit_test", "foo", "value2", null);
+        shared.update("unit_test", "FOO", "value2", null);
+        rec1 = shared.read("unit_test", "foo");
+        rec2 = shared.read("unit_test", "FOO");
+        Assert.assertNotNull(rec1);
+        Assert.assertNotNull(rec2);
+        Assert.assertNotEquals(rec1, rec2);
+
+        Assert.assertEquals(2, storageService.readAll().size());
+        Assert.assertEquals(2, storageService.readAll("unit_test").size());
+
+        shared.delete("unit_test", "foo");
+        rec1 = shared.read("unit_test", "foo");
+        rec2 = shared.read("unit_test", "FOO");
+        Assert.assertNull(rec1);
+        Assert.assertNotNull(rec2);
+        shared.delete("unit_test", "FOO");
+        rec1 = shared.read("unit_test", "foo");
+        rec2 = shared.read("unit_test", "FOO");
+        Assert.assertNull(rec1);
+        Assert.assertNull(rec2);
+    }
+
+    @Test(enabled = false)
+    public void largeValue() throws IOException {
+        // hsqldb defaults LOB length to 255 chars; disabled for now
+        StringBuilder sb = new StringBuilder(1000 * 36);
+        for (int i = 0; i < 1000; i++) {
+            sb.append(UUID.randomUUID());
+        }
+        shared.create("unit_test", "large", sb.toString(), System.currentTimeMillis() + 300000);
+        StorageRecord<?> rec = shared.read("unit_test", "large");
+        Assert.assertNotNull(rec);
+        Assert.assertEquals(sb.toString(), rec.getValue());
+    }
+    
+    @Test
+    public void jpaWriteRDBMSRead() throws IOException {
+        StringBuilder sb = new StringBuilder(255);
+        for (int i = 0; i < 255/36; i++) {
+            sb.append(UUID.randomUUID());
+        }
+        shared.create("mixed1", "large", sb.toString(), System.currentTimeMillis() + 300000);
+        final StorageRecord<?> jpa = shared.read("mixed1", "large");
+        Assert.assertNotNull(jpa);
+        Assert.assertEquals(sb.toString(), jpa.getValue());
+
+        final StorageRecord<?> rdbms = jdbmsService.read("mixed1", "large");
+        Assert.assertNotNull(rdbms );
+        Assert.assertEquals(sb.toString(), rdbms .getValue());
+    }
+    @Test
+    public void jpaReadDBMSWrite() throws IOException {
+        StringBuilder sb = new StringBuilder(255);
+        for (int i = 0; i < 255/36; i++) {
+            sb.append(UUID.randomUUID());
+        }
+        Assert.assertTrue(jdbmsService.create("mixed1", "rrrwd", sb.toString(), System.currentTimeMillis() + 300000));
+        final StorageRecord<?> rdbms = jdbmsService.read("mixed1", "rrrwd");
+        Assert.assertNotNull(rdbms );
+        Assert.assertEquals(sb.toString(), rdbms .getValue());
+        final StorageRecord<?> jpa = shared.read("mixed1", "rrrwd");
+        Assert.assertNotNull(jpa);
+        Assert.assertEquals(sb.toString(), jpa.getValue());
+
+        Assert.assertFalse(jdbmsService.create("mixed1", "rrrwd", sb.toString(), System.currentTimeMillis() + 300000));
+    }
+}
diff --git a/jdbc-storage-impl/src/test/resources/logback-test.xml b/jdbc-storage-impl/src/test/resources/logback-test.xml
new file mode 100644
index 0000000..c21c4ab
--- /dev/null
+++ b/jdbc-storage-impl/src/test/resources/logback-test.xml
@@ -0,0 +1,21 @@
+<?xml version="1.0" encoding="UTF-8"?>
+
+<configuration>
+
+    <appender name="STDOUT" class="ch.qos.logback.core.ConsoleAppender">
+        <encoder class="ch.qos.logback.classic.encoder.PatternLayoutEncoder">
+            <pattern>%level [%logger:%line] - %msg%n%ex{short}</pattern>
+            <charset>UTF-8</charset>
+        </encoder>
+    </appender>
+
+    <root>
+        <level value="warn" />
+        <appender-ref ref="STDOUT" />
+    </root>
+    
+    <logger name="net.shibboleth.plugin.storage.jdbc.impl" level="TRACE"/>
+    <logger name="org.opensaml.storage" level="DEBUG"/>
+    <logger name="net.shibboleth.utilities.java.support.security.DataSealer" level="TRACE"/>
+    
+</configuration>
\ No newline at end of file
diff --git a/pom.xml b/pom.xml
index 9a7a2ed..82287be 100644
--- a/pom.xml
+++ b/pom.xml
@@ -67,13 +67,13 @@
     <!-- jdbc-storage project dependencies -->
     <dependencies>
       <dependency>
-        <groupId>net.shibboleth.idp.plugin.storage.jdbc</groupId>
-        <artifactId>idp-plugin-jdbc-storage-api</artifactId>
+        <groupId>net.shibboleth.plugin.storage.jdbc</groupId>
+        <artifactId>jdbc-storage-api</artifactId>
         <version>${project.version}</version>
       </dependency>
       <dependency>
-        <groupId>net.shibboleth.idp.plugin.storage.jdbc</groupId>
-        <artifactId>idp-plugin-jdbc-storage-impl</artifactId>
+        <groupId>net.shibboleth.plugin.storage.jdbc</groupId>
+        <artifactId>jdbc-storage-impl</artifactId>
         <version>${project.version}</version>
       </dependency>
       <!-- Shibboleth IdP BOM for importing IdP dependencies -->

-- 
To stop receiving notification emails like this one, please contact
the administrator of this repository.


More information about the commits mailing list