github-actions[bot] commented on code in PR #66399:
URL: https://github.com/apache/doris/pull/66399#discussion_r4092453801
##########
fe/fe-connector/fe-connector-paimon/src/main/java/org/apache/doris/connector/paimon/PaimonConnector.java:
##########
@@ -389,7 +389,13 @@ public Set<ConnectorCapability> getCapabilities() {
// connector-wide: it holds for every paimon DATA table. The
narrower question of which
// SYSTEM table can honor the clause is answered per table by
// PaimonScanPlanProvider.supportsSystemTableOptions.
- ConnectorCapability.SUPPORTS_SCAN_PARAM_OPTIONS);
+ ConnectorCapability.SUPPORTS_SCAN_PARAM_OPTIONS,
+ // SUPPORTS_NESTED_COLUMN_PRUNE: the paimon JNI scanner
mirrors a pruned nested type onto
+ // paimon's own types and pushes it down
(ReadBuilder.withReadType), and the native
+ // parquet/orc split path resolves the access paths by name.
NOT
+ // SUPPORTS_FIELD_ID_ACCESS_PATH: paimon carries no field id
on the Doris column tree, so
+ // rewriting the paths to ids would make every segment "-1".
+ ConnectorCapability.SUPPORTS_NESTED_COLUMN_PRUNE);
Review Comment:
[P1] Gate Paimon nested pruning on BE support
Declaring this capability immediately lets a new FE narrow nested slot types
for Paimon JNI scans even while a pre-change BE still runs the old scanner. For
`STRUCT<a INT, b INT>` and `SELECT s.b`, that scanner reads the full source
struct, while the narrowed destination supplies index `[0]`; the positional
decoder therefore returns `a` as `b` (or fails when their types differ). The
`withReadType` repair in this PR exists only on upgraded BEs. Please gate this
capability on the query-wide BE execution version/current scanner semantics, or
preserve the full nested wire type for old peers.
##########
fe/fe-connector/fe-connector-fluss/src/main/java/org/apache/doris/connector/fluss/FlussScanPlanProvider.java:
##########
@@ -0,0 +1,1437 @@
+// 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.fluss;
+
+import org.apache.doris.connector.spi.Connector;
+import org.apache.doris.connector.spi.ConnectorSession;
+import org.apache.doris.connector.spi.DorisConnectorException;
+import org.apache.doris.connector.spi.handle.ConnectorColumnHandle;
+import org.apache.doris.connector.spi.handle.ConnectorTableHandle;
+import org.apache.doris.connector.spi.mvcc.ConnectorMvccSnapshot;
+import org.apache.doris.connector.spi.pushdown.ConnectorExpression;
+import org.apache.doris.connector.spi.scan.ConnectorScanPlanProvider;
+import org.apache.doris.connector.spi.scan.ConnectorScanRange;
+import org.apache.doris.connector.spi.scan.ConnectorScanRequest;
+import org.apache.doris.connector.spi.scan.ScanNodePropertyKeys;
+import org.apache.doris.thrift.TFileScanRangeParams;
+import org.apache.doris.thrift.TTableFormatFileDesc;
+
+import org.apache.fluss.client.admin.OffsetSpec;
+import org.apache.fluss.client.metadata.KvSnapshots;
+import org.apache.fluss.client.metadata.LakeSnapshot;
+import org.apache.fluss.client.table.scanner.log.LogScanner;
+import org.apache.fluss.metadata.PartitionInfo;
+import org.apache.fluss.metadata.TableBucket;
+import org.apache.fluss.metadata.TablePath;
+import org.apache.fluss.types.DataType;
+
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.HashSet;
+import java.util.LinkedHashMap;
+import java.util.LinkedHashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Optional;
+import java.util.Set;
+import java.util.function.Function;
+
+/**
+ * Turns a fluss table into the scan ranges that cover it.
+ *
+ * <p>A log table is read one bucket at a time, from the earliest offset fluss
still holds up to the
+ * offset the log had reached when planning ran. That stopping offset is taken
once, for all of a
+ * partition's buckets, so every bucket of a partition stops at the same view
of the table; without it
+ * a bucket planned later would read rows written after the query started
while an earlier bucket did
+ * not. A bucket that has never been written to (stopping offset 0) yields no
range at all.
+ *
+ * <p>A primary-key table cannot be read that way: its log is a change log, so
replaying it verbatim
+ * returns superseded and deleted rows. Each bucket is read instead as its
latest kv snapshot plus the
+ * change log that followed, which the scanner merges by key. A bucket fluss
has never snapshotted is
+ * rebuilt by replaying its whole change log, which is equally correct and
only slower.
+ *
+ * <p>Planning only reads metadata — partition lists, offsets and snapshot ids
— so it is safe to run
+ * for an {@code EXPLAIN}, which does reach {@code planScan}. (There is no
explain-only signal on the
+ * SPI in this branch; the point is that fluss does not need one. A future
change that takes a snapshot
+ * lease during planning would.)
+ *
+ * <p>A table that is tiered into a lake is read as the union of the two: the
lake at the snapshot fluss
+ * says is readable, plus each bucket's log from where that snapshot ended.
The lake half is not planned
+ * here — it is planned by the paimon sibling connector, pinned to that
snapshot, and its ranges are mixed
+ * into the same scan node, each wrapped ({@link FlussLakeRange}) so that
every range of the scan carries
+ * the one table format "fluss" and BE's fluss reader can dispatch it to the
sibling's reader stack. That
+ * keeps this plugin free of any paimon dependency and gives the lake half
paimon's native readers,
+ * deletion vectors and file cache for free. Refusing to serve such a table at
all is the alternative to
+ * avoid: a datalake table read as fluss-only silently returns just the rows
tiering has not moved yet,
+ * which looks like a working query. {@code fluss.union_read.mode=disabled} is
how a user asks for that
+ * fluss-only read on purpose.
+ *
+ * <p>A tiered PRIMARY-KEY table is read as a union too, but its halves
overlap by KEY rather than meeting
+ * at an offset: the log tail carries updates and deletes of rows the lake
already holds. It is split into
+ * three parts, per bucket. The lake splits of a bucket whose log has moved on
are wrapped with the
+ * offsets of that tail ({@code LAKE_SUPPRESS} rather than plain {@code
LAKE}), and BE drops the lake rows
+ * whose keys the tail names. The surviving state of the tail itself is
contributed once, by a
+ * {@code PK_TAIL} range. A bucket the lake has never seen is read whole from
fluss, as {@code PK_FULL},
+ * exactly as it would be without a lake. Every live-partition row is
therefore produced exactly once;
+ * lake-only historical partitions are carried as plain lake ranges because
Fluss has no current state
+ * with which to compare them.
+ *
+ * <p>Falling back to full Fluss reads is safe for every LIVE primary-key
partition, in a way it is not for
+ * a log table: Fluss keeps each live partition's state in full. Historical
partitions can outlive Fluss
+ * retention in the lake, so {@code auto} keeps those plain lake splits while
reading live partitions from
+ * Fluss. That is how it answers a key it cannot compare exactly or a tail the
log no longer holds without
+ * dropping history. {@code required} answers the same conditions with an
error: that mode exists to make
+ * "did this actually key-merge the lake?" answerable in a test.
+ */
+public class FlussScanPlanProvider implements ConnectorScanPlanProvider {
+
+ /**
+ * Prefix marking a node property that belongs to BE rather than to the
engine. Everything under it
+ * is copied verbatim into {@code TFileScanRangeParams.fluss_properties};
everything else (the
+ * engine's own keys, e.g. the partition keys) is not.
+ */
+ static final String BE_PROPERTY_PREFIX = "fluss.";
+
+ /**
+ * Prefix for the fluss client configuration. The scanner strips it and
hands the rest to fluss's
+ * {@code Configuration}, so the connector never has to enumerate fluss's
own option names.
+ */
+ static final String PROP_CLIENT_PREFIX = "fluss.client.";
+
+ static final String PROP_DB_NAME = "fluss.db_name";
+ static final String PROP_TABLE_NAME = "fluss.table_name";
+
+ /**
+ * Node property naming the key columns BE compares when it suppresses
lake rows: the primary key
+ * minus the partition columns, in Doris's own column names. Only the
names — the types travel the
+ * ordinary way, as the slot descriptors of columns that {@link
#getMustReadColumns} kept in the
+ * scan's tuple.
+ */
+ static final String PROP_UNION_PK_NAMES = "fluss.union.pk_names";
+
+ /** Node property carrying {@link
FlussCatalogProperties#UNION_READ_MAX_TAIL_ROWS} to both readers. */
+ static final String PROP_UNION_MAX_TAIL_ROWS = "fluss.union.max_tail_rows";
+
+ /**
+ * The property a lake split carries to say which bucket it holds. Written
by the paimon connector
+ * (from {@code DataSplit.bucket()}) for this connector's sake, and part
of the contract between the
+ * two: a fluss table's lake table is bucketed identically, so a lake
split can be matched with the
+ * log tail of the SAME bucket. Its absence is never treated as "this
bucket has no tail" — that would
+ * turn a version mismatch into duplicated rows — see {@link
#lakeSplitBucket}.
+ */
+ private static final String LAKE_BUCKET_PROPERTY = "paimon.bucket";
+
+ /** The only lake format that can be delegated today; fluss also defines
iceberg / lance / hudi. */
+ private static final String PAIMON_LAKE_FORMAT = "paimon";
+
+ /**
+ * The session variable that gives one statement its own union-read mode,
overriding
+ * {@link FlussCatalogProperties#UNION_READ_MODE}. Byte-identical to
+ * {@code SessionVariable.FLUSS_UNION_READ_MODE}, where it is declared and
from where the engine hands
+ * it to every connector through {@code
ConnectorSession.getSessionProperties()} — a connector reads
+ * such a flag by name and registers nothing (see the SPI's package-info).
+ *
+ * <p>It is a session variable and not a name suffix because a suffix can
only say which SEGMENT of a
+ * table is read. Which PATH a whole-table read takes is a property of the
statement, and the value
+ * that matters most for checking one — {@code required}, which refuses to
quietly read fluss alone —
+ * has no segment to name.
+ */
+ static final String SESSION_UNION_READ_MODE = "fluss_union_read_mode";
+
+ /**
+ * The SCAN-LEVEL table format, read by BE's scanner selection before it
fetches any range. Every reader
+ * this connector needs lives in {@code FileScannerV2} only, so a scan
planned here must use it whatever
+ * {@code enable_file_scanner_v2} says; the legacy scanner's JNI dispatch
has no fluss branch and would
+ * fail the query with {@code Not supported create reader for table
format}. Same literal as the per-range
+ * marker ({@link FlussScanRange#getTableFormatType}), and the mirror
image of the {@code
+ * transactional_hive} stamp, which uses this same channel to force the
OPPOSITE choice.
+ */
+ private static final String SCAN_LEVEL_TABLE_FORMAT =
FlussScanRange.TABLE_FORMAT;
+
+ private final FlussAdminOps adminOps;
+ private final FlussCatalogProperties catalogProperties;
+ private final Function<Map<String, String>, Connector> lakeSiblingFactory;
+
+ /**
+ * What the last {@link #planScan} produced, for the EXPLAIN line. Plain
fields, not volatile: the
+ * engine memoizes one provider instance per scan node and plans that node
on the FE planning
+ * thread, then renders EXPLAIN from the same thread afterwards. This
connector declares neither
+ * batch scan nor streaming splits, which are what would move planning off
that thread — enabling
+ * either means revisiting this (the ES provider carries the same caveat).
+ */
+ private int plannedLogRanges;
+ private int plannedPkRanges;
+ private int plannedPkTailRanges;
+ private int plannedLakeSplits;
+ private int plannedSuppressedLakeSplits;
+ private boolean plannedUnionRead;
+ private String plannedReadMode = READ_MODE_DEFAULT;
+
+ /** EXPLAIN spellings of {@link FlussTableHandle.ReadMode}, which is a
plan anchor and not a Java name. */
+ private static final String READ_MODE_DEFAULT = "default";
+ private static final String READ_MODE_LOG = "log";
+
+ /**
+ * This scan node's lake half, resolved at most once (see {@link
#resolveUnionRead}). Same threading
+ * argument as the counters above; {@code unionResolved} distinguishes
"not asked yet" from "asked, and
+ * this table has no lake half".
+ */
+ private boolean unionResolved;
+ private UnionRead unionRead;
+
+ /**
+ * The union-read mode this scan is planned under, and whether the
statement rather than the catalog
+ * supplied it. Written where the lake half is resolved, so that every
reader of the mode and the
+ * EXPLAIN line that reports it are the same answer by construction — a
plan that says {@code required}
+ * while it was planned under {@code auto} would be worse than no line at
all.
+ *
+ * <p>Starts at the catalog's value, which is what a scan that never
resolved is planned under.
+ */
+ private FlussCatalogProperties.UnionReadMode plannedUnionReadMode;
+ private boolean unionReadModeFromSession;
+
+ /**
+ * Why this scan gave up key-merging its live lake half, or null when it
did not. Only {@code auto} can
+ * get here. Live partitions fall back to full Fluss reads; retained
lake-only partitions remain in the
+ * plan when present, because Fluss no longer owns those rows.
+ */
+ private String degradedReason;
+ /** Schema-level reason live PK partitions must be read whole from Fluss,
if any. */
+ private String forcePkFlussOnlyReason;
+ /** Whether lake-only and live partitions cannot be distinguished safely
by rendered values. */
+ private boolean unsafePartitionIdentity;
+
+ /** {@link #degradedReason} when the log no longer holds the tail the lake
snapshot stops before. */
+ private static final String DEGRADED_TAIL_TRUNCATED = "tail-truncated";
+
+ /** {@link #degradedReason} when the tails this scan would have to hold
are over their ceiling. */
+ private static final String DEGRADED_TAIL_TOO_LARGE = "tail-too-large";
+
+ /** {@link #degradedReason} when a key column's values cannot be compared
exactly across the halves. */
+ private static final String DEGRADED_KEY_TYPE = "key-type";
+
+ /** {@link #degradedReason} when a partition column's values do not render
the same way on both sides. */
+ private static final String DEGRADED_PARTITION_TYPE = "partition-type";
+
+ public FlussScanPlanProvider(FlussAdminOps adminOps,
FlussCatalogProperties catalogProperties,
+ Function<Map<String, String>, Connector> lakeSiblingFactory) {
+ this.adminOps = adminOps;
+ this.catalogProperties = catalogProperties;
+ this.lakeSiblingFactory = lakeSiblingFactory;
+ this.plannedUnionReadMode = catalogProperties.getUnionReadMode();
+ }
+
+ /**
+ * The lake half of a union read: the sibling connector that owns it, its
scan planner, its table handle
+ * already pinned to {@link #snapshotId}, and where that snapshot left
each bucket's log.
+ *
+ * <p>A {@code $log} scan resolves the same snapshot for its offsets alone
and never reads the lake, so
+ * it gets a {@link #boundaryOnly} instance: the offsets and the snapshot
id, and no sibling. Everything
+ * that reads the lake half asks {@link #hasLakeHalf()} first — the field
is left null rather than
+ * filled with an unused sibling so that a missed check fails immediately
instead of quietly planning a
+ * lake read into a scan that promised not to do one.
+ */
+ private static final class UnionRead {
+ private final Connector sibling;
+ private final ConnectorScanPlanProvider siblingProvider;
+ private final ConnectorTableHandle pinnedLakeHandle;
+ private final long snapshotId;
+ private final Map<TableBucket, Long> logOffsets;
+
+ private UnionRead(Connector sibling, ConnectorScanPlanProvider
siblingProvider,
+ ConnectorTableHandle pinnedLakeHandle, long snapshotId,
+ Map<TableBucket, Long> logOffsets) {
+ this.sibling = sibling;
+ this.siblingProvider = siblingProvider;
+ this.pinnedLakeHandle = pinnedLakeHandle;
+ this.snapshotId = snapshotId;
+ this.logOffsets = logOffsets;
+ }
+
+ /** Where the lake snapshot leaves each bucket's log, with no lake
half to read. */
+ private static UnionRead boundaryOnly(long snapshotId,
Map<TableBucket, Long> logOffsets) {
+ return new UnionRead(null, null, null, snapshotId, logOffsets);
+ }
+
+ private boolean hasLakeHalf() {
+ return sibling != null;
+ }
+ }
+
+ @Override
+ public List<ConnectorScanRange> planScan(ConnectorSession session,
ConnectorScanRequest request) {
+ FlussTableHandle handle = (FlussTableHandle) request.getTableHandle();
+ UnionRead union = resolveUnionRead(session, handle);
+
+ List<ConnectorScanRange> ranges = union != null &&
handle.hasPrimaryKey()
+ ? planPrimaryKeyUnion(session, handle, union, request)
+ : planWithoutKeyMerging(session, handle, union, request);
+
+ plannedReadMode = handle.isLogOnly() ? READ_MODE_LOG :
READ_MODE_DEFAULT;
+ plannedLogRanges = count(ranges, FlussScanRange.RangeType.LOG);
+ plannedPkRanges = count(ranges, FlussScanRange.RangeType.PK_FULL);
+ plannedPkTailRanges = count(ranges, FlussScanRange.RangeType.PK_TAIL);
+ plannedLakeSplits = countLakeSplits(ranges);
+ plannedSuppressedLakeSplits = countSuppressedLakeSplits(ranges);
+ // Read back from the field, not from the local: a primary-key plan
may have given up its lake half
+ // half-way through, and EXPLAIN has to say what was actually planned.
+ plannedUnionRead = unionRead != null;
+ return ranges;
+ }
+
+ /**
+ * A log table, or any table read from fluss alone: every bucket end to
end, with the lake half — when
+ * there is one — prepended.
+ *
+ * <p>A {@code $log} scan is this same plan with the lake half left out.
Its log half is not merely
+ * similar to a union read's, it IS one — the same bucket ranges, from the
same snapshot offsets — which
+ * is what makes {@code tbl$lake} and {@code tbl$log} add up to {@code
tbl} rather than merely look as
+ * though they should.
+ */
+ private List<ConnectorScanRange> planWithoutKeyMerging(ConnectorSession
session,
+ FlussTableHandle handle, UnionRead union, ConnectorScanRequest
request) {
+ List<ConnectorScanRange> ranges = new ArrayList<>();
+ // The lake half first, so its ranges lead the list the way they lead
the table's history. It is
+ // planned once for the whole table: the sibling prunes partitions
from the pushed-down filter, not
+ // from the engine's pruned partition list (which it does not
consume). No key overlaps the log
+ // half here - the two halves meet at an offset - so every lake split
is wrapped plain.
+ if (union != null && union.hasLakeHalf()) {
+ for (ConnectorScanRange lakeSplit : planLakeRanges(session, union,
request)) {
+ ranges.add(FlussLakeRange.plain(lakeSplit));
+ }
+ }
+
+ if (handle.isPartitioned()) {
+ if (!request.isPartitionsPrunedToEmpty()) {
+ for (PartitionInfo partition : selectedPartitions(
+ handle, request.getRequiredPartitions())) {
+ // fluss's own partition name ("20260101$cn"), not the
Doris one: this is a fluss API.
+ appendPartitionRanges(ranges, handle, union,
+ FlussPartitions.toScanPartition(partition,
handle.getPartitionKeys()),
+ bucketsOf(handle, partition),
partition.getPartitionName());
+ }
+ }
+ } else {
+ appendPartitionRanges(ranges, handle, union,
FlussScanRange.Partition.NONE,
+ bucketsOf(handle, null), null);
+ }
+ return ranges;
+ }
+
+ /**
+ * A primary-key table read as its lake plus the log written since: lake
splits bound to the tail of
+ * their own bucket, plus one range per bucket for what the lake does not
hold.
+ *
+ * <p>The offsets are read BEFORE the lake half is planned, and that
ordering carries the last guard of
+ * the design (D17): a tail the log no longer holds cannot be read at all,
and a primary-key table
+ * cannot fetch it from the lake instead. Finding that out after asking
the sibling to plan would mean
+ * throwing its work away; finding it out here means the fluss-only read
that replaces it is planned
+ * from the very same offsets, and is the plan {@code disabled} would have
produced.
+ */
+ private List<ConnectorScanRange> planPrimaryKeyUnion(ConnectorSession
session,
+ FlussTableHandle handle, UnionRead union, ConnectorScanRequest
request) {
+ List<PartitionState> states = new ArrayList<>();
+ Set<Map<String, String>> livePartitionValues = new HashSet<>();
+ if (handle.isPartitioned()) {
+ List<PartitionInfo> livePartitions =
adminOps.listPartitionInfos(handle.toTablePath());
+ for (PartitionInfo partition : livePartitions) {
+ livePartitionValues.add(new
LinkedHashMap<>(FlussPartitions.toScanPartition(
+ partition, handle.getPartitionKeys()).getValues()));
+ }
+ if (!request.isPartitionsPrunedToEmpty()) {
+ for (PartitionInfo partition : selectedPartitions(
+ handle, request.getRequiredPartitions(),
livePartitions)) {
+ states.add(readPartitionState(handle, union,
+ FlussPartitions.toScanPartition(partition,
handle.getPartitionKeys()),
+ bucketsOf(handle, partition),
partition.getPartitionName()));
+ }
+ }
+ } else {
+ livePartitionValues.add(Collections.emptyMap());
+ states.add(readPartitionState(handle, union,
FlussScanRange.Partition.NONE,
+ bucketsOf(handle, null), null));
+ }
+
+ if (forcePkFlussOnlyReason != null) {
+ return degradedPkRanges(session, union, request, states,
livePartitionValues,
+ forcePkFlussOnlyReason);
+ }
+
+ String truncated = firstTruncatedTail(states);
+ if (truncated != null) {
+ if (plannedUnionReadMode ==
FlussCatalogProperties.UnionReadMode.REQUIRED) {
+ throw new DorisConnectorException("Table '" +
handle.getDatabaseName() + "."
+ + handle.getTableName() + "' cannot be read as its
lake plus its log: " + truncated
+ + ". Fluss has already deleted part of the log the
lake snapshot stops before, and a"
+ + " primary-key table's log cannot be re-read from the
lake. Set "
+ + unionReadModeSetting() + " to auto to read live
partitions from Fluss while"
+ + " retaining lake-only history, or disabled to read
only the current Fluss state.");
+ }
+ return degradedPkRanges(session, union, request, states,
livePartitionValues,
+ DEGRADED_TAIL_TRUNCATED);
+ }
+
+ // The offset distance is a conservative upper bound on how many
records each tail can hold, so a
+ // read estimated too large for memory can use a plan that does not
cache a tail. Doing it here rather
+ // than at read time also means the ceilings are reported once, from
the numbers that tripped them,
+ // instead of by whichever bucket happened to reach its limit first on
some BE.
+ String tooLarge = firstTailOverBudget(states);
+ if (tooLarge != null) {
+ if (plannedUnionReadMode ==
FlussCatalogProperties.UnionReadMode.REQUIRED) {
+ throw new DorisConnectorException("Table '" +
handle.getDatabaseName() + "."
+ + handle.getTableName() + "' cannot be read as its
lake plus its log: " + tooLarge
+ + ". Set " + unionReadModeSetting() + " to auto to
read live partitions from Fluss"
+ + " while retaining lake-only history, set it to
disabled to read only the current"
+ + " Fluss state, wait for tiering to move the tail
into the lake, or raise the"
+ + " ceiling.");
+ }
+ return degradedPkRanges(session, union, request, states,
livePartitionValues,
+ DEGRADED_TAIL_TOO_LARGE);
+ }
+
+ List<ConnectorScanRange> ranges = new ArrayList<>();
+ for (ConnectorScanRange lakeSplit : planLakeRanges(session, union,
request)) {
+ ConnectorScanRange classified = bindTailToLakeSplit(
+ handle, lakeSplit, states, livePartitionValues);
+ if (classified != null) {
+ ranges.add(classified);
+ }
+ }
+ for (PartitionState state : states) {
+ for (Map.Entry<Integer, BucketState> entry :
state.buckets.entrySet()) {
+ int bucket = entry.getKey();
+ BucketState bucketState = entry.getValue();
+ if (bucketState.lakeEnd == null) {
+ // Never tiered: the lake holds nothing of this bucket, so
it is read exactly as it
+ // would be with no lake at all, and no lake split of it
can be suppressed.
+ appendPkFullRange(ranges, state, bucket);
+ } else if (bucketState.lakeEnd < bucketState.stop) {
+ ranges.add(FlussScanRange.pkTail(state.partition, bucket,
+ bucketState.lakeEnd, bucketState.stop));
+ }
+ // lakeEnd == stop: the lake holds this bucket entirely and
fluss adds nothing.
+ }
+ }
+ return ranges;
+ }
+
+ /**
+ * Falls back to full Fluss reads for live partitions while retaining
historical partitions that now
+ * exist only in the lake. Dropping those plain lake splits would make an
availability fallback return
+ * fewer rows than either source still owns.
+ */
+ private List<ConnectorScanRange> degradedPkRanges(ConnectorSession
session, UnionRead union,
+ ConnectorScanRequest request, List<PartitionState> states,
+ Set<Map<String, String>> livePartitionValues, String reason) {
+ List<ConnectorScanRange> ranges = new ArrayList<>();
+ for (ConnectorScanRange lakeSplit : planLakeRanges(session, union,
request)) {
+ if (!livePartitionValues.contains(lakeSplit.getPartitionValues()))
{
+ if (unsafePartitionIdentity) {
+ throw new DorisConnectorException("The paimon lake planned
partition values "
+ + lakeSplit.getPartitionValues() + " that cannot
be matched safely to a live"
+ + " fluss partition because this table's partition
type has no stable shared"
+ + " rendering. The split may be retained lake-only
history or a live partition;"
+ + " refusing it avoids either duplicate or missing
rows");
+ }
+ ranges.add(FlussLakeRange.plain(lakeSplit));
+ }
+ }
+ boolean retainedLakeHistory = !ranges.isEmpty();
+ ranges.addAll(pkRangesFromFlussAlone(states));
+ recordDegradation(reason, retainedLakeHistory);
+ return ranges;
+ }
+
+ /**
+ * What planning read about one partition of a primary-key table, or about
an unpartitioned one.
+ *
+ * <p>{@link #buckets} is keyed by this partition's OWN buckets, in order,
and everything that walks a
+ * state walks that map rather than a table-wide bucket list: since fluss
1.0.0 the partitions of one
+ * table need not have the same number of buckets (see {@link #bucketsOf}).
+ */
+ private static final class PartitionState {
+ private final FlussScanRange.Partition partition;
+ private final KvSnapshots snapshots;
+ private final Map<Integer, BucketState> buckets;
+
+ private PartitionState(FlussScanRange.Partition partition, KvSnapshots
snapshots,
+ Map<Integer, BucketState> buckets) {
+ this.partition = partition;
+ this.snapshots = snapshots;
+ this.buckets = buckets;
+ }
+ }
+
+ /** Where one bucket's log begins, ends, and how much of it the lake
already holds. */
+ private static final class BucketState {
+ /** First offset NOT in the lake, or null when the lake snapshot does
not mention this bucket. */
+ private final Long lakeEnd;
+ /** Where planning saw the log end; 0 for a bucket nothing has ever
been written to. */
+ private final long stop;
+ /** The earliest offset fluss still holds, or null when it did not
answer for this bucket. */
+ private final Long earliest;
+
+ private BucketState(Long lakeEnd, long stop, Long earliest) {
+ this.lakeEnd = lakeEnd;
+ this.stop = stop;
+ this.earliest = earliest;
+ }
+ }
+
+ /**
+ * Everything planning needs about one partition, read in the one order
that is safe: kv snapshots,
+ * then the offsets that bound them, then the earliest offsets the guard
compares against.
+ *
+ * <p>The kv snapshots are read even when every bucket turns out to be
tiered and none of them is used.
+ * That is deliberate: giving up the lake half is decided AFTER the
offsets are in hand, and the
+ * fluss-only plan that replaces it needs the snapshots — read at that
point they would be newer than
+ * the offsets that bound them, which is the one ordering this planner
exists to prevent.
+ */
+ private PartitionState readPartitionState(FlussTableHandle handle,
UnionRead union,
+ FlussScanRange.Partition partition, List<Integer> buckets, String
flussPartitionName) {
+ TablePath tablePath = handle.toTablePath();
+ KvSnapshots snapshots = latestKvSnapshots(tablePath,
flussPartitionName);
+ Map<Integer, Long> stopping = latestOffsets(tablePath,
flussPartitionName, buckets);
+ // Last, and that order is what makes it conservative: the earliest
offset only moves forward, so
+ // one read later can only make the guard stricter, never let a
truncated tail through.
+ Map<Integer, Long> earliest = earliestOffsets(tablePath,
flussPartitionName, buckets);
+
+ Map<Integer, BucketState> byBucket = new LinkedHashMap<>();
+ for (int bucket : buckets) {
+ TableBucket tableBucket = partition.isPartitioned()
+ ? new TableBucket(handle.getTableId(), partition.getId(),
bucket)
+ : new TableBucket(handle.getTableId(), bucket);
+ Long stop = stopping.get(bucket);
+ byBucket.put(bucket, new
BucketState(union.logOffsets.get(tableBucket),
+ stop == null ? 0L : stop, earliest.get(bucket)));
+ }
+ return new PartitionState(partition, snapshots, byBucket);
+ }
+
+ /**
+ * The first bucket whose tail fluss can no longer serve, described for an
error message, or null when
+ * every tail is intact.
+ *
+ * <p>A tail is the log from where the lake snapshot ended up to where
planning saw the log. Fluss
+ * deletes old log segments on a timer that does not wait for tiering
(only for the kv snapshot), so on
+ * a cluster without remote log storage the beginning of that tail can be
gone. Reading it anyway
+ * returns fewer rows than the table holds, and unlike a log table there
is nowhere else to get them
+ * from — the lake's copy stops exactly where the missing tail begins.
+ *
+ * <p>A bucket fluss did not answer for at all counts as truncated: the
check is the only thing
+ * standing between a deleted tail and a silently short answer, and "could
not verify" is not "fine".
+ */
+ private static String firstTruncatedTail(List<PartitionState> states) {
+ for (PartitionState state : states) {
+ for (Map.Entry<Integer, BucketState> entry :
state.buckets.entrySet()) {
+ int bucket = entry.getKey();
+ BucketState bucketState = entry.getValue();
+ if (bucketState.lakeEnd == null || bucketState.lakeEnd >=
bucketState.stop) {
+ continue;
+ }
+ String where = (state.partition.isPartitioned()
+ ? "partition '" + state.partition.getName() + "', " :
"") + "bucket " + bucket;
+ if (bucketState.earliest == null) {
+ return where + " (fluss did not report an earliest offset
for it)";
+ }
+ if (bucketState.earliest > bucketState.lakeEnd) {
+ return where + " (the lake ends at offset " +
bucketState.lakeEnd
+ + ", but the log now starts at " +
bucketState.earliest + ")";
+ }
+ }
+ }
+ return null;
+ }
+
+ /**
+ * The first ceiling a tail of this scan would breach, described for a
message, or null when the scan
+ * fits under both.
+ *
+ * <p>Two ceilings, because they answer two different questions. The
per-bucket one is what the readers
+ * enforce while they replay a tail, and reaching it means tiering has
stalled for that bucket. The
+ * scan-wide one has no per-reader equivalent: BE holds the keys of every
tail it has touched until the
+ * scan ends, so a table with many partitions and many buckets can hold
far more than any one of them
+ * would ever report. Summed over the whole scan rather than per BE
because planning does not decide
+ * which BE gets which split, and over-counting a scan that fits is the
safe direction.
+ */
+ private String firstTailOverBudget(List<PartitionState> states) {
+ long maxTailRows = catalogProperties.getMaxTailRows();
+ long total = 0;
+ for (PartitionState state : states) {
+ for (Map.Entry<Integer, BucketState> entry :
state.buckets.entrySet()) {
+ int bucket = entry.getKey();
+ BucketState bucketState = entry.getValue();
+ if (bucketState.lakeEnd == null || bucketState.lakeEnd >=
bucketState.stop) {
+ continue;
+ }
+ // Offset distance is a conservative upper bound, not an exact
record count: control
+ // records consume offsets but are not returned by the
scanner. The readers enforce the
+ // exact row count; planning can therefore only reject early,
never let an oversized tail
+ // through.
+ long rows = bucketState.stop - bucketState.lakeEnd;
+ String where = (state.partition.isPartitioned()
+ ? "partition '" + state.partition.getName() + "', " :
"") + "bucket " + bucket;
+ if (rows > maxTailRows) {
+ return where + " has a log tail of " + rows + " records,
over the "
+ + maxTailRows + " allowed by '"
+ + FlussCatalogProperties.UNION_READ_MAX_TAIL_ROWS
+ "'";
+ }
+ total += rows;
+ }
+ }
+ long maxTotal = catalogProperties.getMaxTotalTailRows();
+ if (total > maxTotal) {
+ return "its log tails hold " + total + " records in total, over
the " + maxTotal
+ + " allowed by '" +
FlussCatalogProperties.UNION_READ_MAX_TOTAL_TAIL_ROWS + "'";
+ }
+ return null;
+ }
+
+ /** Every bucket read whole from fluss: the plan {@code disabled}
produces, and the safe fallback. */
+ private static List<ConnectorScanRange>
pkRangesFromFlussAlone(List<PartitionState> states) {
+ List<ConnectorScanRange> ranges = new ArrayList<>();
+ for (PartitionState state : states) {
+ for (int bucket : state.buckets.keySet()) {
+ appendPkFullRange(ranges, state, bucket);
+ }
+ }
+ return ranges;
+ }
+
+ /**
+ * Records a live-partition fallback for {@code reason}. When no retained
history remains, clear the
+ * lake half entirely; otherwise keep it so scan properties and EXPLAIN
describe the plain lake ranges
+ * that are still part of the answer.
+ *
+ * <p>One question was asked earlier and cannot be taken back: {@link
#getMustReadColumns}, at plan
+ * translation time, may already have kept the key columns in the scan's
tuple. That is harmless in
+ * this direction — they are read and then dropped by the projection above
the scan — and it is the
+ * reason this guard is allowed to run so late. The opposite order would
not be harmless, which is why
+ * the conditions that CAN be decided before translation (a key column's
type) are decided there.
+ */
+ private void recordDegradation(String reason, boolean retainedLakeHistory)
{
+ degradedReason = reason;
+ if (!retainedLakeHistory) {
+ unionRead = null;
+ }
+ }
+
+ /**
+ * The lake split, wrapped with the log tail of its own bucket when that
tail holds anything.
+ *
+ * <p>Which bucket a split belongs to is the sibling's answer ({@code
paimon.bucket}); which partition
+ * it belongs to is compared by the rendered partition values, which is
sound because a union read of a
+ * partitioned table is refused unless the partition columns are strings.
Three things can go wrong,
+ * and none of them may be waved through: a split with no bucket at all
means the paimon connector does
+ * not write the property this contract is built on; a bucket number this
table does not have means the
+ * two tables are not bucketed alike; a bucket the lake holds files for
but fluss records no tiering
+ * offset for means their metadata disagrees. Each would silently
duplicate rows.
+ */
+ private ConnectorScanRange bindTailToLakeSplit(FlussTableHandle handle,
ConnectorScanRange lakeSplit,
+ List<PartitionState> states, Set<Map<String, String>>
livePartitionValues) {
+ PartitionState state = matchingPartition(lakeSplit, states);
+ if (state == null) {
+ if (livePartitionValues.contains(lakeSplit.getPartitionValues())) {
+ // The partition still exists in Fluss but the engine pruned
it from this scan. Its lake
+ // split must be pruned too; treating it as retained history
would defeat partition pruning.
+ return null;
+ }
+ // Fluss expires partitions independently of the lake. With no
live partition there can be no
+ // post-snapshot tail to suppress, and the retained lake split is
the only remaining copy.
+ return FlussLakeRange.plain(lakeSplit);
+ }
+ int bucket = lakeSplitBucket(handle, lakeSplit);
+ BucketState bucketState = state.buckets.get(bucket);
+ if (bucketState == null) {
+ // The partition's own count, not the table's: after a bucket
rescale the two can differ, and
+ // it is the partition's buckets this split is being matched
against.
+ throw new DorisConnectorException("The lake table of fluss table
'" + handle.getDatabaseName()
+ + "." + handle.getTableName() + "' has a split in bucket "
+ bucket + ", but "
+ + (state.partition.isPartitioned()
+ ? "partition '" + state.partition.getName() + "'
of the fluss table"
+ : "the fluss table")
+ + " has only " + state.buckets.size() + " buckets. The two
are not bucketed alike, so"
+ + " their rows cannot be matched by bucket");
+ }
+ if (bucketState.lakeEnd == null) {
+ throw new DorisConnectorException("The lake table of fluss table
'" + handle.getDatabaseName()
+ + "." + handle.getTableName() + "' holds data for bucket "
+ bucket
+ + (state.partition.isPartitioned()
+ ? " of partition '" + state.partition.getName() +
"'" : "")
+ + ", but fluss records no tiering offset for that bucket.
Their metadata disagrees;"
+ + " reading them as one would return the rows of that
bucket twice");
+ }
+ if (bucketState.lakeEnd >= bucketState.stop) {
+ // The lake holds this bucket up to where its log ends: nothing
can supersede it.
+ return FlussLakeRange.plain(lakeSplit);
+ }
+ return FlussLakeRange.suppressed(lakeSplit, new FlussLakeRange.Tail(
+ state.partition, bucket, bucketState.lakeEnd,
bucketState.stop));
+ }
+
+ /**
+ * The partition this scan planned that {@code lakeSplit} belongs to, or
null when it planned none.
+ * Matched on the partition values as a whole rather than on a rendered
name, so that there is exactly
+ * one place in this connector that turns partition values into a name
(see {@link FlussPartitions}).
+ */
+ private static PartitionState matchingPartition(ConnectorScanRange
lakeSplit,
+ List<PartitionState> states) {
+ Map<String, String> values = lakeSplit.getPartitionValues();
+ for (PartitionState state : states) {
+ if (state.partition.getValues().equals(values)) {
+ return state;
+ }
+ }
+ return null;
+ }
+
+ /** The bucket a lake split holds, per the contract in {@link
#LAKE_BUCKET_PROPERTY}. */
+ private static int lakeSplitBucket(FlussTableHandle handle,
ConnectorScanRange lakeSplit) {
+ String bucket = lakeSplit.getProperties().get(LAKE_BUCKET_PROPERTY);
+ if (bucket == null) {
+ throw new DorisConnectorException("A lake split of fluss table '"
+ handle.getDatabaseName()
+ + "." + handle.getTableName() + "' does not say which
bucket it holds ('"
+ + LAKE_BUCKET_PROPERTY + "' is missing). Reading a
primary-key table together with its"
+ + " lake needs it, to bind each split to the log tail of
the same bucket; the paimon"
+ + " connector plugin is older than this fluss connector
plugin. Split: " + lakeSplit);
+ }
+ try {
+ return Integer.parseInt(bucket);
+ } catch (NumberFormatException e) {
+ throw new DorisConnectorException("A lake split of fluss table '"
+ handle.getDatabaseName()
+ + "." + handle.getTableName() + "' reports bucket '" +
bucket + "', which is not a"
+ + " number", e);
+ }
+ }
+
+ /**
+ * The lake half, planned by the sibling connector on the handle already
pinned to the readable snapshot.
+ *
+ * <p>Only the filter is carried over. The row limit is deliberately
dropped — applying it to one half of
+ * a union would silently drop rows from the other — and so is the {@code
COUNT(*)} signal, because a
+ * per-range row count is not this table's count once the log half is
added. The engine's pruned
+ * partition list is not passed either: the sibling does not consume it
(it re-plans from the filter),
+ * and pretending otherwise would hide that the two halves prune by
different means.
+ */
+ private List<ConnectorScanRange> planLakeRanges(ConnectorSession session,
UnionRead union,
+ ConnectorScanRequest request) {
+ List<ConnectorColumnHandle> lakeColumns =
+ lakeColumns(session, union, request.getColumns(),
request.getTableHandle());
+ ConnectorScanRequest lakeRequest =
+ ConnectorScanRequest.builder(union.pinnedLakeHandle,
lakeColumns)
+ .filter(request.getFilter())
+ .build();
+ return LakeSibling.call(union.sibling,
+ () -> union.siblingProvider.planScan(session, lakeRequest));
+ }
+
+ /**
+ * The sibling's own column handles for the columns this scan reads.
+ *
+ * <p>Needed because the sibling projects by ITS handle type and silently
ignores anything else: handing
+ * it fluss's handles would leave it with no projection at all. The lake
table fluss 1.0.0 tiers into
+ * carries this table's columns under the same names and no fluss system
columns (the {@code __bucket} /
+ * {@code __offset} / {@code __timestamp} columns earlier releases
appended are gone; paimon 2.0's
+ * {@code lakestream.enabled} table option took their place), plus the
invisible metadata columns the
+ * sibling itself declares ({@code __paimon_file_path} / {@code
__paimon_row_index}), which are simply
+ * never asked for. A column that is missing is a real mismatch between
the two schemas — the lake table
+ * was not created by this fluss table's tiering — and fails loud rather
than reading a silently narrower
+ * row.
+ */
+ private List<ConnectorColumnHandle> lakeColumns(ConnectorSession session,
UnionRead union,
+ List<ConnectorColumnHandle> columns, ConnectorTableHandle handle) {
+ Map<String, ConnectorColumnHandle> lakeHandles =
LakeSibling.forward(session, union.sibling,
+ metadata -> metadata.getColumnHandles(session,
union.pinnedLakeHandle));
+ List<ConnectorColumnHandle> mapped = new ArrayList<>(columns.size());
+ for (ConnectorColumnHandle column : columns) {
+ String name = ((FlussColumnHandle) column).getName();
+ ConnectorColumnHandle lakeHandle = lakeHandles.get(name);
+ if (lakeHandle == null) {
+ FlussTableHandle flussHandle = (FlussTableHandle) handle;
+ throw new DorisConnectorException("Column '" + name + "' of
fluss table '"
+ + flussHandle.getDatabaseName() + "." +
flussHandle.getTableName()
+ + "' does not exist in its lake table, so the two
cannot be read as one");
+ }
+ mapped.add(lakeHandle);
+ }
+ return mapped;
+ }
+
+ /**
+ * This scan node's lake half, or null when the table is read from fluss
alone. Resolved at most once per
+ * scan node because two entry points need it — {@link #planScan} and
{@link #getScanNodeProperties} —
+ * and the engine may call either first.
+ *
+ * <p>Resolving it once is also what keeps the two halves from
overlapping. The lake snapshot is read
+ * HERE, before {@link #planScan} asks fluss where each bucket's log
currently ends; log offsets only
+ * move forward, so the snapshot can only be at or behind those stopping
offsets. Read the other way
+ * round, a snapshot committed in between would cover rows past the point
the log half stops at, and the
+ * bucket's log range would start after it ends.
+ */
+ private UnionRead resolveUnionRead(ConnectorSession session,
FlussTableHandle handle) {
+ if (unionResolved) {
+ return unionRead;
+ }
+ unionResolved = true;
+ unionRead = resolveUnionReadUncached(session, handle);
+ return unionRead;
+ }
+
+ private UnionRead resolveUnionReadUncached(ConnectorSession session,
FlussTableHandle handle) {
+ // Resolved for every fluss scan, including the $log scans below that
go on to ignore it, so that a
+ // statement is refused for a mode it cannot mean wherever it set one
-- a value this connector
+ // cannot parse is a request it cannot honour, whichever table the
statement then reads.
+ FlussCatalogProperties.UnionReadMode mode =
resolveUnionReadMode(session);
+ // A $log scan asks a different question of the same resolution, so it
reads the mode differently.
+ // The union-read mode chooses a PATH for reading the base table, and
$log is not the base table: it
+ // is defined AS "the part past the lake snapshot", so there is no
path here to choose and the mode
+ // does not apply. A base-table read may use only the state Fluss
still retains, but $log cannot
+ // substitute such a scan: it must start at the lake boundary promised
by its name.
+ if (handle.isLogOnly()) {
+ return resolveLakeBoundary(session, handle);
+ }
+ if (!handle.isDataLakeEnabled() || mode ==
FlussCatalogProperties.UnionReadMode.DISABLED) {
+ // Not a lake table, or the user asked for the fluss-only read
explicitly.
+ return null;
+ }
+ if (handle.hasPrimaryKey()) {
+ // Whether the two halves can be matched by key at all is decided
HERE, before the lake snapshot
+ // is asked for, because it depends only on the table's schema —
and because this is the one
+ // decision that must be the same at plan-translation time (when
getMustReadColumns keeps the key
+ // columns in the scan's tuple) as it is later, when the ranges
are planned. Everything that can
+ // only be known from the offsets is decided in planScan, where
giving up is still safe.
+ String rejection = keyColumnRejection(handle);
+ String reason = DEGRADED_KEY_TYPE;
+ String partitionRejection = partitionColumnRejection(handle);
+ if (rejection == null && partitionRejection != null) {
+ rejection = partitionRejection;
+ reason = DEGRADED_PARTITION_TYPE;
+ }
+ if (rejection != null) {
+ if (mode == FlussCatalogProperties.UnionReadMode.REQUIRED) {
+ throw new DorisConnectorException("Table '" +
handle.getDatabaseName() + "."
+ + handle.getTableName() + "' cannot be read as its
lake plus its change log: "
+ + rejection + ". Set " + unionReadModeSetting()
+ + " to auto to read live partitions from fluss, or
disabled to explicitly"
+ + " read only the current fluss state.");
+ }
+ if (!handle.isPartitioned()) {
+ // With no partitions there is no retained lake-only
history to preserve. A full PK
+ // read from Fluss is the complete current table and needs
no lake metadata at all.
+ degradedReason = reason;
+ return null;
+ }
+ forcePkFlussOnlyReason = reason;
+ unsafePartitionIdentity = partitionRejection != null;
+ // Record the schema-level fallback even when no readable lake
snapshot exists yet.
+ // Otherwise EXPLAIN makes the same table look as though it
took the ordinary no-snapshot
+ // fallback and hides the incompatibility that will still
apply once tiering publishes one.
+ degradedReason = reason;
+ }
+ }
+ Optional<LakeSnapshot> readableSnapshot =
FlussStatementScope.sharedLakeSnapshot(
+ session, handle.toTablePath(),
+ () -> adminOps.getReadableLakeSnapshot(handle.toTablePath()));
+ if (!readableSnapshot.isPresent()) {
+ if (mode == FlussCatalogProperties.UnionReadMode.REQUIRED) {
+ throw new DorisConnectorException("Table '" +
handle.getDatabaseName() + "."
+ + handle.getTableName() + "' has no readable lake
snapshot yet, and "
+ + unionReadModeSetting() + " = required forbids
falling back to "
+ + "a fluss-only read. Wait for the tiering service to
commit, or set it "
+ + "to auto or disabled.");
+ }
+ // There is no readable lake boundary to combine with the log, so
auto uses current Fluss state.
+ return null;
+ }
+ LakeSnapshot snapshot = readableSnapshot.get();
+ String lakeFormat = handle.getDataLakeFormat();
+ if (lakeFormat == null ||
!PAIMON_LAKE_FORMAT.equalsIgnoreCase(lakeFormat)) {
+ throw new DorisConnectorException("Cannot read table '" +
handle.getDatabaseName() + "."
+ + handle.getTableName() + "': its table.datalake.format is
'" + lakeFormat
+ + "', and the fluss connector currently supports only '" +
PAIMON_LAKE_FORMAT + "'");
+ }
+
+ Connector sibling =
lakeSiblingFactory.apply(PaimonSiblingProperties.synthesize(
+ catalogProperties.getRawCatalogProperties(),
handle.getProperties(),
+ catalogProperties.getLakeOverrides()));
+ ConnectorTableHandle lakeHandle = LakeSibling.forward(session, sibling,
+ metadata -> metadata.getTableHandle(
+ session, handle.getLakeDatabaseName(),
handle.getLakeTableName()))
+ .orElseThrow(() -> new DorisConnectorException("Fluss reports
a readable lake snapshot for '"
+ + handle.getDatabaseName() + "." +
handle.getTableName() + "' but its lake table"
+ + " does not exist. The lake warehouse and the fluss
cluster disagree; check the"
+ + " table's table.datalake.* settings"));
+ // The lake half's ranges are planned by the sibling and mixed into
this node's range list, where
+ // they are told apart from fluss's by which connector owns them.
Checked at birth; see requireOwned.
+ LakeSibling.requireOwned(sibling, lakeHandle);
+ // This is a statement fence on DATA, not user-visible schema time
travel. An empty-properties pin
+ // tells the paimon sibling to hold file planning at the
Fluss-readable snapshot while retaining the
+ // current query schema, so a nullable column added since that
snapshot is materialized as NULL for
+ // older lake rows. The explicit $lake view resolves time travel
separately and keeps historical
+ // schema semantics.
+ ConnectorMvccSnapshot pin = ConnectorMvccSnapshot.builder()
+ .snapshotId(snapshot.getSnapshotId())
+ .build();
+ ConnectorTableHandle pinnedHandle = LakeSibling.forward(session,
sibling,
+ metadata -> metadata.applySnapshot(session, lakeHandle, pin));
+ ConnectorScanPlanProvider siblingProvider = LakeSibling.call(sibling,
+ () -> sibling.getScanPlanProvider(pinnedHandle));
+ return new UnionRead(sibling, siblingProvider, pinnedHandle,
snapshot.getSnapshotId(),
+ snapshot.getTableBucketsOffset());
+ }
+
+ /**
+ * The mode this scan reads under: the statement's, when it set one, and
the catalog's otherwise.
+ *
+ * <p>A session variable overrides a catalog property rather than the
other way round because it is the
+ * narrower statement of intent — the catalog says how its tables are read
in general, and one query
+ * says how it wants to be answered. That is also the only order under
which {@code required} is worth
+ * having: its use is to check a catalog's own setting from outside it.
+ *
+ * <p>Resolved once per scan node, with the lake half, which is what keeps
the answer stable between
+ * plan translation and range planning (see {@link #getMustReadColumns}).
The engine hands both the
+ * same session object, so the two would agree even without the memo;
resolving here rather than
+ * separately is what makes that a property of this code and not of the
engine's.
+ */
+ private FlussCatalogProperties.UnionReadMode
resolveUnionReadMode(ConnectorSession session) {
+ String statementValue = sessionUnionReadMode(session);
+ unionReadModeFromSession = statementValue != null;
+ plannedUnionReadMode = unionReadModeFromSession
+ ? FlussCatalogProperties.parseUnionReadMode(statementValue,
+ "session variable '" + SESSION_UNION_READ_MODE + "'")
+ : catalogProperties.getUnionReadMode();
+ return plannedUnionReadMode;
+ }
+
+ /**
+ * What this statement set {@link #SESSION_UNION_READ_MODE} to, or null
when it set nothing.
+ *
+ * <p>Blank reads as unset, matching the variable's own default and the
way the property binder treats
+ * a blank value, so {@code set fluss_union_read_mode = ''} is how a
session gives the catalog back.
+ */
+ private static String sessionUnionReadMode(ConnectorSession session) {
+ if (session == null) {
+ return null;
+ }
+ String value =
session.getSessionProperties().get(SESSION_UNION_READ_MODE);
+ return value == null || value.trim().isEmpty() ? null : value;
+ }
+
+ /**
+ * How to name the setting that put this scan in its mode, for a message
that asks for it to be
+ * changed. Naming the catalog property while a statement is overriding it
would send the user to edit
+ * a value their own session is already ignoring.
+ */
+ private String unionReadModeSetting() {
+ return unionReadModeFromSession
+ ? "session variable '" + SESSION_UNION_READ_MODE + "'"
+ : "property '" + FlussCatalogProperties.UNION_READ_MODE + "'";
+ }
+
+ /**
+ * Where the lake ends, for a {@code $log} scan — which is the whole of
what such a scan needs from the
+ * lake, and it needs it absolutely.
+ *
+ * <p>Both failures below are the same failure seen twice: {@code $log}
names the log PAST the lake, so
+ * without a lake there is no such segment. Answering with an unbounded
Fluss scan instead would be the
+ * one mistake that cannot be noticed downstream — the plan looks exactly
like a correct one, but its
+ * rows are not constrained to the segment promised by the name.
+ */
+ private UnionRead resolveLakeBoundary(ConnectorSession session,
FlussTableHandle handle) {
+ if (!handle.isDataLakeEnabled()) {
+ // Reachable: the lake can be turned off between resolving the
name and planning the scan.
+ throw new DorisConnectorException("Table '" +
handle.getDatabaseName() + "."
+ + handle.getTableName() + "' is no longer tiered into a
lake (table.datalake.enabled),"
+ + " so it has no '$log' part. Query '" +
handle.getTableName() + "' itself.");
+ }
+ Optional<LakeSnapshot> readableSnapshot =
FlussStatementScope.sharedLakeSnapshot(
+ session, handle.toTablePath(),
+ () -> adminOps.getReadableLakeSnapshot(handle.toTablePath()));
+ if (!readableSnapshot.isPresent()) {
+ throw new DorisConnectorException("Table '" +
handle.getDatabaseName() + "."
+ + handle.getTableName() + "' has no readable lake snapshot
yet, so '$log' has no point"
+ + " to start from: nothing has been tiered, and the whole
table is still in the log."
+ + " Query '" + handle.getTableName() + "' itself, or wait
for the tiering service to"
+ + " commit.");
+ }
+ LakeSnapshot snapshot = readableSnapshot.get();
+ return UnionRead.boundaryOnly(snapshot.getSnapshotId(),
snapshot.getTableBucketsOffset());
+ }
+
+ /**
+ * Why this table's rows cannot be matched across the two halves by their
keys, or null when they can.
+ * The key is the primary key minus the partition columns, because a
bucket lives inside one partition
+ * and the partition columns are equal for every row in it.
+ */
+ private static String keyColumnRejection(FlussTableHandle handle) {
+ for (String column : handle.getPhysicalPrimaryKeys()) {
+ DataType type = handle.getKeyColumnTypes().get(column);
+ if (type == null) {
+ // The handle names a key column the schema it was built from
does not have.
+ return "its primary key names column '" + column + "', which
the table does not have";
+ }
+ String rejection = FlussUnionKeyTypes.keyColumnRejection(type);
+ if (rejection != null) {
+ return "primary-key column '" + column + "' has type " + type
+ ", and " + rejection;
+ }
+ }
+ return null;
+ }
+
+ /** The same question for the partition columns, which decide which bucket
a lake split belongs to. */
+ private static String partitionColumnRejection(FlussTableHandle handle) {
+ for (String column : handle.getPartitionKeys()) {
+ DataType type = handle.getKeyColumnTypes().get(column);
+ if (type == null) {
+ return "it is partitioned by column '" + column + "', which
the table does not have";
+ }
+ String rejection =
FlussUnionKeyTypes.partitionColumnRejection(type);
+ if (rejection != null) {
+ return "partition column '" + column + "' has type " + type +
", and " + rejection;
+ }
+ }
+ return null;
+ }
+
+ /**
+ * The key columns BE has to read whether or not the query asked for them,
so that it can tell which
+ * lake rows the log tail supersedes. Empty for every other read, which is
every read but this one.
+ *
+ * <p>Asked by the engine while it is translating the plan, well before
{@link #planScan}, and answered
+ * from the SAME memoized resolution — that is a correctness requirement,
not a saving. If the two
+ * answers could differ, a scan whose tuple was pruned as a fluss-only
read could still be planned as a
+ * union read, and BE would look for a key column that is not in its
projection.
+ *
+ * <p>Only a primary-key table is asked further, so that a log table's
scan does not pull the lake
+ * snapshot's round trip forward into plan translation for nothing.
+ */
+ @Override
+ public Set<String> getMustReadColumns(ConnectorSession session,
ConnectorTableHandle handle) {
+ FlussTableHandle flussHandle = (FlussTableHandle) handle;
+ if (!flussHandle.hasPrimaryKey() || resolveUnionRead(session,
flussHandle) == null
+ || forcePkFlussOnlyReason != null) {
+ return Collections.emptySet();
+ }
+ return new LinkedHashSet<>(flussHandle.getPhysicalPrimaryKeys());
+ }
+
+ /**
+ * The ranges covering one partition of a table, or the whole of an
unpartitioned one
+ * ({@code flussPartitionName} is null, which is also how the two admin
overloads are told apart).
+ */
+ private void appendPartitionRanges(List<ConnectorScanRange> ranges,
FlussTableHandle handle,
+ UnionRead union, FlussScanRange.Partition partition, List<Integer>
buckets,
+ String flussPartitionName) {
+ TablePath tablePath = handle.toTablePath();
+ if (!handle.hasPrimaryKey()) {
+ Map<Integer, Long> stopping = latestOffsets(tablePath,
flussPartitionName, buckets);
+ if (union == null) {
+ appendLogRanges(ranges, partition, buckets, stopping);
+ } else {
+ Map<Integer, Long> earliest = earliestOffsets(tablePath,
flussPartitionName, buckets);
+ appendUnionLogRanges(ranges, handle, union, partition,
buckets, stopping, earliest);
+ }
+ return;
+ }
+ // Snapshots BEFORE offsets, and the order is load-bearing. A snapshot
committed between the two
+ // calls ends past the offset planning stopped at; that bucket would
then be read from a snapshot
+ // already containing rows written after the query started, while
every other bucket stopped
+ // where planning saw it. Log offsets only move forward, so asking in
this order keeps every
+ // snapshot at or behind the stopping offset.
+ KvSnapshots snapshots = latestKvSnapshots(tablePath,
flussPartitionName);
+ appendPkRanges(ranges, partition, buckets, snapshots,
+ latestOffsets(tablePath, flussPartitionName, buckets));
+ }
+
+ private KvSnapshots latestKvSnapshots(TablePath tablePath, String
flussPartitionName) {
+ return flussPartitionName == null
+ ? adminOps.getLatestKvSnapshots(tablePath)
+ : adminOps.getLatestKvSnapshots(tablePath, flussPartitionName);
+ }
+
+ private Map<Integer, Long> latestOffsets(TablePath tablePath, String
flussPartitionName,
+ List<Integer> buckets) {
+ return flussPartitionName == null
+ ? adminOps.listOffsets(tablePath, buckets, new
OffsetSpec.LatestSpec())
+ : adminOps.listOffsets(tablePath, flussPartitionName, buckets,
new OffsetSpec.LatestSpec());
+ }
+
+ /** The earliest offset each bucket's log still holds — what a tail has to
still begin at or after. */
+ private Map<Integer, Long> earliestOffsets(TablePath tablePath, String
flussPartitionName,
+ List<Integer> buckets) {
+ return flussPartitionName == null
+ ? adminOps.listOffsets(tablePath, buckets, new
OffsetSpec.EarliestSpec())
+ : adminOps.listOffsets(tablePath, flussPartitionName, buckets,
+ new OffsetSpec.EarliestSpec());
+ }
+
+ /**
+ * The partitions to scan: those the engine's pruning left, or all of them
when it pruned nothing.
+ * A pruned name that fluss no longer lists is simply absent from the
result — the partition was
+ * dropped between pruning and planning, and there is nothing left to read.
+ */
+ private List<PartitionInfo> selectedPartitions(FlussTableHandle handle,
List<String> requiredPartitions) {
+ return selectedPartitions(
+ handle, requiredPartitions,
adminOps.listPartitionInfos(handle.toTablePath()));
+ }
+
+ private static List<PartitionInfo> selectedPartitions(FlussTableHandle
handle,
+ List<String> requiredPartitions, List<PartitionInfo> partitions) {
+ if (requiredPartitions.isEmpty()) {
+ return partitions;
+ }
+ Set<String> required = new HashSet<>(requiredPartitions);
+ List<PartitionInfo> selected = new ArrayList<>(partitions.size());
+ for (PartitionInfo partition : partitions) {
+ // Matched on the DORIS name, which is what the engine pruned
over: FlussPartitions renders
+ // both this and the metadata listing the engine pruned, so the
two cannot disagree.
+ if (required.contains(
+ FlussPartitions.toScanPartition(partition,
handle.getPartitionKeys()).getName())) {
+ selected.add(partition);
+ }
+ }
+ return selected;
+ }
+
+ private static void appendLogRanges(List<ConnectorScanRange> ranges,
+ FlussScanRange.Partition partition, List<Integer> buckets,
Map<Integer, Long> stopping) {
+ for (int bucket : buckets) {
+ Long stop = stopping.get(bucket);
+ if (stop == null || stop <= 0) {
+ // Never written to: no range at all rather than an empty one
for BE to open and close.
+ // A bucket whose records have all aged out of the log is NOT
this case — its latest
+ // offset stayed where it was — and still gets a range that
reads nothing, which costs
+ // one scanner rather than an extra round trip for every
bucket to find out.
+ continue;
+ }
+ ranges.add(FlussScanRange.log(partition, bucket,
LogScanner.EARLIEST_OFFSET, stop));
+ }
+ }
+
+ /**
+ * The log half of a union read: each bucket from where the lake snapshot
left off, up to where planning
+ * saw the log. The snapshot's offset is exclusive — it is the first
offset NOT in the lake — so the two
+ * halves meet exactly, with no row read twice and none skipped.
+ *
+ * <p>A bucket the snapshot does not mention has never been tiered, so its
log is read from the earliest
+ * offset fluss still holds; a bucket whose snapshot offset has caught up
with the stopping offset has
+ * nothing left outside the lake and yields no range at all.
+ */
+ private static void appendUnionLogRanges(List<ConnectorScanRange> ranges,
FlussTableHandle handle,
+ UnionRead union, FlussScanRange.Partition partition, List<Integer>
buckets,
+ Map<Integer, Long> stopping, Map<Integer, Long> earliest) {
+ for (int bucket : buckets) {
+ Long stop = stopping.get(bucket);
+ if (stop == null || stop <= 0) {
+ continue;
+ }
+ TableBucket tableBucket = partition.isPartitioned()
+ ? new TableBucket(handle.getTableId(), partition.getId(),
bucket)
+ : new TableBucket(handle.getTableId(), bucket);
+ Long lakeEnd = union.logOffsets.get(tableBucket);
+ if (lakeEnd == null) {
+ ranges.add(FlussScanRange.log(partition, bucket,
LogScanner.EARLIEST_OFFSET, stop));
+ } else if (lakeEnd < stop) {
+ Long earliestOffset = earliest.get(bucket);
+ if (earliestOffset == null || earliestOffset > lakeEnd) {
+ throw new DorisConnectorException("Cannot read fluss table
'"
+ + handle.getDatabaseName() + "." +
handle.getTableName() + "': the lake"
+ + " snapshot ends at offset " + lakeEnd + " for
bucket " + bucket
+ + ", but " + (earliestOffset == null
+ ? "fluss did not report the earliest
retained log offset"
+ : "the log now starts at offset " +
earliestOffset)
+ + ". Part of the log tail has expired; wait for a
newer readable lake"
+ + " snapshot or restore the missing log segment");
+ }
+ ranges.add(FlussScanRange.log(partition, bucket, lakeEnd,
stop));
+ }
+ }
+ }
+
+ /**
+ * One range per bucket that holds anything: its latest kv snapshot, plus
the change log from where
+ * that snapshot ended up to where planning saw the log. A bucket fluss
has never snapshotted gets
+ * {@code -1} and the earliest sentinel, and its state is rebuilt by
replaying the whole change log
+ * — correct, because a primary-key table's log carries every change, just
slower.
+ */
+ private static void appendPkRanges(List<ConnectorScanRange> ranges,
+ FlussScanRange.Partition partition, List<Integer> buckets,
KvSnapshots snapshots,
+ Map<Integer, Long> stopping) {
+ for (int bucket : buckets) {
+ Long stop = stopping.get(bucket);
+ appendPkFullRange(ranges, partition, bucket, snapshots, stop ==
null ? 0L : stop);
+ }
+ }
+
+ /** The same, for a bucket of a table whose lake half is being read too. */
+ private static void appendPkFullRange(List<ConnectorScanRange> ranges,
PartitionState state,
+ int bucket) {
+ appendPkFullRange(ranges, state.partition, bucket, state.snapshots,
state.buckets.get(bucket).stop);
+ }
+
+ private static void appendPkFullRange(List<ConnectorScanRange> ranges,
+ FlussScanRange.Partition partition, int bucket, KvSnapshots
snapshots, long logStop) {
+ long snapshotId =
snapshots.getSnapshotId(bucket).orElse(FlussScanRange.NO_KV_SNAPSHOT);
+ long logStart =
snapshots.getLogOffset(bucket).orElse(LogScanner.EARLIEST_OFFSET);
+ if (snapshotId == FlussScanRange.NO_KV_SNAPSHOT && logStop <= 0) {
+ // Nothing snapshotted and nothing logged: the bucket is empty. A
bucket WITH a snapshot
+ // is planned even when its log has caught up, because the
snapshot still holds rows.
+ return;
+ }
+ ranges.add(FlussScanRange.pkFull(partition, bucket, snapshotId,
logStart, logStop));
+ }
+
+ /**
+ * How many of {@code ranges} are fluss ranges of this kind. The type test
is not defensive: on a union
+ * read the list also holds the lake half's ranges, which are the
sibling's own type and would fail a
+ * cast.
+ */
+ private static int count(List<ConnectorScanRange> ranges,
FlussScanRange.RangeType rangeType) {
+ int found = 0;
+ for (ConnectorScanRange range : ranges) {
+ if (range instanceof FlussScanRange && ((FlussScanRange)
range).getRangeType() == rangeType) {
+ found++;
+ }
+ }
+ return found;
+ }
+
+ /** Ranges the sibling planned; every one of them is wrapped, suppressed
or plain. */
+ private static int countLakeSplits(List<ConnectorScanRange> ranges) {
+ int found = 0;
+ for (ConnectorScanRange range : ranges) {
+ if (range instanceof FlussLakeRange) {
+ found++;
+ }
+ }
+ return found;
+ }
+
+ private static int countSuppressedLakeSplits(List<ConnectorScanRange>
ranges) {
+ int found = 0;
+ for (ConnectorScanRange range : ranges) {
+ if (range instanceof FlussLakeRange && ((FlussLakeRange)
range).getTail() != null) {
+ found++;
+ }
+ }
+ return found;
+ }
+
+ /**
+ * The buckets of {@code partition}, or of the whole table when it is
unpartitioned ({@code partition}
+ * is null).
+ *
+ * <p>Since fluss 1.0.0 a table's bucket count can be changed after the
table exists, and a partition
+ * keeps the count it was created with: after a rescale the partitions
that already existed have the
+ * old count, while the table — and every partition created since — has
the new one. So each partition
+ * is planned by its own count and never by the table's. Planned by the
table's, a partition created
+ * before a scale-up would be asked for offsets of buckets it does not
have, and one created before a
+ * scale-down would have its highest buckets left out of the scan, which
reads as a table with fewer
+ * rows than it holds. An unpartitioned table has the one count.
+ */
+ private static List<Integer> bucketsOf(FlussTableHandle handle,
PartitionInfo partition) {
+ return allBuckets(PartitionInfo.bucketCountOrDefault(partition,
handle.getBucketCount()));
+ }
+
+ private static List<Integer> allBuckets(int bucketCount) {
+ List<Integer> buckets = new ArrayList<>(bucketCount);
+ for (int bucket = 0; bucket < bucketCount; bucket++) {
+ buckets.add(bucket);
+ }
+ return buckets;
+ }
+
+ /**
+ * What every range of this scan shares. The {@code fluss.}-prefixed
entries are the BE half and are
+ * forwarded by {@link #populateScanLevelParams}; {@code
path_partition_keys} is the engine's, and
+ * declaring it is what keeps the partition columns out of the scanner's
projection so BE
+ * materializes them from each range instead (see {@link FlussScanRange}).
+ */
+ @Override
+ public Map<String, String> getScanNodeProperties(ConnectorSession session,
ConnectorTableHandle handle,
+ List<ConnectorColumnHandle> columns, Optional<ConnectorExpression>
filter) {
+ FlussTableHandle flussHandle = (FlussTableHandle) handle;
+ Map<String, String> props = new LinkedHashMap<>();
+ if (flussHandle.isPartitioned()) {
+ props.put(ScanNodePropertyKeys.PATH_PARTITION_KEYS,
+ String.join(",", flussHandle.getPartitionKeys()));
+ }
+ props.put(PROP_DB_NAME, flussHandle.getDatabaseName());
+ props.put(PROP_TABLE_NAME, flussHandle.getTableName());
+ catalogProperties.getFlussClientConfig()
+ .forEach((key, value) -> props.put(PROP_CLIENT_PREFIX + key,
value));
+
+ UnionRead union = resolveUnionRead(session, flussHandle);
+ // A $log scan resolved the lake only to find where the log starts; it
plans no lake range, so
+ // sending the lake's scan-node properties to BE would configure a
reader for ranges that are
+ // not there.
+ if (union != null && union.hasLakeHalf()) {
+ if (flussHandle.hasPrimaryKey() && forcePkFlussOnlyReason == null)
{
+ // What BE needs to suppress lake rows by key: which columns
the key is made of, and how
+ // large a tail it may hold in memory while doing so. Both are
node-level because both are
+ // the same for every range of the scan.
+ props.put(PROP_UNION_PK_NAMES, String.join(",",
flussHandle.getPhysicalPrimaryKeys()));
Review Comment:
[P1] Encode union key names without delimiter ambiguity
This creates a raw comma-separated protocol, but quoted identifiers may
themselves contain commas (the new encoded JNI-schema path even uses
`region,code` as its delimiter-safety case). A real primary key named
`region,code` is parsed by BE as two ordinary key columns, `region` and `code`.
If those columns exist, initialization succeeds and tail suppression hashes
them instead of the actual key: two lake rows that share those values are both
removed when the tail touches only one real key, while PK-tail replay emits
only the touched row, silently losing the other. Please carry this list in a
delimiter-safe/typed form and cover the colliding-name case.
--
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.
To unsubscribe, e-mail: [email protected]
For queries about this service, please contact Infrastructure at:
[email protected]
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]