This is an automated email from the ASF dual-hosted git repository.
morningman pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/doris.git
The following commit(s) were added to refs/heads/master by this push:
new cf2988baa66 [feature](fe) Support iceberg REST catalog labels as the
$labels table (#69135)
cf2988baa66 is described below
commit cf2988baa66b0836a491d377622857e1cc36eb27
Author: Andrei Tserakhau <[email protected]>
AuthorDate: Tue Oct 27 10:00:05 2026 +0100
[feature](fe) Support iceberg REST catalog labels as the $labels table
(#69135)
### What problem does this PR solve?
Issue Number: #68916
Related PR: #69031
Problem Summary: The Iceberg REST catalog spec now lets a catalog return
`labels` with each table load (apache/iceberg#15750): catalog-owned,
per-request metadata such as ownership, classification or cost
attribution, at the table level (`object-labels`) and per field (keyed
by `field-id`). Labels are not part of the table metadata, have no
snapshots and are never written to `metadata.json`. Iceberg 1.12.0
exposes them on the loaded table via `SupportsLabels#labels()`. Doris
had no way to show them.
Add a `t$labels` system table for iceberg tables in a REST catalog:
```sql
SELECT * FROM rest_ctl.db.tbl$labels;
scope | field_id | field_name | key | value
object | NULL | NULL | owner | team-a
field | 2 | email | classification | pii
```
One row per label. `field_name` is resolved against the current table
schema and is NULL for a field id that is no longer in it (a dropped
column). The schema mirrors iceberg's proposed `LabelsTable`.
Implementation:
- Keep labels when Doris rebuilds a loaded table. iceberg's REST client
attaches them to the loaded `BaseTable` as a transient field; they are
not table metadata, so a `BaseTable` rebuilt from its `TableOperations`
must pass them explicitly.
`IcebergTableCache.TableLease#snapshotReadTable` (cached reads),
`IcebergStatementScope#snapshotReadTable` (statement reads) and
`IcebergScanPlanProvider#wrapTableForScan` (Kerberos FileIO wrap) used
constructors that reset labels to `Labels.EMPTY`, so every read saw no
labels; they now pass `baseTable.labels()`.
- `labels` is not a `MetadataTableType`, so it is handled next to the
SDK metadata tables rather than through `MetadataTableUtils`.
`IcebergConnectorMetadata` lists and resolves it only when the catalog
flavor is `rest` (no other catalog returns labels), and serves its fixed
schema and column handles without loading the base table.
- `IcebergScanPlanProvider` plans it as one split. A new split-package
helper `org.apache.iceberg.IcebergLabelsDataTask` builds an iceberg
`StaticDataTask` (package-private in the SDK) holding the label rows,
projected to the requested columns in BE's required-field order; its
metadata `DataFile` is synthesized, so no storage request is made. The
task is serialized like every other static metadata table, so BE's
`IcebergSysTableJniScanner` reads it with no BE change.
- The ordered system-table projection is extracted into
`projectSystemTableSchema` and shared with the existing path, and the
base-table load is split out of `resolveSysTable` as
`resolveSysBaseTable`.
- Labels are not versioned: like `$snapshots`/`$history`, the snapshot
pin of the handle is ignored, and `$labels` is exempt from the
resolved-empty-snapshot fence, so a table with no commit still shows its
labels.
Labels reflect the table load the statement used, so they follow the
iceberg table cache (`meta.cache.iceberg.table.ttl-second`); REFRESH
TABLE picks up new labels. Like the other system tables, a REST catalog
that requires server-side scan planning rejects `$labels`.
Co-authored-by: Isaac <[email protected]>
Co-authored-by: morningman <[email protected]>
---
fe/check/checkstyle/suppressions.xml | 2 +
.../iceberg/IcebergConnectorMetadata.java | 29 ++-
.../connector/iceberg/IcebergLabelsSysTable.java | 49 +++++
.../connector/iceberg/IcebergScanPlanProvider.java | 101 ++++++---
.../connector/iceberg/IcebergStatementScope.java | 2 +-
.../doris/connector/iceberg/IcebergTableCache.java | 2 +-
.../org/apache/iceberg/IcebergLabelsDataTask.java | 71 +++++++
.../IcebergConnectorMetadataSysTableTest.java | 50 +++++
.../iceberg/IcebergLabelsDataTaskTest.java | 143 +++++++++++++
.../iceberg/IcebergStatementScopeTest.java | 20 ++
.../connector/iceberg/IcebergTableCacheTest.java | 33 +++
.../iceberg/test_iceberg_rest_labels.out | 97 +++++++++
.../iceberg/test_iceberg_rest_labels.groovy | 233 +++++++++++++++++++++
13 files changed, 801 insertions(+), 31 deletions(-)
diff --git a/fe/check/checkstyle/suppressions.xml
b/fe/check/checkstyle/suppressions.xml
index 906a511b4be..96d40705bfe 100644
--- a/fe/check/checkstyle/suppressions.xml
+++ b/fe/check/checkstyle/suppressions.xml
@@ -79,6 +79,8 @@ under the License.
<suppress files="org[\\/]apache[\\/]iceberg[\\/]DeleteFileIndex\.java"
checks="[a-zA-Z0-9]*"/>
<!-- Iceberg package access is required to preserve historical schema
binding in DataTableScan. -->
<suppress
files="org[\\/]apache[\\/]iceberg[\\/]SchemaAwareDataTableScan\.java"
checks="[a-zA-Z0-9]*"/>
+ <!-- Iceberg package access is required to build a StaticDataTask for REST
catalog labels; other checks apply. -->
+ <suppress
files="org[\\/]apache[\\/]iceberg[\\/]IcebergLabelsDataTask\.java"
checks="ImportControl"/>
<!-- ignore gensrc/thrift/ExternalTableSchema.thrift -->
<suppress files=".*thrift/schema/external/.*" checks=".*"/>
diff --git
a/fe/fe-connector/fe-connector-iceberg/src/main/java/org/apache/doris/connector/iceberg/IcebergConnectorMetadata.java
b/fe/fe-connector/fe-connector-iceberg/src/main/java/org/apache/doris/connector/iceberg/IcebergConnectorMetadata.java
index b8b415f6f88..b571662d2b0 100644
---
a/fe/fe-connector/fe-connector-iceberg/src/main/java/org/apache/doris/connector/iceberg/IcebergConnectorMetadata.java
+++
b/fe/fe-connector/fe-connector-iceberg/src/main/java/org/apache/doris/connector/iceberg/IcebergConnectorMetadata.java
@@ -533,6 +533,12 @@ public class IcebergConnectorMetadata implements
ConnectorMetadata {
// parse ITS schema (e.g. t$snapshots ->
committed_at/snapshot_id/...). Mirrors legacy
// IcebergSysExternalTable.getSysIcebergTable +
getOrCreateSchemaCacheValue; the enable.mapping.*
// flags are threaded by the shared buildTableSchema ->
parseSchema (deviation 5).
+ if (IcebergLabelsSysTable.isLabels(iceHandle)) {
+ // $labels is not an iceberg metadata table: its schema is
fixed and needs no table load (the
+ // base table's existence was already checked by
getTableHandle).
+ return new ConnectorTableSchema(iceHandle.getTableName(),
+ parseSchema(IcebergLabelsSysTable.SCHEMA), "ICEBERG",
Collections.emptyMap());
+ }
Table sysTable = loadSysTable(session, iceHandle);
return buildTableSchema(iceHandle.getTableName(), sysTable,
sysTable.schema(), false);
}
@@ -833,6 +839,9 @@ public class IcebergConnectorMetadata implements
ConnectorMetadata {
// Mirror getTableSchema: wrap the remote load in the auth context. A
sys handle resolves the
// metadata-table columns (t$snapshots -> committed_at/...) so the
generic scan node can look up
// its pruned sys-table slots by name; a data handle resolves the base
table's columns.
+ if (IcebergLabelsSysTable.isLabels(iceHandle)) {
+ return buildColumnHandles(IcebergLabelsSysTable.SCHEMA, false);
+ }
Table table = iceHandle.isSystemTable() ? loadSysTable(session,
iceHandle) : loadTable(session, iceHandle);
return buildColumnHandles(table.schema(), !iceHandle.isSystemTable());
}
@@ -1919,6 +1928,9 @@ public class IcebergConnectorMetadata implements
ConnectorMetadata {
* excluding it here would be a capability regression vs master. Unlike
every other entry, its scan takes
* BE's native reader rather than the JNI serialized-split path (see
* {@code IcebergScanPlanProvider.doPlanPositionDeletesSystemTableScan}).
+ *
+ * <p>A REST catalog additionally exposes {@code labels} ({@link
IcebergLabelsSysTable}), which is not a
+ * {@code MetadataTableType}: its rows are the catalog labels returned
with the table load.
*/
@Override
public List<String> listSupportedSysTables(ConnectorSession session,
@@ -1927,6 +1939,9 @@ public class IcebergConnectorMetadata implements
ConnectorMetadata {
for (MetadataTableType type : MetadataTableType.values()) {
names.add(type.name().toLowerCase(Locale.ROOT));
}
+ if (supportsLabels()) {
+ names.add(IcebergLabelsSysTable.NAME);
+ }
return Collections.unmodifiableList(names);
}
@@ -1969,13 +1984,16 @@ public class IcebergConnectorMetadata implements
ConnectorMetadata {
/**
* Whether iceberg exposes a system table named {@code sysName}
(case-insensitive). Mirrors
* {@code IcebergSysTable.SUPPORTED_SYS_TABLES}: every {@code
MetadataTableType}, {@code POSITION_DELETES}
- * included (see {@link #listSupportedSysTables}). A {@code null} name is
simply not exposed (returns
- * false, not NPE).
+ * included (see {@link #listSupportedSysTables}), plus {@code labels} for
a REST catalog. A {@code null}
+ * name is simply not exposed (returns false, not NPE).
*/
- private static boolean isSupportedSysTable(String sysName) {
+ private boolean isSupportedSysTable(String sysName) {
if (sysName == null) {
return false;
}
+ if (IcebergLabelsSysTable.NAME.equalsIgnoreCase(sysName)) {
+ return supportsLabels();
+ }
for (MetadataTableType type : MetadataTableType.values()) {
if (type.name().equalsIgnoreCase(sysName)) {
return true;
@@ -1984,6 +2002,11 @@ public class IcebergConnectorMetadata implements
ConnectorMetadata {
return false;
}
+ /** Only a REST catalog returns catalog labels with the table load, so
only it exposes {@code $labels}. */
+ private boolean supportsLabels() {
+ return
IcebergCatalogProperties.TYPE_REST.equals(catalogProps.getFlavor());
+ }
+
// ========== Write / Transaction (P6.3) ==========
/**
diff --git
a/fe/fe-connector/fe-connector-iceberg/src/main/java/org/apache/doris/connector/iceberg/IcebergLabelsSysTable.java
b/fe/fe-connector/fe-connector-iceberg/src/main/java/org/apache/doris/connector/iceberg/IcebergLabelsSysTable.java
new file mode 100644
index 00000000000..a088e4d515e
--- /dev/null
+++
b/fe/fe-connector/fe-connector-iceberg/src/main/java/org/apache/doris/connector/iceberg/IcebergLabelsSysTable.java
@@ -0,0 +1,49 @@
+// 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.
+
+package org.apache.doris.connector.iceberg;
+
+import org.apache.iceberg.Schema;
+import org.apache.iceberg.types.Types;
+
+/**
+ * The {@code t$labels} system table: the catalog-provided labels of an
iceberg table loaded from a REST catalog
+ * (apache/iceberg#15750), one row per object-level or field-level label.
+ *
+ * <p>Unlike every other iceberg system table it is not a {@link
org.apache.iceberg.MetadataTableType}: labels
+ * come from the REST load response, not from the table metadata, are not
versioned with snapshots, and only a
+ * REST catalog returns them. Rows reflect the table load the statement read,
so they follow the iceberg table
+ * cache like the rest of the table's state. The schema mirrors iceberg's
proposed {@code LabelsTable}.
+ */
+final class IcebergLabelsSysTable {
+ static final String NAME = "labels";
+
+ static final Schema SCHEMA = new Schema(
+ Types.NestedField.required(1, "scope", Types.StringType.get()),
+ Types.NestedField.optional(2, "field_id", Types.IntegerType.get()),
+ Types.NestedField.optional(3, "field_name",
Types.StringType.get()),
+ Types.NestedField.required(4, "key", Types.StringType.get()),
+ Types.NestedField.required(5, "value", Types.StringType.get()));
+
+ private IcebergLabelsSysTable() {
+ }
+
+ /** Whether {@code handle} is a {@code $labels} handle; sys handles carry
the lower-cased name. */
+ static boolean isLabels(IcebergTableHandle handle) {
+ return NAME.equals(handle.getSysTableName());
+ }
+}
diff --git
a/fe/fe-connector/fe-connector-iceberg/src/main/java/org/apache/doris/connector/iceberg/IcebergScanPlanProvider.java
b/fe/fe-connector/fe-connector-iceberg/src/main/java/org/apache/doris/connector/iceberg/IcebergScanPlanProvider.java
index fe1f1fd1da0..acbfe7f4587 100644
---
a/fe/fe-connector/fe-connector-iceberg/src/main/java/org/apache/doris/connector/iceberg/IcebergScanPlanProvider.java
+++
b/fe/fe-connector/fe-connector-iceberg/src/main/java/org/apache/doris/connector/iceberg/IcebergScanPlanProvider.java
@@ -47,12 +47,14 @@ import org.apache.iceberg.BaseTable;
import org.apache.iceberg.BatchScan;
import org.apache.iceberg.ContentScanTask;
import org.apache.iceberg.DataFile;
+import org.apache.iceberg.DataTask;
import org.apache.iceberg.DeleteFile;
import org.apache.iceberg.DeleteFileIndex;
import org.apache.iceberg.FileContent;
import org.apache.iceberg.FileFormat;
import org.apache.iceberg.FileScanTask;
import org.apache.iceberg.HasTableOperations;
+import org.apache.iceberg.IcebergLabelsDataTask;
import org.apache.iceberg.ManifestContent;
import org.apache.iceberg.ManifestFile;
import org.apache.iceberg.ManifestFiles;
@@ -977,6 +979,9 @@ public class IcebergScanPlanProvider implements
ConnectorScanPlanProvider {
private List<ConnectorScanRange> doPlanSystemTableScan(IcebergTableHandle
handle,
List<ConnectorColumnHandle> columns, Optional<ConnectorExpression>
filter,
ConnectorSession session) {
+ if (IcebergLabelsSysTable.isLabels(handle)) {
+ return doPlanLabelsSystemTableScan(handle, columns, session);
+ }
Table metadataTable = resolveSysTable(session, handle);
if (isPositionDeletesSysTable(handle)) {
return doPlanPositionDeletesSystemTableScan(handle, metadataTable,
columns, filter, session);
@@ -998,27 +1003,8 @@ public class IcebergScanPlanProvider implements
ConnectorScanPlanProvider {
// hand from the requested columns.
List<String> projectedColumns = requestedColumnNames(columns);
if (!projectedColumns.isEmpty()) {
- Schema metadataSchema = metadataTable.schema();
- List<NestedField> projectedFields = new
ArrayList<>(projectedColumns.size());
- Set<Integer> seenFieldIds = new HashSet<>();
- for (String columnName : projectedColumns) {
- NestedField field = metadataSchema.findField(columnName);
- if (field == null) {
- throw new RuntimeException("Column " + columnName
- + " not found in iceberg system table schema " +
metadataTable.name());
- }
- if (seenFieldIds.add(field.fieldId())) {
- projectedFields.add(field);
- }
- }
- // Iceberg returns split_offsets only when the last offset is
smaller than file_size_in_bytes.
- // Without that dependency, even valid offsets such as [4, 2048]
are hidden as null. Append it
- // after the requested columns so BE's positional reads keep their
original order.
- if (seenFieldIds.contains(DataFile.SPLIT_OFFSETS.fieldId())
- && seenFieldIds.add(DataFile.FILE_SIZE.fieldId())) {
-
projectedFields.add(metadataSchema.findField(DataFile.FILE_SIZE.fieldId()));
- }
- scan = scan.project(new Schema(projectedFields));
+ scan = scan.project(projectSystemTableSchema(
+ metadataTable.schema(), projectedColumns,
metadataTable.name()));
}
List<ConnectorScanRange> ranges = new ArrayList<>();
try (CloseableIterable<FileScanTask> tasks = scan.planFiles()) {
@@ -1043,6 +1029,58 @@ public class IcebergScanPlanProvider implements
ConnectorScanPlanProvider {
&&
MetadataTableType.POSITION_DELETES.name().equalsIgnoreCase(handle.getSysTableName());
}
+ /**
+ * The system-table schema projected to the requested columns, in the
order BE lists them in
+ * required_fields: BE's {@code IcebergSysTableJniScanner} reads projected
rows positionally. Built by hand
+ * because {@code scan.select(names)} would re-emit the fields in
table-schema order. Any dependent metadata
+ * fields are appended after the requested columns.
+ */
+ private static Schema projectSystemTableSchema(Schema schema, List<String>
projectedColumns,
+ String tableName) {
+ List<NestedField> projectedFields = new
ArrayList<>(projectedColumns.size());
+ Set<Integer> seenFieldIds = new HashSet<>();
+ for (String columnName : projectedColumns) {
+ NestedField field = schema.findField(columnName);
+ if (field == null) {
+ throw new RuntimeException("Column " + columnName
+ + " not found in iceberg system table schema " +
tableName);
+ }
+ if (seenFieldIds.add(field.fieldId())) {
+ projectedFields.add(field);
+ }
+ }
+ // Iceberg returns split_offsets only when the last offset is smaller
than file_size_in_bytes.
+ // Without that dependency, even valid offsets such as [4, 2048] are
hidden as null. Append it
+ // after the requested columns so BE's positional reads keep their
original order.
+ if (seenFieldIds.contains(DataFile.SPLIT_OFFSETS.fieldId())
+ && seenFieldIds.add(DataFile.FILE_SIZE.fieldId())) {
+
projectedFields.add(schema.findField(DataFile.FILE_SIZE.fieldId()));
+ }
+ return new Schema(projectedFields);
+ }
+
+ /**
+ * Plan {@code t$labels}: one serialized split whose {@code
StaticDataTask} carries every catalog label of the
+ * base table, read by BE's JNI scanner like any other static metadata
table. Labels are not versioned, so
+ * the handle's snapshot pin is ignored (as for {@code $snapshots}/{@code
$history}); predicates are left to
+ * the scan node's conjuncts, the row count being bounded by the catalog
response.
+ */
+ private List<ConnectorScanRange>
doPlanLabelsSystemTableScan(IcebergTableHandle handle,
+ List<ConnectorColumnHandle> columns, ConnectorSession session) {
+ Table base = resolveSysBaseTable(session, handle);
+ List<String> projectedColumns = requestedColumnNames(columns);
+ Schema projectedSchema = projectedColumns.isEmpty()
+ ? IcebergLabelsSysTable.SCHEMA
+ : projectSystemTableSchema(IcebergLabelsSysTable.SCHEMA,
projectedColumns,
+ base.name() + "$" + IcebergLabelsSysTable.NAME);
+ DataTask task = IcebergLabelsDataTask.of(
+ base, IcebergLabelsSysTable.SCHEMA, projectedSchema,
SYS_TABLE_DUMMY_PATH);
+ return Collections.singletonList(new IcebergScanRange.Builder()
+ .path(SYS_TABLE_DUMMY_PATH)
+
.serializedSplit(IcebergSystemTableSerialization.serializeToBase64(task))
+ .build());
+ }
+
/**
* Plan a {@code $position_deletes} scan. Port of legacy
* {@code IcebergScanNode.doGetPositionDeletesSystemTableSplits} (upstream
#65135).
@@ -1328,6 +1366,10 @@ public class IcebergScanPlanProvider implements
ConnectorScanPlanProvider {
if (!handle.isSystemTable()) {
return false;
}
+ if (IcebergLabelsSysTable.isLabels(handle)) {
+ // Catalog labels come with the table load, not from a snapshot,
so a table with no commit has them.
+ return true;
+ }
MetadataTableType type =
MetadataTableType.from(handle.getSysTableName());
// Only metadata_log_entries has a creation row before the first data
snapshot; every other metadata
// table must preserve the resolved-empty fence or a concurrent first
append becomes visible.
@@ -3398,9 +3440,11 @@ public class IcebergScanPlanProvider implements
ConnectorScanPlanProvider {
}
BaseTable baseTable = (BaseTable) table;
TableOperations rawOps = baseTable.operations();
- // Keep the table's metrics reporter: every scan of a Kerberos catalog
runs on this wrapper.
+ // Keep the table's metrics reporter and REST catalog labels: every
scan of a Kerberos catalog runs on this
+ // wrapper.
return new BaseTable(new IcebergAuthenticatedTableOperations(
- rawOps, new IcebergAuthenticatedFileIO(rawOps.io(), auth)),
table.name(), baseTable.reporter());
+ rawOps, new IcebergAuthenticatedFileIO(rawOps.io(), auth)),
table.name(), baseTable.reporter(),
+ baseTable.labels());
}
/**
@@ -3413,6 +3457,13 @@ public class IcebergScanPlanProvider implements
ConnectorScanPlanProvider {
* task serialization) in ONE scope — no nested wrap here.
*/
private Table resolveSysTable(ConnectorSession session, IcebergTableHandle
handle) {
+ return MetadataTableUtils.createMetadataTableInstance(
+ resolveSysBaseTable(session, handle),
+ MetadataTableType.from(handle.getSysTableName()));
+ }
+
+ /** The base table a system-table scan reads, shared with metadata
binding; see {@link #resolveSysTable}. */
+ private Table resolveSysBaseTable(ConnectorSession session,
IcebergTableHandle handle) {
IcebergCatalogOps ops = catalogOpsResolver.apply(session);
// Keep the raw base shared with metadata binding and ordinary scan
properties. The caller already owns
// the auth scope, and avoiding a fresh load prevents system-table
slots and rows crossing generations.
@@ -3428,9 +3479,7 @@ public class IcebergScanPlanProvider implements
ConnectorScanPlanProvider {
() -> ops.loadTable(handle.getDbName(),
handle.getTableName())),
() -> ops.loadTable(handle.getDbName(),
handle.getTableName()));
rejectServerSideScanPlanning(base, handle);
- return MetadataTableUtils.createMetadataTableInstance(
- base,
- MetadataTableType.from(handle.getSysTableName()));
+ return base;
}
/** This catalog's engine-owned storage services (see {@link
ConnectorContext#getStorageContext()}). */
diff --git
a/fe/fe-connector/fe-connector-iceberg/src/main/java/org/apache/doris/connector/iceberg/IcebergStatementScope.java
b/fe/fe-connector/fe-connector-iceberg/src/main/java/org/apache/doris/connector/iceberg/IcebergStatementScope.java
index 6bb44ce3612..9d687e68b82 100644
---
a/fe/fe-connector/fe-connector-iceberg/src/main/java/org/apache/doris/connector/iceberg/IcebergStatementScope.java
+++
b/fe/fe-connector/fe-connector-iceberg/src/main/java/org/apache/doris/connector/iceberg/IcebergStatementScope.java
@@ -292,7 +292,7 @@ final class IcebergStatementScope {
TableMetadata metadata = operations.current();
// Keep IO, encryption and location behavior from the raw table while
pinning only metadata.
return new BaseTable(new IcebergSnapshotTableOperations(operations,
metadata),
- table.name(), baseTable.reporter());
+ table.name(), baseTable.reporter(), baseTable.labels());
}
/**
diff --git
a/fe/fe-connector/fe-connector-iceberg/src/main/java/org/apache/doris/connector/iceberg/IcebergTableCache.java
b/fe/fe-connector/fe-connector-iceberg/src/main/java/org/apache/doris/connector/iceberg/IcebergTableCache.java
index b727259d491..ddc89637b8e 100644
---
a/fe/fe-connector/fe-connector-iceberg/src/main/java/org/apache/doris/connector/iceberg/IcebergTableCache.java
+++
b/fe/fe-connector/fe-connector-iceberg/src/main/java/org/apache/doris/connector/iceberg/IcebergTableCache.java
@@ -314,7 +314,7 @@ final class IcebergTableCache {
? operations.current()
: TableMetadataParser.fromJson(snapshotMetadataLocation,
snapshotMetadataJson);
return new BaseTable(new
IcebergSnapshotTableOperations(operations, metadata),
- table.name(), baseTable.reporter());
+ table.name(), baseTable.reporter(), baseTable.labels());
}
private TableLease tryBorrow() {
diff --git
a/fe/fe-connector/fe-connector-iceberg/src/main/java/org/apache/iceberg/IcebergLabelsDataTask.java
b/fe/fe-connector/fe-connector-iceberg/src/main/java/org/apache/iceberg/IcebergLabelsDataTask.java
new file mode 100644
index 00000000000..e8ae091c1c6
--- /dev/null
+++
b/fe/fe-connector/fe-connector-iceberg/src/main/java/org/apache/iceberg/IcebergLabelsDataTask.java
@@ -0,0 +1,71 @@
+// 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.
+
+package org.apache.iceberg;
+
+import org.apache.iceberg.relocated.com.google.common.base.Preconditions;
+
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Map;
+
+/**
+ * Builds the single in-memory {@link DataTask} that carries a table's REST
catalog labels as rows.
+ *
+ * <p>Labels (apache/iceberg#15750) are returned by the catalog with each
table load and are not part of the
+ * table metadata, so no {@link MetadataTableType} can produce them. This
split-package helper builds an iceberg
+ * {@link StaticDataTask} directly (its constructors are package-private) so
the task serializes and is read by
+ * BE's {@code IcebergSysTableJniScanner} exactly like the SDK's own static
metadata tables. The metadata file is
+ * synthesized rather than built from an {@code InputFile}, which would issue
a storage HEAD for its length.
+ */
+public final class IcebergLabelsDataTask {
+ public static final String OBJECT_SCOPE = "object";
+ public static final String FIELD_SCOPE = "field";
+
+ private IcebergLabelsDataTask() {
+ }
+
+ /**
+ * Returns a task with one row per label: {@code (scope, field_id,
field_name, key, value)}. Field labels
+ * resolve {@code field_name} against the current table schema, so a label
on a dropped field keeps its
+ * {@code field_id} with a null {@code field_name}.
+ */
+ public static DataTask of(Table table, Schema labelsSchema, Schema
projectedSchema, String path) {
+ Preconditions.checkState(table instanceof SupportsLabels,
+ "Iceberg table %s does not expose catalog labels",
table.name());
+ Labels labels = ((SupportsLabels) table).labels();
+ Schema tableSchema = table.schema();
+ List<StructLike> rows = new ArrayList<>();
+ for (Map.Entry<String, String> label :
labels.objectLabels().entrySet()) {
+ rows.add(StaticDataTask.Row.of(OBJECT_SCOPE, null, null,
label.getKey(), label.getValue()));
+ }
+ for (FieldLabel field : labels.fields()) {
+ String fieldName = tableSchema.findColumnName(field.fieldId());
+ for (Map.Entry<String, String> label : field.labels().entrySet()) {
+ rows.add(StaticDataTask.Row.of(
+ FIELD_SCOPE, field.fieldId(), fieldName,
label.getKey(), label.getValue()));
+ }
+ }
+ DataFile metadataFile =
DataFiles.builder(PartitionSpec.unpartitioned())
+ .withPath(path)
+ .withFileSizeInBytes(0L)
+ .withRecordCount(rows.size())
+ .withFormat(FileFormat.METADATA)
+ .build();
+ return new StaticDataTask(metadataFile, labelsSchema, projectedSchema,
rows.toArray(new StructLike[0]));
+ }
+}
diff --git
a/fe/fe-connector/fe-connector-iceberg/src/test/java/org/apache/doris/connector/iceberg/IcebergConnectorMetadataSysTableTest.java
b/fe/fe-connector/fe-connector-iceberg/src/test/java/org/apache/doris/connector/iceberg/IcebergConnectorMetadataSysTableTest.java
index 74d4b178756..d354d7eecc0 100644
---
a/fe/fe-connector/fe-connector-iceberg/src/test/java/org/apache/doris/connector/iceberg/IcebergConnectorMetadataSysTableTest.java
+++
b/fe/fe-connector/fe-connector-iceberg/src/test/java/org/apache/doris/connector/iceberg/IcebergConnectorMetadataSysTableTest.java
@@ -80,6 +80,12 @@ public class IcebergConnectorMetadataSysTableTest {
return new IcebergConnectorMetadata(ops,
IcebergCatalogProperties.of(Collections.emptyMap()), ctx);
}
+ private static IcebergConnectorMetadata
restMetadataWith(RecordingIcebergCatalogOps ops) {
+ Map<String, String> props = new HashMap<>();
+ props.put(IcebergCatalogProperties.ICEBERG_CATALOG_TYPE,
IcebergCatalogProperties.TYPE_REST);
+ return new IcebergConnectorMetadata(ops,
IcebergCatalogProperties.of(props), new RecordingConnectorContext());
+ }
+
private static IcebergTableHandle baseHandle() {
return new IcebergTableHandle("db1", "t1");
}
@@ -577,4 +583,48 @@ public class IcebergConnectorMetadataSysTableTest {
.getSysTableHandle(null, baseHandle(), "").isPresent(),
"an empty sys name must yield Optional.empty()");
}
+
+ // ---------------------------------------------------------------------
+ // $labels (REST catalog labels, not a MetadataTableType)
+ // ---------------------------------------------------------------------
+
+ @Test
+ public void listSupportedSysTablesAddsLabelsForRestCatalogOnly() {
+ // WHY: only a REST catalog returns labels with the table load;
elsewhere t$labels would always be
+ // empty, so it is not offered. MUTATION: listing labels
unconditionally -> non-REST list grows -> red.
+ List<String> expectedRest = expectedSupported();
+ expectedRest.add("labels");
+ Assertions.assertEquals(expectedRest,
+ restMetadataWith(new
RecordingIcebergCatalogOps()).listSupportedSysTables(null, baseHandle()));
+ Assertions.assertEquals(expectedSupported(),
+ metadataWith(new
RecordingIcebergCatalogOps()).listSupportedSysTables(null, baseHandle()));
+ }
+
+ @Test
+ public void getSysTableHandleResolvesLabelsForRestCatalogOnly() {
+ RecordingIcebergCatalogOps ops = new RecordingIcebergCatalogOps();
+ Optional<ConnectorTableHandle> rest =
restMetadataWith(ops).getSysTableHandle(null, baseHandle(), "LABELS");
+
+ Assertions.assertTrue(rest.isPresent());
+ Assertions.assertEquals("labels", ((IcebergTableHandle)
rest.get()).getSysTableName());
+ Assertions.assertTrue(ops.log.isEmpty(), "resolving t$labels must not
touch the catalog seam");
+ Assertions.assertFalse(metadataWith(new RecordingIcebergCatalogOps())
+ .getSysTableHandle(null, baseHandle(), "labels").isPresent());
+ }
+
+ @Test
+ public void getTableSchemaForLabelsHandleIsFixedAndLoadsNothing() {
+ // WHY: labels has no iceberg metadata table, so the generic sys path
(MetadataTableType.from -> null)
+ // cannot build its schema; the schema is fixed and needs no base
load. MUTATION: falling through to
+ // loadSysTable -> NPE / a catalog round-trip -> red.
+ RecordingIcebergCatalogOps ops = new RecordingIcebergCatalogOps();
+ IcebergConnectorMetadata md = restMetadataWith(ops);
+ IcebergTableHandle sysHandle = (IcebergTableHandle)
md.getSysTableHandle(null, baseHandle(), "labels").get();
+
+ Assertions.assertEquals(Arrays.asList("scope", "field_id",
"field_name", "key", "value"),
+ columnNames(md.getTableSchema(null, sysHandle)));
+ Assertions.assertEquals(Arrays.asList("scope", "field_id",
"field_name", "key", "value"),
+ new ArrayList<>(md.getColumnHandles(null,
sysHandle).keySet()));
+ Assertions.assertTrue(ops.log.isEmpty(), "t$labels schema must not
load the base table");
+ }
}
diff --git
a/fe/fe-connector/fe-connector-iceberg/src/test/java/org/apache/doris/connector/iceberg/IcebergLabelsDataTaskTest.java
b/fe/fe-connector/fe-connector-iceberg/src/test/java/org/apache/doris/connector/iceberg/IcebergLabelsDataTaskTest.java
new file mode 100644
index 00000000000..b15771c2d68
--- /dev/null
+++
b/fe/fe-connector/fe-connector-iceberg/src/test/java/org/apache/doris/connector/iceberg/IcebergLabelsDataTaskTest.java
@@ -0,0 +1,143 @@
+// 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.
+
+package org.apache.doris.connector.iceberg;
+
+import org.apache.iceberg.BaseTable;
+import org.apache.iceberg.DataTask;
+import org.apache.iceberg.FileScanTask;
+import org.apache.iceberg.IcebergLabelsDataTask;
+import org.apache.iceberg.ImmutableFieldLabel;
+import org.apache.iceberg.ImmutableLabels;
+import org.apache.iceberg.Labels;
+import org.apache.iceberg.PartitionSpec;
+import org.apache.iceberg.Schema;
+import org.apache.iceberg.StructLike;
+import org.apache.iceberg.Table;
+import org.apache.iceberg.catalog.Namespace;
+import org.apache.iceberg.catalog.TableIdentifier;
+import org.apache.iceberg.inmemory.InMemoryCatalog;
+import org.apache.iceberg.io.CloseableIterable;
+import org.apache.iceberg.types.Types;
+import org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.api.Test;
+
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.List;
+
+/**
+ * Tests for the {@code t$labels} rows ({@link IcebergLabelsDataTask}): one
row per object-level and field-level
+ * label, projected in the requested order, and carried intact through the
system-table serialization that BE's
+ * JNI scanner reads.
+ */
+public class IcebergLabelsDataTaskTest {
+
+ private static final Schema BASE_SCHEMA = new Schema(
+ Types.NestedField.required(1, "id", Types.IntegerType.get()),
+ Types.NestedField.optional(2, "email", Types.StringType.get()));
+
+ private static final Labels LABELS = ImmutableLabels.builder()
+ .objectLabels(Collections.singletonMap("owner", "team-a"))
+ .addFields(ImmutableFieldLabel.builder()
+ .fieldId(2)
+ .labels(Collections.singletonMap("classification", "pii"))
+ .build())
+ // Field 7 is not in the current schema (e.g. a dropped column):
keep its id, no name.
+ .addFields(ImmutableFieldLabel.builder()
+ .fieldId(7)
+ .labels(Collections.singletonMap("deprecated", "true"))
+ .build())
+ .build();
+
+ private static Table labeledTable(Labels labels) {
+ InMemoryCatalog catalog = new InMemoryCatalog();
+ catalog.initialize("test", Collections.emptyMap());
+ catalog.createNamespace(Namespace.of("db1"));
+ BaseTable table = (BaseTable) catalog.createTable(
+ TableIdentifier.of("db1", "t1"), BASE_SCHEMA,
PartitionSpec.unpartitioned());
+ return new BaseTable(table.operations(), table.name(),
table.reporter(), labels);
+ }
+
+ /** Reads every row eagerly: StaticDataTask re-wraps one projection per
row, so rows cannot be kept. */
+ private static List<List<Object>> read(DataTask task, int width) throws
IOException {
+ List<List<Object>> rows = new ArrayList<>();
+ try (CloseableIterable<StructLike> iterable = task.rows()) {
+ for (StructLike row : iterable) {
+ List<Object> values = new ArrayList<>(width);
+ for (int i = 0; i < width; i++) {
+ values.add(row.get(i, Object.class));
+ }
+ rows.add(values);
+ }
+ }
+ return rows;
+ }
+
+ @Test
+ public void emitsObjectAndFieldLabelRows() throws IOException {
+ DataTask task = IcebergLabelsDataTask.of(labeledTable(LABELS),
IcebergLabelsSysTable.SCHEMA,
+ IcebergLabelsSysTable.SCHEMA, "/dummyPath");
+
+ Assertions.assertEquals(Arrays.asList(
+ Arrays.asList("object", null, null, "owner", "team-a"),
+ Arrays.asList("field", 2, "email", "classification", "pii"),
+ Arrays.asList("field", 7, null, "deprecated", "true")),
+ read(task, 5));
+ Assertions.assertEquals(3L, task.file().recordCount());
+ }
+
+ @Test
+ public void projectsInRequestedOrder() throws IOException {
+ // BE reads the projected row positionally in required_fields order,
so the task must project to exactly
+ // the requested schema. MUTATION: building the task over the full
schema -> (scope, field_id) -> red.
+ Schema projected = new Schema(
+ IcebergLabelsSysTable.SCHEMA.findField("value"),
+ IcebergLabelsSysTable.SCHEMA.findField("scope"));
+ DataTask task = IcebergLabelsDataTask.of(labeledTable(LABELS),
IcebergLabelsSysTable.SCHEMA,
+ projected, "/dummyPath");
+
+ Assertions.assertEquals(Arrays.asList(
+ Arrays.asList("team-a", "object"),
+ Arrays.asList("pii", "field"),
+ Arrays.asList("true", "field")),
+ read(task, 2));
+ }
+
+ @Test
+ public void tableWithoutLabelsHasNoRows() throws IOException {
+ DataTask task = IcebergLabelsDataTask.of(labeledTable(Labels.EMPTY),
IcebergLabelsSysTable.SCHEMA,
+ IcebergLabelsSysTable.SCHEMA, "/dummyPath");
+
+ Assertions.assertTrue(read(task, 5).isEmpty());
+ }
+
+ @Test
+ public void rowsSurviveSystemTableSerialization() throws IOException {
+ // The task is shipped to BE as a serialized FileScanTask; labels must
be materialized in it, since the
+ // BE side has no catalog to ask. MUTATION: keeping a lazy reference
to the table's labels -> red.
+ DataTask task = IcebergLabelsDataTask.of(labeledTable(LABELS),
IcebergLabelsSysTable.SCHEMA,
+ IcebergLabelsSysTable.SCHEMA, "/dummyPath");
+
+ FileScanTask decoded =
IcebergSystemTableSerialization.deserializeFromBase64(
+ IcebergSystemTableSerialization.serializeToBase64(task));
+
+ Assertions.assertEquals(read(task, 5), read(decoded.asDataTask(), 5));
+ }
+}
diff --git
a/fe/fe-connector/fe-connector-iceberg/src/test/java/org/apache/doris/connector/iceberg/IcebergStatementScopeTest.java
b/fe/fe-connector/fe-connector-iceberg/src/test/java/org/apache/doris/connector/iceberg/IcebergStatementScopeTest.java
index 2df7d37c00b..dd35b63c8ae 100644
---
a/fe/fe-connector/fe-connector-iceberg/src/test/java/org/apache/doris/connector/iceberg/IcebergStatementScopeTest.java
+++
b/fe/fe-connector/fe-connector-iceberg/src/test/java/org/apache/doris/connector/iceberg/IcebergStatementScopeTest.java
@@ -21,6 +21,9 @@ import org.apache.doris.connector.spi.ConnectorSession;
import org.apache.doris.connector.spi.ConnectorStatementScope;
import org.apache.doris.thrift.TIcebergDeleteFileDesc;
+import org.apache.iceberg.BaseTable;
+import org.apache.iceberg.ImmutableLabels;
+import org.apache.iceberg.Labels;
import org.apache.iceberg.PartitionSpec;
import org.apache.iceberg.Schema;
import org.apache.iceberg.Table;
@@ -79,6 +82,23 @@ public class IcebergStatementScopeTest {
Assertions.assertEquals(1, loads.get(), "loaded once per statement");
}
+ @Test
+ public void sharedTableKeepsCatalogLabels() {
+ // The statement-scoped read view is a rebuilt BaseTable; REST catalog
labels must survive the rebuild
+ // because they are not part of the table metadata. MUTATION: dropping
labels on rebuild -> EMPTY -> red.
+ BaseTable raw = (BaseTable) mutableTable("t");
+ Labels labels = ImmutableLabels.builder()
+ .objectLabels(Collections.singletonMap("owner", "team-a"))
+ .build();
+ Table labeled = new BaseTable(raw.operations(), raw.name(),
raw.reporter(), labels);
+
+ Table shared = IcebergStatementScope.sharedTable(
+ new ScopeSession(7L, "q1", new TestStatementScope()), "db1",
"t", () -> labeled);
+
+ Assertions.assertNotSame(labeled, shared);
+ Assertions.assertEquals(labels, ((BaseTable) shared).labels());
+ }
+
@Test
public void sameStatementSharesOneWritableTable() {
ScopeSession session = new ScopeSession(7L, "q1", new
TestStatementScope());
diff --git
a/fe/fe-connector/fe-connector-iceberg/src/test/java/org/apache/doris/connector/iceberg/IcebergTableCacheTest.java
b/fe/fe-connector/fe-connector-iceberg/src/test/java/org/apache/doris/connector/iceberg/IcebergTableCacheTest.java
index 8ee2b2e6db6..b3e82db5fe2 100644
---
a/fe/fe-connector/fe-connector-iceberg/src/test/java/org/apache/doris/connector/iceberg/IcebergTableCacheTest.java
+++
b/fe/fe-connector/fe-connector-iceberg/src/test/java/org/apache/doris/connector/iceberg/IcebergTableCacheTest.java
@@ -24,6 +24,9 @@ import org.apache.doris.connector.cache.JvmSizeUtils;
import com.fasterxml.jackson.databind.node.ArrayNode;
import com.fasterxml.jackson.databind.node.ObjectNode;
import org.apache.iceberg.BaseTable;
+import org.apache.iceberg.ImmutableFieldLabel;
+import org.apache.iceberg.ImmutableLabels;
+import org.apache.iceberg.Labels;
import org.apache.iceberg.ManifestFile;
import org.apache.iceberg.PartitionSpec;
import org.apache.iceberg.Schema;
@@ -42,6 +45,7 @@ import org.apache.iceberg.io.FileIO;
import org.apache.iceberg.io.InputFile;
import org.apache.iceberg.io.LocationProvider;
import org.apache.iceberg.io.OutputFile;
+import org.apache.iceberg.metrics.LoggingMetricsReporter;
import org.apache.iceberg.types.Types;
import org.apache.iceberg.util.JsonUtil;
import org.junit.jupiter.api.Assertions;
@@ -498,6 +502,35 @@ public class IcebergTableCacheTest {
return new BaseTable(new StaticTableOperations(refsOnly),
"lazy-snapshots");
}
+ @Test
+ public void snapshotReadTableKeepsCatalogLabels() {
+ // REST catalog labels live only on the loaded BaseTable (not in
metadata.json), so the statement view
+ // must carry them over. MUTATION: rebuilding with the 3-arg BaseTable
constructor -> Labels.EMPTY -> red.
+ Schema schema = new Schema(Types.NestedField.required(1, "id",
Types.IntegerType.get()));
+ TableMetadata metadata = TableMetadata.newTableMetadata(
+ schema, PartitionSpec.unpartitioned(), "file:///tmp/labeled",
Collections.emptyMap());
+ Labels labels = ImmutableLabels.builder()
+ .objectLabels(Collections.singletonMap("owner", "team-a"))
+ .addFields(ImmutableFieldLabel.builder()
+ .fieldId(1)
+ .labels(Collections.singletonMap("classification",
"pii"))
+ .build())
+ .build();
+ Table table = new BaseTable(new StaticTableOperations(metadata),
"labeled",
+ LoggingMetricsReporter.instance(), labels);
+
+ try (CatalogMetaCache owner = CatalogMetaCache.unmanaged()) {
+ IcebergTableCache cache = new IcebergTableCache(
+ owner, CacheSpec.ofWeight(true, 100L, 1000L, 10L * 1024L *
1024L),
+ ignored -> () -> { }, new IcebergCatalogResourceTracker());
+ try (IcebergTableCache.TableLease lease = cache.borrow(id(), () ->
table)) {
+ BaseTable statement = (BaseTable) lease.snapshotReadTable();
+ Assertions.assertNotSame(table, statement);
+ Assertions.assertEquals(labels, statement.labels());
+ }
+ }
+ }
+
@Test
public void ttlZeroDisablesCachingAlwaysLive() {
AtomicInteger loads = new AtomicInteger();
diff --git
a/regression-test/data/external_table_p0/iceberg/test_iceberg_rest_labels.out
b/regression-test/data/external_table_p0/iceberg/test_iceberg_rest_labels.out
new file mode 100644
index 00000000000..060f94202fb
--- /dev/null
+++
b/regression-test/data/external_table_p0/iceberg/test_iceberg_rest_labels.out
@@ -0,0 +1,97 @@
+-- This file is automatically generated. You should know what you did if you
want to edit this
+-- !desc --
+field_id int Yes true \N NONE
+field_name text Yes true \N NONE
+key text Yes true \N NONE
+scope text Yes true \N NONE
+value text Yes true \N NONE
+
+-- !all --
+field 2 email classification pii
+field 3 phone classification pii
+field 3 phone masking partial
+field 5 address.city classification location
+object \N \N cost.center cc-42
+object \N \N owner team-a
+object \N \N steward 数据平台组
+
+-- !projection --
+cc-42 cost.center \N object \N
+location classification address.city field 5
+partial masking phone field 3
+pii classification email field 2
+pii classification phone field 3
+team-a owner \N object \N
+数据平台组 steward \N object \N
+
+-- !one_column --
+classification
+classification
+classification
+cost.center
+masking
+owner
+steward
+
+-- !filter_field --
+email classification pii
+phone classification pii
+
+-- !filter_object --
+cost.center cc-42
+owner team-a
+steward 数据平台组
+
+-- !count --
+7
+
+-- !count_table --
+2
+
+-- !no_snapshot --
+object \N \N owner team-b
+
+-- !unlabeled --
+0
+
+-- !direct --
+0
+
+-- !vended --
+field 2 email classification pii
+field 3 phone classification pii
+field 3 phone masking partial
+field 5 address.city classification location
+object \N \N cost.center cc-42
+object \N \N owner team-a
+object \N \N steward 数据平台组
+
+-- !schema_evolution --
+field 2 email_address classification pii
+field 3 \N classification pii
+field 3 \N masking partial
+field 5 address.city classification location
+object \N \N cost.center cc-42
+object \N \N owner team-a
+object \N \N steward 数据平台组
+
+-- !time_travel --
+field 2 email_address classification pii
+field 3 \N classification pii
+field 3 \N masking partial
+field 5 address.city classification location
+object \N \N cost.center cc-42
+object \N \N owner team-a
+object \N \N steward 数据平台组
+
+-- !cached --
+owner team-b
+
+-- !uncached --
+owner team-c
+tier gold
+
+-- !refreshed --
+owner team-c
+tier gold
+
diff --git
a/regression-test/suites/external_table_p0/iceberg/test_iceberg_rest_labels.groovy
b/regression-test/suites/external_table_p0/iceberg/test_iceberg_rest_labels.groovy
new file mode 100644
index 00000000000..3b6b8965e06
--- /dev/null
+++
b/regression-test/suites/external_table_p0/iceberg/test_iceberg_rest_labels.groovy
@@ -0,0 +1,233 @@
+// 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.
+
+import com.sun.net.httpserver.HttpExchange
+import com.sun.net.httpserver.HttpHandler
+import com.sun.net.httpserver.HttpServer
+import groovy.json.JsonOutput
+import groovy.json.JsonSlurper
+
+import java.net.InetSocketAddress
+import java.util.concurrent.ConcurrentHashMap
+import java.util.concurrent.Executors
+
+// t$labels shows the labels a REST catalog returns with each table load
(apache/iceberg#15750). The docker REST
+// fixture returns none (a fixture option is apache/iceberg#18047), so this
suite puts a small proxy in front of
+// it: the proxy forwards every request unchanged and adds this suite's labels
to the load-table responses, which
+// is all a labelling catalog does differently. Everything after the HTTP
response is the real path: the iceberg
+// REST client parses the labels, FE plans the labels split and BE reads it
through the JNI system-table scanner.
+suite("test_iceberg_rest_labels", "p0,external") {
+ if
(!"true".equalsIgnoreCase(context.config.otherConfigs.get("enableIcebergTest")))
{
+ return
+ }
+ String externalEnvIp = context.config.otherConfigs.get("externalEnvIp")
+ String restPort = context.config.otherConfigs.get("iceberg_rest_uri_port")
+ String minioPort = context.config.otherConfigs.get("iceberg_minio_port")
+ String restUri = "http://${externalEnvIp}:${restPort}"
+
+ // Labels the proxy adds, keyed by "<namespace>.<table>". A table without
an entry keeps the fixture's
+ // response as is, without labels.
+ Map<String, Map> labelsByTable = new ConcurrentHashMap<>()
+ // Lengths and hop-by-hop headers belong to each connection.
Accept-Encoding is dropped so that the fixture
+ // answers in plain JSON the proxy can rewrite.
+ Set<String> droppedRequestHeaders = ["host", "connection", "keep-alive",
"content-length", "transfer-encoding",
+ "expect", "accept-encoding"] as Set
+ def loadTablePath = ~'^/v1/namespaces/([^/]+)/tables/([^/]+)$'
+ def proxyExecutor = Executors.newFixedThreadPool(4)
+ HttpServer proxy = HttpServer.create(new InetSocketAddress("0.0.0.0", 0),
0)
+ proxy.setExecutor(proxyExecutor)
+ proxy.createContext("/", { HttpExchange exchange ->
+ try {
+ String rawPath = exchange.requestURI.rawPath
+ String rawQuery = exchange.requestURI.rawQuery
+ def upstream = (HttpURLConnection) new URL(restUri + rawPath +
(rawQuery == null ? "" : "?" + rawQuery))
+ .openConnection()
+ upstream.setRequestMethod(exchange.requestMethod)
+ upstream.setConnectTimeout(10000)
+ upstream.setReadTimeout(60000)
+ exchange.requestHeaders.each { String name, List<String> values ->
+ if (!droppedRequestHeaders.contains(name.toLowerCase())) {
+ values.each { upstream.addRequestProperty(name, it) }
+ }
+ }
+ byte[] requestBody = exchange.requestBody.bytes
+ if (requestBody.length > 0) {
+ upstream.setDoOutput(true)
+ upstream.outputStream.withCloseable { it.write(requestBody) }
+ }
+ int status = upstream.responseCode
+ InputStream upstreamBody = status >= 400 ? upstream.errorStream :
upstream.inputStream
+ byte[] body = upstreamBody == null ? new byte[0] :
upstreamBody.withCloseable { it.bytes }
+ def loadTable = loadTablePath.matcher(rawPath)
+ if ("GET" == exchange.requestMethod && status == 200 &&
loadTable.matches()) {
+ Map labels = labelsByTable.get(loadTable.group(1) + "." +
loadTable.group(2))
+ if (labels != null) {
+ def response = new JsonSlurper().parseText(new
String(body, "UTF-8"))
+ response.labels = labels
+ body = JsonOutput.toJson(response).getBytes("UTF-8")
+ }
+ }
+ // Pass back only the content type. The fixture's validators
describe the body before the rewrite: a
+ // forwarded ETag would let the iceberg client revalidate its
cached load and keep stale labels.
+ String contentType = upstream.getHeaderField("Content-Type")
+ if (contentType != null) {
+ exchange.responseHeaders.set("Content-Type", contentType)
+ }
+ exchange.sendResponseHeaders(status, body.length == 0 ? -1 :
body.length)
+ if (body.length > 0) {
+ exchange.responseBody.withCloseable { it.write(body) }
+ }
+ } finally {
+ exchange.close()
+ }
+ } as HttpHandler)
+ proxy.start()
+ try {
+ // FE loads the tables through the proxy, so it must be reachable from
FE; like the HTTP TVF suite, use
+ // httpTvfHost when the regression runner is not on the docker host.
+ String proxyHost = context.config.otherConfigs.get("httpTvfHost") ?:
externalEnvIp
+ String proxyUri = "http://${proxyHost}:${proxy.address.port}"
+
+ sql "drop catalog if exists test_iceberg_rest_labels"
+ sql """create catalog test_iceberg_rest_labels properties (
+ 'type' = 'iceberg', 'iceberg.catalog.type' = 'rest', 'uri' =
'${proxyUri}',
+ 's3.access_key' = 'admin', 's3.secret_key' = 'password',
+ 's3.endpoint' = 'http://${externalEnvIp}:${minioPort}',
's3.region' = 'us-east-1'
+ )"""
+ // With a zero table cache TTL every statement loads the table, and so
its labels, again.
+ sql "drop catalog if exists test_iceberg_rest_labels_nocache"
+ sql """create catalog test_iceberg_rest_labels_nocache properties (
+ 'type' = 'iceberg', 'iceberg.catalog.type' = 'rest', 'uri' =
'${proxyUri}',
+ 's3.access_key' = 'admin', 's3.secret_key' = 'password',
+ 's3.endpoint' = 'http://${externalEnvIp}:${minioPort}',
's3.region' = 'us-east-1',
+ 'meta.cache.iceberg.table.ttl-second' = '0'
+ )"""
+ // Vended credentials turn the cross-query table cache off altogether,
so this catalog reads the table that
+ // each statement loads instead of a cached one.
+ sql "drop catalog if exists test_iceberg_rest_labels_vended"
+ sql """create catalog test_iceberg_rest_labels_vended properties (
+ 'type' = 'iceberg', 'iceberg.catalog.type' = 'rest', 'uri' =
'${proxyUri}',
+ 's3.access_key' = 'admin', 's3.secret_key' = 'password',
+ 's3.endpoint' = 'http://${externalEnvIp}:${minioPort}',
's3.region' = 'us-east-1',
+ 'iceberg.rest.vended-credentials-enabled' = 'true'
+ )"""
+ // The same tables read straight from the fixture, which returns no
labels. The tables are created through
+ // it, so no proxied catalog caches a load before its labels are set.
+ sql "drop catalog if exists test_iceberg_rest_labels_direct"
+ sql """create catalog test_iceberg_rest_labels_direct properties (
+ 'type' = 'iceberg', 'iceberg.catalog.type' = 'rest', 'uri' =
'${restUri}',
+ 's3.access_key' = 'admin', 's3.secret_key' = 'password',
+ 's3.endpoint' = 'http://${externalEnvIp}:${minioPort}',
's3.region' = 'us-east-1'
+ )"""
+
+ sql "create database if not exists
test_iceberg_rest_labels_direct.test_iceberg_rest_labels_db"
+ sql "drop table if exists
test_iceberg_rest_labels_direct.test_iceberg_rest_labels_db.labeled"
+ sql """create table
test_iceberg_rest_labels_direct.test_iceberg_rest_labels_db.labeled (
+ id int, email string, phone string, address struct<city: string,
zip: string>
+ )"""
+ sql """insert into
test_iceberg_rest_labels_direct.test_iceberg_rest_labels_db.labeled values
+ (1, '[email protected]', '555-0101', named_struct('city', 'Paris',
'zip', '75001')),
+ (2, '[email protected]', '555-0102', named_struct('city', 'Lyon',
'zip', '69001'))"""
+ sql "drop table if exists
test_iceberg_rest_labels_direct.test_iceberg_rest_labels_db.labeled_empty"
+ sql "create table
test_iceberg_rest_labels_direct.test_iceberg_rest_labels_db.labeled_empty (id
int, note string)"
+ sql "drop table if exists
test_iceberg_rest_labels_direct.test_iceberg_rest_labels_db.unlabeled"
+ sql "create table
test_iceberg_rest_labels_direct.test_iceberg_rest_labels_db.unlabeled (id int)"
+ sql "insert into
test_iceberg_rest_labels_direct.test_iceberg_rest_labels_db.unlabeled values
(1), (2)"
+
+ // Field labels are keyed by field id, which the catalog assigns: read
the ids from the fixture.
+ def loaded = new JsonSlurper().parseText(
+ new
URL("${restUri}/v1/namespaces/test_iceberg_rest_labels_db/tables/labeled").getText(
+ connectTimeout: 10000, readTimeout: 10000))
+ def schema = loaded.metadata.schemas.find { it["schema-id"] ==
loaded.metadata["current-schema-id"] }
+ def emailId = schema.fields.find { it.name == "email" }.id
+ def phoneId = schema.fields.find { it.name == "phone" }.id
+ def cityId = schema.fields.find { it.name == "address"
}.type.fields.find { it.name == "city" }.id
+ labelsByTable.put("test_iceberg_rest_labels_db.labeled", [
+ "object-labels": ["owner": "team-a", "cost.center": "cc-42",
"steward": "数据平台组"],
+ "fields": [
+ ["field-id": emailId, "labels": ["classification":
"pii"]],
+ ["field-id": phoneId, "labels": ["classification":
"pii", "masking": "partial"]],
+ ["field-id": cityId, "labels": ["classification":
"location"]]]])
+ labelsByTable.put("test_iceberg_rest_labels_db.labeled_empty",
["object-labels": ["owner": "team-b"]])
+
+ sql "switch test_iceberg_rest_labels"
+ sql "use test_iceberg_rest_labels_db"
+ order_qt_desc "desc labeled\$labels"
+ // One row per label. A field label names its field from the current
schema, a nested one by its full path.
+ order_qt_all "select * from labeled\$labels"
+ // BE reads the split's rows by position in the order of the columns
it asks for, so another order or a
+ // subset of the columns must still put every value in its own column.
KEY is a reserved word in Doris,
+ // so the key column is quoted.
+ order_qt_projection "select value, `key`, field_name, scope, field_id
from labeled\$labels"
+ order_qt_one_column "select `key` from labeled\$labels"
+ order_qt_filter_field "select field_name, `key`, value from
labeled\$labels where scope = 'field' and value = 'pii'"
+ order_qt_filter_object "select `key`, value from labeled\$labels where
field_id is null"
+ // COUNT counts labels rather than taking the table's record count:
two records, seven labels.
+ order_qt_count "select count(*) from labeled\$labels"
+ order_qt_count_table "select count(*) from labeled"
+ // Labels come with the table load, not with a snapshot: a table
without any commit has them too.
+ order_qt_no_snapshot "select * from labeled_empty\$labels"
+ // A table the catalog returns no labels for, and the labelled table
read without the proxy.
+ order_qt_unlabeled "select count(*) from unlabeled\$labels"
+ order_qt_direct "select count(*) from
test_iceberg_rest_labels_direct.test_iceberg_rest_labels_db.labeled\$labels"
+ // Without the table cache the labels come straight from the
statement's own table load.
+ order_qt_vended "select * from
test_iceberg_rest_labels_vended.test_iceberg_rest_labels_db.labeled\$labels"
+
+ // field_name follows the field id through schema evolution: a renamed
column keeps its labels under its new
+ // name, and a dropped one keeps its field_id with a NULL field_name.
+ sql "alter table labeled rename column email email_address"
+ sql "alter table labeled drop column phone"
+ order_qt_schema_evolution "select * from labeled\$labels"
+ // Labels are not versioned: a time-travel pin on t$labels is ignored,
so the rows are those of the current
+ // load and field names still come from the current schema, not from
the schema of the pinned snapshot.
+ def snapshotId = sql("select snapshot_id from
labeled\$snapshots")[0][0]
+ order_qt_time_travel "select * from labeled\$labels for version as of
${snapshotId}"
+
+ // Labels belong to the table load, so they follow the table cache
like the rest of the table: the cached
+ // catalog keeps the labels it loaded until REFRESH TABLE, the
uncached one sees new labels at once.
+ labelsByTable.put("test_iceberg_rest_labels_db.labeled_empty",
["object-labels": ["owner": "team-c", "tier": "gold"]])
+ order_qt_cached "select `key`, value from labeled_empty\$labels"
+ order_qt_uncached "select `key`, value from
test_iceberg_rest_labels_nocache.test_iceberg_rest_labels_db.labeled_empty\$labels"
+ sql "refresh table labeled_empty"
+ order_qt_refreshed "select `key`, value from labeled_empty\$labels"
+
+ // Only a REST catalog returns labels with the table load, so no other
catalog offers t$labels.
+ sql "drop catalog if exists test_iceberg_rest_labels_hadoop"
+ sql """create catalog test_iceberg_rest_labels_hadoop properties (
+ 'type' = 'iceberg', 'iceberg.catalog.type' = 'hadoop',
+ 'warehouse' = 's3a://warehouse/test_iceberg_rest_labels_hadoop',
+ 's3.access_key' = 'admin', 's3.secret_key' = 'password',
+ 's3.endpoint' = 'http://${externalEnvIp}:${minioPort}',
's3.region' = 'us-east-1'
+ )"""
+ sql "create database if not exists
test_iceberg_rest_labels_hadoop.test_iceberg_rest_labels_db"
+ sql "drop table if exists
test_iceberg_rest_labels_hadoop.test_iceberg_rest_labels_db.hadoop_table"
+ sql "create table
test_iceberg_rest_labels_hadoop.test_iceberg_rest_labels_db.hadoop_table (id
int)"
+ test {
+ sql "select * from
test_iceberg_rest_labels_hadoop.test_iceberg_rest_labels_db.hadoop_table\$labels"
+ exception "Unknown sys table 'hadoop_table\$labels'"
+ }
+
+ // The proxy stops with the suite, so its catalogs go too; the tables
stay readable through the direct one.
+ sql "switch internal"
+ sql "drop catalog test_iceberg_rest_labels"
+ sql "drop catalog test_iceberg_rest_labels_nocache"
+ sql "drop catalog test_iceberg_rest_labels_vended"
+ } finally {
+ proxy.stop(0)
+ proxyExecutor.shutdownNow()
+ }
+}
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]