mirror of
https://github.com/seaweedfs/seaweedfs.git
synced 2026-10-25 22:40:44 +01:00
New upstream releases build for x86-64-v3 (AVX2) - ClickHouse since 26.5, plus the DuckDB iceberg extension and pylance wheels - so on a runner whose CPU lacks AVX2 the container dies on SIGILL before the test reaches any SeaweedFS code. Probe each third-party image first and skip on the illegal-instruction exit instead of failing; other errors still surface normally.
400 lines
14 KiB
Go
400 lines
14 KiB
Go
package lifecycle
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"os"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"regexp"
|
|
"strconv"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/seaweedfs/seaweedfs/test/testutil"
|
|
"github.com/seaweedfs/seaweedfs/weed/worker/tasks/iceberg"
|
|
)
|
|
|
|
const (
|
|
pyicebergImage = "seaweedfs-lifecycle-pyiceberg"
|
|
duckdbImage = "duckdb/duckdb:latest"
|
|
|
|
// The phase that installs the extension, and so the only one whose failure
|
|
// can mean the image rather than the code.
|
|
firstPhase = "write"
|
|
|
|
// Three appends of this many rows, all inside one month so they land in
|
|
// one partition and compaction has something to merge. The two string
|
|
// columns hold few enough distinct values to be dictionary-encoded, which
|
|
// is the encoding the merge destroyed.
|
|
rowsPerBatch = 4000
|
|
batches = 3
|
|
categories = 7
|
|
values = 13
|
|
)
|
|
|
|
// tally is what a client reports about a table. Both halves of this suite
|
|
// speak it, and the point of the whole suite is that the one taken before
|
|
// maintenance equals the one taken after.
|
|
type tally struct {
|
|
Rows int `json:"rows"`
|
|
Categories int `json:"categories"`
|
|
Values int `json:"values"`
|
|
Digest string `json:"digest"`
|
|
// Lance only: a compaction that merged nothing would otherwise let this
|
|
// test pass without having tested anything.
|
|
Fragments int `json:"fragments"`
|
|
Version int `json:"version"`
|
|
}
|
|
|
|
func TestIcebergTableLifecycle(t *testing.T) {
|
|
if testing.Short() {
|
|
t.Skip("skipping integration test in short mode")
|
|
}
|
|
if shared == nil {
|
|
t.Skip("no cluster")
|
|
}
|
|
if !testutil.HasDocker() {
|
|
t.Skip("Docker not available")
|
|
}
|
|
|
|
// DuckDB is the client the bug was reported against, and the only one
|
|
// here that writes the deprecated PLAIN_DICTIONARY encoding a Go writer
|
|
// will not produce. PyIceberg writes the modern one, so between them the
|
|
// merge is checked against both dictionary encodings in the spec.
|
|
t.Run("DuckDB", testIcebergLifecycleWithDuckDB)
|
|
t.Run("PyIceberg", testIcebergLifecycleWithPyIceberg)
|
|
}
|
|
|
|
func testIcebergLifecycleWithDuckDB(t *testing.T) {
|
|
env := shared
|
|
|
|
// The write phase's first act is INSTALLing the iceberg extension, whose
|
|
// binary can itself target a newer x86-64 baseline than the host CPU has.
|
|
testutil.RequireImageRunnable(t,
|
|
"--entrypoint", "duckdb", duckdbImage,
|
|
"-c", "INSTALL iceberg; LOAD iceberg; SELECT 1;")
|
|
|
|
bucket := "lifecycle-duckdb-" + randomSuffix()
|
|
namespace, table := "sales", "events"
|
|
env.createTableBucket(t, bucket, "ICEBERG")
|
|
|
|
inserts := make([]string, 0, batches)
|
|
for i := 0; i < batches; i++ {
|
|
inserts = append(inserts, fmt.Sprintf(
|
|
"INSERT INTO cat.%s.%s SELECT g, TIMESTAMPTZ '2026-03-01 00:00:00' + INTERVAL (g %% 600) MINUTE, "+
|
|
"'cat-' || (g %% %d), 'v-' || (g %% %d) FROM generate_series(%d, %d) t(g);",
|
|
namespace, table, categories, values, i*rowsPerBatch+1, (i+1)*rowsPerBatch))
|
|
}
|
|
|
|
before := env.duckdb(t, bucket, "write", strings.Join([]string{
|
|
fmt.Sprintf("CREATE SCHEMA cat.%s;", namespace),
|
|
// Partitioned, the way the table in the report was. Compaction bins
|
|
// per partition, so this is also what puts more than one bin in play.
|
|
fmt.Sprintf("CREATE TABLE cat.%s.%s(id int, ts timestamptz, category text, value text) PARTITIONED BY (month(ts));", namespace, table),
|
|
strings.Join(inserts, "\n"),
|
|
duckdbTallySQL(namespace, table),
|
|
}, "\n"))
|
|
assertSeeded(t, before)
|
|
|
|
env.maintainIcebergTable(t, bucket, namespace+"/"+table)
|
|
|
|
after := env.duckdb(t, bucket, "verify", duckdbTallySQL(namespace, table))
|
|
assertSameData(t, before, after)
|
|
|
|
env.duckdb(t, bucket, "drop", fmt.Sprintf("DROP TABLE cat.%s.%s;\nDROP SCHEMA cat.%s;", namespace, table, namespace))
|
|
if env.entryExists(t, fmt.Sprintf("/buckets/%s/%s/%s/", bucket, namespace, table)) {
|
|
t.Fatal("the dropped table's data is still on disk")
|
|
}
|
|
}
|
|
|
|
func testIcebergLifecycleWithPyIceberg(t *testing.T) {
|
|
env := shared
|
|
bucket := "lifecycle-pyiceberg-" + randomSuffix()
|
|
namespace, table := "sales", "events"
|
|
env.createTableBucket(t, bucket, "ICEBERG")
|
|
buildClientImage(t, pyicebergImage, "Dockerfile.pyiceberg")
|
|
testutil.RequireImageRunnable(t,
|
|
"--entrypoint", "python3", pyicebergImage,
|
|
"-c", "import pyarrow; from pyiceberg.catalog import load_catalog")
|
|
|
|
before := env.pyiceberg(t, "write", bucket, namespace, table)
|
|
assertSeeded(t, before)
|
|
|
|
env.maintainIcebergTable(t, bucket, namespace+"/"+table)
|
|
|
|
after := env.pyiceberg(t, "verify", bucket, namespace, table)
|
|
assertSameData(t, before, after)
|
|
|
|
env.pyiceberg(t, "drop", bucket, namespace, table)
|
|
if env.entryExists(t, fmt.Sprintf("/buckets/%s/%s/%s/", bucket, namespace, table)) {
|
|
t.Fatal("the dropped table's data is still on disk")
|
|
}
|
|
}
|
|
|
|
// maintainIcebergTable runs the worker's whole maintenance cycle against the
|
|
// live filer, in the order a scheduled worker would: merge the small files,
|
|
// drop the snapshots that referenced them, sweep what nothing references any
|
|
// more, then fold the manifests together.
|
|
func (env *environment) maintainIcebergTable(t *testing.T, bucket, tablePath string) {
|
|
t.Helper()
|
|
|
|
client := env.filerClient(t)
|
|
handler := iceberg.NewHandler(nil)
|
|
config := iceberg.Config{
|
|
TargetFileSizeBytes: 256 << 20,
|
|
MinInputFiles: 2,
|
|
MaxCommitRetries: 3,
|
|
SnapshotRetentionMs: 1,
|
|
MaxSnapshotsToKeep: 1,
|
|
OrphanOlderThanHours: 1,
|
|
}
|
|
|
|
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute)
|
|
defer cancel()
|
|
|
|
operations := []struct {
|
|
name string
|
|
run func() (string, map[string]int64, error)
|
|
}{
|
|
{"compact", func() (string, map[string]int64, error) {
|
|
return handler.CompactDataFiles(ctx, client, bucket, tablePath, config)
|
|
}},
|
|
{"expire", func() (string, map[string]int64, error) {
|
|
return handler.ExpireSnapshots(ctx, client, bucket, tablePath, config)
|
|
}},
|
|
{"orphans", func() (string, map[string]int64, error) {
|
|
return handler.RemoveOrphans(ctx, client, bucket, tablePath, config)
|
|
}},
|
|
{"manifests", func() (string, map[string]int64, error) {
|
|
return handler.RewriteManifests(ctx, client, bucket, tablePath, config)
|
|
}},
|
|
}
|
|
for _, operation := range operations {
|
|
result, metrics, err := operation.run()
|
|
if err != nil {
|
|
t.Fatalf("%s: %v", operation.name, err)
|
|
}
|
|
t.Logf("%s: %s %v", operation.name, result, metrics)
|
|
|
|
// A compaction that merged nothing leaves the read below checking a
|
|
// file the worker never wrote, which proves nothing at all.
|
|
if operation.name == "compact" && metrics[iceberg.MetricFilesMerged] < batches {
|
|
t.Fatalf("compaction merged %d files, want all %d written by the client: %s",
|
|
metrics[iceberg.MetricFilesMerged], batches, result)
|
|
}
|
|
}
|
|
}
|
|
|
|
// duckdb runs a script against the catalog and returns whatever tally it
|
|
// printed. The prelude is the reporter's own ATTACH, SigV4 and all.
|
|
func (env *environment) duckdb(t *testing.T, bucket, phase, body string) tally {
|
|
t.Helper()
|
|
|
|
script := fmt.Sprintf(`INSTALL iceberg;
|
|
LOAD iceberg;
|
|
CREATE SECRET s3_secret (TYPE S3, KEY_ID '%s', SECRET '%s', ENDPOINT 'host.docker.internal:%d', URL_STYLE 'path', USE_SSL false);
|
|
ATTACH 's3://%s' AS cat (TYPE ICEBERG, ENDPOINT '%s', AUTHORIZATION_TYPE SigV4, SECRET 's3_secret', SIGV4_SERVICE 's3', SIGV4_REGION 'us-east-1', ACCESS_DELEGATION_MODE 'none', READ_ONLY false);
|
|
%s
|
|
`, accessKey, secretKey, env.s3Port, bucket, env.containerURL(env.icebergPort), body)
|
|
|
|
name := fmt.Sprintf("duckdb-%s-%s.sql", bucket, phase)
|
|
if err := os.WriteFile(filepath.Join(env.dataDir, name), []byte(script), 0644); err != nil {
|
|
t.Fatalf("write the DuckDB script: %v", err)
|
|
}
|
|
|
|
ctx, cancel := context.WithTimeout(context.Background(), clientTimeout)
|
|
defer cancel()
|
|
|
|
cmd := exec.CommandContext(ctx, "docker", "run", "--rm",
|
|
"-v", fmt.Sprintf("%s:/test", env.dataDir),
|
|
"--add-host", "host.docker.internal:host-gateway",
|
|
"--entrypoint", "duckdb",
|
|
duckdbImage,
|
|
"-init", "/test/"+name,
|
|
"-c", "SELECT 1",
|
|
)
|
|
out, err := cmd.CombinedOutput()
|
|
t.Logf("DuckDB %s:\n%s", phase, out)
|
|
if err != nil {
|
|
if phase == firstPhase && lacksIcebergExtension(string(out)) {
|
|
t.Skipf("this DuckDB image has no iceberg extension: %v", err)
|
|
}
|
|
t.Fatalf("DuckDB %s: %v", phase, err)
|
|
}
|
|
if !strings.Contains(body, "TALLY") {
|
|
return tally{}
|
|
}
|
|
return parseDuckDBTally(t, string(out))
|
|
}
|
|
|
|
func duckdbTallySQL(namespace, table string) string {
|
|
// The digest covers whole rows - every column, not just the two the
|
|
// cardinalities watch - because counting each column on its own passes a
|
|
// merge that keeps every column's cardinality and hands the values to the
|
|
// wrong rows. ts goes in as microseconds so the session's timezone cannot
|
|
// change how it renders between the two runs.
|
|
return fmt.Sprintf("SELECT 'TALLY ' || count(*) || ' ' || count(DISTINCT category) || ' ' || count(DISTINCT value)"+
|
|
" || ' ' || md5(string_agg(id || '|' || epoch_us(ts) || '|' || category || '|' || value, chr(10) ORDER BY id)) AS marker"+
|
|
" FROM cat.%s.%s;",
|
|
namespace, table)
|
|
}
|
|
|
|
var duckdbTallyPattern = regexp.MustCompile(`TALLY (\d+) (\d+) (\d+) ([0-9a-f]{32})`)
|
|
|
|
func parseDuckDBTally(t *testing.T, out string) tally {
|
|
t.Helper()
|
|
|
|
fields := duckdbTallyPattern.FindStringSubmatch(out)
|
|
if fields == nil {
|
|
t.Fatalf("DuckDB printed no tally:\n%s", out)
|
|
}
|
|
number := func(s string) int {
|
|
value, err := strconv.Atoi(s)
|
|
if err != nil {
|
|
t.Fatalf("parse %q: %v", s, err)
|
|
}
|
|
return value
|
|
}
|
|
return tally{
|
|
Rows: number(fields[1]),
|
|
Categories: number(fields[2]),
|
|
Values: number(fields[3]),
|
|
Digest: fields[4],
|
|
}
|
|
}
|
|
|
|
// lacksIcebergExtension reports whether DuckDB never got as far as this
|
|
// repository's code, which is the only failure worth skipping over.
|
|
//
|
|
// The extension ships separately from the image, so an environment that cannot
|
|
// fetch it has nothing to say about compaction. Everything past LOAD - a parse
|
|
// error on the ATTACH, a refusal from the catalog, a bad read - is ours, and
|
|
// has to fail: this is the one test covering the PLAIN_DICTIONARY encoding, and
|
|
// a skip nobody reads is how the corruption it was written for shipped.
|
|
func lacksIcebergExtension(out string) bool {
|
|
for _, marker := range []string{
|
|
"iceberg extension is not available",
|
|
"Failed to download extension",
|
|
`Extension "iceberg" not found`,
|
|
"Unknown extension",
|
|
} {
|
|
if strings.Contains(out, marker) {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func (env *environment) pyiceberg(t *testing.T, phase, bucket, namespace, table string) tally {
|
|
t.Helper()
|
|
|
|
out := env.runClient(t, pyicebergImage, phase, "/app/iceberg_lifecycle.py",
|
|
"--catalog-url", env.containerURL(env.icebergPort),
|
|
"--s3-endpoint", env.containerURL(env.s3Port),
|
|
"--bucket", bucket,
|
|
"--namespace", namespace,
|
|
"--table", table,
|
|
)
|
|
if phase == "drop" {
|
|
return tally{}
|
|
}
|
|
return decodeTally(t, out)
|
|
}
|
|
|
|
// runClient runs one phase of a python client and returns its stdout.
|
|
func (env *environment) runClient(t *testing.T, image, phase, script string, args ...string) string {
|
|
t.Helper()
|
|
|
|
ctx, cancel := context.WithTimeout(context.Background(), clientTimeout)
|
|
defer cancel()
|
|
|
|
run := []string{"run", "--rm",
|
|
"--add-host", "host.docker.internal:host-gateway",
|
|
"-e", "AWS_ACCESS_KEY_ID=" + accessKey,
|
|
"-e", "AWS_SECRET_ACCESS_KEY=" + secretKey,
|
|
"-e", "AWS_REGION=us-east-1",
|
|
"-e", "AWS_ALLOW_HTTP=true",
|
|
image, "python3", script, "--phase", phase,
|
|
"--access-key", accessKey, "--secret-key", secretKey,
|
|
}
|
|
cmd := exec.CommandContext(ctx, "docker", append(run, args...)...)
|
|
var stderr strings.Builder
|
|
cmd.Stderr = &stderr
|
|
out, err := cmd.Output()
|
|
if stderr.Len() > 0 {
|
|
t.Logf("%s %s (stderr):\n%s", image, phase, stderr.String())
|
|
}
|
|
if err != nil {
|
|
t.Fatalf("%s %s: %v\n%s", image, phase, err, out)
|
|
}
|
|
t.Logf("%s %s: %s", image, phase, strings.TrimSpace(string(out)))
|
|
return string(out)
|
|
}
|
|
|
|
func decodeTally(t *testing.T, out string) tally {
|
|
t.Helper()
|
|
|
|
// The clients print the tally last; anything a library logged before it
|
|
// is not JSON and not ours.
|
|
lines := strings.Split(strings.TrimSpace(out), "\n")
|
|
var decoded tally
|
|
if err := json.Unmarshal([]byte(lines[len(lines)-1]), &decoded); err != nil {
|
|
t.Fatalf("decode the tally from %q: %v", out, err)
|
|
}
|
|
return decoded
|
|
}
|
|
|
|
func buildClientImage(t *testing.T, image, dockerfile string) {
|
|
t.Helper()
|
|
|
|
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Minute)
|
|
defer cancel()
|
|
|
|
cmd := exec.CommandContext(ctx, "docker", "build", "-t", image, "-f", dockerfile, ".")
|
|
cmd.Dir = shared.testDir
|
|
if out, err := cmd.CombinedOutput(); err != nil {
|
|
t.Fatalf("build %s: %v\n%s", image, err, out)
|
|
}
|
|
}
|
|
|
|
// assertSeeded checks the client wrote what this suite assumes it wrote. Every
|
|
// comparison below is against these numbers, so a client that quietly seeded
|
|
// one row would otherwise make the whole test vacuous.
|
|
func assertSeeded(t *testing.T, seeded tally) {
|
|
t.Helper()
|
|
|
|
if want := rowsPerBatch * batches; seeded.Rows != want {
|
|
t.Fatalf("seeded %d rows, want %d", seeded.Rows, want)
|
|
}
|
|
if seeded.Categories != categories || seeded.Values != values {
|
|
t.Fatalf("seeded %d categories and %d values, want %d and %d",
|
|
seeded.Categories, seeded.Values, categories, values)
|
|
}
|
|
}
|
|
|
|
// assertSameData is the test. Maintenance rewrites files; it must not change a
|
|
// single row, and the cardinalities are called out separately because that is
|
|
// the shape the failure took: a dictionary column collapsed onto one entry,
|
|
// read back without an error, and answered wrongly.
|
|
func assertSameData(t *testing.T, before, after tally) {
|
|
t.Helper()
|
|
|
|
if after.Rows != before.Rows {
|
|
t.Errorf("maintenance changed the row count: %d -> %d", before.Rows, after.Rows)
|
|
}
|
|
if after.Categories != before.Categories {
|
|
t.Errorf("maintenance collapsed the category column: %d distinct values -> %d",
|
|
before.Categories, after.Categories)
|
|
}
|
|
if after.Values != before.Values {
|
|
t.Errorf("maintenance collapsed the value column: %d distinct values -> %d",
|
|
before.Values, after.Values)
|
|
}
|
|
if after.Digest != before.Digest {
|
|
t.Errorf("maintenance changed the rows: digest %s -> %s", before.Digest, after.Digest)
|
|
}
|
|
}
|