snapshot of https://github.com/seaweedfs/seaweedfs.git (master, 2026-09-16; blobs > 131072B omitted)
Some checks failed
go: build dev binaries / cleanup (push) Successful in 4s
Codespell / Check for spelling errors (push) Failing after 3s
docker: build dev containers / build-rust-binaries (amd64, x86_64-unknown-linux-musl) (push) Failing after 3s
docker: build dev containers / build-dev-containers (push) Has been skipped
docker: build dev containers / build-rust-binaries (arm64, true, aarch64-unknown-linux-musl) (push) Failing after 3s
End to End / FUSE Mount (push) Failing after 2s
EC Integration Tests / EC Integration Tests (push) Failing after 30s
FUSE DLM Integration Tests / FUSE DLM Integration Tests (push) Failing after 3s
FUSE Volume Server Failover Tests / FUSE Volume Server Failover (push) Failing after 3s
FUSE Integration Tests / FUSE Integration Testing (push) Failing after 2s
FUSE P2P Peer Chunk Sharing Integration Tests / FUSE P2P Peer Chunk Sharing (push) Failing after 3s
go: build binary / Go Vet (push) Failing after 2s
go: build binary / Go Vet 32-bit (push) Failing after 3s
go: build binary / Build (push) Failing after 4s
go: build binary / Build cross-platform (freebsd/amd64) (push) Failing after 3s
go: build binary / Build cross-platform (windows/amd64) (push) Failing after 3s
go: build binary / Build cross-platform (darwin/arm64) (push) Failing after 3s
go: build binary / Build cross-platform (windows/arm64) (push) Failing after 3s
go: build binary / Test (push) Failing after 2s
go: build binary / Test 32-bit (push) Failing after 3s
helm: lint and test charts / lint-test (push) Failing after 3s
Java Client Integration Tests / Java Integration Tests (11) (push) Failing after 3s
Java Client Integration Tests / Java Integration Tests (17) (push) Failing after 3s
go: build dev binaries / build_dev_linux_windows (amd64, linux) (push) Failing after 2s
go: build dev binaries / build_dev_linux_windows (amd64, windows) (push) Failing after 3s
go: build dev binaries / build_dev_darwin (amd64, darwin) (push) Has been skipped
go: build dev binaries / build_dev_darwin (arm64, darwin) (push) Has been skipped
Java Client Unit Tests / Java Unit Tests (11) (push) Failing after 3s
Java Client Unit Tests / Java Unit Tests (17) (push) Failing after 2s
Java Client Unit Tests / Java Unit Tests (21) (push) Failing after 3s
Java Client Unit Tests / Java Unit Tests (8) (push) Failing after 3s
Kafka Quick Test (Load Test with Schema Registry) / Kafka Client Load Test (Quick) (push) Failing after 3s
Kafka Gateway Tests / Kafka Unit Tests (unit-tests-1) (push) Failing after 11s
Kafka Gateway Tests / Kafka Integration Tests (Critical) (integration-1) (push) Failing after 3s
Kafka Gateway Tests / Kafka End-to-End Tests (with SMQ) (e2e-1) (push) Failing after 3s
Kafka Gateway Tests / Kafka Consumer Group Tests (Highly Isolated) (consumer-group-1) (push) Failing after 2s
Kafka Gateway Tests / Kafka Client Compatibility (with SMQ) (client-compat-1) (push) Failing after 3s
Kafka Gateway Tests / Kafka SMQ Integration Tests (Full Stack) (smq-integration-1) (push) Failing after 3s
Kafka Gateway Tests / Kafka Protocol Tests (Isolated) (protocol-1) (push) Failing after 3s
KMS Tests / KMS Provider Integration Tests (push) Failing after 2s
KMS Tests / S3 KMS End-to-End Tests (push) Failing after 3s
Master Cold Start Tests / Master Cold Start Tests (push) Failing after 4s
Metadata Subscribe Integration Tests / Metadata Subscribe Integration Tests (push) Failing after 3s
Multi-Master Tests / Multi-Master Failover Tests (push) Failing after 3s
Performance / CPU and Heap Profile (push) Failing after 2s
Performance / S3 Read/Write Benchmark (rust volume) (push) Failing after 3s
pjdfstest POSIX Compliance / pjdfstest (push) Failing after 2s
Plugin Worker Integration Tests / Plugin Worker: erasure_coding (push) Failing after 3s
Plugin Worker Integration Tests / Plugin Worker: vacuum (push) Failing after 3s
Rust Volume Server Tests / Rust Integration Tests (push) Failing after 3s
Rust Volume Server Tests / Go Tests with Rust Volume (grpc - Shard 1) (push) Failing after 2s
Rust Volume Server Tests / Go Tests with Rust Volume (http - Shard 1) (push) Failing after 3s
Rust Volume Server Tests / Go Tests with Rust Volume (grpc - Shard 2) (push) Failing after 3s
Rust Volume Server Tests / Go Tests with Rust Volume (http - Shard 2) (push) Failing after 3s
Rust Volume Server Tests / Go Tests with Rust Volume (grpc - Shard 3) (push) Failing after 3s
S3 IAM Integration Tests / S3 IAM Distributed Tests (push) Failing after 2s
S3 IAM Integration Tests / S3 IAM Performance Tests (push) Failing after 3s
Performance / Throughput Benchmark (go volume) (push) Failing after 3s
Performance / Throughput Benchmark (rust volume) (push) Failing after 2s
Performance / S3 Read/Write Benchmark (go volume) (push) Failing after 3s
Plugin Worker Integration Tests / Plugin Worker: volume_balance (push) Failing after 3s
PostgreSQL Gateway Tests / PostgreSQL Basic Tests (push) Failing after 3s
Rust Volume Server Tests / Rust Unit Tests (push) Failing after 2s
Rust Volume Server Tests / Go Tests with Rust Volume (http - Shard 3) (push) Failing after 3s
Rust Plugin Worker Tests / Rust Plugin Worker Build and Unit Tests (push) Failing after 3s
rust: build dev volume server binaries / cleanup (push) Successful in 3s
S3 ETag and ACL Tests / S3 ETag + ACL Integration Tests (push) Failing after 3s
S3 IAM Integration Tests / IAM Unit Tests (push) Failing after 3s
S3 IAM Integration Tests / S3 IAM Integration Tests (advanced) (push) Failing after 3s
S3 IAM Integration Tests / S3 IAM Integration Tests (basic) (push) Failing after 2s
S3 IAM Integration Tests / S3 IAM Integration Tests (group) (push) Failing after 3s
S3 IAM Integration Tests / S3 IAM Integration Tests (policy-enforcement) (push) Failing after 2s
S3 IAM Integration Tests / S3 IAM Integration Tests (sts) (push) Failing after 4s
S3 Keycloak Integration Tests / S3 Keycloak Integration Tests (push) Failing after 3s
S3 PyArrow Parquet Tests / PyArrow Parquet Tests (Python 3.11) (push) Failing after 3s
S3 PyArrow Parquet Tests / Go Unit Tests (Implicit Directory) (push) Has been cancelled
S3 PyArrow Parquet Tests / PyArrow Parquet Tests (Python 3.12) (push) Has been cancelled
rust: build dev volume server binaries / build-rust-volume-dev-darwin (darwin-amd64, x86_64-apple-darwin) (push) Has been cancelled
rust: build dev volume server binaries / build-rust-volume-dev-darwin (darwin-arm64, aarch64-apple-darwin) (push) Has been cancelled
rust: build dev volume server binaries / build-rust-volume-dev-linux (linux-amd64, x86_64-unknown-linux-gnu) (push) Has been cancelled
S3 PyArrow Parquet Tests / PyArrow Parquet Tests (Python 3.9) (push) Has been cancelled
S3 Proxy Signature Tests / S3 Proxy Signature Verification Tests (push) Has been cancelled
S3 SDK V2 Route Disambiguation Tests / S3 SDK V2 Routing Tests (push) Has been cancelled
S3 SSE Tests / S3 SSE Integration Tests (quick) (push) Has been cancelled
S3 SSE Tests / S3 SSE Compatibility Test (push) Has been cancelled
S3 SSE Tests / S3 SSE Metadata Persistence Test (push) Has been cancelled
S3 SSE Tests / S3 SSE Copy Operations Test (push) Has been cancelled
S3 SSE Tests / S3 SSE Multipart Upload Test (push) Has been cancelled
S3 SSE Tests / S3 SSE Performance Test (push) Has been cancelled
S3 SSE Tests / S3 Volume Encryption Test (push) Has been cancelled
S3 SSE Tests / S3 SSE Integration Tests (comprehensive) (push) Has been cancelled
Spark Integration Tests / Spark Integration Tests (push) Failing after 2s
Telemetry Integration Tests / telemetry-integration-test (push) Failing after 3s
Volume Server Integration Tests / Volume Server Integration Tests (grpc - Shard 1) (push) Failing after 3s
Volume Server Integration Tests / Volume Server Integration Tests (http - Shard 1) (push) Failing after 3s
Ceph S3 tests / Basic S3 tests (KV store) (push) Has been cancelled
Ceph S3 tests / S3 Versioning & Object Lock tests (push) Has been cancelled
S3 Policy Integration Tests / S3 Policy Unit Tests (push) Failing after 3s
Ceph S3 tests / S3 CORS tests (push) Has been cancelled
Ceph S3 tests / SeaweedFS Custom S3 Copy tests (push) Has been cancelled
Ceph S3 tests / Basic S3 tests (SQL store) (push) Has been cancelled
test s3 over https using aws-cli / awscli-tests (push) Has been cancelled
TLS Rotation Integration Tests / TLS Rotation Integration Tests (push) Has been cancelled
Vacuum Integration Tests / Vacuum Integration Tests (push) Has been cancelled
S3 Policy Integration Tests / S3 Policy Variables Integration Tests (push) Failing after 3s
S3 Policy Integration Tests / S3 Policy Enforcement Integration Tests (advanced-policy) (push) Failing after 3s
S3 Policy Integration Tests / S3 Policy Enforcement Integration Tests (basic-policy) (push) Failing after 2s
S3 Policy Integration Tests / S3 Policy Enforcement Integration Tests (contextual-policy) (push) Failing after 4s
S3 Policy Integration Tests / Trusted Proxy Detection Tests (push) Failing after 2s
S3 Policy Integration Tests / S3 Policy Shell Integration Tests (push) Failing after 3s
Samba on FUSE Integration / samba-integration (push) Failing after 2s
SFTP Integration Tests / SFTP Integration Testing (push) Failing after 3s
terraform: validate and test modules / fmt, validate, plan-level tests (push) Failing after 3s
terraform: validate and test modules / local cluster smoke test (real weed) (push) Failing after 2s
Volume Server Integration Tests / Volume Server Integration Tests (grpc - Shard 3) (push) Failing after 3s
Volume Server Integration Tests / Volume Server Integration Tests (grpc - Shard 2) (push) Failing after 3s
Volume Server Integration Tests / Volume Server Integration Tests (http - Shard 2) (push) Failing after 2s
Volume Server Integration Tests / Volume Server Integration Tests (http - Shard 3) (push) Failing after 5s
mount: windows conformance / WinFsp conformance (push) Has been cancelled
mount: windows / Mount on Windows (push) Has been cancelled
Some checks failed
go: build dev binaries / cleanup (push) Successful in 4s
Codespell / Check for spelling errors (push) Failing after 3s
docker: build dev containers / build-rust-binaries (amd64, x86_64-unknown-linux-musl) (push) Failing after 3s
docker: build dev containers / build-dev-containers (push) Has been skipped
docker: build dev containers / build-rust-binaries (arm64, true, aarch64-unknown-linux-musl) (push) Failing after 3s
End to End / FUSE Mount (push) Failing after 2s
EC Integration Tests / EC Integration Tests (push) Failing after 30s
FUSE DLM Integration Tests / FUSE DLM Integration Tests (push) Failing after 3s
FUSE Volume Server Failover Tests / FUSE Volume Server Failover (push) Failing after 3s
FUSE Integration Tests / FUSE Integration Testing (push) Failing after 2s
FUSE P2P Peer Chunk Sharing Integration Tests / FUSE P2P Peer Chunk Sharing (push) Failing after 3s
go: build binary / Go Vet (push) Failing after 2s
go: build binary / Go Vet 32-bit (push) Failing after 3s
go: build binary / Build (push) Failing after 4s
go: build binary / Build cross-platform (freebsd/amd64) (push) Failing after 3s
go: build binary / Build cross-platform (windows/amd64) (push) Failing after 3s
go: build binary / Build cross-platform (darwin/arm64) (push) Failing after 3s
go: build binary / Build cross-platform (windows/arm64) (push) Failing after 3s
go: build binary / Test (push) Failing after 2s
go: build binary / Test 32-bit (push) Failing after 3s
helm: lint and test charts / lint-test (push) Failing after 3s
Java Client Integration Tests / Java Integration Tests (11) (push) Failing after 3s
Java Client Integration Tests / Java Integration Tests (17) (push) Failing after 3s
go: build dev binaries / build_dev_linux_windows (amd64, linux) (push) Failing after 2s
go: build dev binaries / build_dev_linux_windows (amd64, windows) (push) Failing after 3s
go: build dev binaries / build_dev_darwin (amd64, darwin) (push) Has been skipped
go: build dev binaries / build_dev_darwin (arm64, darwin) (push) Has been skipped
Java Client Unit Tests / Java Unit Tests (11) (push) Failing after 3s
Java Client Unit Tests / Java Unit Tests (17) (push) Failing after 2s
Java Client Unit Tests / Java Unit Tests (21) (push) Failing after 3s
Java Client Unit Tests / Java Unit Tests (8) (push) Failing after 3s
Kafka Quick Test (Load Test with Schema Registry) / Kafka Client Load Test (Quick) (push) Failing after 3s
Kafka Gateway Tests / Kafka Unit Tests (unit-tests-1) (push) Failing after 11s
Kafka Gateway Tests / Kafka Integration Tests (Critical) (integration-1) (push) Failing after 3s
Kafka Gateway Tests / Kafka End-to-End Tests (with SMQ) (e2e-1) (push) Failing after 3s
Kafka Gateway Tests / Kafka Consumer Group Tests (Highly Isolated) (consumer-group-1) (push) Failing after 2s
Kafka Gateway Tests / Kafka Client Compatibility (with SMQ) (client-compat-1) (push) Failing after 3s
Kafka Gateway Tests / Kafka SMQ Integration Tests (Full Stack) (smq-integration-1) (push) Failing after 3s
Kafka Gateway Tests / Kafka Protocol Tests (Isolated) (protocol-1) (push) Failing after 3s
KMS Tests / KMS Provider Integration Tests (push) Failing after 2s
KMS Tests / S3 KMS End-to-End Tests (push) Failing after 3s
Master Cold Start Tests / Master Cold Start Tests (push) Failing after 4s
Metadata Subscribe Integration Tests / Metadata Subscribe Integration Tests (push) Failing after 3s
Multi-Master Tests / Multi-Master Failover Tests (push) Failing after 3s
Performance / CPU and Heap Profile (push) Failing after 2s
Performance / S3 Read/Write Benchmark (rust volume) (push) Failing after 3s
pjdfstest POSIX Compliance / pjdfstest (push) Failing after 2s
Plugin Worker Integration Tests / Plugin Worker: erasure_coding (push) Failing after 3s
Plugin Worker Integration Tests / Plugin Worker: vacuum (push) Failing after 3s
Rust Volume Server Tests / Rust Integration Tests (push) Failing after 3s
Rust Volume Server Tests / Go Tests with Rust Volume (grpc - Shard 1) (push) Failing after 2s
Rust Volume Server Tests / Go Tests with Rust Volume (http - Shard 1) (push) Failing after 3s
Rust Volume Server Tests / Go Tests with Rust Volume (grpc - Shard 2) (push) Failing after 3s
Rust Volume Server Tests / Go Tests with Rust Volume (http - Shard 2) (push) Failing after 3s
Rust Volume Server Tests / Go Tests with Rust Volume (grpc - Shard 3) (push) Failing after 3s
S3 IAM Integration Tests / S3 IAM Distributed Tests (push) Failing after 2s
S3 IAM Integration Tests / S3 IAM Performance Tests (push) Failing after 3s
Performance / Throughput Benchmark (go volume) (push) Failing after 3s
Performance / Throughput Benchmark (rust volume) (push) Failing after 2s
Performance / S3 Read/Write Benchmark (go volume) (push) Failing after 3s
Plugin Worker Integration Tests / Plugin Worker: volume_balance (push) Failing after 3s
PostgreSQL Gateway Tests / PostgreSQL Basic Tests (push) Failing after 3s
Rust Volume Server Tests / Rust Unit Tests (push) Failing after 2s
Rust Volume Server Tests / Go Tests with Rust Volume (http - Shard 3) (push) Failing after 3s
Rust Plugin Worker Tests / Rust Plugin Worker Build and Unit Tests (push) Failing after 3s
rust: build dev volume server binaries / cleanup (push) Successful in 3s
S3 ETag and ACL Tests / S3 ETag + ACL Integration Tests (push) Failing after 3s
S3 IAM Integration Tests / IAM Unit Tests (push) Failing after 3s
S3 IAM Integration Tests / S3 IAM Integration Tests (advanced) (push) Failing after 3s
S3 IAM Integration Tests / S3 IAM Integration Tests (basic) (push) Failing after 2s
S3 IAM Integration Tests / S3 IAM Integration Tests (group) (push) Failing after 3s
S3 IAM Integration Tests / S3 IAM Integration Tests (policy-enforcement) (push) Failing after 2s
S3 IAM Integration Tests / S3 IAM Integration Tests (sts) (push) Failing after 4s
S3 Keycloak Integration Tests / S3 Keycloak Integration Tests (push) Failing after 3s
S3 PyArrow Parquet Tests / PyArrow Parquet Tests (Python 3.11) (push) Failing after 3s
S3 PyArrow Parquet Tests / Go Unit Tests (Implicit Directory) (push) Has been cancelled
S3 PyArrow Parquet Tests / PyArrow Parquet Tests (Python 3.12) (push) Has been cancelled
rust: build dev volume server binaries / build-rust-volume-dev-darwin (darwin-amd64, x86_64-apple-darwin) (push) Has been cancelled
rust: build dev volume server binaries / build-rust-volume-dev-darwin (darwin-arm64, aarch64-apple-darwin) (push) Has been cancelled
rust: build dev volume server binaries / build-rust-volume-dev-linux (linux-amd64, x86_64-unknown-linux-gnu) (push) Has been cancelled
S3 PyArrow Parquet Tests / PyArrow Parquet Tests (Python 3.9) (push) Has been cancelled
S3 Proxy Signature Tests / S3 Proxy Signature Verification Tests (push) Has been cancelled
S3 SDK V2 Route Disambiguation Tests / S3 SDK V2 Routing Tests (push) Has been cancelled
S3 SSE Tests / S3 SSE Integration Tests (quick) (push) Has been cancelled
S3 SSE Tests / S3 SSE Compatibility Test (push) Has been cancelled
S3 SSE Tests / S3 SSE Metadata Persistence Test (push) Has been cancelled
S3 SSE Tests / S3 SSE Copy Operations Test (push) Has been cancelled
S3 SSE Tests / S3 SSE Multipart Upload Test (push) Has been cancelled
S3 SSE Tests / S3 SSE Performance Test (push) Has been cancelled
S3 SSE Tests / S3 Volume Encryption Test (push) Has been cancelled
S3 SSE Tests / S3 SSE Integration Tests (comprehensive) (push) Has been cancelled
Spark Integration Tests / Spark Integration Tests (push) Failing after 2s
Telemetry Integration Tests / telemetry-integration-test (push) Failing after 3s
Volume Server Integration Tests / Volume Server Integration Tests (grpc - Shard 1) (push) Failing after 3s
Volume Server Integration Tests / Volume Server Integration Tests (http - Shard 1) (push) Failing after 3s
Ceph S3 tests / Basic S3 tests (KV store) (push) Has been cancelled
Ceph S3 tests / S3 Versioning & Object Lock tests (push) Has been cancelled
S3 Policy Integration Tests / S3 Policy Unit Tests (push) Failing after 3s
Ceph S3 tests / S3 CORS tests (push) Has been cancelled
Ceph S3 tests / SeaweedFS Custom S3 Copy tests (push) Has been cancelled
Ceph S3 tests / Basic S3 tests (SQL store) (push) Has been cancelled
test s3 over https using aws-cli / awscli-tests (push) Has been cancelled
TLS Rotation Integration Tests / TLS Rotation Integration Tests (push) Has been cancelled
Vacuum Integration Tests / Vacuum Integration Tests (push) Has been cancelled
S3 Policy Integration Tests / S3 Policy Variables Integration Tests (push) Failing after 3s
S3 Policy Integration Tests / S3 Policy Enforcement Integration Tests (advanced-policy) (push) Failing after 3s
S3 Policy Integration Tests / S3 Policy Enforcement Integration Tests (basic-policy) (push) Failing after 2s
S3 Policy Integration Tests / S3 Policy Enforcement Integration Tests (contextual-policy) (push) Failing after 4s
S3 Policy Integration Tests / Trusted Proxy Detection Tests (push) Failing after 2s
S3 Policy Integration Tests / S3 Policy Shell Integration Tests (push) Failing after 3s
Samba on FUSE Integration / samba-integration (push) Failing after 2s
SFTP Integration Tests / SFTP Integration Testing (push) Failing after 3s
terraform: validate and test modules / fmt, validate, plan-level tests (push) Failing after 3s
terraform: validate and test modules / local cluster smoke test (real weed) (push) Failing after 2s
Volume Server Integration Tests / Volume Server Integration Tests (grpc - Shard 3) (push) Failing after 3s
Volume Server Integration Tests / Volume Server Integration Tests (grpc - Shard 2) (push) Failing after 3s
Volume Server Integration Tests / Volume Server Integration Tests (http - Shard 2) (push) Failing after 2s
Volume Server Integration Tests / Volume Server Integration Tests (http - Shard 3) (push) Failing after 5s
mount: windows conformance / WinFsp conformance (push) Has been cancelled
mount: windows / Mount on Windows (push) Has been cancelled
This commit is contained in:
17
test/s3tables/lifecycle/Dockerfile.lance
Normal file
17
test/s3tables/lifecycle/Dockerfile.lance
Normal file
@@ -0,0 +1,17 @@
|
||||
# Lance client for the Lance half of the lifecycle test.
|
||||
#
|
||||
# Pinned to the versions this suite was verified against, the way
|
||||
# catalog_lancedb pins its client: an unrelated upstream release should not be
|
||||
# able to change what an old commit reproduces.
|
||||
FROM python:3.11-slim
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
RUN pip install --no-cache-dir \
|
||||
"lance-namespace==0.8.6" \
|
||||
"pylance==10.0.0" \
|
||||
"pyarrow==25.0.1"
|
||||
|
||||
COPY lance_lifecycle.py /app/
|
||||
|
||||
CMD ["python3", "/app/lance_lifecycle.py", "--help"]
|
||||
10
test/s3tables/lifecycle/Dockerfile.pyiceberg
Normal file
10
test/s3tables/lifecycle/Dockerfile.pyiceberg
Normal file
@@ -0,0 +1,10 @@
|
||||
# PyIceberg client for the Iceberg half of the lifecycle test.
|
||||
FROM python:3.11-slim
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
RUN pip install --no-cache-dir "pyiceberg[s3fs]" pyarrow
|
||||
|
||||
COPY iceberg_lifecycle.py /app/
|
||||
|
||||
CMD ["python3", "/app/iceberg_lifecycle.py", "--help"]
|
||||
77
test/s3tables/lifecycle/README.md
Normal file
77
test/s3tables/lifecycle/README.md
Normal file
@@ -0,0 +1,77 @@
|
||||
# Table Lifecycle Integration Tests
|
||||
|
||||
One table, all the way through: created in the catalog, filled by a real client,
|
||||
maintained, read again, dropped. Once for Iceberg and once for Lance. The
|
||||
Iceberg half always maintains through the worker; the Lance half maintains
|
||||
through the Rust worker or through the lance library, depending on what the
|
||||
environment has - see below.
|
||||
|
||||
## Why this suite exists
|
||||
|
||||
[#10853](https://github.com/seaweedfs/seaweedfs/issues/10853) was a compaction
|
||||
that rewrote every dictionary-encoded column onto a single value. It shipped.
|
||||
The maintenance tests we had were thorough about the bookkeeping - sequence
|
||||
numbers, added and deleted manifest entries, metadata versions, the manifest
|
||||
list - and every one of them passed, because not one of them opened the parquet
|
||||
file the worker had just written.
|
||||
|
||||
So the assertion here is the dull one nothing else was making: tally the table
|
||||
before maintenance, tally it again after, and require the two to be equal. The
|
||||
tally is a row count, the cardinality of each dictionary-encoded column, and an
|
||||
md5 over whole rows. The cardinalities name the failure that happened; the
|
||||
digest catches a rewrite that keeps every column's cardinality and hands the
|
||||
values to the wrong rows.
|
||||
|
||||
The same shape covers Lance, because the exposure is the same: a compaction that
|
||||
merges fragments can hand back a table that reads without complaint and answers
|
||||
wrongly.
|
||||
|
||||
## What runs
|
||||
|
||||
`TestIcebergTableLifecycle` starts a `weed mini` cluster, declares an `ICEBERG`
|
||||
table bucket, and runs two clients against it:
|
||||
|
||||
| Client | Why both |
|
||||
| --- | --- |
|
||||
| DuckDB | the client the bug was reported against, and the only one here that writes the deprecated `PLAIN_DICTIONARY` encoding - parquet-go normalizes it away on write, so a Go writer cannot produce it |
|
||||
| PyIceberg | writes `RLE_DICTIONARY`, the modern spelling, so between the two the merge is checked against both dictionary encodings in the spec |
|
||||
|
||||
Between the write and the read, the test runs the worker's whole maintenance
|
||||
cycle in-process against the live filer: compact, expire snapshots, remove
|
||||
orphans, rewrite manifests. A compaction that merged nothing fails the test
|
||||
rather than passing it - otherwise the read afterwards is checking a file the
|
||||
worker never wrote.
|
||||
|
||||
`TestLanceTableLifecycle` does the same against a `LANCE` bucket: declare
|
||||
through the namespace, write a fragment per append, maintain, read, drop.
|
||||
Maintenance goes through the Rust worker's own handlers where cargo is
|
||||
installed, and through the two lance calls those handlers wrap where it is not.
|
||||
`WEED_LANCE_MAINTENANCE=library|worker` picks one instead of letting the test
|
||||
guess; CI sets `library`, because a cold build of the lance crate costs more
|
||||
than the layer it would be checking.
|
||||
|
||||
Both tests finish by dropping the table and checking the data actually left the
|
||||
filer, which is the half of a lifecycle a catalog test never reaches.
|
||||
|
||||
## Running it
|
||||
|
||||
cd test/s3tables/lifecycle
|
||||
(cd ../../../weed && go build .) # the harness runs this binary
|
||||
go test -v -timeout 40m .
|
||||
|
||||
Skipped without Docker and in `-short` mode. The first run builds the two client
|
||||
images and pulls `duckdb/duckdb:latest`; later runs reuse them. The DuckDB half
|
||||
skips itself, rather than failing, on an image whose iceberg extension cannot
|
||||
write through a REST catalog.
|
||||
|
||||
To watch it catch the bug it was written for, pin parquet-go back to the version
|
||||
that had it:
|
||||
|
||||
go mod edit -require=github.com/parquet-go/parquet-go@v0.30.1 && go mod tidy
|
||||
go test -run TestIcebergTableLifecycle/DuckDB -v .
|
||||
|
||||
maintenance collapsed the category column: 7 distinct values -> 1
|
||||
maintenance collapsed the value column: 13 distinct values -> 1
|
||||
|
||||
The PyIceberg half still passes there, which is the reason both clients are in
|
||||
this directory.
|
||||
277
test/s3tables/lifecycle/cluster_test.go
Normal file
277
test/s3tables/lifecycle/cluster_test.go
Normal file
@@ -0,0 +1,277 @@
|
||||
// Package lifecycle takes a table through everything that happens to it: made
|
||||
// through the catalog, filled by a real client, maintained by the worker, read
|
||||
// again, and dropped.
|
||||
//
|
||||
// The step that matters is the read after maintenance. A compaction once
|
||||
// rewrote every dictionary-encoded column onto a single value and went out in a
|
||||
// release, because the tests we had checked the bookkeeping - sequence numbers,
|
||||
// manifest entries, metadata versions - and none of them opened the file the
|
||||
// worker had just written. A tally taken before maintenance and the same tally
|
||||
// taken after is the whole idea, and it is the same idea for both formats:
|
||||
// Iceberg compaction merges parquet files, Lance compaction merges fragments,
|
||||
// and either can hand back a table that reads without complaint and answers
|
||||
// wrongly.
|
||||
package lifecycle
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"flag"
|
||||
"fmt"
|
||||
"io"
|
||||
"math/rand"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"google.golang.org/grpc"
|
||||
"google.golang.org/grpc/credentials/insecure"
|
||||
|
||||
"github.com/seaweedfs/seaweedfs/test/testutil"
|
||||
"github.com/seaweedfs/seaweedfs/weed/pb/filer_pb"
|
||||
)
|
||||
|
||||
const (
|
||||
// The keys the issue's own recipe uses. weed mini turns them into the
|
||||
// admin identity, which is what the clients then sign with.
|
||||
accessKey = "AKIAIOSFODNN7EXAMPLE"
|
||||
secretKey = "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY"
|
||||
|
||||
startupTimeout = 60 * time.Second
|
||||
clientTimeout = 20 * time.Minute
|
||||
)
|
||||
|
||||
// shared is the one cluster both formats run against. They use separate table
|
||||
// buckets, and a bucket holds one format only.
|
||||
var shared *environment
|
||||
|
||||
// errNoBinary is the one setup failure worth skipping over.
|
||||
var errNoBinary = errors.New("weed binary not found")
|
||||
|
||||
func TestMain(m *testing.M) {
|
||||
flag.Parse()
|
||||
if testing.Short() {
|
||||
os.Exit(m.Run())
|
||||
}
|
||||
|
||||
// A checkout without a weed binary cannot run this and says so. Anything
|
||||
// else - ports, a cluster that will not come up - is a failure, because a
|
||||
// suite that turns its own breakage into a green run is the thing this
|
||||
// directory exists to stop.
|
||||
env, err := newEnvironment()
|
||||
if errors.Is(err, errNoBinary) {
|
||||
fmt.Fprintf(os.Stderr, "SKIP: %v\n", err)
|
||||
os.Exit(m.Run())
|
||||
}
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "FAIL: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
if err := env.start(); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "FAIL: weed mini did not start: %v\n", err)
|
||||
env.cleanup()
|
||||
os.Exit(1)
|
||||
}
|
||||
shared = env
|
||||
|
||||
code := m.Run()
|
||||
shared.cleanup()
|
||||
os.Exit(code)
|
||||
}
|
||||
|
||||
type environment struct {
|
||||
weedBinary string
|
||||
rootDir string
|
||||
testDir string
|
||||
dataDir string
|
||||
|
||||
masterPort int
|
||||
masterGrpcPort int
|
||||
volumePort int
|
||||
volumeGrpcPort int
|
||||
filerPort int
|
||||
filerGrpcPort int
|
||||
s3Port int
|
||||
s3GrpcPort int
|
||||
icebergPort int
|
||||
lancePort int
|
||||
|
||||
weedCancel context.CancelFunc
|
||||
weedCmd *exec.Cmd
|
||||
}
|
||||
|
||||
func newEnvironment() (*environment, error) {
|
||||
wd, err := os.Getwd()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("get working directory: %w", err)
|
||||
}
|
||||
seaweedDir := wd
|
||||
for i := 0; i < 5; i++ {
|
||||
if _, err := os.Stat(filepath.Join(seaweedDir, "go.mod")); err == nil {
|
||||
break
|
||||
}
|
||||
seaweedDir = filepath.Dir(seaweedDir)
|
||||
}
|
||||
|
||||
weedBinary := filepath.Join(seaweedDir, "weed", "weed")
|
||||
if info, statErr := os.Stat(weedBinary); statErr == nil && !info.IsDir() {
|
||||
// A plain `go test` will otherwise drive a binary from days ago and
|
||||
// report a pass for code it never ran.
|
||||
fmt.Fprintf(os.Stderr, "using %s, built %s\n", weedBinary, info.ModTime().Format(time.RFC3339))
|
||||
} else {
|
||||
weedBinary = "weed"
|
||||
if _, err := exec.LookPath(weedBinary); err != nil {
|
||||
return nil, errNoBinary
|
||||
}
|
||||
}
|
||||
|
||||
dataDir, err := os.MkdirTemp("", "seaweed-lifecycle-*")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("create temp dir: %w", err)
|
||||
}
|
||||
|
||||
ports, err := testutil.AllocatePorts(10)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("allocate ports: %w", err)
|
||||
}
|
||||
return &environment{
|
||||
weedBinary: weedBinary,
|
||||
rootDir: seaweedDir,
|
||||
testDir: wd,
|
||||
dataDir: dataDir,
|
||||
masterPort: ports[0],
|
||||
masterGrpcPort: ports[1],
|
||||
volumePort: ports[2],
|
||||
volumeGrpcPort: ports[3],
|
||||
filerPort: ports[4],
|
||||
filerGrpcPort: ports[5],
|
||||
s3Port: ports[6],
|
||||
s3GrpcPort: ports[7],
|
||||
icebergPort: ports[8],
|
||||
lancePort: ports[9],
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (env *environment) start() error {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
env.weedCancel = cancel
|
||||
|
||||
cmd := exec.CommandContext(ctx, env.weedBinary, "mini",
|
||||
"-master.port", fmt.Sprintf("%d", env.masterPort),
|
||||
"-master.port.grpc", fmt.Sprintf("%d", env.masterGrpcPort),
|
||||
"-volume.port", fmt.Sprintf("%d", env.volumePort),
|
||||
"-volume.port.grpc", fmt.Sprintf("%d", env.volumeGrpcPort),
|
||||
"-filer.port", fmt.Sprintf("%d", env.filerPort),
|
||||
"-filer.port.grpc", fmt.Sprintf("%d", env.filerGrpcPort),
|
||||
"-s3.port", fmt.Sprintf("%d", env.s3Port),
|
||||
"-s3.port.grpc", fmt.Sprintf("%d", env.s3GrpcPort),
|
||||
"-s3.port.iceberg", fmt.Sprintf("%d", env.icebergPort),
|
||||
"-s3.port.lance", fmt.Sprintf("%d", env.lancePort),
|
||||
"-ip.bind", "0.0.0.0",
|
||||
"-dir", env.dataDir,
|
||||
)
|
||||
cmd.Dir = env.dataDir
|
||||
cmd.Stdout = os.Stdout
|
||||
cmd.Stderr = os.Stderr
|
||||
// mini makes its admin identity from these, so they are the keys the
|
||||
// clients sign with rather than a separate IAM file to keep in step.
|
||||
cmd.Env = append(os.Environ(),
|
||||
"AWS_ACCESS_KEY_ID="+accessKey,
|
||||
"AWS_SECRET_ACCESS_KEY="+secretKey,
|
||||
)
|
||||
|
||||
if err := cmd.Start(); err != nil {
|
||||
cancel()
|
||||
return err
|
||||
}
|
||||
env.weedCmd = cmd
|
||||
|
||||
if !testutil.WaitForService(env.catalogURL()+"/v1/config", startupTimeout) {
|
||||
cancel()
|
||||
return fmt.Errorf("the Iceberg catalog never answered on port %d", env.icebergPort)
|
||||
}
|
||||
if !testutil.WaitForPort(env.lancePort, startupTimeout) {
|
||||
cancel()
|
||||
return fmt.Errorf("the Lance namespace never answered on port %d", env.lancePort)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (env *environment) cleanup() {
|
||||
if env.weedCancel != nil {
|
||||
env.weedCancel()
|
||||
}
|
||||
if env.weedCmd != nil {
|
||||
_ = env.weedCmd.Wait()
|
||||
}
|
||||
if env.dataDir != "" {
|
||||
_ = os.RemoveAll(env.dataDir)
|
||||
}
|
||||
}
|
||||
|
||||
func (env *environment) catalogURL() string {
|
||||
return fmt.Sprintf("http://127.0.0.1:%d", env.icebergPort)
|
||||
}
|
||||
|
||||
// A container reaches the same gateway by another name than the host does.
|
||||
func (env *environment) containerURL(port int) string {
|
||||
return fmt.Sprintf("http://host.docker.internal:%d", port)
|
||||
}
|
||||
|
||||
// createTableBucket makes a bucket declared for one format, so the catalog
|
||||
// refuses tables of any other kind in it.
|
||||
func (env *environment) createTableBucket(t *testing.T, bucket, format string) {
|
||||
t.Helper()
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
|
||||
defer cancel()
|
||||
|
||||
cmd := exec.CommandContext(ctx, env.weedBinary, "shell",
|
||||
fmt.Sprintf("-master=127.0.0.1:%d.%d", env.masterPort, env.masterGrpcPort),
|
||||
)
|
||||
cmd.Stdin = strings.NewReader(fmt.Sprintf(
|
||||
"s3tables.bucket -create -name %s -format %s -account 000000000000\nexit\n", bucket, format))
|
||||
if out, err := cmd.CombinedOutput(); err != nil {
|
||||
t.Fatalf("create the %s table bucket %s: %v\n%s", format, bucket, err, out)
|
||||
}
|
||||
}
|
||||
|
||||
// filerClient dials the filer the maintenance handlers talk to.
|
||||
func (env *environment) filerClient(t *testing.T) filer_pb.SeaweedFilerClient {
|
||||
t.Helper()
|
||||
|
||||
conn, err := grpc.NewClient(fmt.Sprintf("127.0.0.1:%d", env.filerGrpcPort),
|
||||
grpc.WithTransportCredentials(insecure.NewCredentials()))
|
||||
if err != nil {
|
||||
t.Fatalf("dial the filer: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { conn.Close() })
|
||||
return filer_pb.NewSeaweedFilerClient(conn)
|
||||
}
|
||||
|
||||
// entryExists tells "the catalog forgot the table" from "the data is gone".
|
||||
func (env *environment) entryExists(t *testing.T, path string) bool {
|
||||
t.Helper()
|
||||
|
||||
client := &http.Client{Timeout: 30 * time.Second}
|
||||
resp, err := client.Get(fmt.Sprintf("http://127.0.0.1:%d%s", env.filerPort, path))
|
||||
if err != nil {
|
||||
t.Fatalf("filer GET %s: %v", path, err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
io.Copy(io.Discard, resp.Body)
|
||||
return resp.StatusCode == http.StatusOK
|
||||
}
|
||||
|
||||
func randomSuffix() string {
|
||||
const charset = "abcdefghijklmnopqrstuvwxyz0123456789"
|
||||
suffix := make([]byte, 8)
|
||||
for i := range suffix {
|
||||
suffix[i] = charset[rand.Intn(len(charset))]
|
||||
}
|
||||
return string(suffix)
|
||||
}
|
||||
164
test/s3tables/lifecycle/iceberg_lifecycle.py
Normal file
164
test/s3tables/lifecycle/iceberg_lifecycle.py
Normal file
@@ -0,0 +1,164 @@
|
||||
#!/usr/bin/env python3
|
||||
"""PyIceberg half of the table lifecycle, run one phase per invocation.
|
||||
|
||||
The Go test calls this three times - write, verify, drop - and runs the
|
||||
maintenance worker between the first two. Splitting it that way is the whole
|
||||
point: a tally taken before compaction and the same tally taken after are the
|
||||
only thing that catches a merge that rewrote every dictionary-encoded column
|
||||
onto a single value and said nothing.
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import hashlib
|
||||
import json
|
||||
import sys
|
||||
import time
|
||||
|
||||
import pyarrow as pa
|
||||
from pyiceberg.catalog import load_catalog
|
||||
from pyiceberg.exceptions import NamespaceAlreadyExistsError, NoSuchTableError
|
||||
from pyiceberg.schema import Schema
|
||||
from pyiceberg.types import LongType, NestedField, StringType, TimestamptzType
|
||||
|
||||
# Few enough distinct values in the two string columns that any writer worth
|
||||
# the name dictionary-encodes them, which is the encoding that broke.
|
||||
CATEGORIES = 7
|
||||
VALUES = 13
|
||||
ROWS_PER_BATCH = 4000
|
||||
BATCHES = 3
|
||||
|
||||
SCHEMA = Schema(
|
||||
NestedField(1, "id", LongType(), required=True),
|
||||
NestedField(2, "category", StringType(), required=True),
|
||||
NestedField(3, "value", StringType(), required=True),
|
||||
NestedField(4, "ts", TimestamptzType(), required=True),
|
||||
)
|
||||
|
||||
ARROW_SCHEMA = pa.schema(
|
||||
[
|
||||
pa.field("id", pa.int64(), nullable=False),
|
||||
pa.field("category", pa.string(), nullable=False),
|
||||
pa.field("value", pa.string(), nullable=False),
|
||||
pa.field("ts", pa.timestamp("us", tz="UTC"), nullable=False),
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
def batch(start, count):
|
||||
ids = list(range(start, start + count))
|
||||
return pa.Table.from_pydict(
|
||||
{
|
||||
"id": ids,
|
||||
"category": [f"cat-{i % CATEGORIES}" for i in ids],
|
||||
"value": [f"v-{i % VALUES}" for i in ids],
|
||||
# An hour apart, so the rows spread over months the way a real
|
||||
# table's do without needing a partition spec to prove it.
|
||||
"ts": [1772323200000000 + i * 3600000000 for i in ids],
|
||||
},
|
||||
schema=ARROW_SCHEMA,
|
||||
)
|
||||
|
||||
|
||||
def tally(table):
|
||||
"""Row count, per-column cardinality, and a digest of every row.
|
||||
|
||||
The cardinalities catch a column collapsed onto one dictionary entry; the
|
||||
digest catches everything else, including a merge that keeps the right
|
||||
number of distinct values while handing them to the wrong rows. Every
|
||||
column goes into it, not just the two the cardinalities watch - compaction
|
||||
rewrites the whole row. ts goes in as microseconds so no timezone sits
|
||||
between the two runs.
|
||||
"""
|
||||
scanned = table.scan().to_arrow()
|
||||
categories = scanned.column("category").to_pylist()
|
||||
values = scanned.column("value").to_pylist()
|
||||
rows = [
|
||||
f"{i}|{t}|{c}|{v}"
|
||||
for i, t, c, v in zip(
|
||||
scanned.column("id").to_pylist(),
|
||||
scanned.column("ts").cast(pa.int64()).to_pylist(),
|
||||
categories,
|
||||
values,
|
||||
strict=True,
|
||||
)
|
||||
]
|
||||
digest = hashlib.md5(
|
||||
"\n".join(sorted(rows)).encode(), usedforsecurity=False
|
||||
).hexdigest()
|
||||
return {
|
||||
"rows": scanned.num_rows,
|
||||
"categories": len(set(categories)),
|
||||
"values": len(set(values)),
|
||||
"digest": digest,
|
||||
}
|
||||
|
||||
|
||||
def connect(args):
|
||||
properties = {
|
||||
"type": "rest",
|
||||
"uri": args.catalog_url,
|
||||
"warehouse": f"s3://{args.bucket}/",
|
||||
"prefix": args.bucket,
|
||||
"auth": {"type": "noop"},
|
||||
"s3.endpoint": args.s3_endpoint,
|
||||
"s3.access-key-id": args.access_key,
|
||||
"s3.secret-access-key": args.secret_key,
|
||||
"s3.region": "us-east-1",
|
||||
"s3.path-style-access": "true",
|
||||
}
|
||||
last = None
|
||||
for attempt in range(10):
|
||||
try:
|
||||
return load_catalog("rest", **properties)
|
||||
except Exception as err: # the gateway may still be coming up
|
||||
last = err
|
||||
print(f"connect attempt {attempt + 1} failed: {err}", file=sys.stderr)
|
||||
time.sleep(2)
|
||||
raise last
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--phase", required=True, choices=["write", "verify", "drop"])
|
||||
parser.add_argument("--catalog-url", required=True)
|
||||
parser.add_argument("--s3-endpoint", required=True)
|
||||
parser.add_argument("--bucket", required=True)
|
||||
parser.add_argument("--namespace", required=True)
|
||||
parser.add_argument("--table", required=True)
|
||||
parser.add_argument("--access-key", required=True)
|
||||
parser.add_argument("--secret-key", required=True)
|
||||
args = parser.parse_args()
|
||||
|
||||
catalog = connect(args)
|
||||
identifier = f"{args.namespace}.{args.table}"
|
||||
|
||||
if args.phase == "write":
|
||||
try:
|
||||
catalog.create_namespace(args.namespace)
|
||||
except NamespaceAlreadyExistsError:
|
||||
pass
|
||||
table = catalog.create_table(identifier, schema=SCHEMA)
|
||||
# One append per batch, so compaction has several files to merge
|
||||
# rather than one it would leave alone.
|
||||
for i in range(BATCHES):
|
||||
table.append(batch(i * ROWS_PER_BATCH + 1, ROWS_PER_BATCH))
|
||||
table = catalog.load_table(identifier)
|
||||
print(json.dumps(tally(table)))
|
||||
return
|
||||
|
||||
if args.phase == "verify":
|
||||
print(json.dumps(tally(catalog.load_table(identifier))))
|
||||
return
|
||||
|
||||
catalog.drop_table(identifier)
|
||||
try:
|
||||
catalog.load_table(identifier)
|
||||
except NoSuchTableError:
|
||||
pass
|
||||
else:
|
||||
raise SystemExit("the table is still in the catalog after a drop")
|
||||
catalog.drop_namespace(args.namespace)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
389
test/s3tables/lifecycle/iceberg_lifecycle_test.go
Normal file
389
test/s3tables/lifecycle/iceberg_lifecycle_test.go
Normal file
@@ -0,0 +1,389 @@
|
||||
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
|
||||
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")
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
168
test/s3tables/lifecycle/lance_lifecycle.py
Normal file
168
test/s3tables/lifecycle/lance_lifecycle.py
Normal file
@@ -0,0 +1,168 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Lance half of the table lifecycle, run one phase per invocation.
|
||||
|
||||
The Go test calls this for each step and maintains the table in between, so the
|
||||
tally taken before maintenance and the tally taken after are directly
|
||||
comparable. That comparison is the test: the Iceberg side of this suite exists
|
||||
because compaction once rewrote a table's dictionary columns onto a single
|
||||
value and every check we had still passed, and a Lance dataset is rewritten by
|
||||
the same kind of job.
|
||||
|
||||
The maintain phase is a fallback. When the Rust worker can be built, the Go test
|
||||
runs its handlers against this table instead and skips this phase - what runs
|
||||
here are the same two lance calls the handlers make.
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import hashlib
|
||||
import json
|
||||
import sys
|
||||
import warnings
|
||||
from datetime import timedelta
|
||||
|
||||
warnings.filterwarnings("ignore")
|
||||
|
||||
import lance
|
||||
import lance_namespace as ln
|
||||
import pyarrow as pa
|
||||
|
||||
# Low enough cardinality that these two columns are dictionary-encoded.
|
||||
CATEGORIES = 7
|
||||
VALUES = 13
|
||||
ROWS_PER_BATCH = 4000
|
||||
BATCHES = 3
|
||||
DIM = 8
|
||||
|
||||
|
||||
def rows(start, count):
|
||||
ids = list(range(start, start + count))
|
||||
return pa.table(
|
||||
{
|
||||
"id": pa.array(ids, type=pa.int64()),
|
||||
"category": pa.array([f"cat-{i % CATEGORIES}" for i in ids]),
|
||||
"value": pa.array([f"v-{i % VALUES}" for i in ids]),
|
||||
"vector": pa.array(
|
||||
[[float(i % 97) + d for d in range(DIM)] for i in ids],
|
||||
type=pa.list_(pa.float32(), DIM),
|
||||
),
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def tally(dataset):
|
||||
"""What the table holds, in a form two runs can be compared by.
|
||||
|
||||
The cardinalities catch a column collapsed onto one value; the digest
|
||||
catches a rewrite that keeps the values and moves them to the wrong rows.
|
||||
Every column goes into the digest, the vectors included - compaction
|
||||
rewrites whole fragments, so leaving a column out leaves a place for it to
|
||||
go wrong unnoticed. Fragments come along because a compaction that merged
|
||||
nothing would otherwise let this test pass without having tested anything.
|
||||
"""
|
||||
scanned = dataset.to_table(columns=["id", "category", "value", "vector"])
|
||||
ids = scanned.column("id").to_pylist()
|
||||
categories = scanned.column("category").to_pylist()
|
||||
values = scanned.column("value").to_pylist()
|
||||
vectors = scanned.column("vector").to_pylist()
|
||||
serialized = (
|
||||
f"{i}|{c}|{v}|{w}"
|
||||
for i, c, v, w in zip(ids, categories, values, vectors, strict=True)
|
||||
)
|
||||
digest = hashlib.md5(
|
||||
"\n".join(sorted(serialized)).encode(), usedforsecurity=False
|
||||
).hexdigest()
|
||||
return {
|
||||
"rows": scanned.num_rows,
|
||||
"categories": len(set(categories)),
|
||||
"values": len(set(values)),
|
||||
"digest": digest,
|
||||
"fragments": len(dataset.get_fragments()),
|
||||
"version": dataset.version,
|
||||
}
|
||||
|
||||
|
||||
def resolve(args):
|
||||
"""Ask the namespace where the table lives, the way the worker does."""
|
||||
namespace = ln.connect("rest", {"uri": args.namespace_url})
|
||||
table_id = [args.bucket, args.namespace, args.table]
|
||||
described = namespace.describe_table(ln.DescribeTableRequest(id=table_id))
|
||||
return namespace, table_id, described.location, storage_options(args, described)
|
||||
|
||||
|
||||
def storage_options(args, described=None):
|
||||
# The namespace vends an endpoint correct for its own host; this container
|
||||
# reaches the same gateway by another name. Credentials are filled in
|
||||
# because a deployment without STS vends none.
|
||||
options = dict((described.storage_options or {}) if described else {})
|
||||
options["aws_endpoint"] = args.s3_endpoint
|
||||
options["allow_http"] = "true"
|
||||
options.setdefault("aws_access_key_id", args.access_key)
|
||||
options.setdefault("aws_secret_access_key", args.secret_key)
|
||||
options.setdefault("aws_region", "us-east-1")
|
||||
return options
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--phase", required=True, choices=["write", "maintain", "verify", "drop"])
|
||||
parser.add_argument("--namespace-url", required=True)
|
||||
parser.add_argument("--s3-endpoint", required=True)
|
||||
parser.add_argument("--bucket", required=True)
|
||||
parser.add_argument("--namespace", required=True)
|
||||
parser.add_argument("--table", required=True)
|
||||
parser.add_argument("--access-key", default="any")
|
||||
parser.add_argument("--secret-key", default="any")
|
||||
args = parser.parse_args()
|
||||
|
||||
if args.phase == "write":
|
||||
namespace = ln.connect("rest", {"uri": args.namespace_url})
|
||||
for parent in ([args.bucket], [args.bucket, args.namespace]):
|
||||
namespace.create_namespace(ln.CreateNamespaceRequest(id=parent, mode="EXIST_OK"))
|
||||
table_id = [args.bucket, args.namespace, args.table]
|
||||
declared = namespace.declare_table(ln.DeclareTableRequest(id=table_id))
|
||||
options = storage_options(args)
|
||||
# A fragment per append, so the compaction that follows has something
|
||||
# to merge.
|
||||
for i in range(BATCHES):
|
||||
lance.write_dataset(
|
||||
rows(i * ROWS_PER_BATCH + 1, ROWS_PER_BATCH),
|
||||
declared.location,
|
||||
storage_options=options,
|
||||
mode="overwrite" if i == 0 else "append",
|
||||
)
|
||||
print(json.dumps(tally(lance.dataset(declared.location, storage_options=options))))
|
||||
return 0
|
||||
|
||||
if args.phase == "maintain":
|
||||
_, _, location, options = resolve(args)
|
||||
dataset = lance.dataset(location, storage_options=options)
|
||||
dataset.optimize.compact_files()
|
||||
dataset = lance.dataset(location, storage_options=options)
|
||||
dataset.cleanup_old_versions(older_than=timedelta(seconds=0), delete_unverified=True)
|
||||
print(json.dumps(tally(lance.dataset(location, storage_options=options))))
|
||||
return 0
|
||||
|
||||
if args.phase == "verify":
|
||||
_, _, location, options = resolve(args)
|
||||
print(json.dumps(tally(lance.dataset(location, storage_options=options))))
|
||||
return 0
|
||||
|
||||
namespace, table_id, location, options = resolve(args)
|
||||
namespace.drop_table(ln.DropTableRequest(id=table_id))
|
||||
try:
|
||||
lance.dataset(location, storage_options=options)
|
||||
except ValueError as err:
|
||||
# pylance turns every load failure into a ValueError, so the message is
|
||||
# the only thing separating a dataset that is gone from credentials
|
||||
# that stopped working halfway through the test.
|
||||
if "was not found" in str(err):
|
||||
return 0
|
||||
print(f"FAIL: reading the dropped dataset failed for another reason: {err}",
|
||||
file=sys.stderr)
|
||||
return 1
|
||||
print("FAIL: the dataset is still readable after a drop", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
130
test/s3tables/lifecycle/lance_lifecycle_test.go
Normal file
130
test/s3tables/lifecycle/lance_lifecycle_test.go
Normal file
@@ -0,0 +1,130 @@
|
||||
package lifecycle
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/seaweedfs/seaweedfs/test/testutil"
|
||||
)
|
||||
|
||||
const lanceImage = "seaweedfs-lifecycle-lance"
|
||||
|
||||
// TestLanceTableLifecycle is the Iceberg test's counterpart. A Lance table is
|
||||
// declared through the namespace, written a fragment at a time, compacted, its
|
||||
// superseded versions dropped, and read again - and the read has to answer
|
||||
// exactly what the write put there. Compaction rewrites fragments the way
|
||||
// Iceberg compaction rewrites parquet files, and the failure that suite exists
|
||||
// for is the kind that reads back without an error and answers wrongly.
|
||||
func TestLanceTableLifecycle(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")
|
||||
}
|
||||
|
||||
env := shared
|
||||
bucket := "lifecycle-lance-" + randomSuffix()
|
||||
namespace, table := "ml", "events"
|
||||
env.createTableBucket(t, bucket, "LANCE")
|
||||
buildClientImage(t, lanceImage, "Dockerfile.lance")
|
||||
|
||||
before := env.lance(t, "write", bucket, namespace, table)
|
||||
assertSeeded(t, before)
|
||||
if before.Fragments != batches {
|
||||
t.Fatalf("the client wrote %d fragments, want one per append (%d)", before.Fragments, batches)
|
||||
}
|
||||
|
||||
env.maintainLanceTable(t, bucket, namespace, table)
|
||||
|
||||
after := env.lance(t, "verify", bucket, namespace, table)
|
||||
assertSameData(t, before, after)
|
||||
// Without this the comparison above would pass on a table nothing touched.
|
||||
if after.Fragments >= before.Fragments {
|
||||
t.Fatalf("maintenance merged nothing: %d fragments before, %d after",
|
||||
before.Fragments, after.Fragments)
|
||||
}
|
||||
|
||||
env.lance(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")
|
||||
}
|
||||
}
|
||||
|
||||
// maintainLanceTable compacts the table and drops what compaction superseded.
|
||||
//
|
||||
// Lance maintenance lives in the Rust worker, so where its toolchain is around
|
||||
// the handlers themselves do the work against this table. Where it is not, the
|
||||
// same two lance calls those handlers wrap run in the client container instead.
|
||||
// The lifecycle is checked either way; only the layer above the format changes.
|
||||
// WEED_LANCE_MAINTENANCE picks one - CI sets "library", because a cold build of
|
||||
// the lance crate costs more than the layer it is checking.
|
||||
func (env *environment) maintainLanceTable(t *testing.T, bucket, namespace, table string) {
|
||||
t.Helper()
|
||||
|
||||
if !maintainWithWorker(t) {
|
||||
env.lance(t, "maintain", bucket, namespace, table)
|
||||
return
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), clientTimeout)
|
||||
defer cancel()
|
||||
|
||||
cmd := exec.CommandContext(ctx, "cargo", "test",
|
||||
"-p", "weed-lance-worker", "--test", "lifecycle", "--", "--nocapture")
|
||||
cmd.Dir = filepath.Join(env.rootDir, "seaweed-worker")
|
||||
cmd.Env = append(cmd.Environ(),
|
||||
fmt.Sprintf("WEED_LANCE_NAMESPACE=http://127.0.0.1:%d", env.lancePort),
|
||||
fmt.Sprintf("WEED_LANCE_TABLE=%s$%s$%s", bucket, namespace, table),
|
||||
"AWS_ACCESS_KEY_ID="+accessKey,
|
||||
"AWS_SECRET_ACCESS_KEY="+secretKey,
|
||||
"AWS_REGION=us-east-1",
|
||||
fmt.Sprintf("AWS_ENDPOINT_URL=http://127.0.0.1:%d", env.s3Port),
|
||||
)
|
||||
out, err := cmd.CombinedOutput()
|
||||
t.Logf("lance worker:\n%s", out)
|
||||
if err != nil {
|
||||
t.Fatalf("the Lance maintenance worker failed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// maintainWithWorker says whether to maintain through the Rust worker.
|
||||
func maintainWithWorker(t *testing.T) bool {
|
||||
t.Helper()
|
||||
|
||||
switch os.Getenv("WEED_LANCE_MAINTENANCE") {
|
||||
case "library":
|
||||
t.Log("maintaining through the lance library, as WEED_LANCE_MAINTENANCE asks")
|
||||
return false
|
||||
case "worker":
|
||||
return true
|
||||
}
|
||||
if _, err := exec.LookPath("cargo"); err != nil {
|
||||
t.Log("cargo is not installed, maintaining through the lance library rather than the worker")
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func (env *environment) lance(t *testing.T, phase, bucket, namespace, table string) tally {
|
||||
t.Helper()
|
||||
|
||||
out := env.runClient(t, lanceImage, phase, "/app/lance_lifecycle.py",
|
||||
"--namespace-url", env.containerURL(env.lancePort),
|
||||
"--s3-endpoint", env.containerURL(env.s3Port),
|
||||
"--bucket", bucket,
|
||||
"--namespace", namespace,
|
||||
"--table", table,
|
||||
)
|
||||
if phase == "drop" {
|
||||
return tally{}
|
||||
}
|
||||
return decodeTally(t, out)
|
||||
}
|
||||
Reference in New Issue
Block a user