This is an automated email from the ASF dual-hosted git repository.
RussellSpitzer pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/iceberg.git
The following commit(s) were added to refs/heads/main by this push:
new 42034ffd10 Core: ensure snapshot timestamp is monotonically increasing
for V4 tables (#16293)
42034ffd10 is described below
commit 42034ffd10af29548fc73fe0c8f1833d74f9c792
Author: Steven Zhen Wu <[email protected]>
AuthorDate: Wed Oct 7 14:13:35 2026 -0700
Core: ensure snapshot timestamp is monotonically increasing for V4 tables
(#16293)
---
.../test/java/org/apache/iceberg/TestHelpers.java | 4 +
.../java/org/apache/iceberg/SnapshotProducer.java | 18 ++-
.../java/org/apache/iceberg/TableMetadata.java | 22 ++--
.../org/apache/iceberg/TestRowLineageMetadata.java | 4 +-
.../org/apache/iceberg/TestSnapshotProducer.java | 127 +++++++++++++++++++++
.../java/org/apache/iceberg/TestTableMetadata.java | 114 ++++++++++++++++++
6 files changed, 277 insertions(+), 12 deletions(-)
diff --git a/api/src/test/java/org/apache/iceberg/TestHelpers.java
b/api/src/test/java/org/apache/iceberg/TestHelpers.java
index 58d992d8df..ee9c169aa8 100644
--- a/api/src/test/java/org/apache/iceberg/TestHelpers.java
+++ b/api/src/test/java/org/apache/iceberg/TestHelpers.java
@@ -80,6 +80,10 @@ public class TestHelpers {
IntStream.rangeClosed(2,
MAX_FORMAT_VERSION).boxed().collect(Collectors.toUnmodifiableList());
public static final List<Integer> V3_AND_ABOVE =
IntStream.rangeClosed(3,
MAX_FORMAT_VERSION).boxed().collect(Collectors.toUnmodifiableList());
+ public static final List<Integer> V4_AND_ABOVE =
+ IntStream.rangeClosed(4,
MAX_FORMAT_VERSION).boxed().collect(Collectors.toUnmodifiableList());
+ public static final List<Integer> V3_AND_BELOW =
+ IntStream.rangeClosed(1,
3).boxed().collect(Collectors.toUnmodifiableList());
/** Wait in a tight check loop until system clock is past {@code
timestampMillis} */
public static long waitUntilAfter(long timestampMillis) {
diff --git a/core/src/main/java/org/apache/iceberg/SnapshotProducer.java
b/core/src/main/java/org/apache/iceberg/SnapshotProducer.java
index c735769223..06281188c3 100644
--- a/core/src/main/java/org/apache/iceberg/SnapshotProducer.java
+++ b/core/src/main/java/org/apache/iceberg/SnapshotProducer.java
@@ -35,6 +35,7 @@ import com.github.benmanes.caffeine.cache.Caffeine;
import com.github.benmanes.caffeine.cache.LoadingCache;
import java.io.IOException;
import java.math.RoundingMode;
+import java.time.Clock;
import java.util.Arrays;
import java.util.Collection;
import java.util.List;
@@ -121,6 +122,7 @@ abstract class SnapshotProducer<ThisT> implements
SnapshotUpdate<ThisT> {
private SnapshotAncestryValidator snapshotAncestryValidator =
SnapshotAncestryValidator.NON_VALIDATING;
+ private Clock clock = Clock.systemUTC();
private ExecutorService workerPool;
private ExecutorService writePool;
private int writePoolParallelism = ThreadPools.WORKER_THREAD_POOL_SIZE;
@@ -362,7 +364,7 @@ abstract class SnapshotProducer<ThisT> implements
SnapshotUpdate<ThisT> {
sequenceNumber,
snapshotId(),
parentSnapshotId,
- System.currentTimeMillis(),
+ snapshotTimestampMillis(parentSnapshot),
operation(),
summary(base),
base.currentSchemaId(),
@@ -694,6 +696,20 @@ abstract class SnapshotProducer<ThisT> implements
SnapshotUpdate<ThisT> {
return ManifestFiles.readDeleteManifest(manifest, ops.io(),
ops.current().specsById());
}
+ @VisibleForTesting
+ void setClock(Clock newClock) {
+ this.clock = newClock;
+ }
+
+ private long snapshotTimestampMillis(Snapshot parentSnapshot) {
+ long now = clock.millis();
+ if (parentSnapshot != null) {
+ return Math.max(now, parentSnapshot.timestampMillis() + 1);
+ }
+
+ return now;
+ }
+
protected long snapshotId() {
if (snapshotId == null) {
synchronized (this) {
diff --git a/core/src/main/java/org/apache/iceberg/TableMetadata.java
b/core/src/main/java/org/apache/iceberg/TableMetadata.java
index a43d414590..89427e4c69 100644
--- a/core/src/main/java/org/apache/iceberg/TableMetadata.java
+++ b/core/src/main/java/org/apache/iceberg/TableMetadata.java
@@ -59,6 +59,7 @@ public class TableMetadata implements Serializable {
static final int MIN_FORMAT_VERSION_ROW_LINEAGE = 3;
static final int MIN_FORMAT_VERSION_PARQUET_MANIFESTS = 4;
static final int MIN_FORMAT_VERSION_OPTIONAL_LOCATION = 4;
+ static final int MIN_FORMAT_VERSION_MONOTONIC_TIMESTAMPS = 4;
static final int INITIAL_SPEC_ID = 0;
static final int INITIAL_SORT_ORDER_ID = 1;
static final int INITIAL_SCHEMA_ID = 0;
@@ -378,15 +379,6 @@ public class TableMetadata implements Serializable {
}
last = logEntry;
}
- if (last != null) {
- Preconditions.checkArgument(
- // commits can happen concurrently from different machines.
- // A tolerance helps us avoid failure for small clock skew
- lastUpdatedMillis - last.timestampMillis() >= -ONE_MINUTE,
- "Invalid update timestamp %s: before last snapshot log entry at %s",
- lastUpdatedMillis,
- last.timestampMillis());
- }
MetadataLogEntry previous = null;
for (MetadataLogEntry metadataEntry : previousFiles) {
@@ -1270,6 +1262,18 @@ public class TableMetadata implements Serializable {
snapshot.sequenceNumber(),
lastSequenceNumber);
+ if (formatVersion >= MIN_FORMAT_VERSION_MONOTONIC_TIMESTAMPS &&
snapshot.parentId() != null) {
+ Snapshot parent = snapshotsById.get(snapshot.parentId());
+ if (parent != null) {
+ ValidationException.check(
+ snapshot.timestampMillis() > parent.timestampMillis(),
+ "Invalid snapshot timestamp %s: not after parent snapshot %s at
%s",
+ snapshot.timestampMillis(),
+ snapshot.parentId(),
+ parent.timestampMillis());
+ }
+ }
+
this.lastSequenceNumber = snapshot.sequenceNumber();
snapshots.add(snapshot);
snapshotsById.put(snapshot.snapshotId(), snapshot);
diff --git a/core/src/test/java/org/apache/iceberg/TestRowLineageMetadata.java
b/core/src/test/java/org/apache/iceberg/TestRowLineageMetadata.java
index 4fd4f9d361..4d861d5a81 100644
--- a/core/src/test/java/org/apache/iceberg/TestRowLineageMetadata.java
+++ b/core/src/test/java/org/apache/iceberg/TestRowLineageMetadata.java
@@ -127,7 +127,7 @@ public class TestRowLineageMetadata {
1,
2,
1L,
- 0,
+ 1,
DataOperations.APPEND,
null,
1,
@@ -163,7 +163,7 @@ public class TestRowLineageMetadata {
Snapshot invalidNewRows =
new BaseSnapshot(
- 1, 2, 1L, 0, DataOperations.APPEND, null, 1, "foo",
added.nextRowId() - 1, 10L, null);
+ 1, 2, 1L, 1, DataOperations.APPEND, null, 1, "foo",
added.nextRowId() - 1, 10L, null);
assertThatThrownBy(() ->
TableMetadata.buildFrom(added).addSnapshot(invalidNewRows))
.isInstanceOf(ValidationException.class)
diff --git a/core/src/test/java/org/apache/iceberg/TestSnapshotProducer.java
b/core/src/test/java/org/apache/iceberg/TestSnapshotProducer.java
index 1e6021a5c9..8d128b9b6b 100644
--- a/core/src/test/java/org/apache/iceberg/TestSnapshotProducer.java
+++ b/core/src/test/java/org/apache/iceberg/TestSnapshotProducer.java
@@ -27,9 +27,13 @@ import static org.assertj.core.api.Assumptions.assumeThat;
import java.io.File;
import java.io.IOException;
import java.nio.file.Paths;
+import java.time.Clock;
+import java.time.Instant;
+import java.time.ZoneOffset;
import java.util.List;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
+import java.util.concurrent.TimeUnit;
import java.util.stream.Collectors;
import javax.annotation.Nonnull;
import org.apache.iceberg.exceptions.ValidationException;
@@ -276,4 +280,127 @@ public class TestSnapshotProducer extends TestBase {
executor.shutdownNow();
}
}
+
+ @TestTemplate
+ public void snapshotTimestampsAreMonotonicallyIncreasing() {
+ table.newFastAppend().appendFile(FILE_A).commit();
+ Snapshot first = table.currentSnapshot();
+
+ table.newFastAppend().appendFile(FILE_B).commit();
+ Snapshot second = table.currentSnapshot();
+
assertThat(second.timestampMillis()).isGreaterThan(first.timestampMillis());
+
+ table.newFastAppend().appendFile(FILE_C).commit();
+ Snapshot third = table.currentSnapshot();
+
assertThat(third.timestampMillis()).isGreaterThan(second.timestampMillis());
+ }
+
+ @TestTemplate
+ public void lamportClockFastForwardsDriftedClock() {
+ table.newFastAppend().appendFile(FILE_A).commit();
+ long firstTs = table.currentSnapshot().timestampMillis();
+
+ long driftedTime = firstTs - TimeUnit.MINUTES.toMillis(2);
+ Clock driftedClock = Clock.fixed(Instant.ofEpochMilli(driftedTime),
ZoneOffset.UTC);
+ AppendFiles append = table.newFastAppend().appendFile(FILE_B);
+ ((SnapshotProducer<?>) append).setClock(driftedClock);
+ append.commit();
+
+
assertThat(table.currentSnapshot().timestampMillis()).isGreaterThan(firstTs);
+ }
+
+ @TestTemplate
+ public void transactionCommitsProduceMonotonicTimestamps() {
+ table.newFastAppend().appendFile(FILE_A).commit();
+ Snapshot base = table.currentSnapshot();
+
+ Transaction txn = table.newTransaction();
+ txn.newFastAppend().appendFile(FILE_B).commit();
+ Snapshot first = txn.table().currentSnapshot();
+ txn.newFastAppend().appendFile(FILE_C).commit();
+ Snapshot second = txn.table().currentSnapshot();
+ txn.commitTransaction();
+
+ assertThat(first.parentId()).isEqualTo(base.snapshotId());
+ assertThat(first.timestampMillis()).isGreaterThan(base.timestampMillis());
+ assertThat(second.parentId()).isEqualTo(first.snapshotId());
+
assertThat(second.timestampMillis()).isGreaterThan(first.timestampMillis());
+
assertThat(table.currentSnapshot().snapshotId()).isEqualTo(second.snapshotId());
+ }
+
+ @TestTemplate
+ public void transactionRetryFastForwardsTimestampsPastConflictingSnapshot() {
+ table.updateProperties().set(TableProperties.COMMIT_NUM_RETRIES,
"1").commit();
+
+ table.newFastAppend().appendFile(FILE_A).commit();
+ long baseTs = table.currentSnapshot().timestampMillis();
+ Transaction txn = table.newTransaction();
+
+ long firstCommitTs = baseTs + 1;
+ Clock firstClock = Clock.fixed(Instant.ofEpochMilli(firstCommitTs),
ZoneOffset.UTC);
+ AppendFiles firstAppend = txn.newFastAppend().appendFile(FILE_B);
+ ((SnapshotProducer<?>) firstAppend).setClock(firstClock);
+ firstAppend.commit();
+
+ long secondCommitTs = firstCommitTs + 1;
+ Clock secondClock = Clock.fixed(Instant.ofEpochMilli(secondCommitTs),
ZoneOffset.UTC);
+ AppendFiles secondAppend = txn.newFastAppend().appendFile(FILE_C);
+ ((SnapshotProducer<?>) secondAppend).setClock(secondClock);
+ secondAppend.commit();
+
+ long conflictTs = baseTs + 1_000_000L;
+ AppendFiles conflictAppend = table.newFastAppend().appendFile(FILE_D);
+ ((SnapshotProducer<?>) conflictAppend)
+ .setClock(Clock.fixed(Instant.ofEpochMilli(conflictTs),
ZoneOffset.UTC));
+ conflictAppend.commit();
+ Snapshot conflict = table.currentSnapshot();
+ assertThat(conflict.timestampMillis()).isEqualTo(conflictTs);
+
assertThat(txn.table().currentSnapshot().timestampMillis()).isLessThan(conflictTs);
+
+ txn.commitTransaction();
+
+ Snapshot retriedSecond = table.currentSnapshot();
+ Snapshot retriedFirst = table.snapshot(retriedSecond.parentId());
+ assertThat(retriedFirst.parentId()).isEqualTo(conflict.snapshotId());
+
assertThat(retriedFirst.timestampMillis()).isGreaterThan(conflict.timestampMillis());
+
assertThat(retriedSecond.timestampMillis()).isGreaterThan(retriedFirst.timestampMillis());
+ }
+
+ @TestTemplate
+ public void monotonicityIsScopedToTargetBranch() {
+ table.newFastAppend().appendFile(FILE_A).commit();
+ long mainParentTs = table.currentSnapshot().timestampMillis();
+
+ // Create a branch pointing at the current main head.
+ String branchName = "test-branch";
+ table.manageSnapshots().createBranch(branchName).commit();
+
+ // Commit to the branch with a clock far in the future so the branch head's
+ // timestamp-ms is much higher than main's head timestamp-ms.
+ long branchFutureTs = mainParentTs + 1_000_000L;
+ Clock branchClock = Clock.fixed(Instant.ofEpochMilli(branchFutureTs),
ZoneOffset.UTC);
+ AppendFiles branchAppend =
table.newFastAppend().appendFile(FILE_B).toBranch(branchName);
+ ((SnapshotProducer<?>) branchAppend).setClock(branchClock);
+ branchAppend.commit();
+ long branchTs = table.snapshot(branchName).timestampMillis();
+ assertThat(branchTs)
+ .as("branch commit should adopt the simulated far-future wall-clock
value")
+ .isEqualTo(branchFutureTs);
+
+ // Commit to main with a clock just slightly after mainParentTs but far
below branchTs.
+ // The spec requires monotonicity "on the same branch", so main's new
timestamp must be
+ // constrained only by main's previous head, not by the unrelated future
timestamp on
+ // the other branch.
+ long mainNewTs = mainParentTs + 5;
+ Clock mainClock = Clock.fixed(Instant.ofEpochMilli(mainNewTs),
ZoneOffset.UTC);
+ AppendFiles mainAppend = table.newFastAppend().appendFile(FILE_C);
+ ((SnapshotProducer<?>) mainAppend).setClock(mainClock);
+ mainAppend.commit();
+
+ long actualMainTs = table.currentSnapshot().timestampMillis();
+ assertThat(actualMainTs)
+ .as("Main's monotonicity must be relative to main's parent, not the
branch head")
+ .isEqualTo(mainNewTs)
+ .isLessThan(branchTs);
+ }
}
diff --git a/core/src/test/java/org/apache/iceberg/TestTableMetadata.java
b/core/src/test/java/org/apache/iceberg/TestTableMetadata.java
index be06fecf85..02c2340503 100644
--- a/core/src/test/java/org/apache/iceberg/TestTableMetadata.java
+++ b/core/src/test/java/org/apache/iceberg/TestTableMetadata.java
@@ -52,6 +52,7 @@ import java.util.Random;
import java.util.Set;
import java.util.SortedSet;
import java.util.UUID;
+import java.util.concurrent.TimeUnit;
import java.util.stream.IntStream;
import java.util.stream.Stream;
import org.apache.iceberg.TableMetadata.MetadataLogEntry;
@@ -72,6 +73,7 @@ import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.Arguments;
+import org.junit.jupiter.params.provider.FieldSource;
import org.junit.jupiter.params.provider.MethodSource;
import org.junit.jupiter.params.provider.ValueSource;
@@ -2184,4 +2186,116 @@ public class TestTableMetadata {
.build();
assertThat(withTag.ref("tag1").isTag()).isTrue();
}
+
+ @ParameterizedTest
+ @FieldSource("org.apache.iceberg.TestHelpers#V4_AND_ABOVE")
+ public void addSnapshotRejectsNonMonotonicTimestampAfterV4(int
formatVersion) {
+ TableMetadata base =
+ TableMetadata.newTableMetadata(
+ TEST_SCHEMA,
+ PartitionSpec.unpartitioned(),
+ TEST_LOCATION,
+ ImmutableMap.of(TableProperties.FORMAT_VERSION,
String.valueOf(formatVersion)));
+
+ Snapshot parent =
+ new BaseSnapshot(1, 1L, null, 1_000L, null, null, null,
"file:/s1.avro", 0L, 0L, null);
+ TableMetadata withParent =
TableMetadata.buildFrom(base).addSnapshot(parent).build();
+
+ Snapshot child =
+ new BaseSnapshot(2, 2L, 1L, 1_000L, null, null, null, "file:/s2.avro",
0L, 0L, null);
+
+ assertThatThrownBy(() ->
TableMetadata.buildFrom(withParent).addSnapshot(child))
+ .isInstanceOf(ValidationException.class)
+ .hasMessage("Invalid snapshot timestamp 1000: not after parent
snapshot 1 at 1000");
+ }
+
+ @ParameterizedTest
+ @FieldSource("org.apache.iceberg.TestHelpers#V3_AND_BELOW")
+ public void addSnapshotAllowsNonMonotonicTimestampBeforeV4(int
formatVersion) {
+ TableMetadata base =
+ TableMetadata.newTableMetadata(
+ TEST_SCHEMA,
+ PartitionSpec.unpartitioned(),
+ "location",
+ ImmutableMap.of(TableProperties.FORMAT_VERSION,
String.valueOf(formatVersion)));
+
+ // v1-v3 differ in sequence number and row-lineage fields.
+ long parentSequenceNumber = formatVersion == 1 ? 0L : 1L;
+ Long parentFirstRowId =
+ formatVersion >= TableMetadata.MIN_FORMAT_VERSION_ROW_LINEAGE ? 0L :
null;
+ Long parentAddedRows =
+ formatVersion >= TableMetadata.MIN_FORMAT_VERSION_ROW_LINEAGE ? 10L :
null;
+ Snapshot parent =
+ new BaseSnapshot(
+ parentSequenceNumber,
+ 1L,
+ null,
+ 1_000L,
+ null,
+ null,
+ null,
+ "file:/s1.avro",
+ parentFirstRowId,
+ parentAddedRows,
+ null);
+ TableMetadata withParent =
TableMetadata.buildFrom(base).addSnapshot(parent).build();
+
+ long childSequenceNumber = formatVersion == 1 ? 0L : 2L;
+ Long childFirstRowId =
+ formatVersion >= TableMetadata.MIN_FORMAT_VERSION_ROW_LINEAGE ?
parentAddedRows : null;
+ Long childAddedRows =
+ formatVersion >= TableMetadata.MIN_FORMAT_VERSION_ROW_LINEAGE ? 20L :
null;
+ Snapshot child =
+ new BaseSnapshot(
+ childSequenceNumber,
+ 2L,
+ 1L,
+ 999L,
+ null,
+ null,
+ null,
+ "file:/s2.avro",
+ childFirstRowId,
+ childAddedRows,
+ null);
+
+ TableMetadata withChild =
TableMetadata.buildFrom(withParent).addSnapshot(child).build();
+ assertThat(withChild.snapshot(2L).timestampMillis()).isEqualTo(999L);
+ }
+
+ @ParameterizedTest
+ @FieldSource("org.apache.iceberg.TestHelpers#ALL_VERSIONS")
+ public void snapshotTimestampMayBeAheadOfTableMetadataLastUpdatedMillis(int
formatVersion) {
+ TableMetadata base =
+ TableMetadata.newTableMetadata(
+ TEST_SCHEMA,
+ PartitionSpec.unpartitioned(),
+ TEST_LOCATION,
+ ImmutableMap.of(TableProperties.FORMAT_VERSION,
String.valueOf(formatVersion)));
+
+ long snapshotTs = System.currentTimeMillis() +
TimeUnit.MINUTES.toMillis(2);
+ // Versions differ in sequence number and row-lineage fields.
+ long sequenceNumber = formatVersion == 1 ? 0L : 1L;
+ Long firstRowId = formatVersion >=
TableMetadata.MIN_FORMAT_VERSION_ROW_LINEAGE ? 0L : null;
+ Long addedRows = formatVersion >=
TableMetadata.MIN_FORMAT_VERSION_ROW_LINEAGE ? 10L : null;
+ Snapshot snapshot =
+ new BaseSnapshot(
+ sequenceNumber,
+ 1L,
+ null,
+ snapshotTs,
+ null,
+ null,
+ null,
+ "file:/s1.avro",
+ firstRowId,
+ addedRows,
+ null);
+
+ TableMetadata updated =
+ TableMetadata.buildFrom(base).setBranchSnapshot(snapshot,
SnapshotRef.MAIN_BRANCH).build();
+
+
assertThat(updated.currentSnapshot().timestampMillis()).isEqualTo(snapshotTs);
+ assertThat(updated.lastUpdatedMillis()).isLessThan(snapshotTs);
+ }
}