Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -96,4 +96,7 @@ public class ClusterProperties {
// string
@Value("${cluster.tables.allowed-client-name-values:}")
private List<String> allowedClientNameValues;

@Value("${cluster.tables.iceberg-rest.enabled:false}")
private boolean clusterTablesIcebergRestEnabled;
}
65 changes: 65 additions & 0 deletions docs/iceberg-rest-catalog.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
# Iceberg REST catalog

OpenHouse exposes a read-only Apache Iceberg REST Catalog facade for new clients while preserving
the existing OpenHouse APIs and business behavior.

## Enablement

The facade is disabled by default. Enable it with:

```properties
cluster.tables.iceberg-rest.enabled=true
```

`GET /v1/config` returns the `iceberg` route prefix and advertises only the implemented endpoints:

- `GET /v1/{prefix}/namespaces/{namespace}/tables`
- `GET /v1/{prefix}/namespaces/{namespace}/tables/{table}`
- `HEAD /v1/{prefix}/namespaces/{namespace}/tables/{table}`

## Architecture

The OpenAPI-generated interfaces own the HTTP contract. `IcebergRestCatalogController` is a thin
Spring MVC adapter, and `IcebergRestApiHandler` translates the Iceberg protocol to existing
`TablesApiHandler` and `OpenHouseInternalCatalog` behavior. The facade does not add business rules
or change the existing OpenHouse endpoints.

Iceberg response types use a narrowly scoped Spring `HttpMessageConverter`. Errors are translated
by controller-scoped advice into the standard Iceberg error envelope.

## Compatibility and limitations

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Follow-up — namespace endpoints are absent, so SHOW SCHEMAS is silently empty and USE db throws. Modern Java clients return an empty list from listNamespaces without error when the endpoint isn't advertised, and USE db throws UnsupportedOperationException — while reading the same database's tables works, a split operators will report as a bug. OpenHouse databases map one-to-one onto single-level Iceberg namespaces and GET /v1/databases already exists, so list/load-namespace is mechanical — but it is scope expansion, hence a follow-up. Cheap now: add a line here stating that namespace listing returns empty rather than erroring, so the behavior is not mistaken for an empty catalog.


- Only single-level namespaces are supported.
- The optional `warehouse` configuration hint does not select a different OpenHouse warehouse.
- List responses support opaque continuation tokens and page sizes from 1 through 1000.
- Table loads return all snapshots. The `snapshots=refs` projection is explicitly unsupported.
- The Iceberg 1.11 `referenced-by` query parameter is accepted and ignored.
- Access delegation may be requested, but this read-only version does not vend credentials.
- Conditional ETag responses are not currently emitted.
- Namespace, table-write, view, transaction, credential, and OAuth endpoints are not advertised.

Existing OpenHouse APIs remain supported. Client migrations can therefore be incremental.

## Observability and audit

Spring Boot records the facade through the standard `http.server.requests` metrics, including URI,
status, and latency. Table reads delegate through `TablesApiHandler`, retaining existing
authorization, lock visibility, and table-read audit behavior.

## Contract maintenance

`spec/iceberg-rest-catalog-open-api.yaml` is the full Apache Iceberg REST OpenAPI. OpenHouse support
is opt-in:

```yaml
operationId: listTables
x-openhouse-support: supported
```

Operations without the annotation are unsupported. The build codegens Spring interfaces only for
`supported` operations and generates `IcebergRestOpenHouseSupport.SUPPORTED_ENDPOINTS` for
`/v1/config`. Marking a new operation `supported` (or changing a supported signature) fails
compilation until the facade implements it.

To upgrade Iceberg OpenAPI: merge the newer upstream YAML into the checked-in file, add
`x-openhouse-support: supported` where needed, then compile.
2 changes: 2 additions & 0 deletions infra/recipes/docker-compose/oh-only/docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ services:
extends:
file: ../common/oh-services.yml
service: openhouse-tables
environment:
CLUSTER_TABLES_ICEBERGREST_ENABLED: "true"
volumes:
- ./:/var/config/
depends_on:
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
package com.linkedin.openhouse.tables.api.handler;

import com.linkedin.openhouse.tables.generated.iceberg.model.CatalogConfig;
import com.linkedin.openhouse.tables.generated.iceberg.model.ListTablesResponse;
import org.apache.iceberg.rest.responses.LoadTableResponse;

/** Protocol adapter between the generated Iceberg REST API and existing OpenHouse behavior. */
public interface IcebergRestApiHandler {

String ICEBERG_REST_PREFIX = "iceberg";

CatalogConfig getConfig(String warehouse);

ListTablesResponse listTables(
String prefix, String namespace, String pageToken, Integer pageSize);

LoadTableResponse loadTable(
String prefix,
String namespace,
String table,
String accessDelegation,

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Follow-up — unused wire strings cross the seam, and validation has no single owner. accessDelegation, ifNoneMatch, and referencedBy are accepted by this interface and never used by the implementation; each method re-validates the prefix; and the page-size range check sits inside decodePageToken's catch, so a valid token carrying an out-of-range page size reports "Invalid Iceberg REST page token" — the token was fine. Fix: parse once at the controller edge into a validated value object, drop the unused parameters, and move the range check out of the catch so its message survives.

String ifNoneMatch,
String snapshots,
String referencedBy);

void tableExists(String prefix, String namespace, String table);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,172 @@
package com.linkedin.openhouse.tables.api.handler.impl;

import static com.linkedin.openhouse.common.security.AuthenticationUtils.extractAuthenticatedUserPrincipal;

import com.linkedin.openhouse.common.api.spec.ApiResponse;
import com.linkedin.openhouse.common.exception.NoSuchUserTableException;
import com.linkedin.openhouse.internal.catalog.OpenHouseInternalCatalog;
import com.linkedin.openhouse.tables.api.handler.IcebergRestApiHandler;
import com.linkedin.openhouse.tables.api.handler.TablesApiHandler;
import com.linkedin.openhouse.tables.api.spec.v0.response.GetAllTablesResponseBody;
import com.linkedin.openhouse.tables.api.spec.v0.response.GetTableResponseBody;
import com.linkedin.openhouse.tables.generated.iceberg.IcebergRestOpenHouseSupport;
import com.linkedin.openhouse.tables.generated.iceberg.model.CatalogConfig;
import com.linkedin.openhouse.tables.generated.iceberg.model.ListTablesResponse;
import java.nio.charset.StandardCharsets;
import java.util.Base64;
import java.util.Collections;
import java.util.LinkedHashSet;
import java.util.stream.Collectors;
import org.apache.iceberg.catalog.Namespace;
import org.apache.iceberg.catalog.TableIdentifier;
import org.apache.iceberg.exceptions.NoSuchNamespaceException;
import org.apache.iceberg.exceptions.NoSuchTableException;
import org.apache.iceberg.rest.CatalogHandlers;
import org.apache.iceberg.rest.RESTUtil;
import org.apache.iceberg.rest.responses.LoadTableResponse;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.data.domain.Page;
import org.springframework.stereotype.Component;

/** Default Iceberg REST adapter backed by existing OpenHouse API handlers and catalog behavior. */
@Component
@ConditionalOnProperty(value = "cluster.tables.iceberg-rest.enabled", havingValue = "true")
public class OpenHouseIcebergRestApiHandler implements IcebergRestApiHandler {

static final int DEFAULT_PAGE_SIZE = 100;

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fix before merge — the server paginates when the client never asked, silently truncating at 100. The spec's PageToken schema: "Servers that support pagination must return all results in a single response with the value of next-page-token set to null if the query parameter pageToken is not set in the request." A request with no pageToken gets DEFAULT_PAGE_SIZE = 100 here instead. The clients that never send a token are real and current: PyIceberg (all released versions) and Iceberg Java ≤1.5.x issue one unpaginated GET and read what comes back — a 350-table namespace returns 100 tables with no error, and downstream jobs quietly process a third of the catalog. This is the most dangerous finding in the stack precisely because nothing fails. Fix: when no pageToken is present, return the full listing with next-page-token: null; paginate only when the client opts in via pageToken or pageSize.

static final int MAX_PAGE_SIZE = 1000;
private static final String PAGE_TOKEN_VERSION = "v1";

private final TablesApiHandler tablesApiHandler;
private final OpenHouseInternalCatalog openHouseInternalCatalog;

public OpenHouseIcebergRestApiHandler(
TablesApiHandler tablesApiHandler, OpenHouseInternalCatalog openHouseInternalCatalog) {
this.tablesApiHandler = tablesApiHandler;
this.openHouseInternalCatalog = openHouseInternalCatalog;
}

@Override
public CatalogConfig getConfig(String warehouse) {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Follow-up — the warehouse argument is discarded. A client pointed at the wrong OpenHouse cluster with an explicit warehouse= connects successfully and reads the wrong catalog. Spec-legal (the field is a hint), but cheap to reject when the name doesn't match this cluster's identity, turning a silent wrong-catalog read into an immediate 400.

return new CatalogConfig(
Collections.singletonMap("prefix", ICEBERG_REST_PREFIX), Collections.emptyMap())
.endpoints(IcebergRestOpenHouseSupport.SUPPORTED_ENDPOINTS);
}

@Override
public ListTablesResponse listTables(

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fix before enabling — listing a namespace that doesn't exist returns 200 + empty; the spec declares 404 (NoSuchNamespaceException). No existence check runs: the backing OpenHouseInternalCatalog.listTables runs findAllByDatabaseId, which returns an empty page for a never-created database rather than throwing. A typo'd SHOW TABLES IN prod_bd reads as "empty database", and existence probes built on listing always answer yes. The suite masks this: listTablesEmptyNamespace exercises an emptied-but-existing namespace, which passes identically. The fix is a decision plus a test on either branch: conform (resolve the database via the existing databases API, throw NoSuchNamespaceException, which already maps to 404, and add the contract test) or document the deviation and pin the 200 [] with a test. Checking via the database API is preferred over an "empty first page means 404" rule, which would answer 404 for a real-but-empty namespace.

String prefix, String namespace, String pageToken, Integer pageSize) {
validatePrefix(prefix);
Namespace icebergNamespace = decodeSingleLevelNamespace(namespace);
PageCursor cursor = decodePageToken(pageToken, pageSize);
ApiResponse<GetAllTablesResponseBody> response =
tablesApiHandler.searchTables(

Check failure on line 63 in services/tables/src/main/java/com/linkedin/openhouse/tables/api/handler/impl/OpenHouseIcebergRestApiHandler.java

View workflow job for this annotation

GitHub Actions / build-run-tests / Build and Run Tests

no suitable method found for searchTables(String,int,int,String)
icebergNamespace.level(0), cursor.getPage(), cursor.getPageSize(), "tableId");
Page<GetTableResponseBody> page = response.getResponseBody().getPageResults();
LinkedHashSet<TableIdentifier> identifiers =
page.getContent().stream()
.map(table -> TableIdentifier.of(icebergNamespace, table.getTableId()))
.collect(Collectors.toCollection(LinkedHashSet::new));
String nextPageToken =
page.hasNext() ? encodePageToken(cursor.getPage() + 1, cursor.getPageSize()) : null;
return new ListTablesResponse().identifiers(identifiers).nextPageToken(nextPageToken);
}

@Override
public LoadTableResponse loadTable(
String prefix,
String namespace,
String table,
String accessDelegation,
String ifNoneMatch,

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fix before enabling — the documented "no ETag/304" deviation has no pin. The spec defines 304 Not Modified keyed on If-None-Match; the parameter is accepted here and never read, and docs/iceberg-rest-catalog.md records the deviation ("Conditional ETag responses are not currently emitted"). Deliberate and documented — but no test locks it, so a change that starts emitting 304s (or a caching layer injecting them) alters client-visible behavior with nothing tripping. Fix: one pin test — a load-table request carrying If-None-Match still returns 200 — labeled as a pin so its failure reads "behavior changed", not "bug".

String snapshots,
String referencedBy) {
validatePrefix(prefix);
if (snapshots != null && !"all".equals(snapshots)) {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fix before enabling — snapshots=refs fails every table load for clients configured the documented way. refs is a narrowing projection: a server returning all snapshots has returned a valid superset, so no implementation is needed — yet this guard 501s it, and any operator who sets snapshot-loading-mode=refs (the documented way to make loads cheap on long-history tables) fails on every load, with no fallback. 501 is also not among the responses the spec declares for loadTable. And the same !"all".equals(...) branch reports a typo (snapshots=xyz) as "not implemented", where the spec's enum [all, refs] makes it a 400. Fix: accept refs by ignoring it; return 400 for values outside the enum; add the controller-level test for the malformed value (that partition is currently untested).

throw new UnsupportedOperationException(
"The snapshots=refs projection is not supported by this catalog");
}
// Iceberg 1.11 loadTable may send referenced-by for view-load chains; Phase 1 ignores it.

Namespace icebergNamespace = decodeSingleLevelNamespace(namespace);
String databaseId = icebergNamespace.level(0);
try {
tablesApiHandler.getTable(databaseId, table, extractAuthenticatedUserPrincipal());

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fix before enabling — the facade calls the native API's protocol adapter instead of TablesService, so native-API policy silently becomes Iceberg contract. Concrete leak today: TablesApiHandler.getTable validates identifiers against ^[a-zA-Z0-9_]+$ and throws a validation failure, which this surface renders as 400. Iceberg places no restriction on names, and its clients probe existence by treating 404 as "no" — so Spark's DROP TABLE IF EXISTS db.`my-table` gets 400 instead of 404 and the IF EXISTS guard fails at its one job. Also, the authorization check and the returned metadata come from two separate calls on two different paths, with nothing keeping them consistent when the core's read policy gains a rule. Fix: depend on TablesService.getTable (which already applies authorization) and build the response from its result; note the audit aspect currently points at TablesApiHandler, so move the audit hook to the service layer in the same change or Iceberg reads vanish from the audit log. Interim two-line version: here and in tableExists, catch RequestValidationFailureException and rethrow NoSuchTableException — a name OpenHouse cannot store is a table that does not exist.

} catch (NoSuchUserTableException e) {
throw new NoSuchTableException("Table does not exist: %s.%s", databaseId, table);
}

return CatalogHandlers.loadTable(
openHouseInternalCatalog, TableIdentifier.of(icebergNamespace, table));
}

@Override
public void tableExists(String prefix, String namespace, String table) {
validatePrefix(prefix);
Namespace icebergNamespace = decodeSingleLevelNamespace(namespace);
String databaseId = icebergNamespace.level(0);
try {
tablesApiHandler.getTable(databaseId, table, extractAuthenticatedUserPrincipal());

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Follow-up — a missing login silently becomes the principal "undefined". The handler reads the security context ambiently, and the shared helper converts an absent authentication into the literal string "undefined", which then flows into authorization as if it were a user. Any future invocation off a request thread (async wrapper, warm-up task) authorizes as "undefined" instead of failing, and nothing in the method signatures reveals the dependency. Fix: pass the acting principal as a parameter from the controller, matching the native controllers' convention.

} catch (NoSuchUserTableException e) {
throw new NoSuchTableException("Table does not exist: %s.%s", databaseId, table);
}
}

private static void validatePrefix(String prefix) {
if (!ICEBERG_REST_PREFIX.equals(prefix)) {
throw new IllegalArgumentException("Unsupported Iceberg REST prefix");
}
}

private static Namespace decodeSingleLevelNamespace(String encodedNamespace) {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Follow-up — the single-level-namespace limitation is enforced here but untested. This rejection (empty or multi-level namespace answered with 404) implements the documented "only single-level namespaces" limitation, and no test on any endpoint exercises it. One controller-level test with a two-level namespace (%1F-separated) closes it.

Namespace namespace = RESTUtil.decodeNamespace(encodedNamespace);
if (namespace.isEmpty() || namespace.levels().length != 1) {
throw new NoSuchNamespaceException("Only single-level namespaces are supported");
}
return namespace;
}

private static PageCursor decodePageToken(String pageToken, Integer requestedPageSize) {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fix before merge — empty pageToken is rejected as malformed, breaking listTables for every current Java client. The spec's PageToken schema says clients "may initiate the first paginated request by sending an empty query parameter pageToken", and every Iceberg Java client from 1.6.0 through 1.11.0 does exactly that: RESTSessionCatalog.listTables sends pageToken= unconditionally on its first request. Spring binds that to the empty string, this method takes the non-null branch, base64-decodes "" to an empty array, splits to one part instead of three, and throws, which the error mapper renders as HTTP 400. Result: catalog.listTables() — and with it Spark's SHOW TABLES and Trino's metadata listing — fails outright on every current client, while loadTable still works, so the failure looks arbitrary to operators. Fix: treat a blank token as the first page — if (pageToken == null || pageToken.isEmpty()).

if (pageToken == null) {
return new PageCursor(0, validatePageSize(requestedPageSize));
}

try {
String decoded = new String(Base64.getUrlDecoder().decode(pageToken), StandardCharsets.UTF_8);
String[] parts = decoded.split(":", -1);
if (parts.length != 3 || !PAGE_TOKEN_VERSION.equals(parts[0])) {
throw new IllegalArgumentException("Invalid Iceberg REST page token");
}
int page = Integer.parseInt(parts[1]);
int pageSize = validatePageSize(Integer.parseInt(parts[2]));
if (page < 1 || (requestedPageSize != null && requestedPageSize != pageSize)) {
throw new IllegalArgumentException("Invalid Iceberg REST page token");
}
return new PageCursor(page, pageSize);
} catch (IllegalArgumentException e) {
throw new IllegalArgumentException("Invalid Iceberg REST page token", e);
}
}

private static int validatePageSize(Integer requestedPageSize) {
int pageSize = requestedPageSize == null ? DEFAULT_PAGE_SIZE : requestedPageSize;
if (pageSize < 1 || pageSize > MAX_PAGE_SIZE) {
throw new IllegalArgumentException(
String.format("page-size must be between 1 and %s", MAX_PAGE_SIZE));
}
return pageSize;
}

private static String encodePageToken(int page, int pageSize) {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Follow-up — offset-encoded page tokens skip or duplicate rows under concurrent writes. The token encodes v1:<page-index>:<page-size> over a tableId-sorted query — an offset. A table created or dropped mid-iteration shifts every later row across page boundaries, so a crawler misses or repeats identifiers. Since the sort key is already tableId, a keyset token (encode the last-seen tableId, filter tableId > :last) is stable and no harder — and the token is opaque, so no client notices the change.

String value = String.format("%s:%s:%s", PAGE_TOKEN_VERSION, page, pageSize);
return Base64.getUrlEncoder()
.withoutPadding()
.encodeToString(value.getBytes(StandardCharsets.UTF_8));
}

@lombok.Value
private static class PageCursor {
int page;
int pageSize;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
package com.linkedin.openhouse.tables.controller;

import com.linkedin.openhouse.tables.api.handler.IcebergRestApiHandler;
import com.linkedin.openhouse.tables.generated.iceberg.api.CatalogApiApi;
import com.linkedin.openhouse.tables.generated.iceberg.api.ConfigurationApiApi;
import com.linkedin.openhouse.tables.generated.iceberg.model.CatalogConfig;
import com.linkedin.openhouse.tables.generated.iceberg.model.ListTablesResponse;
import io.swagger.v3.oas.annotations.Hidden;
import java.util.Optional;
import org.apache.iceberg.rest.responses.LoadTableResponse;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.context.request.NativeWebRequest;

/**
* Thin Spring MVC adapter for the generated read-only Iceberg REST contract.
*
* <p>Protocol translation and orchestration live in {@link IcebergRestApiHandler}; existing
* OpenHouse handlers and services remain the source of business behavior.
*/
@Hidden
@RestController
@ConditionalOnProperty(value = "cluster.tables.iceberg-rest.enabled", havingValue = "true")
public class IcebergRestCatalogController implements CatalogApiApi, ConfigurationApiApi {

private final IcebergRestApiHandler icebergRestApiHandler;

public IcebergRestCatalogController(IcebergRestApiHandler icebergRestApiHandler) {
this.icebergRestApiHandler = icebergRestApiHandler;
}

@Override
public Optional<NativeWebRequest> getRequest() {
return Optional.empty();
}

@Override
public ResponseEntity<CatalogConfig> getConfig(String warehouse) {
return ResponseEntity.ok(icebergRestApiHandler.getConfig(warehouse));
}

@Override
public ResponseEntity<ListTablesResponse> listTables(
String prefix, String namespace, String pageToken, Integer pageSize) {
return ResponseEntity.ok(
icebergRestApiHandler.listTables(prefix, namespace, pageToken, pageSize));
}

@Override
public ResponseEntity<LoadTableResponse> loadTable(
String prefix,
String namespace,
String table,
String xIcebergAccessDelegation,
String ifNoneMatch,
String snapshots,
String referencedBy) {
return ResponseEntity.ok(
icebergRestApiHandler.loadTable(
prefix,
namespace,
table,
xIcebergAccessDelegation,
ifNoneMatch,
snapshots,
referencedBy));
}

@Override
public ResponseEntity<Void> tableExists(String prefix, String namespace, String table) {
icebergRestApiHandler.tableExists(prefix, namespace, table);
return ResponseEntity.noContent().build();
}
}
Loading
Loading