diff --git a/ice-rest-catalog/src/test/resources/scenarios/basic-operations/run.sh.tmpl b/ice-rest-catalog/src/test/resources/scenarios/basic-operations/run.sh.tmpl index d82a93e6..8151d190 100644 --- a/ice-rest-catalog/src/test/resources/scenarios/basic-operations/run.sh.tmpl +++ b/ice-rest-catalog/src/test/resources/scenarios/basic-operations/run.sh.tmpl @@ -212,6 +212,23 @@ for t in ${TABLE_IRIS} ${TABLE_PARTITIONED} ${TABLE_SORTED}; do done echo "OK list-tables listed tables in ${NAMESPACE_NAME}" +# List snapshots for the iris table +{{ICE_CLI}} --config {{CLI_CONFIG}} list-snapshots ${TABLE_IRIS} > /tmp/basic_ops_list_snapshots.txt +if ! grep -q "snapshotId" /tmp/basic_ops_list_snapshots.txt; then + echo "FAIL: list-snapshots output missing 'snapshotId'" + cat /tmp/basic_ops_list_snapshots.txt + exit 1 +fi +echo "OK list-snapshots verified for ${TABLE_IRIS}" + +# List snapshots with --limit 1 +{{ICE_CLI}} --config {{CLI_CONFIG}} list-snapshots ${TABLE_IRIS} --limit 1 > /tmp/basic_ops_list_snapshots_limit.txt +if ! grep -q "snapshotId" /tmp/basic_ops_list_snapshots_limit.txt; then + echo "FAIL: list-snapshots --limit 1 output missing 'snapshotId'" + cat /tmp/basic_ops_list_snapshots_limit.txt + exit 1 +fi +echo "OK list-snapshots --limit 1 verified for ${TABLE_IRIS}" # Cleanup tables then namespace {{ICE_CLI}} --config {{CLI_CONFIG}} delete-table ${TABLE_IRIS} diff --git a/ice/README.md b/ice/README.md index c0410008..22581d56 100644 --- a/ice/README.md +++ b/ice/README.md @@ -184,6 +184,12 @@ ice files flowers.iris # list partitions ice list-partitions nyc.taxis_p_by_day +# list current and previous snapshots +ice list-snapshots flowers.iris + +# only the latest 5 +ice list-snapshots flowers.iris --limit 5 + # describe a parquet file directly ice describe-parquet file://iris.parquet ``` diff --git a/ice/src/main/java/com/altinity/ice/cli/Main.java b/ice/src/main/java/com/altinity/ice/cli/Main.java index 7f79483b..0912a83e 100644 --- a/ice/src/main/java/com/altinity/ice/cli/Main.java +++ b/ice/src/main/java/com/altinity/ice/cli/Main.java @@ -25,6 +25,7 @@ import com.altinity.ice.cli.internal.cmd.InsertWatch; import com.altinity.ice.cli.internal.cmd.ListNamespaces; import com.altinity.ice.cli.internal.cmd.ListPartitions; +import com.altinity.ice.cli.internal.cmd.ListSnapshots; import com.altinity.ice.cli.internal.cmd.ListTables; import com.altinity.ice.cli.internal.cmd.Scan; import com.altinity.ice.cli.internal.config.Config; @@ -747,6 +748,30 @@ void listPartitions( } } + @CommandLine.Command( + name = "list-snapshots", + description = "List current and previous snapshots of a table.") + void listSnapshots( + @CommandLine.Parameters( + arity = "1", + paramLabel = "", + description = "Table name (e.g. ns1.table1)") + String name, + @CommandLine.Option( + names = {"--limit"}, + description = "Show only the most recent N snapshots (0 = all)", + defaultValue = "0") + int limit, + @CommandLine.Option( + names = {"--json"}, + description = "Output JSON instead of YAML") + boolean json) + throws IOException { + try (RESTCatalog catalog = loadCatalog()) { + ListSnapshots.run(catalog, TableIdentifier.parse(name), json, limit); + } + } + @CommandLine.Command(name = "delete-table", description = "Delete table.") void deleteTable( @CommandLine.Parameters( diff --git a/ice/src/main/java/com/altinity/ice/cli/internal/cmd/DescribeMetadata.java b/ice/src/main/java/com/altinity/ice/cli/internal/cmd/DescribeMetadata.java index 6ffff298..9bf07575 100644 --- a/ice/src/main/java/com/altinity/ice/cli/internal/cmd/DescribeMetadata.java +++ b/ice/src/main/java/com/altinity/ice/cli/internal/cmd/DescribeMetadata.java @@ -76,7 +76,9 @@ private static MetadataInfo extractMetadataInfo( List snapshots = null; if (includeAll || optionsSet.contains(Option.SNAPSHOTS)) { - snapshots = extractSnapshots(metadata); + Snapshot cur = metadata.currentSnapshot(); + Long currentSnapshotId = cur != null ? cur.snapshotId() : null; + snapshots = extractSnapshots(metadata.snapshots(), currentSnapshotId); } HistoryInfo history = null; @@ -125,10 +127,11 @@ private static SchemaInfo extractSchema(TableMetadata metadata) { return new SchemaInfo(schema.schemaId(), fields); } - private static List extractSnapshots(TableMetadata metadata) { - List snapshots = new ArrayList<>(); - for (Snapshot snapshot : metadata.snapshots()) { - snapshots.add( + public static List extractSnapshots( + Iterable snapshots, Long currentSnapshotId) { + List result = new ArrayList<>(); + for (Snapshot snapshot : snapshots) { + result.add( new SnapshotInfo( snapshot.snapshotId(), snapshot.parentId(), @@ -136,10 +139,11 @@ private static List extractSnapshots(TableMetadata metadata) { snapshot.timestampMillis(), Instant.ofEpochMilli(snapshot.timestampMillis()).toString(), snapshot.operation(), + currentSnapshotId != null && snapshot.snapshotId() == currentSnapshotId, snapshot.summary(), snapshot.manifestListLocation())); } - return snapshots; + return result; } private static HistoryInfo extractHistory(TableMetadata metadata) { @@ -266,6 +270,7 @@ public record SnapshotInfo( long timestampMillis, String timestamp, String operation, + boolean current, Map summary, String manifestListLocation) {} diff --git a/ice/src/main/java/com/altinity/ice/cli/internal/cmd/ListSnapshots.java b/ice/src/main/java/com/altinity/ice/cli/internal/cmd/ListSnapshots.java new file mode 100644 index 00000000..aa9a27b5 --- /dev/null +++ b/ice/src/main/java/com/altinity/ice/cli/internal/cmd/ListSnapshots.java @@ -0,0 +1,123 @@ +/* + * Copyright (c) 2025 Altinity Inc and/or its affiliates. All rights reserved. + * + * 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 + */ +package com.altinity.ice.cli.internal.cmd; + +import com.altinity.ice.cli.internal.util.TreePrinter; +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.dataformat.yaml.YAMLFactory; +import com.fasterxml.jackson.dataformat.yaml.YAMLGenerator; +import java.io.IOException; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import org.apache.iceberg.Table; +import org.apache.iceberg.catalog.TableIdentifier; +import org.apache.iceberg.rest.RESTCatalog; + +public final class ListSnapshots { + + private ListSnapshots() {} + + public static void run(RESTCatalog catalog, TableIdentifier tableId, boolean json, int limit) + throws IOException { + Table table = catalog.loadTable(tableId); + Long currentSnapshotId = + table.currentSnapshot() != null ? table.currentSnapshot().snapshotId() : null; + + List rows = + DescribeMetadata.extractSnapshots(table.snapshots(), currentSnapshotId); + + rows.sort(Comparator.comparingLong(DescribeMetadata.SnapshotInfo::timestampMillis)); + + if (limit > 0 && rows.size() > limit) { + rows = new ArrayList<>(rows.subList(rows.size() - limit, rows.size())); + } + + if (json) { + var result = new Result(tableId.toString(), currentSnapshotId, rows); + ObjectMapper mapper = new ObjectMapper(); + mapper.setSerializationInclusion(JsonInclude.Include.NON_NULL); + System.out.println(mapper.writeValueAsString(result)); + return; + } + + printTree(tableId.toString(), currentSnapshotId, rows); + } + + private static void printTree( + String tableName, Long currentSnapshotId, List rows) + throws IOException { + StringBuilder rootLabel = new StringBuilder(); + rootLabel.append("table: ").append(tableName); + if (currentSnapshotId != null) { + rootLabel.append("\ncurrentSnapshotId: ").append(currentSnapshotId); + } + + if (rows.isEmpty()) { + TreePrinter.print(new TreePrinter.Node(rootLabel.toString(), List.of())); + System.out.println("(no snapshots)"); + return; + } + + ObjectMapper yamlMapper = + new ObjectMapper( + new YAMLFactory() + .enable(YAMLGenerator.Feature.MINIMIZE_QUOTES) + .disable(YAMLGenerator.Feature.WRITE_DOC_START_MARKER)); + yamlMapper.setSerializationInclusion(JsonInclude.Include.NON_NULL); + + Set presentIds = new HashSet<>(rows.size()); + for (DescribeMetadata.SnapshotInfo info : rows) { + presentIds.add(info.snapshotId()); + } + + Map> childrenByParent = new HashMap<>(); + List roots = new ArrayList<>(); + for (DescribeMetadata.SnapshotInfo info : rows) { + Long parentId = info.parentId(); + if (parentId == null || !presentIds.contains(parentId)) { + roots.add(info); + } else { + childrenByParent.computeIfAbsent(parentId, k -> new ArrayList<>()).add(info); + } + } + + List rootChildren = new ArrayList<>(roots.size()); + for (DescribeMetadata.SnapshotInfo root : roots) { + rootChildren.add(buildNode(root, childrenByParent, yamlMapper)); + } + + TreePrinter.print(new TreePrinter.Node(rootLabel.toString(), rootChildren)); + } + + private static TreePrinter.Node buildNode( + DescribeMetadata.SnapshotInfo info, + Map> childrenByParent, + ObjectMapper yamlMapper) + throws IOException { + List children = + childrenByParent.getOrDefault(info.snapshotId(), List.of()); + List childNodes = new ArrayList<>(children.size()); + + for (DescribeMetadata.SnapshotInfo child : children) { + childNodes.add(buildNode(child, childrenByParent, yamlMapper)); + } + return new TreePrinter.Node(yamlMapper.writeValueAsString(info), childNodes); + } + + @JsonInclude(JsonInclude.Include.NON_NULL) + record Result( + String table, Long currentSnapshotId, List snapshots) {} +} diff --git a/ice/src/main/java/com/altinity/ice/cli/internal/util/TreePrinter.java b/ice/src/main/java/com/altinity/ice/cli/internal/util/TreePrinter.java index 82e87a78..2d1558ab 100644 --- a/ice/src/main/java/com/altinity/ice/cli/internal/util/TreePrinter.java +++ b/ice/src/main/java/com/altinity/ice/cli/internal/util/TreePrinter.java @@ -10,20 +10,35 @@ package com.altinity.ice.cli.internal.util; import java.io.PrintStream; +import java.util.ArrayList; +import java.util.Arrays; import java.util.List; public final class TreePrinter { private TreePrinter() {} - public record Node(String label, List children) { + public record Node(List label, List children) { public Node(String label) { - this(label, List.of()); + this(splitLines(label), List.of()); + } + + public Node(String label, List children) { + this(splitLines(label), children); } public Node { + label = List.copyOf(label); children = List.copyOf(children); } + + private static List splitLines(String label) { + List lines = new ArrayList<>(Arrays.asList(label.split("\n", -1))); + while (lines.size() > 1 && lines.getLast().isEmpty()) { + lines.removeLast(); + } + return lines; + } } public static void print(Node root) { @@ -31,7 +46,9 @@ public static void print(Node root) { } public static void print(Node root, PrintStream out) { - out.println(root.label()); + for (String line : root.label()) { + out.println(line); + } printChildren(root.children(), "", out); } @@ -41,7 +58,11 @@ private static void printChildren(List children, String descendantIndent, boolean isLast = (i == children.size() - 1); String connector = isLast ? "└── " : "├── "; String childDescendantIndent = descendantIndent + (isLast ? " " : "│ "); - out.println(descendantIndent + connector + child.label()); + List lines = child.label(); + out.println(descendantIndent + connector + lines.getFirst()); + for (int j = 1; j < lines.size(); j++) { + out.println(childDescendantIndent + lines.get(j)); + } printChildren(child.children(), childDescendantIndent, out); } }