This is an automated email from the ASF dual-hosted git repository.
jerryshao pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/gravitino.git
The following commit(s) were added to refs/heads/main by this push:
new b7ffc2e833 [MINOR] docs: Add Spark REST catalog registration design
(#11280)
b7ffc2e833 is described below
commit b7ffc2e8338bf0282705432dddbd964a80e089aa
Author: FANNG <[email protected]>
AuthorDate: Wed Aug 19 20:08:46 2026 +0800
[MINOR] docs: Add Spark REST catalog registration design (#11280)
### What changes were proposed in this pull request?
Add a design document for automatically registering Spark lakehouse REST
catalogs from Gravitino
REST servers:
`design-docs/spark-rest-catalog-registration.md`
V1 is scoped to Iceberg on Spark. The design covers:
- A server-authoritative catalog-listing endpoint, `GET
{iceberg-rest-base}/gravitino/v1/management/catalogs`,
returning catalog names only, so Spark never guesses names (they match
Gravitino names under
`dynamic-config-provider` but not under `static-config-provider`).
- A single, format-agnostic `GravitinoLakehouseRESTDiscoveryPlugin` that
lists catalogs and writes
`spark.sql.catalog.*` entries. Each lakehouse format plugs in as a
provider (Iceberg in V1, Lance
later) with its own engine runtime and `<format>Rest.*` config; a
provider activates only when its
`uri` is set, so the URI is the switch (no `enable*` flag) and only the
providers a user needs are
on the classpath.
- A single plugin-level `CatalogRegistrationPolicy` (with a `format`
argument) deciding which
catalogs to register and under what Spark name; the default keeps a 1:1
identity.
- Deterministic precedence: the discovery plugin must precede
`GravitinoSparkPlugin` (validated,
fail-fast) and user-written `spark.sql.catalog.*` always wins.
- Listing-call authentication limits (static `token`/`header.*` in V1)
and storage-credential
handling for both vended and non-vended catalogs.
- A Lance provider sketch reusing the same mechanism (future work, not
V1).
### Why are the changes needed?
Spark users must hand-write catalog configuration for every REST
catalog, duplicating what the
Gravitino REST server already manages and requiring an edit whenever
catalogs change. This design
removes that per-catalog configuration while keeping existing
`GravitinoSparkPlugin` behavior
unchanged when the discovery plugin is not added.
Fix: N/A
### Does this PR introduce _any_ user-facing change?
No. This is a design-document-only change. It proposes new configuration
keys for future
implementation, e.g. `spark.sql.gravitino.icebergREST.uri`,
`spark.sql.gravitino.icebergREST.catalogProperties.<key>`, and
`spark.sql.gravitino.REST.registrationPolicy`.
### How was this patch tested?
Documentation-only change.
---------
Co-authored-by: fanng <“[email protected]”>
---
design-docs/spark-rest-catalog-registration.md | 362 +++++++++++++++++++++++++
1 file changed, 362 insertions(+)
diff --git a/design-docs/spark-rest-catalog-registration.md
b/design-docs/spark-rest-catalog-registration.md
new file mode 100644
index 0000000000..1495c7e84f
--- /dev/null
+++ b/design-docs/spark-rest-catalog-registration.md
@@ -0,0 +1,362 @@
+<!--
+ Licensed to the Apache Software Foundation (ASF) under one
+ or more contributor license agreements. See the NOTICE file
+ distributed with this work for additional information
+ regarding copyright ownership. The ASF licenses this file
+ to you under the Apache License, Version 2.0 (the
+ "License"); you may not use this file except in compliance
+ with the License. You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+ Unless required by applicable law or agreed to in writing,
+ software distributed under the License is distributed on an
+ "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ KIND, either express or implied. See the License for the
+ specific language governing permissions and limitations
+ under the License.
+-->
+
+# Design: Spark Lakehouse REST Catalog Automatic Registration for Apache
Gravitino
+
+---
+
+## Background
+
+Connecting Spark to Gravitino takes little configuration:
+
+```text
+spark.plugins=org.apache.gravitino.spark.connector.plugin.GravitinoSparkPlugin
+spark.sql.gravitino.uri=http://127.0.0.1:8090
+spark.sql.gravitino.metalake=test
+```
+
+But accessing Iceberg tables through the Gravitino Iceberg REST server still
requires hand-written
+configuration per catalog, duplicating what the REST server already manages
and needing an edit
+whenever catalogs are added or removed:
+
+```text
+spark.sql.catalog.iceberg_prod=org.apache.iceberg.spark.SparkCatalog
+spark.sql.catalog.iceberg_prod.type=rest
+spark.sql.catalog.iceberg_prod.uri=http://127.0.0.1:9001/iceberg/
+spark.sql.catalog.iceberg_prod.warehouse=iceberg_prod
+```
+
+---
+
+## Goals
+
+1. **Automatic Iceberg registration**: A Spark session configured with only
the new plugin and the
+ Iceberg REST server URI registers one Spark Iceberg REST catalog per
catalog served by that
+ server, with no per-catalog configuration.
+2. **Server-authoritative catalog list**: The REST server tells Spark which
catalogs it serves, so
+ Spark never guesses catalog names.
+3. **User configuration always wins**: A catalog the user configured by hand
is never touched, and
+ this is enforced by mechanism rather than by convention.
+4. **Zero impact when disabled**: Users who do not add the new plugin see no
behavior change.
+
+---
+
+## Non-Goals
+
+1. **Engines beyond Spark**: Flink and Trino may reuse the listing endpoint
later.
+2. **Iceberg REST specification changes**: The listing endpoint is a
Gravitino-private extension.
+
+---
+
+## Proposal
+
+Two new pieces — a **catalog-listing endpoint** on the Iceberg REST server and
a single Spark
+plugin, `GravitinoLakehouseRESTDiscoveryPlugin`, that consumes it — plus one
**ordering rule** that
+makes the interaction with `GravitinoSparkPlugin` deterministic. Each
lakehouse format plugs into
+the plugin as a provider; V1 ships Iceberg.
+
+### Catalog-listing endpoint
+
+#### GET `{iceberg-rest-base}/gravitino/v1/management/catalogs`
+
+A Gravitino-private extension outside the Iceberg REST specification's `/v1/`
namespace (default
+deployment: `http://<host>:9001/iceberg/gravitino/v1/management/catalogs`).
`management` is the
+scope for further Gravitino management APIs on this server.
+
+**Request:** No parameters.
+
+**Response:** `200 OK`
+
+```json
+{
+ "catalogs": [
+ { "name": "iceberg_prod" },
+ { "name": "iceberg_audit" }
+ ]
+}
+```
+
+| Field | Type | Description |
+|-------|------|-------------|
+| `catalogs[].name` | string | Catalog name as accepted by this server's
`warehouse` parameter |
+
+The response only enumerates names: per-catalog client configuration already
arrives via
+`GET /v1/config?warehouse=<name>`. Because JSON objects extend compatibly,
per-catalog fields can be
+added later if an engine ever needs registration-time metadata (e.g. Trino's
vending flag), so V1
+does not speculatively define any.
+
+### GravitinoLakehouseRESTDiscoveryPlugin
+
+Once configured, this single plugin fetches the catalog list from each
configured REST server at
+Spark session startup and writes the corresponding `spark.sql.catalog.*`
entries, so users no longer
+hand-write them. Which catalogs are registered, and under what Spark name, is
decided by
+`CatalogRegistrationPolicy`.
+
+```text
+spark.plugins=org.apache.gravitino.spark.connector.plugin.GravitinoLakehouseRESTDiscoveryPlugin
+```
+
+The plugin is format-agnostic. Each lakehouse format is a **provider** —
Iceberg in V1, Lance later
+(see [Lance support](#lance-support)) — carrying its own engine runtime and
config prefix
+(`icebergREST.*`, `lanceREST.*`). A provider is active only when its `uri` is
set, so the URI
+doubles as the per-format switch and no `enable*` flag is needed; if a `uri`
is set but its provider
+is not on the classpath, the plugin fails fast. Dependency isolation is
preserved — a user who needs
+only Iceberg puts only the Iceberg provider on the classpath — while
`spark.plugins` lists one
+plugin and there is a single ordering rule.
+
+The registration policy and user-configuration precedence below are shared by
every provider; the
+listing client, generated entries, and credential handling are
provider-specific.
+
+#### Registration policy interface
+
+A single plugin-level policy, selected with
`spark.sql.gravitino.REST.registrationPolicy`, applies
+to every provider (the default implementation applies when unset). Each method
receives the `format`
+that advertised the catalog — the same token as the config prefix, `"iceberg"`
or `"lance"` — so one
+policy can still apply format-specific rules without a policy per format.
+
+```java
+/** Decides whether an advertised REST catalog is registered, and under what
Spark name. */
+@DeveloperApi
+public interface CatalogRegistrationPolicy {
+
+ /**
+ * Whether to register this catalog automatically as a Spark REST catalog.
+ *
+ * @param format the lakehouse format that advertised the catalog, e.g.
"iceberg" or "lance"
+ * @param catalogName a catalog name advertised by that format's REST
server; names already
+ * claimed by user configuration are filtered out by the plugin and
never reach this method
+ * @return true to register, false to skip
+ */
+ boolean shouldRegister(String format, String catalogName);
+
+ /**
+ * The Spark catalog name to register an accepted catalog under. Defaults to
the advertised name.
+ *
+ * @param format the lakehouse format that advertised the catalog
+ * @param catalogName the accepted catalog name
+ * @return the Spark catalog name
+ */
+ default String registeredCatalogName(String format, String catalogName) {
+ return catalogName;
+ }
+}
+```
+
+The default implementation registers every advertised catalog under its
advertised name, keeping a
+1:1 identity between the Spark catalog name and the REST server catalog name.
Deployments that need
+to register a subset, rename catalogs on the Spark side, or treat formats
differently implement the
+interface and point `registrationPolicy` at their class.
+
+#### User configuration precedence
+
+User configuration has the highest priority; the plugin fills in only what the
user left unset.
+Precedence for each registered catalog, high to low, enforced by the plugin so
no policy can weaken
+it:
+
+1. **User implementation key** `spark.sql.catalog.<name>`: the user owns that
name entirely — the
+ catalog is dropped before the policy runs and nothing is generated for it.
+2. **User per-catalog sub-key** (`spark.sql.catalog.<name>.<key>`): wins over
the generated value,
+ including the provider's core routing keys.
+3. **Plugin-generated keys**: the implementation class and the provider's core
routing keys.
+4. **Global `catalogProperties.<key>`**: copied in as a per-catalog default,
so it never overrides a
+ generated key — a stray `catalogProperties` core key cannot hijack routing.
+
+Startup still fails fast only for policy output that cannot be resolved: a
returned Spark name that
+duplicates another catalog's name, collides with a name the user already
configured, or is not a
+valid Spark identifier.
+
+#### Iceberg provider
+
+The provider issues the listing request with Iceberg's own `RESTClient`
+(`org.apache.iceberg.rest.HTTPClient`, the one `RESTCatalog` uses), not the
Gravitino client: the
+target is the Iceberg REST server, the Iceberg runtime is already on its
classpath, and it parses
+`ErrorResponse` the same way table calls do (authentication below). It uses the
+`spark.sql.gravitino.icebergREST.*` prefix, disjoint from the existing
plugin's keys:
+
+| Configuration | Required | Default | Description |
+|---------------|----------|---------|-------------|
+| `spark.sql.gravitino.icebergREST.uri` | Yes | None | Base URI of the Iceberg
REST server, e.g. `http://127.0.0.1:9001/iceberg/`; setting it activates the
Iceberg provider |
+| `spark.sql.gravitino.icebergREST.catalogProperties.<key>` | No | None |
Client properties copied into every generated catalog as
`spark.sql.catalog.<name>.<key>`, applied as defaults below the generated keys
(see precedence above), and passed to the listing client (static auth only, see
Authentication) |
+
+For each accepted catalog, with `<sparkName>` from `registeredCatalogName`, it
generates:
+
+```text
+spark.sql.catalog.<sparkName>=org.apache.iceberg.spark.SparkCatalog
+spark.sql.catalog.<sparkName>.type=rest
+spark.sql.catalog.<sparkName>.uri=<spark.sql.gravitino.icebergREST.uri>
+spark.sql.catalog.<sparkName>.warehouse=<advertisedName>
+```
+
+The four core keys are the implementation key, `type`, `uri`, and `warehouse`.
`warehouse` is always
+the advertised name, so a rename changes the Spark-facing name only and
routing stays
+server-authoritative; the plugin logs any non-identity mapping. It also injects
+`IcebergSparkSessionExtensions` into `spark.sql.extensions`, reusing the
existing deduplication so a
+manually configured extension is not added twice.
+
+**Authentication.** Secured deployments configure authentication once through
+`icebergREST.catalogProperties.<key>`, which the plugin copies into every
generated catalog, so no
+per-catalog authentication configuration is needed. The generated catalogs
support whatever the
+Iceberg `RESTCatalog` accepts, including the full OAuth2 client-credentials
flow.
+
+The listing call is the exception. A raw `RESTClient` does not run the
`RESTCatalog` auth
+lifecycle — `AuthManager` loading and `AuthSession` token exchange happen a
layer above it — so in
+V1 the listing call authenticates with static forms only (`token`,
`header.*`). OAuth2-only
+deployments must additionally supply a static token for listing (the generated
catalogs still use
+the full OAuth2 flow). Reproducing the `AuthManager`/`AuthSession` lifecycle,
with proper resource
+cleanup, so listing can share the catalogs' OAuth2 credentials is future work.
+
+**Storage credentials.** The provider generates no storage configuration; how
data-plane credentials
+reach Spark is intentionally left to the two existing Iceberg REST paths:
+
+- **Credential vending** (`data-access=vended-credentials` on the Gravitino
catalog): the load-table
+ response vends per-table credentials, and the client needs nothing
configured. This is the
+ zero-configuration path this design targets.
+- **No vending**: `GET /v1/config` returns no static access keys, so users
supply credentials on the
+ client side — through environment variables or the storage SDK's default
credential chain, or
+ per-catalog sub-keys such as `spark.sql.catalog.<name>.s3.access-key-id`.
+
+### Plugin ordering and precedence
+
+When both plugins are configured, `GravitinoLakehouseRESTDiscoveryPlugin`
**must be listed first**:
+
+```text
+spark.plugins=org.apache.gravitino.spark.connector.plugin.GravitinoLakehouseRESTDiscoveryPlugin,\
+ org.apache.gravitino.spark.connector.plugin.GravitinoSparkPlugin
+```
+
+The discovery plugin validates this at initialization and fails fast
otherwise. Initializing first
+is what lets it treat every `spark.sql.catalog.*` entry it sees as
user-written, with no markers and
+no state shared between the plugins.
+
+`GravitinoSparkPlugin` needs no new rule: `registerCatalog` already asserts
+`!sparkConf.contains("spark.sql.catalog." + name)` before writing, and the
caller catches the
+failure per catalog, so an already-registered name is skipped while the rest
still register. Only
+the reporting changes: today the skip is logged as `Register catalog X failed`
with a stack trace,
+which would now fire once per REST-registered catalog and read as an error.
+
+### User process
+
+1. Deploy the Gravitino Iceberg REST server (with either
`dynamic-config-provider` or
+ `static-config-provider` — the listing endpoint reflects both).
+2. Add to Spark configuration:
+
+ ```text
+
spark.plugins=org.apache.gravitino.spark.connector.plugin.GravitinoLakehouseRESTDiscoveryPlugin
+ spark.sql.gravitino.icebergREST.uri=http://127.0.0.1:9001/iceberg/
+ ```
+
+ To keep `GravitinoSparkPlugin` for other catalogs, list it **after** the
discovery plugin.
+3. Start the session — the plugin lists catalogs and registers one Spark
catalog per selected name.
+4. Query: `SELECT * FROM iceberg_prod.db.table`. Catalogs added later appear
after a session
+ restart, with no Spark configuration change.
+
+### Implementation process
+
+```text
+Spark Driver startup (spark.plugins: GravitinoLakehouseRESTDiscoveryPlugin,
GravitinoSparkPlugin)
+ ├─ GravitinoLakehouseRESTDiscoveryPlugin.init()
+ │ ├─ validate plugin ordering (fail fast if listed after
GravitinoSparkPlugin)
+ │ ├─ snapshot SparkConf (only user-written entries exist at this point)
+ │ └─ for each provider whose <format>REST.uri is set (Iceberg, Lance, …):
+ │ ├─ fail fast if the provider is not on the classpath
+ │ ├─ list catalogs ──► its Gravitino REST server → backend
+ │ ├─ drop names claimed by user conf →
policy.shouldRegister/registeredCatalogName(format,name)
+ │ └─ apply the precedence rules above, then write
+ │ spark.sql.catalog.<sparkName>.* and inject the provider's
extensions
+ │
+ ├─ GravitinoSparkPlugin.init() (if enabled)
+ │ └─ existing behavior: already skips any <name> whose
spark.sql.catalog.<name> is set
+ │
+ └─ Table access: Spark → REST protocol → Gravitino REST server → backend
+```
+
+Governance is unchanged: table access still flows through the REST server,
which remains the
+enforcement point for authorization and audit.
+
+---
+
+## Lance support
+
+A design sketch, not part of V1. Lance plugs into the same plugin as a second
provider, reusing the
+registration policy, the ordering rule, and the user-configuration precedence,
and differing only in
+discovery and the generated entries.
+
+**Discovery needs no new API.** The Lance Namespace protocol's root list,
+`GET {lance-rest-base}/v1/namespace/list`, already returns the
`lakehouse-generic` catalogs from
+Gravitino, and the Lance REST server rejects anything else, so the advertised
names are by
+construction valid `parent` values. The list is paginated, so the plugin
follows `page_token`.
+Filtering to "only Lance catalogs" is neither possible nor needed: `format` is
a table property, not
+a catalog-level marker, and the server serves exactly this set.
+
+The plugin could instead ask the Gravitino server for the generic catalog list
directly. The
+deciding difference is the user's mental model. With the REST server,
discovery source and data
+source are the same endpoint — the one `lanceREST.uri` Spark already queries
for tables — and the
+model is identical to Iceberg: point the plugin at a REST server, get its
catalogs. Going through
+Gravitino splits that into two systems (Gravitino lists them, the REST server
serves them) plus the
+unstated assumption that the names line up, and makes Lance discovery behave
differently from
+Iceberg. That mismatch stays invisible until a name does not line up, which
fails at query time
+rather than at configuration time. Discovery therefore stays
server-authoritative; Gravitino's
+per-principal authorization, the one thing this path would have reused, is
instead added on the
+Lance server (see below).
+
+**The Lance provider.** It uses the `spark.sql.gravitino.lanceREST.*` prefix,
mirroring the Iceberg
+keys (`uri`, `catalogProperties.<key>`), and lists catalogs with
+`org.lance.namespace.client.apache.api.NamespaceApi`, the plain-Java client in
the lance-spark
+bundle, for the same reason the Iceberg provider uses Iceberg's `RESTClient`.
Setting
+`lanceREST.uri` activates it; it and `icebergREST.uri` can be set together to
register both formats
+from the one plugin.
+
+For each accepted catalog it generates:
+
+```text
+spark.sql.catalog.<sparkName>=org.lance.spark.LanceNamespaceSparkCatalog
+spark.sql.catalog.<sparkName>.impl=rest
+spark.sql.catalog.<sparkName>.uri=<spark.sql.gravitino.lanceREST.uri>
+spark.sql.catalog.<sparkName>.parent=<advertisedName>
+```
+
+`parent` is set to the catalog name (`<advertisedName>`) and selects the
target catalog — the same
+role `warehouse` plays in the Iceberg provider. The four core keys are the
implementation key,
+`impl`, `uri`, and `parent`. Like the Iceberg provider, it injects the
format's session extension
+(`org.lance.spark.extensions.LanceSparkSessionExtensions`) into
`spark.sql.extensions` with the same
+deduplication; the extension class ships in the lance-spark bundle the active
provider already
+requires, so a lance-spark version that provides it is a prerequisite.
+
+One difference from the Iceberg provider: storage configuration is static
pass-through. Lance
+delivers it per table in `DescribeTableResponse.storageOptions`, resolved from
the catalog's and
+table's `lance.storage.*` properties. The plugin generates nothing either way;
unlike Iceberg
+vending, secrets in catalog properties reach the client, with no vending
equivalent today.
+
+One gap must close before Lance ships: the root list is authenticated but not
+authorization-filtered, so every authenticated caller sees all catalog names.
Filtering it with
+`MetadataAuthzHelper`, as the Iceberg server already does for namespace
listing, is a prerequisite —
+not a follow-up.
+
+---
+
+## Limitations and Future Work
+
+1. **Flink and Trino**: The server side is reusable as-is; only registration
differs. Flink allows
+ one catalog store per session, so it would resolve REST catalogs lazily
inside
+ `GravitinoCatalogStore` instead of adding a plugin. Trino would extend
`CatalogRegister` to issue
+ `CREATE CATALOG ... USING iceberg` with `iceberg.catalog.type=rest`, and
additionally needs
+ storage configuration in its native form plus a per-catalog
credential-vending flag; a
+ per-catalog field can be added to the listing response (compatibly) to
carry that.
+2. **Multiple REST servers per format**: one `uri` per format per session (one
`icebergREST.uri`,
+ one `lanceREST.uri`); registering several servers of the same format is out
of scope.