Files
seaweedfs/weed/admin/dash/iceberg_data_preview.go
Chris LuandGitHub 4b0d09683a iceberg: read manifest lists that omit the Avro format version (#10475)
* s3tables: read Iceberg manifest lists that omit the Avro format version

The Iceberg spec pins the Avro header metadata of manifest files but says
nothing about manifest lists, so writers disagree. Java and PyIceberg record
"format-version"; DuckDB writes no header metadata at all. iceberg-go reads a
missing entry as v1, so every v2 manifest listed in a DuckDB-written list is
rejected with

  manifest file's 'format-version' metadata indicates version 2,
  but entry from manifest list indicates version 1

and, because v1 has no "content" field, delete manifests silently decode as
data manifests.

ReadManifestList derives the version from the record schema the writer
embedded - v2 added "content" and the sequence numbers, v3 added
"first_row_id" - and splices it into the header before handing the bytes to
iceberg-go. Lists that already carry the entry, and input that is not a
parseable Avro container, go through untouched.

* iceberg: parse DuckDB-written manifest lists in maintenance and data preview

Every manifest list read - the four maintenance operations and the admin
table data preview - went straight to iceberg-go, so tables written by DuckDB
failed detection and all of compact, remove_orphans, rewrite_manifests and
expire_snapshots before they touched anything. Route them through
s3tables.ReadManifestList, which recovers the format version the writer left
out of the Avro header.

This also restores the manifest content type on those tables: with the list
read as v1 every delete manifest looked like a data manifest, which hid
deletes from the compaction guard and made the preview report a table with
position deletes as having none.
2026-07-28 16:42:17 -07:00

470 lines
14 KiB
Go

package dash
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"path"
"slices"
"strings"
"time"
"unicode/utf8"
"github.com/apache/iceberg-go"
"github.com/parquet-go/parquet-go"
"github.com/seaweedfs/seaweedfs/weed/filer"
"github.com/seaweedfs/seaweedfs/weed/pb/filer_pb"
"github.com/seaweedfs/seaweedfs/weed/s3api/s3tables"
)
const (
icebergPreviewDefaultRows = 50
icebergPreviewMaxRows = 200
icebergPreviewMaxFiles = 5
icebergPreviewMaxManifests = 100
icebergPreviewMaxListed = 500
icebergPreviewMaxCellChars = 200
icebergPreviewMaxMetaBytes = 64 << 20
)
type IcebergDataFileInfo struct {
Path string `json:"path"`
Format string `json:"format"`
RecordCount int64 `json:"record_count"`
SizeBytes int64 `json:"size_bytes"`
}
// IcebergDataPreviewData backs the table data-preview page. PreviewError and
// PreviewNotes report per-snapshot problems without failing the whole page.
type IcebergDataPreviewData struct {
Username string `json:"username"`
CatalogName string `json:"catalog_name"`
NamespaceName string `json:"namespace_name"`
TableName string `json:"table_name"`
BucketARN string `json:"bucket_arn"`
SnapshotID int64 `json:"snapshot_id"`
SnapshotTime time.Time `json:"snapshot_time"`
CurrentSnapshotID int64 `json:"current_snapshot_id"`
Snapshots []IcebergSnapshotInfo `json:"snapshots"`
Columns []string `json:"columns"`
Rows [][]string `json:"rows"`
RowLimit int `json:"row_limit"`
ScannedFiles int `json:"scanned_files"`
SelectedFile string `json:"selected_file"`
DataFiles []IcebergDataFileInfo `json:"data_files"`
TotalDataFiles int `json:"total_data_files"`
TotalRecords int64 `json:"total_records"`
HasDeletes bool `json:"has_deletes"`
PreviewNotes []string `json:"preview_notes,omitempty"`
PreviewError string `json:"preview_error,omitempty"`
LastUpdated time.Time `json:"last_updated"`
}
// GetIcebergTableDataPreview walks the selected snapshot's manifests and reads
// sample rows from its Parquet data files. snapshotID 0 means the current
// snapshot; selectedFile restricts the preview to one manifest-listed file.
func (s *AdminServer) GetIcebergTableDataPreview(ctx context.Context, catalogName, bucketArn, namespace, tableName string, snapshotID int64, selectedFile string, rowLimit int) (IcebergDataPreviewData, error) {
if rowLimit < 1 {
rowLimit = icebergPreviewDefaultRows
}
if rowLimit > icebergPreviewMaxRows {
rowLimit = icebergPreviewMaxRows
}
data := IcebergDataPreviewData{
CatalogName: catalogName,
NamespaceName: namespace,
TableName: tableName,
BucketARN: bucketArn,
RowLimit: rowLimit,
SelectedFile: selectedFile,
LastUpdated: time.Now(),
}
namespaceParts, err := parseNamespaceInput(namespace)
if err != nil {
return data, err
}
var resp s3tables.GetTableResponse
req := &s3tables.GetTableRequest{TableBucketARN: bucketArn, Namespace: namespaceParts, Name: tableName}
if err := s.executeS3TablesOperation(ctx, "GetTable", req, &resp); err != nil {
return data, err
}
if resp.Metadata == nil || len(resp.Metadata.FullMetadata) == 0 {
data.PreviewError = "Table has no Iceberg metadata."
return data, nil
}
var full icebergFullMetadata
if err := json.Unmarshal(resp.Metadata.FullMetadata, &full); err != nil {
data.PreviewError = fmt.Sprintf("Failed to parse Iceberg metadata: %v", err)
return data, nil
}
data.Snapshots = snapshotsFromFullMetadata(full.Snapshots)
data.CurrentSnapshotID = full.CurrentSnapshotID
snap := selectPreviewSnapshot(full, snapshotID)
if snap == nil {
if snapshotID != 0 {
data.PreviewError = fmt.Sprintf("Snapshot %d not found.", snapshotID)
} else {
data.PreviewError = "Table has no snapshots yet."
}
return data, nil
}
data.SnapshotID = snap.SnapshotID
if snap.TimestampMs > 0 {
data.SnapshotTime = time.Unix(0, snap.TimestampMs*int64(time.Millisecond))
}
if snap.ManifestList == "" {
data.PreviewError = "Snapshot has no manifest list."
return data, nil
}
dataFiles, hasDeletes, notes, err := s.listIcebergDataFiles(ctx, catalogName, namespace, tableName, snap.ManifestList)
if err != nil {
data.PreviewError = fmt.Sprintf("Failed to read snapshot manifests: %v", err)
return data, nil
}
data.HasDeletes = hasDeletes
data.PreviewNotes = notes
data.TotalDataFiles = len(dataFiles)
for _, f := range dataFiles {
data.TotalRecords += f.RecordCount
}
data.DataFiles = dataFiles
if len(dataFiles) > icebergPreviewMaxListed {
data.DataFiles = dataFiles[:icebergPreviewMaxListed]
data.PreviewNotes = append(data.PreviewNotes, fmt.Sprintf("Listing first %d of %d data files.", icebergPreviewMaxListed, len(dataFiles)))
}
toScan := dataFiles
if selectedFile != "" {
toScan = nil
for _, f := range dataFiles {
if f.Path == selectedFile {
toScan = []IcebergDataFileInfo{f}
break
}
}
if toScan == nil {
data.PreviewError = "Requested data file is not part of this snapshot."
return data, nil
}
}
if len(toScan) == 0 {
data.PreviewNotes = append(data.PreviewNotes, "Snapshot contains no data files.")
return data, nil
}
for _, f := range toScan {
if len(data.Rows) >= rowLimit || data.ScannedFiles >= icebergPreviewMaxFiles {
break
}
if !strings.EqualFold(f.Format, string(iceberg.ParquetFile)) {
data.PreviewNotes = append(data.PreviewNotes, fmt.Sprintf("Skipped %s: %s preview is not supported.", path.Base(f.Path), f.Format))
continue
}
filerPath, err := icebergLocationToFilerPath(f.Path, catalogName, namespace, tableName)
if err != nil {
data.PreviewNotes = append(data.PreviewNotes, fmt.Sprintf("Skipped %s: %v", path.Base(f.Path), err))
continue
}
cols, rows, err := s.readParquetRows(ctx, filerPath, rowLimit-len(data.Rows))
if err != nil {
data.PreviewNotes = append(data.PreviewNotes, fmt.Sprintf("Failed to read %s: %v", path.Base(f.Path), err))
data.ScannedFiles++
continue
}
if data.Columns == nil {
data.Columns = cols
} else if !slices.Equal(data.Columns, cols) {
data.PreviewNotes = append(data.PreviewNotes, fmt.Sprintf("Skipped %s: column layout differs from the first file.", path.Base(f.Path)))
continue
}
data.Rows = append(data.Rows, rows...)
data.ScannedFiles++
}
if hasDeletes {
data.PreviewNotes = append(data.PreviewNotes, "Snapshot has row-level delete files; the preview shows raw data-file rows without applying deletes.")
}
return data, nil
}
func selectPreviewSnapshot(full icebergFullMetadata, snapshotID int64) *icebergSnapshot {
if snapshotID != 0 {
for i := range full.Snapshots {
if full.Snapshots[i].SnapshotID == snapshotID {
return &full.Snapshots[i]
}
}
return nil
}
return selectSnapshotForMetrics(full)
}
func (s *AdminServer) listIcebergDataFiles(ctx context.Context, bucketName, namespaceDir, tableName, manifestListLocation string) (files []IcebergDataFileInfo, hasDeletes bool, notes []string, err error) {
listPath, err := icebergLocationToFilerPath(manifestListLocation, bucketName, namespaceDir, tableName)
if err != nil {
return nil, false, nil, err
}
listBytes, err := s.readFilerFileContent(ctx, listPath)
if err != nil {
return nil, false, nil, fmt.Errorf("read manifest list %s: %w", manifestListLocation, err)
}
manifests, err := s3tables.ReadManifestList(listBytes)
if err != nil {
return nil, false, nil, fmt.Errorf("parse manifest list: %w", err)
}
scanned := 0
for _, mf := range manifests {
if mf.ManifestContent() != iceberg.ManifestContentData {
hasDeletes = true
continue
}
if scanned >= icebergPreviewMaxManifests {
notes = append(notes, fmt.Sprintf("Scanned first %d of %d manifests.", icebergPreviewMaxManifests, len(manifests)))
break
}
scanned++
mfPath, err := icebergLocationToFilerPath(mf.FilePath(), bucketName, namespaceDir, tableName)
if err != nil {
notes = append(notes, fmt.Sprintf("Skipped manifest %s: %v", path.Base(mf.FilePath()), err))
continue
}
mfBytes, err := s.readFilerFileContent(ctx, mfPath)
if err != nil {
notes = append(notes, fmt.Sprintf("Failed to read manifest %s: %v", path.Base(mf.FilePath()), err))
continue
}
entries, err := iceberg.ReadManifest(mf, bytes.NewReader(mfBytes), true)
if err != nil {
notes = append(notes, fmt.Sprintf("Failed to parse manifest %s: %v", path.Base(mf.FilePath()), err))
continue
}
for _, entry := range entries {
df := entry.DataFile()
files = append(files, IcebergDataFileInfo{
Path: df.FilePath(),
Format: string(df.FileFormat()),
RecordCount: df.Count(),
SizeBytes: df.FileSizeBytes(),
})
}
}
return files, hasDeletes, notes, nil
}
// icebergLocationToFilerPath maps an Iceberg file location (s3://bucket/...,
// an absolute filer path, or a table-relative path like data/x.parquet) to a
// filer path, which must stay under the table-buckets root.
func icebergLocationToFilerPath(location, bucketName, namespaceDir, tableName string) (string, error) {
p := location
if idx := strings.Index(p, "://"); idx >= 0 {
p = path.Join(s3tables.TablesPath, p[idx+3:])
} else if strings.HasPrefix(p, "/") {
p = path.Clean(p)
} else {
tableDir := path.Join(s3tables.TablesPath, bucketName, namespaceDir, tableName)
p = path.Join(tableDir, p)
if !strings.HasPrefix(p, tableDir+"/") {
return "", fmt.Errorf("location %q resolves outside the table directory", location)
}
}
p = path.Clean(p)
if !strings.HasPrefix(p, s3tables.TablesPath+"/") {
return "", fmt.Errorf("location %q resolves outside %s", location, s3tables.TablesPath)
}
return p, nil
}
func (s *AdminServer) lookupFilerEntry(ctx context.Context, fullPath string) (*filer_pb.Entry, error) {
dir, name := path.Dir(fullPath), path.Base(fullPath)
var entry *filer_pb.Entry
err := s.WithFilerClient(func(client filer_pb.SeaweedFilerClient) error {
resp, err := filer_pb.LookupEntry(ctx, client, &filer_pb.LookupDirectoryEntryRequest{
Directory: dir,
Name: name,
})
if err != nil {
return err
}
entry = resp.Entry
return nil
})
if err != nil {
return nil, err
}
if entry == nil {
return nil, fmt.Errorf("not found: %s", fullPath)
}
return entry, nil
}
func (s *AdminServer) readFilerFileContent(ctx context.Context, fullPath string) ([]byte, error) {
entry, err := s.lookupFilerEntry(ctx, fullPath)
if err != nil {
return nil, err
}
if len(entry.Content) > 0 || len(entry.GetChunks()) == 0 {
return entry.Content, nil
}
size := int64(filer.FileSize(entry))
if size > icebergPreviewMaxMetaBytes {
return nil, fmt.Errorf("file too large to load (%d bytes)", size)
}
streamFn, err := filer.PrepareStreamContentWithThrottler(ctx, s.GetMasterClient(), VolumeServerReadJwt, entry.GetChunks(), 0, size, 0)
if err != nil {
return nil, err
}
var buf bytes.Buffer
if err := streamFn(&buf); err != nil {
return nil, err
}
return buf.Bytes(), nil
}
func (s *AdminServer) readParquetRows(ctx context.Context, fullPath string, want int) ([]string, [][]string, error) {
entry, err := s.lookupFilerEntry(ctx, fullPath)
if err != nil {
return nil, nil, err
}
var readerAt io.ReaderAt
var size int64
if len(entry.Content) > 0 || len(entry.GetChunks()) == 0 {
readerAt = bytes.NewReader(entry.Content)
size = int64(len(entry.Content))
} else {
size = int64(filer.FileSize(entry))
readerAt = &filerChunkReaderAt{ctx: ctx, server: s, chunks: entry.GetChunks(), size: size}
}
pf, err := parquet.OpenFile(readerAt, size, parquet.SkipPageIndex(true), parquet.SkipBloomFilters(true))
if err != nil {
return nil, nil, fmt.Errorf("open parquet: %w", err)
}
leafColumns := pf.Schema().Columns()
cols := make([]string, len(leafColumns))
for i, c := range leafColumns {
cols[i] = strings.Join(c, ".")
}
reader := parquet.NewReader(pf)
defer reader.Close()
rowBuf := make([]parquet.Row, 32)
var out [][]string
for len(out) < want {
n, readErr := reader.ReadRows(rowBuf)
for i := 0; i < n && len(out) < want; i++ {
out = append(out, formatParquetRow(rowBuf[i], len(cols)))
}
if readErr != nil {
if readErr == io.EOF {
break
}
return nil, nil, readErr
}
if n == 0 {
break
}
}
return cols, out, nil
}
func formatParquetRow(row parquet.Row, columnCount int) []string {
cells := make([]string, columnCount)
seen := make([]bool, columnCount)
for _, v := range row {
ci := v.Column()
if ci < 0 || ci >= columnCount {
continue
}
cell := formatParquetValue(v)
if seen[ci] {
cells[ci] += ", " + cell
} else {
cells[ci] = cell
seen[ci] = true
}
}
return cells
}
func formatParquetValue(v parquet.Value) string {
if v.IsNull() {
return ""
}
switch v.Kind() {
case parquet.ByteArray, parquet.FixedLenByteArray:
b := v.ByteArray()
if utf8.Valid(b) {
return truncateCell(string(b))
}
return truncateCell(fmt.Sprintf("0x%x", b))
default:
return truncateCell(v.String())
}
}
func truncateCell(s string) string {
if len(s) <= icebergPreviewMaxCellChars {
return s
}
cut := icebergPreviewMaxCellChars
for cut > 0 && !utf8.RuneStart(s[cut]) {
cut--
}
return s[:cut] + "…"
}
// filerChunkReaderAt serves random-access reads over a chunked filer entry by
// issuing ranged volume-server reads, carrying the read JWT when configured.
type filerChunkReaderAt struct {
ctx context.Context
server *AdminServer
chunks []*filer_pb.FileChunk
size int64
}
func (r *filerChunkReaderAt) ReadAt(p []byte, off int64) (int, error) {
if off < 0 {
return 0, fmt.Errorf("negative offset: %d", off)
}
if off >= r.size {
return 0, io.EOF
}
want := int64(len(p))
if off+want > r.size {
want = r.size - off
}
streamFn, err := filer.PrepareStreamContentWithThrottler(r.ctx, r.server.GetMasterClient(), VolumeServerReadJwt, r.chunks, off, want, 0)
if err != nil {
return 0, err
}
// Write straight into p; a bytes.Buffer wrapping p would silently
// allocate a fresh backing array if it ever grew, dropping bytes the
// caller expects in p.
w := &sliceWriter{dst: p}
if err := streamFn(w); err != nil {
return w.n, err
}
if w.n < len(p) {
return w.n, io.EOF
}
return w.n, nil
}
// sliceWriter writes into a fixed destination slice, discarding any overflow.
type sliceWriter struct {
dst []byte
n int
}
func (w *sliceWriter) Write(p []byte) (int, error) {
c := copy(w.dst[w.n:], p)
w.n += c
return c, nil
}