diff --git a/CHANGES.txt b/CHANGES.txt index 726cfad750..050767a943 100644 --- a/CHANGES.txt +++ b/CHANGES.txt @@ -1,4 +1,5 @@ 4.1 + * Add auto_snapshot_ttl configuration (CASSANDRA-16790) * List snapshots of dropped tables (CASSANDRA-16843) * Add information whether sstables are dropped to SchemaChangeListener (CASSANDRA-17582) * Add a pluggable memtable API (CEP-11 / CASSANDRA-17034) diff --git a/conf/cassandra.yaml b/conf/cassandra.yaml index f846c7148a..2879680ecf 100644 --- a/conf/cassandra.yaml +++ b/conf/cassandra.yaml @@ -876,6 +876,14 @@ snapshot_before_compaction: false # lose data on truncation or drop. auto_snapshot: true +# Adds a time-to-live (TTL) to auto snapshots generated by table +# truncation or drop (when enabled). +# After the TTL is elapsed, the snapshot is automatically cleared. +# By default, auto snapshots *do not* have TTL, uncomment the property below +# to enable TTL on auto snapshots. +# Accepted units: d (days), h (hours) or m (minutes) +# auto_snapshot_ttl: 30d + # The act of creating or clearing a snapshot involves creating or removing # potentially tens of thousands of links, which can cause significant performance # impact, especially on consumer grade SSDs. A non-zero value here can diff --git a/src/java/org/apache/cassandra/config/Config.java b/src/java/org/apache/cassandra/config/Config.java index 9bf9dfffe5..35c9f67e83 100644 --- a/src/java/org/apache/cassandra/config/Config.java +++ b/src/java/org/apache/cassandra/config/Config.java @@ -292,6 +292,14 @@ public class Config public boolean snapshot_before_compaction = false; public boolean auto_snapshot = true; + + /** + * When auto_snapshot is true and this property + * is set, snapshots created by truncation or + * drop use this TTL. + */ + public String auto_snapshot_ttl; + public volatile long snapshot_links_per_second = 0; /* if the size of columns or super-columns are more than this, indexing will kick in */ diff --git a/src/java/org/apache/cassandra/config/DatabaseDescriptor.java b/src/java/org/apache/cassandra/config/DatabaseDescriptor.java index 5c661321a3..7ed6ce742f 100644 --- a/src/java/org/apache/cassandra/config/DatabaseDescriptor.java +++ b/src/java/org/apache/cassandra/config/DatabaseDescriptor.java @@ -160,6 +160,7 @@ public class DatabaseDescriptor private static boolean daemonInitialized; private static final int searchConcurrencyFactor = Integer.parseInt(System.getProperty(Config.PROPERTY_PREFIX + "search_concurrency_factor", "1")); + private static DurationSpec autoSnapshoTtl; private static volatile boolean disableSTCSInL0 = Boolean.getBoolean(Config.PROPERTY_PREFIX + "disable_stcs_in_l0"); private static final boolean unsafeSystem = Boolean.getBoolean(Config.PROPERTY_PREFIX + "unsafesystem"); @@ -394,6 +395,18 @@ public class DatabaseDescriptor //InetAddressAndPort and get the right defaults InetAddressAndPort.initializeDefaultPort(getStoragePort()); + if (conf.auto_snapshot_ttl != null) + { + try + { + autoSnapshoTtl = new DurationSpec(conf.auto_snapshot_ttl); + } + catch (IllegalArgumentException e) + { + throw new ConfigurationException("Invalid value of auto_snapshot_ttl: " + conf.auto_snapshot_ttl, false); + } + } + if (conf.commitlog_sync == null) { throw new ConfigurationException("Missing required directive CommitLogSync", false); @@ -2805,6 +2818,17 @@ public class DatabaseDescriptor return conf.auto_snapshot; } + public static DurationSpec getAutoSnapshotTtl() + { + return autoSnapshoTtl; + } + + @VisibleForTesting + public static void setAutoSnapshotTtl(DurationSpec newTtl) + { + autoSnapshoTtl = newTtl; + } + @VisibleForTesting public static void setAutoSnapshot(boolean autoSnapshot) { diff --git a/src/java/org/apache/cassandra/db/ColumnFamilyStore.java b/src/java/org/apache/cassandra/db/ColumnFamilyStore.java index d46b20421a..0057300690 100644 --- a/src/java/org/apache/cassandra/db/ColumnFamilyStore.java +++ b/src/java/org/apache/cassandra/db/ColumnFamilyStore.java @@ -2184,7 +2184,12 @@ public class ColumnFamilyStore implements ColumnFamilyStoreMBean, Memtable.Owner */ public TableSnapshot snapshot(String snapshotName) { - return snapshot(snapshotName, false, null, null, now()); + return snapshot(snapshotName, null); + } + + public TableSnapshot snapshot(String snapshotName, DurationSpec ttl) + { + return snapshot(snapshotName, false, ttl, null, now()); } /** @@ -2627,7 +2632,7 @@ public class ColumnFamilyStore implements ColumnFamilyStoreMBean, Memtable.Owner data.notifyTruncated(truncatedAt); if (!noSnapshot && DatabaseDescriptor.isAutoSnapshot()) - snapshot(Keyspace.getTimestampedSnapshotNameWithPrefix(name, SNAPSHOT_TRUNCATE_PREFIX)); + snapshot(Keyspace.getTimestampedSnapshotNameWithPrefix(name, SNAPSHOT_TRUNCATE_PREFIX), DatabaseDescriptor.getAutoSnapshotTtl()); discardSSTables(truncatedAt); @@ -3189,7 +3194,7 @@ public class ColumnFamilyStore implements ColumnFamilyStoreMBean, Memtable.Owner CompactionManager.instance.interruptCompactionForCFs(concatWithIndexes(), (sstable) -> true, true); if (DatabaseDescriptor.isAutoSnapshot()) - snapshot(Keyspace.getTimestampedSnapshotNameWithPrefix(name, ColumnFamilyStore.SNAPSHOT_DROP_PREFIX)); + snapshot(Keyspace.getTimestampedSnapshotNameWithPrefix(name, ColumnFamilyStore.SNAPSHOT_DROP_PREFIX), DatabaseDescriptor.getAutoSnapshotTtl()); CommitLog.instance.forceRecycleAllSegments(Collections.singleton(metadata.id)); diff --git a/test/distributed/org/apache/cassandra/distributed/test/AutoSnapshotTtlTest.java b/test/distributed/org/apache/cassandra/distributed/test/AutoSnapshotTtlTest.java new file mode 100644 index 0000000000..ace3b8fc26 --- /dev/null +++ b/test/distributed/org/apache/cassandra/distributed/test/AutoSnapshotTtlTest.java @@ -0,0 +1,160 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF 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 org.apache.cassandra.distributed.test; + +import java.io.IOException; + +import org.junit.AfterClass; +import org.junit.BeforeClass; +import org.junit.Test; + +import org.apache.cassandra.config.CassandraRelevantProperties; +import org.apache.cassandra.db.ColumnFamilyStore; +import org.apache.cassandra.distributed.Cluster; +import org.apache.cassandra.distributed.api.ConsistencyLevel; +import org.apache.cassandra.distributed.api.Feature; +import org.apache.cassandra.distributed.api.IInvokableInstance; +import org.apache.cassandra.distributed.shared.WithProperties; + +import static java.util.concurrent.TimeUnit.SECONDS; +import static org.apache.cassandra.db.ColumnFamilyStore.SNAPSHOT_DROP_PREFIX; +import static org.apache.cassandra.db.ColumnFamilyStore.SNAPSHOT_TRUNCATE_PREFIX; +import static org.apache.cassandra.distributed.Cluster.build; +import static org.awaitility.Awaitility.await; + +public class AutoSnapshotTtlTest extends TestBaseImpl +{ + public static final Integer SNAPSHOT_CLEANUP_PERIOD_SECONDS = 1; + public static final Integer FIVE_SECONDS = 5; + private static WithProperties properties = new WithProperties(); + + @BeforeClass + public static void beforeClass() throws Throwable + { + TestBaseImpl.beforeClass(); + properties.set(CassandraRelevantProperties.SNAPSHOT_CLEANUP_INITIAL_DELAY_SECONDS, 0); + properties.set(CassandraRelevantProperties.SNAPSHOT_CLEANUP_PERIOD_SECONDS, SNAPSHOT_CLEANUP_PERIOD_SECONDS); + properties.set(CassandraRelevantProperties.SNAPSHOT_MIN_ALLOWED_TTL_SECONDS, FIVE_SECONDS); + } + + @AfterClass + public static void after() + { + properties.close(); + } + + /** + * Check that when auto_snapshot_ttl=5s, snapshots created from TRUNCATE are expired after 10s + */ + @Test + public void testAutoSnapshotTTlOnTruncate() throws IOException + { + try (Cluster cluster = init(build().withNodes(1) + .withConfig(c -> c.with(Feature.GOSSIP) + .set("auto_snapshot_ttl", String.format("%ds", FIVE_SECONDS))) + .start())) + { + IInvokableInstance instance = cluster.get(1); + + cluster.schemaChange(withKeyspace("CREATE TABLE %s.tbl (key int, value text, PRIMARY KEY (key))")); + + populate(cluster); + + // Truncate Table + cluster.schemaChange(withKeyspace("TRUNCATE %s.tbl;")); + + // Check snapshot is listed after table is truncated + instance.nodetoolResult("listsnapshots").asserts().success().stdoutContains(SNAPSHOT_TRUNCATE_PREFIX); + + // Check snapshot is removed after 10s + await().timeout(10, SECONDS) + .pollInterval(1, SECONDS) + .until(() -> !instance.nodetoolResult("listsnapshots").getStdout().contains(SNAPSHOT_DROP_PREFIX)); + } + } + + /** + * Check that when auto_snapshot_ttl=5s, snapshots created from TRUNCATE are expired after 10s + */ + @Test + public void testAutoSnapshotTTlOnDrop() throws IOException + { + try (Cluster cluster = init(build().withNodes(1) + .withConfig(c -> c.with(Feature.GOSSIP) + .set("auto_snapshot_ttl", String.format("%ds", FIVE_SECONDS))) + .start())) + { + IInvokableInstance instance = cluster.get(1); + + cluster.schemaChange(withKeyspace("CREATE TABLE %s.tbl (key int, value text, PRIMARY KEY (key))")); + + populate(cluster); + + // Drop Table + cluster.schemaChange(withKeyspace("DROP TABLE %s.tbl;")); + + // Check snapshot is listed after table is dropped + instance.nodetoolResult("listsnapshots").asserts().success().stdoutContains(SNAPSHOT_DROP_PREFIX); + + // Check snapshot is removed after 10s + await().timeout(10, SECONDS) + .pollInterval(1, SECONDS) + .until(() -> !instance.nodetoolResult("listsnapshots").getStdout().contains(SNAPSHOT_DROP_PREFIX)); + } + } + + /** + * Check that when auto_snapshot_ttl is unset, snapshots created from DROP or TRUNCATE do not expire + */ + @Test + public void testAutoSnapshotTtlDisabled() throws IOException, InterruptedException + { + try (Cluster cluster = init(build().withNodes(1) + .withConfig(c -> c.with(Feature.GOSSIP)) + .start())) + { + IInvokableInstance instance = cluster.get(1); + + cluster.schemaChange(withKeyspace("CREATE TABLE %s.tbl (key int, value text, PRIMARY KEY (key))")); + + populate(cluster); + + // Truncate Table + cluster.schemaChange(withKeyspace("TRUNCATE %s.tbl;")); + + // Drop Table + cluster.schemaChange(withKeyspace("DROP TABLE %s.tbl;")); + + // Check snapshots are created after table is truncated and dropped + instance.nodetoolResult("listsnapshots").asserts().success().stdoutContains(SNAPSHOT_TRUNCATE_PREFIX); + instance.nodetoolResult("listsnapshots").asserts().success().stdoutContains(SNAPSHOT_DROP_PREFIX); + + // Check snapshot are *NOT* expired after 10s + Thread.sleep(2 * FIVE_SECONDS * 1000L); + instance.nodetoolResult("listsnapshots").asserts().success().stdoutContains(ColumnFamilyStore.SNAPSHOT_TRUNCATE_PREFIX); + instance.nodetoolResult("listsnapshots").asserts().success().stdoutContains(ColumnFamilyStore.SNAPSHOT_DROP_PREFIX); + } + } + + protected static void populate(Cluster cluster) + { + for (int i = 0; i < 100; i++) + cluster.coordinator(1).execute(withKeyspace("INSERT INTO %s.tbl (key, value) VALUES (?, 'txt')"), ConsistencyLevel.ONE, i); + } +} diff --git a/test/unit/org/apache/cassandra/cql3/validation/operations/AutoSnapshotTest.java b/test/unit/org/apache/cassandra/cql3/validation/operations/AutoSnapshotTest.java new file mode 100644 index 0000000000..839ca44b28 --- /dev/null +++ b/test/unit/org/apache/cassandra/cql3/validation/operations/AutoSnapshotTest.java @@ -0,0 +1,162 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF 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 org.apache.cassandra.cql3.validation.operations; + +import java.time.Instant; +import java.util.Arrays; +import java.util.Collection; +import java.util.Map; +import java.util.concurrent.TimeUnit; + +import com.google.common.util.concurrent.Uninterruptibles; +import org.junit.AfterClass; +import org.junit.Before; +import org.junit.BeforeClass; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.junit.runners.Parameterized; + +import org.apache.cassandra.config.DatabaseDescriptor; +import org.apache.cassandra.config.DurationSpec; +import org.apache.cassandra.cql3.CQLTester; +import org.apache.cassandra.db.ColumnFamilyStore; +import org.apache.cassandra.service.snapshot.TableSnapshot; +import org.assertj.core.api.Condition; + +import static org.apache.cassandra.db.ColumnFamilyStore.SNAPSHOT_DROP_PREFIX; +import static org.assertj.core.api.Assertions.assertThat; + +@RunWith(Parameterized.class) +public class AutoSnapshotTest extends CQLTester +{ + static int TTL_SECS = 1; + + public static Boolean enabledBefore; + public static DurationSpec ttlBefore; + + @BeforeClass + public static void beforeClass() + { + enabledBefore = DatabaseDescriptor.isAutoSnapshot(); + ttlBefore = DatabaseDescriptor.getAutoSnapshotTtl(); + } + + @AfterClass + public static void afterClass() + { + DatabaseDescriptor.setAutoSnapshot(enabledBefore); + DatabaseDescriptor.setAutoSnapshotTtl(ttlBefore); + } + + // Dynamic parameters used during tests + @Parameterized.Parameter(0) + public Boolean autoSnapshotEnabled; + + @Parameterized.Parameter(1) + public DurationSpec autoSnapshotTTl; + + @Before + public void beforeTest() throws Throwable + { + super.beforeTest(); + // Make sure we're testing the correct parameterized settings + DatabaseDescriptor.setAutoSnapshot(autoSnapshotEnabled); + DatabaseDescriptor.setAutoSnapshotTtl(autoSnapshotTTl); + } + + // Test for all values of [auto_snapshot=[true,false], ttl=[1s, null] + @Parameterized.Parameters( name = "enabled={0},ttl={1}" ) + public static Collection options() { + return Arrays.asList(new Object[][] { + { true, DurationSpec.inSeconds(TTL_SECS) }, + { false, DurationSpec.inSeconds(TTL_SECS) }, + { true, null }, + { false, null }, + }); + } + + @Test + public void testAutoSnapshotOnTrucate() throws Throwable + { + createTable("CREATE TABLE %s (a int, b int, c int, PRIMARY KEY(a, b))"); + // Check there are no snapshots + ColumnFamilyStore tableDir = getCurrentColumnFamilyStore(); + assertThat(tableDir.listSnapshots()).isEmpty(); + + execute("INSERT INTO %s (a, b, c) VALUES (?, ?, ?)", 0, 0, 0); + execute("INSERT INTO %s (a, b, c) VALUES (?, ?, ?)", 0, 1, 1); + + flush(); + + execute("DROP TABLE %s"); + + verifyAutoSnapshot(SNAPSHOT_DROP_PREFIX, tableDir); + } + + @Test + public void testAutoSnapshotOnDrop() throws Throwable + { + createTable("CREATE TABLE %s (a int, b int, c int, PRIMARY KEY(a, b))"); + // Check there are no snapshots + ColumnFamilyStore tableDir = getCurrentColumnFamilyStore(); + assertThat(tableDir.listSnapshots()).isEmpty(); + + execute("INSERT INTO %s (a, b, c) VALUES (?, ?, ?)", 0, 0, 0); + execute("INSERT INTO %s (a, b, c) VALUES (?, ?, ?)", 0, 1, 1); + + flush(); + + execute("DROP TABLE %s"); + + verifyAutoSnapshot(SNAPSHOT_DROP_PREFIX, tableDir); + } + + /** + * Verify that: + * - A snapshot is created when auto_snapshot = true. + * - TTL is added to the snapshot when auto_snapshot_ttl != null + */ + private void verifyAutoSnapshot(String snapshotPrefix, ColumnFamilyStore tableDir) + { + Map snapshots = tableDir.listSnapshots(); + if (autoSnapshotEnabled) + { + assertThat(snapshots).hasSize(1); + assertThat(snapshots).hasKeySatisfying(new Condition<>(k -> k.startsWith(snapshotPrefix), "is dropped snapshot")); + TableSnapshot snapshot = snapshots.values().iterator().next(); + assertThat(snapshot.getTableName()).isEqualTo(currentTable()); + if (autoSnapshotTTl == null) + { + // check that the snapshot has NO TTL + assertThat(snapshot.isExpiring()).isFalse(); + } + else + { + // check that snapshot has TTL and is expired after 1 second + assertThat(snapshot.isExpiring()).isTrue(); + Uninterruptibles.sleepUninterruptibly(TTL_SECS, TimeUnit.SECONDS); + assertThat(snapshot.isExpired(Instant.now())).isTrue(); + } + } + else + { + // No snapshot should be created when auto_snapshot = false + assertThat(snapshots).isEmpty(); + } + } +}