cassandra-trunk/src/java/org/apache/cassandra/service/snapshot/TableSnapshot.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.service.snapshot;

import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.time.Instant;
import java.util.Collection;
import java.util.HashSet;
import java.util.Objects;
import java.util.Optional;
import java.util.Set;
import java.util.UUID;
import java.util.function.Predicate;

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import org.apache.cassandra.db.Directories;
import org.apache.cassandra.io.util.File;
import org.apache.cassandra.io.util.FileUtils;
import org.apache.cassandra.utils.DirectorySizeCalculator;

public class TableSnapshot
{
    private static final Logger logger = LoggerFactory.getLogger(TableSnapshot.class);

    private final String keyspaceName;
    private final String tableName;
    private final UUID tableId;
    private final String tag;
    private final boolean ephemeral;

    private final Instant createdAt;
    private final Instant expiresAt;

    private final Set<File> snapshotDirs;

    public TableSnapshot(String keyspaceName, String tableName, UUID tableId,
                         String tag, Instant createdAt, Instant expiresAt,
                         Set<File> snapshotDirs, boolean ephemeral)
    {
        this.keyspaceName = keyspaceName;
        this.tableName = tableName;
        this.tableId = tableId;
        this.tag = tag;
        this.createdAt = createdAt;
        this.expiresAt = expiresAt;
        this.snapshotDirs = snapshotDirs;
        this.ephemeral = ephemeral;
    }

    /**
     * Unique identifier of a snapshot. Used
     * only to deduplicate snapshots internally,
     * not exposed externally.
     *
     * Format: "$ks:$table_name:$table_id:$tag"
     */
    public String getId()
    {
        return buildSnapshotId(keyspaceName, tableName, tableId, tag);
    }

    public String getKeyspaceName()
    {
        return keyspaceName;
    }

    public String getTableName()
    {
        return tableName;
    }

    public String getTag()
    {
        return tag;
    }

    public Instant getCreatedAt()
    {
        if (createdAt == null)
        {
            long minCreation = snapshotDirs.stream().mapToLong(File::lastModified).min().orElse(0);
            if (minCreation != 0)
            {
                return Instant.ofEpochMilli(minCreation);
            }
        }
        return createdAt;
    }

    public Instant getExpiresAt()
    {
        return expiresAt;
    }

    public boolean isExpired(Instant now)
    {
        if (createdAt == null || expiresAt == null)
        {
            return false;
        }

        return expiresAt.compareTo(now) < 0;
    }

    public boolean exists()
    {
        return snapshotDirs.stream().anyMatch(File::exists);
    }

    public boolean isEphemeral()
    {
        return ephemeral;
    }

    public boolean isExpiring()
    {
        return expiresAt != null;
    }

    public long computeSizeOnDiskBytes()
    {
        return snapshotDirs.stream().mapToLong(FileUtils::folderSize).sum();
    }

    public long computeTrueSizeBytes()
    {
        DirectorySizeCalculator visitor = new SnapshotTrueSizeCalculator();

        for (File snapshotDir : snapshotDirs)
        {
            try
            {
                Files.walkFileTree(snapshotDir.toPath(), visitor);
            }
            catch (IOException e)
            {
                logger.error("Could not calculate the size of {}.", snapshotDir, e);
            }
        }

        return visitor.getAllocatedSize();
    }

    public Collection<File> getDirectories()
    {
        return snapshotDirs;
    }

    public Optional<File> getManifestFile()
    {
        for (File snapshotDir : snapshotDirs)
        {
            File manifestFile = Directories.getSnapshotManifestFile(snapshotDir);
            if (manifestFile.exists())
            {
                return Optional.of(manifestFile);
            }
        }
        return Optional.empty();
    }

    public Optional<File> getSchemaFile()
    {
        for (File snapshotDir : snapshotDirs)
        {
            File schemaFile = Directories.getSnapshotSchemaFile(snapshotDir);
            if (schemaFile.exists())
            {
                return Optional.of(schemaFile);
            }
        }
        return Optional.empty();
    }

    @Override
    public boolean equals(Object o)
    {
        if (this == o) return true;
        if (o == null || getClass() != o.getClass()) return false;
        TableSnapshot snapshot = (TableSnapshot) o;
        return Objects.equals(keyspaceName, snapshot.keyspaceName) && Objects.equals(tableName, snapshot.tableName) &&
               Objects.equals(tableId, snapshot.tableId) && Objects.equals(tag, snapshot.tag) &&
               Objects.equals(createdAt, snapshot.createdAt) && Objects.equals(expiresAt, snapshot.expiresAt) &&
               Objects.equals(snapshotDirs, snapshot.snapshotDirs) && Objects.equals(ephemeral, snapshot.ephemeral);
    }

    @Override
    public int hashCode()
    {
        return Objects.hash(keyspaceName, tableName, tableId, tag, createdAt, expiresAt, snapshotDirs, ephemeral);
    }

    @Override
    public String toString()
    {
        return "TableSnapshot{" +
               "keyspaceName='" + keyspaceName + '\'' +
               ", tableName='" + tableName + '\'' +
               ", tableId=" + tableId +
               ", tag='" + tag + '\'' +
               ", createdAt=" + createdAt +
               ", expiresAt=" + expiresAt +
               ", snapshotDirs=" + snapshotDirs +
               ", ephemeral=" + ephemeral +
               '}';
    }

    static class Builder {
        private final String keyspaceName;
        private final String tableName;
        private final UUID tableId;
        private final String tag;

        private Instant createdAt = null;
        private Instant expiresAt = null;
        private boolean ephemeral;

        private final Set<File> snapshotDirs = new HashSet<>();

        Builder(String keyspaceName, String tableName, UUID tableId, String tag)
        {
            this.keyspaceName = keyspaceName;
            this.tableName = tableName;
            this.tag = tag;
            this.tableId = tableId;
        }

        void addSnapshotDir(File snapshotDir)
        {
            snapshotDirs.add(snapshotDir);
            File manifestFile = new File(snapshotDir, "manifest.json");
            if (manifestFile.exists() && createdAt == null && expiresAt == null)
                loadMetadataFromManifest(manifestFile);

            // check if an ephemeral marker file exists only in case it is not already ephemeral
            // by reading it from manifest
            // TODO remove this on Cassandra 4.3 release, see CASSANDRA-16911
            if (!ephemeral && new File(snapshotDir, "ephemeral.snapshot").exists())
                ephemeral = true;
        }

        private void loadMetadataFromManifest(File manifestFile)
        {
            try
            {
                logger.trace("Loading snapshot manifest from {}", manifestFile);
                SnapshotManifest manifest = SnapshotManifest.deserializeFromJsonFile(manifestFile);
                createdAt = manifest.createdAt;
                expiresAt = manifest.expiresAt;
                // a snapshot may be ephemeral when it has a marker file (old way) or flag in manifest (new way)
                if (!ephemeral)
                    ephemeral = manifest.ephemeral;
            }
            catch (IOException e)
            {
                logger.warn("Cannot read manifest file {} of snapshot {}.", manifestFile, tag, e);
            }
        }

        TableSnapshot build()
        {
            return new TableSnapshot(keyspaceName, tableName, tableId, tag, createdAt, expiresAt, snapshotDirs, ephemeral);
        }
    }

    protected static String buildSnapshotId(String keyspaceName, String tableName, UUID tableId, String tag)
    {
        return String.format("%s:%s:%s:%s", keyspaceName, tableName, tableId, tag);
    }

    public static class SnapshotTrueSizeCalculator extends DirectorySizeCalculator
    {
        /**
         * Snapshots are composed of hard-linked sstables. The true snapshot size should only include
         * snapshot files which do not contain a corresponding "live" sstable file.
         */
        @Override
        public boolean isAcceptable(Path snapshotFilePath)
        {
            return !getLiveFileFromSnapshotFile(snapshotFilePath).exists();
        }
    }

    /**
     * Returns the corresponding live file for a given snapshot file.
     *
     * Example:
     *  - Base table:
     *    - Snapshot file: ~/.ccm/test/node1/data0/test_ks/tbl-e03faca0813211eca100c705ea09b5ef/snapshots/1643481737850/me-1-big-Data.db
     *    - Live file: ~/.ccm/test/node1/data0/test_ks/tbl-e03faca0813211eca100c705ea09b5ef/me-1-big-Data.db
     *  - Secondary index:
     *    - Snapshot file: ~/.ccm/test/node1/data0/test_ks/tbl-e03faca0813211eca100c705ea09b5ef/snapshots/1643481737850/.tbl_val_idx/me-1-big-Summary.db
     *    - Live file: ~/.ccm/test/node1/data0/test_ks/tbl-e03faca0813211eca100c705ea09b5ef/.tbl_val_idx/me-1-big-Summary.db
     *
     */
    static File getLiveFileFromSnapshotFile(Path snapshotFilePath)
    {
        // Snapshot directory structure format is {data_dir}/snapshots/{snapshot_name}/{snapshot_file}
        Path liveDir = snapshotFilePath.getParent().getParent().getParent();
        if (Directories.isSecondaryIndexFolder(snapshotFilePath.getParent()))
        {
            // Snapshot file structure format is {data_dir}/snapshots/{snapshot_name}/.{index}/{sstable-component}.db
            liveDir = File.getPath(liveDir.getParent().toString(), snapshotFilePath.getParent().getFileName().toString());
        }
        return new File(liveDir.toString(), snapshotFilePath.getFileName().toString());
    }

    public static Predicate<TableSnapshot> shouldClearSnapshot(String tag, long olderThanTimestamp)
    {
        return ts ->
        {
            // When no tag is supplied, all snapshots must be cleared
            boolean clearAll = tag == null || tag.isEmpty();
            if (!clearAll && ts.isEphemeral())
                logger.info("Skipping deletion of ephemeral snapshot '{}' in keyspace {}. " +
                            "Ephemeral snapshots are not removable by a user.",
                            tag, ts.keyspaceName);
            boolean notEphemeral = !ts.isEphemeral();
            boolean shouldClearTag = clearAll || ts.tag.equals(tag);
            boolean byTimestamp = true;

            if (olderThanTimestamp > 0L)
            {
                Instant createdAt = ts.getCreatedAt();
                if (createdAt != null)
                    byTimestamp = createdAt.isBefore(Instant.ofEpochMilli(olderThanTimestamp));
            }

            return notEphemeral && shouldClearTag && byTimestamp;
        };
    }

}
