* iceberg: return 401 for invalid or expired Bearer tokens BUG-0001: when the OAuth JWT expired, Server.Auth fell through to the S3 SigV4 authenticator, which rejects the "Authorization: Bearer" scheme with NotImplemented — a 501. Iceberg clients (Java OAuth2Manager, pyiceberg) only refresh tokens on 401, so they retried the dead token forever: RisingWave sinks stalled and Doris catalog queries failed every token TTL (1h) until the client process was restarted. A request carrying a Bearer header is an Iceberg REST client: answer 401 (+ WWW-Authenticate: Bearer, RFC 6750) when the token fails, and only fall through to the S3 authenticator when no Bearer header is present. * iceberg: make OAuth token TTL configurable via ICEBERG_OAUTH_TOKEN_EXPIRY BUG-0001 follow-up: production evidence shows Iceberg Java 1.10.x clients (RisingWave connector node, Doris FE) never re-fetch tokens on 401 — the sink stalled again on token expiry even with the 501→401 fix, and no POST /v1/oauth/tokens appeared in server logs across dozens of retries. 401 is necessary but not sufficient for these clients. The TTL was hardcoded to 3600 with no knob. Read the expiry (seconds) from ICEBERG_OAUTH_TOKEN_EXPIRY, defaulting to 3600, so deployments can issue longer-lived tokens (e.g. 86400) to survive client restart cycles. * iceberg: support OAuth token exchange (RFC 8693) for client refresh Decompiling the Iceberg Java 1.10.1 client bundled with Doris FE showed the missing half of BUG-0001: OAuth2Manager refreshes via token-exchange (AuthConfig.exchangeEnabled defaults to true — the client_credentials re-fetch branch only runs with exchange disabled), so a server that only accepts client_credentials leaves Iceberg clients unable to ever refresh their token, regardless of 401 correctness. Accept grant_type=urn:ietf:params:oauth:grant-type:token-exchange on POST /v1/oauth/tokens: verify the subject_token signature against the issuing credential, allow exchange within a recovery grace window (max(2*TTL, 1h), capped 24h) so clients holding tokens that expired while the grant was unsupported recover without a restart, and mint a fresh access token with the configured TTL. * iceberg: harden OAuth token exchange and Bearer matching per review - match the Bearer scheme case-insensitively (RFC 7235), like authenticateBearer already does - accept optional client authentication on the token-exchange grant (Basic or form credentials, bound to the subject token's client); expired subject tokens now require it. Iceberg Java's proactive refresh sends Bearer-only headers, so the grant cannot require it - reject subject tokens without an exp claim, and re-check the issuer on the verified claims - unauthenticated exchange cannot extend the lifetime past the subject token's own expiry (no chain-refresh from a leaked token) - return 400 invalid_grant per RFC 6749 §5.2 (was 401) - include issued_token_type on exchange responses (RFC 8693) - clamp ICEBERG_OAUTH_TOKEN_EXPIRY to 365d so Duration math cannot overflow into already-expired tokens * iceberg: give authenticated token exchanges a fresh full TTL The remaining-lifetime cap only guards unauthenticated (Bearer-only) exchanges; an authenticated client renewing a live token must get the full configured TTL, matching client_credentials. * iceberg: reject token exchange when no lifetime remains A Bearer-only exchange with under a second of subject lifetime would mint a token with expires_in: 0. Reject with invalid_grant instead. * iceberg: pin near-expiry test token to the next second boundary jwt/v5 serializes exp at one-second precision, so a 300 ms offset can round into the current second and route the test through the expired branch instead of the ttlSeconds<=0 guard. Mint the subject with the next whole-second expiry: live at exchange time, deterministically under a second of remaining lifetime. * iceberg: drop internal ticket reference from comments * iceberg: clamp oversized OAuth TTLs on 32-bit platforms strconv.Atoi on an int-sized value fails with ErrRange on 386, so an oversized ICEBERG_OAUTH_TOKEN_EXPIRY silently fell back to the default instead of clamping. Parse in 64-bit space and clamp, then narrow. * iceberg: make OAuth TTL narrowing explicit * iceberg: disable legacy OAuth in PyIceberg integration tests
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 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.