From 2ece27288e92bf3b1304c19ee3b20886ef62bc1b Mon Sep 17 00:00:00 2001 From: Mark Paluch Date: Thu, 1 Jun 2017 16:34:25 +0200 Subject: [PATCH] DATACASS-403 - Add improved support for prepared statement caching. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit We now provide PreparedStatementCache and CachedPreparedStatementCreator to prepare and cache prepared statements. The original o.s.d.c.c.CachedPreparedStatementCreator is deprecated as it uses an static held Map to cache statements. PreparedStatementCache cache = PreparedStatementCache.create(); CachedPreparedStatementCreator creator = CachedPreparedStatementCreator.of(cache, insert) template.execute(creator, (session, ps) -> session.execute(ps.bind(…))); Related ticket: DATACASS-291. --- .../CachedPreparedStatementCreator.java | 132 +++++++++++++ .../support/MapPreparedStatementCache.java | 110 +++++++++++ .../core/support/PreparedStatementCache.java | 56 ++++++ ...eparedStatementCreatorIntegrationTest.java | 63 ++++++ ...chedPreparedStatementCreatorUnitTests.java | 181 ++++++++++++++++++ 5 files changed, 542 insertions(+) create mode 100644 spring-data-cassandra/src/main/java/org/springframework/data/cql/core/support/CachedPreparedStatementCreator.java create mode 100644 spring-data-cassandra/src/main/java/org/springframework/data/cql/core/support/MapPreparedStatementCache.java create mode 100644 spring-data-cassandra/src/main/java/org/springframework/data/cql/core/support/PreparedStatementCache.java create mode 100644 spring-data-cassandra/src/test/java/org/springframework/data/cql/core/support/CachedPreparedStatementCreatorIntegrationTest.java create mode 100644 spring-data-cassandra/src/test/java/org/springframework/data/cql/core/support/CachedPreparedStatementCreatorUnitTests.java diff --git a/spring-data-cassandra/src/main/java/org/springframework/data/cql/core/support/CachedPreparedStatementCreator.java b/spring-data-cassandra/src/main/java/org/springframework/data/cql/core/support/CachedPreparedStatementCreator.java new file mode 100644 index 000000000..1fd15d091 --- /dev/null +++ b/spring-data-cassandra/src/main/java/org/springframework/data/cql/core/support/CachedPreparedStatementCreator.java @@ -0,0 +1,132 @@ +/* + * Copyright 2017 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.springframework.data.cql.core.support; + +import org.springframework.data.cql.core.PreparedStatementCreator; +import org.springframework.data.cql.core.QueryOptions; +import org.springframework.data.cql.core.QueryOptionsUtil; +import org.springframework.util.Assert; + +import com.datastax.driver.core.PreparedStatement; +import com.datastax.driver.core.RegularStatement; +import com.datastax.driver.core.Session; +import com.datastax.driver.core.SimpleStatement; +import com.datastax.driver.core.exceptions.DriverException; + +/** + * {@link PreparedStatementCreator} implementation using caching of prepared statements. + *

+ * Regular CQL statements are prepared on first use and executed as prepared statements. Prepared statements are cached + * by Cassandra itself (invalidation/eviction possible), in the driver to be able to re-prepare a statement and in this + * {@link CachedPreparedStatementCreator} using {@link PreparedStatementCache}. + * + * @author Mark Paluch + * @since 2.0 + * @see PreparedStatementCache + */ +public class CachedPreparedStatementCreator implements PreparedStatementCreator { + + private final PreparedStatementCache cache; + + private final RegularStatement statement; + + /** + * Create a new {@link CachedPreparedStatementCreator}. + * + * @param cache must not be {@literal null}. + * @param statement may be {@literal null} of {@code cql} is provided. + */ + protected CachedPreparedStatementCreator(PreparedStatementCache cache, RegularStatement statement) { + + Assert.notNull(cache, "Cache must not be null"); + + this.cache = cache; + this.statement = statement; + } + + /** + * Create a new {@link CachedPreparedStatementCreator} given {@link PreparedStatementCache} and + * {@link RegularStatement} to prepare. Subsequent calls require the a {@link RegularStatement} object with the same + * CQL test for a cache hit. Otherwise, the statement is likely to be re-prepared. + * + * @param cache must not be {@literal null}. + * @param cql must not be {@literal null} or empty. + * @return the {@link CachedPreparedStatementCreator} for {@link RegularStatement}. + */ + public static CachedPreparedStatementCreator of(PreparedStatementCache cache, RegularStatement statement) { + + Assert.notNull(cache, "Cache must not be null"); + Assert.notNull(statement, "Statement must not be null"); + + return new CachedPreparedStatementCreator(cache, statement); + } + + /** + * Create a new {@link CachedPreparedStatementCreator} given {@link PreparedStatementCache} and {@code cql} to + * prepare. Subsequent calls require the a CQL statement that {@link String#equals(Object) are equal} to the + * previously used CQL string for a cache hit. Otherwise, the statement is likely to be re-prepared. + * + * @param cache must not be {@literal null}. + * @param cql must not be {@literal null} or empty. + * @return the {@link CachedPreparedStatementCreator} for {@code cql}. + */ + public static CachedPreparedStatementCreator of(PreparedStatementCache cache, String cql) { + + Assert.notNull(cache, "Cache must not be null"); + Assert.hasText(cql, "CQL statement must not be null"); + + return new CachedPreparedStatementCreator(cache, new SimpleStatement(cql)); + } + + /** + * Create a new {@link CachedPreparedStatementCreator} given {@link PreparedStatementCache} and {@code cql} to + * prepare. This method applies {@link QueryOptions} to the {@link com.datastax.driver.core.Statement} before + * preparing it. Subsequent calls require the a CQL statement that {@link String#equals(Object) are equal} to the + * previously used CQL string for a cache hit. Otherwise, the statement is likely to be re-prepared. + * + * @param cache must not be {@literal null}. + * @param cql must not be {@literal null} or empty. + * @param queryOptions must not be {@literal null}. + * @return the {@link CachedPreparedStatementCreator} for {@code cql}. + */ + public static CachedPreparedStatementCreator of(PreparedStatementCache cache, String cql, QueryOptions queryOptions) { + + Assert.notNull(cache, "Cache must not be null"); + Assert.hasText(cql, "CQL statement must not be null"); + Assert.notNull(queryOptions, "QueryOptions must not be null"); + + SimpleStatement statement = new SimpleStatement(cql); + + QueryOptionsUtil.addQueryOptions(statement, queryOptions); + + return new CachedPreparedStatementCreator(cache, statement); + } + + /** + * @return the underlying {@link PreparedStatementCache}. + */ + public PreparedStatementCache getCache() { + return cache; + } + + /* (non-Javadoc) + * @see org.springframework.data.cql.core.PreparedStatementCreator#createPreparedStatement(com.datastax.driver.core.Session) + */ + @Override + public PreparedStatement createPreparedStatement(Session session) throws DriverException { + return cache.getPreparedStatement(session, statement, () -> session.prepare(statement)); + } +} diff --git a/spring-data-cassandra/src/main/java/org/springframework/data/cql/core/support/MapPreparedStatementCache.java b/spring-data-cassandra/src/main/java/org/springframework/data/cql/core/support/MapPreparedStatementCache.java new file mode 100644 index 000000000..275a13da5 --- /dev/null +++ b/spring-data-cassandra/src/main/java/org/springframework/data/cql/core/support/MapPreparedStatementCache.java @@ -0,0 +1,110 @@ +/* + * Copyright 2017 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.springframework.data.cql.core.support; + +import lombok.EqualsAndHashCode; + +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; +import java.util.function.Supplier; + +import org.springframework.util.Assert; + +import com.datastax.driver.core.Cluster; +import com.datastax.driver.core.PreparedStatement; +import com.datastax.driver.core.RegularStatement; +import com.datastax.driver.core.Session; + +/** + * {@link PreparedStatementCache} backed by a {@link Map} cache. Defaults to simple {@link ConcurrentHashMap} caching. + *

+ * Statements are cached with a key consisting of {@link Cluster}, {@code keyspace} and the {@code cql} text. Statement + * options (idempotency, timeouts) apply from the statement that was initially prepared. + * + * @author Mark Paluch + * @since 2.0 + */ +public class MapPreparedStatementCache implements PreparedStatementCache { + + private final Map cache; + + /** + * Create a new {@link MapPreparedStatementCache}. + * + * @param cache must not be {@literal null}. + */ + private MapPreparedStatementCache(Map cache) { + + Assert.notNull(cache, "Cache must not be null"); + + this.cache = cache; + } + + /** + * Create a {@link MapPreparedStatementCache} using {@link ConcurrentHashMap}. + * + * @return the new {@link MapPreparedStatementCache} backed by {@link ConcurrentHashMap}. + */ + public static MapPreparedStatementCache create() { + return of(new ConcurrentHashMap<>()); + } + + /** + * Create a {@link MapPreparedStatementCache} using the given {@link Map}. + * + * @return the new {@link MapPreparedStatementCache} backed the given {@link Map}. + */ + public static MapPreparedStatementCache of(Map cache) { + return new MapPreparedStatementCache(cache); + } + + /** + * @return the underlying {@link Map cache}. + */ + public Map getCache() { + return cache; + } + + /* (non-Javadoc) + * @see org.springframework.data.cql.core.support.PrepatedStatementCache#getPreparedStatement(com.datastax.driver.core.RegularStatement, com.datastax.driver.core.Session, java.util.function.Supplier) + */ + @Override + public PreparedStatement getPreparedStatement(Session session, RegularStatement statement, + Supplier preparer) { + + CacheKey cacheKey = new CacheKey(session, statement.toString()); + + return cache.computeIfAbsent(cacheKey, key -> session.prepare(statement)); + } + + /** + * Cache key for {@link PreparedStatement} caching. + */ + @EqualsAndHashCode + public static class CacheKey { + + final Cluster cluster; + final String keyspace; + final String cql; + + CacheKey(Session session, String cql) { + + this.cluster = session.getCluster(); + this.keyspace = session.getLoggedKeyspace(); + this.cql = cql; + } + } +} diff --git a/spring-data-cassandra/src/main/java/org/springframework/data/cql/core/support/PreparedStatementCache.java b/spring-data-cassandra/src/main/java/org/springframework/data/cql/core/support/PreparedStatementCache.java new file mode 100644 index 000000000..0f509b3b6 --- /dev/null +++ b/spring-data-cassandra/src/main/java/org/springframework/data/cql/core/support/PreparedStatementCache.java @@ -0,0 +1,56 @@ +/* + * Copyright 2017 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.springframework.data.cql.core.support; + +import java.util.function.Supplier; + +import com.datastax.driver.core.PreparedStatement; +import com.datastax.driver.core.RegularStatement; +import com.datastax.driver.core.Session; + +/** + * Cache interface to synchronously prepare CQL statements. + *

+ * Implementing classes of {@link PreparedStatementCache} come with own synchronization and cache implementation + * characteristics. A cache implementation should optimize for reduction of preparation calls and cache statements using + * Cassandras cache key which is specific to the Cluster, keyspace, and CQL text. + * + * @author Mark Paluch + * @since 2.0 + * @see PreparedStatement + */ +public interface PreparedStatementCache { + + /** + * Obtain a {@link PreparedStatement} by {@link Session} and {@link RegularStatement}. + * + * @param session must not be {@literal null}. + * @param statement must not be {@literal null}. + * @param preparer must not be {@literal null}. + * @return the {@link PreparedStatement}. + */ + PreparedStatement getPreparedStatement(Session session, RegularStatement statement, + Supplier preparer); + + /** + * Create a default cache backed by a {@link java.util.concurrent.ConcurrentHashMap}. + * + * @return a new {@link MapPreparedStatementCache}. + */ + static PreparedStatementCache create() { + return MapPreparedStatementCache.create(); + } +} diff --git a/spring-data-cassandra/src/test/java/org/springframework/data/cql/core/support/CachedPreparedStatementCreatorIntegrationTest.java b/spring-data-cassandra/src/test/java/org/springframework/data/cql/core/support/CachedPreparedStatementCreatorIntegrationTest.java new file mode 100644 index 000000000..b7fd54849 --- /dev/null +++ b/spring-data-cassandra/src/test/java/org/springframework/data/cql/core/support/CachedPreparedStatementCreatorIntegrationTest.java @@ -0,0 +1,63 @@ +/* + * Copyright 2017 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.springframework.data.cql.core.support; + +import static com.datastax.driver.core.querybuilder.QueryBuilder.*; +import static org.assertj.core.api.Assertions.*; + +import java.util.concurrent.atomic.AtomicBoolean; + +import org.junit.Before; +import org.junit.Test; +import org.springframework.data.cql.AbstractKeyspaceCreatingIntegrationTest; + +import com.datastax.driver.core.PreparedStatement; +import com.datastax.driver.core.querybuilder.Insert; +import com.datastax.driver.core.querybuilder.QueryBuilder; + +/** + * Integration tests for {@link CachedPreparedStatementCreator}. + * + * @author Mark Paluch + */ +public class CachedPreparedStatementCreatorIntegrationTest extends AbstractKeyspaceCreatingIntegrationTest { + + private static final AtomicBoolean initialized = new AtomicBoolean(); + + @Before + public void before() throws Exception { + + if (initialized.compareAndSet(false, true)) { + getSession().execute("CREATE TABLE IF NOT EXISTS user (id text PRIMARY KEY, username text);"); + } else { + session.execute("TRUNCATE user;"); + } + } + + @Test // DATACASS-403 + public void shouldRetainIdempotencyFlag() { + + Insert insert = QueryBuilder.insertInto("user").value("id", bindMarker()).value("username", bindMarker()); + + assertThat(insert.isIdempotent()).isTrue(); + + PreparedStatementCache cache = PreparedStatementCache.create(); + PreparedStatement preparedStatement = CachedPreparedStatementCreator.of(cache, insert) + .createPreparedStatement(session); + + assertThat(preparedStatement.isIdempotent()).isTrue(); + } +} diff --git a/spring-data-cassandra/src/test/java/org/springframework/data/cql/core/support/CachedPreparedStatementCreatorUnitTests.java b/spring-data-cassandra/src/test/java/org/springframework/data/cql/core/support/CachedPreparedStatementCreatorUnitTests.java new file mode 100644 index 000000000..14f2bf346 --- /dev/null +++ b/spring-data-cassandra/src/test/java/org/springframework/data/cql/core/support/CachedPreparedStatementCreatorUnitTests.java @@ -0,0 +1,181 @@ +/* + * Copyright 2017 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.springframework.data.cql.core.support; + +import static org.assertj.core.api.Assertions.*; +import static org.mockito.Mockito.*; + +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.mockito.Mock; +import org.mockito.junit.MockitoJUnitRunner; + +import com.datastax.driver.core.Cluster; +import com.datastax.driver.core.PreparedStatement; +import com.datastax.driver.core.RegularStatement; +import com.datastax.driver.core.Session; +import com.datastax.driver.core.SimpleStatement; +import com.datastax.driver.core.querybuilder.QueryBuilder; + +/** + * Unit tests for {@link CachedPreparedStatementCreator}. + * + * @author Mark Paluch + */ +@RunWith(MockitoJUnitRunner.class) +public class CachedPreparedStatementCreatorUnitTests { + + @Mock Session session; + + @Mock Session otherSession; + + @Mock Session otherKeyspaceSession; + + @Mock Cluster cluster; + + @Mock PreparedStatement preparedStatement; + + @Before + public void before() { + + when(session.getCluster()).thenReturn(cluster); + when(otherSession.getCluster()).thenReturn(cluster); + when(otherKeyspaceSession.getCluster()).thenReturn(cluster); + + when(session.getLoggedKeyspace()).thenReturn("keyspace"); + when(otherSession.getLoggedKeyspace()).thenReturn("keyspace"); + when(otherKeyspaceSession.getLoggedKeyspace()).thenReturn("other"); + + when(session.prepare(any(RegularStatement.class))).thenReturn(preparedStatement); + } + + @Test // DATACASS-403 + public void shouldPrepareStatement() { + + String cql = "SELECT foo FROM users;"; + + MapPreparedStatementCache cache = MapPreparedStatementCache.create(); + + CachedPreparedStatementCreator creator = CachedPreparedStatementCreator.of(cache, cql); + + PreparedStatement result = creator.createPreparedStatement(session); + + assertThat(result).isSameAs(preparedStatement); + assertThat(creator.getCache()).isSameAs(cache); + assertThat(cache.getCache()).hasSize(1); + } + + @Test // DATACASS-403 + public void shouldCachePreparedStatement() { + + String cql = "SELECT foo FROM users;"; + + PreparedStatementCache cache = PreparedStatementCache.create(); + + assertThat(CachedPreparedStatementCreator.of(cache, cql).createPreparedStatement(session)) + .isSameAs(preparedStatement); + assertThat(CachedPreparedStatementCreator.of(cache, cql).createPreparedStatement(session)) + .isSameAs(preparedStatement); + + verify(session, atMost(1)).prepare(any(SimpleStatement.class)); + } + + @Test // DATACASS-403 + public void shouldCachePreparedStatementAcrossSessions() { + + String cql = "SELECT foo FROM users;"; + + PreparedStatementCache cache = PreparedStatementCache.create(); + + CachedPreparedStatementCreator creator = CachedPreparedStatementCreator.of(cache, cql); + + assertThat(creator.createPreparedStatement(session)).isSameAs(preparedStatement); + assertThat(creator.createPreparedStatement(otherSession)).isSameAs(preparedStatement); + + verify(session, atMost(1)).prepare(any(SimpleStatement.class)); + verify(otherSession, never()).prepare(any(SimpleStatement.class)); + } + + @Test // DATACASS-403 + public void shouldCachePreparedStatementOnKeyspaceLevel() { + + String cql = "SELECT foo FROM users;"; + when(otherKeyspaceSession.prepare(any(RegularStatement.class))).thenReturn(preparedStatement); + + PreparedStatementCache cache = PreparedStatementCache.create(); + + CachedPreparedStatementCreator creator = CachedPreparedStatementCreator.of(cache, cql); + + assertThat(creator.createPreparedStatement(session)).isSameAs(preparedStatement); + assertThat(creator.createPreparedStatement(otherKeyspaceSession)).isSameAs(preparedStatement); + + verify(session).prepare(any(SimpleStatement.class)); + verify(otherKeyspaceSession).prepare(any(SimpleStatement.class)); + } + + @Test // DATACASS-403 + public void shouldCacheBuiltPreparedStatement() { + + RegularStatement statement = QueryBuilder.update("users").with(QueryBuilder.set("foo", "bar")); + + PreparedStatementCache cache = PreparedStatementCache.create(); + + when(session.prepare(statement)).thenReturn(preparedStatement); + + assertThat(CachedPreparedStatementCreator.of(cache, statement).createPreparedStatement(session)) + .isSameAs(preparedStatement); + assertThat(CachedPreparedStatementCreator.of(cache, statement).createPreparedStatement(session)) + .isSameAs(preparedStatement); + + assertThat(statement.isIdempotent()).isTrue(); + verify(session).prepare(statement); + } + + @Test // DATACASS-403 + public void shouldCacheSameBuiltPreparedStatements() { + + RegularStatement firstStatement = QueryBuilder.update("users").with(QueryBuilder.set("foo", "bar")); + RegularStatement secondStatement = QueryBuilder.update("users").with(QueryBuilder.set("foo", "bar")); + + PreparedStatementCache cache = PreparedStatementCache.create(); + + when(session.prepare(firstStatement)).thenReturn(preparedStatement); + + CachedPreparedStatementCreator.of(cache, firstStatement).createPreparedStatement(session); + CachedPreparedStatementCreator.of(cache, secondStatement).createPreparedStatement(session); + + verify(session).prepare(firstStatement); + } + + @Test // DATACASS-403 + public void shouldCacheAdoptDifferencesInCachedPreparedStatements() { + + RegularStatement firstStatement = QueryBuilder.update("users").with(QueryBuilder.set("foo", "bar")); + RegularStatement secondStatement = QueryBuilder.update("users").with(QueryBuilder.set("bar", "foo")); + + PreparedStatementCache cache = PreparedStatementCache.create(); + + when(session.prepare(firstStatement)).thenReturn(preparedStatement); + when(session.prepare(secondStatement)).thenReturn(preparedStatement); + + CachedPreparedStatementCreator.of(cache, firstStatement).createPreparedStatement(session); + CachedPreparedStatementCreator.of(cache, secondStatement).createPreparedStatement(session); + + verify(session).prepare(firstStatement); + verify(session).prepare(secondStatement); + } +}