Files
Mingyu Chen (Rayner) ef58609449 [feat](fluss) Support reading Apache Fluss tables through a fluss catalog (#66399)
### What problem does this PR solve?

Issue Number: close #xxx

Related PR: #66403 (merged)

Problem Summary:

Doris can now read [Apache Fluss](https://fluss.apache.org/) tables.
This adds a **fluss catalog**, reading all three shapes a fluss table
can take:

| Table | How it is read |
|---|---|
| log table | the fluss change log, one scan range per bucket |
| primary-key table | a kv snapshot plus the change log after it, merged
by key |
| tiered table (`table.datalake.enabled`) | its **paimon lake plus the
log written after it**, in one scan |

For a tiered table the lake half is not re-implemented: it is planned by
the **paimon connector**, on the snapshot the fluss coordinator pinned,
so that half gets native ORC/Parquet, deletion vectors and the file
cache for free — and the fluss plugin ships **no paimon dependency at
all** (it borrows the paimon plugin through `createSiblingConnector`,
the way hive borrows iceberg and hudi).

For a tiered **primary-key** table the two halves are merged BY KEY,
natively in BE: FE wraps each lake split with the key set of that
bucket's log tail, the BE reader drops the lake rows that tail replaced
before materialization, and the tail is replayed once as its own range.

```sql
CREATE CATALOG fluss PROPERTIES (
    "type" = "fluss",
    "fluss.bootstrap.servers" = "host:9123"
);
SELECT * FROM fluss.db.tbl;         -- lake + log, merged
SELECT * FROM fluss.db.`tbl$lake`;  -- the lake alone
SELECT * FROM fluss.db.`tbl$log`;   -- the part that is not in the lake yet
```

#### Naming a segment, and choosing a path

`tbl$lake` and `tbl$log` name **which segment of the table** is read,
and together they partition it: `tbl$lake ⊎ tbl$log == tbl`. They are
named after where the data is, not after how fresh it is, because a
stopped tiering service makes "the recent part" a lie while "the part
still in the log" stays true.

How the whole table is read is a different question, and it is a
**session variable** rather than a suffix, because the useful third
value cannot be spelled as a name:

```sql
SET fluss_union_read_mode = 'required';  -- fail rather than fall back to a fluss-only read
SET fluss_union_read_mode = 'disabled';  -- read fluss alone
SET fluss_union_read_mode = '';          -- follow the catalog (the default)
```

`required` is the one a regression baseline and a bug report want: it
turns "quietly read it the other way" into an error. The variable
overrides `fluss.union_read.mode` for one statement and changes no row
that comes back — only which reader produced it.

#### Lake settings a fluss cluster cannot send

Fluss removes every lake option whose name contains `key`, `secret` or
`password` before it hands a table's properties to a client, so a lake
in an object store cannot have its credentials learned from the cluster
at all. A catalog states them itself, under paimon's own spelling so
that the tail of a `datalake.paimon.*` line can be copied straight out
of a cluster's configuration:

```sql
CREATE CATALOG fluss PROPERTIES (
    "type" = "fluss",
    "fluss.bootstrap.servers" = "host:9123",
    "fluss.lake.paimon.s3.endpoint" = "http://minio:9000",
    "fluss.lake.paimon.s3.access-key" = "...",
    "fluss.lake.paimon.s3.secret-key" = "..."
);
```

Overrides are applied key by key, not as a replacement set, so stating
the metastore leaves the warehouse alone. The keys that describe
**storage** are taken out of what the lake catalog gets and handed to
the engine's storage layer instead, under the names it binds them by:
storage is configured once per catalog and read by the FE (which reads
manifests while planning) and the BE (which reads the data files) alike,
and a storage setting carried to the lake catalog would reach at most
the FE — sending the two halves to different endpoints with nothing
reporting the disagreement.

The lake's paimon catalog may be `filesystem`, `hive` or `rest`.

#### Where a scan's time went

Because a single fluss scan can be four different reads at once —
fluss's own log, a paimon lake, the log tail that supersedes part of it,
and the suppression itself — the scan reports where its time and rows
went, per side and per kind of range:

```
TableReader
├── FlussLogReadTime            8s25ms      the JNI side, everything forwarded to it
│   ├── FlussPkTailRangeNum          2      registered only for the kinds this scan read
│   ├── FlussPkTailRangeReadTime  6s2ms
│   └── FlussLogRowsReturned         2
├── FlussLakeReadTime           8s25ms      the paimon side, tail read and suppression included
│   ├── FlussLakeRangeNum            1
│   ├── FlussLakeSuppressRangeNum    2
│   └── FlussLakeRowsReturned        7      after suppression; + SuppressedRows = before
├── FlussUnionTailReadTime      5s20ms      one round trip to fluss per bucket
└── FlussUnionSuppressTime      16.1us      per block, grows with the lake's rows
```

A side's total minus its range kinds' is what standing that child up
cost — JNI class loading, or the paimon stack's setup — so "slow scan"
resolves to a specific half and a specific cause without subtracting
counters by hand.

### Reviewing this

85 commits, rebased on master (`f88735f8f62`). Two earlier PRs carried
the parts of this work that were **not** in the fluss module and have
both landed, so neither is part of this branch any more: #66403 (five
independent fixes a sibling-connector read depends on) and #66589
(removing FileScannerV2's per-range table reader rebuild, which this
branch's own refactor left without a producer). What remains is the
connector itself plus some changes to shared code. These three commits
reshape the shared BE scan path and are called out here so a reviewer
knows to look at them:

| Commit | Touches | Why it is here |
|---|---|---|
| `[feat](be) Dispatch a wrapped lake split to the reader that unwraps
it` | `be/src/exec/scan/file_scanner_v2.cpp` | Teaches the V2 scanner
that a fluss range may carry a paimon payload, so it can answer "can V2
read this" by the same key the fluss reader dispatches on. |
| `[refactor](be) file_scanner_v2: one table format for every fluss
range` | `be/src/exec/scan/file_scanner_v2.cpp`,
`file_scan_operator.cpp` | A review request on this PR: the scanner
should not see two `table_format_type`s for one table. Every fluss range
is now the format `fluss`, and the split-level dispatch moved inside the
connector's own reader — the same arrangement paimon and hudi already
have. |
| `[opt](fluss) Retain each log-tail key once, not once per change-log
record` | `be/src/format_v2/expr/equality_delete_predicate.{h,cpp}` |
Adds a static `distinct_rows` to the class that owns the equality,
rather than deduplicating beside the caller: a caller with its own idea
of when two keys match could drop a row this predicate would not have
suppressed. Additive; no existing behaviour changes. |

Beyond these three, the branch also touches shared code outside the
fluss module: the connector SPI (`ConnectorCapability`,
`ConnectorScanRequest`); in fe-core the plugin-driven table and scan
classes, `SlotTypeReplacer`, `LogicalFileScan`, `DescribeCommand`,
`SessionVariable`, `CatalogMgr`, `DatasourcePrintableMap`,
`MetadataGenerator`, `LineageUtils` and `KafkaRoutineLoadJob`; the
paimon, hive and iceberg connectors; the paimon BE scanner and the JNI
plugin toolkit; and in BE `parquet_scan.cpp` / `column_data.h`,
`jni_table_reader.cpp` and `jni_plugin_registry.h`. Build, thrift and
pipeline files (`build.sh`, `run-fe-ut.sh`, `PlanNodes.thrift`, the
external pipeline's regression conf) change as well.

The connector commits are grouped so that each group compiles, runs and
has its own acceptance:

1. **metadata** — module, type mapping, catalog/table/partition
metadata, the e2e docker environment
2. **log tables** — thrift payload, FE planning, the BE java scanner,
the BE C++ glue
3. **primary-key tables** — kv snapshot + change log
4. **the lake** — `$lake` through the paimon sibling, and lake-plus-log
for log tables
5. **merging a primary-key table's halves by key** — FE planning, the
tail replayer, the BE suppression reader
6. **coverage** — a partition-column-type gate, and five more e2e suites
7. **shape and observability** — the property layout every other
connector uses, one table format for every range, and the
per-side/per-range-kind profile above
8. **saying what to read and where the lake is** — `$log`, the session
variable, catalog-level lake settings, the storage keys, the
non-filesystem lake catalogs, and two more e2e suites

### Things worth saying out loud

- **This depends on `fluss 1.0.0`**, released on 2026-09-21 and taken
from Maven Central; the APIs this uses are not in 0.9.1, and 1.0.0 is
the first release that has them. The docker environment runs the
official `apache/fluss:1.0.0` and
`apache/fluss-quickstart-flink:1.20-1.0.0` images, with the one jar the
server image lacks (`paimon-s3`) mounted into its paimon plugin
directory. Moving from the development snapshot to 1.0.0 surfaced two
behaviour changes, both handled here: every fluss partition now carries
its own bucket count (1.0.0 supports rescaling), so a partition is
planned by the count it was created with rather than the table's; and
the paimon table the tiering service writes no longer carries
`__bucket`/`__offset`/`__timestamp` (paimon 2.0's `lakestream.enabled`
takes their place), which the suites now expect.
- **Connector plugin API 12.0.** The two `ConnectorCapability` constants
this series adds (`SUPPORTS_FIELD_ID_ACCESS_PATH`,
`SUPPORTS_SYS_TABLE_NESTED_COLUMN_PRUNE`) are a plugin-surface change,
so `connector.plugin.api.version` moves from master's 11.0 (#68320) to
12.0, in the same commit that records the two constants in the
plugin-surface baseline.
- **The e2e suites run in the External Regression pipeline.** It starts
the fluss docker component and its conf sets `enableFlussTest=true`, so
the suites under `external_table_p0/fluss` exercise a real cluster
instead of returning early. The local default stays `false`, for
developers who have not started the fluss environment.
- **The e2e lake lives in an object store**, in a minio the fluss
compose brings up itself. That is not "one more storage backend to
cover": with the lake in a directory, the whole credential path is
unexecuted, because a warehouse that needs no credentials never asks for
any. With it in a bucket, every lake-reading case in the suite exercises
the catalog-stated credentials, and the FE's manifest reads and the BE's
data-file reads are shown to be configured from the same answer.
`FLUSS_PAIMON_WAREHOUSE` still takes a directory path, so a storage
problem can be told from a lake problem by switching back.
- **Two things have no end-to-end coverage and are held up by unit tests
alone**, which is worth knowing before trusting them:
- a `hive` or `rest` lake catalog. The e2e environment's lake is a
filesystem catalog, and standing up a metastore beside it is a separate
piece of work.
- `s3.path.style.access`. It is carried to the engine's storage layer
like any other storage key, but the e2e object store is addressed by IP,
where the addressing rules fall back to path style on their own — so no
query in this environment can distinguish the setting taking effect from
it being ignored. An object store named by a hostname would.
- Two pre-existing bugs in neighbouring code were found by this work and
are **not** fixed here, so as not to widen the diff. Both reproduce
without fluss: an equality predicate on a microsecond `TIMESTAMP` pushed
into paimon matches no row (a plain paimon catalog over the same
warehouse behaves the same), and `java-common`'s VARBINARY read-back
decodes what it wrote as a StringView, so it always reads zeros (the
write side and BE agree; only the Java read-back is wrong, and no
production path takes it).
- `TIME` is mapped to `UNSUPPORTED` rather than to a string or to
elapsed millis: Doris has no type meaning what fluss's TIME means, and
handing back a plausible value of a different meaning is not something a
later error would catch. The paimon and iceberg connectors mark their
own TIME the same way, which also keeps `tbl` and `tbl$lake` agreeing on
one schema.
- Predicate pass-through into the lake half and the write path are
follow-ups.
- The profile counters are registered lazily, so a pure log-table
query's profile carries no lake or primary-key lines at all rather than
a column of zeros. `FlussUnionSuppressTime` is nested inside the
existing `FinalizeBlockTime` rather than taken out of it, so no
established counter changed meaning.
2026-09-29 20:06:29 +08:00

137 lines
6.2 KiB
XML

<?xml version="1.0" encoding="UTF-8"?>
<!--
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.
-->
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<parent>
<artifactId>be-java-extensions</artifactId>
<groupId>org.apache.doris</groupId>
<version>${revision}</version>
</parent>
<modelVersion>4.0.0</modelVersion>
<artifactId>plugin-toolkit</artifactId>
<name>Doris BE Plugin Toolkit</name>
<description>
Utilities shared by the plugins' source, never by their runtime.
Unlike jni-spi this jar is NOT on BE's classpath: each plugin that wants it declares a
normal compile dependency and gets its own copy inside its own directory. So two plugins
using it share the source but not the classes, which is the whole point - a bug fix reaches
both, while neither can be affected by the other's version of anything. Trino's
trino-plugin-toolkit works the same way.
The corollary is that anything added here is duplicated into every consumer, so it must be
code that genuinely has more than one, and it must stay dependency-free: a dependency here
would be forced on every plugin that wants any part of the toolkit.
</description>
<properties>
<maven.compiler.source>8</maven.compiler.source>
<maven.compiler.target>8</maven.compiler.target>
</properties>
<dependencies>
<!--
The SPI a helper here may build on - NestedProjection walks the ColumnType tree BE hands a
scanner. Provided, never compile: the enforcer below only pre-authorises it in that scope,
because a compile-scope SPI would be copied into every consuming plugin's directory and
the loader rejects a plugin that ships its own SPI copy.
-->
<dependency>
<groupId>org.apache.doris</groupId>
<artifactId>jni-spi</artifactId>
<version>${project.version}</version>
<scope>provided</scope>
</dependency>
<!--
Narrowed to test scope for the same reason as in jni-spi: the fe parent pom declares
these for every module, and a child cannot drop an inherited dependency. See that pom
for the full explanation. Do not "fix" these to compile scope.
-->
<dependency>
<groupId>org.slf4j</groupId>
<artifactId>slf4j-api</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.apache.logging.log4j</groupId>
<artifactId>log4j-slf4j2-impl</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.apache.logging.log4j</groupId>
<artifactId>log4j-core</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.apache.logging.log4j</groupId>
<artifactId>log4j-1.2-api</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
<build>
<finalName>doris-plugin-toolkit</finalName>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-enforcer-plugin</artifactId>
<executions>
<execution>
<id>enforce-plugin-toolkit-stays-dependency-free</id>
<phase>validate</phase>
<goals>
<goal>enforce</goal>
</goals>
<configuration>
<rules>
<bannedDependencies>
<searchTransitive>true</searchTransitive>
<excludes>
<exclude>*</exclude>
</excludes>
<includes>
<!--
jni-spi is pre-authorised, but only in provided scope: a
compile-scope SPI would be copied into every consuming
plugin's directory, and the loader rejects a plugin that
ships its own SPI copy.
-->
<include>org.apache.doris:jni-spi:*:*:provided</include>
<include>*:*:*:*:test</include>
</includes>
<message>
plugin-toolkit must stay dependency-free. Its jar is copied
into every plugin that uses any part of it, so a dependency
added here is imposed on all of them - including plugins
that only wanted one unrelated helper. Put code that needs
a library in the plugin that needs it.
</message>
</bannedDependencies>
</rules>
<fail>true</fail>
</configuration>
</execution>
</executions>
</plugin>
</plugins>
</build>
</project>