cassandra-trunk/test/unit/org/apache/cassandra/tools/nodetool/ClearSnapshotTest.java
/*
 * 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.tools.nodetool;

import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.time.Instant;
import java.util.Map;
import java.util.Optional;
import java.util.concurrent.TimeUnit;
import java.util.regex.Pattern;
import javax.management.openmbean.TabularData;

import org.junit.AfterClass;
import org.junit.BeforeClass;
import org.junit.Test;

import org.apache.cassandra.cql3.CQLTester;
import org.apache.cassandra.db.Keyspace;
import org.apache.cassandra.io.util.File;
import org.apache.cassandra.schema.TableMetadata;
import org.apache.cassandra.service.snapshot.SnapshotManifest;
import org.apache.cassandra.tools.NodeProbe;
import org.apache.cassandra.tools.ToolRunner.ToolResult;

import static java.lang.String.format;
import static java.time.temporal.ChronoUnit.HOURS;
import static java.time.temporal.ChronoUnit.SECONDS;
import static java.util.Collections.emptyMap;
import static org.apache.cassandra.config.DatabaseDescriptor.getAllDataFileLocations;
import static org.apache.cassandra.tools.ToolRunner.invokeNodetool;
import static org.apache.cassandra.utils.Clock.Global.currentTimeMillis;
import static org.assertj.core.api.Assertions.assertThat;
import static org.awaitility.Awaitility.await;
import static org.junit.Assert.assertTrue;

public class ClearSnapshotTest extends CQLTester
{
    private static final Pattern DASH_PATTERN = Pattern.compile("-");
    private static NodeProbe probe;

    @BeforeClass
    public static void setup() throws Exception
    {
        startJMXServer();
        requireNetwork();
        probe = new NodeProbe(jmxHost, jmxPort);
    }

    @AfterClass
    public static void teardown() throws IOException
    {
        probe.close();
    }

    @Test
    public void testClearSnapshot_RemoveByName()
    {
        ToolResult tool = invokeNodetool("snapshot", "-t", "some-name");
        tool.assertOnCleanExit();
        assertThat(tool.getStdout()).isNotEmpty();

        Map<String, TabularData> snapshots_before = probe.getSnapshotDetails(emptyMap());
        assertThat(snapshots_before).containsKey("some-name");

        tool = invokeNodetool("clearsnapshot", "-t", "some-name");
        tool.assertOnCleanExit();
        assertThat(tool.getStdout()).isNotEmpty();

        Map<String, TabularData> snapshots_after = probe.getSnapshotDetails(emptyMap());
        assertThat(snapshots_after).doesNotContainKey("some-name");
    }

    @Test
    public void testClearSnapshot_RemoveMultiple()
    {
        ToolResult tool = invokeNodetool("snapshot", "-t", "some-name");
        tool.assertOnCleanExit();
        assertThat(tool.getStdout()).isNotEmpty();

        tool = invokeNodetool("snapshot", "-t", "some-other-name");
        tool.assertOnCleanExit();
        assertThat(tool.getStdout()).isNotEmpty();

        Map<String, TabularData> snapshots_before = probe.getSnapshotDetails(emptyMap());
        assertThat(snapshots_before).hasSize(2);

        tool = invokeNodetool("clearsnapshot", "--all");
        tool.assertOnCleanExit();
        assertThat(tool.getStdout()).isNotEmpty();

        Map<String, TabularData> snapshots_after = probe.getSnapshotDetails(emptyMap());
        assertThat(snapshots_after).isEmpty();
    }

    @Test
    public void testClearSnapshotWithOlderThanFlag() throws Throwable
    {
        Instant start = Instant.ofEpochMilli(currentTimeMillis());
        prepareData(start);

        // wait 10 seconds for the sake of the test
        await().timeout(15, TimeUnit.SECONDS).until(() -> Instant.now().isAfter(start.plusSeconds(10)));

        // clear all snapshots for specific keyspace older than 3 hours for a specific keyspace
        invokeNodetool("clearsnapshot", "--older-than", "3h", "--all", "--", KEYSPACE).assertOnCleanExit();

        await().until(() -> {
            String output = invokeNodetool("listsnapshots").getStdout();
            return !output.contains("snapshot-to-clear-ks1-tb1") &&
                   output.contains("some-other-snapshot-ks1-tb1") &&
                   output.contains("last-snapshot-ks1-tb1") &&
                   output.contains("snapshot-to-clear-ks2-tb2") &&
                   output.contains("some-other-snapshot-ks2-tb2") &&
                   output.contains("last-snapshot-ks2-tb2");
        });

        // clear all snapshots older than 2 hours for all keyspaces
        invokeNodetool("clearsnapshot", "--older-than", "2h", "--all").assertOnCleanExit();

        await().until(() -> {
            String output = invokeNodetool("listsnapshots").getStdout();

            return !output.contains("some-other-snapshot-ks1-tb1") &&
                   output.contains("last-snapshot-ks1-tb1") &&
                   !output.contains("snapshot-to-clear-ks2-tb2") &&
                   !output.contains("some-other-snapshot-ks2-tb2") &&
                   output.contains("last-snapshot-ks2-tb2");
        });

        // clear all snapshosts older than 1 second
        invokeNodetool("clearsnapshot", "--older-than", "1s", "--all", "--", currentKeyspace()).assertOnCleanExit();

        await().until(() -> {
            String output = invokeNodetool("listsnapshots").getStdout();
            return output.contains("last-snapshot-ks1-tb1") &&
                   !output.contains("last-snapshot-ks2-tb2");
        });

        invokeNodetool("clearsnapshot", "--older-than", "1s", "--all").assertOnCleanExit();
        await().until(() -> !invokeNodetool("listsnapshots").getStdout().contains("last-snapshot-ks1-tb1"));
    }


    @Test
    public void testClearSnapshotWithOlderThanTimestampFlag() throws Throwable
    {
        Instant start = Instant.ofEpochMilli(currentTimeMillis());
        prepareData(start);

        // wait 10 seconds for the sake of the test
        await().timeout(15, TimeUnit.SECONDS).until(() -> Instant.now().isAfter(start.plusSeconds(10)));

        // clear all snapshots for specific keyspace older than 3 hours for a specific keyspace
        invokeNodetool("clearsnapshot", "--older-than-timestamp",
                       Instant.now().minus(3, HOURS).toString(),
                       "--all", "--", KEYSPACE).assertOnCleanExit();

        await().until(() -> {
            String output = invokeNodetool("listsnapshots").getStdout();
            return !output.contains("snapshot-to-clear-ks1-tb1") &&
                   output.contains("some-other-snapshot-ks1-tb1") &&
                   output.contains("last-snapshot-ks1-tb1") &&
                   output.contains("snapshot-to-clear-ks2-tb2") &&
                   output.contains("some-other-snapshot-ks2-tb2") &&
                   output.contains("last-snapshot-ks2-tb2");
        });

        // clear all snapshots older than 2 hours for all keyspaces
        invokeNodetool("clearsnapshot", "--older-than-timestamp",
                       Instant.now().minus(2, HOURS).toString(),
                       "--all").assertOnCleanExit();

        await().until(() -> {
            String output = invokeNodetool("listsnapshots").getStdout();

            return !output.contains("some-other-snapshot-ks1-tb1") &&
                   output.contains("last-snapshot-ks1-tb1") &&
                   !output.contains("snapshot-to-clear-ks2-tb2") &&
                   !output.contains("some-other-snapshot-ks2-tb2") &&
                   output.contains("last-snapshot-ks2-tb2");
        });

        // clear all snapshots older than now for all keyspaces
        invokeNodetool("clearsnapshot", "--older-than-timestamp",
                       Instant.now().toString(),
                       "--all").assertOnCleanExit();

        await().until(() -> {
            String output = invokeNodetool("listsnapshots").getStdout();
            return !output.contains("last-snapshot-ks1-tb1") &&
                   !output.contains("last-snapshot-ks2-tb2");
        });
    }

    @Test
    public void testIncompatibleFlags()
    {
        ToolResult invalidCommand1 = invokeNodetool("clearsnapshot",
                                                    "--older-than-timestamp", Instant.now().toString(),
                                                    "--older-than", "3h",
                                                    "--all");
        invalidCommand1.asserts().failure();
        assertTrue(invalidCommand1.getStdout().contains("Specify only one of --older-than or --older-than-timestamp"));

        ToolResult invalidCommand2 = invokeNodetool("clearsnapshot", "-t", "some-snapshot-tag", "--all");
        invalidCommand2.asserts().failure();
        assertTrue(invalidCommand2.getStdout().contains("Specify only one of snapshot name or --all"));

        ToolResult invalidCommand3 = invokeNodetool("clearsnapshot", "--", "keyspace");
        invalidCommand3.asserts().failure();
        assertTrue(invalidCommand3.getStdout().contains("Specify snapshot name or --all"));

        ToolResult invalidCommand4 = invokeNodetool("clearsnapshot",
                                                    "--older-than-timestamp", Instant.now().toString(),
                                                    "-t", "some-snapshot-tag");
        invalidCommand4.asserts().failure();
        assertTrue(invalidCommand4.getStdout().contains("Specifying snapshot name together with --older-than-timestamp flag is not allowed"));

        ToolResult invalidCommand5 = invokeNodetool("clearsnapshot",
                                                    "--older-than", "3h",
                                                    "-t", "some-snapshot-tag");
        invalidCommand5.asserts().failure();
        assertTrue(invalidCommand5.getStdout().contains("Specifying snapshot name together with --older-than flag is not allowed"));

        ToolResult invalidCommand6 = invokeNodetool("clearsnapshot",
                                                    "--older-than-timestamp", "123",
                                                    "--all", "--", "somekeyspace");
        invalidCommand6.asserts().failure();
        assertTrue(invalidCommand6.getStdout().contains("Parameter --older-than-timestamp has to be a valid instant in ISO format."));

        ToolResult invalidCommand7 = invokeNodetool("clearsnapshot",
                                                    "--older-than", "3k",
                                                    "--all", "--", "somekeyspace");
        invalidCommand7.asserts().failure();
        assertTrue(invalidCommand7.getStdout().contains("Invalid duration: 3k"));
    }

    private void rewriteManifest(String tableId,
                                 String[] dataDirs,
                                 String keyspace,
                                 String tableName,
                                 String snapshotName,
                                 Instant createdAt) throws Exception
    {
        Path manifestPath = findManifest(dataDirs, keyspace, tableId, tableName, snapshotName);
        SnapshotManifest manifest = SnapshotManifest.deserializeFromJsonFile(new File(manifestPath));
        SnapshotManifest manifestWithEphemeralFlag = new SnapshotManifest(manifest.files, null, createdAt, false);
        manifestWithEphemeralFlag.serializeToJsonFile(new File(manifestPath));
    }

    private Path findManifest(String[] dataDirs, String keyspace, String tableId, String tableName, String snapshotName)
    {
        for (String dataDir : dataDirs)
        {
            Path manifest = Paths.get(dataDir)
                                 .resolve(keyspace)
                                 .resolve(format("%s-%s", tableName, tableId))
                                 .resolve("snapshots")
                                 .resolve(snapshotName)
                                 .resolve("manifest.json");

            if (Files.exists(manifest))
            {
                return manifest;
            }
        }

        throw new IllegalStateException("Unable to find manifest!");
    }

    private void prepareData(Instant start) throws Throwable
    {
        String tableName = createTable(KEYSPACE, "CREATE TABLE %s (id int primary key)");
        execute("INSERT INTO %s (id) VALUES (?)", 1);
        flush(KEYSPACE);

        String keyspace2 = createKeyspace("CREATE KEYSPACE %s WITH replication = {'class': 'SimpleStrategy', 'replication_factor': 1}");
        String tableName2 = createTable(keyspace2, "CREATE TABLE %s (id int primary key)");
        execute(formatQuery(keyspace2, "INSERT INTO %s (id) VALUES (?)"), 1);
        flush(keyspace2);

        invokeNodetool("snapshot", "-t", "snapshot-to-clear-ks1-tb1", "-cf", tableName, "--", KEYSPACE).assertOnCleanExit();
        invokeNodetool("snapshot", "-t", "some-other-snapshot-ks1-tb1", "-cf", tableName, "--", KEYSPACE).assertOnCleanExit();
        invokeNodetool("snapshot", "-t", "last-snapshot-ks1-tb1", "-cf", tableName, "--", KEYSPACE).assertOnCleanExit();

        invokeNodetool("snapshot", "-t", "snapshot-to-clear-ks2-tb2", "-cf", tableName2, "--", keyspace2).assertOnCleanExit();
        invokeNodetool("snapshot", "-t", "some-other-snapshot-ks2-tb2", "-cf", tableName2, "--", keyspace2).assertOnCleanExit();
        invokeNodetool("snapshot", "-t", "last-snapshot-ks2-tb2", "-cf", tableName2, "--", keyspace2).assertOnCleanExit();

        Optional<TableMetadata> tableMetadata = Keyspace.open(KEYSPACE).getMetadata().tables.get(tableName);
        Optional<TableMetadata> tableMetadata2 = Keyspace.open(keyspace2).getMetadata().tables.get(tableName2);

        String tableId = DASH_PATTERN.matcher(tableMetadata.orElseThrow(() -> new IllegalStateException(format("no metadata found for %s.%s", KEYSPACE, tableName)))
                                              .id.asUUID().toString()).replaceAll("");

        String tableId2 = DASH_PATTERN.matcher(tableMetadata2.orElseThrow(() -> new IllegalStateException(format("no metadata found for %s.%s", keyspace2, tableName2)))
                                               .id.asUUID().toString()).replaceAll("");

        rewriteManifest(tableId, getAllDataFileLocations(), KEYSPACE, tableName, "snapshot-to-clear-ks1-tb1", start.minus(5, HOURS));
        rewriteManifest(tableId, getAllDataFileLocations(), KEYSPACE, tableName, "some-other-snapshot-ks1-tb1", start.minus(2, HOURS));
        rewriteManifest(tableId, getAllDataFileLocations(), KEYSPACE, tableName, "last-snapshot-ks1-tb1", start.minus(1, SECONDS));
        rewriteManifest(tableId2, getAllDataFileLocations(), keyspace2, tableName2, "snapshot-to-clear-ks2-tb2", start.minus(5, HOURS));
        rewriteManifest(tableId2, getAllDataFileLocations(), keyspace2, tableName2, "some-other-snapshot-ks2-tb2", start.minus(2, HOURS));
        rewriteManifest(tableId2, getAllDataFileLocations(), keyspace2, tableName2, "last-snapshot-ks2-tb2", start.minus(1, SECONDS));
    }
}
