This is an automated email from the ASF dual-hosted git repository.
zeroshade pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/iceberg-go.git
The following commit(s) were added to refs/heads/main by this push:
new 61dff5cc8 refactor(table): export SnapshotLogEntryAsOf for
metadata-table time travel (#2150)
61dff5cc8 is described below
commit 61dff5cc8365857cdcf9a91e3194d6ace421420e
Author: Andrei Tserakhau <[email protected]>
AuthorDate: Wed Oct 14 15:28:08 2026 +0200
refactor(table): export SnapshotLogEntryAsOf for metadata-table time travel
(#2150)
* refactor(table): export SnapshotLogEntryAsOf for metadata-table time
travel
Promote the snapshot-log time-travel resolver from the unexported
snapshotLogEntryAsOf to the exported SnapshotLogEntryAsOf, so code outside
the
table package (the forthcoming table/inspect package) can resolve a
snapshot as
of a timestamp without reaching into table internals.
Pure rename plus repoint of the in-package callers (the as-of-timestamp
scan,
Table.SnapshotAsOf, and the metadata_log_entries inspection). No behavior
change.
* docs(table): state the SnapshotLogEntryAsOf tie-break and test it
On equal timestamps SnapshotLogEntryAsOf keeps the earliest entry in log
order. That matches Java's SnapshotUtil.nullableSnapshotIdAsOfTime, which
now picks the greatest eligible timestamp with a strict comparison, so a tie
also resolves to the first entry there, and Java's metadata_log_entries
table resolves through the same function. Behavior is unchanged; the doc
comment now says so instead of implying order never matters.
Add a table test for the exported function covering an empty log, no
eligible entry, an out-of-order log, inclusive and exclusive matching, and
equal timestamps.
Signed-off-by: laskoviymishka <[email protected]>
---------
Signed-off-by: laskoviymishka <[email protected]>
---
table/inspect.go | 2 +-
table/scanner.go | 2 +-
table/snapshots.go | 14 +++++---
table/table.go | 2 +-
table/time_travel_test.go | 90 +++++++++++++++++++++++++++++++++++++++++++++++
5 files changed, 102 insertions(+), 8 deletions(-)
diff --git a/table/inspect.go b/table/inspect.go
index af1ea364e..5a5b065b6 100644
--- a/table/inspect.go
+++ b/table/inspect.go
@@ -650,7 +650,7 @@ func (i InspectTable) MetadataLogEntries(ctx
context.Context) (array.RecordReade
// The snapshot ID remains available when the snapshot itself has expired from
// metadata.
func latestSnapshotAt(metadata Metadata, timestampMs int64) (int64, *Snapshot,
bool) {
- entry, found := snapshotLogEntryAsOf(metadata.SnapshotLogs(),
timestampMs, true)
+ entry, found := SnapshotLogEntryAsOf(metadata.SnapshotLogs(),
timestampMs, true)
if !found {
return 0, nil, false
}
diff --git a/table/scanner.go b/table/scanner.go
index f6bf14c35..2d053e1e8 100644
--- a/table/scanner.go
+++ b/table/scanner.go
@@ -676,7 +676,7 @@ func (scan *Scan) ResolveSnapshot() (*Snapshot, error) {
}
if scan.asOfTimestamp != nil {
- entry, ok := snapshotLogEntryAsOf(scan.metadata.SnapshotLogs(),
*scan.asOfTimestamp, true)
+ entry, ok := SnapshotLogEntryAsOf(scan.metadata.SnapshotLogs(),
*scan.asOfTimestamp, true)
if !ok {
return nil, fmt.Errorf("no snapshot found for timestamp
%d", *scan.asOfTimestamp)
}
diff --git a/table/snapshots.go b/table/snapshots.go
index 36b828574..a66acce83 100644
--- a/table/snapshots.go
+++ b/table/snapshots.go
@@ -548,11 +548,15 @@ type SnapshotLogEntry struct {
TimestampMs int64 `json:"timestamp-ms"`
}
-// snapshotLogEntryAsOf returns the log entry with the greatest eligible
-// timestamp. Snapshot-log entries can be out of chronological order when
-// commits have small clock skews, so the result must not depend on iteration
-// order.
-func snapshotLogEntryAsOf(entries iter.Seq[SnapshotLogEntry], timestampMs
int64, inclusive bool) (SnapshotLogEntry, bool) {
+// SnapshotLogEntryAsOf returns the snapshot-log entry with the greatest
+// timestamp at or before timestampMs (or strictly before it when inclusive is
+// false), and whether one was found. Snapshot-log entries can be out of
+// chronological order when commits have small clock skews, so the entry is
+// chosen by timestamp rather than by position. On equal timestamps the
earliest
+// entry in log order wins, matching Java's
+// SnapshotUtil.nullableSnapshotIdAsOfTime. It is the time-travel resolution
+// used by the as-of-timestamp scan and the metadata_log_entries metadata
table.
+func SnapshotLogEntryAsOf(entries iter.Seq[SnapshotLogEntry], timestampMs
int64, inclusive bool) (SnapshotLogEntry, bool) {
var (
best SnapshotLogEntry
found bool
diff --git a/table/table.go b/table/table.go
index 48d2ea51a..27950c12c 100644
--- a/table/table.go
+++ b/table/table.go
@@ -1165,7 +1165,7 @@ func backoffDuration(attempt uint, minMs, maxMs uint64)
time.Duration {
// SnapshotAsOf finds the snapshot that was current as of or right before the
given timestamp.
func (t Table) SnapshotAsOf(timestampMs int64, inclusive bool) *Snapshot {
- entry, ok := snapshotLogEntryAsOf(t.metadata.SnapshotLogs(),
timestampMs, inclusive)
+ entry, ok := SnapshotLogEntryAsOf(t.metadata.SnapshotLogs(),
timestampMs, inclusive)
if ok {
return t.metadata.SnapshotByID(entry.SnapshotID)
}
diff --git a/table/time_travel_test.go b/table/time_travel_test.go
index 67732fa98..b047f1f43 100644
--- a/table/time_travel_test.go
+++ b/table/time_travel_test.go
@@ -19,6 +19,8 @@ package table
import (
"context"
+ "iter"
+ "slices"
"testing"
"time"
@@ -188,6 +190,94 @@ func
TestSnapshotAsOfWithEqualTimestampsUsesFirstLogEntry(t *testing.T) {
assert.Equal(t, int64(1000), snapshot.SnapshotID)
}
+func TestSnapshotLogEntryAsOf(t *testing.T) {
+ entries := func(log ...SnapshotLogEntry) iter.Seq[SnapshotLogEntry] {
+ return slices.Values(log)
+ }
+
+ tests := []struct {
+ name string
+ log []SnapshotLogEntry
+ timestampMs int64
+ inclusive bool
+ wantID int64
+ wantFound bool
+ }{
+ {
+ name: "empty log",
+ timestampMs: 1000,
+ inclusive: true,
+ },
+ {
+ name: "all entries after the timestamp",
+ log: []SnapshotLogEntry{{SnapshotID: 1,
TimestampMs: 2000}},
+ timestampMs: 1000,
+ inclusive: true,
+ },
+ {
+ name: "greatest eligible timestamp in an out-of-order
log",
+ log: []SnapshotLogEntry{
+ {SnapshotID: 2, TimestampMs: 2000},
+ {SnapshotID: 1, TimestampMs: 1000},
+ {SnapshotID: 3, TimestampMs: 3000},
+ },
+ timestampMs: 2500,
+ inclusive: true,
+ wantID: 2,
+ wantFound: true,
+ },
+ {
+ name: "inclusive matches an equal timestamp",
+ log: []SnapshotLogEntry{{SnapshotID: 1,
TimestampMs: 1000}, {SnapshotID: 2, TimestampMs: 2000}},
+ timestampMs: 2000,
+ inclusive: true,
+ wantID: 2,
+ wantFound: true,
+ },
+ {
+ name: "exclusive skips an equal timestamp",
+ log: []SnapshotLogEntry{{SnapshotID: 1,
TimestampMs: 1000}, {SnapshotID: 2, TimestampMs: 2000}},
+ timestampMs: 2000,
+ wantID: 1,
+ wantFound: true,
+ },
+ {
+ name: "equal timestamps take the first entry in log
order",
+ log: []SnapshotLogEntry{
+ {SnapshotID: 1, TimestampMs: 1000},
+ {SnapshotID: 2, TimestampMs: 1000},
+ {SnapshotID: 3, TimestampMs: 1000},
+ },
+ timestampMs: 1000,
+ inclusive: true,
+ wantID: 1,
+ wantFound: true,
+ },
+ {
+ name: "equal timestamps after a later out-of-order
entry",
+ log: []SnapshotLogEntry{
+ {SnapshotID: 1, TimestampMs: 2000},
+ {SnapshotID: 2, TimestampMs: 1000},
+ {SnapshotID: 3, TimestampMs: 2000},
+ },
+ timestampMs: 2500,
+ inclusive: true,
+ wantID: 1,
+ wantFound: true,
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ got, found := SnapshotLogEntryAsOf(entries(tt.log...),
tt.timestampMs, tt.inclusive)
+ require.Equal(t, tt.wantFound, found)
+ if tt.wantFound {
+ assert.Equal(t, tt.wantID, got.SnapshotID)
+ }
+ })
+ }
+}
+
func TestResolveSnapshotRejectsUnknownSnapshotLogEntry(t *testing.T) {
baseTime := time.Now().Add(5 * time.Second).UnixMilli()
snapshots := []Snapshot{