This is an automated email from the ASF dual-hosted git repository.
Gabriel39 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 6e887178b99 [fix](paimon) Push limit into split planning (#66818)
6e887178b99 is described below
commit 6e887178b9945b6528ed9f074069f0b485db271e
Author: Gabriel <[email protected]>
AuthorDate: Thu Aug 20 14:16:17 2026 +0800
[fix](paimon) Push limit into split planning (#66818)
### What problem does this PR solve?
Paimon scan planning ignored the limit carried by the Doris connector
request. As a result, even a small unfiltered query such as `SELECT *
FROM t LIMIT 1` could still scan all relevant manifests, enumerate every
file split, and create scan ranges for the full table before Doris
applied the limit during execution.
For tables with many files, this causes unnecessary FE planning latency,
memory usage, and scheduling overhead. However, pushing a limit into
Paimon indiscriminately is unsafe because some planning statistics do
not represent the number of rows that Doris will finally return.
### What is changed and how does it work?
This PR forwards the connector request limit into Paimon scan planning.
For eligible scans, it calls `ReadBuilder.withLimit` before `newScan()`,
allowing Paimon to stop manifest and split planning after it has covered
enough rows.
Limit pushdown is enabled only when all of the following conditions
hold:
- The limit is positive and fits in the integer range supported by the
Paimon API.
- The original request has no filter.
- The scan is an append-only `FileStoreTable` without primary keys.
- The table is not backed by a fallback reader.
- The scan does not use the file-creation-time planning path.
- Neither native nor JNI split routing is configured to be ignored.
The change also retains the resolved source table behind system-table
wrappers so that fallback-backed scans can still be detected after
handles are reloaded or decorated.
### Why are the safety guards required?
- **Filtered scans:** Paimon 1.3.1 can stop planning based on pre-filter
split row counts. A retained split may produce no matching rows while a
later matching split has already been pruned.
- **Primary-key tables:** File metadata can count deleted rows or
tombstones that the execution reader later removes, so planned row
counts may exceed final output rows.
- **Format tables:** Their limit accounting can count files rather than
output rows. An empty first file could satisfy the planning limit and
hide a later non-empty file.
- **Fallback-backed tables:** The Paimon 1.3.1 fallback planner can
derive partition ownership from an already limited main-table plan and
expose stale fallback data.
- **File-creation-time scans:** This path creates a separate snapshot
reader and does not consume the limit-bearing `TableScan`.
- **Ignored split types:** Native/JNI routing happens after planning.
Applying the limit first could retain only a split that Doris
subsequently discards, hiding rows from the other reader path.
Unsafe or uncertain modes therefore keep the complete split plan instead
of trading correctness for pruning.
### User-visible effect
Small, unfiltered LIMIT queries on append-only Paimon FileStore tables
can plan fewer manifests and splits. SQL semantics and the
execution-stage limit are unchanged; this optimization only reduces
planning work where split accounting is known to match final output
rows.
Filtered scans, primary-key tables, format tables, fallback-backed
tables, file-creation-time scans, and split-ignore modes intentionally
do not receive this optimization yet.
### Test coverage
The regression tests cover:
- Limit-aware split pruning for eligible append-only tables.
- Non-positive and oversized limits.
- Primary-key row-accounting safety.
- Format-table file-count versus row-count safety.
- Residual-filter correctness.
- Direct, decorated, and system-hidden fallback readers.
- File-creation-time scan planning.
- Mixed native/JNI split routing and ignore modes.
- Deterministic empty-file and split-order scenarios.
### Check List
- [x] PaimonScanPlanProviderTest
- [x] FE Checkstyle
---
.../connector/paimon/PaimonScanPlanProvider.java | 47 +-
.../paimon/PaimonScanPlanProviderTest.java | 473 +++++++++++++++++++++
2 files changed, 513 insertions(+), 7 deletions(-)
diff --git
a/fe/fe-connector/fe-connector-paimon/src/main/java/org/apache/doris/connector/paimon/PaimonScanPlanProvider.java
b/fe/fe-connector/fe-connector-paimon/src/main/java/org/apache/doris/connector/paimon/PaimonScanPlanProvider.java
index 335d01a6178..30c109dfb2f 100644
---
a/fe/fe-connector/fe-connector-paimon/src/main/java/org/apache/doris/connector/paimon/PaimonScanPlanProvider.java
+++
b/fe/fe-connector/fe-connector-paimon/src/main/java/org/apache/doris/connector/paimon/PaimonScanPlanProvider.java
@@ -357,6 +357,9 @@ public class PaimonScanPlanProvider implements
ConnectorScanPlanProvider {
}
try {
Table dataTable =
PaimonTableResolver.resolveSystemSource(catalogOps, handle, context);
+ // System wrappers hide the fallback pair from instanceof checks.
Retain the exact source
+ // resolved here so split-limit safety applies after transient
handles are reloaded too.
+ handle.setSystemTableSource(dataTable);
return PaimonReaderOptions.runtimeSafeSystemTable(
handle.getSysTableName(), systemTable, dataTable,
scanOptions);
} catch (IllegalArgumentException e) {
@@ -475,14 +478,14 @@ public class PaimonScanPlanProvider implements
ConnectorScanPlanProvider {
/**
* The scan entry. Of everything on the request, paimon consumes the
handle, the columns, the filter and
- * the no-grouping {@code COUNT(*)} signal (FIX-COUNT-PUSHDOWN, which lets
a split answer from its
- * precomputed merged row count); the row limit and the pruned partition
set are not consumed by the
- * paimon read path — it is predicate-driven and re-plans through the SDK
from the filter.
+ * the row limit, and the no-grouping {@code COUNT(*)} signal
(FIX-COUNT-PUSHDOWN, which lets a split
+ * answer from its precomputed merged row count); the pruned partition set
is not consumed by the paimon
+ * read path — it is predicate-driven and re-plans through the SDK from
the filter.
*/
@Override
public List<ConnectorScanRange> planScan(ConnectorSession session,
ConnectorScanRequest request) {
return planScanInternal(session, request.getTableHandle(),
request.getColumns(),
- request.getFilter(), request.isCountPushdown());
+ request.getFilter(), request.getLimit(),
request.isCountPushdown());
}
/**
@@ -593,6 +596,7 @@ public class PaimonScanPlanProvider implements
ConnectorScanPlanProvider {
ConnectorTableHandle handle,
List<ConnectorColumnHandle> columns,
Optional<ConnectorExpression> filter,
+ long limit,
boolean countPushdown) {
PaimonTableHandle paimonHandle = (PaimonTableHandle) handle;
@@ -610,6 +614,9 @@ public class PaimonScanPlanProvider implements
ConnectorScanPlanProvider {
return Collections.emptyList();
}
Table table = resolveScanTable(paimonHandle);
+ Optional<Long> fileCreationTime = optionsPin
+ ? PaimonScanParams.getPinnedFileCreationTime(pinnedOptions)
+ : Optional.empty();
// Build predicates from filter expression
RowType rowType = table.rowType();
@@ -664,6 +671,19 @@ public class PaimonScanPlanProvider implements
ConnectorScanPlanProvider {
if (projected.length > 0) {
readBuilder.withProjection(projected);
}
+ if (limit > 0 && limit <= Integer.MAX_VALUE
+ && filter.isEmpty()
+ && fileCreationTime.isEmpty()
+ && hasTrustworthyLimitAccounting(table)
+ && !usesFallbackRead(table, paimonHandle)
+ && !ignoreJni
+ && !ignoreNative) {
+ // Only append-only FileStore manifests count final output rows:
format tables count
+ // files, while primary-key metadata may count deletes that its
reader later removes.
+ // Ignore routing happens after planning, so pruning first could
discard every retained
+ // split and hide rows from the non-ignored reader path.
+ readBuilder.withLimit((int) limit);
+ }
TableScan scan = readBuilder.newScan();
// FIX-SCAN-METRICS: attach a metric registry so scan.plan() records
its ScanMetrics (manifest cache
// hit/miss, scan durations, table files skipped/resulted), then
harvest them below — restores the
@@ -673,9 +693,6 @@ public class PaimonScanPlanProvider implements
ConnectorScanPlanProvider {
if (scan instanceof InnerTableScan) {
scan = ((InnerTableScan) scan).withMetricRegistry(metricRegistry);
}
- Optional<Long> fileCreationTime = optionsPin
- ? PaimonScanParams.getPinnedFileCreationTime(pinnedOptions)
- : Optional.empty();
List<Split> paimonSplits = fileCreationTime.isPresent()
? planFileCreationTimeSplits(table, pinnedOptions, predicates,
fileCreationTime.get())
: planSplits(scan);
@@ -819,6 +836,22 @@ public class PaimonScanPlanProvider implements
ConnectorScanPlanProvider {
return ranges;
}
+ private static boolean usesFallbackRead(Table scanTable, PaimonTableHandle
handle) {
+ return isFallbackFileStoreTable(scanTable)
+ || isFallbackFileStoreTable(handle.getSystemTableSource())
+ || isFallbackFileStoreTable(handle.getSysBaseTable());
+ }
+
+ private static boolean hasTrustworthyLimitAccounting(Table table) {
+ return table instanceof FileStoreTable &&
table.primaryKeys().isEmpty();
+ }
+
+ private static boolean isFallbackFileStoreTable(Table table) {
+ return table instanceof FileStoreTable
+ &&
PaimonTableDecorators.unwrapToFallbackOrBase((FileStoreTable) table)
+ instanceof FallbackReadFileStoreTable;
+ }
+
/**
* Builds the native-reader {@link PaimonScanRange} for one raw
ORC/Parquet file plus its optional
* deletion vector. BOTH the data-file path and the deletion-vector path
are routed through
diff --git
a/fe/fe-connector/fe-connector-paimon/src/test/java/org/apache/doris/connector/paimon/PaimonScanPlanProviderTest.java
b/fe/fe-connector/fe-connector-paimon/src/test/java/org/apache/doris/connector/paimon/PaimonScanPlanProviderTest.java
index f5f7126a4fc..e66a38c021d 100644
---
a/fe/fe-connector/fe-connector-paimon/src/test/java/org/apache/doris/connector/paimon/PaimonScanPlanProviderTest.java
+++
b/fe/fe-connector/fe-connector-paimon/src/test/java/org/apache/doris/connector/paimon/PaimonScanPlanProviderTest.java
@@ -20,8 +20,13 @@ package org.apache.doris.connector.paimon;
import org.apache.doris.connector.spi.ConnectorContext;
import org.apache.doris.connector.spi.ConnectorSession;
import org.apache.doris.connector.spi.ConnectorStorageContext;
+import org.apache.doris.connector.spi.ConnectorType;
import org.apache.doris.connector.spi.DorisConnectorException;
import org.apache.doris.connector.spi.handle.ConnectorColumnHandle;
+import org.apache.doris.connector.spi.pushdown.ConnectorColumnRef;
+import org.apache.doris.connector.spi.pushdown.ConnectorComparison;
+import org.apache.doris.connector.spi.pushdown.ConnectorExpression;
+import org.apache.doris.connector.spi.pushdown.ConnectorLiteral;
import org.apache.doris.connector.spi.scan.ConnectorScanRange;
import org.apache.doris.connector.spi.scan.ConnectorScanRequest;
import org.apache.doris.filesystem.FileSystemType;
@@ -44,13 +49,23 @@ import org.apache.paimon.catalog.FileSystemCatalog;
import org.apache.paimon.catalog.Identifier;
import org.apache.paimon.data.BinaryString;
import org.apache.paimon.data.GenericRow;
+import org.apache.paimon.data.InternalRow;
+import org.apache.paimon.fs.FileStatus;
import org.apache.paimon.fs.local.LocalFileIO;
+import org.apache.paimon.io.DataFileMeta;
import org.apache.paimon.io.DataInputViewStreamWrapper;
+import org.apache.paimon.predicate.Predicate;
+import org.apache.paimon.privilege.AllGrantedPrivilegeChecker;
+import org.apache.paimon.privilege.PrivilegedFileStoreTable;
+import org.apache.paimon.reader.RecordReader;
import org.apache.paimon.schema.Schema;
import org.apache.paimon.schema.SchemaChange;
import org.apache.paimon.schema.SchemaManager;
+import org.apache.paimon.table.FallbackReadFileStoreTable;
import org.apache.paimon.table.FileStoreTable;
+import org.apache.paimon.table.FormatTable;
import org.apache.paimon.table.Table;
+import org.apache.paimon.table.format.FormatDataSplit;
import org.apache.paimon.table.sink.BatchTableCommit;
import org.apache.paimon.table.sink.BatchTableWrite;
import org.apache.paimon.table.sink.BatchWriteBuilder;
@@ -70,7 +85,9 @@ import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import java.io.ByteArrayInputStream;
+import java.io.IOException;
import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.Arrays;
@@ -104,6 +121,18 @@ public class PaimonScanPlanProviderTest {
return builder.build();
}
+ private static final class OrderedLocalFileIO extends LocalFileIO {
+ @Override
+ public FileStatus[] listFiles(org.apache.paimon.fs.Path path, boolean
recursive)
+ throws IOException {
+ FileStatus[] files = super.listFiles(path, recursive);
+ // FormatTableScan preserves FileIO order, so pin it to keep the
empty-file regression
+ // independent of the host filesystem's directory iteration order.
+ Arrays.sort(files, Comparator.comparing(file ->
file.getPath().getName()));
+ return files;
+ }
+ }
+
@Test
public void resolveTableReloadsWhenTransientTableNull() {
RecordingPaimonCatalogOps ops = new RecordingPaimonCatalogOps();
@@ -270,6 +299,376 @@ public class PaimonScanPlanProviderTest {
}
}
+ @Test
+ public void planScanPushesLimitIntoPaimonSplitPlanning(@TempDir Path
warehouse) throws Exception {
+ try (Catalog catalog = new FileSystemCatalog(LocalFileIO.create(),
+ new org.apache.paimon.fs.Path(warehouse.toUri()))) {
+ catalog.createDatabase("db", false);
+ Identifier id = Identifier.create("db", "limited");
+ catalog.createTable(id, Schema.newBuilder()
+ .column("id", DataTypes.INT())
+ .column("pt", DataTypes.INT())
+ .partitionKeys("pt")
+ .option("bucket", "1")
+ .option("bucket-key", "id")
+ .build(), false);
+ Table table = catalog.getTable(id);
+ BatchWriteBuilder wb = table.newBatchWriteBuilder();
+ try (BatchTableWrite write = wb.newWrite()) {
+ write.write(GenericRow.of(1, 1));
+ write.write(GenericRow.of(2, 2));
+ List<CommitMessage> messages = write.prepareCommit();
+ try (BatchTableCommit commit = wb.newCommit()) {
+ commit.commit(messages);
+ }
+ }
+
+ RecordingPaimonCatalogOps ops = new RecordingPaimonCatalogOps();
+ ops.table = table;
+ PaimonScanPlanProvider provider = new PaimonScanPlanProvider(
+ PaimonCatalogProperties.of(Collections.emptyMap()), ops);
+ PaimonTableHandle handle = new PaimonTableHandle(
+ "db", "limited", Collections.emptyList(),
Collections.emptyList());
+ ConnectorSession session =
sessionWithProps(Collections.emptyMap());
+
+ List<ConnectorScanRange> unlimited = provider.planScan(session,
+ ConnectorScanRequest.builder(handle,
Collections.emptyList()).build());
+ List<ConnectorScanRange> limited = provider.planScan(session,
+ ConnectorScanRequest.builder(handle,
Collections.emptyList()).limit(1).build());
+ List<ConnectorScanRange> oversized = provider.planScan(session,
+ ConnectorScanRequest.builder(handle,
Collections.emptyList())
+ .limit((long) Integer.MAX_VALUE + 1)
+ .build());
+
+ Assertions.assertTrue(unlimited.size() >= 2,
+ "fixture must plan at least one split for each partition");
+ Assertions.assertEquals(1, limited.size(),
+ "LIMIT 1 must let Paimon stop split planning after enough
rows are covered");
+ Assertions.assertEquals(unlimited.size(), oversized.size(),
+ "a Doris limit wider than Paimon's int must not be
narrowed during split planning");
+ }
+ }
+
+ @Test
+ public void primaryKeyLimitKeepsAllRowsForUnsafeSplitAccounting(@TempDir
Path warehouse)
+ throws Exception {
+ try (Catalog catalog = new FileSystemCatalog(LocalFileIO.create(),
+ new org.apache.paimon.fs.Path(warehouse.toUri()))) {
+ catalog.createDatabase("db", false);
+ Identifier id = Identifier.create("db", "primary_key_limit");
+ catalog.createTable(id, Schema.newBuilder()
+ .column("id", DataTypes.INT())
+ .column("pt", DataTypes.INT())
+ .partitionKeys("pt")
+ .primaryKey("id", "pt")
+ .option("bucket", "1")
+ .build(), false);
+ Table table = catalog.getTable(id);
+ BatchWriteBuilder wb = table.newBatchWriteBuilder();
+ try (BatchTableWrite write = wb.newWrite()) {
+ write.write(GenericRow.of(1, 1));
+ write.write(GenericRow.of(2, 2));
+ List<CommitMessage> messages = write.prepareCommit();
+ try (BatchTableCommit commit = wb.newCommit()) {
+ commit.commit(messages);
+ }
+ }
+
+ RecordingPaimonCatalogOps ops = new RecordingPaimonCatalogOps();
+ ops.table = table;
+ PaimonScanPlanProvider provider = new PaimonScanPlanProvider(
+ PaimonCatalogProperties.of(Collections.emptyMap()), ops);
+ PaimonTableHandle handle = new PaimonTableHandle(
+ "db", "primary_key_limit", Collections.emptyList(),
Collections.emptyList());
+ List<ConnectorScanRange> ranges = provider.planScan(
+
sessionWithProps(Collections.singletonMap("force_jni_scanner", "true")),
+ ConnectorScanRequest.builder(handle,
Collections.emptyList())
+ .limit(1)
+ .build());
+
+ RecordReader<InternalRow> reader = table.newReadBuilder()
+ .newRead()
+ .createReader(deserializeJniSplits(ranges));
+ List<Integer> ids = new ArrayList<>();
+ reader.forEachRemaining(row -> ids.add(row.getInt(0)));
+ ids.sort(Integer::compareTo);
+ Assertions.assertEquals(Arrays.asList(1, 2), ids,
+ "primary-key metadata may count deleted rows, so Doris
must retain every split");
+ }
+ }
+
+ @Test
+ public void formatTableLimitDoesNotTreatFilesAsRows(@TempDir Path
warehouse)
+ throws Exception {
+ Path dataDir =
Files.createDirectories(warehouse.resolve("format_data"));
+ Files.write(dataDir.resolve("000-empty.csv"), new byte[0]);
+ Files.write(dataDir.resolve("999-live.csv"),
Collections.singletonList("7"),
+ StandardCharsets.UTF_8);
+ FormatTable table = FormatTable.builder()
+ .fileIO(new OrderedLocalFileIO())
+ .identifier(Identifier.create("db", "format_limit"))
+ .rowType(rowType("id"))
+ .partitionKeys(Collections.emptyList())
+ .location(dataDir.toUri().toString())
+ .format(FormatTable.Format.CSV)
+
.options(Collections.singletonMap(CoreOptions.FILE_FORMAT.key(), "csv"))
+ .build();
+ List<Split> plannedSplits =
table.newReadBuilder().newScan().plan().splits();
+ Assertions.assertTrue(plannedSplits.size() >= 2,
+ "fixture must expose both the empty and live format files");
+ Assertions.assertEquals("000-empty.csv",
+ ((FormatDataSplit) plannedSplits.get(0)).filePath().getName(),
+ "the unsafe file-count limit must encounter the empty file
first");
+ RecordingPaimonCatalogOps ops = new RecordingPaimonCatalogOps();
+ ops.table = table;
+ PaimonScanPlanProvider provider = new PaimonScanPlanProvider(
+ PaimonCatalogProperties.of(Collections.emptyMap()), ops);
+ PaimonTableHandle handle = new PaimonTableHandle(
+ "db", "format_limit", Collections.emptyList(),
Collections.emptyList());
+ List<ConnectorScanRange> ranges = provider.planScan(
+ sessionWithProps(Collections.singletonMap("force_jni_scanner",
"true")),
+ ConnectorScanRequest.builder(handle,
Collections.emptyList()).limit(1).build());
+
+ RecordReader<InternalRow> reader = table.newReadBuilder()
+ .newRead()
+ .createReader(deserializeJniSplits(ranges));
+ List<Integer> ids = new ArrayList<>();
+ reader.forEachRemaining(row -> ids.add(row.getInt(0)));
+ Assertions.assertEquals(Collections.singletonList(7), ids,
+ "a LIMIT measured in rows must not stop after an empty format
file");
+ }
+
+ private static List<Split> deserializeJniSplits(List<ConnectorScanRange>
ranges)
+ throws Exception {
+ List<Split> splits = new ArrayList<>();
+ for (ConnectorScanRange range : ranges) {
+ String encoded = range.getProperties().get("paimon.split");
+ Assertions.assertNotNull(encoded, "the result-bearing test
requires JNI splits");
+ splits.add((Split) InstantiationUtil.deserializeObject(
+ Base64.getDecoder().decode(encoded),
+ PaimonScanPlanProviderTest.class.getClassLoader()));
+ }
+ return splits;
+ }
+
+ @Test
+ public void filteredLimitDoesNotDiscardLaterMatchingSplit(@TempDir Path
warehouse)
+ throws Exception {
+ try (Catalog catalog = new FileSystemCatalog(LocalFileIO.create(),
+ new org.apache.paimon.fs.Path(warehouse.toUri()))) {
+ catalog.createDatabase("db", false);
+ Identifier id = Identifier.create("db", "filtered_limit");
+ catalog.createTable(id, Schema.newBuilder()
+ .column("id", DataTypes.INT())
+ .column("pt", DataTypes.INT())
+ .partitionKeys("pt")
+ .option("bucket", "-1")
+ .build(), false);
+ Table table = catalog.getTable(id);
+ BatchWriteBuilder wb = table.newBatchWriteBuilder();
+ try (BatchTableWrite write = wb.newWrite()) {
+ // The first split's [1, 3] min/max admits id=2, but contains
no matching row.
+ write.write(GenericRow.of(1, 2));
+ write.write(GenericRow.of(3, 2));
+ write.write(GenericRow.of(2, 1));
+ List<CommitMessage> messages = write.prepareCommit();
+ try (BatchTableCommit commit = wb.newCommit()) {
+ commit.commit(messages);
+ }
+ }
+
+ RecordingPaimonCatalogOps ops = new RecordingPaimonCatalogOps();
+ ops.table = table;
+ PaimonScanPlanProvider provider = new PaimonScanPlanProvider(
+ PaimonCatalogProperties.of(Collections.emptyMap()), ops);
+ PaimonTableHandle handle = new PaimonTableHandle(
+ "db", "filtered_limit", Collections.emptyList(),
Collections.emptyList());
+ ConnectorExpression filter = new ConnectorComparison(
+ ConnectorComparison.Operator.EQ,
+ new ConnectorColumnRef("id", ConnectorType.of("INT")),
+ ConnectorLiteral.ofInt(2));
+ List<ConnectorScanRange> ranges = provider.planScan(
+
sessionWithProps(Collections.singletonMap("force_jni_scanner", "true")),
+ ConnectorScanRequest.builder(handle,
Collections.emptyList())
+ .filter(Optional.of(filter))
+ .limit(1)
+ .build());
+
+ List<Predicate> predicates = new
PaimonPredicateConverter(table.rowType()).convert(filter);
+ List<Split> filteredSplits = table.newReadBuilder()
+ .withFilter(predicates)
+ .newScan()
+ .plan()
+ .splits();
+ Assertions.assertEquals(2, filteredSplits.size(),
+ "both min/max-admitted partitions must remain in the
fixture");
+ Assertions.assertEquals(2,
+ ((DataSplit) filteredSplits.get(0)).partition().getInt(0),
+ "the first split must contain only non-matching ids 1 and
3");
+ Assertions.assertEquals(1,
+ ((DataSplit) filteredSplits.get(1)).partition().getInt(0),
+ "the later split must contain the matching id 2");
+ RecordReader<InternalRow> reader = table.newReadBuilder()
+ .withFilter(predicates)
+ .newRead()
+ .executeFilter()
+ .createReader(deserializeJniSplits(ranges));
+ List<Integer> ids = new ArrayList<>();
+ reader.forEachRemaining(row -> ids.add(row.getInt(0)));
+ Assertions.assertEquals(Collections.singletonList(2), ids,
+ "LIMIT split pruning must not discard a later split
containing the match");
+ }
+ }
+
+ @Test
+ public void fallbackLimitDoesNotExposeStaleFallbackRows(@TempDir Path
warehouse)
+ throws Exception {
+ try (Catalog catalog = new FileSystemCatalog(LocalFileIO.create(),
+ new org.apache.paimon.fs.Path(warehouse.toUri()))) {
+ catalog.createDatabase("db", false);
+ Schema schema = Schema.newBuilder()
+ .column("id", DataTypes.INT())
+ .column("pt", DataTypes.INT())
+ .column("val", DataTypes.INT())
+ .partitionKeys("pt")
+ .option("bucket", "-1")
+ .build();
+ Identifier mainId = Identifier.create("db", "fallback_main");
+ Identifier fallbackId = Identifier.create("db", "fallback_old");
+ catalog.createTable(mainId, schema, false);
+ catalog.createTable(fallbackId, schema, false);
+ FileStoreTable main = (FileStoreTable) catalog.getTable(mainId);
+ FileStoreTable fallback = (FileStoreTable)
catalog.getTable(fallbackId);
+
+ BatchWriteBuilder mainWriteBuilder = main.newBatchWriteBuilder();
+ try (BatchTableWrite write = mainWriteBuilder.newWrite()) {
+ write.write(GenericRow.of(1, 2, 200));
+ write.write(GenericRow.of(2, 1, 100));
+ List<CommitMessage> messages = write.prepareCommit();
+ try (BatchTableCommit commit = mainWriteBuilder.newCommit()) {
+ commit.commit(messages);
+ }
+ }
+ BatchWriteBuilder fallbackWriteBuilder =
fallback.newBatchWriteBuilder();
+ try (BatchTableWrite write = fallbackWriteBuilder.newWrite()) {
+ write.write(GenericRow.of(2, 1, 50));
+ List<CommitMessage> messages = write.prepareCommit();
+ try (BatchTableCommit commit =
fallbackWriteBuilder.newCommit()) {
+ commit.commit(messages);
+ }
+ }
+
+ FallbackReadFileStoreTable pair = new
FallbackReadFileStoreTable(main, fallback);
+ FileStoreTable decorated = PrivilegedFileStoreTable.wrap(
+ pair, new AllGrantedPrivilegeChecker(), mainId);
+ for (Table planningTable : Arrays.asList(pair, decorated)) {
+ RecordingPaimonCatalogOps ops = new
RecordingPaimonCatalogOps();
+ ops.table = planningTable;
+ PaimonScanPlanProvider provider = new PaimonScanPlanProvider(
+ PaimonCatalogProperties.of(Collections.emptyMap()),
ops);
+ PaimonTableHandle handle = new PaimonTableHandle(
+ "db", "fallback_main", Collections.emptyList(),
Collections.emptyList());
+ List<ConnectorScanRange> ranges = provider.planScan(
+
sessionWithProps(Collections.singletonMap("force_jni_scanner", "true")),
+ ConnectorScanRequest.builder(handle,
Collections.emptyList())
+ .limit(1)
+ .build());
+
+ RecordReader<InternalRow> reader = pair.newReadBuilder()
+ .newRead()
+ .createReader(deserializeJniSplits(ranges));
+ List<Integer> values = new ArrayList<>();
+ reader.forEachRemaining(row -> values.add(row.getInt(2)));
+ values.sort(Integer::compareTo);
+ Assertions.assertEquals(Arrays.asList(100, 200), values,
+ "direct and decorated fallback tables must never
expose stale rows");
+ }
+
+ RecordingPaimonCatalogOps systemOps = new
RecordingPaimonCatalogOps();
+ systemOps.table = pair;
+ PaimonScanPlanProvider systemProvider = new PaimonScanPlanProvider(
+ PaimonCatalogProperties.of(Collections.emptyMap()),
systemOps);
+ PaimonTableHandle systemHandle = PaimonTableHandle.forSystemTable(
+ "db", "fallback_main", "ro", false);
+ systemHandle.setPaimonTable(new ReadOptimizedTable(pair));
+ systemHandle.setSysBaseTable(pair);
+ systemHandle.setSystemTableSource(decorated);
+ ConnectorSession forceJni = sessionWithProps(
+ Collections.singletonMap("force_jni_scanner", "true"));
+ List<ConnectorScanRange> unlimitedSystemRanges =
systemProvider.planScan(
+ forceJni,
+ ConnectorScanRequest.builder(systemHandle,
Collections.emptyList()).build());
+ List<ConnectorScanRange> systemRanges = systemProvider.planScan(
+ forceJni,
+ ConnectorScanRequest.builder(systemHandle,
Collections.emptyList())
+ .limit(1)
+ .build());
+ List<String> fallbackFiles = new ArrayList<>();
+ for (Split split :
fallback.newReadBuilder().newScan().plan().splits()) {
+ for (DataFileMeta file : ((DataSplit) split).dataFiles()) {
+ fallbackFiles.add(file.fileName());
+ }
+ }
+ List<Split> systemSplits = deserializeJniSplits(systemRanges);
+ Assertions.assertEquals(unlimitedSystemRanges.size(),
systemSplits.size(),
+ "the system wrapper must not hide fallback ownership from
limit safety");
+ for (Split split : systemSplits) {
+ for (DataFileMeta file : ((DataSplit) split).dataFiles()) {
+
Assertions.assertFalse(fallbackFiles.contains(file.fileName()),
+ "a system wrapper must not hide fallback ownership
from limit safety");
+ }
+ }
+ }
+ }
+
+ @Test
+ public void fileCreationTimeScanDoesNotApplyLimitToDiscardedTableScan(
+ @TempDir Path warehouse) throws Exception {
+ try (Catalog catalog = new FileSystemCatalog(LocalFileIO.create(),
+ new org.apache.paimon.fs.Path(warehouse.toUri()))) {
+ catalog.createDatabase("db", false);
+ Identifier id = Identifier.create("db", "creation_time_limit");
+ catalog.createTable(id, Schema.newBuilder()
+ .column("id", DataTypes.INT())
+ .column("pt", DataTypes.INT())
+ .partitionKeys("pt")
+ .primaryKey("id", "pt")
+ .option("bucket", "1")
+ .build(), false);
+ Table table = catalog.getTable(id);
+ BatchWriteBuilder wb = table.newBatchWriteBuilder();
+ try (BatchTableWrite write = wb.newWrite()) {
+ write.write(GenericRow.of(1, 1));
+ write.write(GenericRow.of(2, 2));
+ List<CommitMessage> messages = write.prepareCommit();
+ try (BatchTableCommit commit = wb.newCommit()) {
+ commit.commit(messages);
+ }
+ }
+
+ RecordingPaimonCatalogOps ops = new RecordingPaimonCatalogOps();
+ ops.table = table;
+ PaimonScanPlanProvider provider = new PaimonScanPlanProvider(
+ PaimonCatalogProperties.of(Collections.emptyMap()), ops);
+ PaimonTableHandle handle = new PaimonTableHandle(
+ "db", "creation_time_limit", Collections.emptyList(),
Collections.emptyList());
+ Map<String, String> resolved = PaimonScanParams.markAsOptions(
+ PaimonScanParams.resolveOptions(table,
Collections.singletonMap(
+ CoreOptions.SCAN_FILE_CREATION_TIME_MILLIS.key(),
"0")));
+ PaimonTableHandle pinned = handle.withScanOptions(resolved);
+ ConnectorSession session =
sessionWithProps(Collections.emptyMap());
+
+ List<ConnectorScanRange> unlimited = provider.planScan(session,
+ ConnectorScanRequest.builder(pinned,
Collections.emptyList()).build());
+ List<ConnectorScanRange> limited = provider.planScan(session,
+ ConnectorScanRequest.builder(pinned,
Collections.emptyList()).limit(1).build());
+ Assertions.assertTrue(unlimited.size() >= 2,
+ "fixture must include multiple file-creation-time splits");
+ Assertions.assertEquals(unlimited.size(), limited.size(),
+ "the SnapshotReader path has no safe limit API and must
retain its full plan");
+ }
+ }
+
/** Builds a native-eligible RawFile (parquet suffix). The numeric fields
are irrelevant to the
* native-vs-JNI routing decision under test, only the path suffix
matters. */
private static RawFile parquetRawFile(String path) {
@@ -1617,6 +2016,80 @@ public class PaimonScanPlanProviderTest {
}
}
+ @Test
+ public void ignoreNativeLimitKeepsLaterJniSplit(@TempDir Path warehouse)
throws Exception {
+ try (Catalog catalog = new FileSystemCatalog(LocalFileIO.create(),
+ new org.apache.paimon.fs.Path(warehouse.toUri()))) {
+ catalog.createDatabase("db", false);
+ Identifier id = Identifier.create("db", "mixed_limit");
+ catalog.createTable(id, Schema.newBuilder()
+ .column("id", DataTypes.INT())
+ .column("pt", DataTypes.INT())
+ .partitionKeys("pt")
+ .option("bucket", "-1")
+ .option("file.format", "parquet")
+ .build(), false);
+ Table parquetTable = catalog.getTable(id);
+ BatchWriteBuilder parquetWriteBuilder =
parquetTable.newBatchWriteBuilder();
+ try (BatchTableWrite write = parquetWriteBuilder.newWrite()) {
+ write.write(GenericRow.of(1, 1));
+ List<CommitMessage> messages = write.prepareCommit();
+ try (BatchTableCommit commit =
parquetWriteBuilder.newCommit()) {
+ commit.commit(messages);
+ }
+ }
+
+ catalog.alterTable(id, SchemaChange.setOption("file.format",
"avro"), false);
+ Table mixedTable = catalog.getTable(id);
+ BatchWriteBuilder avroWriteBuilder =
mixedTable.newBatchWriteBuilder();
+ try (BatchTableWrite write = avroWriteBuilder.newWrite()) {
+ write.write(GenericRow.of(2, 2));
+ List<CommitMessage> messages = write.prepareCommit();
+ try (BatchTableCommit commit = avroWriteBuilder.newCommit()) {
+ commit.commit(messages);
+ }
+ }
+
+ List<Split> plannedSplits =
mixedTable.newReadBuilder().newScan().plan().splits();
+ Assertions.assertEquals(2, plannedSplits.size(),
+ "fixture must plan one split for each file format");
+ Assertions.assertEquals("parquet",
firstRawFileFormat(plannedSplits.get(0)),
+ "the native split must be first so an unsafe LIMIT 1 would
retain only it");
+ Assertions.assertEquals("avro",
firstRawFileFormat(plannedSplits.get(1)),
+ "the later split must require the JNI reader");
+
+ RecordingPaimonCatalogOps ops = new RecordingPaimonCatalogOps();
+ ops.table = mixedTable;
+ PaimonScanPlanProvider provider = new PaimonScanPlanProvider(
+ PaimonCatalogProperties.of(Collections.emptyMap()), ops);
+ PaimonTableHandle handle = new PaimonTableHandle(
+ "db", "mixed_limit", Collections.emptyList(),
Collections.emptyList());
+ List<ConnectorScanRange> ranges = provider.planScan(
+ sessionWithProps(Collections.singletonMap(
+ "ignore_split_type", "IGNORE_NATIVE")),
+ ConnectorScanRequest.builder(handle,
Collections.emptyList())
+ .limit(1)
+ .build());
+
+ Assertions.assertEquals(1, ranges.size(),
+ "ignoring native splits must retain the later JNI split
despite LIMIT 1");
+ RecordReader<InternalRow> reader = mixedTable.newReadBuilder()
+ .newRead()
+ .createReader(deserializeJniSplits(ranges));
+ List<Integer> ids = new ArrayList<>();
+ reader.forEachRemaining(row -> ids.add(row.getInt(0)));
+ Assertions.assertEquals(Collections.singletonList(2), ids,
+ "routing after split planning must not turn a non-empty
scan into zero rows");
+ }
+ }
+
+ private static String firstRawFileFormat(Split split) {
+ List<RawFile> rawFiles = split.convertToRawFiles().orElseThrow(
+ () -> new AssertionError("fixture split must expose a raw
file"));
+ Assertions.assertFalse(rawFiles.isEmpty(), "fixture split must contain
a raw file");
+ return rawFiles.get(0).format();
+ }
+
@Test
public void ignoreNativeDropsNativeSplit(@TempDir Path warehouse) throws
Exception {
// FIX-L14: an append-only table's DataSplit is native-eligible;
ignore_split_type=IGNORE_NATIVE must
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]