You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@fluss.apache.org by ho...@apache.org on 2026/07/30 09:28:30 UTC
(fluss) branch main updated: [FIP-43][client] Support log scanner and writer for multiple tables (#3140)
This is an automated email from the ASF dual-hosted git repository.
loserwang1024 pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/fluss.git
The following commit(s) were added to refs/heads/main by this push:
new d951e4d6b [FIP-43][client] Support log scanner and writer for multiple tables (#3140)
d951e4d6b is described below
commit d951e4d6b40e8802131a9c0bd499adfff9f3d647
Author: Hongshun Wang <12...@users.noreply.github.com>
AuthorDate: Thu Jul 30 17:28:25 2026 +0800
[FIP-43][client] Support log scanner and writer for multiple tables (#3140)
---
.../java/org/apache/fluss/client/Connection.java | 11 +
.../org/apache/fluss/client/FlussConnection.java | 7 +
.../metrics/MultiTableWriterMetricGroup.java | 45 +
.../fluss/client/metrics/WriterMetricGroup.java | 38 +-
.../org/apache/fluss/client/table/MultiTable.java | 48 ++
.../apache/fluss/client/table/MultiTableImpl.java | 56 ++
...ndingFetch.java => MultiTableBatchScanner.java} | 22 +-
.../client/table/scanner/MultiTableRecord.java | 117 +++
.../fluss/client/table/scanner/MultiTableScan.java | 41 +
.../client/table/scanner/MultiTableScanImpl.java | 55 ++
.../fluss/client/table/scanner/ScanRecord.java | 25 +
.../table/scanner/batch/LimitBatchScanner.java | 7 +-
.../scanner/log/AbstractLogFetchCollector.java | 11 +-
.../table/scanner/log/AbstractLogScanner.java | 207 +++++
.../table/scanner/log/ArrowLogFetchCollector.java | 13 +-
.../client/table/scanner/log/CompletedFetch.java | 16 +-
.../table/scanner/log/CompletedPendingFetch.java | 5 +
.../table/scanner/log/DefaultCompletedFetch.java | 3 +
.../client/table/scanner/log/LogFetchBuffer.java | 11 +-
.../table/scanner/log/LogFetchCollector.java | 4 +-
.../fluss/client/table/scanner/log/LogFetcher.java | 431 +++++++---
.../client/table/scanner/log/LogScannerImpl.java | 290 +++----
.../table/scanner/log/MultiTableLogScanner.java | 99 +++
.../scanner/log/MultiTableLogScannerImpl.java | 387 +++++++++
.../table/scanner/log/MultiTableRecords.java | 223 +++++
.../client/table/scanner/log/PendingFetch.java | 3 +
.../table/scanner/log/RemoteCompletedFetch.java | 3 +
.../table/scanner/log/RemoteLogDownloadFuture.java | 17 +
.../table/scanner/log/RemoteLogDownloader.java | 44 +-
.../table/scanner/log/RemotePendingFetch.java | 10 +
.../client/table/scanner/log/ScanRecords.java | 31 +-
.../client/table/scanner/log/TableScanSpec.java | 64 ++
.../MultiTableWrite.java} | 21 +-
.../client/table/writer/MultiTableWriteImpl.java | 60 ++
.../client/table/writer/MultiTableWriteRecord.java | 138 +++
.../client/table/writer/MultiTableWriter.java | 58 ++
.../client/table/writer/MultiTableWriterImpl.java | 521 +++++++++++
.../PendingFetch.java => writer/WriteResult.java} | 22 +-
.../client/write/AbstractRowLogWriteBatch.java | 6 +-
.../fluss/client/write/ArrowLogWriteBatch.java | 4 +-
.../fluss/client/write/CompactedLogWriteBatch.java | 2 +
.../fluss/client/write/IndexedLogWriteBatch.java | 2 +
.../apache/fluss/client/write/KvWriteBatch.java | 19 +-
.../fluss/client/write/RecordAccumulator.java | 3 +
.../java/org/apache/fluss/client/write/Sender.java | 27 +-
.../org/apache/fluss/client/write/WriteBatch.java | 45 +-
.../apache/fluss/client/write/WriterClient.java | 13 +-
.../client/metrics/WriterMetricGroupTest.java | 122 +++
.../DefaultCompletedFetchBufferLifecycleTest.java | 2 +
.../scanner/log/DefaultCompletedFetchTest.java | 11 +-
.../table/scanner/log/LogFetchBufferTest.java | 37 +
.../table/scanner/log/LogFetchCollectorTest.java | 18 +-
.../table/scanner/log/LogFetcherFilterITCase.java | 54 +-
.../client/table/scanner/log/LogFetcherITCase.java | 51 +-
.../client/table/scanner/log/LogFetcherTest.java | 99 ++-
.../client/table/scanner/log/LogScannerITCase.java | 61 ++
.../scanner/log/MultiTableLogScannerITCase.java | 793 +++++++++++++++++
.../scanner/log/RemoteCompletedFetchTest.java | 10 +-
.../table/scanner/log/RemoteLogDownloaderTest.java | 136 ++-
.../client/table/scanner/log/ScanRecordsTest.java | 31 +
.../table/writer/MultiTableWriteRecordTest.java | 88 ++
.../table/writer/MultiTableWriterITCase.java | 949 +++++++++++++++++++++
.../client/write/CompactedLogWriteBatchTest.java | 66 +-
.../fluss/client/write/RecordAccumulatorTest.java | 50 +-
.../apache/fluss/record/DefaultLogRecordBatch.java | 20 +-
.../apache/fluss/record/LogRecordReadContext.java | 336 ++++----
.../fluss/record/DefaultLogRecordBatchTest.java | 5 +-
.../apache/fluss/record/FileLogProjectionTest.java | 21 +-
.../apache/fluss/record/FileLogRecordsTest.java | 8 +-
.../fluss/record/LogRecordBatchStatisticsTest.java | 31 +-
.../java/org/apache/fluss/record/LogTestBase.java | 9 +-
.../record/MemoryLogRecordsArrowBuilderTest.java | 13 +-
.../MemoryLogRecordsCompactedBuilderTest.java | 7 +-
.../apache/fluss/record/MemoryLogRecordsTest.java | 2 +
.../java/org/apache/fluss/record/TestData.java | 2 +-
.../apache/fluss/record/TestingSchemaGetter.java | 11 +-
.../org/apache/fluss/testutils/DataTestUtils.java | 16 +-
.../apache/fluss/testutils/LogRecordsAssert.java | 3 +-
.../apache/fluss/server/kv/KvRecoverHelper.java | 2 +-
.../fluss/server/kv/KvTabletMergeModeTest.java | 9 +
.../server/kv/KvTabletSchemaEvolutionTest.java | 2 +
.../org/apache/fluss/server/kv/KvTabletTest.java | 19 +-
.../kv/rowmerger/AggregateRowMergerTest.java | 3 +-
.../apache/fluss/server/log/LogSegmentTest.java | 2 +-
.../server/log/PredicateSchemaResolverTest.java | 2 +-
.../apache/fluss/server/replica/ReplicaTest.java | 1 +
.../replica/fetcher/ReplicaFetcherITCase.java | 3 +-
.../fluss/server/tablet/TabletServiceITCase.java | 6 +-
.../server/testutils/FlussClusterExtension.java | 1 +
89 files changed, 5705 insertions(+), 692 deletions(-)
diff --git a/fluss-client/src/main/java/org/apache/fluss/client/Connection.java b/fluss-client/src/main/java/org/apache/fluss/client/Connection.java
index b4c81e41b..05213ffd9 100644
--- a/fluss-client/src/main/java/org/apache/fluss/client/Connection.java
+++ b/fluss-client/src/main/java/org/apache/fluss/client/Connection.java
@@ -19,6 +19,7 @@ package org.apache.fluss.client;
import org.apache.fluss.annotation.PublicEvolving;
import org.apache.fluss.client.admin.Admin;
+import org.apache.fluss.client.table.MultiTable;
import org.apache.fluss.client.table.Table;
import org.apache.fluss.config.Configuration;
import org.apache.fluss.metadata.TablePath;
@@ -56,6 +57,16 @@ public interface Connection extends AutoCloseable {
/** Retrieve a new Table client to operate data in table. */
Table getTable(TablePath tablePath);
+ /**
+ * Retrieve a {@link MultiTable} client to scan and write data across multiple tables in a
+ * single unified API. Unlike {@link #getTable(TablePath)} which is bound to one table, {@code
+ * MultiTable} is table-agnostic: tables are identified per scan subscription and per write
+ * record.
+ *
+ * <p>{@link MultiTable} instances are light-weight and NOT thread-safe; obtain per-thread.
+ */
+ MultiTable getMultiTable();
+
/** Close the connection and release all resources. */
@Override
void close() throws Exception;
diff --git a/fluss-client/src/main/java/org/apache/fluss/client/FlussConnection.java b/fluss-client/src/main/java/org/apache/fluss/client/FlussConnection.java
index 0d800b3c9..9327ad0ac 100644
--- a/fluss-client/src/main/java/org/apache/fluss/client/FlussConnection.java
+++ b/fluss-client/src/main/java/org/apache/fluss/client/FlussConnection.java
@@ -22,6 +22,8 @@ import org.apache.fluss.client.admin.FlussAdmin;
import org.apache.fluss.client.lookup.LookupClient;
import org.apache.fluss.client.metadata.MetadataUpdater;
import org.apache.fluss.client.table.FlussTable;
+import org.apache.fluss.client.table.MultiTable;
+import org.apache.fluss.client.table.MultiTableImpl;
import org.apache.fluss.client.table.Table;
import org.apache.fluss.client.table.scanner.RemoteFileDownloader;
import org.apache.fluss.client.token.DefaultSecurityTokenManager;
@@ -108,6 +110,11 @@ public final class FlussConnection implements Connection {
return new FlussTable(this, tablePath, admin.getTableInfo(tablePath).join());
}
+ @Override
+ public MultiTable getMultiTable() {
+ return new MultiTableImpl(this);
+ }
+
public MetadataUpdater getMetadataUpdater() {
return metadataUpdater;
}
diff --git a/fluss-client/src/main/java/org/apache/fluss/client/metrics/MultiTableWriterMetricGroup.java b/fluss-client/src/main/java/org/apache/fluss/client/metrics/MultiTableWriterMetricGroup.java
new file mode 100644
index 000000000..850f11975
--- /dev/null
+++ b/fluss-client/src/main/java/org/apache/fluss/client/metrics/MultiTableWriterMetricGroup.java
@@ -0,0 +1,45 @@
+/*
+ * 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.fluss.client.metrics;
+
+import org.apache.fluss.annotation.Internal;
+import org.apache.fluss.client.write.WriterClient;
+import org.apache.fluss.rpc.metrics.ClientMetricGroup;
+
+import java.util.concurrent.atomic.AtomicLong;
+
+/**
+ * Metrics for {@link WriterClient}s owned by multi-table writers.
+ *
+ * <p>Unlike the connection-level shared writer client which keeps the {@code client.writer} metric
+ * identity, each multi-table writer owns a dedicated {@link WriterClient}, so multiple instances
+ * may coexist under the same {@link ClientMetricGroup}. This group therefore uses the dedicated
+ * {@code multi_table_writer} scope plus a unique {@code writer_instance_id} variable so that
+ * reporters derive distinct metric identities (e.g. JMX ObjectNames, Prometheus label sets) instead
+ * of colliding.
+ */
+@Internal
+public class MultiTableWriterMetricGroup extends WriterMetricGroup {
+
+ private static final String NAME = "multi_table_writer";
+ private static final AtomicLong NEXT_WRITER_INSTANCE_ID = new AtomicLong();
+
+ public MultiTableWriterMetricGroup(ClientMetricGroup parent) {
+ super(parent, NAME, String.valueOf(NEXT_WRITER_INSTANCE_ID.incrementAndGet()));
+ }
+}
diff --git a/fluss-client/src/main/java/org/apache/fluss/client/metrics/WriterMetricGroup.java b/fluss-client/src/main/java/org/apache/fluss/client/metrics/WriterMetricGroup.java
index c717a780e..c5ccc20ca 100644
--- a/fluss-client/src/main/java/org/apache/fluss/client/metrics/WriterMetricGroup.java
+++ b/fluss-client/src/main/java/org/apache/fluss/client/metrics/WriterMetricGroup.java
@@ -29,14 +29,30 @@ import org.apache.fluss.metrics.ThreadSafeSimpleCounter;
import org.apache.fluss.metrics.groups.AbstractMetricGroup;
import org.apache.fluss.rpc.metrics.ClientMetricGroup;
+import javax.annotation.Nullable;
+
+import java.util.Map;
+
import static org.apache.fluss.metrics.utils.MetricGroupUtils.makeScope;
-/** Metrics for {@link WriterClient}. */
+/**
+ * Metrics for {@link WriterClient}.
+ *
+ * <p>The connection-level shared writer client uses the {@code client.writer} scope without any
+ * extra variables. Writer clients that may have multiple instances under the same {@link
+ * ClientMetricGroup} should use a subclass with a dedicated scope and a disambiguating {@code
+ * writer_instance_id} variable, see {@link MultiTableWriterMetricGroup}.
+ */
@Internal
public class WriterMetricGroup extends AbstractMetricGroup {
private static final String name = "writer";
private static final int WINDOW_SIZE = 1024;
+ private final String groupName;
+
+ /** Only set for subclasses whose instances may coexist under the same parent. */
+ @Nullable private final String writerInstanceId;
+
private final Counter recordsRetryTotal;
private final Counter recordsSendTotal;
private final Counter bytesSendTotal;
@@ -47,7 +63,16 @@ public class WriterMetricGroup extends AbstractMetricGroup {
private volatile long batchQueueTimeMs = -1;
public WriterMetricGroup(ClientMetricGroup parent) {
- super(parent.getMetricRegistry(), makeScope(parent, name), parent);
+ this(parent, name, null);
+ }
+
+ protected WriterMetricGroup(
+ ClientMetricGroup parent, String groupName, @Nullable String writerInstanceId) {
+ super(parent.getMetricRegistry(), makeScope(parent, groupName), parent);
+ this.groupName = groupName;
+ // must be assigned before the first metric registration below, as reporters resolve
+ // getAllVariables() (which reads writerInstanceId) when a metric is added
+ this.writerInstanceId = writerInstanceId;
gauge(MetricNames.WRITER_BATCH_QUEUE_TIME_MS, () -> batchQueueTimeMs);
@@ -99,6 +124,13 @@ public class WriterMetricGroup extends AbstractMetricGroup {
@Override
protected String getGroupName(CharacterFilter filter) {
- return name;
+ return groupName;
+ }
+
+ @Override
+ protected final void putVariables(Map<String, String> variables) {
+ if (writerInstanceId != null) {
+ variables.put("writer_instance_id", writerInstanceId);
+ }
}
}
diff --git a/fluss-client/src/main/java/org/apache/fluss/client/table/MultiTable.java b/fluss-client/src/main/java/org/apache/fluss/client/table/MultiTable.java
new file mode 100644
index 000000000..ba90f1fa6
--- /dev/null
+++ b/fluss-client/src/main/java/org/apache/fluss/client/table/MultiTable.java
@@ -0,0 +1,48 @@
+/*
+ * 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.fluss.client.table;
+
+import org.apache.fluss.annotation.PublicEvolving;
+import org.apache.fluss.client.Connection;
+import org.apache.fluss.client.table.scanner.MultiTableScan;
+import org.apache.fluss.client.table.writer.MultiTableWrite;
+import org.apache.fluss.metadata.TablePath;
+
+/**
+ * Used to communicate with multiple Fluss tables through a single client. Obtain an instance from
+ * {@link Connection#getMultiTable()}.
+ *
+ * <p>{@code MultiTable} is the cross-table counterpart of {@link Table}. Useful for CDC ingestion,
+ * cross-table streaming pipelines, and multi-table catalog sinks.
+ *
+ * <p>Unlike {@link Connection#getTable(TablePath)} which is bound to one table, {@code MultiTable}
+ * is table-agnostic: tables are identified per scan subscription and per write record.
+ *
+ * <p>{@code MultiTable} instances are light-weight and NOT thread-safe; obtain per-thread.
+ *
+ * @since 0.7
+ */
+@PublicEvolving
+public interface MultiTable {
+
+ /** Build a scanner that reads data from multiple tables simultaneously. */
+ MultiTableScan newMultiTableScan();
+
+ /** Build a writer that writes data (with change types) to multiple tables. */
+ MultiTableWrite newMultiTableWrite();
+}
diff --git a/fluss-client/src/main/java/org/apache/fluss/client/table/MultiTableImpl.java b/fluss-client/src/main/java/org/apache/fluss/client/table/MultiTableImpl.java
new file mode 100644
index 000000000..201a938aa
--- /dev/null
+++ b/fluss-client/src/main/java/org/apache/fluss/client/table/MultiTableImpl.java
@@ -0,0 +1,56 @@
+/*
+ * 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.fluss.client.table;
+
+import org.apache.fluss.annotation.Internal;
+import org.apache.fluss.client.FlussConnection;
+import org.apache.fluss.client.table.scanner.MultiTableScan;
+import org.apache.fluss.client.table.scanner.MultiTableScanImpl;
+import org.apache.fluss.client.table.writer.MultiTableWrite;
+import org.apache.fluss.client.table.writer.MultiTableWriteImpl;
+
+import static org.apache.fluss.utils.Preconditions.checkNotNull;
+
+/**
+ * Default implementation of {@link MultiTable}.
+ *
+ * <p>{@code MultiTableImpl} is light-weight and NOT thread-safe; the underlying {@link
+ * FlussConnection} is shared and thread-safe, but each {@code MultiTableImpl} instance should be
+ * obtained per-thread.
+ *
+ * @since 0.7
+ */
+@Internal
+public class MultiTableImpl implements MultiTable {
+
+ private final FlussConnection connection;
+
+ public MultiTableImpl(FlussConnection connection) {
+ this.connection = checkNotNull(connection, "connection");
+ }
+
+ @Override
+ public MultiTableScan newMultiTableScan() {
+ return new MultiTableScanImpl(connection);
+ }
+
+ @Override
+ public MultiTableWrite newMultiTableWrite() {
+ return new MultiTableWriteImpl(connection);
+ }
+}
diff --git a/fluss-client/src/main/java/org/apache/fluss/client/table/scanner/log/PendingFetch.java b/fluss-client/src/main/java/org/apache/fluss/client/table/scanner/MultiTableBatchScanner.java
similarity index 66%
copy from fluss-client/src/main/java/org/apache/fluss/client/table/scanner/log/PendingFetch.java
copy to fluss-client/src/main/java/org/apache/fluss/client/table/scanner/MultiTableBatchScanner.java
index c3fa29936..5149c6417 100644
--- a/fluss-client/src/main/java/org/apache/fluss/client/table/scanner/log/PendingFetch.java
+++ b/fluss-client/src/main/java/org/apache/fluss/client/table/scanner/MultiTableBatchScanner.java
@@ -15,19 +15,17 @@
* limitations under the License.
*/
-package org.apache.fluss.client.table.scanner.log;
+package org.apache.fluss.client.table.scanner;
-import org.apache.fluss.metadata.TableBucket;
+import org.apache.fluss.annotation.PublicEvolving;
/**
- * A pending fetch is a fetch that requested to download remote log segment file to local but not
- * yet been completed.
+ * Reads current data from multiple tables through a single client.
+ *
+ * <p>NOTE: planned for a future release; declared as a placeholder for forward compatibility. There
+ * is no concrete implementation today.
+ *
+ * @since 0.7
*/
-interface PendingFetch {
-
- TableBucket tableBucket();
-
- boolean isCompleted();
-
- CompletedFetch toCompletedFetch();
-}
+@PublicEvolving
+public interface MultiTableBatchScanner extends AutoCloseable {}
diff --git a/fluss-client/src/main/java/org/apache/fluss/client/table/scanner/MultiTableRecord.java b/fluss-client/src/main/java/org/apache/fluss/client/table/scanner/MultiTableRecord.java
new file mode 100644
index 000000000..e05662f6d
--- /dev/null
+++ b/fluss-client/src/main/java/org/apache/fluss/client/table/scanner/MultiTableRecord.java
@@ -0,0 +1,117 @@
+/*
+ * 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.fluss.client.table.scanner;
+
+import org.apache.fluss.annotation.PublicEvolving;
+import org.apache.fluss.client.table.scanner.log.MultiTableLogScanner;
+import org.apache.fluss.client.table.writer.MultiTableWriter;
+import org.apache.fluss.metadata.Schema;
+import org.apache.fluss.metadata.TablePath;
+import org.apache.fluss.record.ChangeType;
+import org.apache.fluss.row.InternalRow;
+
+import static org.apache.fluss.utils.Preconditions.checkNotNull;
+
+/**
+ * A record produced by a {@link MultiTableLogScanner}. Wraps an unchanged {@link ScanRecord} and
+ * enriches it with the source table identity (path, id) and schema (schema object, schema id).
+ *
+ * <p>The underlying {@link ScanRecord} structure is NOT modified.
+ *
+ * <p>Read-side only. For writes via {@link MultiTableWriter}, use {@link
+ * org.apache.fluss.client.table.writer.MultiTableWriteRecord} which carries only the write-relevant
+ * fields.
+ *
+ * @since 0.7
+ */
+@PublicEvolving
+public final class MultiTableRecord {
+
+ private final TablePath tablePath;
+ private final long tableId;
+ private final Schema schema;
+ private final int schemaId;
+ private final ScanRecord scanRecord;
+
+ public MultiTableRecord(
+ TablePath tablePath, long tableId, int schemaId, Schema schema, ScanRecord scanRecord) {
+ this.tablePath = checkNotNull(tablePath, "tablePath");
+ this.tableId = tableId;
+ this.schema = checkNotNull(schema, "schema");
+ this.schemaId = schemaId;
+ this.scanRecord = checkNotNull(scanRecord, "scanRecord");
+ }
+
+ // ---- table identity ----
+
+ public TablePath getTablePath() {
+ return tablePath;
+ }
+
+ public long getTableId() {
+ return tableId;
+ }
+
+ // ---- schema info ----
+
+ public Schema getSchema() {
+ return schema;
+ }
+
+ public int getSchemaId() {
+ return schemaId;
+ }
+
+ // ---- underlying record (unchanged) ----
+
+ public ScanRecord getScanRecord() {
+ return scanRecord;
+ }
+
+ // ---- convenience delegates ----
+
+ public long logOffset() {
+ return scanRecord.logOffset();
+ }
+
+ public long timestamp() {
+ return scanRecord.timestamp();
+ }
+
+ public ChangeType getChangeType() {
+ return scanRecord.getChangeType();
+ }
+
+ public InternalRow getRow() {
+ return scanRecord.getRow();
+ }
+
+ @Override
+ public String toString() {
+ return "MultiTableRecord{"
+ + "tablePath="
+ + tablePath
+ + ", tableId="
+ + tableId
+ + ", schemaId="
+ + schemaId
+ + ", scanRecord="
+ + scanRecord
+ + '}';
+ }
+}
diff --git a/fluss-client/src/main/java/org/apache/fluss/client/table/scanner/MultiTableScan.java b/fluss-client/src/main/java/org/apache/fluss/client/table/scanner/MultiTableScan.java
new file mode 100644
index 000000000..a533753cf
--- /dev/null
+++ b/fluss-client/src/main/java/org/apache/fluss/client/table/scanner/MultiTableScan.java
@@ -0,0 +1,41 @@
+/*
+ * 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.fluss.client.table.scanner;
+
+import org.apache.fluss.annotation.PublicEvolving;
+import org.apache.fluss.client.table.scanner.log.MultiTableLogScanner;
+
+/**
+ * Builder for a multi-table scanner.
+ *
+ * <p>Phase 1 surface intentionally exposes no per-table builder configuration: tables are
+ * registered dynamically on the created {@link MultiTableLogScanner} when {@code subscribe(...)} is
+ * first called for a {@code TablePath}. Future phases may add {@code withTable(...)} overloads to
+ * pre-declare tables together with projection / filter pushdown.
+ *
+ * @since 0.7
+ */
+@PublicEvolving
+public interface MultiTableScan {
+
+ /**
+ * Creates a {@link MultiTableLogScanner} to continuously read log data from multiple tables.
+ * Tables are registered dynamically on first {@code subscribe(...)} call.
+ */
+ MultiTableLogScanner createLogScanner();
+}
diff --git a/fluss-client/src/main/java/org/apache/fluss/client/table/scanner/MultiTableScanImpl.java b/fluss-client/src/main/java/org/apache/fluss/client/table/scanner/MultiTableScanImpl.java
new file mode 100644
index 000000000..53fa823a7
--- /dev/null
+++ b/fluss-client/src/main/java/org/apache/fluss/client/table/scanner/MultiTableScanImpl.java
@@ -0,0 +1,55 @@
+/*
+ * 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.fluss.client.table.scanner;
+
+import org.apache.fluss.annotation.Internal;
+import org.apache.fluss.client.FlussConnection;
+import org.apache.fluss.client.table.scanner.log.MultiTableLogScanner;
+import org.apache.fluss.client.table.scanner.log.MultiTableLogScannerImpl;
+
+import static org.apache.fluss.utils.Preconditions.checkNotNull;
+
+/**
+ * Default implementation of {@link MultiTableScan}.
+ *
+ * <p>Phase 1: a thin pass-through that creates a {@link MultiTableLogScanner} bound to the {@link
+ * FlussConnection}. Tables are registered dynamically on the scanner via {@code subscribe(...)} —
+ * no upfront declaration is required.
+ *
+ * @since 0.7
+ */
+@Internal
+public class MultiTableScanImpl implements MultiTableScan {
+
+ private final FlussConnection connection;
+
+ public MultiTableScanImpl(FlussConnection connection) {
+ this.connection = checkNotNull(connection, "connection");
+ }
+
+ @Override
+ public MultiTableLogScanner createLogScanner() {
+ return new MultiTableLogScannerImpl(
+ "multi-table-log-scanner",
+ connection.getMetadataUpdater(),
+ connection.getConfiguration(),
+ connection.getClientMetricGroup(),
+ connection.getOrCreateRemoteFileDownloader(),
+ connection.getOrCreateAdmin());
+ }
+}
diff --git a/fluss-client/src/main/java/org/apache/fluss/client/table/scanner/ScanRecord.java b/fluss-client/src/main/java/org/apache/fluss/client/table/scanner/ScanRecord.java
index 829c56a00..3c34a7f1d 100644
--- a/fluss-client/src/main/java/org/apache/fluss/client/table/scanner/ScanRecord.java
+++ b/fluss-client/src/main/java/org/apache/fluss/client/table/scanner/ScanRecord.java
@@ -33,6 +33,8 @@ public class ScanRecord implements LogRecord {
/** Indicates that the size in bytes is unknown for this record. */
public static final int UNKNOWN_SIZE_IN_BYTES = -1;
+ private final long tableId;
+ private final int schemaId;
private final long offset;
private final long timestamp;
private final ChangeType changeType;
@@ -53,6 +55,19 @@ public class ScanRecord implements LogRecord {
public ScanRecord(
long offset, long timestamp, ChangeType changeType, InternalRow row, int sizeInBytes) {
+ this(INVALID, -1, offset, timestamp, changeType, row, sizeInBytes);
+ }
+
+ public ScanRecord(
+ long tableId,
+ int schemaId,
+ long offset,
+ long timestamp,
+ ChangeType changeType,
+ InternalRow row,
+ int sizeInBytes) {
+ this.tableId = tableId;
+ this.schemaId = schemaId;
this.offset = offset;
this.timestamp = timestamp;
this.changeType = changeType;
@@ -85,6 +100,16 @@ public class ScanRecord implements LogRecord {
return sizeInBytes;
}
+ /** The table id this record belongs to. */
+ public long getTableId() {
+ return tableId;
+ }
+
+ /** The schema id of the batch this record was produced with. */
+ public int getSchemaId() {
+ return schemaId;
+ }
+
@Override
public boolean equals(Object o) {
if (this == o) {
diff --git a/fluss-client/src/main/java/org/apache/fluss/client/table/scanner/batch/LimitBatchScanner.java b/fluss-client/src/main/java/org/apache/fluss/client/table/scanner/batch/LimitBatchScanner.java
index 26e097fef..b5a381ad1 100644
--- a/fluss-client/src/main/java/org/apache/fluss/client/table/scanner/batch/LimitBatchScanner.java
+++ b/fluss-client/src/main/java/org/apache/fluss/client/table/scanner/batch/LimitBatchScanner.java
@@ -181,7 +181,12 @@ public class LimitBatchScanner implements BatchScanner {
} else {
LogRecordReadContext readContext =
LogRecordReadContext.createReadContext(
- tableInfo, false, null, schemaGetter, chunkedFactory);
+ tableInfo,
+ false,
+ LogRecordReadContext.SchemaResolution.TARGET,
+ null,
+ schemaGetter,
+ chunkedFactory);
LogRecords records = MemoryLogRecords.pointToByteBuffer(recordsBuffer);
for (LogRecordBatch logRecordBatch : records.batches()) {
// A batch of log record maybe little more than limit, thus we need slice the
diff --git a/fluss-client/src/main/java/org/apache/fluss/client/table/scanner/log/AbstractLogFetchCollector.java b/fluss-client/src/main/java/org/apache/fluss/client/table/scanner/log/AbstractLogFetchCollector.java
index e058e0e2d..3ffa640f9 100644
--- a/fluss-client/src/main/java/org/apache/fluss/client/table/scanner/log/AbstractLogFetchCollector.java
+++ b/fluss-client/src/main/java/org/apache/fluss/client/table/scanner/log/AbstractLogFetchCollector.java
@@ -44,19 +44,16 @@ import java.util.Map;
@Internal
abstract class AbstractLogFetchCollector<T, R> {
protected final Logger log;
- protected final TablePath tablePath;
protected final LogScannerStatus logScannerStatus;
private final int maxPollRecords;
private final MetadataUpdater metadataUpdater;
protected AbstractLogFetchCollector(
Logger log,
- TablePath tablePath,
LogScannerStatus logScannerStatus,
Configuration conf,
MetadataUpdater metadataUpdater) {
this.log = log;
- this.tablePath = tablePath;
this.logScannerStatus = logScannerStatus;
this.maxPollRecords = conf.getInt(ConfigOptions.CLIENT_SCANNER_LOG_MAX_POLL_RECORDS);
this.metadataUpdater = metadataUpdater;
@@ -159,7 +156,11 @@ abstract class AbstractLogFetchCollector<T, R> {
if (error.isSuccess()) {
return handleInitializeSuccess(completedFetch);
} else {
- handleInitializeErrors(completedFetch, error.error(), error.messageWithFallback());
+ handleInitializeErrors(
+ completedFetch,
+ error.error(),
+ error.messageWithFallback(),
+ completedFetch.tablePath);
return null;
}
} finally {
@@ -205,7 +206,7 @@ abstract class AbstractLogFetchCollector<T, R> {
}
private void handleInitializeErrors(
- CompletedFetch completedFetch, Errors error, String errorMessage) {
+ CompletedFetch completedFetch, Errors error, String errorMessage, TablePath tablePath) {
TableBucket tb = completedFetch.tableBucket;
long fetchOffset = completedFetch.nextFetchOffset();
if (error == Errors.NOT_LEADER_OR_FOLLOWER
diff --git a/fluss-client/src/main/java/org/apache/fluss/client/table/scanner/log/AbstractLogScanner.java b/fluss-client/src/main/java/org/apache/fluss/client/table/scanner/log/AbstractLogScanner.java
new file mode 100644
index 000000000..8e16b35ef
--- /dev/null
+++ b/fluss-client/src/main/java/org/apache/fluss/client/table/scanner/log/AbstractLogScanner.java
@@ -0,0 +1,207 @@
+/*
+ * 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.fluss.client.table.scanner.log;
+
+import org.apache.fluss.annotation.Internal;
+import org.apache.fluss.client.metrics.ScannerMetricGroup;
+import org.apache.fluss.exception.WakeupException;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.io.IOException;
+import java.time.Duration;
+import java.util.ConcurrentModificationException;
+import java.util.concurrent.atomic.AtomicInteger;
+import java.util.concurrent.atomic.AtomicLong;
+
+/**
+ * Base class that centralizes the shared poll loop, lightweight single-thread guard, wakeup and
+ * close lifecycle for log scanners. Concrete scanners supply the result type {@code R} and an
+ * enrichment step from raw {@link ScanRecords} to that type.
+ *
+ * <p>This class is NOT thread-safe. The lightweight guard only detects accidental concurrent use
+ * and throws {@link ConcurrentModificationException}; it does not serialize callers.
+ */
+@Internal
+abstract class AbstractLogScanner<R> implements AutoCloseable {
+
+ private static final Logger LOG = LoggerFactory.getLogger(AbstractLogScanner.class);
+
+ private static final long NO_CURRENT_THREAD = -1L;
+
+ protected final String name;
+ protected final LogScannerStatus logScannerStatus;
+ protected final LogFetcher logFetcher;
+ protected final ScannerMetricGroup scannerMetricGroup;
+
+ private volatile boolean closed = false;
+
+ // currentThread holds the threadId of the current thread accessing the scanner
+ // and is used to prevent multithreaded access.
+ private final AtomicLong currentThread = new AtomicLong(NO_CURRENT_THREAD);
+ // refCount is used to allow reentrant access by the thread that has acquired currentThread.
+ private final AtomicInteger refCount = new AtomicInteger(0);
+
+ protected AbstractLogScanner(
+ String name,
+ LogScannerStatus logScannerStatus,
+ LogFetcher logFetcher,
+ ScannerMetricGroup scannerMetricGroup) {
+ this.name = name;
+ this.logScannerStatus = logScannerStatus;
+ this.logFetcher = logFetcher;
+ this.scannerMetricGroup = scannerMetricGroup;
+ }
+
+ /**
+ * Template poll loop shared by all concrete scanners.
+ *
+ * <ul>
+ * <li>If {@link #pollForFetches()} returns a batch with scanner progress, {@link
+ * #toResult(ScanRecords)} is invoked to produce the concrete result type.
+ * <li>If the batch has no progress, we wait on {@link LogFetcher#awaitNotEmpty(long)} until
+ * either data arrives, the timeout elapses, or {@link #wakeup()} is called.
+ * <li>Any empty/timeout/wakeup path returns {@link #emptyResult()}.
+ * </ul>
+ */
+ public final R poll(Duration timeout) {
+ acquireAndEnsureOpen();
+ try {
+ if (!logScannerStatus.prepareToPoll()) {
+ throw new IllegalStateException(notSubscribedMessage());
+ }
+
+ scannerMetricGroup.recordPollStart(System.currentTimeMillis());
+ long timeoutNanos = timeout.toNanos();
+ long startNanos = System.nanoTime();
+ do {
+ ScanRecords scanRecords = pollForFetches();
+ if (!scanRecords.hasProgress()) {
+ try {
+ if (!logFetcher.awaitNotEmpty(startNanos + timeoutNanos)) {
+ // no data in buffer within the timeout
+ return emptyResult();
+ }
+ } catch (WakeupException e) {
+ // wakeup() was called, return empty
+ return emptyResult();
+ }
+ } else {
+ // before returning the fetched records, send off the next round of fetches
+ // to enable pipelining while the user is handling the current batch.
+ logFetcher.sendFetches();
+ return toResult(scanRecords);
+ }
+ } while (System.nanoTime() - startNanos < timeoutNanos);
+
+ return emptyResult();
+ } finally {
+ release();
+ scannerMetricGroup.recordPollEnd(System.currentTimeMillis());
+ }
+ }
+
+ private ScanRecords pollForFetches() {
+ ScanRecords scanRecords = logFetcher.collectFetch();
+ if (scanRecords.hasProgress()) {
+ return scanRecords;
+ }
+
+ // send any new fetches (won't resend pending fetches).
+ logFetcher.sendFetches();
+
+ return logFetcher.collectFetch();
+ }
+
+ /** The empty sentinel for the concrete result type. */
+ protected abstract R emptyResult();
+
+ /** Convert a non-empty batch of {@link ScanRecords} into the concrete result type. */
+ protected abstract R toResult(ScanRecords scanRecords);
+
+ /** Error message for {@link IllegalStateException} when no buckets are subscribed. */
+ protected abstract String notSubscribedMessage();
+
+ public final void wakeup() {
+ logFetcher.wakeup();
+ }
+
+ /**
+ * Acquire the light lock and ensure that the scanner hasn't been closed.
+ *
+ * @throws IllegalStateException if the scanner has been closed
+ */
+ protected final void acquireAndEnsureOpen() {
+ acquire();
+ if (closed) {
+ release();
+ throw new IllegalStateException("This scanner has already been closed.");
+ }
+ }
+
+ /**
+ * Acquire the light lock protecting this scanner from multithreaded access. Instead of blocking
+ * when the lock is not available, we throw an exception (multithreaded usage is not supported).
+ *
+ * @throws ConcurrentModificationException if another thread already has the lock
+ */
+ private void acquire() {
+ final Thread thread = Thread.currentThread();
+ final long threadId = thread.getId();
+ if (threadId != currentThread.get()
+ && !currentThread.compareAndSet(NO_CURRENT_THREAD, threadId)) {
+ throw new ConcurrentModificationException(
+ "Scanner is not safe for multithreaded access. "
+ + "currentThread(name: "
+ + thread.getName()
+ + ", id: "
+ + threadId
+ + ")"
+ + " otherThread(id: "
+ + currentThread.get()
+ + ")");
+ }
+ refCount.incrementAndGet();
+ }
+
+ /** Release the light lock protecting the scanner from multithreaded access. */
+ protected final void release() {
+ if (refCount.decrementAndGet() == 0) {
+ currentThread.set(NO_CURRENT_THREAD);
+ }
+ }
+
+ @Override
+ public final void close() {
+ acquire();
+ try {
+ if (!closed) {
+ LOG.trace("Closing log scanner: {}", name);
+ scannerMetricGroup.close();
+ logFetcher.close();
+ }
+ LOG.debug("Log scanner: {} has been closed", name);
+ } catch (IOException e) {
+ throw new RuntimeException("Failed to close log scanner: " + name, e);
+ } finally {
+ closed = true;
+ release();
+ }
+ }
+}
diff --git a/fluss-client/src/main/java/org/apache/fluss/client/table/scanner/log/ArrowLogFetchCollector.java b/fluss-client/src/main/java/org/apache/fluss/client/table/scanner/log/ArrowLogFetchCollector.java
index ef53b464e..cbe56b094 100644
--- a/fluss-client/src/main/java/org/apache/fluss/client/table/scanner/log/ArrowLogFetchCollector.java
+++ b/fluss-client/src/main/java/org/apache/fluss/client/table/scanner/log/ArrowLogFetchCollector.java
@@ -21,7 +21,6 @@ import org.apache.fluss.annotation.Internal;
import org.apache.fluss.client.metadata.MetadataUpdater;
import org.apache.fluss.config.Configuration;
import org.apache.fluss.metadata.TableBucket;
-import org.apache.fluss.metadata.TablePath;
import org.apache.fluss.record.ArrowBatchData;
import org.slf4j.Logger;
@@ -40,11 +39,10 @@ public class ArrowLogFetchCollector
private static final Logger LOG = LoggerFactory.getLogger(ArrowLogFetchCollector.class);
public ArrowLogFetchCollector(
- TablePath tablePath,
LogScannerStatus logScannerStatus,
Configuration conf,
MetadataUpdater metadataUpdater) {
- super(LOG, tablePath, logScannerStatus, conf, metadataUpdater);
+ super(LOG, logScannerStatus, conf, metadataUpdater);
}
@Override
@@ -70,14 +68,15 @@ public class ArrowLogFetchCollector
@Override
protected void closeFetchedRecords(Map<TableBucket, List<ArrowBatchData>> fetched) {
- for (List<ArrowBatchData> batches : fetched.values()) {
- for (ArrowBatchData batch : batches) {
+ for (Map.Entry<TableBucket, List<ArrowBatchData>> entry : fetched.entrySet()) {
+
+ for (ArrowBatchData batch : entry.getValue()) {
try {
batch.close();
} catch (Exception e) {
LOG.warn(
- "Failed to close Arrow batch during cleanup for table {}",
- tablePath,
+ "Failed to close Arrow batch during cleanup for bucket {}",
+ entry.getKey(),
e);
}
}
diff --git a/fluss-client/src/main/java/org/apache/fluss/client/table/scanner/log/CompletedFetch.java b/fluss-client/src/main/java/org/apache/fluss/client/table/scanner/log/CompletedFetch.java
index f22a65213..de9cb1d3f 100644
--- a/fluss-client/src/main/java/org/apache/fluss/client/table/scanner/log/CompletedFetch.java
+++ b/fluss-client/src/main/java/org/apache/fluss/client/table/scanner/log/CompletedFetch.java
@@ -22,6 +22,7 @@ import org.apache.fluss.client.table.scanner.ScanRecord;
import org.apache.fluss.exception.CorruptRecordException;
import org.apache.fluss.exception.FetchException;
import org.apache.fluss.metadata.TableBucket;
+import org.apache.fluss.metadata.TablePath;
import org.apache.fluss.record.ArrowBatchData;
import org.apache.fluss.record.ChangeType;
import org.apache.fluss.record.CompactedLogRecord;
@@ -56,6 +57,7 @@ public abstract class CompletedFetch {
static final long NO_FILTERED_END_OFFSET = -1L;
final TableBucket tableBucket;
+ final TablePath tablePath;
final ApiError error;
final int sizeInBytes;
final long highWatermark;
@@ -66,9 +68,9 @@ public abstract class CompletedFetch {
private final Iterator<LogRecordBatch> batches;
private final LogScannerStatus logScannerStatus;
protected final LogRecordReadContext readContext;
- protected final InternalRow.FieldGetter[] selectedFieldGetters;
private LogRecordBatch currentBatch;
+ private int currentBatchSchemaId = -1;
private LogRecord lastRecord;
private CloseableIterator<LogRecord> records;
private int recordsRead = 0;
@@ -80,6 +82,7 @@ public abstract class CompletedFetch {
public CompletedFetch(
TableBucket tableBucket,
+ TablePath tablePath,
ApiError error,
int sizeInBytes,
long highWatermark,
@@ -90,6 +93,7 @@ public abstract class CompletedFetch {
long fetchOffset,
long filteredEndOffset) {
this.tableBucket = tableBucket;
+ this.tablePath = tablePath;
this.error = error;
this.sizeInBytes = sizeInBytes;
this.highWatermark = highWatermark;
@@ -97,7 +101,6 @@ public abstract class CompletedFetch {
this.readContext = readContext;
this.isCheckCrcs = isCheckCrcs;
this.logScannerStatus = logScannerStatus;
- this.selectedFieldGetters = readContext.getSelectedFieldGetters();
this.fetchOffset = fetchOffset;
checkArgument(
filteredEndOffset == NO_FILTERED_END_OFFSET || filteredEndOffset >= fetchOffset,
@@ -114,12 +117,20 @@ public abstract class CompletedFetch {
// refactor #fetchRecords to return an iterator which lazily deserialize
// from underlying record stream and arrow buffer.
ScanRecord toScanRecord(LogRecord record) {
+ long tableId = tableBucket.getTableId();
+ int schemaId = currentBatchSchemaId;
+ InternalRow.FieldGetter[] selectedFieldGetters =
+ readContext.getSelectedFieldGetters(schemaId);
+
GenericRow newRow = new GenericRow(selectedFieldGetters.length);
InternalRow internalRow = record.getRow();
for (int i = 0; i < selectedFieldGetters.length; i++) {
newRow.setField(i, selectedFieldGetters[i].getFieldOrNull(internalRow));
}
+
return new ScanRecord(
+ tableId,
+ schemaId,
record.logOffset(),
record.timestamp(),
record.getChangeType(),
@@ -345,6 +356,7 @@ public abstract class CompletedFetch {
return null;
}
+ currentBatchSchemaId = batch.schemaId();
records = batch.records(readContext);
} else {
LogRecord record = records.next();
diff --git a/fluss-client/src/main/java/org/apache/fluss/client/table/scanner/log/CompletedPendingFetch.java b/fluss-client/src/main/java/org/apache/fluss/client/table/scanner/log/CompletedPendingFetch.java
index 2ca237875..c713cd076 100644
--- a/fluss-client/src/main/java/org/apache/fluss/client/table/scanner/log/CompletedPendingFetch.java
+++ b/fluss-client/src/main/java/org/apache/fluss/client/table/scanner/log/CompletedPendingFetch.java
@@ -45,4 +45,9 @@ class CompletedPendingFetch implements PendingFetch {
public CompletedFetch toCompletedFetch() {
return completedFetch;
}
+
+ @Override
+ public void discard() {
+ completedFetch.drain();
+ }
}
diff --git a/fluss-client/src/main/java/org/apache/fluss/client/table/scanner/log/DefaultCompletedFetch.java b/fluss-client/src/main/java/org/apache/fluss/client/table/scanner/log/DefaultCompletedFetch.java
index 595f18dd2..0384389c2 100644
--- a/fluss-client/src/main/java/org/apache/fluss/client/table/scanner/log/DefaultCompletedFetch.java
+++ b/fluss-client/src/main/java/org/apache/fluss/client/table/scanner/log/DefaultCompletedFetch.java
@@ -19,6 +19,7 @@ package org.apache.fluss.client.table.scanner.log;
import org.apache.fluss.annotation.Internal;
import org.apache.fluss.metadata.TableBucket;
+import org.apache.fluss.metadata.TablePath;
import org.apache.fluss.record.LogRecordReadContext;
import org.apache.fluss.rpc.entity.FetchLogResultForBucket;
import org.apache.fluss.rpc.messages.FetchLogRequest;
@@ -42,6 +43,7 @@ class DefaultCompletedFetch extends CompletedFetch {
public DefaultCompletedFetch(
TableBucket tableBucket,
+ TablePath tablePath,
FetchLogResultForBucket fetchLogResultForBucket,
LogRecordReadContext readContext,
LogScannerStatus logScannerStatus,
@@ -50,6 +52,7 @@ class DefaultCompletedFetch extends CompletedFetch {
@Nullable ByteBuf parsedByteBuf) {
super(
tableBucket,
+ tablePath,
fetchLogResultForBucket.getError(),
fetchLogResultForBucket.recordsOrEmpty().sizeInBytes(),
fetchLogResultForBucket.getHighWatermark(),
diff --git a/fluss-client/src/main/java/org/apache/fluss/client/table/scanner/log/LogFetchBuffer.java b/fluss-client/src/main/java/org/apache/fluss/client/table/scanner/log/LogFetchBuffer.java
index 12928eda4..b6b6e6144 100644
--- a/fluss-client/src/main/java/org/apache/fluss/client/table/scanner/log/LogFetchBuffer.java
+++ b/fluss-client/src/main/java/org/apache/fluss/client/table/scanner/log/LogFetchBuffer.java
@@ -227,7 +227,16 @@ public class LogFetchBuffer implements AutoCloseable {
}
// remove entries that not matches the buckets from pendingFetches
- pendingFetches.entrySet().removeIf(entry -> !buckets.contains(entry.getKey()));
+ pendingFetches
+ .entrySet()
+ .removeIf(
+ entry -> {
+ if (!buckets.contains(entry.getKey())) {
+ entry.getValue().forEach(PendingFetch::discard);
+ return true;
+ }
+ return false;
+ });
});
}
diff --git a/fluss-client/src/main/java/org/apache/fluss/client/table/scanner/log/LogFetchCollector.java b/fluss-client/src/main/java/org/apache/fluss/client/table/scanner/log/LogFetchCollector.java
index 799e74d2e..abdbede39 100644
--- a/fluss-client/src/main/java/org/apache/fluss/client/table/scanner/log/LogFetchCollector.java
+++ b/fluss-client/src/main/java/org/apache/fluss/client/table/scanner/log/LogFetchCollector.java
@@ -22,7 +22,6 @@ import org.apache.fluss.client.metadata.MetadataUpdater;
import org.apache.fluss.client.table.scanner.ScanRecord;
import org.apache.fluss.config.Configuration;
import org.apache.fluss.metadata.TableBucket;
-import org.apache.fluss.metadata.TablePath;
import org.apache.fluss.record.LogRecord;
import org.apache.fluss.record.LogRecordBatch;
@@ -49,11 +48,10 @@ public class LogFetchCollector extends AbstractLogFetchCollector<ScanRecord, Sca
private static final Logger LOG = LoggerFactory.getLogger(LogFetchCollector.class);
public LogFetchCollector(
- TablePath tablePath,
LogScannerStatus logScannerStatus,
Configuration conf,
MetadataUpdater metadataUpdater) {
- super(LOG, tablePath, logScannerStatus, conf, metadataUpdater);
+ super(LOG, logScannerStatus, conf, metadataUpdater);
}
@Override
diff --git a/fluss-client/src/main/java/org/apache/fluss/client/table/scanner/log/LogFetcher.java b/fluss-client/src/main/java/org/apache/fluss/client/table/scanner/log/LogFetcher.java
index a5b8ba483..a178ab206 100644
--- a/fluss-client/src/main/java/org/apache/fluss/client/table/scanner/log/LogFetcher.java
+++ b/fluss-client/src/main/java/org/apache/fluss/client/table/scanner/log/LogFetcher.java
@@ -75,7 +75,6 @@ import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.Set;
-import java.util.stream.Collectors;
import static org.apache.fluss.rpc.util.CommonRpcMessageUtils.getFetchLogResultForBucket;
import static org.apache.fluss.utils.Preconditions.checkNotNull;
@@ -89,17 +88,11 @@ import static org.apache.fluss.utils.Preconditions.checkNotNull;
public class LogFetcher implements Closeable {
private static final Logger LOG = LoggerFactory.getLogger(LogFetcher.class);
- private final TablePath tablePath;
- private final boolean isPartitioned;
- private final LogRecordReadContext readContext;
- // TODO this context can be merge with readContext. Introduce it only because log remote read
- // currently can only do project when generate scanRecord instead of doing project while read
- // bytes from remote file.
- private final LogRecordReadContext remoteReadContext;
+ /** Per-table read contexts keyed by tableId. */
+ @GuardedBy("this")
+ private final Map<Long, TableReadContext> tableReadContexts;
+
private final ChunkedAllocationManager.ChunkedFactory chunkedFactory;
- @Nullable private final Projection projection;
- @Nullable private final org.apache.fluss.rpc.messages.PbPredicate cachedPbPredicate;
- private final int filterSchemaId;
private final int maxFetchBytes;
private final int maxBucketFetchBytes;
private final int minFetchBytes;
@@ -120,33 +113,19 @@ public class LogFetcher implements Closeable {
private final MetadataUpdater metadataUpdater;
private final ScannerMetricGroup scannerMetricGroup;
+ private final LogRecordReadContext.SchemaResolution schemaResolution;
+ /** Create a multi-table LogFetcher without an initial table. */
public LogFetcher(
- TableInfo tableInfo,
- @Nullable Projection projection,
- @Nullable Predicate recordBatchFilter,
+ String scannerName,
LogScannerStatus logScannerStatus,
Configuration conf,
MetadataUpdater metadataUpdater,
ScannerMetricGroup scannerMetricGroup,
RemoteFileDownloader remoteFileDownloader,
- SchemaGetter schemaGetter) {
- this.tablePath = tableInfo.getTablePath();
- this.isPartitioned = tableInfo.isPartitioned();
+ LogRecordReadContext.SchemaResolution schemaResolution) {
+ this.tableReadContexts = new HashMap<>();
this.chunkedFactory = new ChunkedAllocationManager.ChunkedFactory();
- this.readContext =
- LogRecordReadContext.createReadContext(
- tableInfo, false, projection, schemaGetter, chunkedFactory);
- this.remoteReadContext =
- LogRecordReadContext.createReadContext(
- tableInfo, true, projection, schemaGetter, chunkedFactory);
- this.projection = projection;
- this.cachedPbPredicate =
- recordBatchFilter != null
- ? PredicateMessageUtils.toPbPredicate(
- recordBatchFilter, tableInfo.getRowType())
- : null;
- this.filterSchemaId = tableInfo.getSchemaId();
this.logScannerStatus = logScannerStatus;
this.maxFetchBytes =
(int) conf.get(ConfigOptions.CLIENT_SCANNER_LOG_FETCH_MAX_BYTES).getBytes();
@@ -163,15 +142,49 @@ public class LogFetcher implements Closeable {
this.logFetchBuffer = new LogFetchBuffer();
this.nodesWithPendingFetchRequests = new HashSet<>();
this.metadataUpdater = metadataUpdater;
- this.logFetchCollector =
- new LogFetchCollector(tablePath, logScannerStatus, conf, metadataUpdater);
+ this.logFetchCollector = new LogFetchCollector(logScannerStatus, conf, metadataUpdater);
this.arrowLogFetchCollector =
- new ArrowLogFetchCollector(tablePath, logScannerStatus, conf, metadataUpdater);
+ new ArrowLogFetchCollector(logScannerStatus, conf, metadataUpdater);
this.scannerMetricGroup = scannerMetricGroup;
this.readPreference = conf.get(ConfigOptions.CLIENT_SCANNER_LOG_READ_PREFERENCE);
this.remoteLogDownloader =
- new RemoteLogDownloader(tablePath, conf, remoteFileDownloader, scannerMetricGroup);
+ new RemoteLogDownloader(
+ scannerName, conf, remoteFileDownloader, scannerMetricGroup);
remoteLogDownloader.start();
+ this.schemaResolution = schemaResolution;
+ }
+
+ /**
+ * Register a new table for multi-table fetching. If the table is already registered, this is a
+ * no-op.
+ *
+ * <p>The fetcher monitor protects registration against asynchronous fetch response callbacks.
+ */
+ public synchronized void registerTable(TableScanSpec spec, SchemaGetter schemaGetter) {
+ TableInfo tableInfo = spec.getTableInfo();
+ long tableId = tableInfo.getTableId();
+ if (!tableReadContexts.containsKey(tableId)) {
+ TableReadContext tableReadContext =
+ new TableReadContext(
+ tableInfo,
+ schemaResolution,
+ spec.getProjection(),
+ spec.getFilter(),
+ schemaGetter,
+ chunkedFactory);
+ tableReadContexts.put(tableId, tableReadContext);
+ }
+ }
+
+ synchronized void unregisterTable(long tableId) {
+ TableReadContext tableReadContext = tableReadContexts.remove(tableId);
+ if (tableReadContext == null) {
+ return;
+ }
+ Set<TableBucket> retainedBuckets =
+ new HashSet<>(logScannerStatus.fetchableBuckets(ignored -> true));
+ logFetchBuffer.retainAll(retainedBuckets);
+ tableReadContext.close();
}
/**
@@ -196,11 +209,12 @@ public class LogFetcher implements Closeable {
* have an in-flight fetch or pending fetch data.
*/
public void sendFetches() {
- checkAndUpdateMetadata(fetchableBuckets());
+ List<TableBucket> fetchable = fetchableBuckets();
+ checkAndUpdateMetadata(fetchable);
synchronized (this) {
// NOTE: Don't perform heavy I/O operations or synchronous waits inside this lock to
// avoid blocking the future complete of FetchLogResponse.
- Map<Integer, FetchLogRequest> fetchRequestMap = prepareFetchLogRequests();
+ Map<Integer, FetchLogRequest> fetchRequestMap = prepareFetchLogRequests(fetchable);
fetchRequestMap.forEach(
(nodeId, fetchLogRequest) -> {
LOG.debug("Adding pending request for node id {}", nodeId);
@@ -229,94 +243,118 @@ public class LogFetcher implements Closeable {
}
private void checkAndUpdateMetadata(List<TableBucket> tableBuckets) {
- // If the table is partitioned table, check if we need update partition metadata.
- List<Long> partitionIds = isPartitioned ? new ArrayList<>() : null;
- // If the table is none-partitioned table, check if we need update table metadata.
- boolean needUpdate = false;
+ // Group buckets (without a leader) by tableId to support multi-table fetching.
+ Map<Long, List<TableBucket>> bucketsByTable = new HashMap<>();
for (TableBucket tb : tableBuckets) {
if (getTableBucketLeader(tb) != null) {
continue;
}
-
- if (isPartitioned) {
- partitionIds.add(tb.getPartitionId());
- } else {
- needUpdate = true;
- break;
- }
+ bucketsByTable.computeIfAbsent(tb.getTableId(), k -> new ArrayList<>()).add(tb);
}
- try {
- if (isPartitioned && !partitionIds.isEmpty()) {
- metadataUpdater.updateMetadata(
- Collections.singleton(tablePath), null, partitionIds);
- } else if (needUpdate) {
- metadataUpdater.updateTableOrPartitionMetadata(tablePath, null);
+ for (Map.Entry<Long, List<TableBucket>> entry : bucketsByTable.entrySet()) {
+ long tableId = entry.getKey();
+ List<TableBucket> buckets = entry.getValue();
+ TableReadContext tableReadContext;
+ synchronized (this) {
+ tableReadContext = tableReadContexts.get(tableId);
}
- } catch (Exception e) {
- if (e instanceof PartitionNotExistException) {
- // ignore this exception, this is probably happen because the partition is deleted.
- // The fetcher can also work fine. The caller like flink can remove the partition
- // from fetch list when receive exception.
- LOG.warn("Receive PartitionNotExistException when update metadata, ignore it", e);
- } else {
- throw e;
+ checkNotNull(tableReadContext, "Table read context for table id " + tableId);
+ boolean isPartitioned = tableReadContext.isPartitioned;
+ TablePath tablePath = tableReadContext.tablePath;
+ try {
+ if (isPartitioned) {
+ List<Long> partitionIds = new ArrayList<>();
+ for (TableBucket tb : buckets) {
+ partitionIds.add(tb.getPartitionId());
+ }
+ if (!partitionIds.isEmpty()) {
+ metadataUpdater.updateMetadata(
+ Collections.singleton(tablePath), null, partitionIds);
+ }
+ } else {
+ metadataUpdater.updateTableOrPartitionMetadata(tablePath, null);
+ }
+ } catch (Exception e) {
+ if (e instanceof PartitionNotExistException) {
+ LOG.warn(
+ "Receive PartitionNotExistException when update metadata, ignore it",
+ e);
+ } else {
+ throw e;
+ }
}
}
}
@VisibleForTesting
- void sendFetchRequest(int destination, FetchLogRequest fetchLogRequest) {
- TableOrPartitions tableOrPartitionsInFetchRequest =
- getTableOrPartitionsInFetchRequest(fetchLogRequest);
+ synchronized void sendFetchRequest(int destination, FetchLogRequest fetchLogRequest) {
+ FetchRequestContext fetchRequestContext = createFetchRequestContext(fetchLogRequest);
// TODO cache the tablet server gateway.
TabletServerGateway gateway = metadataUpdater.newTabletServerClientForNode(destination);
if (gateway == null) {
handleFetchLogException(
destination,
- tableOrPartitionsInFetchRequest,
+ fetchRequestContext,
new LeaderNotAvailableException(
"Server " + destination + " is not found in metadata cache."));
} else {
final long requestStartTime = System.currentTimeMillis();
scannerMetricGroup.fetchRequestCount().inc();
- gateway.fetchLog(fetchLogRequest)
- .whenComplete(
- (fetchLogResponse, e) -> {
- if (e != null) {
- handleFetchLogException(
- destination, tableOrPartitionsInFetchRequest, e);
- } else {
- handleFetchLogResponse(
- destination, requestStartTime, fetchLogResponse);
- }
- });
+ try {
+ gateway.fetchLog(fetchLogRequest)
+ .whenComplete(
+ (fetchLogResponse, e) -> {
+ if (e != null) {
+ handleFetchLogException(
+ destination, fetchRequestContext, e);
+ } else {
+ handleFetchLogResponse(
+ destination,
+ requestStartTime,
+ fetchLogResponse,
+ fetchRequestContext);
+ }
+ });
+ } catch (Throwable t) {
+ handleFetchLogException(destination, fetchRequestContext, t);
+ }
}
}
- private TableOrPartitions getTableOrPartitionsInFetchRequest(FetchLogRequest fetchLogRequest) {
- Set<Long> tableIdsInFetchRequest = null;
- Set<TablePartition> tablePartitionsInFetchRequest = null;
- if (!isPartitioned) {
- tableIdsInFetchRequest =
- fetchLogRequest.getTablesReqsList().stream()
- .map(PbFetchLogReqForTable::getTableId)
- .collect(Collectors.toSet());
- } else {
- tablePartitionsInFetchRequest = new HashSet<>();
- // iterate over table requests
- for (PbFetchLogReqForTable fetchTableRequest : fetchLogRequest.getTablesReqsList()) {
+ private FetchRequestContext createFetchRequestContext(FetchLogRequest fetchLogRequest) {
+ Map<Long, TableReadContext> requestedTableContexts = new HashMap<>();
+ Set<Long> tableIdsInFetchRequest = new HashSet<>();
+ Set<TablePartition> tablePartitionsInFetchRequest = new HashSet<>();
+ for (PbFetchLogReqForTable fetchTableRequest : fetchLogRequest.getTablesReqsList()) {
+ long tableId = fetchTableRequest.getTableId();
+ TableReadContext tableReadContext = tableReadContexts.get(tableId);
+ if (tableReadContext == null) {
+ LOG.warn(
+ "Skip fetch log request for table id {} because its read context is not "
+ + "registered. The table may have already been unregistered.",
+ tableId);
+ continue;
+ }
+ requestedTableContexts.put(tableId, tableReadContext);
+ if (!tableReadContext.isPartitioned) {
+ tableIdsInFetchRequest.add(tableId);
+ } else {
for (PbFetchLogReqForBucket fetchLogReqForBucket :
fetchTableRequest.getBucketsReqsList()) {
tablePartitionsInFetchRequest.add(
- new TablePartition(
- fetchTableRequest.getTableId(),
- fetchLogReqForBucket.getPartitionId()));
+ new TablePartition(tableId, fetchLogReqForBucket.getPartitionId()));
}
}
}
- return new TableOrPartitions(tableIdsInFetchRequest, tablePartitionsInFetchRequest);
+ return new FetchRequestContext(
+ requestedTableContexts,
+ new TableOrPartitions(
+ tableIdsInFetchRequest.isEmpty() ? null : tableIdsInFetchRequest,
+ tablePartitionsInFetchRequest.isEmpty()
+ ? null
+ : tablePartitionsInFetchRequest));
}
/** A helper class to hold table ids or table partitions. */
@@ -340,8 +378,8 @@ public class LogFetcher implements Closeable {
metadataUpdater.invalidPhysicalTableBucketMeta(physicalTablePaths);
}
- private void handleFetchLogException(
- int destination, TableOrPartitions tableOrPartitionsInFetchRequest, Throwable e) {
+ private synchronized void handleFetchLogException(
+ int destination, FetchRequestContext fetchRequestContext, Throwable e) {
try {
if (isClosed) {
return;
@@ -355,7 +393,7 @@ public class LogFetcher implements Closeable {
"Invalid metadata error in fetch log request. "
+ "Going to request metadata update.",
e);
- invalidTableOrPartitions(tableOrPartitionsInFetchRequest);
+ invalidTableOrPartitions(fetchRequestContext.tableOrPartitions);
}
} finally {
LOG.debug("Removing pending request for node: {}", destination);
@@ -365,7 +403,10 @@ public class LogFetcher implements Closeable {
/** Implements the core logic for a successful fetch log response. */
private synchronized void handleFetchLogResponse(
- int destination, long requestStartTime, FetchLogResponse fetchLogResponse) {
+ int destination,
+ long requestStartTime,
+ FetchLogResponse fetchLogResponse,
+ FetchRequestContext fetchRequestContext) {
// Capture the parsed ByteBuf for buffer lifecycle management. The response may
// have been lazily parsed from the network buffer. Each DefaultCompletedFetch
// that references the buffer's records data must retain it. We release the base
@@ -382,6 +423,14 @@ public class LogFetcher implements Closeable {
for (PbFetchLogRespForTable respForTable : fetchLogResponse.getTablesRespsList()) {
long tableId = respForTable.getTableId();
+ TableReadContext trc = fetchRequestContext.requestedTableContexts.get(tableId);
+ if (trc == null || tableReadContexts.get(tableId) != trc) {
+ LOG.debug(
+ "Ignoring stale or unsolicited fetch log response for table id {}.",
+ tableId);
+ continue;
+ }
+ TablePath tablePathForResp = trc.tablePath;
for (PbFetchLogRespForBucket respForBucket : respForTable.getBucketsRespsList()) {
TableBucket tb =
new TableBucket(
@@ -391,7 +440,7 @@ public class LogFetcher implements Closeable {
: null,
respForBucket.getBucketId());
FetchLogResultForBucket fetchResultForBucket =
- getFetchLogResultForBucket(tb, tablePath, respForBucket);
+ getFetchLogResultForBucket(tb, tablePathForResp, respForBucket);
// if error code is not NONE, it means the fetch log request failed, we need to
// clear table bucket meta for InvalidMetadataException.
@@ -411,6 +460,7 @@ public class LogFetcher implements Closeable {
} else {
if (fetchResultForBucket.fetchFromRemote()) {
pendRemoteFetches(
+ trc,
fetchResultForBucket.remoteLogFetchInfo(),
fetchOffset,
fetchResultForBucket.getHighWatermark());
@@ -428,8 +478,9 @@ public class LogFetcher implements Closeable {
logFetchBuffer.add(
new DefaultCompletedFetch(
tb,
+ tablePathForResp,
fetchResultForBucket,
- readContext,
+ trc.readContext,
logScannerStatus,
// skipping CRC check if projection push downed as
// the data is pruned
@@ -477,7 +528,10 @@ public class LogFetcher implements Closeable {
}
private void pendRemoteFetches(
- RemoteLogFetchInfo remoteLogFetchInfo, long firstFetchOffset, long highWatermark) {
+ TableReadContext tableReadContext,
+ RemoteLogFetchInfo remoteLogFetchInfo,
+ long firstFetchOffset,
+ long highWatermark) {
checkNotNull(remoteLogFetchInfo);
FsPath remoteLogTabletDir = new FsPath(remoteLogFetchInfo.remoteLogTabletDir());
List<RemoteLogSegment> remoteLogSegments = remoteLogFetchInfo.remoteLogSegmentList();
@@ -495,10 +549,11 @@ public class LogFetcher implements Closeable {
new RemotePendingFetch(
segment,
downloadFuture,
+ tableReadContext.tablePath,
posInLogSegment,
fetchOffset,
highWatermark,
- remoteReadContext,
+ tableReadContext.remoteReadContext,
logScannerStatus,
isCheckCrcs);
logFetchBuffer.pend(pendingFetch);
@@ -507,14 +562,12 @@ public class LogFetcher implements Closeable {
}
@VisibleForTesting
- Map<Integer, FetchLogRequest> prepareFetchLogRequests() {
- Map<Integer, List<PbFetchLogReqForBucket>> fetchLogReqForBuckets = new HashMap<>();
+ Map<Integer, FetchLogRequest> prepareFetchLogRequests(List<TableBucket> fetchableBuckets) {
+ // Outer key: leaderId, inner key: tableId -> list of bucket requests
+ Map<Integer, Map<Long, List<PbFetchLogReqForBucket>>> fetchReqsByLeaderAndTable =
+ new HashMap<>();
int readyForFetchCount = 0;
- Long tableId = null;
- for (TableBucket tb : fetchableBuckets()) {
- if (tableId == null) {
- tableId = tb.getTableId();
- }
+ for (TableBucket tb : fetchableBuckets) {
Long offset = logScannerStatus.getBucketOffset(tb);
if (offset == null) {
LOG.debug(
@@ -546,8 +599,9 @@ public class LogFetcher implements Closeable {
if (tb.getPartitionId() != null) {
fetchLogReqForBucket.setPartitionId(tb.getPartitionId());
}
- fetchLogReqForBuckets
- .computeIfAbsent(leader, key -> new ArrayList<>())
+ fetchReqsByLeaderAndTable
+ .computeIfAbsent(leader, k -> new HashMap<>())
+ .computeIfAbsent(tb.getTableId(), k -> new ArrayList<>())
.add(fetchLogReqForBucket);
readyForFetchCount++;
}
@@ -557,34 +611,57 @@ public class LogFetcher implements Closeable {
return Collections.emptyMap();
} else {
Map<Integer, FetchLogRequest> fetchLogRequests = new HashMap<>();
- long finalTableId = tableId;
- fetchLogReqForBuckets.forEach(
- (leaderId, reqForBuckets) -> {
- FetchLogRequest fetchLogRequest =
- new FetchLogRequest()
- .setFollowerServerId(-1)
- .setMaxBytes(maxFetchBytes)
- .setMinBytes(minFetchBytes)
- .setMaxWaitMs(maxFetchWaitMs)
- .setReadPreference(readPreference.value());
- PbFetchLogReqForTable reqForTable =
- new PbFetchLogReqForTable().setTableId(finalTableId);
- if (readContext.isProjectionPushDowned()) {
- assert projection != null;
- reqForTable
- .setProjectionPushdownEnabled(true)
- .setProjectedFields(projection.getProjectionInOrder());
- } else {
- reqForTable.setProjectionPushdownEnabled(false);
- }
- if (cachedPbPredicate != null) {
- reqForTable.setFilterPredicate(cachedPbPredicate);
- reqForTable.setFilterSchemaId(filterSchemaId);
- }
- reqForTable.addAllBucketsReqs(reqForBuckets);
- fetchLogRequest.addAllTablesReqs(Collections.singletonList(reqForTable));
- fetchLogRequests.put(leaderId, fetchLogRequest);
- });
+ for (Map.Entry<Integer, Map<Long, List<PbFetchLogReqForBucket>>> leaderEntry :
+ fetchReqsByLeaderAndTable.entrySet()) {
+ int leaderId = leaderEntry.getKey();
+ Map<Long, List<PbFetchLogReqForBucket>> tableReqs = leaderEntry.getValue();
+
+ FetchLogRequest fetchLogRequest =
+ new FetchLogRequest()
+ .setFollowerServerId(-1)
+ .setMaxBytes(maxFetchBytes)
+ .setMinBytes(minFetchBytes)
+ .setMaxWaitMs(maxFetchWaitMs)
+ .setReadPreference(readPreference.value());
+
+ List<PbFetchLogReqForTable> tablesReqs = new ArrayList<>();
+ for (Map.Entry<Long, List<PbFetchLogReqForBucket>> tableEntry :
+ tableReqs.entrySet()) {
+ long tableId = tableEntry.getKey();
+ List<PbFetchLogReqForBucket> bucketReqs = tableEntry.getValue();
+ TableReadContext trc = tableReadContexts.get(tableId);
+ if (trc == null) {
+ LOG.warn(
+ "Skip fetch log request for table id {} because its read context "
+ + "is not registered. The table may have already been "
+ + "unregistered.",
+ tableId);
+ continue;
+ }
+
+ PbFetchLogReqForTable reqForTable =
+ new PbFetchLogReqForTable().setTableId(tableId);
+ if (trc.readContext.isProjectionPushDowned()) {
+ assert trc.projection != null;
+ reqForTable
+ .setProjectionPushdownEnabled(true)
+ .setProjectedFields(trc.projection.getProjectionInOrder());
+ } else {
+ reqForTable.setProjectionPushdownEnabled(false);
+ }
+ if (trc.cachedPbPredicate != null) {
+ reqForTable.setFilterPredicate(trc.cachedPbPredicate);
+ reqForTable.setFilterSchemaId(trc.filterSchemaId);
+ }
+ reqForTable.addAllBucketsReqs(bucketReqs);
+ tablesReqs.add(reqForTable);
+ }
+ if (tablesReqs.isEmpty()) {
+ continue;
+ }
+ fetchLogRequest.addAllTablesReqs(tablesReqs);
+ fetchLogRequests.put(leaderId, fetchLogRequest);
+ }
return fetchLogRequests;
}
}
@@ -615,13 +692,14 @@ public class LogFetcher implements Closeable {
@Override
public synchronized void close() throws IOException {
if (!isClosed) {
+ isClosed = true;
IOUtils.closeQuietly(logFetchBuffer, "logFetchBuffer");
IOUtils.closeQuietly(remoteLogDownloader, "remoteLogDownloader");
- readContext.close();
- remoteReadContext.close();
+ for (TableReadContext tableReadContext : tableReadContexts.values()) {
+ tableReadContext.close();
+ }
chunkedFactory.close();
- isClosed = true;
- LOG.info("Fetcher for {} is closed.", tablePath);
+ LOG.info("LogFetcher is closed.");
}
}
@@ -634,4 +712,75 @@ public class LogFetcher implements Closeable {
int getCompletedFetchesSize() {
return logFetchBuffer.bufferedBuckets().size();
}
+
+ @VisibleForTesting
+ synchronized int getRegisteredTableCount() {
+ return tableReadContexts.size();
+ }
+
+ /** Request-scoped context snapshot. */
+ private static class FetchRequestContext {
+ private final Map<Long, TableReadContext> requestedTableContexts;
+ private final TableOrPartitions tableOrPartitions;
+
+ private FetchRequestContext(
+ Map<Long, TableReadContext> requestedTableContexts,
+ TableOrPartitions tableOrPartitions) {
+ this.requestedTableContexts = requestedTableContexts;
+ this.tableOrPartitions = tableOrPartitions;
+ }
+ }
+
+ /** Per-table context holding read contexts, projection, and predicate info. */
+ @VisibleForTesting
+ static class TableReadContext {
+ final TablePath tablePath;
+ final boolean isPartitioned;
+ final LogRecordReadContext readContext;
+ final LogRecordReadContext remoteReadContext;
+ @Nullable final Projection projection;
+ @Nullable final org.apache.fluss.rpc.messages.PbPredicate cachedPbPredicate;
+ final int filterSchemaId;
+
+ TableReadContext(
+ TableInfo tableInfo,
+ LogRecordReadContext.SchemaResolution schemaResolution,
+ @Nullable Projection projection,
+ @Nullable Predicate recordBatchFilter,
+ SchemaGetter schemaGetter,
+ ChunkedAllocationManager.ChunkedFactory chunkedFactory) {
+ this.tablePath = tableInfo.getTablePath();
+ this.isPartitioned = tableInfo.isPartitioned();
+ // Share the LogFetcher-owned chunked factory so all tables reuse one chunk
+ // pool and LogFetcher.close() releases the chunks after contexts are closed.
+ this.readContext =
+ LogRecordReadContext.createReadContext(
+ tableInfo,
+ false,
+ schemaResolution,
+ projection,
+ schemaGetter,
+ chunkedFactory);
+ this.remoteReadContext =
+ LogRecordReadContext.createReadContext(
+ tableInfo,
+ true,
+ schemaResolution,
+ projection,
+ schemaGetter,
+ chunkedFactory);
+ this.projection = projection;
+ this.cachedPbPredicate =
+ recordBatchFilter != null
+ ? PredicateMessageUtils.toPbPredicate(
+ recordBatchFilter, tableInfo.getRowType())
+ : null;
+ this.filterSchemaId = tableInfo.getSchemaId();
+ }
+
+ void close() {
+ readContext.close();
+ remoteReadContext.close();
+ }
+ }
}
diff --git a/fluss-client/src/main/java/org/apache/fluss/client/table/scanner/log/LogScannerImpl.java b/fluss-client/src/main/java/org/apache/fluss/client/table/scanner/log/LogScannerImpl.java
index 9e9316069..4637f87f6 100644
--- a/fluss-client/src/main/java/org/apache/fluss/client/table/scanner/log/LogScannerImpl.java
+++ b/fluss-client/src/main/java/org/apache/fluss/client/table/scanner/log/LogScannerImpl.java
@@ -31,22 +31,16 @@ import org.apache.fluss.metadata.TableBucket;
import org.apache.fluss.metadata.TableInfo;
import org.apache.fluss.metadata.TablePath;
import org.apache.fluss.predicate.Predicate;
+import org.apache.fluss.record.LogRecordReadContext;
import org.apache.fluss.rpc.metrics.ClientMetricGroup;
import org.apache.fluss.types.RowType;
import org.apache.fluss.utils.Projection;
-import org.slf4j.Logger;
-import org.slf4j.LoggerFactory;
-
import javax.annotation.Nullable;
-import java.io.IOException;
import java.time.Duration;
import java.util.Collections;
import java.util.ConcurrentModificationException;
-import java.util.concurrent.atomic.AtomicInteger;
-import java.util.concurrent.atomic.AtomicLong;
-import java.util.function.Supplier;
/**
* The default impl of {@link LogScanner}.
@@ -58,29 +52,20 @@ import java.util.function.Supplier;
* @since 0.1
*/
@PublicEvolving
-public class LogScannerImpl implements LogScanner {
- private static final Logger LOG = LoggerFactory.getLogger(LogScannerImpl.class);
- private static final long NO_CURRENT_THREAD = -1L;
+public class LogScannerImpl extends AbstractLogScanner<ScanRecords> implements LogScanner {
private final TablePath tablePath;
- private final LogScannerStatus logScannerStatus;
private final MetadataUpdater metadataUpdater;
- private final LogFetcher logFetcher;
private final long tableId;
private final boolean isPartitionedTable;
private final boolean isArrowLogFormat;
private final boolean isLogTable;
private final boolean hasProjection;
- // metrics
- private final ScannerMetricGroup scannerMetricGroup;
-
- // currentThread holds the threadId of the current thread accessing FlussLogScanner
- // and is used to prevent multithreaded access
- private final AtomicLong currentThread = new AtomicLong(NO_CURRENT_THREAD);
- // refCount is used to allow reentrant access by the thread who has acquired currentThread.
- private final AtomicInteger refCount = new AtomicInteger(0);
- private volatile boolean closed = false;
+ private final TableInfo tableInfo;
+ private final @Nullable Projection projection;
+ private final @Nullable Predicate recordBatchFilter;
+ private final SchemaGetter schemaGetter;
public LogScannerImpl(
Configuration conf,
@@ -91,6 +76,40 @@ public class LogScannerImpl implements LogScanner {
@Nullable int[] projectedFields,
SchemaGetter schemaGetter,
@Nullable Predicate recordBatchFilter) {
+ this(
+ conf,
+ tableInfo,
+ metadataUpdater,
+ new LogScannerStatus(),
+ new ScannerMetricGroup(clientMetricGroup, tableInfo.getTablePath()),
+ remoteFileDownloader,
+ projectedFields,
+ schemaGetter,
+ recordBatchFilter);
+ }
+
+ private LogScannerImpl(
+ Configuration conf,
+ TableInfo tableInfo,
+ MetadataUpdater metadataUpdater,
+ LogScannerStatus logScannerStatus,
+ ScannerMetricGroup scannerMetricGroup,
+ RemoteFileDownloader remoteFileDownloader,
+ @Nullable int[] projectedFields,
+ SchemaGetter schemaGetter,
+ @Nullable Predicate recordBatchFilter) {
+ super(
+ tableInfo.getTablePath().toString(),
+ logScannerStatus,
+ new LogFetcher(
+ tableInfo.getTablePath().toString(),
+ logScannerStatus,
+ conf,
+ metadataUpdater,
+ scannerMetricGroup,
+ remoteFileDownloader,
+ LogRecordReadContext.SchemaResolution.TARGET),
+ scannerMetricGroup);
this.tablePath = tableInfo.getTablePath();
this.tableId = tableInfo.getTableId();
this.isPartitionedTable = tableInfo.isPartitioned();
@@ -99,21 +118,11 @@ public class LogScannerImpl implements LogScanner {
this.hasProjection = projectedFields != null;
// add this table to metadata updater.
metadataUpdater.checkAndUpdateTableMetadata(Collections.singleton(tablePath));
- this.logScannerStatus = new LogScannerStatus();
this.metadataUpdater = metadataUpdater;
- Projection projection = sanityProjection(projectedFields, tableInfo);
- this.scannerMetricGroup = new ScannerMetricGroup(clientMetricGroup, tablePath);
- this.logFetcher =
- new LogFetcher(
- tableInfo,
- projection,
- recordBatchFilter,
- logScannerStatus,
- conf,
- metadataUpdater,
- scannerMetricGroup,
- remoteFileDownloader,
- schemaGetter);
+ this.projection = sanityProjection(projectedFields, tableInfo);
+ this.tableInfo = tableInfo;
+ this.recordBatchFilter = recordBatchFilter;
+ this.schemaGetter = schemaGetter;
}
/**
@@ -121,7 +130,8 @@ public class LogScannerImpl implements LogScanner {
* not null.
*/
@Nullable
- private Projection sanityProjection(@Nullable int[] projectedFields, TableInfo tableInfo) {
+ private static Projection sanityProjection(
+ @Nullable int[] projectedFields, TableInfo tableInfo) {
RowType tableRowType = tableInfo.getRowType();
if (projectedFields != null) {
for (int projectedField : projectedFields) {
@@ -140,12 +150,18 @@ public class LogScannerImpl implements LogScanner {
}
@Override
- public ScanRecords poll(Duration timeout) {
- return doPoll(
- timeout,
- this::pollForFetches,
- scanRecords -> scanRecords.buckets().isEmpty(),
- () -> ScanRecords.EMPTY);
+ protected ScanRecords emptyResult() {
+ return ScanRecords.EMPTY;
+ }
+
+ @Override
+ protected ScanRecords toResult(ScanRecords scanRecords) {
+ return scanRecords;
+ }
+
+ @Override
+ protected String notSubscribedMessage() {
+ return "LogScanner is not subscribed any buckets.";
}
/**
@@ -168,11 +184,47 @@ public class LogScannerImpl implements LogScanner {
"Arrow record batch polling does not support projection. Please create the scanner without projection.");
}
- return doPoll(
- timeout,
- this::pollForRecordBatches,
- ArrowScanRecords::isEmpty,
- () -> ArrowScanRecords.EMPTY);
+ acquireAndEnsureOpen();
+ try {
+ if (!logScannerStatus.prepareToPoll()) {
+ throw new IllegalStateException("LogScanner is not subscribed any buckets.");
+ }
+
+ scannerMetricGroup.recordPollStart(System.currentTimeMillis());
+ long timeoutNanos = timeout.toNanos();
+ long startNanos = System.nanoTime();
+ do {
+ ArrowScanRecords scanRecords = pollForRecordBatches();
+ if (scanRecords.isEmpty()) {
+ try {
+ if (!logFetcher.awaitNotEmpty(startNanos + timeoutNanos)) {
+ return scanRecords;
+ }
+ } catch (WakeupException e) {
+ return scanRecords;
+ }
+ } else {
+ logFetcher.sendFetches();
+ return scanRecords;
+ }
+ } while (System.nanoTime() - startNanos < timeoutNanos);
+
+ return ArrowScanRecords.EMPTY;
+ } finally {
+ release();
+ scannerMetricGroup.recordPollEnd(System.currentTimeMillis());
+ }
+ }
+
+ private ArrowScanRecords pollForRecordBatches() {
+ ArrowScanRecords scanRecords = logFetcher.collectArrowFetch();
+ if (!scanRecords.isEmpty()) {
+ return scanRecords;
+ }
+
+ // send any new fetches (won't resend pending fetches).
+ logFetcher.sendFetches();
+ return logFetcher.collectArrowFetch();
}
@Override
@@ -186,8 +238,10 @@ public class LogScannerImpl implements LogScanner {
acquireAndEnsureOpen();
try {
TableBucket tableBucket = new TableBucket(tableId, bucket);
- metadataUpdater.checkAndUpdateTableMetadata(Collections.singleton(tablePath));
- logScannerStatus.assignScanBuckets(Collections.singletonMap(tableBucket, offset));
+ this.logFetcher.registerTable(
+ new TableScanSpec(tableInfo, projection, recordBatchFilter), schemaGetter);
+ this.metadataUpdater.checkAndUpdateTableMetadata(Collections.singleton(tablePath));
+ this.logScannerStatus.assignScanBuckets(Collections.singletonMap(tableBucket, offset));
} finally {
release();
}
@@ -204,6 +258,8 @@ public class LogScannerImpl implements LogScanner {
acquireAndEnsureOpen();
try {
TableBucket tableBucket = new TableBucket(tableId, partitionId, bucket);
+ this.logFetcher.registerTable(
+ new TableScanSpec(tableInfo, projection, recordBatchFilter), schemaGetter);
// we make assumption that the partition id must belong to the current table
// if we can't find the partition id from the table path, we'll consider the table
// is not exist
@@ -246,140 +302,4 @@ public class LogScannerImpl implements LogScanner {
release();
}
}
-
- @Override
- public void wakeup() {
- logFetcher.wakeup();
- }
-
- private ScanRecords pollForFetches() {
- ScanRecords scanRecords = logFetcher.collectFetch();
- // Check buckets() (includes progress-only buckets).
- if (!scanRecords.buckets().isEmpty()) {
- return scanRecords;
- }
-
- // send any new fetches (won't resend pending fetches).
- logFetcher.sendFetches();
- return logFetcher.collectFetch();
- }
-
- private ArrowScanRecords pollForRecordBatches() {
- ArrowScanRecords scanRecords = logFetcher.collectArrowFetch();
- if (!scanRecords.isEmpty()) {
- return scanRecords;
- }
-
- // send any new fetches (won't resend pending fetches).
- logFetcher.sendFetches();
- return logFetcher.collectArrowFetch();
- }
-
- /** Shared polling loop for row and Arrow scan results. */
- private <T> T doPoll(
- Duration timeout,
- Supplier<T> pollForFetches,
- java.util.function.Predicate<T> isEmpty,
- Supplier<T> emptyResult) {
- acquireAndEnsureOpen();
- try {
- if (!logScannerStatus.prepareToPoll()) {
- throw new IllegalStateException("LogScanner is not subscribed any buckets.");
- }
-
- scannerMetricGroup.recordPollStart(System.currentTimeMillis());
- long timeoutNanos = timeout.toNanos();
- long startNanos = System.nanoTime();
- do {
- T scanRecords = pollForFetches.get();
- if (isEmpty.test(scanRecords)) {
- try {
- if (!logFetcher.awaitNotEmpty(startNanos + timeoutNanos)) {
- // logFetcher waits for the timeout and no data in buffer,
- // so we return empty
- return scanRecords;
- }
- } catch (WakeupException e) {
- // wakeup() is called, we need to return empty
- return scanRecords;
- }
- } else {
- // before returning the fetched records, we can send off the next round of
- // fetches and avoid block waiting for their responses to enable pipelining
- // while the user is handling the fetched records.
- logFetcher.sendFetches();
- return scanRecords;
- }
- } while (System.nanoTime() - startNanos < timeoutNanos);
-
- return emptyResult.get();
- } finally {
- release();
- scannerMetricGroup.recordPollEnd(System.currentTimeMillis());
- }
- }
-
- /**
- * Acquire the light lock and ensure that the scanner hasn't been closed.
- *
- * @throws IllegalStateException If the scanner has been closed
- */
- private void acquireAndEnsureOpen() {
- acquire();
- if (closed) {
- release();
- throw new IllegalStateException("This scanner has already been closed.");
- }
- }
-
- /**
- * Acquire the light lock protecting this scanner from multithreaded access. Instead of blocking
- * when the lock is not available, however, we just throw an exception (since multithreaded
- * usage is not supported).
- *
- * @throws ConcurrentModificationException if another thread already has the lock
- */
- private void acquire() {
- Thread thread = Thread.currentThread();
- long threadId = thread.getId();
- if (threadId != currentThread.get()
- && !currentThread.compareAndSet(NO_CURRENT_THREAD, threadId)) {
- throw new ConcurrentModificationException(
- "Scanner is not safe for multithreaded access. "
- + "currentThread(name: "
- + thread.getName()
- + ", id: "
- + threadId
- + ")"
- + " otherThread(id: "
- + currentThread.get()
- + ")");
- }
- refCount.incrementAndGet();
- }
-
- /** Release the light lock protecting the scanner from multithreaded access. */
- private void release() {
- if (refCount.decrementAndGet() == 0) {
- currentThread.set(NO_CURRENT_THREAD);
- }
- }
-
- @Override
- public void close() {
- acquire();
- try {
- if (!closed) {
- LOG.trace("Closing log scanner for table: {}", tablePath);
- scannerMetricGroup.close();
- logFetcher.close();
- }
- LOG.debug("Log scanner for table: {} has been closed", tablePath);
- } catch (IOException e) {
- throw new RuntimeException("Failed to close log scanner for table " + tablePath, e);
- } finally {
- closed = true;
- release();
- }
- }
}
diff --git a/fluss-client/src/main/java/org/apache/fluss/client/table/scanner/log/MultiTableLogScanner.java b/fluss-client/src/main/java/org/apache/fluss/client/table/scanner/log/MultiTableLogScanner.java
new file mode 100644
index 000000000..9055fd750
--- /dev/null
+++ b/fluss-client/src/main/java/org/apache/fluss/client/table/scanner/log/MultiTableLogScanner.java
@@ -0,0 +1,99 @@
+/*
+ * 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.fluss.client.table.scanner.log;
+
+import org.apache.fluss.annotation.PublicEvolving;
+import org.apache.fluss.metadata.TablePath;
+
+import java.time.Duration;
+
+/**
+ * Reads log data from multiple tables through a single client. All subscribed buckets (across
+ * different tables) are polled fairly and returned in one {@link MultiTableRecords} result.
+ *
+ * <p>Unlike {@link LogScanner} which is bound to a single table, {@code MultiTableLogScanner}
+ * allows subscribing to buckets across different tables. Per-table projection and filter pushdown
+ * are configured on the {@link org.apache.fluss.client.table.scanner.MultiTableScan} builder, not
+ * on {@code subscribe}.
+ *
+ * <p>{@code MultiTableLogScanner} is NOT thread-safe (consistent with {@link LogScanner}). It is
+ * the responsibility of the user to ensure that multithreaded access is properly synchronized.
+ *
+ * @since 0.7
+ */
+@PublicEvolving
+public interface MultiTableLogScanner extends AutoCloseable {
+
+ /**
+ * The earliest offset to fetch from. Fluss uses "-2" to indicate fetching from log start
+ * offset.
+ */
+ long EARLIEST_OFFSET = LogScanner.EARLIEST_OFFSET;
+
+ /**
+ * The latest offset to fetch to. Fluss uses this to indicate the default stopping offset for
+ * unbounded Fluss sources.
+ */
+ long NO_STOPPING_OFFSET = LogScanner.NO_STOPPING_OFFSET;
+
+ /**
+ * Poll log data from all subscribed table buckets.
+ *
+ * <p>On each poll, the scanner will try to use the last scanned offset as the starting offset
+ * and fetch sequentially for each subscribed bucket. The result may contain records from
+ * multiple tables.
+ *
+ * @param timeout the timeout to poll.
+ * @return the result of poll, organized by {@link TablePath} and {@link
+ * org.apache.fluss.metadata.TableBucket}.
+ * @throws IllegalStateException if the scanner is not subscribed to any buckets.
+ */
+ MultiTableRecords poll(Duration timeout);
+
+ // ---- non-partitioned tables ----
+
+ /** Subscribe to the given non-partitioned table bucket starting at {@code offset}. */
+ void subscribe(TablePath tablePath, int bucket, long offset);
+
+ /** Subscribe to the given non-partitioned table bucket starting from the earliest offset. */
+ default void subscribeFromBeginning(TablePath tablePath, int bucket) {
+ subscribe(tablePath, bucket, EARLIEST_OFFSET);
+ }
+
+ /** Unsubscribe from the given non-partitioned table bucket. */
+ void unsubscribe(TablePath tablePath, int bucket);
+
+ // ---- partitioned tables ----
+
+ /** Subscribe to the given partitioned table bucket starting at {@code offset}. */
+ void subscribe(TablePath tablePath, long partitionId, int bucket, long offset);
+
+ /** Subscribe to the given partitioned table bucket starting from the earliest offset. */
+ default void subscribeFromBeginning(TablePath tablePath, long partitionId, int bucket) {
+ subscribe(tablePath, partitionId, bucket, EARLIEST_OFFSET);
+ }
+
+ /** Unsubscribe from the given partitioned table bucket. */
+ void unsubscribe(TablePath tablePath, long partitionId, int bucket);
+
+ /**
+ * Wake up the log scanner in case the fetcher thread in the log scanner is blocking in {@link
+ * #poll(Duration)}.
+ */
+ void wakeup();
+}
diff --git a/fluss-client/src/main/java/org/apache/fluss/client/table/scanner/log/MultiTableLogScannerImpl.java b/fluss-client/src/main/java/org/apache/fluss/client/table/scanner/log/MultiTableLogScannerImpl.java
new file mode 100644
index 000000000..170be2947
--- /dev/null
+++ b/fluss-client/src/main/java/org/apache/fluss/client/table/scanner/log/MultiTableLogScannerImpl.java
@@ -0,0 +1,387 @@
+/*
+ * 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.fluss.client.table.scanner.log;
+
+import org.apache.fluss.annotation.Internal;
+import org.apache.fluss.annotation.VisibleForTesting;
+import org.apache.fluss.client.admin.Admin;
+import org.apache.fluss.client.metadata.ClientSchemaGetter;
+import org.apache.fluss.client.metadata.MetadataUpdater;
+import org.apache.fluss.client.metrics.ScannerMetricGroup;
+import org.apache.fluss.client.table.scanner.MultiTableRecord;
+import org.apache.fluss.client.table.scanner.RemoteFileDownloader;
+import org.apache.fluss.client.table.scanner.ScanRecord;
+import org.apache.fluss.config.Configuration;
+import org.apache.fluss.metadata.Schema;
+import org.apache.fluss.metadata.SchemaGetter;
+import org.apache.fluss.metadata.TableBucket;
+import org.apache.fluss.metadata.TableInfo;
+import org.apache.fluss.metadata.TablePath;
+import org.apache.fluss.record.LogRecordReadContext;
+import org.apache.fluss.rpc.metrics.ClientMetricGroup;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.util.Collections;
+import java.util.ConcurrentModificationException;
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Optional;
+import java.util.Set;
+import java.util.concurrent.ExecutionException;
+
+/**
+ * Default implementation of {@link MultiTableLogScanner}.
+ *
+ * <p>This scanner subscribes to log data from multiple tables simultaneously. Tables are registered
+ * dynamically on the first {@code subscribe(...)} call to a given {@link TablePath}: the scanner
+ * resolves the {@link TableInfo} via the admin client and registers the table on the underlying
+ * {@link LogFetcher} with no projection / filter (phase 1 surface).
+ *
+ * <p>Internally, it delegates to a single shared {@link LogFetcher} that manages per-table read
+ * contexts.
+ *
+ * <p>{@code MultiTableLogScannerImpl} is NOT thread-safe. It is the responsibility of the user to
+ * ensure that multithreaded access is properly synchronized. Un-synchronized access will result in
+ * {@link ConcurrentModificationException}.
+ *
+ * @since 0.7
+ */
+@Internal
+public class MultiTableLogScannerImpl extends AbstractLogScanner<MultiTableRecords>
+ implements MultiTableLogScanner {
+
+ private static final Logger LOG = LoggerFactory.getLogger(MultiTableLogScannerImpl.class);
+
+ private final MetadataUpdater metadataUpdater;
+ private final Admin admin;
+
+ /** Per-table {@link TableInfo}, populated lazily on first subscribe to the table. */
+ private final Map<TablePath, TableInfo> registeredByPath = new HashMap<>();
+
+ /** tableId -> tablePath, for poll-time record enrichment. */
+ private final Map<Long, TablePath> pathByTableId = new HashMap<>();
+
+ private final Map<Long, SchemaGetter> schemaGetters = new HashMap<>();
+
+ /**
+ * Currently subscribed buckets per table. When the last bucket of a table is unsubscribed, the
+ * table registration is evicted so that a subsequent {@code subscribe(...)} re-resolves a fresh
+ * {@link TableInfo}. This is what lets callers recover from a drop+recreate (tableId change) by
+ * unsubscribing all buckets of the stale table and subscribing again.
+ */
+ private final Map<TablePath, Set<TableBucket>> subscribedBucketsByPath = new HashMap<>();
+
+ public MultiTableLogScannerImpl(
+ String scannerName,
+ MetadataUpdater metadataUpdater,
+ Configuration conf,
+ ClientMetricGroup clientMetricGroup,
+ RemoteFileDownloader remoteFileDownloader,
+ Admin admin) {
+ this(
+ scannerName,
+ metadataUpdater,
+ conf,
+ remoteFileDownloader,
+ admin,
+ new LogScannerStatus(),
+ // Use a synthetic TablePath for scanner-level metrics scoping.
+ new ScannerMetricGroup(
+ clientMetricGroup, TablePath.of("multi-table", scannerName)));
+ }
+
+ private MultiTableLogScannerImpl(
+ String scannerName,
+ MetadataUpdater metadataUpdater,
+ Configuration conf,
+ RemoteFileDownloader remoteFileDownloader,
+ Admin admin,
+ LogScannerStatus logScannerStatus,
+ ScannerMetricGroup scannerMetricGroup) {
+ this(
+ scannerName,
+ metadataUpdater,
+ admin,
+ logScannerStatus,
+ new LogFetcher(
+ scannerName,
+ logScannerStatus,
+ conf,
+ metadataUpdater,
+ scannerMetricGroup,
+ remoteFileDownloader,
+ LogRecordReadContext.SchemaResolution.DYNAMIC),
+ scannerMetricGroup);
+ }
+
+ @VisibleForTesting
+ MultiTableLogScannerImpl(
+ String scannerName,
+ MetadataUpdater metadataUpdater,
+ Admin admin,
+ LogScannerStatus logScannerStatus,
+ LogFetcher logFetcher,
+ ScannerMetricGroup scannerMetricGroup) {
+ super(scannerName, logScannerStatus, logFetcher, scannerMetricGroup);
+ this.metadataUpdater = metadataUpdater;
+ this.admin = admin;
+ }
+
+ @Override
+ protected MultiTableRecords emptyResult() {
+ return MultiTableRecords.EMPTY;
+ }
+
+ @Override
+ protected MultiTableRecords toResult(ScanRecords scanRecords) {
+ return enrich(scanRecords);
+ }
+
+ @Override
+ protected String notSubscribedMessage() {
+ return "MultiTableLogScanner is not subscribed to any buckets.";
+ }
+
+ @Override
+ public void subscribe(TablePath tablePath, int bucket, long offset) {
+ acquireAndEnsureOpen();
+ try {
+ TableInfo tableInfo = getTableInfo(tablePath);
+ if (tableInfo.isPartitioned()) {
+ throw new IllegalStateException(
+ "Table "
+ + tablePath
+ + " is a partitioned table, please use "
+ + "\"subscribe(TablePath, long partitionId, int bucket, long offset)\" "
+ + "to subscribe a partitioned bucket instead.");
+ }
+ // Validate metadata before mutating any scanner/fetcher state, so a failure
+ // here doesn't leave a stale registration without tracked buckets.
+ metadataUpdater.checkAndUpdateTableMetadata(Collections.singleton(tablePath));
+ registerIfAbsent(tablePath, tableInfo);
+ TableBucket tableBucket = new TableBucket(tableInfo.getTableId(), bucket);
+ logScannerStatus.assignScanBuckets(Collections.singletonMap(tableBucket, offset));
+ trackSubscription(tablePath, tableBucket);
+ } finally {
+ release();
+ }
+ }
+
+ @Override
+ public void unsubscribe(TablePath tablePath, int bucket) {
+ acquireAndEnsureOpen();
+ try {
+ TableInfo tableInfo = requireRegistered(tablePath);
+ if (tableInfo.isPartitioned()) {
+ throw new IllegalStateException(
+ "Table "
+ + tablePath
+ + " is a partitioned table, please use "
+ + "\"unsubscribe(TablePath, long partitionId, int bucket)\" "
+ + "to unsubscribe a partitioned bucket instead.");
+ }
+ TableBucket tableBucket = new TableBucket(tableInfo.getTableId(), bucket);
+ logScannerStatus.unassignScanBuckets(Collections.singletonList(tableBucket));
+ untrackSubscription(tablePath, tableBucket);
+ } finally {
+ release();
+ }
+ }
+
+ @Override
+ public void subscribe(TablePath tablePath, long partitionId, int bucket, long offset) {
+ acquireAndEnsureOpen();
+ try {
+ TableInfo tableInfo = getTableInfo(tablePath);
+ if (!tableInfo.isPartitioned()) {
+ throw new IllegalStateException(
+ "Table "
+ + tablePath
+ + " is not a partitioned table, please use "
+ + "\"subscribe(TablePath, int bucket, long offset)\" "
+ + "to subscribe a non-partitioned bucket instead.");
+ }
+ // Validate table and partition metadata (e.g. a nonexistent partition) before
+ // mutating any scanner/fetcher state, so a failure here doesn't leave a stale
+ // registration without tracked buckets.
+ metadataUpdater.checkAndUpdateTableMetadata(Collections.singleton(tablePath));
+ metadataUpdater.checkAndUpdatePartitionMetadata(
+ tablePath, Collections.singleton(partitionId));
+ registerIfAbsent(tablePath, tableInfo);
+ TableBucket tableBucket = new TableBucket(tableInfo.getTableId(), partitionId, bucket);
+ logScannerStatus.assignScanBuckets(Collections.singletonMap(tableBucket, offset));
+ trackSubscription(tablePath, tableBucket);
+ } finally {
+ release();
+ }
+ }
+
+ @Override
+ public void unsubscribe(TablePath tablePath, long partitionId, int bucket) {
+ acquireAndEnsureOpen();
+ try {
+ TableInfo tableInfo = requireRegistered(tablePath);
+ if (!tableInfo.isPartitioned()) {
+ throw new IllegalStateException(
+ "Table " + tablePath + " is not a partitioned table.");
+ }
+ TableBucket tableBucket = new TableBucket(tableInfo.getTableId(), partitionId, bucket);
+ logScannerStatus.unassignScanBuckets(Collections.singletonList(tableBucket));
+ untrackSubscription(tablePath, tableBucket);
+ } finally {
+ release();
+ }
+ }
+
+ /**
+ * Register the table on the underlying {@link LogFetcher} if not yet registered. Must be called
+ * under the acquire-lock. Phase 1 always registers with no projection / filter.
+ */
+ private void registerIfAbsent(TablePath tablePath, TableInfo tableInfo) {
+ TableInfo cached = registeredByPath.get(tablePath);
+ if (cached != null) {
+ checkTableIdNotChanged(tablePath, cached);
+ return;
+ }
+
+ TableScanSpec spec = new TableScanSpec(tableInfo, null, null);
+ SchemaGetter schemaGetter =
+ new ClientSchemaGetter(tablePath, tableInfo.getSchemaInfo(), admin);
+ logFetcher.registerTable(spec, schemaGetter);
+ registeredByPath.put(tablePath, tableInfo);
+ pathByTableId.put(tableInfo.getTableId(), tablePath);
+ schemaGetters.put(tableInfo.getTableId(), schemaGetter);
+ }
+
+ private TableInfo getTableInfo(TablePath tablePath) {
+ TableInfo cached = registeredByPath.get(tablePath);
+ if (cached != null) {
+ checkTableIdNotChanged(tablePath, cached);
+ return cached;
+ }
+ return resolveTableInfo(tablePath);
+ }
+
+ /**
+ * Record a freshly assigned bucket against its table. Must be called under the acquire-lock.
+ */
+ private void trackSubscription(TablePath tablePath, TableBucket tableBucket) {
+ subscribedBucketsByPath.computeIfAbsent(tablePath, k -> new HashSet<>()).add(tableBucket);
+ }
+
+ /**
+ * Drop a bucket from its table's subscription set. When the table has no remaining subscribed
+ * buckets, evict its cached registration so a later {@code subscribe(...)} re-resolves the
+ * table (picking up a new tableId after a drop+recreate). Must be called under the
+ * acquire-lock.
+ */
+ private void untrackSubscription(TablePath tablePath, TableBucket tableBucket) {
+ Set<TableBucket> buckets = subscribedBucketsByPath.get(tablePath);
+ if (buckets == null) {
+ return;
+ }
+ buckets.remove(tableBucket);
+ if (buckets.isEmpty()) {
+ subscribedBucketsByPath.remove(tablePath);
+ TableInfo evicted = registeredByPath.remove(tablePath);
+ if (evicted != null) {
+ pathByTableId.remove(evicted.getTableId());
+ schemaGetters.remove(evicted.getTableId());
+ logFetcher.unregisterTable(evicted.getTableId());
+ }
+ }
+ }
+
+ private void checkTableIdNotChanged(TablePath tablePath, TableInfo cached) {
+ Optional<Long> currentTableId = metadataUpdater.getCluster().getTableId(tablePath);
+ if (currentTableId.isPresent() && currentTableId.get() != cached.getTableId()) {
+ throw new IllegalStateException(
+ "Table "
+ + tablePath
+ + " has been recreated (tableId changed from "
+ + cached.getTableId()
+ + " to "
+ + currentTableId.get()
+ + "). Please unsubscribe the stale table and subscribe again.");
+ }
+ }
+
+ private TableInfo requireRegistered(TablePath tablePath) {
+ TableInfo tableInfo = registeredByPath.get(tablePath);
+ if (tableInfo == null) {
+ throw new IllegalArgumentException(
+ "Table "
+ + tablePath
+ + " has not been subscribed on this MultiTableLogScanner.");
+ }
+ return tableInfo;
+ }
+
+ private TableInfo resolveTableInfo(TablePath tablePath) {
+ try {
+ return admin.getTableInfo(tablePath).get();
+ } catch (InterruptedException e) {
+ Thread.currentThread().interrupt();
+ throw new RuntimeException("Interrupted while resolving table info: " + tablePath, e);
+ } catch (ExecutionException e) {
+ Throwable cause = e.getCause() != null ? e.getCause() : e;
+ if (cause instanceof RuntimeException) {
+ throw (RuntimeException) cause;
+ }
+ throw new RuntimeException("Failed to resolve table info: " + tablePath, cause);
+ }
+ }
+
+ private MultiTableRecords enrich(ScanRecords scanRecords) {
+ MultiTableRecords.Builder builder = new MultiTableRecords.Builder();
+ for (TableBucket bucket : scanRecords.buckets()) {
+ long tableId = bucket.getTableId();
+ TablePath tablePath = pathByTableId.get(tableId);
+ SchemaGetter schemaGetter = schemaGetters.get(tableId);
+ if (tablePath == null || schemaGetter == null) {
+ // Should not happen: only registered tables can produce records.
+ throw new IllegalStateException(
+ String.format(
+ "Dropping records for unknown tableId %s in bucket %s",
+ tableId, bucket));
+ }
+ builder.addConsumedUpToOffset(
+ tablePath, bucket, scanRecords.consumedUpToOffset(bucket));
+ List<ScanRecord> records = scanRecords.records(bucket);
+ for (ScanRecord scanRecord : records) {
+ // The SchemaGetter is consumed by LogFetcher while decoding records: it resolves
+ // the schema for each record's schemaId. ClientSchemaGetter is seeded with the
+ // latest
+ // SchemaInfo and caches every schema it ever resolves (by schemaId), so during the
+ // records loading path lookups are served from the in-memory cache; The per-record
+ // overhead is therefore negligible.
+ int schemaId = scanRecord.getSchemaId();
+ Schema schema = schemaGetter.getSchema(schemaId);
+ builder.add(
+ tablePath,
+ bucket,
+ new MultiTableRecord(tablePath, tableId, schemaId, schema, scanRecord));
+ }
+ }
+ return builder.build();
+ }
+}
diff --git a/fluss-client/src/main/java/org/apache/fluss/client/table/scanner/log/MultiTableRecords.java b/fluss-client/src/main/java/org/apache/fluss/client/table/scanner/log/MultiTableRecords.java
new file mode 100644
index 000000000..c99221232
--- /dev/null
+++ b/fluss-client/src/main/java/org/apache/fluss/client/table/scanner/log/MultiTableRecords.java
@@ -0,0 +1,223 @@
+/*
+ * 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.fluss.client.table.scanner.log;
+
+import org.apache.fluss.annotation.PublicEvolving;
+import org.apache.fluss.client.table.scanner.MultiTableRecord;
+import org.apache.fluss.metadata.TableBucket;
+import org.apache.fluss.metadata.TablePath;
+import org.apache.fluss.utils.AbstractIterator;
+
+import javax.annotation.Nullable;
+
+import java.time.Duration;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.Iterator;
+import java.util.LinkedHashMap;
+import java.util.LinkedHashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+
+/**
+ * Container for {@link MultiTableRecord}s produced by {@link MultiTableLogScanner#poll(Duration)}.
+ * Records are organized by {@link TablePath} and {@link TableBucket}.
+ *
+ * @since 0.7
+ */
+@PublicEvolving
+public class MultiTableRecords implements Iterable<MultiTableRecord> {
+
+ public static final MultiTableRecords EMPTY =
+ new MultiTableRecords(Collections.emptyMap(), Collections.emptyMap());
+
+ private final Map<TablePath, Map<TableBucket, List<MultiTableRecord>>> records;
+
+ private final Map<TableBucket, Long> consumedUpToOffsets;
+
+ MultiTableRecords(
+ Map<TablePath, Map<TableBucket, List<MultiTableRecord>>> records,
+ Map<TableBucket, Long> consumedUpToOffsets) {
+ this.records = records;
+ this.consumedUpToOffsets = consumedUpToOffsets;
+ }
+
+ /** All tables with records or scanner progress in this result set. */
+ public Set<TablePath> tablePaths() {
+ return Collections.unmodifiableSet(records.keySet());
+ }
+
+ /** All buckets with records or scanner progress for a given table. */
+ public Set<TableBucket> buckets(TablePath tablePath) {
+ Map<TableBucket, List<MultiTableRecord>> byBucket = records.get(tablePath);
+ if (byBucket == null) {
+ return Collections.emptySet();
+ }
+ return Collections.unmodifiableSet(byBucket.keySet());
+ }
+
+ /** Records for all buckets of a given table. */
+ public List<MultiTableRecord> records(TablePath tablePath) {
+ Map<TableBucket, List<MultiTableRecord>> byBucket = records.get(tablePath);
+ if (byBucket == null || byBucket.isEmpty()) {
+ return Collections.emptyList();
+ }
+ // Preserve insertion order across buckets.
+ java.util.ArrayList<MultiTableRecord> all = new java.util.ArrayList<>();
+ for (List<MultiTableRecord> list : byBucket.values()) {
+ all.addAll(list);
+ }
+ return Collections.unmodifiableList(all);
+ }
+
+ /** Records for a specific bucket of a given table. */
+ public List<MultiTableRecord> records(TablePath tablePath, TableBucket bucket) {
+ Map<TableBucket, List<MultiTableRecord>> byBucket = records.get(tablePath);
+ if (byBucket == null) {
+ return Collections.emptyList();
+ }
+ List<MultiTableRecord> list = byBucket.get(bucket);
+ if (list == null) {
+ return Collections.emptyList();
+ }
+ return Collections.unmodifiableList(list);
+ }
+
+ /** Total number of records across all tables. */
+ public int count() {
+ int count = 0;
+ for (Map<TableBucket, List<MultiTableRecord>> byBucket : records.values()) {
+ for (List<MultiTableRecord> list : byBucket.values()) {
+ count += list.size();
+ }
+ }
+ return count;
+ }
+
+ /** Returns {@code true} if this {@code MultiTableRecords} contains no materialized records. */
+ public boolean isEmpty() {
+ return count() == 0;
+ }
+
+ /**
+ * Returns {@code true} if this {@code MultiTableRecords} carries any scanner progress.
+ *
+ * <p>A result may have progress even when {@link #isEmpty()} is {@code true}, for example when
+ * a bucket returns no materialized records but advances its consumed offset.
+ */
+ public boolean hasProgress() {
+ return count() > 0 || !consumedUpToOffsets.isEmpty();
+ }
+
+ /**
+ * Get the exclusive upper bound of offsets consumed for the given bucket of a given table in
+ * this poll round.
+ *
+ * @param tablePath the table the bucket belongs to
+ * @param bucket the bucket to query
+ * @return the exclusive upper bound offset, or {@code null} if the bucket of the table was not
+ * polled in this round
+ */
+ @Nullable
+ public Long consumedUpToOffset(TablePath tablePath, TableBucket bucket) {
+ Map<TableBucket, List<MultiTableRecord>> byBucket = records.get(tablePath);
+ if (byBucket == null || !byBucket.containsKey(bucket)) {
+ return null;
+ }
+ return consumedUpToOffsets.get(bucket);
+ }
+
+ /** Iterate over every record across all tables. */
+ @Override
+ public Iterator<MultiTableRecord> iterator() {
+ // Flatten the nested structure for iteration while keeping insertion order stable.
+ Set<List<MultiTableRecord>> flat = new LinkedHashSet<>();
+ for (Map<TableBucket, List<MultiTableRecord>> byBucket : records.values()) {
+ flat.addAll(byBucket.values());
+ }
+ return new ConcatenatedIterable(flat).iterator();
+ }
+
+ /**
+ * A mutable builder for {@link MultiTableRecords}; not part of the public API surface, used
+ * internally by the scanner.
+ */
+ public static final class Builder {
+ private final Map<TablePath, Map<TableBucket, List<MultiTableRecord>>> records =
+ new LinkedHashMap<>();
+ private final Map<TableBucket, Long> consumedUpToOffsets = new LinkedHashMap<>();
+
+ public Builder add(TablePath tablePath, TableBucket bucket, MultiTableRecord record) {
+ records.computeIfAbsent(tablePath, k -> new LinkedHashMap<>())
+ .computeIfAbsent(bucket, k -> new ArrayList<>())
+ .add(record);
+ return this;
+ }
+
+ /**
+ * Record the consumed offset for a bucket. Also creates an (empty) table/bucket entry in
+ * the result so that {@link MultiTableRecords#tablePaths()} and {@link
+ * MultiTableRecords#buckets(TablePath)} surface progress-only buckets, as documented.
+ */
+ public Builder addConsumedUpToOffset(
+ TablePath tablePath, TableBucket bucket, @Nullable Long consumedUpToOffset) {
+ if (consumedUpToOffset != null) {
+ consumedUpToOffsets.put(bucket, consumedUpToOffset);
+ records.computeIfAbsent(tablePath, k -> new LinkedHashMap<>())
+ .computeIfAbsent(bucket, k -> new ArrayList<>());
+ }
+ return this;
+ }
+
+ public MultiTableRecords build() {
+ if (records.isEmpty() && consumedUpToOffsets.isEmpty()) {
+ return EMPTY;
+ }
+ return new MultiTableRecords(records, consumedUpToOffsets);
+ }
+ }
+
+ private static class ConcatenatedIterable implements Iterable<MultiTableRecord> {
+
+ private final Iterable<? extends Iterable<MultiTableRecord>> iterables;
+
+ ConcatenatedIterable(Iterable<? extends Iterable<MultiTableRecord>> iterables) {
+ this.iterables = iterables;
+ }
+
+ @Override
+ public Iterator<MultiTableRecord> iterator() {
+ return new AbstractIterator<MultiTableRecord>() {
+ final Iterator<? extends Iterable<MultiTableRecord>> iters = iterables.iterator();
+ Iterator<MultiTableRecord> current;
+
+ public MultiTableRecord makeNext() {
+ while (current == null || !current.hasNext()) {
+ if (iters.hasNext()) {
+ current = iters.next().iterator();
+ } else {
+ return allDone();
+ }
+ }
+ return current.next();
+ }
+ };
+ }
+ }
+}
diff --git a/fluss-client/src/main/java/org/apache/fluss/client/table/scanner/log/PendingFetch.java b/fluss-client/src/main/java/org/apache/fluss/client/table/scanner/log/PendingFetch.java
index c3fa29936..3f49bc5e7 100644
--- a/fluss-client/src/main/java/org/apache/fluss/client/table/scanner/log/PendingFetch.java
+++ b/fluss-client/src/main/java/org/apache/fluss/client/table/scanner/log/PendingFetch.java
@@ -30,4 +30,7 @@ interface PendingFetch {
boolean isCompleted();
CompletedFetch toCompletedFetch();
+
+ /** Release resources when this pending fetch is removed before completion. */
+ default void discard() {}
}
diff --git a/fluss-client/src/main/java/org/apache/fluss/client/table/scanner/log/RemoteCompletedFetch.java b/fluss-client/src/main/java/org/apache/fluss/client/table/scanner/log/RemoteCompletedFetch.java
index 0ac064307..9d7d1cbc0 100644
--- a/fluss-client/src/main/java/org/apache/fluss/client/table/scanner/log/RemoteCompletedFetch.java
+++ b/fluss-client/src/main/java/org/apache/fluss/client/table/scanner/log/RemoteCompletedFetch.java
@@ -19,6 +19,7 @@ package org.apache.fluss.client.table.scanner.log;
import org.apache.fluss.annotation.Internal;
import org.apache.fluss.metadata.TableBucket;
+import org.apache.fluss.metadata.TablePath;
import org.apache.fluss.record.FileLogRecords;
import org.apache.fluss.record.LogRecordReadContext;
import org.apache.fluss.rpc.protocol.ApiError;
@@ -39,6 +40,7 @@ class RemoteCompletedFetch extends CompletedFetch {
RemoteCompletedFetch(
TableBucket tableBucket,
+ TablePath tablePath,
FileLogRecords fileLogRecords,
long highWatermark,
LogRecordReadContext readContext,
@@ -48,6 +50,7 @@ class RemoteCompletedFetch extends CompletedFetch {
Runnable recycleCallback) {
super(
tableBucket,
+ tablePath,
ApiError.NONE,
fileLogRecords.sizeInBytes(),
highWatermark,
diff --git a/fluss-client/src/main/java/org/apache/fluss/client/table/scanner/log/RemoteLogDownloadFuture.java b/fluss-client/src/main/java/org/apache/fluss/client/table/scanner/log/RemoteLogDownloadFuture.java
index 339df422c..57e16989c 100644
--- a/fluss-client/src/main/java/org/apache/fluss/client/table/scanner/log/RemoteLogDownloadFuture.java
+++ b/fluss-client/src/main/java/org/apache/fluss/client/table/scanner/log/RemoteLogDownloadFuture.java
@@ -23,17 +23,28 @@ import org.apache.fluss.record.FileLogRecords;
import java.io.File;
import java.io.IOException;
import java.util.concurrent.CompletableFuture;
+import java.util.concurrent.atomic.AtomicBoolean;
/** Represents the future of a remote log download request. */
public class RemoteLogDownloadFuture {
private final CompletableFuture<File> logFileFuture;
private final Runnable recycleCallback;
+ private final Runnable discardCallback;
+ private final AtomicBoolean discarded = new AtomicBoolean(false);
public RemoteLogDownloadFuture(
CompletableFuture<File> logFileFuture, Runnable recycleCallback) {
+ this(logFileFuture, recycleCallback, () -> {});
+ }
+
+ RemoteLogDownloadFuture(
+ CompletableFuture<File> logFileFuture,
+ Runnable recycleCallback,
+ Runnable discardCallback) {
this.logFileFuture = logFileFuture;
this.recycleCallback = recycleCallback;
+ this.discardCallback = discardCallback;
}
public boolean isDone() {
@@ -57,6 +68,12 @@ public class RemoteLogDownloadFuture {
return recycleCallback;
}
+ public void discard() {
+ if (discarded.compareAndSet(false, true)) {
+ discardCallback.run();
+ }
+ }
+
public void onComplete(Runnable callback) {
logFileFuture.thenRun(callback);
}
diff --git a/fluss-client/src/main/java/org/apache/fluss/client/table/scanner/log/RemoteLogDownloader.java b/fluss-client/src/main/java/org/apache/fluss/client/table/scanner/log/RemoteLogDownloader.java
index 15bc1f631..04c03eb70 100644
--- a/fluss-client/src/main/java/org/apache/fluss/client/table/scanner/log/RemoteLogDownloader.java
+++ b/fluss-client/src/main/java/org/apache/fluss/client/table/scanner/log/RemoteLogDownloader.java
@@ -26,7 +26,6 @@ import org.apache.fluss.config.Configuration;
import org.apache.fluss.fs.FsPath;
import org.apache.fluss.fs.FsPathAndFileName;
import org.apache.fluss.metadata.TableBucket;
-import org.apache.fluss.metadata.TablePath;
import org.apache.fluss.remote.RemoteLogSegment;
import org.apache.fluss.utils.ExceptionUtils;
import org.apache.fluss.utils.FlussPaths;
@@ -88,17 +87,17 @@ public class RemoteLogDownloader implements Closeable {
private volatile boolean closed = false;
public RemoteLogDownloader(
- TablePath tablePath,
+ String scannerName,
Configuration conf,
RemoteFileDownloader remoteFileDownloader,
ScannerMetricGroup scannerMetricGroup) {
// default we give a 5s long interval to avoid frequent loop
- this(tablePath, conf, remoteFileDownloader, scannerMetricGroup, POLL_TIMEOUT);
+ this(scannerName, conf, remoteFileDownloader, scannerMetricGroup, POLL_TIMEOUT);
}
@VisibleForTesting
RemoteLogDownloader(
- TablePath tablePath,
+ String scannerName,
Configuration conf,
RemoteFileDownloader remoteFileDownloader,
ScannerMetricGroup scannerMetricGroup,
@@ -116,7 +115,7 @@ public class RemoteLogDownloader implements Closeable {
Paths.get(
conf.get(ConfigOptions.CLIENT_SCANNER_IO_TMP_DIR),
"remote-logs-" + UUID.randomUUID());
- this.downloadThread = new DownloadRemoteLogThread(tablePath);
+ this.downloadThread = new DownloadRemoteLogThread(scannerName);
}
public void start() {
@@ -127,7 +126,8 @@ public class RemoteLogDownloader implements Closeable {
public RemoteLogDownloadFuture requestRemoteLog(FsPath logTabletDir, RemoteLogSegment segment) {
RemoteLogDownloadRequest request = new RemoteLogDownloadRequest(segment, logTabletDir);
segmentsToFetch.add(request);
- return new RemoteLogDownloadFuture(request.future, () -> recycleRemoteLog(segment));
+ return new RemoteLogDownloadFuture(
+ request.future, () -> recycleRemoteLog(segment), () -> discardRemoteLog(request));
}
/**
@@ -139,6 +139,14 @@ public class RemoteLogDownloader implements Closeable {
prefetchSemaphore.release();
}
+ private void discardRemoteLog(RemoteLogDownloadRequest request) {
+ if (request.future.cancel(false)) {
+ segmentsToFetch.remove(request);
+ } else if (!request.future.isCompletedExceptionally()) {
+ recycleRemoteLog(request.segment);
+ }
+ }
+
/**
* Fetch a remote log segment file to local. This method will block until there is a log segment
* to fetch.
@@ -158,6 +166,10 @@ public class RemoteLogDownloader implements Closeable {
prefetchSemaphore.release();
return;
}
+ if (request.future.isCancelled()) {
+ prefetchSemaphore.release();
+ return;
+ }
TableBucket tableBucket = request.getTableBucket();
try {
@@ -191,6 +203,13 @@ public class RemoteLogDownloader implements Closeable {
deleteDirectoryQuietly(localLogDir.toFile());
return;
}
+ if (request.future.isCancelled()) {
+ prefetchSemaphore.release();
+ if (throwable == null) {
+ cleanupFinishedRemoteLog(request.segment);
+ }
+ return;
+ }
if (throwable != null) {
LOG.error(
"Failed to download remote log segment file {} for table bucket {}.",
@@ -210,15 +229,18 @@ public class RemoteLogDownloader implements Closeable {
File localFile =
new File(localLogDir.toFile(), fsPathAndFileName.getFileName());
scannerMetricGroup.remoteFetchBytes().inc(bytes);
- request.future.complete(localFile);
+ if (!request.future.complete(localFile)) {
+ prefetchSemaphore.release();
+ cleanupFinishedRemoteLog(request.segment);
+ }
}
});
} catch (Throwable t) {
prefetchSemaphore.release();
// only re-queue the request if the downloader is still active
- if (!closed) {
+ if (!closed && !request.future.isCancelled()) {
segmentsToFetch.add(request);
- } else {
+ } else if (!request.future.isDone()) {
request.future.cancel(false);
}
scannerMetricGroup.remoteFetchErrorCount().inc();
@@ -315,8 +337,8 @@ public class RemoteLogDownloader implements Closeable {
* until it is interrupted.
*/
private class DownloadRemoteLogThread extends ShutdownableThread {
- public DownloadRemoteLogThread(TablePath tablePath) {
- super(String.format("DownloadRemoteLog-[%s]", tablePath.toString()), true);
+ public DownloadRemoteLogThread(String scannerName) {
+ super(String.format("DownloadRemoteLog-[%s]", scannerName), true);
}
@Override
diff --git a/fluss-client/src/main/java/org/apache/fluss/client/table/scanner/log/RemotePendingFetch.java b/fluss-client/src/main/java/org/apache/fluss/client/table/scanner/log/RemotePendingFetch.java
index ba4ffd8c8..5bb6d3568 100644
--- a/fluss-client/src/main/java/org/apache/fluss/client/table/scanner/log/RemotePendingFetch.java
+++ b/fluss-client/src/main/java/org/apache/fluss/client/table/scanner/log/RemotePendingFetch.java
@@ -18,6 +18,7 @@
package org.apache.fluss.client.table.scanner.log;
import org.apache.fluss.metadata.TableBucket;
+import org.apache.fluss.metadata.TablePath;
import org.apache.fluss.record.FileLogRecords;
import org.apache.fluss.record.LogRecordReadContext;
import org.apache.fluss.remote.RemoteLogSegment;
@@ -30,6 +31,7 @@ class RemotePendingFetch implements PendingFetch {
final RemoteLogSegment remoteLogSegment;
private final RemoteLogDownloadFuture downloadFuture;
+ private final TablePath tablePath;
private final int posInLogSegment;
private final long fetchOffset;
@@ -41,6 +43,7 @@ class RemotePendingFetch implements PendingFetch {
RemotePendingFetch(
RemoteLogSegment remoteLogSegment,
RemoteLogDownloadFuture downloadFuture,
+ TablePath tablePath,
int posInLogSegment,
long fetchOffset,
long highWatermark,
@@ -49,6 +52,7 @@ class RemotePendingFetch implements PendingFetch {
boolean isCheckCrc) {
this.remoteLogSegment = remoteLogSegment;
this.downloadFuture = downloadFuture;
+ this.tablePath = tablePath;
this.posInLogSegment = posInLogSegment;
this.fetchOffset = fetchOffset;
this.highWatermark = highWatermark;
@@ -72,6 +76,7 @@ class RemotePendingFetch implements PendingFetch {
FileLogRecords fileLogRecords = downloadFuture.getFileLogRecords(posInLogSegment);
return new RemoteCompletedFetch(
remoteLogSegment.tableBucket(),
+ tablePath,
fileLogRecords,
highWatermark,
readContext,
@@ -81,6 +86,11 @@ class RemotePendingFetch implements PendingFetch {
downloadFuture.getRecycleCallback());
}
+ @Override
+ public void discard() {
+ downloadFuture.discard();
+ }
+
@Override
public String toString() {
return "RemotePendingFetch{"
diff --git a/fluss-client/src/main/java/org/apache/fluss/client/table/scanner/log/ScanRecords.java b/fluss-client/src/main/java/org/apache/fluss/client/table/scanner/log/ScanRecords.java
index eb3e157d1..ebeab20c2 100644
--- a/fluss-client/src/main/java/org/apache/fluss/client/table/scanner/log/ScanRecords.java
+++ b/fluss-client/src/main/java/org/apache/fluss/client/table/scanner/log/ScanRecords.java
@@ -26,6 +26,7 @@ import javax.annotation.Nullable;
import java.util.Collections;
import java.util.Iterator;
+import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Set;
@@ -53,10 +54,28 @@ public class ScanRecords implements Iterable<ScanRecord> {
public ScanRecords(
Map<TableBucket, List<ScanRecord>> records,
Map<TableBucket, Long> consumedUpToOffsets) {
- this.records = records;
+ this.records = withProgressOnlyBuckets(records, consumedUpToOffsets);
this.consumedUpToOffsets = consumedUpToOffsets;
}
+ /**
+ * Ensures every bucket with a consumed offset has a (possibly empty) record list entry, so that
+ * {@link #buckets()} surfaces progress-only buckets as documented. Returns the original map
+ * when the invariant already holds (the common case for collector-produced results).
+ */
+ private static Map<TableBucket, List<ScanRecord>> withProgressOnlyBuckets(
+ Map<TableBucket, List<ScanRecord>> records,
+ Map<TableBucket, Long> consumedUpToOffsets) {
+ if (records.keySet().containsAll(consumedUpToOffsets.keySet())) {
+ return records;
+ }
+ Map<TableBucket, List<ScanRecord>> merged = new LinkedHashMap<>(records);
+ for (TableBucket bucket : consumedUpToOffsets.keySet()) {
+ merged.putIfAbsent(bucket, Collections.emptyList());
+ }
+ return merged;
+ }
+
/**
* Get just the records for the given bucketId.
*
@@ -104,6 +123,16 @@ public class ScanRecords implements Iterable<ScanRecord> {
return count() == 0;
}
+ /**
+ * Returns {@code true} if this {@code ScanRecords} carries any scanner progress.
+ *
+ * <p>A result may have progress even when {@link #isEmpty()} is {@code true}, for example when
+ * a bucket returns no materialized records but advances its consumed offset.
+ */
+ public boolean hasProgress() {
+ return count() > 0 || !consumedUpToOffsets.isEmpty();
+ }
+
@Override
public Iterator<ScanRecord> iterator() {
return new ConcatenatedIterable(records.values()).iterator();
diff --git a/fluss-client/src/main/java/org/apache/fluss/client/table/scanner/log/TableScanSpec.java b/fluss-client/src/main/java/org/apache/fluss/client/table/scanner/log/TableScanSpec.java
new file mode 100644
index 000000000..28346a496
--- /dev/null
+++ b/fluss-client/src/main/java/org/apache/fluss/client/table/scanner/log/TableScanSpec.java
@@ -0,0 +1,64 @@
+/*
+ * 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.fluss.client.table.scanner.log;
+
+import org.apache.fluss.annotation.Internal;
+import org.apache.fluss.metadata.TableInfo;
+import org.apache.fluss.predicate.Predicate;
+import org.apache.fluss.utils.Projection;
+
+import javax.annotation.Nullable;
+
+import static org.apache.fluss.utils.Preconditions.checkNotNull;
+
+/**
+ * Per-table scan specification consumed by {@link LogFetcher#registerTable}.
+ *
+ * <p>Captures the {@link TableInfo} together with optional projection and filter pushdown to be
+ * applied while reading log records for the table.
+ *
+ * @since 0.7
+ */
+@Internal
+public final class TableScanSpec {
+
+ private final TableInfo tableInfo;
+ @Nullable private final Projection projection;
+ @Nullable private final Predicate filter;
+
+ public TableScanSpec(
+ TableInfo tableInfo, @Nullable Projection projection, @Nullable Predicate filter) {
+ this.tableInfo = checkNotNull(tableInfo, "tableInfo");
+ this.projection = projection;
+ this.filter = filter;
+ }
+
+ public TableInfo getTableInfo() {
+ return tableInfo;
+ }
+
+ @Nullable
+ public Projection getProjection() {
+ return projection;
+ }
+
+ @Nullable
+ public Predicate getFilter() {
+ return filter;
+ }
+}
diff --git a/fluss-client/src/main/java/org/apache/fluss/client/table/scanner/log/PendingFetch.java b/fluss-client/src/main/java/org/apache/fluss/client/table/writer/MultiTableWrite.java
similarity index 62%
copy from fluss-client/src/main/java/org/apache/fluss/client/table/scanner/log/PendingFetch.java
copy to fluss-client/src/main/java/org/apache/fluss/client/table/writer/MultiTableWrite.java
index c3fa29936..c1b589e82 100644
--- a/fluss-client/src/main/java/org/apache/fluss/client/table/scanner/log/PendingFetch.java
+++ b/fluss-client/src/main/java/org/apache/fluss/client/table/writer/MultiTableWrite.java
@@ -15,19 +15,20 @@
* limitations under the License.
*/
-package org.apache.fluss.client.table.scanner.log;
+package org.apache.fluss.client.table.writer;
-import org.apache.fluss.metadata.TableBucket;
+import org.apache.fluss.annotation.PublicEvolving;
/**
- * A pending fetch is a fetch that requested to download remote log segment file to local but not
- * yet been completed.
+ * Builder for a multi-table writer. Unlike {@link Append} / {@link Upsert}, no table is bound at
+ * builder time; the target table is carried on each {@link MultiTableWriteRecord} passed to the
+ * writer.
+ *
+ * @since 0.7
*/
-interface PendingFetch {
-
- TableBucket tableBucket();
-
- boolean isCompleted();
+@PublicEvolving
+public interface MultiTableWrite {
- CompletedFetch toCompletedFetch();
+ /** Creates a {@link MultiTableWriter} that can write append/upsert/delete records. */
+ MultiTableWriter createWriter();
}
diff --git a/fluss-client/src/main/java/org/apache/fluss/client/table/writer/MultiTableWriteImpl.java b/fluss-client/src/main/java/org/apache/fluss/client/table/writer/MultiTableWriteImpl.java
new file mode 100644
index 000000000..12fca0ec6
--- /dev/null
+++ b/fluss-client/src/main/java/org/apache/fluss/client/table/writer/MultiTableWriteImpl.java
@@ -0,0 +1,60 @@
+/*
+ * 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.fluss.client.table.writer;
+
+import org.apache.fluss.annotation.Internal;
+import org.apache.fluss.client.FlussConnection;
+import org.apache.fluss.client.metrics.MultiTableWriterMetricGroup;
+import org.apache.fluss.client.write.WriterClient;
+
+import static org.apache.fluss.utils.Preconditions.checkNotNull;
+
+/**
+ * Default implementation of {@link MultiTableWrite}.
+ *
+ * <p>Thin pass-through builder that constructs a {@link MultiTableWriterImpl} owning a dedicated
+ * {@link WriterClient}, so that flushes and schema-scoped batching of one writer never interfere
+ * with other writers on the same connection. Tables are registered dynamically on the writer on
+ * first {@code write(...)} call — no upfront declaration is required.
+ *
+ * @since 0.7
+ */
+@Internal
+public class MultiTableWriteImpl implements MultiTableWrite {
+
+ private final FlussConnection connection;
+
+ public MultiTableWriteImpl(FlussConnection connection) {
+ this.connection = checkNotNull(connection, "connection");
+ }
+
+ @Override
+ public MultiTableWriter createWriter() {
+ // Each writer owns a dedicated WriterClient (buffer pool + sender thread) instead of the
+ // connection-level shared one, so MultiTableWriter#flush() only affects this writer and
+ // schema-id batching of different writers cannot interfere with each other.
+ return new MultiTableWriterImpl(
+ connection.getMetadataUpdater(),
+ connection.getOrCreateAdmin(),
+ new WriterClient(
+ connection.getConfiguration(),
+ connection.getMetadataUpdater(),
+ new MultiTableWriterMetricGroup(connection.getClientMetricGroup()),
+ connection.getOrCreateAdmin()));
+ }
+}
diff --git a/fluss-client/src/main/java/org/apache/fluss/client/table/writer/MultiTableWriteRecord.java b/fluss-client/src/main/java/org/apache/fluss/client/table/writer/MultiTableWriteRecord.java
new file mode 100644
index 000000000..103ef5ea5
--- /dev/null
+++ b/fluss-client/src/main/java/org/apache/fluss/client/table/writer/MultiTableWriteRecord.java
@@ -0,0 +1,138 @@
+/*
+ * 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.fluss.client.table.writer;
+
+import org.apache.fluss.annotation.PublicEvolving;
+import org.apache.fluss.metadata.TablePath;
+import org.apache.fluss.row.InternalRow;
+
+import static org.apache.fluss.utils.Preconditions.checkNotNull;
+
+/**
+ * A write-side record for {@link MultiTableWriter}. Carries only the fields a writer needs:
+ *
+ * <ul>
+ * <li>{@link #getTablePath()} — target table
+ * <li>{@link #getOperation()} — APPEND / UPSERT / DELETE
+ * <li>{@link #getRow()} — the row payload encoded against the table's schema
+ * <li>{@link #getSchemaId()} — the schema id the row was encoded against; must be a valid
+ * (positive) schema id obtained from table metadata
+ * </ul>
+ *
+ * <p>Server-assigned identity ({@code tableId}) and read-only metadata ({@code schema} object,
+ * {@code logOffset}, {@code timestamp}) are intentionally absent here — they belong to the
+ * read-side {@link org.apache.fluss.client.table.scanner.MultiTableRecord}.
+ *
+ * <p>Instances are immutable.
+ *
+ * @since 0.7
+ */
+@PublicEvolving
+public final class MultiTableWriteRecord {
+
+ /**
+ * The write operation understood by {@link MultiTableWriter}.
+ *
+ * <p>Log tables accept {@link #APPEND}; primary-key tables accept {@link #UPSERT} and {@link
+ * #DELETE}.
+ */
+ @PublicEvolving
+ public enum Operation {
+ APPEND,
+ UPSERT,
+ DELETE
+ }
+
+ private final TablePath tablePath;
+ private final Operation operation;
+ private final InternalRow row;
+ private final int schemaId;
+
+ private MultiTableWriteRecord(
+ TablePath tablePath, Operation operation, InternalRow row, int schemaId) {
+ this.tablePath = checkNotNull(tablePath, "tablePath");
+ this.operation = checkNotNull(operation, "operation");
+ this.row = checkNotNull(row, "row");
+ this.schemaId = schemaId;
+ }
+
+ /**
+ * Build an append record for a log table.
+ *
+ * @param tablePath target log table
+ * @param row the row payload
+ * @param schemaId the schema id the row was encoded against; must be a valid positive id
+ */
+ public static MultiTableWriteRecord forAppend(
+ TablePath tablePath, InternalRow row, int schemaId) {
+ return new MultiTableWriteRecord(tablePath, Operation.APPEND, row, schemaId);
+ }
+
+ /**
+ * Build an upsert record for a primary-key table.
+ *
+ * @param tablePath target primary-key table
+ * @param row the row payload
+ * @param schemaId the schema id the row was encoded against
+ */
+ public static MultiTableWriteRecord forUpsert(
+ TablePath tablePath, InternalRow row, int schemaId) {
+ return new MultiTableWriteRecord(tablePath, Operation.UPSERT, row, schemaId);
+ }
+
+ /**
+ * Build a delete record for a primary-key table.
+ *
+ * @param tablePath target primary-key table
+ * @param row the row payload
+ * @param schemaId the schema id the row was encoded against
+ */
+ public static MultiTableWriteRecord forDelete(
+ TablePath tablePath, InternalRow row, int schemaId) {
+ return new MultiTableWriteRecord(tablePath, Operation.DELETE, row, schemaId);
+ }
+
+ public TablePath getTablePath() {
+ return tablePath;
+ }
+
+ public Operation getOperation() {
+ return operation;
+ }
+
+ public InternalRow getRow() {
+ return row;
+ }
+
+ /** Returns the schema id the row was encoded against. */
+ public int getSchemaId() {
+ return schemaId;
+ }
+
+ @Override
+ public String toString() {
+ return "MultiTableWriteRecord{"
+ + "tablePath="
+ + tablePath
+ + ", operation="
+ + operation
+ + ", schemaId="
+ + schemaId
+ + '}';
+ }
+}
diff --git a/fluss-client/src/main/java/org/apache/fluss/client/table/writer/MultiTableWriter.java b/fluss-client/src/main/java/org/apache/fluss/client/table/writer/MultiTableWriter.java
new file mode 100644
index 000000000..8edf038d8
--- /dev/null
+++ b/fluss-client/src/main/java/org/apache/fluss/client/table/writer/MultiTableWriter.java
@@ -0,0 +1,58 @@
+/*
+ * 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.fluss.client.table.writer;
+
+import org.apache.fluss.annotation.PublicEvolving;
+import org.apache.fluss.exception.TableNotExistException;
+
+import java.util.concurrent.CompletableFuture;
+
+/**
+ * A writer that sends records to multiple tables through a writer-owned underlying writer client.
+ * The target table, operation, row, and schema id are carried by the {@link MultiTableWriteRecord}
+ * itself.
+ *
+ * <p>NOT thread-safe.
+ *
+ * @since 0.7
+ */
+@PublicEvolving
+public interface MultiTableWriter extends AutoCloseable {
+
+ /**
+ * Write a record to the table identified by {@link MultiTableWriteRecord#getTablePath()}.
+ *
+ * <p>Asynchronous; the returned future completes when the server has acknowledged the write (or
+ * has failed to write it).
+ *
+ * @throws TableNotExistException wrapped in the future, if the table is unknown
+ */
+ CompletableFuture<WriteResult> write(MultiTableWriteRecord record);
+
+ /**
+ * Flush all buffered records and block until acknowledged by the servers (or failed).
+ * Consistent with {@link TableWriter#flush()}.
+ */
+ void flush();
+
+ /**
+ * Close the writer and release its writer-owned resources; pending records are flushed first.
+ */
+ @Override
+ void close() throws Exception;
+}
diff --git a/fluss-client/src/main/java/org/apache/fluss/client/table/writer/MultiTableWriterImpl.java b/fluss-client/src/main/java/org/apache/fluss/client/table/writer/MultiTableWriterImpl.java
new file mode 100644
index 000000000..5702188d3
--- /dev/null
+++ b/fluss-client/src/main/java/org/apache/fluss/client/table/writer/MultiTableWriterImpl.java
@@ -0,0 +1,521 @@
+/*
+ * 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.fluss.client.table.writer;
+
+import org.apache.fluss.annotation.Internal;
+import org.apache.fluss.client.admin.Admin;
+import org.apache.fluss.client.metadata.MetadataUpdater;
+import org.apache.fluss.client.table.getter.PartitionGetter;
+import org.apache.fluss.client.write.WriteFormat;
+import org.apache.fluss.client.write.WriteRecord;
+import org.apache.fluss.client.write.WriterClient;
+import org.apache.fluss.metadata.KvFormat;
+import org.apache.fluss.metadata.LogFormat;
+import org.apache.fluss.metadata.PhysicalTablePath;
+import org.apache.fluss.metadata.Schema;
+import org.apache.fluss.metadata.TableInfo;
+import org.apache.fluss.metadata.TablePath;
+import org.apache.fluss.row.BinaryRow;
+import org.apache.fluss.row.InternalRow;
+import org.apache.fluss.row.InternalRow.FieldGetter;
+import org.apache.fluss.row.compacted.CompactedRow;
+import org.apache.fluss.row.encode.CompactedRowEncoder;
+import org.apache.fluss.row.encode.IndexedRowEncoder;
+import org.apache.fluss.row.encode.KeyEncoder;
+import org.apache.fluss.row.encode.RowEncoder;
+import org.apache.fluss.row.indexed.IndexedRow;
+import org.apache.fluss.rpc.protocol.MergeMode;
+import org.apache.fluss.types.DataType;
+import org.apache.fluss.types.RowType;
+
+import javax.annotation.Nullable;
+
+import java.time.Duration;
+import java.util.HashMap;
+import java.util.Map;
+import java.util.concurrent.CompletableFuture;
+import java.util.concurrent.ExecutionException;
+
+import static org.apache.fluss.utils.Preconditions.checkNotNull;
+import static org.apache.fluss.utils.Preconditions.checkState;
+
+/** Default implementation of {@link MultiTableWriter}. */
+@Internal
+class MultiTableWriterImpl implements MultiTableWriter {
+
+ private final MetadataUpdater metadataUpdater;
+ private final Admin admin;
+ /** Owned by this writer; created per writer instance and closed in {@link #close()}. */
+ private final WriterClient writerClient;
+
+ /** Per-table encoder states keyed by schemaId, supporting writes against historical schemas. */
+ private final Map<TablePath, Map<Integer, TableWriteState>> states = new HashMap<>();
+
+ /** The latest known schemaId per table, for fast-path lookup of the current encoder state. */
+ private final Map<TablePath, Integer> latestSchemaIdByPath = new HashMap<>();
+
+ private volatile boolean closed;
+
+ MultiTableWriterImpl(MetadataUpdater metadataUpdater, Admin admin, WriterClient writerClient) {
+ this.metadataUpdater = checkNotNull(metadataUpdater, "metadataUpdater");
+ this.admin = checkNotNull(admin, "admin");
+ this.writerClient = checkNotNull(writerClient, "writerClient");
+ }
+
+ @Override
+ public CompletableFuture<WriteResult> write(MultiTableWriteRecord record) {
+ checkNotNull(record, "record");
+ checkState(!closed, "MultiTableWriter is already closed.");
+
+ TablePath tablePath = record.getTablePath();
+ MultiTableWriteRecord.Operation operation = record.getOperation();
+ InternalRow row = record.getRow();
+
+ final WriteRecord writeRecord;
+ try {
+ TableWriteState state = resolveWriteState(tablePath, record.getSchemaId());
+ state.checkFieldCount(row);
+ writeRecord = state.build(operation, row);
+ } catch (Exception e) {
+ CompletableFuture<WriteResult> failed = new CompletableFuture<>();
+ failed.completeExceptionally(e);
+ return failed;
+ }
+
+ CompletableFuture<WriteResult> future = new CompletableFuture<>();
+ writerClient.send(
+ writeRecord,
+ (bucket, logEndOffset, exception) -> {
+ if (exception == null) {
+ future.complete(new WriteResult());
+ } else {
+ future.completeExceptionally(exception);
+ }
+ });
+ return future;
+ }
+
+ @Override
+ public void flush() {
+ writerClient.flush();
+ }
+
+ @Override
+ public void close() throws Exception {
+ if (closed) {
+ return;
+ }
+
+ try {
+ try {
+ writerClient.flush();
+ } finally {
+ // The writer owns its WriterClient: release its sender thread and buffers even
+ // when the final flush fails.
+ writerClient.close(Duration.ofMillis(Long.MAX_VALUE));
+ }
+ } finally {
+ // Drop per-table encoder state to help GC on long-lived connections that may
+ // create and close many MultiTableWriter instances.
+ states.clear();
+ latestSchemaIdByPath.clear();
+ closed = true;
+ }
+ }
+
+ /**
+ * Resolve the encoding state for the schemaId carried by the record: return the cached state
+ * (latest or historical) if present, so consecutive records carrying the same schemaId never
+ * re-trigger a refresh; otherwise refresh metadata (force-refresh once if the record's schemaId
+ * is ahead of the known latest), fail fast if it is still ahead, and otherwise encode against
+ * the latest or the matching historical schema.
+ */
+ private TableWriteState resolveWriteState(TablePath tablePath, int recordSchemaId) {
+ TableWriteState cached = getCachedState(tablePath, recordSchemaId);
+ if (cached != null) {
+ return cached;
+ }
+
+ TableWriteState latest = resolveLatestState(tablePath, false);
+ if (recordSchemaId > latest.tableInfo.getSchemaId()) {
+ // record may reference a newer schema; force-refresh metadata once.
+ latest = resolveLatestState(tablePath, true);
+ }
+ int latestSchemaId = latest.tableInfo.getSchemaId();
+ if (recordSchemaId > latestSchemaId) {
+ throw new IllegalArgumentException(
+ "SchemaId mismatch for table "
+ + tablePath
+ + ": record schemaId="
+ + recordSchemaId
+ + " is ahead of the current table schemaId="
+ + latestSchemaId
+ + " (after metadata refresh).");
+ }
+ // encode against the latest schema directly.
+ if (recordSchemaId == latestSchemaId) {
+ return latest;
+ }
+ return resolveHistoricalState(tablePath, latest.tableInfo, recordSchemaId);
+ }
+
+ @Nullable
+ private TableWriteState getCachedState(TablePath tablePath, int schemaId) {
+ Map<Integer, TableWriteState> bySchema = states.get(tablePath);
+ return bySchema == null ? null : bySchema.get(schemaId);
+ }
+
+ /**
+ * Resolve the encoding state for the table's latest schema. When {@code forceUpdate} is true,
+ * force-refresh metadata to avoid stale schema, matching {@code FlussConnection.getTable(...)}.
+ */
+ private TableWriteState resolveLatestState(TablePath tablePath, boolean forceUpdate) {
+ Integer latestSchemaId = latestSchemaIdByPath.get(tablePath);
+ if (latestSchemaId != null && !forceUpdate) {
+ return states.get(tablePath).get(latestSchemaId);
+ }
+ metadataUpdater.updateTableOrPartitionMetadata(tablePath, null);
+ TableInfo info = getTableInfo(tablePath);
+ TableWriteState state = TableWriteState.forTable(tablePath, info);
+ cacheState(tablePath, info.getSchemaId(), state);
+ latestSchemaIdByPath.put(tablePath, info.getSchemaId());
+ return state;
+ }
+
+ /**
+ * Resolve (or build and cache) the encoding state for a historical {@code schemaId}. The
+ * historical {@link TableInfo} reuses every non-schema attribute of the latest table info and
+ * only swaps in the historical schema / schemaId, so the produced {@link WriteRecord} carries
+ * the record's original schemaId (see {@link WriteRecord#getSchemaId()}).
+ */
+ private TableWriteState resolveHistoricalState(
+ TablePath tablePath, TableInfo latestTableInfo, int schemaId) {
+ Map<Integer, TableWriteState> bySchema = states.get(tablePath);
+ if (bySchema != null) {
+ TableWriteState cached = bySchema.get(schemaId);
+ if (cached != null) {
+ return cached;
+ }
+ }
+ Schema historicalSchema = getTableSchema(tablePath, schemaId);
+ TableInfo historicalTableInfo = withSchema(latestTableInfo, schemaId, historicalSchema);
+ TableWriteState state = TableWriteState.forTable(tablePath, historicalTableInfo);
+ cacheState(tablePath, schemaId, state);
+ return state;
+ }
+
+ private void cacheState(TablePath tablePath, int schemaId, TableWriteState state) {
+ states.computeIfAbsent(tablePath, k -> new HashMap<>()).put(schemaId, state);
+ }
+
+ /**
+ * Build a {@link TableInfo} for a historical schema by reusing every non-schema attribute of
+ * {@code base}. This assumes schema evolution does not change bucket keys / partition keys /
+ * bucket count (compatible evolution such as adding columns); otherwise encoding would fail
+ * fast downstream.
+ */
+ private static TableInfo withSchema(TableInfo base, int schemaId, Schema schema) {
+ return new TableInfo(
+ base.getTablePath(),
+ base.getTableId(),
+ schemaId,
+ schema,
+ base.getBucketKeys(),
+ base.getPartitionKeys(),
+ base.getNumBuckets(),
+ base.getProperties(),
+ base.getCustomProperties(),
+ base.getRemoteDataDir(),
+ base.getComment().orElse(null),
+ base.getCreatedTime(),
+ base.getModifiedTime());
+ }
+
+ private TableInfo getTableInfo(TablePath tablePath) {
+ try {
+ return admin.getTableInfo(tablePath).get();
+ } catch (InterruptedException e) {
+ Thread.currentThread().interrupt();
+ throw new RuntimeException("Interrupted while resolving table info: " + tablePath, e);
+ } catch (ExecutionException e) {
+ Throwable cause = e.getCause() != null ? e.getCause() : e;
+ if (cause instanceof RuntimeException) {
+ throw (RuntimeException) cause;
+ }
+ throw new RuntimeException("Failed to resolve table info: " + tablePath, cause);
+ }
+ }
+
+ private Schema getTableSchema(TablePath tablePath, int schemaId) {
+ try {
+ return admin.getTableSchema(tablePath, schemaId).get().getSchema();
+ } catch (InterruptedException e) {
+ Thread.currentThread().interrupt();
+ throw new RuntimeException("Interrupted while resolving table schema: " + tablePath, e);
+ } catch (ExecutionException e) {
+ Throwable cause = e.getCause() != null ? e.getCause() : e;
+ if (cause instanceof RuntimeException) {
+ throw (RuntimeException) cause;
+ }
+ throw new RuntimeException("Failed to resolve table schema: " + tablePath, cause);
+ }
+ }
+
+ /**
+ * Per-table encoding state, lazily constructed on first write. Mirrors the encoder setup done
+ * in the constructors of {@link AppendWriterImpl} and {@link UpsertWriterImpl}. Sub-classed for
+ * log tables ({@link LogTableWriteState}) and primary-key tables ({@link
+ * PrimaryTableWriteState}) so that each path only carries the encoders it actually uses, and
+ * operation validation is owned by the table-shape that supports it.
+ */
+ private abstract static class TableWriteState {
+ final TablePath tablePath;
+ final TableInfo tableInfo;
+ final int fieldCount;
+ @Nullable final PartitionGetter partitionGetter;
+ final FieldGetter[] fieldGetters;
+
+ TableWriteState(TablePath tablePath, TableInfo tableInfo) {
+ this.tablePath = tablePath;
+ this.tableInfo = tableInfo;
+ RowType rowType = tableInfo.getRowType();
+ this.fieldCount = rowType.getFieldCount();
+ this.partitionGetter =
+ tableInfo.isPartitioned()
+ ? new PartitionGetter(rowType, tableInfo.getPartitionKeys())
+ : null;
+ this.fieldGetters = InternalRow.createFieldGetters(rowType);
+ }
+
+ /** Factory: pick the right shape based on whether the table has a primary key. */
+ static TableWriteState forTable(TablePath tablePath, TableInfo tableInfo) {
+ return tableInfo.hasPrimaryKey()
+ ? new PrimaryTableWriteState(tablePath, tableInfo)
+ : new LogTableWriteState(tablePath, tableInfo);
+ }
+
+ void checkFieldCount(InternalRow row) {
+ if (row.getFieldCount() != fieldCount) {
+ throw new IllegalArgumentException(
+ "The field count of the row does not match the table schema for "
+ + tablePath
+ + ". Expected: "
+ + fieldCount
+ + ", Actual: "
+ + row.getFieldCount());
+ }
+ }
+
+ PhysicalTablePath getPhysicalPath(InternalRow row) {
+ if (partitionGetter == null) {
+ return PhysicalTablePath.of(tablePath);
+ }
+ return PhysicalTablePath.of(tablePath, partitionGetter.getPartition(row));
+ }
+
+ /**
+ * Encode the row into a {@link WriteRecord} for the given operation. Throws {@link
+ * IllegalArgumentException} if the operation is not supported by the underlying table
+ * shape.
+ */
+ abstract WriteRecord build(MultiTableWriteRecord.Operation operation, InternalRow row);
+ }
+
+ /** Encoding state for a log (non-PK) table; only supports APPEND. */
+ private static final class LogTableWriteState extends TableWriteState {
+ final LogFormat logFormat;
+ /**
+ * Per-row encoder for the {@link #logFormat}. Only initialized for {@link
+ * LogFormat#INDEXED} and {@link LogFormat#COMPACTED}; {@code null} for {@link
+ * LogFormat#ARROW} which accepts a general {@link InternalRow} directly.
+ */
+ @Nullable final RowEncoder rowEncoder;
+
+ @Nullable final KeyEncoder logBucketKeyEncoder;
+
+ LogTableWriteState(TablePath tablePath, TableInfo tableInfo) {
+ super(tablePath, tableInfo);
+ RowType rowType = tableInfo.getRowType();
+ if (tableInfo.getBucketKeys().isEmpty()) {
+ this.logBucketKeyEncoder = null;
+ } else {
+ this.logBucketKeyEncoder =
+ KeyEncoder.ofBucketKeyEncoder(
+ rowType,
+ tableInfo.getBucketKeys(),
+ tableInfo.getTableConfig().getDataLakeFormat().orElse(null));
+ }
+ this.logFormat = tableInfo.getTableConfig().getLogFormat();
+ if (logFormat == LogFormat.INDEXED) {
+ this.rowEncoder = new IndexedRowEncoder(rowType);
+ } else if (logFormat == LogFormat.COMPACTED) {
+ DataType[] fieldDataTypes =
+ tableInfo.getSchema().getRowType().getChildren().toArray(new DataType[0]);
+ this.rowEncoder = new CompactedRowEncoder(fieldDataTypes);
+ } else {
+ // ARROW format accepts a general InternalRow; no per-row encoder needed.
+ this.rowEncoder = null;
+ }
+ }
+
+ @Override
+ WriteRecord build(MultiTableWriteRecord.Operation operation, InternalRow row) {
+ if (operation == MultiTableWriteRecord.Operation.APPEND) {
+ return buildAppendRecord(row);
+ }
+ throw new IllegalArgumentException(
+ "Operation "
+ + operation
+ + " is not supported for log table "
+ + tablePath
+ + "; please use MultiTableWriteRecord.forAppend(...).");
+ }
+
+ WriteRecord buildAppendRecord(InternalRow row) {
+ PhysicalTablePath physicalPath = getPhysicalPath(row);
+ byte[] bucketKey =
+ logBucketKeyEncoder != null ? logBucketKeyEncoder.encodeKey(row) : null;
+ if (logFormat == LogFormat.INDEXED) {
+ IndexedRow indexedRow =
+ row instanceof IndexedRow ? (IndexedRow) row : (IndexedRow) encodeRow(row);
+ return WriteRecord.forIndexedAppend(tableInfo, physicalPath, indexedRow, bucketKey);
+ } else if (logFormat == LogFormat.COMPACTED) {
+ CompactedRow compactedRow =
+ row instanceof CompactedRow
+ ? (CompactedRow) row
+ : (CompactedRow) encodeRow(row);
+ return WriteRecord.forCompactedAppend(
+ tableInfo, physicalPath, compactedRow, bucketKey);
+ } else {
+ // ARROW format accepts a general InternalRow.
+ return WriteRecord.forArrowAppend(tableInfo, physicalPath, row, bucketKey);
+ }
+ }
+
+ private BinaryRow encodeRow(InternalRow row) {
+ assert rowEncoder != null;
+ rowEncoder.startNewRow();
+ for (int i = 0; i < fieldCount; i++) {
+ rowEncoder.encodeField(i, fieldGetters[i].getFieldOrNull(row));
+ }
+ return rowEncoder.finishRow();
+ }
+ }
+
+ /** Encoding state for a primary-key (KV) table; only supports UPSERT / DELETE. */
+ private static final class PrimaryTableWriteState extends TableWriteState {
+ final KeyEncoder primaryKeyEncoder;
+ final KeyEncoder kvBucketKeyEncoder;
+ final KvFormat kvFormat;
+ final WriteFormat kvWriteFormat;
+ final RowEncoder kvRowEncoder;
+
+ PrimaryTableWriteState(TablePath tablePath, TableInfo tableInfo) {
+ super(tablePath, tableInfo);
+ RowType rowType = tableInfo.getRowType();
+ this.primaryKeyEncoder =
+ KeyEncoder.ofPrimaryKeyEncoder(
+ rowType,
+ tableInfo.getPhysicalPrimaryKeys(),
+ tableInfo.getTableConfig(),
+ tableInfo.isDefaultBucketKey());
+ this.kvBucketKeyEncoder =
+ tableInfo.isDefaultBucketKey()
+ ? this.primaryKeyEncoder
+ : KeyEncoder.ofBucketKeyEncoder(
+ rowType,
+ tableInfo.getBucketKeys(),
+ tableInfo.getTableConfig().getDataLakeFormat().orElse(null));
+ this.kvFormat = tableInfo.getTableConfig().getKvFormat();
+ this.kvWriteFormat = WriteFormat.fromKvFormat(this.kvFormat);
+ this.kvRowEncoder = RowEncoder.create(this.kvFormat, rowType);
+ }
+
+ @Override
+ WriteRecord build(MultiTableWriteRecord.Operation operation, InternalRow row) {
+ if (operation == MultiTableWriteRecord.Operation.UPSERT) {
+ return buildUpsertRecord(row);
+ }
+ if (operation == MultiTableWriteRecord.Operation.DELETE) {
+ return buildDeleteRecord(row);
+ }
+ throw new IllegalArgumentException(
+ "Operation "
+ + operation
+ + " is not supported for primary-key table "
+ + tablePath
+ + "; please use MultiTableWriteRecord.forUpsert(...) or "
+ + "MultiTableWriteRecord.forDelete(...).");
+ }
+
+ WriteRecord buildUpsertRecord(InternalRow row) {
+ byte[] key = primaryKeyEncoder.encodeKey(row);
+ return WriteRecord.forUpsert(
+ tableInfo,
+ getPhysicalPath(row),
+ encodeKvRow(row),
+ key,
+ encodeBucketKey(row, key),
+ kvWriteFormat,
+ null /*targetColumns*/,
+ MergeMode.DEFAULT);
+ }
+
+ WriteRecord buildDeleteRecord(InternalRow row) {
+ byte[] key = primaryKeyEncoder.encodeKey(row);
+ return WriteRecord.forDelete(
+ tableInfo,
+ getPhysicalPath(row),
+ key,
+ encodeBucketKey(row, key),
+ kvWriteFormat,
+ null /*targetColumns*/,
+ MergeMode.DEFAULT);
+ }
+
+ /**
+ * When the bucket key equals the primary key (default-bucket-key tables), reuse the already
+ * encoded primary key bytes to avoid a second encode pass.
+ */
+ private byte[] encodeBucketKey(InternalRow row, byte[] primaryKey) {
+ return kvBucketKeyEncoder == primaryKeyEncoder
+ ? primaryKey
+ : kvBucketKeyEncoder.encodeKey(row);
+ }
+
+ private BinaryRow encodeKvRow(InternalRow row) {
+ if (kvFormat == KvFormat.INDEXED && row instanceof IndexedRow) {
+ return (IndexedRow) row;
+ } else if (kvFormat == KvFormat.COMPACTED && row instanceof CompactedRow) {
+ return (CompactedRow) row;
+ }
+ kvRowEncoder.startNewRow();
+ for (int i = 0; i < fieldCount; i++) {
+ kvRowEncoder.encodeField(i, fieldGetters[i].getFieldOrNull(row));
+ }
+ return kvRowEncoder.finishRow();
+ }
+ }
+
+ // ----- test hooks -----
+
+ /** For tests: number of resolved tables (lazy registration count). */
+ int resolvedTableCount() {
+ return states.size();
+ }
+}
diff --git a/fluss-client/src/main/java/org/apache/fluss/client/table/scanner/log/PendingFetch.java b/fluss-client/src/main/java/org/apache/fluss/client/table/writer/WriteResult.java
similarity index 55%
copy from fluss-client/src/main/java/org/apache/fluss/client/table/scanner/log/PendingFetch.java
copy to fluss-client/src/main/java/org/apache/fluss/client/table/writer/WriteResult.java
index c3fa29936..5fa5579e1 100644
--- a/fluss-client/src/main/java/org/apache/fluss/client/table/scanner/log/PendingFetch.java
+++ b/fluss-client/src/main/java/org/apache/fluss/client/table/writer/WriteResult.java
@@ -15,19 +15,23 @@
* limitations under the License.
*/
-package org.apache.fluss.client.table.scanner.log;
+package org.apache.fluss.client.table.writer;
-import org.apache.fluss.metadata.TableBucket;
+import org.apache.fluss.annotation.PublicEvolving;
/**
- * A pending fetch is a fetch that requested to download remote log segment file to local but not
- * yet been completed.
+ * Unified result of a {@link MultiTableWriter#write} call. Exposes the resolved table identity, the
+ * change type that was applied, and the bucket the record was routed to.
+ *
+ * <p>Avoids fan-out into {@link AppendResult} / {@link UpsertResult} / {@link DeleteResult} since
+ * the caller of {@code write(...)} does not know the table kind up-front.
+ *
+ * @since 0.7
*/
-interface PendingFetch {
-
- TableBucket tableBucket();
+@PublicEvolving
+public final class WriteResult {
- boolean isCompleted();
+ // currently, it's an empty class, it will be a compatible evolution if it extends
+ // to have offset, timestamp, etc. in the future
- CompletedFetch toCompletedFetch();
}
diff --git a/fluss-client/src/main/java/org/apache/fluss/client/write/AbstractRowLogWriteBatch.java b/fluss-client/src/main/java/org/apache/fluss/client/write/AbstractRowLogWriteBatch.java
index fcb71764a..104f8f29e 100644
--- a/fluss-client/src/main/java/org/apache/fluss/client/write/AbstractRowLogWriteBatch.java
+++ b/fluss-client/src/main/java/org/apache/fluss/client/write/AbstractRowLogWriteBatch.java
@@ -47,18 +47,20 @@ abstract class AbstractRowLogWriteBatch<R> extends WriteBatch {
long tableId,
int bucketId,
PhysicalTablePath physicalTablePath,
+ int schemaId,
+ WriteFormat writeFormat,
long createdMs,
AbstractPagedOutputView outputView,
MemoryLogRecordsRowBuilder<R> recordsBuilder,
String buildErrorMessage) {
- super(tableId, bucketId, physicalTablePath, createdMs);
+ super(tableId, bucketId, physicalTablePath, schemaId, writeFormat, createdMs);
this.outputView = outputView;
this.recordsBuilder = recordsBuilder;
this.buildErrorMessage = buildErrorMessage;
}
@Override
- public boolean tryAppend(WriteRecord writeRecord, WriteCallback callback) throws Exception {
+ boolean tryAppendRecord(WriteRecord writeRecord, WriteCallback callback) throws Exception {
checkNotNull(callback, "write callback must be not null");
InternalRow rowObj = writeRecord.getRow();
checkNotNull(rowObj, "row must not be null for log record");
diff --git a/fluss-client/src/main/java/org/apache/fluss/client/write/ArrowLogWriteBatch.java b/fluss-client/src/main/java/org/apache/fluss/client/write/ArrowLogWriteBatch.java
index a800666bc..a6894b98b 100644
--- a/fluss-client/src/main/java/org/apache/fluss/client/write/ArrowLogWriteBatch.java
+++ b/fluss-client/src/main/java/org/apache/fluss/client/write/ArrowLogWriteBatch.java
@@ -60,7 +60,7 @@ public class ArrowLogWriteBatch extends WriteBatch {
AbstractPagedOutputView outputView,
long createdMs,
@Nullable LogRecordBatchStatisticsCollector statisticsCollector) {
- super(tableId, bucketId, physicalTablePath, createdMs);
+ super(tableId, bucketId, physicalTablePath, schemaId, WriteFormat.ARROW_LOG, createdMs);
this.outputView = outputView;
this.recordsBuilder =
MemoryLogRecordsArrowBuilder.builder(
@@ -73,7 +73,7 @@ public class ArrowLogWriteBatch extends WriteBatch {
}
@Override
- public boolean tryAppend(WriteRecord writeRecord, WriteCallback callback) throws Exception {
+ boolean tryAppendRecord(WriteRecord writeRecord, WriteCallback callback) throws Exception {
InternalRow row = writeRecord.getRow();
checkArgument(
writeRecord.getTargetColumns() == null,
diff --git a/fluss-client/src/main/java/org/apache/fluss/client/write/CompactedLogWriteBatch.java b/fluss-client/src/main/java/org/apache/fluss/client/write/CompactedLogWriteBatch.java
index 9b7cee9a7..81bedff0f 100644
--- a/fluss-client/src/main/java/org/apache/fluss/client/write/CompactedLogWriteBatch.java
+++ b/fluss-client/src/main/java/org/apache/fluss/client/write/CompactedLogWriteBatch.java
@@ -51,6 +51,8 @@ public final class CompactedLogWriteBatch extends AbstractRowLogWriteBatch<Compa
tableId,
bucketId,
physicalTablePath,
+ schemaId,
+ WriteFormat.COMPACTED_LOG,
createdMs,
outputView,
MemoryLogRecordsCompactedBuilder.builder(schemaId, writeLimit, outputView, true),
diff --git a/fluss-client/src/main/java/org/apache/fluss/client/write/IndexedLogWriteBatch.java b/fluss-client/src/main/java/org/apache/fluss/client/write/IndexedLogWriteBatch.java
index dc408a384..2bb496cbf 100644
--- a/fluss-client/src/main/java/org/apache/fluss/client/write/IndexedLogWriteBatch.java
+++ b/fluss-client/src/main/java/org/apache/fluss/client/write/IndexedLogWriteBatch.java
@@ -50,6 +50,8 @@ public final class IndexedLogWriteBatch extends AbstractRowLogWriteBatch<Indexed
tableId,
bucketId,
physicalTablePath,
+ schemaId,
+ WriteFormat.INDEXED_LOG,
createdMs,
outputView,
MemoryLogRecordsIndexedBuilder.builder(schemaId, writeLimit, outputView, true),
diff --git a/fluss-client/src/main/java/org/apache/fluss/client/write/KvWriteBatch.java b/fluss-client/src/main/java/org/apache/fluss/client/write/KvWriteBatch.java
index 814df03f7..4ca01e133 100644
--- a/fluss-client/src/main/java/org/apache/fluss/client/write/KvWriteBatch.java
+++ b/fluss-client/src/main/java/org/apache/fluss/client/write/KvWriteBatch.java
@@ -51,7 +51,6 @@ public class KvWriteBatch extends WriteBatch {
private final AbstractPagedOutputView outputView;
private final KvRecordBatchBuilder recordsBuilder;
private final @Nullable int[] targetColumns;
- private final int schemaId;
private final MergeMode mergeMode;
public KvWriteBatch(
@@ -65,12 +64,17 @@ public class KvWriteBatch extends WriteBatch {
@Nullable int[] targetColumns,
MergeMode mergeMode,
long createdMs) {
- super(tableId, bucketId, physicalTablePath, createdMs);
+ super(
+ tableId,
+ bucketId,
+ physicalTablePath,
+ schemaId,
+ WriteFormat.fromKvFormat(kvFormat),
+ createdMs);
this.outputView = outputView;
this.recordsBuilder =
KvRecordBatchBuilder.builder(schemaId, writeLimit, outputView, kvFormat);
this.targetColumns = targetColumns;
- this.schemaId = schemaId;
this.mergeMode = mergeMode;
}
@@ -80,14 +84,7 @@ public class KvWriteBatch extends WriteBatch {
}
@Override
- public boolean tryAppend(WriteRecord writeRecord, WriteCallback callback) throws Exception {
- if (schemaId != writeRecord.getSchemaId()) {
- throw new IllegalStateException(
- String.format(
- "schema id %d of the write record to append is not the same as the current schema id %d in the batch.",
- writeRecord.getSchemaId(), schemaId));
- }
-
+ boolean tryAppendRecord(WriteRecord writeRecord, WriteCallback callback) throws Exception {
// currently, we throw exception directly when the target columns of the write record is
// not the same as the current target columns in the batch.
// this should be quite fast as they should be the same objects.
diff --git a/fluss-client/src/main/java/org/apache/fluss/client/write/RecordAccumulator.java b/fluss-client/src/main/java/org/apache/fluss/client/write/RecordAccumulator.java
index 7a0e0f5dd..8013f2ac8 100644
--- a/fluss-client/src/main/java/org/apache/fluss/client/write/RecordAccumulator.java
+++ b/fluss-client/src/main/java/org/apache/fluss/client/write/RecordAccumulator.java
@@ -695,6 +695,9 @@ public final class RecordAccumulator {
if (last != null) {
boolean success = last.tryAppend(writeRecord, callback);
if (!success) {
+ // The last batch is either full/closed or belongs to a different table/write
+ // format/schema. Close it so the incoming record rolls over to a compatible new
+ // batch.
// TODO For ArrowLogWriteBatch, close here is a heavy operation (including build
// logic), we need to avoid do that in an lock which locked dq. However, why we not
// remove build logic out of close for ArrowLogWriteBatch is that we want to release
diff --git a/fluss-client/src/main/java/org/apache/fluss/client/write/Sender.java b/fluss-client/src/main/java/org/apache/fluss/client/write/Sender.java
index a8e62cd37..e16754763 100644
--- a/fluss-client/src/main/java/org/apache/fluss/client/write/Sender.java
+++ b/fluss-client/src/main/java/org/apache/fluss/client/write/Sender.java
@@ -91,6 +91,9 @@ public class Sender implements Runnable {
/** true when the caller wants to ignore all unsent/inflight messages and force close. */
private volatile boolean forceClose;
+ private final Object wakeupLock = new Object();
+ private boolean wakeup;
+
/**
* A per-bucket queue of batches ordered by creation time for tracking the in-flight batches.
*/
@@ -236,8 +239,7 @@ public class Sender implements Runnable {
// TODO The method sendWriteData is in a busy loop. If there is no data continuously, it
// will cause the CPU to be occupied.
// In the future, we need to introduce delay logic to deal with it.
- // TODO: condition waiter
- Thread.sleep(readyCheckResult.nextReadyCheckDelayMs);
+ awaitNextReadyCheck(readyCheckResult.nextReadyCheckDelayMs);
}
// get the list of batches prepare to send.
@@ -644,6 +646,27 @@ public class Sender implements Runnable {
// breaking from the sender loop. Otherwise, we may miss some callbacks when shutting down.
accumulator.close();
running = false;
+ wakeup();
+ }
+
+ /** Wake up the sender if it is waiting for the next ready check. */
+ public void wakeup() {
+ synchronized (wakeupLock) {
+ wakeup = true;
+ wakeupLock.notifyAll();
+ }
+ }
+
+ private void awaitNextReadyCheck(long delayMs) throws InterruptedException {
+ if (delayMs <= 0) {
+ return;
+ }
+ synchronized (wakeupLock) {
+ if (!wakeup) {
+ wakeupLock.wait(delayMs);
+ }
+ wakeup = false;
+ }
}
/**
diff --git a/fluss-client/src/main/java/org/apache/fluss/client/write/WriteBatch.java b/fluss-client/src/main/java/org/apache/fluss/client/write/WriteBatch.java
index 9702bb543..754f9caa7 100644
--- a/fluss-client/src/main/java/org/apache/fluss/client/write/WriteBatch.java
+++ b/fluss-client/src/main/java/org/apache/fluss/client/write/WriteBatch.java
@@ -47,6 +47,8 @@ public abstract class WriteBatch {
private final PhysicalTablePath physicalTablePath;
private final RequestFuture requestFuture;
private final long tableId;
+ private final int schemaId;
+ private final WriteFormat writeFormat;
private final int bucketId;
protected final List<WriteCallback> callbacks = new ArrayList<>();
@@ -57,10 +59,17 @@ public abstract class WriteBatch {
private long drainedMs;
public WriteBatch(
- long tableId, int bucketId, PhysicalTablePath physicalTablePath, long createdMs) {
+ long tableId,
+ int bucketId,
+ PhysicalTablePath physicalTablePath,
+ int schemaId,
+ WriteFormat writeFormat,
+ long createdMs) {
this.physicalTablePath = physicalTablePath;
this.createdMs = createdMs;
this.tableId = tableId;
+ this.schemaId = schemaId;
+ this.writeFormat = checkNotNull(writeFormat, "write format must be not null");
this.bucketId = bucketId;
this.requestFuture = new RequestFuture();
this.recordCount = 0;
@@ -79,10 +88,18 @@ public abstract class WriteBatch {
*
* @param writeRecord the record to write
* @param callback the callback to send back to writer
- * @return true if append success, false if the batch is full.
+ * @return true if append success, false if the batch is full or belongs to a different table,
+ * write format or schema.
*/
- public abstract boolean tryAppend(WriteRecord writeRecord, WriteCallback callback)
- throws Exception;
+ public final boolean tryAppend(WriteRecord writeRecord, WriteCallback callback)
+ throws Exception {
+ checkNotNull(writeRecord, "write record must be not null");
+ checkNotNull(callback, "write callback must be not null");
+ if (!canAppend(writeRecord)) {
+ return false;
+ }
+ return tryAppendRecord(writeRecord, callback);
+ }
/**
* Gets the memory segment bytes view of the batch. This includes the latest updated {@link
@@ -164,6 +181,14 @@ public abstract class WriteBatch {
return tableId;
}
+ int schemaId() {
+ return schemaId;
+ }
+
+ WriteFormat writeFormat() {
+ return writeFormat;
+ }
+
public PhysicalTablePath physicalTablePath() {
return physicalTablePath;
}
@@ -243,6 +268,18 @@ public abstract class WriteBatch {
return finalState.get() != null;
}
+ abstract boolean tryAppendRecord(WriteRecord writeRecord, WriteCallback callback)
+ throws Exception;
+
+ private boolean canAppend(WriteRecord writeRecord) {
+ // a schema id mismatch is a batch-compatibility boundary like table id and write format:
+ // returning false makes the accumulator close this batch and roll a new one carrying the
+ // record's schema id, so a single batch never mixes schemas.
+ return tableId == writeRecord.getTableInfo().getTableId()
+ && writeFormat == writeRecord.getWriteFormat()
+ && schemaId == writeRecord.getSchemaId();
+ }
+
/**
* Finalize the state of a batch. Final state, once set, is immutable. This function may be
* called twice when an inflight batch expires before a response from the tablet server is
diff --git a/fluss-client/src/main/java/org/apache/fluss/client/write/WriterClient.java b/fluss-client/src/main/java/org/apache/fluss/client/write/WriterClient.java
index ff8b9232f..9d1a76c75 100644
--- a/fluss-client/src/main/java/org/apache/fluss/client/write/WriterClient.java
+++ b/fluss-client/src/main/java/org/apache/fluss/client/write/WriterClient.java
@@ -92,6 +92,14 @@ public class WriterClient {
MetadataUpdater metadataUpdater,
ClientMetricGroup clientMetricGroup,
Admin admin) {
+ this(conf, metadataUpdater, new WriterMetricGroup(clientMetricGroup), admin);
+ }
+
+ public WriterClient(
+ Configuration conf,
+ MetadataUpdater metadataUpdater,
+ WriterMetricGroup writerMetricGroup,
+ Admin admin) {
int maxRequestSizeLocal = -1;
IdempotenceManager idempotenceManagerLocal = null;
try {
@@ -100,9 +108,9 @@ public class WriterClient {
maxRequestSizeLocal =
(int) conf.get(ConfigOptions.CLIENT_WRITER_REQUEST_MAX_SIZE).getBytes();
this.maxRequestSize = maxRequestSizeLocal;
+ this.writerMetricGroup = writerMetricGroup;
idempotenceManagerLocal = buildIdempotenceManager();
this.idempotenceManager = idempotenceManagerLocal;
- this.writerMetricGroup = new WriterMetricGroup(clientMetricGroup);
short acks = configureAcks(idempotenceManager.idempotenceEnabled());
int retries = configureRetries(idempotenceManager.idempotenceEnabled());
@@ -156,6 +164,7 @@ public class WriterClient {
LOG.trace("Flushing accumulated records in writer.");
long start = System.currentTimeMillis();
accumulator.beginFlush();
+ sender.wakeup();
try {
accumulator.awaitFlushCompletion();
} catch (InterruptedException e) {
@@ -212,7 +221,7 @@ public class WriterClient {
"Waking up the sender since table {} bucket {} is either full or getting a new batch",
record.getPhysicalTablePath(),
bucketId);
- // TODO add the wakeup logic refer to Kafka.
+ sender.wakeup();
}
} catch (Exception e) {
throw new FlussRuntimeException(
diff --git a/fluss-client/src/test/java/org/apache/fluss/client/metrics/WriterMetricGroupTest.java b/fluss-client/src/test/java/org/apache/fluss/client/metrics/WriterMetricGroupTest.java
new file mode 100644
index 000000000..fc1c3d4ad
--- /dev/null
+++ b/fluss-client/src/test/java/org/apache/fluss/client/metrics/WriterMetricGroupTest.java
@@ -0,0 +1,122 @@
+/*
+ * 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.fluss.client.metrics;
+
+import org.apache.fluss.config.Configuration;
+import org.apache.fluss.metrics.CharacterFilter;
+import org.apache.fluss.metrics.Metric;
+import org.apache.fluss.metrics.groups.MetricGroup;
+import org.apache.fluss.metrics.registry.MetricRegistryImpl;
+import org.apache.fluss.metrics.reporter.MetricReporter;
+import org.apache.fluss.rpc.metrics.ClientMetricGroup;
+
+import org.junit.jupiter.api.Test;
+
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+import java.util.TreeMap;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+/** Test for {@link WriterMetricGroup} and {@link MultiTableWriterMetricGroup}. */
+class WriterMetricGroupTest {
+
+ private static final String WRITER_INSTANCE_ID = "writer_instance_id";
+
+ @Test
+ void testSingleTableWriters() throws Exception {
+ try (MetricTestContext context = new MetricTestContext()) {
+ WriterMetricGroup writer = new WriterMetricGroup(context.clientMetricGroup);
+
+ assertThat(logicalScope(writer)).isEqualTo("client.writer");
+ assertThat(writer.getAllVariables()).containsOnlyKeys("client_id");
+ }
+ }
+
+ @Test
+ void testMultiTableWriter() throws Exception {
+ try (MetricTestContext context = new MetricTestContext()) {
+ WriterMetricGroup sharedWriter = new WriterMetricGroup(context.clientMetricGroup);
+ WriterMetricGroup multiWriter1 =
+ new MultiTableWriterMetricGroup(context.clientMetricGroup);
+ WriterMetricGroup multiWriter2 =
+ new MultiTableWriterMetricGroup(context.clientMetricGroup);
+ String instanceId1 = multiWriter1.getAllVariables().get(WRITER_INSTANCE_ID);
+ String instanceId2 = multiWriter2.getAllVariables().get(WRITER_INSTANCE_ID);
+
+ assertThat(logicalScope(multiWriter1)).isEqualTo("client.multi_table_writer");
+ assertThat(sharedWriter.getAllVariables()).doesNotContainKey(WRITER_INSTANCE_ID);
+ assertThat(instanceId1).isNotNull();
+ assertThat(instanceId2).isNotNull().isNotEqualTo(instanceId1);
+ assertThat(context.reporter.added).doesNotHaveDuplicates();
+
+ multiWriter1.close();
+
+ assertThat(context.reporter.removed).isNotEmpty();
+ assertThat(context.reporter.removed)
+ .allSatisfy(
+ identity ->
+ assertThat(identity)
+ .contains(WRITER_INSTANCE_ID + "=" + instanceId1));
+ }
+ }
+
+ private static String logicalScope(MetricGroup group) {
+ return group.getLogicalScope(CharacterFilter.NO_OP_FILTER, '.');
+ }
+
+ private static final class MetricTestContext implements AutoCloseable {
+ final RecordingReporter reporter = new RecordingReporter();
+ final MetricRegistryImpl registry =
+ new MetricRegistryImpl(Collections.singletonList(reporter));
+ final ClientMetricGroup clientMetricGroup = new ClientMetricGroup(registry, "test-client");
+
+ @Override
+ public void close() throws Exception {
+ registry.closeAsync().get();
+ }
+ }
+
+ /** Records reporter-facing metric identities: scoped metric name plus variables. */
+ private static final class RecordingReporter implements MetricReporter {
+
+ final List<String> added = new ArrayList<>();
+ final List<String> removed = new ArrayList<>();
+
+ @Override
+ public void open(Configuration config) {}
+
+ @Override
+ public void close() {}
+
+ @Override
+ public void notifyOfAddedMetric(Metric metric, String metricName, MetricGroup group) {
+ added.add(identityOf(metricName, group));
+ }
+
+ @Override
+ public void notifyOfRemovedMetric(Metric metric, String metricName, MetricGroup group) {
+ removed.add(identityOf(metricName, group));
+ }
+
+ private static String identityOf(String metricName, MetricGroup group) {
+ return logicalScope(group) + '.' + metricName + new TreeMap<>(group.getAllVariables());
+ }
+ }
+}
diff --git a/fluss-client/src/test/java/org/apache/fluss/client/table/scanner/log/DefaultCompletedFetchBufferLifecycleTest.java b/fluss-client/src/test/java/org/apache/fluss/client/table/scanner/log/DefaultCompletedFetchBufferLifecycleTest.java
index 090bc932f..6a0943674 100644
--- a/fluss-client/src/test/java/org/apache/fluss/client/table/scanner/log/DefaultCompletedFetchBufferLifecycleTest.java
+++ b/fluss-client/src/test/java/org/apache/fluss/client/table/scanner/log/DefaultCompletedFetchBufferLifecycleTest.java
@@ -33,6 +33,7 @@ import java.util.Map;
import static org.apache.fluss.record.TestData.DATA1;
import static org.apache.fluss.record.TestData.DATA1_ROW_TYPE;
+import static org.apache.fluss.record.TestData.DATA1_TABLE_PATH;
import static org.apache.fluss.record.TestData.DEFAULT_SCHEMA_ID;
import static org.apache.fluss.record.TestData.TEST_SCHEMA_GETTER;
import static org.apache.fluss.testutils.DataTestUtils.genMemoryLogRecordsByObject;
@@ -155,6 +156,7 @@ class DefaultCompletedFetchBufferLifecycleTest {
throws Exception {
return new DefaultCompletedFetch(
tableBucket,
+ DATA1_TABLE_PATH,
new FetchLogResultForBucket(tableBucket, genMemoryLogRecordsByObject(DATA1), 10L),
readContext,
logScannerStatus,
diff --git a/fluss-client/src/test/java/org/apache/fluss/client/table/scanner/log/DefaultCompletedFetchTest.java b/fluss-client/src/test/java/org/apache/fluss/client/table/scanner/log/DefaultCompletedFetchTest.java
index 427f59d4d..f8ccbbae5 100644
--- a/fluss-client/src/test/java/org/apache/fluss/client/table/scanner/log/DefaultCompletedFetchTest.java
+++ b/fluss-client/src/test/java/org/apache/fluss/client/table/scanner/log/DefaultCompletedFetchTest.java
@@ -36,6 +36,7 @@ import org.apache.fluss.row.GenericMap;
import org.apache.fluss.row.GenericRow;
import org.apache.fluss.row.InternalRow;
import org.apache.fluss.rpc.entity.FetchLogResultForBucket;
+import org.apache.fluss.shaded.arrow.org.apache.arrow.memory.ChunkedAllocationManager;
import org.apache.fluss.testutils.InternalRowAssert;
import org.apache.fluss.types.DataTypes;
import org.apache.fluss.types.RowType;
@@ -336,13 +337,16 @@ public class DefaultCompletedFetchTest {
DefaultCompletedFetch defaultCompletedFetch =
new DefaultCompletedFetch(
tb,
+ DATA2_TABLE_PATH,
resultForBucket,
LogRecordReadContext.createReadContext(
tableInfo,
false,
+ LogRecordReadContext.SchemaResolution.DYNAMIC,
null,
new TestingSchemaGetter(
- tableInfo.getSchemaId(), tableInfo.getSchema())),
+ tableInfo.getSchemaId(), tableInfo.getSchema()),
+ new ChunkedAllocationManager.ChunkedFactory()),
logScannerStatus,
true,
fetchOffset,
@@ -391,12 +395,15 @@ public class DefaultCompletedFetchTest {
Projection projection) {
return new DefaultCompletedFetch(
tableBucket,
+ DATA2_TABLE_PATH,
resultForBucket,
LogRecordReadContext.createReadContext(
tableInfo,
false,
+ LogRecordReadContext.SchemaResolution.DYNAMIC,
projection,
- new TestingSchemaGetter(tableInfo.getSchemaId(), tableInfo.getSchema())),
+ new TestingSchemaGetter(tableInfo.getSchemaId(), tableInfo.getSchema()),
+ new ChunkedAllocationManager.ChunkedFactory()),
logScannerStatus,
true,
offset,
diff --git a/fluss-client/src/test/java/org/apache/fluss/client/table/scanner/log/LogFetchBufferTest.java b/fluss-client/src/test/java/org/apache/fluss/client/table/scanner/log/LogFetchBufferTest.java
index abfd0f1d1..c65e9c06c 100644
--- a/fluss-client/src/test/java/org/apache/fluss/client/table/scanner/log/LogFetchBufferTest.java
+++ b/fluss-client/src/test/java/org/apache/fluss/client/table/scanner/log/LogFetchBufferTest.java
@@ -41,6 +41,7 @@ import java.util.concurrent.atomic.AtomicReference;
import static org.apache.fluss.record.TestData.DATA1;
import static org.apache.fluss.record.TestData.DATA1_ROW_TYPE;
+import static org.apache.fluss.record.TestData.DATA1_TABLE_PATH;
import static org.apache.fluss.record.TestData.DEFAULT_SCHEMA_ID;
import static org.apache.fluss.record.TestData.TEST_SCHEMA_GETTER;
import static org.apache.fluss.testutils.DataTestUtils.genMemoryLogRecordsByObject;
@@ -166,6 +167,41 @@ public class LogFetchBufferTest {
}
}
+ @Test
+ void testRetainAllDiscardsRemovedPendingFetches() throws Exception {
+ AtomicBoolean discarded = new AtomicBoolean(false);
+ PendingFetch pendingFetch =
+ new PendingFetch() {
+ @Override
+ public TableBucket tableBucket() {
+ return tableBucket1;
+ }
+
+ @Override
+ public boolean isCompleted() {
+ return false;
+ }
+
+ @Override
+ public CompletedFetch toCompletedFetch() {
+ throw new AssertionError("Pending fetch should be discarded.");
+ }
+
+ @Override
+ public void discard() {
+ discarded.set(true);
+ }
+ };
+
+ try (LogFetchBuffer logFetchBuffer = new LogFetchBuffer()) {
+ logFetchBuffer.pend(pendingFetch);
+ logFetchBuffer.retainAll(Collections.emptySet());
+
+ assertThat(discarded).isTrue();
+ assertThat(logFetchBuffer.pendedBuckets()).isEmpty();
+ }
+ }
+
@Test
void testWakeup() throws Exception {
try (LogFetchBuffer logFetchBuffer = new LogFetchBuffer()) {
@@ -260,6 +296,7 @@ public class LogFetchBufferTest {
private DefaultCompletedFetch makeCompletedFetch(TableBucket tableBucket) throws Exception {
return new DefaultCompletedFetch(
tableBucket,
+ DATA1_TABLE_PATH,
new FetchLogResultForBucket(tableBucket, genMemoryLogRecordsByObject(DATA1), 10L),
readContext,
logScannerStatus,
diff --git a/fluss-client/src/test/java/org/apache/fluss/client/table/scanner/log/LogFetchCollectorTest.java b/fluss-client/src/test/java/org/apache/fluss/client/table/scanner/log/LogFetchCollectorTest.java
index 3b4d47fc1..f5752d74d 100644
--- a/fluss-client/src/test/java/org/apache/fluss/client/table/scanner/log/LogFetchCollectorTest.java
+++ b/fluss-client/src/test/java/org/apache/fluss/client/table/scanner/log/LogFetchCollectorTest.java
@@ -72,8 +72,7 @@ public class LogFetchCollectorTest {
logScannerStatus.assignScanBuckets(scanBuckets);
logFetchBuffer = new LogFetchBuffer();
logFetchCollector =
- new LogFetchCollector(
- DATA1_TABLE_PATH, logScannerStatus, new Configuration(), metadataUpdater);
+ new LogFetchCollector(logScannerStatus, new Configuration(), metadataUpdater);
readContext =
LogRecordReadContext.createArrowReadContext(
DATA1_ROW_TYPE, DEFAULT_SCHEMA_ID, TEST_SCHEMA_GETTER);
@@ -220,7 +219,7 @@ public class LogFetchCollectorTest {
new TestingMetadataUpdater(
Collections.singletonMap(DATA1_TABLE_PATH, DATA1_TABLE_INFO));
LogFetchCollector collector =
- new LogFetchCollector(DATA1_TABLE_PATH, logScannerStatus, conf, metadataUpdater);
+ new LogFetchCollector(logScannerStatus, conf, metadataUpdater);
TableBucket tb = new TableBucket(DATA1_TABLE_ID, 0);
FetchLogResultForBucket result =
@@ -247,7 +246,7 @@ public class LogFetchCollectorTest {
new TestingMetadataUpdater(
Collections.singletonMap(DATA1_TABLE_PATH, DATA1_TABLE_INFO));
LogFetchCollector collector =
- new LogFetchCollector(DATA1_TABLE_PATH, logScannerStatus, conf, metadataUpdater);
+ new LogFetchCollector(logScannerStatus, conf, metadataUpdater);
TableBucket tb = new TableBucket(DATA1_TABLE_ID, 1);
FetchLogResultForBucket filteredEmpty = new FetchLogResultForBucket(tb, 10L, 20L);
@@ -266,7 +265,14 @@ public class LogFetchCollectorTest {
private DefaultCompletedFetch makeCompletedFetch(
TableBucket tableBucket, FetchLogResultForBucket resultForBucket, long offset) {
return new DefaultCompletedFetch(
- tableBucket, resultForBucket, readContext, logScannerStatus, true, offset, null);
+ tableBucket,
+ DATA1_TABLE_PATH,
+ resultForBucket,
+ readContext,
+ logScannerStatus,
+ true,
+ offset,
+ null);
}
@Test
@@ -317,7 +323,7 @@ public class LogFetchCollectorTest {
new TestingMetadataUpdater(
Collections.singletonMap(DATA1_TABLE_PATH, DATA1_TABLE_INFO));
LogFetchCollector collector =
- new LogFetchCollector(DATA1_TABLE_PATH, logScannerStatus, conf, metadataUpdater);
+ new LogFetchCollector(logScannerStatus, conf, metadataUpdater);
TableBucket tb = new TableBucket(DATA1_TABLE_ID, 0);
FetchLogResultForBucket result = new FetchLogResultForBucket(tb, records, 4L);
diff --git a/fluss-client/src/test/java/org/apache/fluss/client/table/scanner/log/LogFetcherFilterITCase.java b/fluss-client/src/test/java/org/apache/fluss/client/table/scanner/log/LogFetcherFilterITCase.java
index ac64fbdbd..df8e090db 100644
--- a/fluss-client/src/test/java/org/apache/fluss/client/table/scanner/log/LogFetcherFilterITCase.java
+++ b/fluss-client/src/test/java/org/apache/fluss/client/table/scanner/log/LogFetcherFilterITCase.java
@@ -24,9 +24,11 @@ import org.apache.fluss.client.table.scanner.RemoteFileDownloader;
import org.apache.fluss.client.table.scanner.ScanRecord;
import org.apache.fluss.config.ConfigOptions;
import org.apache.fluss.metadata.TableBucket;
+import org.apache.fluss.metadata.TableInfo;
import org.apache.fluss.predicate.Predicate;
import org.apache.fluss.predicate.PredicateBuilder;
import org.apache.fluss.record.LogRecordBatchStatisticsTestUtils;
+import org.apache.fluss.record.LogRecordReadContext;
import org.apache.fluss.record.MemoryLogRecords;
import org.apache.fluss.rpc.RpcClient;
import org.apache.fluss.rpc.gateway.TabletServerGateway;
@@ -46,8 +48,8 @@ import java.util.stream.Collectors;
import static org.apache.fluss.record.TestData.DATA1_ROW_TYPE;
import static org.apache.fluss.record.TestData.DATA1_TABLE_DESCRIPTOR;
-import static org.apache.fluss.record.TestData.DATA1_TABLE_INFO;
import static org.apache.fluss.record.TestData.DATA1_TABLE_PATH;
+import static org.apache.fluss.record.TestData.DEFAULT_REMOTE_DATA_DIR;
import static org.apache.fluss.record.TestData.DEFAULT_SCHEMA_ID;
import static org.apache.fluss.record.TestData.TEST_SCHEMA_GETTER;
import static org.apache.fluss.server.testutils.RpcMessageTestUtils.newProduceLogRequest;
@@ -109,15 +111,26 @@ public class LogFetcherFilterITCase extends ClientToServerITCaseBase {
TestingScannerMetricGroup scannerMetricGroup = TestingScannerMetricGroup.newInstance();
logFetcher =
new LogFetcher(
- DATA1_TABLE_INFO,
- null, // projection
- recordBatchFilter, // recordBatchFilter
+ "default-scanner",
logScannerStatus,
clientConf,
metadataUpdater,
scannerMetricGroup,
new RemoteFileDownloader(1),
- TEST_SCHEMA_GETTER);
+ LogRecordReadContext.SchemaResolution.TARGET);
+ logFetcher.registerTable(
+ new TableScanSpec(
+ TableInfo.of(
+ DATA1_TABLE_PATH,
+ tableId,
+ 1,
+ DATA1_TABLE_DESCRIPTOR,
+ DEFAULT_REMOTE_DATA_DIR,
+ System.currentTimeMillis(),
+ System.currentTimeMillis()),
+ null,
+ recordBatchFilter),
+ TEST_SCHEMA_GETTER);
}
@AfterEach
@@ -430,16 +443,29 @@ public class LogFetcherFilterITCase extends ClientToServerITCaseBase {
Predicate recordBatchFilter = builder.greaterThan(0, 5); // a > 5
TestingScannerMetricGroup scannerMetricGroup = TestingScannerMetricGroup.newInstance();
- return new LogFetcher(
- DATA1_TABLE_INFO,
- null,
- recordBatchFilter,
- scannerStatus,
- clientConf,
- metadataUpdater,
- scannerMetricGroup,
- new RemoteFileDownloader(1),
+ LogFetcher logFetcher =
+ new LogFetcher(
+ "default fetcher",
+ scannerStatus,
+ clientConf,
+ metadataUpdater,
+ scannerMetricGroup,
+ new RemoteFileDownloader(1),
+ LogRecordReadContext.SchemaResolution.TARGET);
+ logFetcher.registerTable(
+ new TableScanSpec(
+ TableInfo.of(
+ DATA1_TABLE_PATH,
+ tableId,
+ 1,
+ DATA1_TABLE_DESCRIPTOR,
+ DEFAULT_REMOTE_DATA_DIR,
+ System.currentTimeMillis(),
+ System.currentTimeMillis()),
+ null,
+ recordBatchFilter),
TEST_SCHEMA_GETTER);
+ return logFetcher;
}
private void addRecordsToBucket(TableBucket tableBucket, MemoryLogRecords logRecords)
diff --git a/fluss-client/src/test/java/org/apache/fluss/client/table/scanner/log/LogFetcherITCase.java b/fluss-client/src/test/java/org/apache/fluss/client/table/scanner/log/LogFetcherITCase.java
index 147be7922..d45725776 100644
--- a/fluss-client/src/test/java/org/apache/fluss/client/table/scanner/log/LogFetcherITCase.java
+++ b/fluss-client/src/test/java/org/apache/fluss/client/table/scanner/log/LogFetcherITCase.java
@@ -30,6 +30,7 @@ import org.apache.fluss.metadata.SchemaInfo;
import org.apache.fluss.metadata.TableBucket;
import org.apache.fluss.metadata.TableChange;
import org.apache.fluss.metadata.TableInfo;
+import org.apache.fluss.record.LogRecordReadContext;
import org.apache.fluss.record.MemoryLogRecords;
import org.apache.fluss.row.GenericRow;
import org.apache.fluss.rpc.RpcClient;
@@ -62,6 +63,7 @@ import static org.apache.fluss.record.TestData.DATA1_TABLE_PATH;
import static org.apache.fluss.record.TestData.DATA2;
import static org.apache.fluss.record.TestData.DATA2_ROW_TYPE;
import static org.apache.fluss.record.TestData.DATA2_SCHEMA;
+import static org.apache.fluss.record.TestData.DEFAULT_REMOTE_DATA_DIR;
import static org.apache.fluss.server.testutils.RpcMessageTestUtils.newProduceLogRequest;
import static org.apache.fluss.testutils.DataTestUtils.genMemoryLogRecordsByObject;
import static org.apache.fluss.testutils.common.CommonTestUtils.retry;
@@ -71,6 +73,7 @@ import static org.assertj.core.api.Assertions.assertThat;
public class LogFetcherITCase extends ClientToServerITCaseBase {
private LogFetcher logFetcher;
private long tableId;
+ private TableInfo tableInfo;
private final int bucketId0 = 0;
private final int bucketId1 = 1;
private LogScannerStatus logScannerStatus;
@@ -101,15 +104,23 @@ public class LogFetcherITCase extends ClientToServerITCaseBase {
new ClientSchemaGetter(DATA1_TABLE_PATH, new SchemaInfo(DATA1_SCHEMA, 1), admin);
logFetcher =
new LogFetcher(
- DATA1_TABLE_INFO,
- null,
- null,
+ "default-scanner",
logScannerStatus,
clientConf,
metadataUpdater,
TestingScannerMetricGroup.newInstance(),
new RemoteFileDownloader(1),
- clientSchemaGetter);
+ LogRecordReadContext.SchemaResolution.TARGET);
+ tableInfo =
+ TableInfo.of(
+ DATA1_TABLE_PATH,
+ tableId,
+ 1,
+ DATA1_TABLE_DESCRIPTOR,
+ DEFAULT_REMOTE_DATA_DIR,
+ System.currentTimeMillis(),
+ System.currentTimeMillis());
+ logFetcher.registerTable(new TableScanSpec(tableInfo, null, null), clientSchemaGetter);
}
@Test
@@ -166,6 +177,15 @@ public class LogFetcherITCase extends ClientToServerITCaseBase {
logScannerStatus.assignScanBuckets(Collections.singletonMap(tb0, 0L));
LogFetcher newSchemaLogFetcher =
new LogFetcher(
+ "default-scanner",
+ logScannerStatus,
+ clientConf,
+ metadataUpdater,
+ TestingScannerMetricGroup.newInstance(),
+ new RemoteFileDownloader(1),
+ LogRecordReadContext.SchemaResolution.TARGET);
+ newSchemaLogFetcher.registerTable(
+ new TableScanSpec(
new TableInfo(
DATA1_TABLE_INFO.getTablePath(),
tableId,
@@ -181,13 +201,8 @@ public class LogFetcherITCase extends ClientToServerITCaseBase {
DATA1_TABLE_INFO.getCreatedTime(),
DATA1_TABLE_INFO.getModifiedTime()),
null,
- null,
- logScannerStatus,
- clientConf,
- metadataUpdater,
- TestingScannerMetricGroup.newInstance(),
- new RemoteFileDownloader(1),
- clientSchemaGetter);
+ null),
+ clientSchemaGetter);
newSchemaLogFetcher.sendFetches();
// The fetcher is async to fetch data, so we need to wait the result write to the
// logFetchBuffer.
@@ -279,16 +294,15 @@ public class LogFetcherITCase extends ClientToServerITCaseBase {
LogFetcher logFetcher =
new LogFetcher(
- DATA1_TABLE_INFO,
- null,
- null,
+ DATA1_TABLE_PATH.toString(),
logScannerStatus,
clientConf,
metadataUpdater,
TestingScannerMetricGroup.newInstance(),
new RemoteFileDownloader(1),
- clientSchemaGetter);
+ LogRecordReadContext.SchemaResolution.TARGET);
+ logFetcher.registerTable(new TableScanSpec(tableInfo, null, null), clientSchemaGetter);
// send fetches to fetch data, should have no available fetch.
logFetcher.sendFetches();
assertThat(logFetcher.hasAvailableFetches()).isFalse();
@@ -320,15 +334,14 @@ public class LogFetcherITCase extends ClientToServerITCaseBase {
new FlussAdmin(FLUSS_CLUSTER_EXTENSION.getRpcClient(), metadataUpdater1));
logFetcher =
new LogFetcher(
- DATA1_TABLE_INFO,
- null,
- null,
+ DATA1_TABLE_PATH.toString(),
logScannerStatus,
clientConf,
metadataUpdater1,
TestingScannerMetricGroup.newInstance(),
new RemoteFileDownloader(1),
- clientSchemaGetter);
+ LogRecordReadContext.SchemaResolution.TARGET);
+ logFetcher.registerTable(new TableScanSpec(tableInfo, null, null), clientSchemaGetter);
ExecutorService executor = Executors.newSingleThreadExecutor();
Future<?> future =
diff --git a/fluss-client/src/test/java/org/apache/fluss/client/table/scanner/log/LogFetcherTest.java b/fluss-client/src/test/java/org/apache/fluss/client/table/scanner/log/LogFetcherTest.java
index 0a96b840d..564cbb06e 100644
--- a/fluss-client/src/test/java/org/apache/fluss/client/table/scanner/log/LogFetcherTest.java
+++ b/fluss-client/src/test/java/org/apache/fluss/client/table/scanner/log/LogFetcherTest.java
@@ -29,6 +29,7 @@ import org.apache.fluss.exception.NotLeaderOrFollowerException;
import org.apache.fluss.metadata.PhysicalTablePath;
import org.apache.fluss.metadata.SchemaInfo;
import org.apache.fluss.metadata.TableBucket;
+import org.apache.fluss.record.LogRecordReadContext;
import org.apache.fluss.rpc.entity.FetchLogResultForBucket;
import org.apache.fluss.rpc.messages.FetchLogRequest;
import org.apache.fluss.rpc.messages.FetchLogResponse;
@@ -45,6 +46,7 @@ import org.junit.jupiter.api.Test;
import java.util.Arrays;
import java.util.Collections;
import java.util.HashMap;
+import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.concurrent.CompletableFuture;
@@ -81,25 +83,30 @@ public class LogFetcherTest {
}
private LogFetcher createLogFetcher(Configuration conf) {
- ClientSchemaGetter clientSchemaGetter =
- new TestingClientSchemaGetter(
- DATA1_TABLE_PATH, new SchemaInfo(DATA1_SCHEMA, 0), metadataUpdater, conf);
LogScannerStatus logScannerStatus = initializeLogScannerStatus();
- return new LogFetcher(
- DATA1_TABLE_INFO,
- null,
- null,
- logScannerStatus,
- conf,
- metadataUpdater,
- TestingScannerMetricGroup.newInstance(),
- new RemoteFileDownloader(1),
- clientSchemaGetter);
+ LogFetcher fetcher =
+ new LogFetcher(
+ "default-fetcher",
+ logScannerStatus,
+ conf,
+ metadataUpdater,
+ TestingScannerMetricGroup.newInstance(),
+ new RemoteFileDownloader(1),
+ LogRecordReadContext.SchemaResolution.TARGET);
+ fetcher.registerTable(
+ new TableScanSpec(DATA1_TABLE_INFO, null, null), createSchemaGetter(conf));
+ return fetcher;
+ }
+
+ private ClientSchemaGetter createSchemaGetter(Configuration conf) {
+ return new TestingClientSchemaGetter(
+ DATA1_TABLE_PATH, new SchemaInfo(DATA1_SCHEMA, 0), metadataUpdater, conf);
}
@Test
void sendFetchRequestWithNotLeaderOrFollowerException() {
- Map<Integer, FetchLogRequest> requestMap = logFetcher.prepareFetchLogRequests();
+ List<TableBucket> fetchable = Collections.singletonList(tb1);
+ Map<Integer, FetchLogRequest> requestMap = logFetcher.prepareFetchLogRequests(fetchable);
Set<Integer> serverSet = requestMap.keySet();
assertThat(serverSet).containsExactlyInAnyOrder(1);
@@ -120,9 +127,48 @@ public class LogFetcherTest {
assertThat(metadataUpdater.getBucketLocation(tb1)).isNotPresent();
}
+ @Test
+ void testUnregisterTableDiscardsBufferedFetches() {
+ Map<Integer, FetchLogRequest> requestMap =
+ logFetcher.prepareFetchLogRequests(Collections.singletonList(tb1));
+ logFetcher.sendFetchRequest(1, requestMap.get(1));
+ assertThat(logFetcher.getCompletedFetchesSize()).isEqualTo(1);
+
+ logFetcher.getLogScannerStatus().unassignScanBuckets(Collections.singletonList(tb1));
+ logFetcher.unregisterTable(DATA1_TABLE_ID);
+
+ assertThat(logFetcher.getCompletedFetchesSize()).isZero();
+ assertThat(logFetcher.getRegisteredTableCount()).isZero();
+ }
+
+ @Test
+ void testDiscardStaleResponseAfterTableReregistered() {
+ IOUtils.closeQuietly(logFetcher);
+ DelayedTabletServerGateway delayedGateway = new DelayedTabletServerGateway();
+ metadataUpdater = initializeMetadataUpdater(delayedGateway);
+ Configuration conf = new Configuration();
+ logFetcher = createLogFetcher(conf);
+
+ Map<Integer, FetchLogRequest> requestMap =
+ logFetcher.prepareFetchLogRequests(Collections.singletonList(tb1));
+ logFetcher.sendFetchRequest(1, requestMap.get(1));
+
+ logFetcher.getLogScannerStatus().unassignScanBuckets(Collections.singletonList(tb1));
+ logFetcher.unregisterTable(DATA1_TABLE_ID);
+ logFetcher.registerTable(
+ new TableScanSpec(DATA1_TABLE_INFO, null, null), createSchemaGetter(conf));
+ logFetcher.getLogScannerStatus().assignScanBuckets(Collections.singletonMap(tb1, 0L));
+
+ delayedGateway.completeResponse();
+
+ assertThat(logFetcher.getCompletedFetchesSize()).isZero();
+ assertThat(logFetcher.getRegisteredTableCount()).isEqualTo(1);
+ }
+
@Test
void testPrepareFetchLogRequestWithReadPreference() throws Exception {
- Map<Integer, FetchLogRequest> defaultRequestMap = logFetcher.prepareFetchLogRequests();
+ Map<Integer, FetchLogRequest> defaultRequestMap =
+ logFetcher.prepareFetchLogRequests(Collections.singletonList(tb1));
FetchLogRequest defaultRequest = defaultRequestMap.get(1);
assertThat(defaultRequest.hasReadPreference()).isTrue();
assertThat(defaultRequest.getReadPreference())
@@ -135,7 +181,7 @@ public class LogFetcherTest {
LogFetcher remoteFirstFetcher = createLogFetcher(remoteFirstConf);
try {
Map<Integer, FetchLogRequest> remoteFirstRequestMap =
- remoteFirstFetcher.prepareFetchLogRequests();
+ remoteFirstFetcher.prepareFetchLogRequests(Collections.singletonList(tb1));
FetchLogRequest remoteFirstRequest = remoteFirstRequestMap.get(1);
assertThat(remoteFirstRequest.hasReadPreference()).isTrue();
assertThat(remoteFirstRequest.getReadPreference())
@@ -178,13 +224,32 @@ public class LogFetcherTest {
}
}
+ private static class DelayedTabletServerGateway extends TestingTabletServerGateway {
+ private final CompletableFuture<FetchLogResponse> responseFuture =
+ new CompletableFuture<>();
+ private FetchLogResponse response;
+
+ @Override
+ public CompletableFuture<FetchLogResponse> fetchLog(FetchLogRequest request) {
+ response = super.fetchLog(request).join();
+ return responseFuture;
+ }
+
+ private void completeResponse() {
+ responseFuture.complete(response);
+ }
+ }
+
private TestingMetadataUpdater initializeMetadataUpdater() {
+ return initializeMetadataUpdater(new TestingTabletServerGateway());
+ }
+ private TestingMetadataUpdater initializeMetadataUpdater(TestTabletServerGateway gateway) {
return new TestingMetadataUpdater(
TestingMetadataUpdater.COORDINATOR,
Arrays.asList(NODE1, NODE2, NODE3),
Collections.singletonMap(DATA1_TABLE_PATH, DATA1_TABLE_INFO),
- Collections.singletonMap(1, new TestingTabletServerGateway()),
+ Collections.singletonMap(1, gateway),
new Configuration());
}
}
diff --git a/fluss-client/src/test/java/org/apache/fluss/client/table/scanner/log/LogScannerITCase.java b/fluss-client/src/test/java/org/apache/fluss/client/table/scanner/log/LogScannerITCase.java
index e3f60e2df..7f6700acf 100644
--- a/fluss-client/src/test/java/org/apache/fluss/client/table/scanner/log/LogScannerITCase.java
+++ b/fluss-client/src/test/java/org/apache/fluss/client/table/scanner/log/LogScannerITCase.java
@@ -25,9 +25,11 @@ import org.apache.fluss.client.table.writer.UpsertWriter;
import org.apache.fluss.exception.FetchException;
import org.apache.fluss.metadata.LogFormat;
import org.apache.fluss.metadata.Schema;
+import org.apache.fluss.metadata.TableBucket;
import org.apache.fluss.metadata.TableChange;
import org.apache.fluss.metadata.TableDescriptor;
import org.apache.fluss.metadata.TablePath;
+import org.apache.fluss.predicate.PredicateBuilder;
import org.apache.fluss.record.ArrowBatchData;
import org.apache.fluss.record.ChangeType;
import org.apache.fluss.row.GenericRow;
@@ -92,6 +94,36 @@ public class LogScannerITCase extends ClientToServerITCaseBase {
}
}
+ @Test
+ void testPollReturnsProgressOnlyRecords() throws Exception {
+ TablePath tablePath = TablePath.of("test_db_1", "test_log_scanner_progress_only");
+ TableDescriptor descriptor =
+ TableDescriptor.builder()
+ .schema(DATA1_SCHEMA)
+ .distributedBy(1)
+ .logFormat(LogFormat.ARROW)
+ .build();
+ long tableId = createTable(tablePath, descriptor, false);
+ waitAllReplicasReady(tableId, 1);
+
+ TableBucket tableBucket = new TableBucket(tableId, 0);
+ try (Table table = conn.getTable(tablePath)) {
+ appendRows(table, 1, 5);
+
+ try (LogScanner logScanner =
+ table.newScan()
+ .filter(
+ new PredicateBuilder(DATA1_SCHEMA.getRowType())
+ .greaterThan(0, 5))
+ .createLogScanner()) {
+ logScanner.subscribeFromBeginning(0);
+
+ ScanRecords scanRecords = pollUntilProgressOnly(logScanner, tableBucket);
+ assertThat(scanRecords.consumedUpToOffset(tableBucket)).isGreaterThan(0L);
+ }
+ }
+ }
+
@Test
void testPollWhileCreateTableNotReady() throws Exception {
// create one table with 30 buckets.
@@ -568,4 +600,33 @@ public class LogScannerITCase extends ClientToServerITCaseBase {
}
assertThat(count).isEqualTo(expectedRecords);
}
+
+ private static ScanRecords pollUntilProgressOnly(
+ LogScanner logScanner, TableBucket tableBucket) {
+ long deadline = System.nanoTime() + Duration.ofSeconds(30).toNanos();
+ ScanRecords scanRecords = ScanRecords.EMPTY;
+ while (System.nanoTime() < deadline) {
+ scanRecords = logScanner.poll(Duration.ofSeconds(1));
+ if (scanRecords.isEmpty()
+ && scanRecords.hasProgress()
+ && scanRecords.buckets().contains(tableBucket)) {
+ assertThat(scanRecords.count()).isEqualTo(0);
+ assertThat(scanRecords.records(tableBucket)).isEmpty();
+ return scanRecords;
+ }
+ }
+ assertThat(scanRecords.hasProgress())
+ .as("Timed out waiting for progress-only poll")
+ .isTrue();
+ return scanRecords;
+ }
+
+ private static void appendRows(Table table, int fromInclusive, int toInclusive)
+ throws Exception {
+ AppendWriter writer = table.newAppend().createWriter();
+ for (int i = fromInclusive; i <= toInclusive; i++) {
+ writer.append(row(i, "value-" + i)).get();
+ }
+ writer.flush();
+ }
}
diff --git a/fluss-client/src/test/java/org/apache/fluss/client/table/scanner/log/MultiTableLogScannerITCase.java b/fluss-client/src/test/java/org/apache/fluss/client/table/scanner/log/MultiTableLogScannerITCase.java
new file mode 100644
index 000000000..907c86a74
--- /dev/null
+++ b/fluss-client/src/test/java/org/apache/fluss/client/table/scanner/log/MultiTableLogScannerITCase.java
@@ -0,0 +1,793 @@
+/*
+ * 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.fluss.client.table.scanner.log;
+
+import org.apache.fluss.client.Connection;
+import org.apache.fluss.client.ConnectionFactory;
+import org.apache.fluss.client.FlussConnection;
+import org.apache.fluss.client.admin.ClientToServerITCaseBase;
+import org.apache.fluss.client.metadata.ClientSchemaGetter;
+import org.apache.fluss.client.metrics.TestingScannerMetricGroup;
+import org.apache.fluss.client.table.MultiTable;
+import org.apache.fluss.client.table.Table;
+import org.apache.fluss.client.table.scanner.MultiTableRecord;
+import org.apache.fluss.client.table.writer.AppendWriter;
+import org.apache.fluss.client.table.writer.MultiTableWriteRecord;
+import org.apache.fluss.client.table.writer.MultiTableWriter;
+import org.apache.fluss.config.AutoPartitionTimeUnit;
+import org.apache.fluss.config.ConfigOptions;
+import org.apache.fluss.metadata.LogFormat;
+import org.apache.fluss.metadata.Schema;
+import org.apache.fluss.metadata.TableBucket;
+import org.apache.fluss.metadata.TableChange;
+import org.apache.fluss.metadata.TableDescriptor;
+import org.apache.fluss.metadata.TableInfo;
+import org.apache.fluss.metadata.TablePath;
+import org.apache.fluss.predicate.PredicateBuilder;
+import org.apache.fluss.record.ChangeType;
+import org.apache.fluss.record.LogRecordReadContext;
+import org.apache.fluss.row.GenericRow;
+import org.apache.fluss.row.InternalRow;
+import org.apache.fluss.types.DataTypes;
+import org.apache.fluss.types.RowType;
+
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.Test;
+
+import java.time.Duration;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+
+import static org.apache.fluss.testutils.DataTestUtils.row;
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+
+/** ITCase for {@link MultiTableLogScannerImpl}. */
+public class MultiTableLogScannerITCase extends ClientToServerITCaseBase {
+
+ private static final String TEST_DB = "test_db";
+
+ private static final Schema LOG_SCHEMA =
+ Schema.newBuilder()
+ .column("id", DataTypes.INT())
+ .column("name", DataTypes.STRING())
+ .build();
+
+ private static final Schema LOG_SCHEMA_2 =
+ Schema.newBuilder()
+ .column("key", DataTypes.INT())
+ .column("value", DataTypes.STRING())
+ .column("score", DataTypes.BIGINT())
+ .build();
+
+ @AfterEach
+ protected void teardown() throws Exception {
+ if (admin != null) {
+ admin.dropDatabase(TEST_DB, true, true);
+ }
+ super.teardown();
+ }
+
+ @Test
+ void testSingleTableSubscribeAndPoll() throws Exception {
+ TablePath tablePath = TablePath.of(TEST_DB, "multi_scanner_single");
+ TableDescriptor descriptor =
+ TableDescriptor.builder().schema(LOG_SCHEMA).distributedBy(1).build();
+ long tableId = createTable(tablePath, descriptor, false);
+ waitAllReplicasReady(tableId, 1);
+
+ int recordCount = 10;
+ List<GenericRow> expectedRows = new ArrayList<>();
+ try (Table table = conn.getTable(tablePath)) {
+ AppendWriter writer = table.newAppend().createWriter();
+ for (int i = 0; i < recordCount; i++) {
+ GenericRow r = row(i, "name-" + i);
+ expectedRows.add(r);
+ writer.append(r).get();
+ }
+ }
+
+ MultiTable multiTable = conn.getMultiTable();
+ try (MultiTableLogScanner scanner = multiTable.newMultiTableScan().createLogScanner()) {
+ scanner.subscribeFromBeginning(tablePath, 0);
+
+ List<GenericRow> actualRows = new ArrayList<>();
+ while (actualRows.size() < recordCount) {
+ MultiTableRecords records = scanner.poll(Duration.ofSeconds(1));
+ for (MultiTableRecord record : records) {
+ assertThat(record.getTablePath()).isEqualTo(tablePath);
+ assertThat(record.getTableId()).isEqualTo(tableId);
+ assertThat(record.getSchemaId()).isGreaterThanOrEqualTo(0);
+ assertThat(record.getSchema().getRowType()).isEqualTo(LOG_SCHEMA.getRowType());
+ assertThat(record.getChangeType()).isEqualTo(ChangeType.APPEND_ONLY);
+ InternalRow r = record.getRow();
+ actualRows.add(row(r.getInt(0), r.getString(1)));
+ }
+ }
+
+ assertThat(actualRows).hasSize(recordCount);
+ assertThat(actualRows).containsExactlyInAnyOrderElementsOf(expectedRows);
+ }
+ }
+
+ @Test
+ void testPollReturnsProgressOnlyRecords() throws Exception {
+ TablePath tablePath = TablePath.of(TEST_DB, "multi_scanner_progress_only");
+ TableDescriptor descriptor =
+ TableDescriptor.builder()
+ .schema(LOG_SCHEMA)
+ .distributedBy(1)
+ .logFormat(LogFormat.ARROW)
+ .build();
+ long tableId = createTable(tablePath, descriptor, false);
+ waitAllReplicasReady(tableId, 1);
+
+ try (Table table = conn.getTable(tablePath)) {
+ appendRows(table, 1, 5);
+ }
+
+ TableInfo tableInfo = admin.getTableInfo(tablePath).get();
+ TableBucket tableBucket = new TableBucket(tableId, 0);
+ TestingScannerMetricGroup scannerMetricGroup = TestingScannerMetricGroup.newInstance();
+ LogScannerStatus logScannerStatus = new LogScannerStatus();
+ LogFetcher logFetcher =
+ createTestingLogFetcher(
+ "multi-table-progress-only-scanner", logScannerStatus, scannerMetricGroup);
+ logFetcher.registerTable(
+ new TableScanSpec(
+ tableInfo,
+ null,
+ new PredicateBuilder(LOG_SCHEMA.getRowType()).greaterThan(0, 5)),
+ new ClientSchemaGetter(tablePath, tableInfo.getSchemaInfo(), admin));
+
+ try (MultiTableLogScanner scanner =
+ createTestingScanner(
+ "multi-table-progress-only-scanner",
+ logScannerStatus,
+ logFetcher,
+ scannerMetricGroup)) {
+ scanner.subscribeFromBeginning(tablePath, 0);
+ assertThat(logFetcher.getRegisteredTableCount()).isEqualTo(1);
+
+ MultiTableRecords records = pollUntilProgressOnly(scanner, tablePath, tableBucket);
+ assertThat(records.consumedUpToOffset(tablePath, tableBucket)).isGreaterThan(0L);
+
+ scanner.unsubscribe(tablePath, 0);
+ assertThat(logFetcher.getRegisteredTableCount()).isEqualTo(0);
+ }
+ }
+
+ @Test
+ void testWrongOverloadSubscribeDoesNotRegisterTable() throws Exception {
+ TablePath partitionedPath =
+ TablePath.of(TEST_DB, "multi_scanner_wrong_overload_partitioned");
+ Schema partitionedSchema =
+ Schema.newBuilder()
+ .column("id", DataTypes.INT())
+ .column("pt", DataTypes.STRING())
+ .build();
+ createTable(
+ partitionedPath,
+ TableDescriptor.builder()
+ .schema(partitionedSchema)
+ .distributedBy(1)
+ .partitionedBy("pt")
+ .build(),
+ false);
+
+ TablePath nonPartitionedPath =
+ TablePath.of(TEST_DB, "multi_scanner_wrong_overload_non_partitioned");
+ createTable(
+ nonPartitionedPath,
+ TableDescriptor.builder().schema(LOG_SCHEMA).distributedBy(1).build(),
+ true);
+
+ String scannerName = "multi-table-wrong-overload-scanner";
+ TestingScannerMetricGroup scannerMetricGroup = TestingScannerMetricGroup.newInstance();
+ LogScannerStatus logScannerStatus = new LogScannerStatus();
+ LogFetcher logFetcher =
+ createTestingLogFetcher(scannerName, logScannerStatus, scannerMetricGroup);
+
+ try (MultiTableLogScanner scanner =
+ createTestingScanner(
+ scannerName, logScannerStatus, logFetcher, scannerMetricGroup)) {
+ assertThatThrownBy(() -> scanner.subscribe(partitionedPath, 0, 0L))
+ .isInstanceOf(IllegalStateException.class)
+ .hasMessageContaining("is a partitioned table");
+ assertThat(logFetcher.getRegisteredTableCount()).isEqualTo(0);
+
+ assertThatThrownBy(() -> scanner.subscribe(nonPartitionedPath, 1L, 0, 0L))
+ .isInstanceOf(IllegalStateException.class)
+ .hasMessageContaining("is not a partitioned table");
+ assertThat(logFetcher.getRegisteredTableCount()).isEqualTo(0);
+ }
+ }
+
+ @Test
+ void testMultipleTableSubscribeAndPoll() throws Exception {
+ TablePath tablePath1 = TablePath.of(TEST_DB, "multi_scanner_t1");
+ TablePath tablePath2 = TablePath.of(TEST_DB, "multi_scanner_t2");
+ TableDescriptor descriptor1 =
+ TableDescriptor.builder().schema(LOG_SCHEMA).distributedBy(1).build();
+ TableDescriptor descriptor2 =
+ TableDescriptor.builder().schema(LOG_SCHEMA_2).distributedBy(1).build();
+
+ long tableId1 = createTable(tablePath1, descriptor1, false);
+ long tableId2 = createTable(tablePath2, descriptor2, true);
+ waitAllReplicasReady(tableId1, 1);
+ waitAllReplicasReady(tableId2, 1);
+
+ // Write data to table 1.
+ int recordCount1 = 5;
+ List<GenericRow> expectedRows1 = new ArrayList<>();
+ try (Table table1 = conn.getTable(tablePath1)) {
+ AppendWriter writer = table1.newAppend().createWriter();
+ for (int i = 0; i < recordCount1; i++) {
+ GenericRow r = row(i, "t1-" + i);
+ expectedRows1.add(r);
+ writer.append(r).get();
+ }
+ }
+
+ // Write data to table 2.
+ int recordCount2 = 5;
+ List<GenericRow> expectedRows2 = new ArrayList<>();
+ try (Table table2 = conn.getTable(tablePath2)) {
+ AppendWriter writer = table2.newAppend().createWriter();
+ for (int i = 0; i < recordCount2; i++) {
+ GenericRow r = row(100 + i, "t2-" + i, (long) (i * 10));
+ expectedRows2.add(r);
+ writer.append(r).get();
+ }
+ }
+
+ int totalRecords = recordCount1 + recordCount2;
+ MultiTable multiTable = conn.getMultiTable();
+ try (MultiTableLogScanner scanner = multiTable.newMultiTableScan().createLogScanner()) {
+ scanner.subscribeFromBeginning(tablePath1, 0);
+ scanner.subscribeFromBeginning(tablePath2, 0);
+
+ Map<TablePath, List<GenericRow>> actualRowsByTable = new HashMap<>();
+ int totalPolled = 0;
+ while (totalPolled < totalRecords) {
+ MultiTableRecords records = scanner.poll(Duration.ofSeconds(1));
+ for (TablePath tp : records.tablePaths()) {
+ for (MultiTableRecord record : records.records(tp)) {
+ assertThat(record.getTablePath()).isEqualTo(tp);
+ assertThat(record.getSchemaId()).isGreaterThanOrEqualTo(0);
+ List<GenericRow> rows =
+ actualRowsByTable.computeIfAbsent(tp, k -> new ArrayList<>());
+ InternalRow r = record.getRow();
+ if (tp.equals(tablePath1)) {
+ rows.add(row(r.getInt(0), r.getString(1)));
+ } else {
+ rows.add(row(r.getInt(0), r.getString(1), r.getLong(2)));
+ }
+ }
+ }
+ totalPolled += records.count();
+ }
+
+ assertThat(actualRowsByTable).containsKey(tablePath1);
+ assertThat(actualRowsByTable).containsKey(tablePath2);
+ assertThat(actualRowsByTable.get(tablePath1))
+ .containsExactlyInAnyOrderElementsOf(expectedRows1);
+ assertThat(actualRowsByTable.get(tablePath2))
+ .containsExactlyInAnyOrderElementsOf(expectedRows2);
+ }
+ }
+
+ @Test
+ void testUnsubscribe() throws Exception {
+ TablePath tablePath = TablePath.of(TEST_DB, "multi_scanner_unsub");
+ TableDescriptor descriptor =
+ TableDescriptor.builder().schema(LOG_SCHEMA).distributedBy(2).build();
+ long tableId = createTable(tablePath, descriptor, false);
+ waitAllReplicasReady(tableId, 2);
+
+ // Write data.
+ int recordCount = 10;
+ try (Table table = conn.getTable(tablePath)) {
+ AppendWriter writer = table.newAppend().createWriter();
+ for (int i = 0; i < recordCount; i++) {
+ writer.append(row(i, "v-" + i)).get();
+ }
+ }
+
+ MultiTable multiTable = conn.getMultiTable();
+ try (MultiTableLogScanner scanner = multiTable.newMultiTableScan().createLogScanner()) {
+ scanner.subscribeFromBeginning(tablePath, 0);
+ scanner.subscribeFromBeginning(tablePath, 1);
+
+ // Poll some data to ensure subscription works.
+ int polled = 0;
+ long deadline = System.currentTimeMillis() + 10_000;
+ while (polled < recordCount && System.currentTimeMillis() < deadline) {
+ MultiTableRecords records = scanner.poll(Duration.ofSeconds(1));
+ polled += records.count();
+ }
+ assertThat(polled).isEqualTo(recordCount);
+
+ // Unsubscribe bucket 1 and verify it no longer returns records from bucket 1.
+ scanner.unsubscribe(tablePath, 1);
+
+ // Write more data.
+ try (Table table = conn.getTable(tablePath)) {
+ AppendWriter writer = table.newAppend().createWriter();
+ for (int i = 0; i < 10; i++) {
+ writer.append(row(100 + i, "new-" + i)).get();
+ }
+ }
+
+ // Poll again and collect bucket info - should only see bucket 0.
+ int extraPolled = 0;
+ deadline = System.currentTimeMillis() + 5_000;
+ while (System.currentTimeMillis() < deadline) {
+ MultiTableRecords records = scanner.poll(Duration.ofSeconds(1));
+ for (TableBucket bucket : records.buckets(tablePath)) {
+ assertThat(bucket.getBucket()).isEqualTo(0);
+ assertThat(bucket.getTableId()).isEqualTo(tableId);
+ }
+ extraPolled += records.count();
+ }
+ // We should have polled some records only from bucket 0.
+ assertThat(extraPolled).isGreaterThan(0);
+ }
+ }
+
+ @Test
+ void testPollWithoutSubscription() throws Exception {
+ TablePath tablePath = TablePath.of(TEST_DB, "multi_scanner_no_sub");
+ TableDescriptor descriptor =
+ TableDescriptor.builder().schema(LOG_SCHEMA).distributedBy(1).build();
+ createTable(tablePath, descriptor, false);
+
+ MultiTable multiTable = conn.getMultiTable();
+ try (MultiTableLogScanner scanner = multiTable.newMultiTableScan().createLogScanner()) {
+ assertThatThrownBy(() -> scanner.poll(Duration.ofSeconds(1)))
+ .isInstanceOf(IllegalStateException.class)
+ .hasMessageContaining("not subscribed");
+ }
+ }
+
+ @Test
+ void testPollAfterClose() throws Exception {
+ TablePath tablePath = TablePath.of(TEST_DB, "multi_scanner_closed");
+ TableDescriptor descriptor =
+ TableDescriptor.builder().schema(LOG_SCHEMA).distributedBy(1).build();
+ createTable(tablePath, descriptor, false);
+
+ MultiTable multiTable = conn.getMultiTable();
+ MultiTableLogScanner scanner = multiTable.newMultiTableScan().createLogScanner();
+ scanner.close();
+ assertThatThrownBy(() -> scanner.poll(Duration.ofSeconds(1)))
+ .isInstanceOf(IllegalStateException.class)
+ .hasMessageContaining("closed");
+ }
+
+ @Test
+ void testPartitionedTableSubscribeAndPoll() throws Exception {
+ // Create a partitioned log table with partition key "pt".
+ TablePath tablePath = TablePath.of(TEST_DB, "multi_scanner_partitioned");
+ Schema schema =
+ Schema.newBuilder()
+ .column("id", DataTypes.INT())
+ .column("name", DataTypes.STRING())
+ .column("pt", DataTypes.STRING())
+ .build();
+ TableDescriptor descriptor =
+ TableDescriptor.builder()
+ .schema(schema)
+ .distributedBy(1)
+ .partitionedBy("pt")
+ .property(ConfigOptions.TABLE_AUTO_PARTITION_ENABLED, true)
+ .property(
+ ConfigOptions.TABLE_AUTO_PARTITION_TIME_UNIT,
+ AutoPartitionTimeUnit.YEAR)
+ .build();
+ long tableId = createTable(tablePath, descriptor, false);
+
+ Map<String, Long> partitionIdByNames =
+ FLUSS_CLUSTER_EXTENSION.waitUntilPartitionAllReady(tablePath);
+ assertThat(partitionIdByNames).isNotEmpty();
+
+ // Write data to each partition.
+ int recordsPerPartition = 5;
+ Map<Long, List<GenericRow>> expectedRowsByPartition = new HashMap<>();
+ try (Table table = conn.getTable(tablePath)) {
+ AppendWriter writer = table.newAppend().createWriter();
+ for (Map.Entry<String, Long> entry : partitionIdByNames.entrySet()) {
+ String partitionName = entry.getKey();
+ long partitionId = entry.getValue();
+ for (int i = 0; i < recordsPerPartition; i++) {
+ GenericRow r = row(i, "p-" + i, partitionName);
+ expectedRowsByPartition
+ .computeIfAbsent(partitionId, k -> new ArrayList<>())
+ .add(r);
+ writer.append(r).get();
+ }
+ }
+ }
+
+ int totalRecords = recordsPerPartition * partitionIdByNames.size();
+ // use a new connection to avoid sharing metadata.
+ try (Connection newConnection = ConnectionFactory.createConnection(clientConf);
+ MultiTableLogScanner scanner =
+ newConnection.getMultiTable().newMultiTableScan().createLogScanner()) {
+ // Subscribe to each partition bucket.
+ for (Long partitionId : partitionIdByNames.values()) {
+ scanner.subscribeFromBeginning(tablePath, partitionId, 0);
+ }
+
+ Map<Long, List<GenericRow>> actualRowsByPartition = new HashMap<>();
+ int totalPolled = 0;
+ while (totalPolled < totalRecords) {
+ MultiTableRecords records = scanner.poll(Duration.ofSeconds(1));
+ for (TableBucket bucket : records.buckets(tablePath)) {
+ for (MultiTableRecord record : records.records(tablePath, bucket)) {
+ assertThat(record.getTablePath()).isEqualTo(tablePath);
+ assertThat(record.getTableId()).isEqualTo(tableId);
+ assertThat(record.getSchemaId()).isGreaterThanOrEqualTo(0);
+ assertThat(record.getSchema().getRowType()).isEqualTo(schema.getRowType());
+ assertThat(bucket.getPartitionId()).isNotNull();
+ long partitionId = bucket.getPartitionId();
+ InternalRow r = record.getRow();
+ actualRowsByPartition
+ .computeIfAbsent(partitionId, k -> new ArrayList<>())
+ .add(row(r.getInt(0), r.getString(1), r.getString(2)));
+ }
+ }
+ totalPolled += records.count();
+ }
+
+ // Verify we received records for each partition.
+ for (Map.Entry<Long, List<GenericRow>> entry : expectedRowsByPartition.entrySet()) {
+ long partitionId = entry.getKey();
+ assertThat(actualRowsByPartition).containsKey(partitionId);
+ assertThat(actualRowsByPartition.get(partitionId))
+ .containsExactlyInAnyOrderElementsOf(entry.getValue());
+ }
+ }
+ }
+
+ @Test
+ void testPartitionedAndNonPartitionedMixed() throws Exception {
+ // Table 1: partitioned log table.
+ TablePath tablePath1 = TablePath.of(TEST_DB, "multi_scanner_part_mix1");
+ Schema schema1 =
+ Schema.newBuilder()
+ .column("id", DataTypes.INT())
+ .column("value", DataTypes.STRING())
+ .column("pt", DataTypes.STRING())
+ .build();
+ TableDescriptor descriptor1 =
+ TableDescriptor.builder()
+ .schema(schema1)
+ .distributedBy(1)
+ .partitionedBy("pt")
+ .property(ConfigOptions.TABLE_AUTO_PARTITION_ENABLED, true)
+ .property(
+ ConfigOptions.TABLE_AUTO_PARTITION_TIME_UNIT,
+ AutoPartitionTimeUnit.YEAR)
+ .build();
+ long tableId1 = createTable(tablePath1, descriptor1, false);
+
+ Map<String, Long> partitionIdByNames =
+ FLUSS_CLUSTER_EXTENSION.waitUntilPartitionAllReady(tablePath1);
+ assertThat(partitionIdByNames).isNotEmpty();
+
+ // Table 2: non-partitioned log table.
+ TablePath tablePath2 = TablePath.of("test_db", "multi_scanner_part_mix2");
+ TableDescriptor descriptor2 =
+ TableDescriptor.builder().schema(LOG_SCHEMA).distributedBy(1).build();
+ long tableId2 = createTable(tablePath2, descriptor2, true);
+ waitAllReplicasReady(tableId2, 1);
+
+ // Write data to the partitioned table.
+ int recordsPerPartition = 3;
+ int partitionedRecordCount = recordsPerPartition * partitionIdByNames.size();
+ try (Table table1 = conn.getTable(tablePath1)) {
+ AppendWriter writer = table1.newAppend().createWriter();
+ for (String partitionName : partitionIdByNames.keySet()) {
+ for (int i = 0; i < recordsPerPartition; i++) {
+ writer.append(row(i, "pv-" + i, partitionName)).get();
+ }
+ }
+ }
+
+ // Write data to the non-partitioned table.
+ int nonPartitionedRecordCount = 5;
+ List<GenericRow> expectedNonPartitioned = new ArrayList<>();
+ try (Table table2 = conn.getTable(tablePath2)) {
+ AppendWriter writer = table2.newAppend().createWriter();
+ for (int i = 0; i < nonPartitionedRecordCount; i++) {
+ GenericRow r = row(i, "np-" + i);
+ expectedNonPartitioned.add(r);
+ writer.append(r).get();
+ }
+ }
+
+ int totalRecords = partitionedRecordCount + nonPartitionedRecordCount;
+ MultiTable multiTable = conn.getMultiTable();
+ try (MultiTableLogScanner scanner = multiTable.newMultiTableScan().createLogScanner()) {
+ // Subscribe to partitioned table buckets.
+ for (Long partitionId : partitionIdByNames.values()) {
+ scanner.subscribeFromBeginning(tablePath1, partitionId, 0);
+ }
+ // Subscribe to non-partitioned table bucket.
+ scanner.subscribeFromBeginning(tablePath2, 0);
+
+ List<GenericRow> actualNonPartitioned = new ArrayList<>();
+ int partitionedPolled = 0;
+ int totalPolled = 0;
+ while (totalPolled < totalRecords) {
+ MultiTableRecords records = scanner.poll(Duration.ofSeconds(1));
+ for (MultiTableRecord record : records) {
+ long tid = record.getTableId();
+ InternalRow r = record.getRow();
+ if (tid == tableId1) {
+ partitionedPolled++;
+ } else {
+ assertThat(tid).isEqualTo(tableId2);
+ actualNonPartitioned.add(row(r.getInt(0), r.getString(1)));
+ }
+ }
+ totalPolled += records.count();
+ }
+
+ assertThat(partitionedPolled).isEqualTo(partitionedRecordCount);
+ assertThat(actualNonPartitioned)
+ .containsExactlyInAnyOrderElementsOf(expectedNonPartitioned);
+ }
+ }
+
+ @Test
+ void testMultipleBucketsOfSameTable() throws Exception {
+ TablePath tablePath = TablePath.of(TEST_DB, "multi_scanner_buckets");
+ int bucketCount = 3;
+ TableDescriptor descriptor =
+ TableDescriptor.builder().schema(LOG_SCHEMA).distributedBy(bucketCount).build();
+ long tableId = createTable(tablePath, descriptor, false);
+ waitAllReplicasReady(tableId, bucketCount);
+
+ int recordCount = 30;
+ List<GenericRow> expectedRows = new ArrayList<>();
+ try (Table table = conn.getTable(tablePath)) {
+ AppendWriter writer = table.newAppend().createWriter();
+ for (int i = 0; i < recordCount; i++) {
+ GenericRow r = row(i, "val-" + i);
+ expectedRows.add(r);
+ writer.append(r).get();
+ }
+ }
+
+ MultiTable multiTable = conn.getMultiTable();
+ try (MultiTableLogScanner scanner = multiTable.newMultiTableScan().createLogScanner()) {
+ for (int i = 0; i < bucketCount; i++) {
+ scanner.subscribeFromBeginning(tablePath, i);
+ }
+
+ List<GenericRow> actualRows = new ArrayList<>();
+ while (actualRows.size() < recordCount) {
+ MultiTableRecords records = scanner.poll(Duration.ofSeconds(1));
+ for (MultiTableRecord record : records) {
+ assertThat(record.getTableId()).isEqualTo(tableId);
+ InternalRow r = record.getRow();
+ actualRows.add(row(r.getInt(0), r.getString(1)));
+ }
+ }
+
+ assertThat(actualRows).hasSize(recordCount);
+ assertThat(actualRows).containsExactlyInAnyOrderElementsOf(expectedRows);
+ }
+ }
+
+ @Test
+ void testReadDataBeforeAndAfterAddColumn() throws Exception {
+ TablePath tablePath = TablePath.of(TEST_DB, "multi_scanner_add_col");
+ TableDescriptor descriptor =
+ TableDescriptor.builder().schema(LOG_SCHEMA).distributedBy(1).build();
+ long tableId = createTable(tablePath, descriptor, false);
+ waitAllReplicasReady(tableId, 1);
+
+ // Phase 1: Write data with initial schema (id INT, name STRING).
+ int preAddColumnCount = 5;
+ List<GenericRow> expectedPreRows = new ArrayList<>();
+ try (Table table = conn.getTable(tablePath)) {
+ AppendWriter writer = table.newAppend().createWriter();
+ for (int i = 0; i < preAddColumnCount; i++) {
+ GenericRow r = row(i, "before-" + i);
+ expectedPreRows.add(r);
+ writer.append(r).get();
+ }
+ }
+
+ // Add a new column "score BIGINT" to the table.
+ admin.alterTable(
+ tablePath,
+ Collections.singletonList(
+ TableChange.addColumn(
+ "score",
+ DataTypes.BIGINT(),
+ null,
+ TableChange.ColumnPosition.last())),
+ false)
+ .get();
+
+ // Phase 2: Write data with new schema (id INT, name STRING, score BIGINT).
+ int postAddColumnCount = 5;
+ List<GenericRow> expectedPostRows = new ArrayList<>();
+ try (Table table = conn.getTable(tablePath)) {
+ AppendWriter writer = table.newAppend().createWriter();
+ for (int i = 0; i < postAddColumnCount; i++) {
+ GenericRow r = row(100 + i, "after-" + i, (long) (i * 10));
+ expectedPostRows.add(r);
+ writer.append(r).get();
+ }
+ }
+
+ // Subscribe from the beginning and read all records.
+ int totalRecords = preAddColumnCount + postAddColumnCount;
+ MultiTable multiTable = conn.getMultiTable();
+ try (MultiTableLogScanner scanner = multiTable.newMultiTableScan().createLogScanner()) {
+ scanner.subscribeFromBeginning(tablePath, 0);
+
+ List<GenericRow> actualPreRows = new ArrayList<>();
+ List<GenericRow> actualPostRows = new ArrayList<>();
+ int totalPolled = 0;
+ while (totalPolled < totalRecords) {
+ MultiTableRecords records = scanner.poll(Duration.ofSeconds(1));
+ for (MultiTableRecord record : records) {
+ assertThat(record.getTableId()).isEqualTo(tableId);
+ InternalRow r = record.getRow();
+ RowType rowType = record.getSchema().getRowType();
+ if (rowType.getFieldCount() == 2) {
+ actualPreRows.add(row(r.getInt(0), r.getString(1)));
+ } else {
+ assertThat(rowType.getFieldCount()).isEqualTo(3);
+ actualPostRows.add(row(r.getInt(0), r.getString(1), r.getLong(2)));
+ }
+ totalPolled++;
+ }
+ }
+
+ assertThat(actualPreRows).containsExactlyInAnyOrderElementsOf(expectedPreRows);
+ assertThat(actualPostRows).containsExactlyInAnyOrderElementsOf(expectedPostRows);
+ }
+ }
+
+ @Test
+ void testSubscribeAfterDropRecreateFailsThenRecovers() throws Exception {
+ // A table that is dropped and recreated under the same TablePath gets a new tableId. A
+ // scanner that already subscribed the original table caches the old tableId; re-subscribing
+ // the same path must detect the tableId change (once the shared connection metadata learns
+ // the new id) and fail fast. Recovery: unsubscribe all stale buckets (which evicts the
+ // cached registration) and subscribe again, which re-resolves the recreated table.
+ TablePath tablePath = TablePath.of(TEST_DB, "multi_scanner_drop_recreate");
+ TableDescriptor descriptor =
+ TableDescriptor.builder().schema(LOG_SCHEMA).distributedBy(1).build();
+ long oldTableId = createTable(tablePath, descriptor, false);
+ waitAllReplicasReady(oldTableId, 1);
+
+ MultiTable multiTable = conn.getMultiTable();
+ try (MultiTableLogScanner scanner = multiTable.newMultiTableScan().createLogScanner()) {
+ // 1. Subscribe to the original table; the scanner caches the old tableId.
+ scanner.subscribeFromBeginning(tablePath, 0);
+
+ // 2. Drop and recreate the same path -> new tableId.
+ admin.dropTable(tablePath, false).get();
+ long newTableId = createTable(tablePath, descriptor, true);
+ waitAllReplicasReady(newTableId, 1);
+ assertThat(newTableId).isNotEqualTo(oldTableId);
+
+ // 3. Write a row to the recreated table through a writer on the SAME connection. This
+ // forces the shared connection metadata to learn the new tableId, which is the
+ // state the scanner inspects when validating its cached registration.
+ GenericRow recreatedRow = row(1, "after-recreate");
+ try (MultiTableWriter writer = multiTable.newMultiTableWrite().createWriter()) {
+ int newSid = admin.getTableInfo(tablePath).get().getSchemaId();
+ writer.write(MultiTableWriteRecord.forAppend(tablePath, recreatedRow, newSid))
+ .get();
+ writer.flush();
+ }
+
+ // 4. Re-subscribing the stale table now detects the recreate and fails fast.
+ assertThatThrownBy(() -> scanner.subscribeFromBeginning(tablePath, 0))
+ .isInstanceOf(IllegalStateException.class)
+ .hasMessageContaining("has been recreated")
+ .hasMessageContaining(String.valueOf(oldTableId))
+ .hasMessageContaining(String.valueOf(newTableId));
+
+ // 5. Recovery: unsubscribe the stale bucket (evicts the cached registration), then
+ // subscribe again -> re-resolves the recreated table and reads its data.
+ scanner.unsubscribe(tablePath, 0);
+ scanner.subscribeFromBeginning(tablePath, 0);
+
+ List<GenericRow> actualRows = new ArrayList<>();
+ long deadline = System.currentTimeMillis() + 30_000;
+ while (actualRows.isEmpty() && System.currentTimeMillis() < deadline) {
+ MultiTableRecords records = scanner.poll(Duration.ofSeconds(1));
+ for (MultiTableRecord record : records) {
+ assertThat(record.getTableId()).isEqualTo(newTableId);
+ InternalRow r = record.getRow();
+ actualRows.add(row(r.getInt(0), r.getString(1)));
+ }
+ }
+ assertThat(actualRows).containsExactly(recreatedRow);
+ }
+ }
+
+ private static MultiTableRecords pollUntilProgressOnly(
+ MultiTableLogScanner scanner, TablePath tablePath, TableBucket tableBucket) {
+ long deadline = System.nanoTime() + Duration.ofSeconds(30).toNanos();
+ while (System.nanoTime() < deadline) {
+ MultiTableRecords records = scanner.poll(Duration.ofSeconds(1));
+ if (records.isEmpty() && records.hasProgress()) {
+ // A progress-only result must still surface the table and bucket that advanced.
+ assertThat(records.tablePaths()).contains(tablePath);
+ assertThat(records.buckets(tablePath)).contains(tableBucket);
+ assertThat(records.count()).isEqualTo(0);
+ assertThat(records.records(tablePath, tableBucket)).isEmpty();
+ return records;
+ }
+ }
+ throw new AssertionError(
+ "Timed out waiting for a progress-only poll for bucket " + tableBucket);
+ }
+
+ private LogFetcher createTestingLogFetcher(
+ String scannerName,
+ LogScannerStatus logScannerStatus,
+ TestingScannerMetricGroup scannerMetricGroup) {
+ FlussConnection flussConnection = (FlussConnection) conn;
+ return new LogFetcher(
+ scannerName,
+ logScannerStatus,
+ clientConf,
+ flussConnection.getMetadataUpdater(),
+ scannerMetricGroup,
+ flussConnection.getOrCreateRemoteFileDownloader(),
+ LogRecordReadContext.SchemaResolution.DYNAMIC);
+ }
+
+ private MultiTableLogScanner createTestingScanner(
+ String scannerName,
+ LogScannerStatus logScannerStatus,
+ LogFetcher logFetcher,
+ TestingScannerMetricGroup scannerMetricGroup) {
+ return new MultiTableLogScannerImpl(
+ scannerName,
+ ((FlussConnection) conn).getMetadataUpdater(),
+ admin,
+ logScannerStatus,
+ logFetcher,
+ scannerMetricGroup);
+ }
+
+ private static void appendRows(Table table, int fromInclusive, int toInclusive)
+ throws Exception {
+ AppendWriter writer = table.newAppend().createWriter();
+ for (int i = fromInclusive; i <= toInclusive; i++) {
+ writer.append(row(i, "value-" + i)).get();
+ }
+ writer.flush();
+ }
+}
diff --git a/fluss-client/src/test/java/org/apache/fluss/client/table/scanner/log/RemoteCompletedFetchTest.java b/fluss-client/src/test/java/org/apache/fluss/client/table/scanner/log/RemoteCompletedFetchTest.java
index e5fe7290c..c01ea7e2f 100644
--- a/fluss-client/src/test/java/org/apache/fluss/client/table/scanner/log/RemoteCompletedFetchTest.java
+++ b/fluss-client/src/test/java/org/apache/fluss/client/table/scanner/log/RemoteCompletedFetchTest.java
@@ -32,6 +32,7 @@ import org.apache.fluss.record.LogRecordReadContext;
import org.apache.fluss.record.TestingSchemaGetter;
import org.apache.fluss.remote.RemoteLogSegment;
import org.apache.fluss.row.InternalRow;
+import org.apache.fluss.shaded.arrow.org.apache.arrow.memory.ChunkedAllocationManager;
import org.apache.fluss.types.DataTypes;
import org.apache.fluss.types.RowType;
import org.apache.fluss.utils.Projection;
@@ -298,9 +299,16 @@ class RemoteCompletedFetchTest {
Runnable recycle) {
return new RemoteCompletedFetch(
tableBucket,
+ DATA2_TABLE_PATH,
fileLogRecords,
10L,
- LogRecordReadContext.createReadContext(tableInfo, true, projection, schemaGetter),
+ LogRecordReadContext.createReadContext(
+ tableInfo,
+ true,
+ LogRecordReadContext.SchemaResolution.DYNAMIC,
+ projection,
+ schemaGetter,
+ new ChunkedAllocationManager.ChunkedFactory()),
logScannerStatus,
true,
fetchOffset,
diff --git a/fluss-client/src/test/java/org/apache/fluss/client/table/scanner/log/RemoteLogDownloaderTest.java b/fluss-client/src/test/java/org/apache/fluss/client/table/scanner/log/RemoteLogDownloaderTest.java
index 4c2bcdaa1..77f6dc998 100644
--- a/fluss-client/src/test/java/org/apache/fluss/client/table/scanner/log/RemoteLogDownloaderTest.java
+++ b/fluss-client/src/test/java/org/apache/fluss/client/table/scanner/log/RemoteLogDownloaderTest.java
@@ -87,7 +87,11 @@ class RemoteLogDownloaderTest {
RemoteFileDownloader remoteFileDownloader = new RemoteFileDownloader(1);
RemoteLogDownloader remoteLogDownloader =
new RemoteLogDownloader(
- DATA1_TABLE_PATH, conf, remoteFileDownloader, scannerMetricGroup, 10L);
+ DATA1_TABLE_PATH.toString(),
+ conf,
+ remoteFileDownloader,
+ scannerMetricGroup,
+ 10L);
try {
// trigger auto download.
remoteLogDownloader.start();
@@ -144,6 +148,124 @@ class RemoteLogDownloaderTest {
}
}
+ @Test
+ void testDiscardQueuedDownload() throws Exception {
+ RemoteFileDownloader fileDownloader = new RemoteFileDownloader(1);
+ RemoteLogDownloader downloader =
+ new RemoteLogDownloader(
+ DATA1_TABLE_PATH.toString(), conf, fileDownloader, scannerMetricGroup, 10L);
+ try {
+ TableBucket tableBucket = new TableBucket(DATA1_TABLE_ID, 0);
+ RemoteLogSegment segment =
+ buildRemoteLogSegmentList(tableBucket, DATA1_PHYSICAL_TABLE_PATH, 1, conf, 10)
+ .get(0);
+ FsPath tabletDir =
+ remoteLogTabletDir(remoteLogDir, DATA1_PHYSICAL_TABLE_PATH, tableBucket);
+
+ RemoteLogDownloadFuture future = downloader.requestRemoteLog(tabletDir, segment);
+ future.discard();
+ future.discard();
+
+ assertThat(future.isDone()).isTrue();
+ assertThat(downloader.getSizeOfSegmentsToFetch()).isZero();
+ assertThat(downloader.getPrefetchSemaphore().availablePermits()).isEqualTo(4);
+ } finally {
+ IOUtils.closeQuietly(downloader);
+ IOUtils.closeQuietly(fileDownloader);
+ }
+ }
+
+ @Test
+ void testDiscardInFlightDownload() throws Exception {
+ CountDownLatch downloadStarted = new CountDownLatch(1);
+ CountDownLatch continueDownload = new CountDownLatch(1);
+
+ class BlockingFileDownloader extends RemoteFileDownloader {
+ BlockingFileDownloader() {
+ super(1);
+ }
+
+ @Override
+ protected long downloadFile(Path targetFilePath, FsPath remoteFilePath)
+ throws IOException {
+ downloadStarted.countDown();
+ try {
+ continueDownload.await();
+ } catch (InterruptedException e) {
+ throw new IOException("Interrupted while blocking", e);
+ }
+ return super.downloadFile(targetFilePath, remoteFilePath);
+ }
+ }
+
+ BlockingFileDownloader fileDownloader = new BlockingFileDownloader();
+ RemoteLogDownloader downloader =
+ new RemoteLogDownloader(
+ DATA1_TABLE_PATH.toString(), conf, fileDownloader, scannerMetricGroup, 10L);
+ try {
+ TableBucket tableBucket = new TableBucket(DATA1_TABLE_ID, 0);
+ RemoteLogSegment segment =
+ buildRemoteLogSegmentList(tableBucket, DATA1_PHYSICAL_TABLE_PATH, 1, conf, 10)
+ .get(0);
+ FsPath tabletDir =
+ remoteLogTabletDir(remoteLogDir, DATA1_PHYSICAL_TABLE_PATH, tableBucket);
+ File localFile = localFile(downloader, tabletDir, segment);
+
+ RemoteLogDownloadFuture future = downloader.requestRemoteLog(tabletDir, segment);
+ downloader.fetchOnce();
+ assertThat(downloadStarted.await(30, TimeUnit.SECONDS)).isTrue();
+ assertThat(downloader.getPrefetchSemaphore().availablePermits()).isEqualTo(3);
+
+ future.discard();
+ continueDownload.countDown();
+
+ retry(
+ Duration.ofMinutes(1),
+ () ->
+ assertThat(downloader.getPrefetchSemaphore().availablePermits())
+ .isEqualTo(4));
+ assertThat(future.isDone()).isTrue();
+ assertThat(localFile).doesNotExist();
+ } finally {
+ continueDownload.countDown();
+ IOUtils.closeQuietly(downloader);
+ IOUtils.closeQuietly(fileDownloader);
+ }
+ }
+
+ @Test
+ void testDiscardCompletedDownload() throws Exception {
+ RemoteFileDownloader fileDownloader = new RemoteFileDownloader(1);
+ RemoteLogDownloader downloader =
+ new RemoteLogDownloader(
+ DATA1_TABLE_PATH.toString(), conf, fileDownloader, scannerMetricGroup, 10L);
+ try {
+ TableBucket tableBucket = new TableBucket(DATA1_TABLE_ID, 0);
+ RemoteLogSegment segment =
+ buildRemoteLogSegmentList(tableBucket, DATA1_PHYSICAL_TABLE_PATH, 1, conf, 10)
+ .get(0);
+ FsPath tabletDir =
+ remoteLogTabletDir(remoteLogDir, DATA1_PHYSICAL_TABLE_PATH, tableBucket);
+ File localFile = localFile(downloader, tabletDir, segment);
+
+ RemoteLogDownloadFuture future = downloader.requestRemoteLog(tabletDir, segment);
+ downloader.fetchOnce();
+ retry(Duration.ofMinutes(1), () -> assertThat(future.isDone()).isTrue());
+ assertThat(downloader.getPrefetchSemaphore().availablePermits()).isEqualTo(3);
+ assertThat(localFile).exists();
+
+ future.discard();
+ future.discard();
+
+ assertThat(downloader.getPrefetchSemaphore().availablePermits()).isEqualTo(4);
+ downloader.start();
+ retry(Duration.ofMinutes(1), () -> assertThat(localFile).doesNotExist());
+ } finally {
+ IOUtils.closeQuietly(downloader);
+ IOUtils.closeQuietly(fileDownloader);
+ }
+ }
+
@Test
void testDownloadLogInParallelAndInPriority() throws Exception {
class TestRemoteFileDownloader extends RemoteFileDownloader {
@@ -165,7 +287,7 @@ class RemoteLogDownloaderTest {
TestRemoteFileDownloader fileDownloader = new TestRemoteFileDownloader(4);
RemoteLogDownloader remoteLogDownloader =
new RemoteLogDownloader(
- DATA1_TABLE_PATH,
+ DATA1_TABLE_PATH.toString(),
conf, // max 4 pre-fetch num
fileDownloader,
scannerMetricGroup,
@@ -337,7 +459,7 @@ class RemoteLogDownloaderTest {
BlockingFileDownloader fileDownloader = new BlockingFileDownloader(4);
RemoteLogDownloader downloader =
new RemoteLogDownloader(
- DATA1_TABLE_PATH, conf, fileDownloader, scannerMetricGroup, 10L);
+ DATA1_TABLE_PATH.toString(), conf, fileDownloader, scannerMetricGroup, 10L);
try {
TableBucket tb = new TableBucket(DATA1_TABLE_ID, 0);
@@ -452,4 +574,12 @@ class RemoteLogDownloaderTest {
.length())
.sum();
}
+
+ private static File localFile(
+ RemoteLogDownloader downloader, FsPath remoteLogTabletDir, RemoteLogSegment segment) {
+ return new File(
+ downloader.getLocalLogDir().toFile(),
+ RemoteLogDownloader.getFsPathAndFileName(remoteLogTabletDir, segment)
+ .getFileName());
+ }
}
diff --git a/fluss-client/src/test/java/org/apache/fluss/client/table/scanner/log/ScanRecordsTest.java b/fluss-client/src/test/java/org/apache/fluss/client/table/scanner/log/ScanRecordsTest.java
index 49c6e9c42..47f1355f8 100644
--- a/fluss-client/src/test/java/org/apache/fluss/client/table/scanner/log/ScanRecordsTest.java
+++ b/fluss-client/src/test/java/org/apache/fluss/client/table/scanner/log/ScanRecordsTest.java
@@ -70,6 +70,7 @@ public class ScanRecordsTest {
// No records and no progress: both isEmpty() and buckets() must be empty.
ScanRecords trulyEmpty = ScanRecords.EMPTY;
assertThat(trulyEmpty.isEmpty()).isTrue();
+ assertThat(trulyEmpty.hasProgress()).isFalse();
assertThat(trulyEmpty.buckets()).isEmpty();
// Progress-only round: isEmpty() stays true (no materialized records),
@@ -83,6 +84,7 @@ public class ScanRecordsTest {
progressOffsets.put(emptyBucket, 10L);
ScanRecords progressOnly = new ScanRecords(progressRecords, progressOffsets);
assertThat(progressOnly.isEmpty()).isTrue();
+ assertThat(progressOnly.hasProgress()).isTrue();
assertThat(progressOnly.buckets()).containsExactlyInAnyOrder(tb, emptyBucket);
assertThat(progressOnly.records(emptyBucket)).isEmpty();
assertThat(progressOnly.consumedUpToOffset(tb)).isEqualTo(42L);
@@ -98,7 +100,36 @@ public class ScanRecordsTest {
new ScanRecord(0L, 1000L, ChangeType.INSERT, row(1, "a"))));
ScanRecords withRecords = new ScanRecords(matRecords);
assertThat(withRecords.isEmpty()).isFalse();
+ assertThat(withRecords.hasProgress()).isTrue();
assertThat(withRecords.buckets()).containsExactly(tb);
assertThat(withRecords.consumedUpToOffset(tb)).isNull();
}
+
+ /**
+ * Verifies the constructor surfaces progress-only buckets in buckets() even when the caller
+ * omits their (empty) record list entries.
+ */
+ @Test
+ void progressOnlyBucketsSurfacedWithoutRecordEntries() {
+ TableBucket recordBucket = new TableBucket(0L, 0);
+ TableBucket progressOnlyBucket = new TableBucket(0L, 1);
+
+ Map<TableBucket, List<ScanRecord>> records = new HashMap<>();
+ records.put(
+ recordBucket,
+ Collections.singletonList(
+ new ScanRecord(0L, 1000L, ChangeType.INSERT, row(1, "a"))));
+ Map<TableBucket, Long> offsets = new HashMap<>();
+ offsets.put(recordBucket, 1L);
+ // no records entry for this bucket, only an advanced offset
+ offsets.put(progressOnlyBucket, 10L);
+
+ ScanRecords scanRecords = new ScanRecords(records, offsets);
+ assertThat(scanRecords.buckets())
+ .containsExactlyInAnyOrder(recordBucket, progressOnlyBucket);
+ assertThat(scanRecords.records(progressOnlyBucket)).isEmpty();
+ assertThat(scanRecords.consumedUpToOffset(progressOnlyBucket)).isEqualTo(10L);
+ assertThat(scanRecords.count()).isEqualTo(1);
+ assertThat(scanRecords.hasProgress()).isTrue();
+ }
}
diff --git a/fluss-client/src/test/java/org/apache/fluss/client/table/writer/MultiTableWriteRecordTest.java b/fluss-client/src/test/java/org/apache/fluss/client/table/writer/MultiTableWriteRecordTest.java
new file mode 100644
index 000000000..44a628274
--- /dev/null
+++ b/fluss-client/src/test/java/org/apache/fluss/client/table/writer/MultiTableWriteRecordTest.java
@@ -0,0 +1,88 @@
+/*
+ * 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.fluss.client.table.writer;
+
+import org.apache.fluss.metadata.TablePath;
+import org.apache.fluss.row.GenericRow;
+import org.apache.fluss.row.InternalRow;
+
+import org.junit.jupiter.api.Test;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+
+/** Unit test for {@link MultiTableWriteRecord}. */
+class MultiTableWriteRecordTest {
+
+ private static final TablePath TP = TablePath.of("db", "t");
+
+ private static InternalRow newRow() {
+ GenericRow r = new GenericRow(1);
+ r.setField(0, 42);
+ return r;
+ }
+
+ @Test
+ void testForAppendWithExplicitSchemaId() {
+ InternalRow row = newRow();
+ MultiTableWriteRecord rec = MultiTableWriteRecord.forAppend(TP, row, 5);
+ assertThat(rec.getTablePath()).isEqualTo(TP);
+ assertThat(rec.getOperation()).isEqualTo(MultiTableWriteRecord.Operation.APPEND);
+ assertThat(rec.getRow()).isSameAs(row);
+ assertThat(rec.getSchemaId()).isEqualTo(5);
+ }
+
+ @Test
+ void testForUpsertWithExplicitSchemaId() {
+ InternalRow row = newRow();
+ MultiTableWriteRecord rec = MultiTableWriteRecord.forUpsert(TP, row, 7);
+ assertThat(rec.getOperation()).isEqualTo(MultiTableWriteRecord.Operation.UPSERT);
+ assertThat(rec.getSchemaId()).isEqualTo(7);
+ }
+
+ @Test
+ void testForDeleteWithExplicitSchemaId() {
+ InternalRow row = newRow();
+ MultiTableWriteRecord rec = MultiTableWriteRecord.forDelete(TP, row, 9);
+ assertThat(rec.getOperation()).isEqualTo(MultiTableWriteRecord.Operation.DELETE);
+ assertThat(rec.getSchemaId()).isEqualTo(9);
+ }
+
+ @Test
+ void testToStringContainsCoreFields() {
+ MultiTableWriteRecord rec = MultiTableWriteRecord.forDelete(TP, newRow(), 3);
+ assertThat(rec.toString())
+ .contains("tablePath=" + TP)
+ .contains("operation=DELETE")
+ .contains("schemaId=3");
+ }
+
+ @Test
+ void testRejectsNullTablePath() {
+ assertThatThrownBy(() -> MultiTableWriteRecord.forAppend(null, newRow(), 1))
+ .isInstanceOf(NullPointerException.class)
+ .hasMessageContaining("tablePath");
+ }
+
+ @Test
+ void testRejectsNullRow() {
+ assertThatThrownBy(() -> MultiTableWriteRecord.forAppend(TP, null, 1))
+ .isInstanceOf(NullPointerException.class)
+ .hasMessageContaining("row");
+ }
+}
diff --git a/fluss-client/src/test/java/org/apache/fluss/client/table/writer/MultiTableWriterITCase.java b/fluss-client/src/test/java/org/apache/fluss/client/table/writer/MultiTableWriterITCase.java
new file mode 100644
index 000000000..b45bf957c
--- /dev/null
+++ b/fluss-client/src/test/java/org/apache/fluss/client/table/writer/MultiTableWriterITCase.java
@@ -0,0 +1,949 @@
+/*
+ * 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.fluss.client.table.writer;
+
+import org.apache.fluss.client.Connection;
+import org.apache.fluss.client.ConnectionFactory;
+import org.apache.fluss.client.FlussConnection;
+import org.apache.fluss.client.admin.Admin;
+import org.apache.fluss.client.admin.ClientToServerITCaseBase;
+import org.apache.fluss.client.lookup.Lookuper;
+import org.apache.fluss.client.table.MultiTable;
+import org.apache.fluss.client.table.Table;
+import org.apache.fluss.client.table.scanner.MultiTableRecord;
+import org.apache.fluss.client.table.scanner.ScanRecord;
+import org.apache.fluss.client.table.scanner.log.LogScanner;
+import org.apache.fluss.client.table.scanner.log.MultiTableLogScanner;
+import org.apache.fluss.client.table.scanner.log.MultiTableRecords;
+import org.apache.fluss.client.table.scanner.log.ScanRecords;
+import org.apache.fluss.client.write.WriterClient;
+import org.apache.fluss.config.AutoPartitionTimeUnit;
+import org.apache.fluss.config.ConfigOptions;
+import org.apache.fluss.config.Configuration;
+import org.apache.fluss.metadata.Schema;
+import org.apache.fluss.metadata.TableBucket;
+import org.apache.fluss.metadata.TableChange;
+import org.apache.fluss.metadata.TableDescriptor;
+import org.apache.fluss.metadata.TablePath;
+import org.apache.fluss.row.GenericRow;
+import org.apache.fluss.row.InternalRow;
+import org.apache.fluss.types.DataTypes;
+
+import org.junit.jupiter.api.Test;
+
+import java.lang.reflect.Proxy;
+import java.time.Duration;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+import java.util.Map;
+import java.util.concurrent.CompletableFuture;
+import java.util.concurrent.ExecutionException;
+import java.util.concurrent.atomic.AtomicInteger;
+
+import static org.apache.fluss.testutils.DataTestUtils.row;
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+
+/** ITCase for {@link MultiTableWriter} / {@link MultiTableWriterImpl}. */
+class MultiTableWriterITCase extends ClientToServerITCaseBase {
+
+ private static final Schema LOG_SCHEMA =
+ Schema.newBuilder()
+ .column("id", DataTypes.INT())
+ .column("name", DataTypes.STRING())
+ .build();
+
+ private static final Schema LOG_SCHEMA_2 =
+ Schema.newBuilder()
+ .column("key", DataTypes.INT())
+ .column("value", DataTypes.STRING())
+ .column("score", DataTypes.BIGINT())
+ .build();
+
+ private static final Schema PK_SCHEMA =
+ Schema.newBuilder()
+ .primaryKey("id")
+ .column("id", DataTypes.INT())
+ .column("name", DataTypes.STRING())
+ .build();
+
+ // ---------------------------------------------------------------- log
+
+ @Test
+ void testWriteSingleLogTable() throws Exception {
+ TablePath tp = TablePath.of("test_db", "mt_w_single_log");
+ TableDescriptor descriptor =
+ TableDescriptor.builder().schema(LOG_SCHEMA).distributedBy(1).build();
+ long tableId = createTable(tp, descriptor, false);
+ waitAllReplicasReady(tableId, 1);
+ int sid = currentSchemaId(tp);
+
+ int recordCount = 5;
+ List<GenericRow> expected = new ArrayList<>();
+ MultiTable multiTable = conn.getMultiTable();
+ try (MultiTableWriter writer = multiTable.newMultiTableWrite().createWriter()) {
+ for (int i = 0; i < recordCount; i++) {
+ GenericRow r = row(i, "v-" + i);
+ expected.add(r);
+ writer.write(MultiTableWriteRecord.forAppend(tp, r, sid)).get();
+ }
+ writer.flush();
+ }
+
+ // Verify through a log scanner bound to the table.
+ List<GenericRow> actual = pollAllAppendRows(tp, recordCount);
+ assertThat(actual).containsExactlyInAnyOrderElementsOf(expected);
+ }
+
+ @Test
+ void testWriteMultipleLogTables() throws Exception {
+ TablePath tp1 = TablePath.of("test_db", "mt_w_multi_log1");
+ TablePath tp2 = TablePath.of("test_db", "mt_w_multi_log2");
+ long id1 =
+ createTable(
+ tp1,
+ TableDescriptor.builder().schema(LOG_SCHEMA).distributedBy(1).build(),
+ false);
+ long id2 =
+ createTable(
+ tp2,
+ TableDescriptor.builder().schema(LOG_SCHEMA_2).distributedBy(1).build(),
+ true);
+ waitAllReplicasReady(id1, 1);
+ waitAllReplicasReady(id2, 1);
+ int sid1 = currentSchemaId(tp1);
+ int sid2 = currentSchemaId(tp2);
+
+ int n1 = 3;
+ int n2 = 4;
+ List<GenericRow> expected1 = new ArrayList<>();
+ List<GenericRow> expected2 = new ArrayList<>();
+
+ try (MultiTableWriter writer = conn.getMultiTable().newMultiTableWrite().createWriter()) {
+ for (int i = 0; i < n1; i++) {
+ GenericRow r = row(i, "t1-" + i);
+ expected1.add(r);
+ writer.write(MultiTableWriteRecord.forAppend(tp1, r, sid1)).get();
+ }
+ for (int i = 0; i < n2; i++) {
+ GenericRow r = row(100 + i, "t2-" + i, (long) (i * 10));
+ expected2.add(r);
+ writer.write(MultiTableWriteRecord.forAppend(tp2, r, sid2)).get();
+ }
+ writer.flush();
+ }
+
+ assertThat(pollAllAppendRows(tp1, n1)).containsExactlyInAnyOrderElementsOf(expected1);
+ assertThat(pollAllAppendRows(tp2, n2)).containsExactlyInAnyOrderElementsOf(expected2);
+ }
+
+ // ---------------------------------------------------------------- pk
+
+ @Test
+ void testWriteSinglePkTableUpsertAndDelete() throws Exception {
+ TablePath tp = TablePath.of("test_db", "mt_w_pk");
+ TableDescriptor descriptor =
+ TableDescriptor.builder().schema(PK_SCHEMA).distributedBy(1).build();
+ long tableId = createTable(tp, descriptor, false);
+ waitAllReplicasReady(tableId, 1);
+ int sid = currentSchemaId(tp);
+
+ try (MultiTableWriter writer = conn.getMultiTable().newMultiTableWrite().createWriter()) {
+ writer.write(MultiTableWriteRecord.forUpsert(tp, row(1, "a"), sid)).get();
+ writer.write(MultiTableWriteRecord.forUpsert(tp, row(1, "a-updated"), sid)).get();
+
+ writer.flush();
+ }
+
+ // Verify: pk=1 resolves to ("a-updated").
+ try (Table table = conn.getTable(tp)) {
+ Lookuper lookuper = table.newLookup().createLookuper();
+ InternalRow found = lookupRow(lookuper, row(1));
+ assertThat(found).isNotNull();
+ assertThat(found.getInt(0)).isEqualTo(1);
+ assertThat(found.getString(1).toString()).isEqualTo("a-updated");
+ }
+
+ // DELETE: pk=1 disappears.
+ try (MultiTableWriter writer = conn.getMultiTable().newMultiTableWrite().createWriter()) {
+ writer.write(MultiTableWriteRecord.forDelete(tp, row(1, "a-updated"), sid)).get();
+ writer.flush();
+ }
+ try (Table table = conn.getTable(tp)) {
+ Lookuper lookuper = table.newLookup().createLookuper();
+ assertThat(lookupRow(lookuper, row(1))).isNull();
+ }
+ }
+
+ // ---------------------------------------------------------------- mix
+
+ @Test
+ void testWriteMixedLogAndPkTables() throws Exception {
+ TablePath logTp = TablePath.of("test_db", "mt_w_mix_log");
+ TablePath pkTp = TablePath.of("test_db", "mt_w_mix_pk");
+ long logId =
+ createTable(
+ logTp,
+ TableDescriptor.builder().schema(LOG_SCHEMA).distributedBy(1).build(),
+ false);
+ long pkId =
+ createTable(
+ pkTp,
+ TableDescriptor.builder().schema(PK_SCHEMA).distributedBy(1).build(),
+ true);
+ waitAllReplicasReady(logId, 1);
+ waitAllReplicasReady(pkId, 1);
+ int logSid = currentSchemaId(logTp);
+ int pkSid = currentSchemaId(pkTp);
+
+ try (MultiTableWriter writer = conn.getMultiTable().newMultiTableWrite().createWriter()) {
+ writer.write(MultiTableWriteRecord.forAppend(logTp, row(1, "log-a"), logSid)).get();
+ writer.write(MultiTableWriteRecord.forUpsert(pkTp, row(42, "pk-a"), pkSid)).get();
+ writer.write(MultiTableWriteRecord.forUpsert(pkTp, row(42, "pk-b"), pkSid)).get();
+ writer.flush();
+ }
+
+ // Log table: one appended row.
+ List<GenericRow> logRows = pollAllAppendRows(logTp, 1);
+ assertThat(logRows).hasSize(1);
+
+ // PK table: key=42 has value "pk-b".
+ try (Table table = conn.getTable(pkTp)) {
+ Lookuper lookuper = table.newLookup().createLookuper();
+ InternalRow found = lookupRow(lookuper, row(42));
+ assertThat(found).isNotNull();
+ assertThat(found.getString(1).toString()).isEqualTo("pk-b");
+ }
+ }
+
+ // ---------------------------------------------------------------- partitioned
+
+ @Test
+ void testWritePartitionedTable() throws Exception {
+ TablePath tp = TablePath.of("test_db", "mt_w_part");
+ Schema schema =
+ Schema.newBuilder()
+ .column("id", DataTypes.INT())
+ .column("name", DataTypes.STRING())
+ .column("pt", DataTypes.STRING())
+ .build();
+ TableDescriptor descriptor =
+ TableDescriptor.builder()
+ .schema(schema)
+ .distributedBy(1)
+ .partitionedBy("pt")
+ .property(ConfigOptions.TABLE_AUTO_PARTITION_ENABLED, true)
+ .property(
+ ConfigOptions.TABLE_AUTO_PARTITION_TIME_UNIT,
+ AutoPartitionTimeUnit.YEAR)
+ .build();
+ long tableId = createTable(tp, descriptor, false);
+
+ Map<String, Long> partitionIdByNames =
+ FLUSS_CLUSTER_EXTENSION.waitUntilPartitionAllReady(tp);
+ assertThat(partitionIdByNames).isNotEmpty();
+
+ int perPartition = 2;
+ int partSid = currentSchemaId(tp);
+ try (MultiTableWriter writer = conn.getMultiTable().newMultiTableWrite().createWriter()) {
+ for (String partitionName : partitionIdByNames.keySet()) {
+ for (int i = 0; i < perPartition; i++) {
+ writer.write(
+ MultiTableWriteRecord.forAppend(
+ tp, row(i, "v-" + i, partitionName), partSid))
+ .get();
+ }
+ }
+ writer.flush();
+ }
+
+ int total = perPartition * partitionIdByNames.size();
+ // Scan across all partitions via a MultiTableLogScanner.
+ try (MultiTableLogScanner scanner =
+ conn.getMultiTable().newMultiTableScan().createLogScanner()) {
+ for (Long partitionId : partitionIdByNames.values()) {
+ scanner.subscribeFromBeginning(tp, partitionId, 0);
+ }
+ int polled = 0;
+ long deadline = System.currentTimeMillis() + 30_000;
+ while (polled < total && System.currentTimeMillis() < deadline) {
+ MultiTableRecords records = scanner.poll(Duration.ofSeconds(1));
+ polled += records.count();
+ }
+ assertThat(polled).isEqualTo(total);
+ }
+ }
+
+ // ---------------------------------------------------------------- cdc mirror
+
+ @Test
+ void testCdcMirrorScanThenWrite() throws Exception {
+ TablePath src = TablePath.of("test_db", "mt_w_cdc_src");
+ TablePath dst = TablePath.of("test_db", "mt_w_cdc_dst");
+ long srcId =
+ createTable(
+ src,
+ TableDescriptor.builder().schema(LOG_SCHEMA).distributedBy(1).build(),
+ false);
+ long dstId =
+ createTable(
+ dst,
+ TableDescriptor.builder().schema(LOG_SCHEMA).distributedBy(1).build(),
+ true);
+ waitAllReplicasReady(srcId, 1);
+ waitAllReplicasReady(dstId, 1);
+
+ // 1. Seed src via the single-table AppendWriter.
+ int recordCount = 6;
+ List<GenericRow> seeded = new ArrayList<>();
+ try (Table table = conn.getTable(src)) {
+ AppendWriter append = table.newAppend().createWriter();
+ for (int i = 0; i < recordCount; i++) {
+ GenericRow r = row(i, "src-" + i);
+ seeded.add(r);
+ append.append(r).get();
+ }
+ }
+
+ // 2. Mirror src -> dst via MultiTable scanner + writer.
+ MultiTable multiTable = conn.getMultiTable();
+ try (MultiTableLogScanner scanner = multiTable.newMultiTableScan().createLogScanner();
+ MultiTableWriter writer = multiTable.newMultiTableWrite().createWriter()) {
+ scanner.subscribeFromBeginning(src, 0);
+ int mirrored = 0;
+ long deadline = System.currentTimeMillis() + 30_000;
+ while (mirrored < recordCount && System.currentTimeMillis() < deadline) {
+ MultiTableRecords records = scanner.poll(Duration.ofSeconds(1));
+ for (MultiTableRecord scanned : records) {
+ // Retarget to dst; log tables only accept append records.
+ MultiTableWriteRecord retarget =
+ MultiTableWriteRecord.forAppend(
+ dst, scanned.getRow(), scanned.getSchemaId());
+ writer.write(retarget).get();
+ mirrored++;
+ }
+ }
+ writer.flush();
+ assertThat(mirrored).isEqualTo(recordCount);
+ }
+
+ // 3. Verify dst has the same rows as src.
+ List<GenericRow> mirroredRows = pollAllAppendRows(dst, recordCount);
+ assertThat(mirroredRows).containsExactlyInAnyOrderElementsOf(seeded);
+ }
+
+ // ---------------------------------------------------------------- errors
+
+ @Test
+ void testWriteUnknownTableFailsFuture() throws Exception {
+ TablePath unknown = TablePath.of("test_db", "mt_w_unknown_table");
+ try (MultiTableWriter writer = conn.getMultiTable().newMultiTableWrite().createWriter()) {
+ assertThatThrownBy(
+ () ->
+ writer.write(
+ MultiTableWriteRecord.forAppend(
+ unknown, row(1, "v"), 1))
+ .get())
+ .isInstanceOf(ExecutionException.class)
+ .rootCause()
+ .hasMessageContaining("Table 'test_db.mt_w_unknown_table' does not exist.");
+ }
+ }
+
+ @Test
+ void testOperationIncompatibleWithTableKind() throws Exception {
+ // DELETE on a log table must fail (log tables only accept APPEND).
+ TablePath logTp = TablePath.of("test_db", "mt_w_bad_ct_log");
+ TablePath pkTp = TablePath.of("test_db", "mt_w_bad_ct_pk");
+ long logId =
+ createTable(
+ logTp,
+ TableDescriptor.builder().schema(LOG_SCHEMA).distributedBy(1).build(),
+ false);
+ long pkId =
+ createTable(
+ pkTp,
+ TableDescriptor.builder().schema(PK_SCHEMA).distributedBy(1).build(),
+ true);
+ waitAllReplicasReady(logId, 1);
+ waitAllReplicasReady(pkId, 1);
+ int logSid = currentSchemaId(logTp);
+ int pkSid = currentSchemaId(pkTp);
+
+ try (MultiTableWriter writer = conn.getMultiTable().newMultiTableWrite().createWriter()) {
+ assertThatThrownBy(
+ () ->
+ writer.write(
+ MultiTableWriteRecord.forDelete(
+ logTp, row(1, "v"), logSid))
+ .get())
+ .isInstanceOf(ExecutionException.class)
+ .hasCauseInstanceOf(IllegalArgumentException.class)
+ .hasMessageContaining("log table")
+ .hasMessageContaining("forAppend");
+
+ assertThatThrownBy(
+ () ->
+ writer.write(
+ MultiTableWriteRecord.forAppend(
+ pkTp, row(1, "v"), pkSid))
+ .get())
+ .isInstanceOf(ExecutionException.class)
+ .hasCauseInstanceOf(IllegalArgumentException.class)
+ .hasMessageContaining("primary-key table")
+ .hasMessageContaining("forUpsert")
+ .hasMessageContaining("forDelete");
+ }
+ }
+
+ @Test
+ void testFlushAndClose() throws Exception {
+ TablePath tp = TablePath.of("test_db", "mt_w_flush_close");
+ long tableId =
+ createTable(
+ tp,
+ TableDescriptor.builder().schema(LOG_SCHEMA).distributedBy(1).build(),
+ false);
+ waitAllReplicasReady(tableId, 1);
+ int sid = currentSchemaId(tp);
+
+ MultiTableWriter writer = conn.getMultiTable().newMultiTableWrite().createWriter();
+ writer.write(MultiTableWriteRecord.forAppend(tp, row(1, "x"), sid)).get();
+ writer.flush();
+ writer.close();
+
+ // Further writes after close must fail synchronously.
+ assertThatThrownBy(
+ () -> writer.write(MultiTableWriteRecord.forAppend(tp, row(2, "y"), sid)))
+ .isInstanceOf(IllegalStateException.class)
+ .hasMessageContaining("closed");
+
+ // Close is idempotent.
+ writer.close();
+ }
+
+ @Test
+ void testWriteAfterAddColumn() throws Exception {
+ // Verify the writer transparently picks up a new schema after an ALTER TABLE ADD
+ // COLUMN: an old-schema record tagged with the original schemaId uses the cached
+ // encoder, and a record tagged with the post-alter schemaId triggers a metadata
+ // refresh + encoder rebuild so both batches end up readable with their original
+ // shapes.
+ TablePath tp = TablePath.of("test_db", "mt_w_add_column");
+ long tableId =
+ createTable(
+ tp,
+ TableDescriptor.builder().schema(LOG_SCHEMA).distributedBy(1).build(),
+ false);
+ waitAllReplicasReady(tableId, 1);
+ int oldSid = currentSchemaId(tp);
+
+ int preCount = 3;
+ int postCount = 3;
+ List<GenericRow> expectedPre = new ArrayList<>();
+ List<GenericRow> expectedPost = new ArrayList<>();
+
+ try (MultiTableWriter writer = conn.getMultiTable().newMultiTableWrite().createWriter()) {
+ // Phase 1: write with the original 2-column schema.
+ for (int i = 0; i < preCount; i++) {
+ GenericRow r = row(i, "before-" + i);
+ expectedPre.add(r);
+ writer.write(MultiTableWriteRecord.forAppend(tp, r, oldSid)).get();
+ }
+
+ // ALTER TABLE: append a new column "score BIGINT".
+ admin.alterTable(
+ tp,
+ Collections.singletonList(
+ TableChange.addColumn(
+ "score",
+ DataTypes.BIGINT(),
+ null,
+ TableChange.ColumnPosition.last())),
+ false)
+ .get();
+ FLUSS_CLUSTER_EXTENSION.waitUntilAllGatewayHasSameMetadata();
+ int newSid = waitForSchemaIdBump(tp, oldSid);
+
+ // Phase 2: write the new 3-column rows tagged with the bumped schemaId —
+ // the writer must refresh metadata and rebuild the encoder against the new schema
+ // before encoding the wider row.
+ for (int i = 0; i < postCount; i++) {
+ GenericRow r = row(100 + i, "after-" + i, (long) (i * 10));
+ expectedPost.add(r);
+ writer.write(MultiTableWriteRecord.forAppend(tp, r, newSid)).get();
+ }
+ writer.flush();
+ }
+
+ // Read back using the multi-table scanner so each record keeps its write-time schema,
+ // and split rows by their RowType field count.
+ int total = preCount + postCount;
+ List<GenericRow> actualPre = new ArrayList<>();
+ List<GenericRow> actualPost = new ArrayList<>();
+ try (MultiTableLogScanner scanner =
+ conn.getMultiTable().newMultiTableScan().createLogScanner()) {
+ scanner.subscribeFromBeginning(tp, 0);
+ long deadline = System.currentTimeMillis() + 30_000;
+ int polled = 0;
+ while (polled < total && System.currentTimeMillis() < deadline) {
+ MultiTableRecords records = scanner.poll(Duration.ofSeconds(1));
+ for (MultiTableRecord rec : records) {
+ assertThat(rec.getTableId()).isEqualTo(tableId);
+ InternalRow r = rec.getRow();
+ int fc = rec.getSchema().getRowType().getFieldCount();
+ if (fc == 2) {
+ actualPre.add(row(r.getInt(0), r.getString(1)));
+ } else {
+ assertThat(fc).isEqualTo(3);
+ actualPost.add(row(r.getInt(0), r.getString(1), r.getLong(2)));
+ }
+ polled++;
+ }
+ }
+ }
+
+ assertThat(actualPre).containsExactlyInAnyOrderElementsOf(expectedPre);
+ assertThat(actualPost).containsExactlyInAnyOrderElementsOf(expectedPost);
+ }
+
+ @Test
+ void testWritersAreIsolatedAcrossWriterClients() throws Exception {
+ // Each MultiTableWriter owns a dedicated WriterClient, so an outstanding batch of one
+ // writer neither fails another writer's schema-bumped write nor is flushed by it.
+ TablePath tp = TablePath.of("test_db", "mt_w_isolated_writer_clients");
+ long tableId =
+ createTable(
+ tp,
+ TableDescriptor.builder().schema(LOG_SCHEMA).distributedBy(1).build(),
+ false);
+ waitAllReplicasReady(tableId, 1);
+ int oldSid = currentSchemaId(tp);
+
+ Configuration writerConf = new Configuration(clientConf);
+ writerConf.set(ConfigOptions.CLIENT_WRITER_BATCH_TIMEOUT, Duration.ofMinutes(1));
+
+ try (Connection sharedConn = ConnectionFactory.createConnection(writerConf);
+ MultiTableWriter oldSchemaWriter =
+ sharedConn.getMultiTable().newMultiTableWrite().createWriter();
+ MultiTableWriter newSchemaWriter =
+ sharedConn.getMultiTable().newMultiTableWrite().createWriter()) {
+ CompletableFuture<WriteResult> oldFuture =
+ oldSchemaWriter.write(
+ MultiTableWriteRecord.forAppend(tp, row(1, "old-schema"), oldSid));
+
+ admin.alterTable(
+ tp,
+ Collections.singletonList(
+ TableChange.addColumn(
+ "score",
+ DataTypes.BIGINT(),
+ null,
+ TableChange.ColumnPosition.last())),
+ false)
+ .get();
+ FLUSS_CLUSTER_EXTENSION.waitUntilAllGatewayHasSameMetadata();
+ int newSid = waitForSchemaIdBump(tp, oldSid);
+
+ // The new-schema write goes to its own accumulator and must not collide with the
+ // other writer's outstanding old-schema batch.
+ CompletableFuture<WriteResult> newFuture =
+ newSchemaWriter.write(
+ MultiTableWriteRecord.forAppend(tp, row(2, "new-schema", 10L), newSid));
+
+ // Flushing one writer must not flush (or complete) the other writer's records.
+ newSchemaWriter.flush();
+ assertThat(newFuture.isDone()).isTrue();
+ newFuture.get();
+ assertThat(oldFuture.isDone()).isFalse();
+
+ oldSchemaWriter.flush();
+ assertThat(oldFuture.isDone()).isTrue();
+ oldFuture.get();
+ }
+
+ // Read back both records, each keeping its write-time schema shape.
+ List<GenericRow> actualOld = new ArrayList<>();
+ List<GenericRow> actualNew = new ArrayList<>();
+ pollRowsGroupedByFieldCount(tp, 2, actualOld, actualNew);
+ assertThat(actualOld).containsExactly(row(1, "old-schema"));
+ assertThat(actualNew).containsExactly(row(2, "new-schema", 10L));
+ }
+
+ @Test
+ void testSchemaSwitchRollsBatchWithoutFlush() throws Exception {
+ // A batch never mixes schemas: when a record carrying a bumped schemaId arrives while the
+ // previous schema's records are still buffered, the old-schema batch is closed and a new
+ // schema-specific batch is rolled automatically, so no explicit flush() is required at
+ // the schema boundary.
+ TablePath tp = TablePath.of("test_db", "mt_w_schema_switch_roll");
+ long tableId =
+ createTable(
+ tp,
+ TableDescriptor.builder().schema(LOG_SCHEMA).distributedBy(1).build(),
+ false);
+ waitAllReplicasReady(tableId, 1);
+ int oldSid = currentSchemaId(tp);
+
+ Configuration writerConf = new Configuration(clientConf);
+ writerConf.set(ConfigOptions.CLIENT_WRITER_BATCH_TIMEOUT, Duration.ofMinutes(1));
+
+ try (Connection writerConn = ConnectionFactory.createConnection(writerConf);
+ MultiTableWriter writer =
+ writerConn.getMultiTable().newMultiTableWrite().createWriter()) {
+ CompletableFuture<WriteResult> oldFuture =
+ writer.write(MultiTableWriteRecord.forAppend(tp, row(1, "old-schema"), oldSid));
+
+ admin.alterTable(
+ tp,
+ Collections.singletonList(
+ TableChange.addColumn(
+ "score",
+ DataTypes.BIGINT(),
+ null,
+ TableChange.ColumnPosition.last())),
+ false)
+ .get();
+ FLUSS_CLUSTER_EXTENSION.waitUntilAllGatewayHasSameMetadata();
+ int newSid = waitForSchemaIdBump(tp, oldSid);
+
+ // the old-schema batch is still buffered (long batch timeout), yet the new-schema
+ // record is accepted without an intermediate flush.
+ CompletableFuture<WriteResult> newFuture =
+ writer.write(
+ MultiTableWriteRecord.forAppend(tp, row(2, "new-schema", 10L), newSid));
+
+ // a single flush drains both the rolled old-schema batch and the new-schema batch.
+ writer.flush();
+ assertThat(oldFuture.isDone()).isTrue();
+ assertThat(newFuture.isDone()).isTrue();
+ oldFuture.get();
+ newFuture.get();
+ }
+ }
+
+ @Test
+ void testHistoricalSchemaRecordsBatchWithoutPerRowRefresh() throws Exception {
+ // Regression test: consecutive records carrying the same historical schemaId must reuse
+ // the cached encoding state — a single schema fetch, no per-row metadata refresh and
+ // no per-row flush — so historical CDC records still batch normally.
+ TablePath tp = TablePath.of("test_db", "mt_w_hist_no_per_row_refresh");
+ long tableId =
+ createTable(
+ tp,
+ TableDescriptor.builder().schema(LOG_SCHEMA).distributedBy(1).build(),
+ false);
+ waitAllReplicasReady(tableId, 1);
+ int oldSid = currentSchemaId(tp);
+
+ admin.alterTable(
+ tp,
+ Collections.singletonList(
+ TableChange.addColumn(
+ "score",
+ DataTypes.BIGINT(),
+ null,
+ TableChange.ColumnPosition.last())),
+ false)
+ .get();
+ FLUSS_CLUSTER_EXTENSION.waitUntilAllGatewayHasSameMetadata();
+ int newSid = waitForSchemaIdBump(tp, oldSid);
+
+ Configuration writerConf = new Configuration(clientConf);
+ writerConf.set(ConfigOptions.CLIENT_WRITER_BATCH_TIMEOUT, Duration.ofMinutes(1));
+
+ AtomicInteger schemaFetches = new AtomicInteger();
+ AtomicInteger tableInfoFetches = new AtomicInteger();
+ try (Connection writerConn = ConnectionFactory.createConnection(writerConf)) {
+ FlussConnection flussConn = (FlussConnection) writerConn;
+ Admin realAdmin = flussConn.getOrCreateAdmin();
+ Admin countingAdmin =
+ (Admin)
+ Proxy.newProxyInstance(
+ Admin.class.getClassLoader(),
+ new Class<?>[] {Admin.class},
+ (proxy, method, args) -> {
+ if ("getTableSchema".equals(method.getName())) {
+ schemaFetches.incrementAndGet();
+ } else if ("getTableInfo".equals(method.getName())) {
+ tableInfoFetches.incrementAndGet();
+ }
+ return method.invoke(realAdmin, args);
+ });
+
+ List<CompletableFuture<WriteResult>> historicalFutures = new ArrayList<>();
+ try (MultiTableWriter writer =
+ new MultiTableWriterImpl(
+ flussConn.getMetadataUpdater(),
+ countingAdmin,
+ new WriterClient(
+ flussConn.getConfiguration(),
+ flussConn.getMetadataUpdater(),
+ flussConn.getClientMetricGroup(),
+ realAdmin))) {
+ // Prime the writer with the latest schema, then flush to honor the schema-switch
+ // contract before writing historical records.
+ List<CompletableFuture<WriteResult>> newFutures = new ArrayList<>();
+ for (int i = 0; i < 2; i++) {
+ newFutures.add(
+ writer.write(
+ MultiTableWriteRecord.forAppend(
+ tp, row(100 + i, "new-" + i, (long) i), newSid)));
+ }
+ writer.flush();
+ for (CompletableFuture<WriteResult> future : newFutures) {
+ future.get();
+ }
+
+ // Consecutive historical-schema records, none awaited individually.
+ for (int i = 0; i < 5; i++) {
+ historicalFutures.add(
+ writer.write(
+ MultiTableWriteRecord.forAppend(
+ tp, row(i, "old-" + i), oldSid)));
+ }
+
+ // No per-row flush: nothing is acknowledged until the explicit flush below.
+ for (CompletableFuture<WriteResult> future : historicalFutures) {
+ assertThat(future.isDone()).isFalse();
+ }
+ // The historical schema is fetched exactly once and the latest table info at most
+ // once — never once per record.
+ assertThat(schemaFetches.get()).isEqualTo(1);
+ assertThat(tableInfoFetches.get()).isLessThanOrEqualTo(1);
+
+ writer.flush();
+ for (CompletableFuture<WriteResult> future : historicalFutures) {
+ assertThat(future.isDone()).isTrue();
+ future.get();
+ }
+ assertThat(schemaFetches.get()).isEqualTo(1);
+ }
+ }
+
+ // Read back: 5 historical rows keep the 2-column shape, 2 new rows the 3-column shape.
+ List<GenericRow> actualOld = new ArrayList<>();
+ List<GenericRow> actualNew = new ArrayList<>();
+ pollRowsGroupedByFieldCount(tp, 7, actualOld, actualNew);
+ assertThat(actualOld).hasSize(5);
+ assertThat(actualNew).hasSize(2);
+ }
+
+ @Test
+ void testWriteWithFutureSchemaIdFailsAfterRefresh() throws Exception {
+ // When the record's schemaId differs from the cached state, the writer refreshes
+ // table metadata and rebuilds encoders against the latest schema. If after the
+ // refresh the record's schemaId is still ahead of the server's schemaId (i.e., the
+ // caller is referencing a future / unknown schema), the write must fail.
+ TablePath tp = TablePath.of("test_db", "mt_w_bad_schema_id");
+ long tableId =
+ createTable(
+ tp,
+ TableDescriptor.builder().schema(LOG_SCHEMA).distributedBy(1).build(),
+ false);
+ waitAllReplicasReady(tableId, 1);
+ int sid = currentSchemaId(tp);
+ int futureSid = sid + 99;
+
+ try (MultiTableWriter writer = conn.getMultiTable().newMultiTableWrite().createWriter()) {
+ assertThatThrownBy(
+ () ->
+ writer.write(
+ MultiTableWriteRecord.forAppend(
+ tp, row(1, "v"), futureSid))
+ .get())
+ .isInstanceOf(ExecutionException.class)
+ .hasCauseInstanceOf(IllegalArgumentException.class)
+ .hasMessageContaining("SchemaId mismatch")
+ .hasMessageContaining("record schemaId=" + futureSid)
+ .hasMessageContaining("current table schemaId=" + sid)
+ .hasMessageContaining("after metadata refresh");
+ }
+ }
+
+ @Test
+ void testWriteHistoricalSchemaAfterSchemaEvolves() throws Exception {
+ // After the table has evolved to a new schema, a record still tagged with the OLD schemaId
+ // (record.schemaId < latest schemaId) must be encoded against its historical schema and
+ // sent successfully — exercising the writer's historical-schema build path rather
+ // than
+ // the cached-latest fast path used by testWriteAfterAddColumn (which writes old rows BEFORE
+ // the alter, while the cached latest still IS the old schema).
+ TablePath tp = TablePath.of("test_db", "mt_w_historical_schema");
+ long tableId =
+ createTable(
+ tp,
+ TableDescriptor.builder().schema(LOG_SCHEMA).distributedBy(1).build(),
+ false);
+ waitAllReplicasReady(tableId, 1);
+ int oldSid = currentSchemaId(tp);
+
+ // Evolve the schema first: append "score BIGINT" so the table's latest schema is the
+ // 3-column shape before any write happens.
+ admin.alterTable(
+ tp,
+ Collections.singletonList(
+ TableChange.addColumn(
+ "score",
+ DataTypes.BIGINT(),
+ null,
+ TableChange.ColumnPosition.last())),
+ false)
+ .get();
+ FLUSS_CLUSTER_EXTENSION.waitUntilAllGatewayHasSameMetadata();
+ int newSid = waitForSchemaIdBump(tp, oldSid);
+
+ List<GenericRow> expectedNew = new ArrayList<>();
+ List<GenericRow> expectedOld = new ArrayList<>();
+ try (MultiTableWriter writer = conn.getMultiTable().newMultiTableWrite().createWriter()) {
+ // Prime the writer with the latest (3-column) schema so its cached "latest" is newSid.
+ for (int i = 0; i < 2; i++) {
+ GenericRow r = row(100 + i, "new-" + i, (long) (i * 10));
+ expectedNew.add(r);
+ writer.write(MultiTableWriteRecord.forAppend(tp, r, newSid)).get();
+ }
+
+ // Now write 2-column rows tagged with the historical oldSid. Because oldSid is behind
+ // the cached latest newSid, the writer must fetch the historical schema and build a
+ // dedicated encoder so the narrow row is encoded & sent against schemaId=oldSid.
+ for (int i = 0; i < 3; i++) {
+ GenericRow r = row(i, "old-" + i);
+ expectedOld.add(r);
+ writer.write(MultiTableWriteRecord.forAppend(tp, r, oldSid)).get();
+ }
+ writer.flush();
+ }
+
+ // Read back via the multi-table scanner so each record keeps its write-time schema, then
+ // split rows by their RowType field count.
+ int total = expectedNew.size() + expectedOld.size();
+ List<GenericRow> actualNew = new ArrayList<>();
+ List<GenericRow> actualOld = new ArrayList<>();
+ try (MultiTableLogScanner scanner =
+ conn.getMultiTable().newMultiTableScan().createLogScanner()) {
+ scanner.subscribeFromBeginning(tp, 0);
+ long deadline = System.currentTimeMillis() + 30_000;
+ int polled = 0;
+ while (polled < total && System.currentTimeMillis() < deadline) {
+ MultiTableRecords records = scanner.poll(Duration.ofSeconds(1));
+ for (MultiTableRecord rec : records) {
+ assertThat(rec.getTableId()).isEqualTo(tableId);
+ InternalRow r = rec.getRow();
+ int fc = rec.getSchema().getRowType().getFieldCount();
+ if (fc == 2) {
+ actualOld.add(row(r.getInt(0), r.getString(1)));
+ } else {
+ assertThat(fc).isEqualTo(3);
+ actualNew.add(row(r.getInt(0), r.getString(1), r.getLong(2)));
+ }
+ polled++;
+ }
+ }
+ }
+
+ assertThat(actualOld).containsExactlyInAnyOrderElementsOf(expectedOld);
+ assertThat(actualNew).containsExactlyInAnyOrderElementsOf(expectedNew);
+ }
+
+ // ---------------------------------------------------------------- helpers
+
+ private int currentSchemaId(TablePath tp) throws Exception {
+ return admin.getTableInfo(tp).get().getSchemaId();
+ }
+
+ /**
+ * Polls up to {@code expected} records from bucket 0 of {@code tp} via the multi-table scanner
+ * (so each record keeps its write-time schema) and splits the rows by their field count into
+ * the 2-column ({@code actualOld}) and 3-column ({@code actualNew}) shapes used by the
+ * schema-evolution tests in this class.
+ */
+ private void pollRowsGroupedByFieldCount(
+ TablePath tp, int expected, List<GenericRow> actualOld, List<GenericRow> actualNew)
+ throws Exception {
+ try (MultiTableLogScanner scanner =
+ conn.getMultiTable().newMultiTableScan().createLogScanner()) {
+ scanner.subscribeFromBeginning(tp, 0);
+ long deadline = System.currentTimeMillis() + 30_000;
+ int polled = 0;
+ while (polled < expected && System.currentTimeMillis() < deadline) {
+ MultiTableRecords records = scanner.poll(Duration.ofSeconds(1));
+ for (MultiTableRecord rec : records) {
+ InternalRow r = rec.getRow();
+ int fc = rec.getSchema().getRowType().getFieldCount();
+ if (fc == 2) {
+ actualOld.add(row(r.getInt(0), r.getString(1)));
+ } else {
+ assertThat(fc).isEqualTo(3);
+ actualNew.add(row(r.getInt(0), r.getString(1), r.getLong(2)));
+ }
+ polled++;
+ }
+ }
+ }
+ }
+
+ /**
+ * Polls {@link #currentSchemaId(TablePath)} until it advances past {@code oldSid} or the
+ * deadline expires; returns the new schemaId.
+ */
+ private int waitForSchemaIdBump(TablePath tp, int oldSid) throws Exception {
+ long deadline = System.currentTimeMillis() + 10_000;
+ int sid = currentSchemaId(tp);
+ while (sid == oldSid && System.currentTimeMillis() < deadline) {
+ Thread.sleep(100);
+ sid = currentSchemaId(tp);
+ }
+ assertThat(sid).isGreaterThan(oldSid);
+
+ return sid;
+ }
+
+ private List<GenericRow> pollAllAppendRows(TablePath tp, int expected) throws Exception {
+ List<GenericRow> result = new ArrayList<>();
+ try (Table table = conn.getTable(tp);
+ LogScanner scanner = table.newScan().createLogScanner()) {
+ subscribeFromBeginning(scanner, table);
+ int fieldCount = table.getTableInfo().getRowType().getFieldCount();
+ long deadline = System.currentTimeMillis() + 30_000;
+ while (result.size() < expected && System.currentTimeMillis() < deadline) {
+ ScanRecords records = scanner.poll(Duration.ofSeconds(1));
+ for (TableBucket bucket : records.buckets()) {
+ for (ScanRecord r : records.records(bucket)) {
+ result.add(copyRow(r.getRow(), fieldCount));
+ }
+ }
+ }
+ }
+ return result;
+ }
+
+ /**
+ * Copies a scanner-produced row field-by-field into a detached {@link GenericRow} using the two
+ * row shapes produced by this test: {@code (int, string)} or {@code (int, string, long)}.
+ */
+ private static GenericRow copyRow(InternalRow row, int fieldCount) {
+ GenericRow out = new GenericRow(fieldCount);
+ out.setField(0, row.getInt(0));
+ out.setField(1, row.getString(1));
+ if (fieldCount == 3) {
+ out.setField(2, row.getLong(2));
+ }
+ return out;
+ }
+}
diff --git a/fluss-client/src/test/java/org/apache/fluss/client/write/CompactedLogWriteBatchTest.java b/fluss-client/src/test/java/org/apache/fluss/client/write/CompactedLogWriteBatchTest.java
index 140058eca..dc8fbce9d 100644
--- a/fluss-client/src/test/java/org/apache/fluss/client/write/CompactedLogWriteBatchTest.java
+++ b/fluss-client/src/test/java/org/apache/fluss/client/write/CompactedLogWriteBatchTest.java
@@ -20,6 +20,7 @@ package org.apache.fluss.client.write;
import org.apache.fluss.memory.MemorySegment;
import org.apache.fluss.memory.PreAllocatedPagedOutputView;
import org.apache.fluss.metadata.TableBucket;
+import org.apache.fluss.metadata.TableInfo;
import org.apache.fluss.record.ChangeType;
import org.apache.fluss.record.CompactedLogRecord;
import org.apache.fluss.record.LogRecord;
@@ -45,7 +46,9 @@ import static org.apache.fluss.record.TestData.DATA1_PHYSICAL_TABLE_PATH;
import static org.apache.fluss.record.TestData.DATA1_ROW_TYPE;
import static org.apache.fluss.record.TestData.DATA1_TABLE_ID;
import static org.apache.fluss.record.TestData.DATA1_TABLE_INFO;
+import static org.apache.fluss.record.TestData.TEST_SCHEMA_GETTER;
import static org.apache.fluss.testutils.DataTestUtils.compactedRow;
+import static org.apache.fluss.testutils.DataTestUtils.indexedRow;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
@@ -145,6 +148,41 @@ public class CompactedLogWriteBatchTest {
assertThat(appendResult).isFalse();
}
+ @Test
+ void testTryAppendRejectsIncompatibleBatchIdentity() throws Exception {
+ int bucketId = 0;
+ CompactedLogWriteBatch logProducerBatch =
+ createLogWriteBatch(new TableBucket(DATA1_TABLE_ID, bucketId));
+
+ assertThat(logProducerBatch.tryAppend(createWriteRecord(), newWriteCallback())).isTrue();
+ assertThat(
+ logProducerBatch.tryAppend(
+ createWriteRecord(
+ withTableIdAndSchemaId(
+ DATA1_TABLE_ID,
+ DATA1_TABLE_INFO.getSchemaId() + 1)),
+ newWriteCallback()))
+ .isFalse();
+ assertThat(
+ logProducerBatch.tryAppend(
+ createWriteRecord(
+ withTableIdAndSchemaId(
+ DATA1_TABLE_ID + 1,
+ DATA1_TABLE_INFO.getSchemaId())),
+ newWriteCallback()))
+ .isFalse();
+ assertThat(
+ logProducerBatch.tryAppend(
+ WriteRecord.forIndexedAppend(
+ DATA1_TABLE_INFO,
+ DATA1_PHYSICAL_TABLE_PATH,
+ indexedRow(DATA1_ROW_TYPE, new Object[] {1, "a"}),
+ null),
+ newWriteCallback()))
+ .isFalse();
+ assertThat(logProducerBatch.getRecordCount()).isEqualTo(1);
+ }
+
@Test
void testBatchAborted() throws Exception {
int bucketId = 0;
@@ -196,8 +234,11 @@ public class CompactedLogWriteBatchTest {
}
private WriteRecord createWriteRecord() {
- return WriteRecord.forCompactedAppend(
- DATA1_TABLE_INFO, DATA1_PHYSICAL_TABLE_PATH, row, null);
+ return createWriteRecord(DATA1_TABLE_INFO);
+ }
+
+ private WriteRecord createWriteRecord(TableInfo tableInfo) {
+ return WriteRecord.forCompactedAppend(tableInfo, DATA1_PHYSICAL_TABLE_PATH, row, null);
}
private CompactedLogWriteBatch createLogWriteBatch(TableBucket tb) throws Exception {
@@ -216,13 +257,32 @@ public class CompactedLogWriteBatchTest {
System.currentTimeMillis());
}
+ private TableInfo withTableIdAndSchemaId(long tableId, int schemaId) {
+ return new TableInfo(
+ DATA1_TABLE_INFO.getTablePath(),
+ tableId,
+ schemaId,
+ DATA1_TABLE_INFO.getSchema(),
+ DATA1_TABLE_INFO.getBucketKeys(),
+ DATA1_TABLE_INFO.getPartitionKeys(),
+ DATA1_TABLE_INFO.getNumBuckets(),
+ DATA1_TABLE_INFO.getProperties(),
+ DATA1_TABLE_INFO.getCustomProperties(),
+ DATA1_TABLE_INFO.getRemoteDataDir(),
+ DATA1_TABLE_INFO.getComment().orElse(null),
+ DATA1_TABLE_INFO.getCreatedTime(),
+ DATA1_TABLE_INFO.getModifiedTime());
+ }
+
private void assertDefaultLogRecordBatchEquals(LogRecordBatch recordBatch) {
assertThat(recordBatch.getRecordCount()).isEqualTo(1);
assertThat(recordBatch.baseLogOffset()).isEqualTo(0L);
assertThat(recordBatch.schemaId()).isEqualTo((short) DATA1_TABLE_INFO.getSchemaId());
try (LogRecordReadContext readContext =
LogRecordReadContext.createCompactedRowReadContext(
- DATA1_ROW_TYPE, DATA1_TABLE_INFO.getSchemaId());
+ DATA1_ROW_TYPE,
+ DATA1_TABLE_INFO.getSchemaId(),
+ TEST_SCHEMA_GETTER);
CloseableIterator<LogRecord> iterator = recordBatch.records(readContext)) {
assertThat(iterator.hasNext()).isTrue();
LogRecord record = iterator.next();
diff --git a/fluss-client/src/test/java/org/apache/fluss/client/write/RecordAccumulatorTest.java b/fluss-client/src/test/java/org/apache/fluss/client/write/RecordAccumulatorTest.java
index 0e4ab9c97..82e0e35cc 100644
--- a/fluss-client/src/test/java/org/apache/fluss/client/write/RecordAccumulatorTest.java
+++ b/fluss-client/src/test/java/org/apache/fluss/client/write/RecordAccumulatorTest.java
@@ -309,6 +309,33 @@ class RecordAccumulatorTest {
}
}
+ @Test
+ void testAppendRollsNewBatchWhenSchemaIdChanges() throws Exception {
+ int batchSize = 1024;
+ IndexedRow row = indexedRow(DATA1_ROW_TYPE, new Object[] {1, "a"});
+ RecordAccumulator accum = createTestRecordAccumulator(batchSize, 10L * batchSize);
+ int oldSchemaId = DATA1_TABLE_INFO.getSchemaId();
+ int newSchemaId = oldSchemaId + 1;
+
+ accum.append(createRecord(row), writeCallback, cluster, 0, false);
+ // a record with a bumped schema id closes the old-schema batch and rolls a new one.
+ accum.append(
+ createRecord(row, withSchemaId(newSchemaId)), writeCallback, cluster, 0, false);
+
+ Deque<WriteBatch> writeBatches =
+ accum.getReadyDeque(DATA1_PHYSICAL_TABLE_PATH, tb1.getBucket());
+ assertThat(writeBatches).hasSize(2);
+ Iterator<WriteBatch> batchIterator = writeBatches.iterator();
+ WriteBatch oldBatch = batchIterator.next();
+ assertThat(oldBatch.isClosed()).isTrue();
+ assertThat(oldBatch.schemaId()).isEqualTo(oldSchemaId);
+ assertThat(oldBatch.getRecordCount()).isEqualTo(1);
+ WriteBatch newBatch = batchIterator.next();
+ assertThat(newBatch.isClosed()).isFalse();
+ assertThat(newBatch.schemaId()).isEqualTo(newSchemaId);
+ assertThat(newBatch.getRecordCount()).isEqualTo(1);
+ }
+
@Test
void testAppendLarge() throws Exception {
int batchSize = 100;
@@ -550,7 +577,28 @@ class RecordAccumulatorTest {
* format , see {@link #updateCluster(List)}.
*/
private WriteRecord createRecord(IndexedRow row) {
- return WriteRecord.forIndexedAppend(DATA1_TABLE_INFO, DATA1_PHYSICAL_TABLE_PATH, row, null);
+ return createRecord(row, DATA1_TABLE_INFO);
+ }
+
+ private WriteRecord createRecord(IndexedRow row, TableInfo tableInfo) {
+ return WriteRecord.forIndexedAppend(tableInfo, DATA1_PHYSICAL_TABLE_PATH, row, null);
+ }
+
+ private TableInfo withSchemaId(int schemaId) {
+ return new TableInfo(
+ DATA1_TABLE_INFO.getTablePath(),
+ DATA1_TABLE_INFO.getTableId(),
+ schemaId,
+ DATA1_TABLE_INFO.getSchema(),
+ DATA1_TABLE_INFO.getBucketKeys(),
+ DATA1_TABLE_INFO.getPartitionKeys(),
+ DATA1_TABLE_INFO.getNumBuckets(),
+ DATA1_TABLE_INFO.getProperties(),
+ DATA1_TABLE_INFO.getCustomProperties(),
+ DATA1_TABLE_INFO.getRemoteDataDir(),
+ DATA1_TABLE_INFO.getComment().orElse(null),
+ DATA1_TABLE_INFO.getCreatedTime(),
+ DATA1_TABLE_INFO.getModifiedTime());
}
private Cluster updateCluster(List<BucketLocation> bucketLocations) {
diff --git a/fluss-common/src/main/java/org/apache/fluss/record/DefaultLogRecordBatch.java b/fluss-common/src/main/java/org/apache/fluss/record/DefaultLogRecordBatch.java
index e5e961fb1..d4bfcb9ad 100644
--- a/fluss-common/src/main/java/org/apache/fluss/record/DefaultLogRecordBatch.java
+++ b/fluss-common/src/main/java/org/apache/fluss/record/DefaultLogRecordBatch.java
@@ -245,20 +245,20 @@ public class DefaultLogRecordBatch implements LogRecordBatch {
long timestamp = commitTimestamp();
LogFormat logFormat = context.getLogFormat();
RowType rowType = context.getRowType(schemaId);
+ ProjectedRow outputProjection = context.getOutputProjectedRow(schemaId);
switch (logFormat) {
case ARROW:
return columnRecordIterator(
rowType,
- context.getOutputProjectedRow(schemaId),
+ outputProjection,
context.getVectorSchemaRoot(schemaId),
context.getBufferAllocator(),
timestamp);
case INDEXED:
- return rowRecordIterator(
- rowType, context.getOutputProjectedRow(schemaId), timestamp);
+ return rowRecordIterator(rowType, outputProjection, timestamp);
case COMPACTED:
- return compactedRowRecordIterator(rowType, timestamp);
+ return compactedRowRecordIterator(rowType, outputProjection, timestamp);
default:
throw new IllegalArgumentException("Unsupported log format: " + logFormat);
}
@@ -354,7 +354,7 @@ public class DefaultLogRecordBatch implements LogRecordBatch {
}
private CloseableIterator<LogRecord> compactedRowRecordIterator(
- RowType rowType, long timestamp) {
+ RowType rowType, @Nullable ProjectedRow outputProjection, long timestamp) {
DataType[] fieldTypes = rowType.getChildren().toArray(new DataType[0]);
return new LogRecordIterator() {
int position = DefaultLogRecordBatch.this.position + recordsDataOffset();
@@ -367,7 +367,15 @@ public class DefaultLogRecordBatch implements LogRecordBatch {
segment, position, baseOffset + rowId, timestamp, fieldTypes);
rowId++;
position += logRecord.getSizeInBytes();
- return logRecord;
+ if (outputProjection == null) {
+ return logRecord;
+ } else {
+ return new GenericRecord(
+ logRecord.logOffset(),
+ logRecord.timestamp(),
+ logRecord.getChangeType(),
+ outputProjection.replaceRow(logRecord.getRow()));
+ }
}
@Override
diff --git a/fluss-common/src/main/java/org/apache/fluss/record/LogRecordReadContext.java b/fluss-common/src/main/java/org/apache/fluss/record/LogRecordReadContext.java
index 904fbd505..cc5bcbdba 100644
--- a/fluss-common/src/main/java/org/apache/fluss/record/LogRecordReadContext.java
+++ b/fluss-common/src/main/java/org/apache/fluss/record/LogRecordReadContext.java
@@ -45,48 +45,68 @@ import java.util.List;
import java.util.concurrent.ConcurrentHashMap;
import java.util.stream.IntStream;
+import static org.apache.fluss.utils.Preconditions.checkNotNull;
+
/** A simple implementation for {@link LogRecordBatch.ReadContext}. */
@ThreadSafe
public class LogRecordReadContext
implements LogRecordBatch.ReadContext, ArrowRecordBatchContext, AutoCloseable {
- // the log format of the table
+ /**
+ * Schema resolution strategy for reading records.
+ *
+ * <p>Replaces an earlier {@code boolean readAsTargetSchema} flag that was adjacent to {@code
+ * boolean readFromRemote} in the factory signature and prone to accidental swaps at call sites.
+ */
+ public enum SchemaResolution {
+ /**
+ * Records are projected to the table's current target schema. Columns added via later
+ * schema evolutions are hidden; columns removed from the target are dropped.
+ */
+ TARGET,
+ /**
+ * Records are returned with their original write-time schema. Callers observe schema
+ * evolution: fields added in a newer schema version become visible once written with that
+ * schema.
+ */
+ DYNAMIC
+ }
+
+ private final long tableId;
private final LogFormat logFormat;
- // the schema of the date read form server or remote. (which is projected in the server side)
- private final RowType dataRowType;
- // the static schemaId of the table, should support dynamic schema evolution in the future
- private final int targetSchemaId;
- // the shaded Arrow memory buffer allocator, should be null if not ARROW log format
+ /**
+ * When present, provides fixed schema identity, data row type, and pre-built field getters.
+ * When {@code null}, the reader dynamically adapts to each batch's schema so that columns added
+ * via schema evolution are visible.
+ */
+ @Nullable private final ReadTarget target;
+
@Nullable private final BufferAllocator bufferAllocator;
// the unshaded Arrow memory buffer allocator, declared as AutoCloseable to avoid
// importing unshaded Arrow classes in this module (they are provided-scope)
@Nullable private volatile AutoCloseable unshadedBufferAllocator;
- private final FieldGetter[] selectedFieldGetters;
- // whether the projection is push downed to the server side and the returned data is pruned.
private final boolean projectionPushDowned;
private final SchemaGetter schemaGetter;
private final ConcurrentHashMap<Integer, VectorSchemaRoot> vectorSchemaRootMap =
new ConcurrentHashMap<>();
private final Object unshadedArrowResourceLock = new Object();
+ private final ConcurrentHashMap<Integer, FieldGetter[]> fieldGetterCache =
+ new ConcurrentHashMap<>();
- public static LogRecordReadContext createReadContext(
- TableInfo tableInfo,
- boolean readFromRemote,
- @Nullable Projection projection,
- SchemaGetter schemaGetter) {
- return createReadContext(
- tableInfo,
- readFromRemote,
- projection,
- schemaGetter,
- new ChunkedAllocationManager.ChunkedFactory());
- }
+ // -------------------------------------------------------------------------
+ // Main factory methods
+ // -------------------------------------------------------------------------
/**
* Creates a {@link LogRecordReadContext} with a custom {@link AllocationManager.Factory}.
*
+ * <p>When {@code schemaResolution} is {@link SchemaResolution#DYNAMIC} and {@code projection}
+ * is {@code null}, no fixed {@link ReadTarget} is created. In that mode the reader dynamically
+ * builds field getters per batch schema, so columns added via schema evolution become visible.
+ *
* @param tableInfo the table info of the table to read
* @param readFromRemote whether the data is read from remote storage
+ * @param schemaResolution how to resolve the output schema: see {@link SchemaResolution}
* @param projection the projection to apply, or null for all fields
* @param schemaGetter the schema getter to resolve schema by id
* @param allocationManagerFactory the factory for creating Arrow memory allocations
@@ -94,76 +114,89 @@ public class LogRecordReadContext
public static LogRecordReadContext createReadContext(
TableInfo tableInfo,
boolean readFromRemote,
+ SchemaResolution schemaResolution,
@Nullable Projection projection,
SchemaGetter schemaGetter,
AllocationManager.Factory allocationManagerFactory) {
+ checkNotNull(schemaResolution, "schemaResolution");
+ boolean readAsTargetSchema = schemaResolution == SchemaResolution.TARGET;
RowType rowType = tableInfo.getRowType();
LogFormat logFormat = tableInfo.getTableConfig().getLogFormat();
// only for arrow log format, the projection can be push downed to the server side
- boolean projectionPushDowned = projection != null && logFormat == LogFormat.ARROW;
- int schemaId = tableInfo.getSchemaId();
- if (projection == null) {
- // set a default dummy projection to simplify code
- projection = Projection.of(IntStream.range(0, rowType.getFieldCount()).toArray());
+ boolean projectionPushDowned =
+ logFormat == LogFormat.ARROW && !readFromRemote && projection != null;
+
+ // Compute ReadTarget when there is a fixed output configuration.
+ // When schemaResolution is DYNAMIC and no projection is set, target is null and
+ // the reader dynamically adapts to each batch's schema.
+ ReadTarget target = null;
+ if (readAsTargetSchema || projection != null) {
+ int schemaId = tableInfo.getSchemaId();
+ if (projection == null) {
+ // set a default dummy projection to simplify code
+ projection = Projection.of(IntStream.range(0, rowType.getFieldCount()).toArray());
+ }
+ RowType dataRowType;
+ int[] selectedFields;
+ if (projectionPushDowned) {
+ dataRowType = projection.projectInOrder(rowType);
+ selectedFields = projection.getReorderingIndexes();
+ } else {
+ dataRowType = rowType;
+ selectedFields = projection.getProjection();
+ }
+ target = new ReadTarget(schemaId, dataRowType, selectedFields);
}
if (logFormat == LogFormat.ARROW) {
- if (readFromRemote) {
- // currently, for remote read, arrow log doesn't support projection pushdown,
- // so set the rowType as is.
- int[] selectedFields = projection.getProjection();
- return createArrowReadContext(
- rowType,
- schemaId,
- selectedFields,
- false,
- schemaGetter,
- allocationManagerFactory);
- } else {
- // arrow data that returned from server has been projected (in order)
- RowType projectedRowType = projection.projectInOrder(rowType);
- // need to reorder the fields for final output
- int[] selectedFields = projection.getReorderingIndexes();
- return createArrowReadContext(
- projectedRowType,
- schemaId,
- selectedFields,
- projectionPushDowned,
- schemaGetter,
- allocationManagerFactory);
- }
+ return createArrowReadContext(
+ tableInfo.getTableId(),
+ target,
+ projectionPushDowned,
+ schemaGetter,
+ allocationManagerFactory);
} else if (logFormat == LogFormat.INDEXED) {
- int[] selectedFields = projection.getProjection();
- return createIndexedReadContext(rowType, schemaId, selectedFields, schemaGetter);
+ return createIndexedReadContext(tableInfo.getTableId(), target, schemaGetter);
} else if (logFormat == LogFormat.COMPACTED) {
- int[] selectedFields = projection.getProjection();
- return createCompactedRowReadContext(rowType, schemaId, selectedFields);
+ return createCompactedRowReadContext(tableInfo.getTableId(), target, schemaGetter);
} else {
throw new IllegalArgumentException("Unsupported log format: " + logFormat);
}
}
+ // -------------------------------------------------------------------------
+ // Internal factory methods
+ // -------------------------------------------------------------------------
+
private static LogRecordReadContext createArrowReadContext(
- RowType dataRowType,
- int schemaId,
- int[] selectedFields,
+ long tableId,
+ @Nullable ReadTarget target,
boolean projectionPushDowned,
SchemaGetter schemaGetter,
AllocationManager.Factory allocationManagerFactory) {
// TODO: use a more reasonable memory limit
BufferAllocator allocator =
BufferAllocatorUtil.createBufferAllocator(allocationManagerFactory);
- FieldGetter[] fieldGetters = buildProjectedFieldGetters(dataRowType, selectedFields);
return new LogRecordReadContext(
- LogFormat.ARROW,
- dataRowType,
- schemaId,
- allocator,
- fieldGetters,
- projectionPushDowned,
- schemaGetter);
+ tableId, LogFormat.ARROW, target, allocator, projectionPushDowned, schemaGetter);
+ }
+
+ private static LogRecordReadContext createIndexedReadContext(
+ long tableId, @Nullable ReadTarget target, SchemaGetter schemaGetter) {
+ return new LogRecordReadContext(
+ tableId, LogFormat.INDEXED, target, null, false, schemaGetter);
+ }
+
+ private static LogRecordReadContext createCompactedRowReadContext(
+ long tableId, @Nullable ReadTarget target, @Nullable SchemaGetter schemaGetter) {
+ return new LogRecordReadContext(
+ tableId, LogFormat.COMPACTED, target, null, false, schemaGetter);
}
+ // -------------------------------------------------------------------------
+ // @VisibleForTesting and convenience factory methods
+ // -------------------------------------------------------------------------
+
/**
* Creates a LogRecordReadContext for ARROW log format, that underlying Arrow resources are not
* reused.
@@ -176,13 +209,9 @@ public class LogRecordReadContext
public static LogRecordReadContext createArrowReadContext(
RowType rowType, int schemaId, SchemaGetter schemaGetter) {
int[] selectedFields = IntStream.range(0, rowType.getFieldCount()).toArray();
+ ReadTarget target = new ReadTarget(schemaId, rowType, selectedFields);
return createArrowReadContext(
- rowType,
- schemaId,
- selectedFields,
- false,
- schemaGetter,
- new ChunkedAllocationManager.ChunkedFactory());
+ -1L, target, false, schemaGetter, new ChunkedAllocationManager.ChunkedFactory());
}
@VisibleForTesting
@@ -192,10 +221,10 @@ public class LogRecordReadContext
SchemaGetter schemaGetter,
boolean projectionPushDowned) {
int[] selectedFields = IntStream.range(0, rowType.getFieldCount()).toArray();
+ ReadTarget target = new ReadTarget(schemaId, rowType, selectedFields);
return createArrowReadContext(
- rowType,
- schemaId,
- selectedFields,
+ -1L,
+ target,
projectionPushDowned,
schemaGetter,
new ChunkedAllocationManager.ChunkedFactory());
@@ -212,87 +241,41 @@ public class LogRecordReadContext
public static LogRecordReadContext createIndexedReadContext(
RowType rowType, int schemaId, SchemaGetter schemaGetter) {
int[] selectedFields = IntStream.range(0, rowType.getFieldCount()).toArray();
- return createIndexedReadContext(rowType, schemaId, selectedFields, schemaGetter);
+ ReadTarget target = new ReadTarget(schemaId, rowType, selectedFields);
+ return createIndexedReadContext(-1L, target, schemaGetter);
}
/** Creates a LogRecordReadContext for COMPACTED log format. */
- public static LogRecordReadContext createCompactedRowReadContext(
- RowType rowType, int schemaId) {
- int[] selectedFields = IntStream.range(0, rowType.getFieldCount()).toArray();
- return createCompactedRowReadContext(rowType, schemaId, selectedFields, null);
- }
-
- /** Creates a LogRecordReadContext for COMPACTED log format with schema evolution support. */
public static LogRecordReadContext createCompactedRowReadContext(
RowType rowType, int schemaId, SchemaGetter schemaGetter) {
int[] selectedFields = IntStream.range(0, rowType.getFieldCount()).toArray();
- return createCompactedRowReadContext(rowType, schemaId, selectedFields, schemaGetter);
- }
-
- /**
- * Creates a LogRecordReadContext for INDEXED log format.
- *
- * @param rowType the schema of the read data
- * @param schemaId the schemaId of the table
- * @param selectedFields the final selected fields of the read data
- * @param schemaGetter the schema getter of to get schema by schemaId
- */
- public static LogRecordReadContext createIndexedReadContext(
- RowType rowType, int schemaId, int[] selectedFields, SchemaGetter schemaGetter) {
- FieldGetter[] fieldGetters = buildProjectedFieldGetters(rowType, selectedFields);
- // for INDEXED log format, the projection is NEVER push downed to the server side
- return new LogRecordReadContext(
- LogFormat.INDEXED, rowType, schemaId, null, fieldGetters, false, schemaGetter);
+ ReadTarget target = new ReadTarget(schemaId, rowType, selectedFields);
+ return createCompactedRowReadContext(-1L, target, schemaGetter);
}
- /**
- * Creates a LogRecordReadContext for COMPACTED log format.
- *
- * @param rowType the schema of the read data
- * @param schemaId the schemaId of the table
- * @param selectedFields the final selected fields of the read data
- */
- public static LogRecordReadContext createCompactedRowReadContext(
- RowType rowType, int schemaId, int[] selectedFields) {
- return createCompactedRowReadContext(rowType, schemaId, selectedFields, null);
- }
-
- /**
- * Creates a LogRecordReadContext for COMPACTED log format.
- *
- * @param rowType the schema of the read data
- * @param schemaId the schemaId of the table
- * @param selectedFields the final selected fields of the read data
- * @param schemaGetter the schema getter to resolve evolved batch schemas
- */
- public static LogRecordReadContext createCompactedRowReadContext(
- RowType rowType,
- int schemaId,
- int[] selectedFields,
- @Nullable SchemaGetter schemaGetter) {
- FieldGetter[] fieldGetters = buildProjectedFieldGetters(rowType, selectedFields);
- // for COMPACTED log format, the projection is NEVER push downed to the server side
- return new LogRecordReadContext(
- LogFormat.COMPACTED, rowType, schemaId, null, fieldGetters, false, schemaGetter);
- }
+ // -------------------------------------------------------------------------
+ // Constructor
+ // -------------------------------------------------------------------------
private LogRecordReadContext(
+ long tableId,
LogFormat logFormat,
- RowType targetDataRowType,
- int targetSchemaId,
- BufferAllocator bufferAllocator,
- FieldGetter[] selectedFieldGetters,
+ @Nullable ReadTarget target,
+ @Nullable BufferAllocator bufferAllocator,
boolean projectionPushDowned,
SchemaGetter schemaGetter) {
+ this.tableId = tableId;
this.logFormat = logFormat;
- this.dataRowType = targetDataRowType;
- this.targetSchemaId = targetSchemaId;
+ this.target = target;
this.bufferAllocator = bufferAllocator;
- this.selectedFieldGetters = selectedFieldGetters;
this.projectionPushDowned = projectionPushDowned;
this.schemaGetter = schemaGetter;
}
+ // -------------------------------------------------------------------------
+ // ReadContext interface methods
+ // -------------------------------------------------------------------------
+
@Override
public LogFormat getLogFormat() {
return logFormat;
@@ -300,17 +283,30 @@ public class LogRecordReadContext
@Override
public RowType getRowType(int schemaId) {
- if (isSameRowType(schemaId)) {
- return dataRowType;
+ if (isProjectionPushDowned() || (target != null && target.schemaId == schemaId)) {
+ assert target != null;
+ return target.dataRowType;
}
-
- Schema schema = schemaGetter.getSchema(schemaId);
- return schema.getRowType();
+ return schemaGetter.getSchema(schemaId).getRowType();
}
- /** Get the selected field getters for the read data. */
- public FieldGetter[] getSelectedFieldGetters() {
- return selectedFieldGetters;
+ /**
+ * Get the selected field getters for the read data. When no fixed {@link ReadTarget} is
+ * configured, getters are built dynamically from the batch's actual schema so that columns
+ * added via schema evolution are visible.
+ */
+ public FieldGetter[] getSelectedFieldGetters(int schemaId) {
+ if (target != null) {
+ return target.fieldGetters;
+ }
+ // Dynamic: build all-field getters from the batch's actual schema.
+ return fieldGetterCache.computeIfAbsent(
+ schemaId,
+ id -> {
+ RowType batchRowType = schemaGetter.getSchema(id).getRowType();
+ int[] allFields = IntStream.range(0, batchRowType.getFieldCount()).toArray();
+ return buildProjectedFieldGetters(batchRowType, allFields);
+ });
}
/** Whether the projection is push downed to the server side and the returned data is pruned. */
@@ -359,6 +355,7 @@ public class LogRecordReadContext
if (logFormat != LogFormat.ARROW) {
throw new IllegalArgumentException("Only Arrow log format provides buffer allocator.");
}
+ checkNotNull(bufferAllocator, "The buffer allocator is not available.");
return bufferAllocator;
}
@@ -374,15 +371,14 @@ public class LogRecordReadContext
@Nullable
@Override
public ProjectedRow getOutputProjectedRow(int schemaId) {
- if (isSameRowType(schemaId)) {
- return null;
- }
- if (schemaGetter == null) {
+
+ if (target == null || isProjectionPushDowned() || schemaId == target.schemaId) {
return null;
}
+
// TODO: should we cache the projection?
Schema originSchema = schemaGetter.getSchema(schemaId);
- Schema expectedSchema = schemaGetter.getSchema(targetSchemaId);
+ Schema expectedSchema = schemaGetter.getSchema(target.schemaId);
return ProjectedRow.from(originSchema, expectedSchema);
}
@@ -452,7 +448,7 @@ public class LogRecordReadContext
if (schemaMapping != null) {
outputRoot =
UnshadedArrowReadUtils.projectVectorSchemaRoot(
- readRoot, dataRowType, schemaMapping, allocator);
+ readRoot, target.dataRowType, schemaMapping, allocator);
readRoot.close();
readRoot = null;
}
@@ -472,11 +468,10 @@ public class LogRecordReadContext
}
}
- private boolean isSameRowType(int schemaId) {
- return targetSchemaId == schemaId || isProjectionPushDowned();
- }
-
- private static FieldGetter[] buildProjectedFieldGetters(RowType rowType, int[] selectedFields) {
+ // -------------------------------------------------------------------------
+ // Private helpers
+ // -------------------------------------------------------------------------
+ static FieldGetter[] buildProjectedFieldGetters(RowType rowType, int[] selectedFields) {
List<DataType> dataTypeList = rowType.getChildren();
FieldGetter[] fieldGetters = new FieldGetter[selectedFields.length];
for (int i = 0; i < fieldGetters.length; i++) {
@@ -487,4 +482,37 @@ public class LogRecordReadContext
}
return fieldGetters;
}
+
+ // -------------------------------------------------------------------------
+ // ReadTarget
+ // -------------------------------------------------------------------------
+
+ /**
+ * Encapsulates the target output configuration for reading log records. Contains the target
+ * schema identity, data row type, selected field indices, and pre-built field getters.
+ *
+ * <p>When a {@code ReadTarget} is provided, field getters are fixed and schema evolution may be
+ * enabled. When absent ({@code null}), the reader dynamically adapts to each batch's schema so
+ * that columns added via schema evolution are visible.
+ */
+ static class ReadTarget {
+ /** The schema id this configuration was built for. */
+ final int schemaId;
+
+ /** The row type of the data (may be server-projected for ARROW format). */
+ final RowType dataRowType;
+
+ /** The field indices to select from the data row type. */
+ final int[] selectedFields;
+
+ /** Pre-built field getters corresponding to {@link #selectedFields}. */
+ final FieldGetter[] fieldGetters;
+
+ ReadTarget(int schemaId, RowType dataRowType, int[] selectedFields) {
+ this.schemaId = schemaId;
+ this.dataRowType = dataRowType;
+ this.selectedFields = selectedFields;
+ this.fieldGetters = buildProjectedFieldGetters(dataRowType, selectedFields);
+ }
+ }
}
diff --git a/fluss-common/src/test/java/org/apache/fluss/record/DefaultLogRecordBatchTest.java b/fluss-common/src/test/java/org/apache/fluss/record/DefaultLogRecordBatchTest.java
index fb55be89a..2860eaece 100644
--- a/fluss-common/src/test/java/org/apache/fluss/record/DefaultLogRecordBatchTest.java
+++ b/fluss-common/src/test/java/org/apache/fluss/record/DefaultLogRecordBatchTest.java
@@ -90,10 +90,7 @@ public class DefaultLogRecordBatchTest extends LogTestBase {
assertThat(logRecordBatch.isValid()).isTrue();
assertThat(logRecordBatch.schemaId()).isEqualTo(schemaId);
- SchemaGetter schemaGetter =
- new TestingSchemaGetter(
- new SchemaInfo(
- Schema.newBuilder().fromRowType(allRowType).build(), schemaId));
+ SchemaGetter schemaGetter = new TestingSchemaGetter(schemaId, allRowType);
// verify record.
int i = 0;
try (LogRecordReadContext readContext =
diff --git a/fluss-common/src/test/java/org/apache/fluss/record/FileLogProjectionTest.java b/fluss-common/src/test/java/org/apache/fluss/record/FileLogProjectionTest.java
index 1fb197c01..6fa2c2ad1 100644
--- a/fluss-common/src/test/java/org/apache/fluss/record/FileLogProjectionTest.java
+++ b/fluss-common/src/test/java/org/apache/fluss/record/FileLogProjectionTest.java
@@ -599,7 +599,8 @@ class FileLogProjectionTest {
RowType projectedType = TestData.DATA2_ROW_TYPE.project(new int[] {0, 2});
try (LogRecordReadContext context =
- createArrowReadContext(projectedType, schemaIdForData2, testingSchemaGetter)) {
+ createArrowReadContext(
+ projectedType, schemaIdForData2, testingSchemaGetter, true)) {
for (LogRecordBatch projectedBatch : projectedRecords.batches()) {
try (CloseableIterator<LogRecord> records = projectedBatch.records(context)) {
int recordCount = 0;
@@ -692,7 +693,8 @@ class FileLogProjectionTest {
RowType projectedType = TestData.DATA2_ROW_TYPE.project(new int[] {1});
try (LogRecordReadContext context =
- createArrowReadContext(projectedType, schemaIdForData2, testingSchemaGetter)) {
+ createArrowReadContext(
+ projectedType, schemaIdForData2, testingSchemaGetter, true)) {
for (LogRecordBatch projectedBatch : projectedRecords.batches()) {
try (CloseableIterator<LogRecord> records = projectedBatch.records(context)) {
int recordCount = 0;
@@ -744,7 +746,8 @@ class FileLogProjectionTest {
RowType projectedType = TestData.DATA2_ROW_TYPE.project(new int[] {0, 1, 2});
try (LogRecordReadContext context =
- createArrowReadContext(projectedType, schemaIdForData2, testingSchemaGetter)) {
+ createArrowReadContext(
+ projectedType, schemaIdForData2, testingSchemaGetter, true)) {
for (LogRecordBatch projectedBatch : projectedRecords.batches()) {
try (CloseableIterator<LogRecord> records = projectedBatch.records(context)) {
int recordCount = 0;
@@ -824,7 +827,8 @@ class FileLogProjectionTest {
RowType projectedType = TestData.DATA1_ROW_TYPE.project(new int[] {0});
try (LogRecordReadContext context =
- createArrowReadContext(projectedType, DEFAULT_SCHEMA_ID, testingSchemaGetter)) {
+ createArrowReadContext(
+ projectedType, DEFAULT_SCHEMA_ID, testingSchemaGetter, true)) {
// Verify first batch
int firstBatchCount = 0;
for (LogRecordBatch projectedBatch : firstProjectedRecords.batches()) {
@@ -881,7 +885,8 @@ class FileLogProjectionTest {
RowType projectedType = TestData.DATA2_ROW_TYPE.project(new int[] {0, 1});
try (LogRecordReadContext context =
- createArrowReadContext(projectedType, DEFAULT_SCHEMA_ID, testingSchemaGetter)) {
+ createArrowReadContext(
+ projectedType, DEFAULT_SCHEMA_ID, testingSchemaGetter, true)) {
for (LogRecordBatch projectedBatch : projectedRecords.batches()) {
// Verify that statistics are not available in projected batch
assertThat(projectedBatch.getStatistics(context)).isEmpty();
@@ -932,7 +937,8 @@ class FileLogProjectionTest {
RowType projectedType = TestData.DATA2_ROW_TYPE.project(new int[] {0, 1});
try (LogRecordReadContext context =
- createArrowReadContext(projectedType, DEFAULT_SCHEMA_ID, testingSchemaGetter)) {
+ createArrowReadContext(
+ projectedType, DEFAULT_SCHEMA_ID, testingSchemaGetter, true)) {
for (LogRecordBatch projectedBatch : projectedRecords.batches()) {
// For V0/V1, statistics should be 0 (not supported)
assertThat(projectedBatch.getStatistics(context)).isEmpty();
@@ -994,7 +1000,8 @@ class FileLogProjectionTest {
RowType projectedType = TestData.DATA2_ROW_TYPE.project(new int[] {0, 2});
try (LogRecordReadContext context =
- createArrowReadContext(projectedType, schemaIdForData2, testingSchemaGetter)) {
+ createArrowReadContext(
+ projectedType, schemaIdForData2, testingSchemaGetter, true)) {
for (LogRecordBatch projectedBatch : projectedRecords.batches()) {
// Verify that statistics are not available in projected batch
assertThat(projectedBatch.getStatistics(context)).isEmpty();
diff --git a/fluss-common/src/test/java/org/apache/fluss/record/FileLogRecordsTest.java b/fluss-common/src/test/java/org/apache/fluss/record/FileLogRecordsTest.java
index b20505c48..f85ec23ab 100644
--- a/fluss-common/src/test/java/org/apache/fluss/record/FileLogRecordsTest.java
+++ b/fluss-common/src/test/java/org/apache/fluss/record/FileLogRecordsTest.java
@@ -38,6 +38,7 @@ import java.util.concurrent.Future;
import static org.apache.fluss.record.TestData.DATA1;
import static org.apache.fluss.record.TestData.DATA1_ROW_TYPE;
+import static org.apache.fluss.record.TestData.TEST_SCHEMA_GETTER;
import static org.apache.fluss.shaded.guava32.com.google.common.collect.Lists.newArrayList;
import static org.apache.fluss.testutils.DataTestUtils.assertLogRecordBatchEquals;
import static org.apache.fluss.testutils.DataTestUtils.assertLogRecordsEquals;
@@ -182,7 +183,10 @@ public class FileLogRecordsTest extends LogTestBase {
for (MemoryLogRecords value : values) {
assertThat(fileLogRecordsIterator.hasNext()).isTrue();
assertLogRecordBatchEquals(
- DATA1_ROW_TYPE, fileLogRecordsIterator.next(), value.batchIterator().next());
+ DATA1_ROW_TYPE,
+ fileLogRecordsIterator.next(),
+ value.batchIterator().next(),
+ TEST_SCHEMA_GETTER);
}
assertThat(fileLogRecords.channel().position()).isEqualTo(position);
}
@@ -191,7 +195,7 @@ public class FileLogRecordsTest extends LogTestBase {
void testRead() throws Exception {
FileLogRecords read = fileLogRecords.slice(0, fileLogRecords.sizeInBytes());
assertThat(read.sizeInBytes()).isEqualTo(fileLogRecords.sizeInBytes());
- assertLogRecordsEquals(DATA1_ROW_TYPE, read, fileLogRecords);
+ assertLogRecordsEquals(DATA1_ROW_TYPE, read, fileLogRecords, TEST_SCHEMA_GETTER);
List<LogRecordBatch> items = batches(read);
LogRecordBatch first = items.get(0);
diff --git a/fluss-common/src/test/java/org/apache/fluss/record/LogRecordBatchStatisticsTest.java b/fluss-common/src/test/java/org/apache/fluss/record/LogRecordBatchStatisticsTest.java
index dba5145b7..5e0f07972 100644
--- a/fluss-common/src/test/java/org/apache/fluss/record/LogRecordBatchStatisticsTest.java
+++ b/fluss-common/src/test/java/org/apache/fluss/record/LogRecordBatchStatisticsTest.java
@@ -20,6 +20,7 @@ package org.apache.fluss.record;
import org.apache.fluss.memory.MemorySegment;
import org.apache.fluss.memory.MemorySegmentOutputView;
import org.apache.fluss.metadata.LogFormat;
+import org.apache.fluss.metadata.Schema;
import org.apache.fluss.row.InternalRow;
import org.apache.fluss.row.aligned.AlignedRow;
import org.apache.fluss.testutils.DataTestUtils;
@@ -74,7 +75,11 @@ public class LogRecordBatchStatisticsTest extends LogTestBase {
LogRecordReadContext.createArrowReadContext(
TestData.STATISTICS_WITH_BOOLEAN_ROW_TYPE,
DEFAULT_SCHEMA_ID,
- TEST_SCHEMA_GETTER)) {
+ new TestingSchemaGetter(
+ DEFAULT_SCHEMA_ID,
+ Schema.newBuilder()
+ .fromRowType(TestData.STATISTICS_WITH_BOOLEAN_ROW_TYPE)
+ .build()))) {
Optional<LogRecordBatchStatistics> statisticsOpt = batch.getStatistics(readContext);
assertThat(statisticsOpt).isPresent();
@@ -108,7 +113,11 @@ public class LogRecordBatchStatisticsTest extends LogTestBase {
LogRecordReadContext.createArrowReadContext(
TestData.STATISTICS_WITH_NULLS_ROW_TYPE,
DEFAULT_SCHEMA_ID,
- TEST_SCHEMA_GETTER)) {
+ new TestingSchemaGetter(
+ DEFAULT_SCHEMA_ID,
+ Schema.newBuilder()
+ .fromRowType(TestData.STATISTICS_WITH_NULLS_ROW_TYPE)
+ .build()))) {
LogRecordBatchStatistics statistics = batch.getStatistics(readContext).get();
assertThat(statistics.getNullCounts()[0]).isEqualTo(1);
assertThat(statistics.getNullCounts()[1]).isEqualTo(1);
@@ -178,7 +187,11 @@ public class LogRecordBatchStatisticsTest extends LogTestBase {
LogRecordReadContext.createArrowReadContext(
TestData.STATISTICS_WITH_BOOLEAN_ROW_TYPE,
DEFAULT_SCHEMA_ID,
- TEST_SCHEMA_GETTER)) {
+ new TestingSchemaGetter(
+ DEFAULT_SCHEMA_ID,
+ Schema.newBuilder()
+ .fromRowType(TestData.STATISTICS_WITH_BOOLEAN_ROW_TYPE)
+ .build()))) {
Optional<LogRecordBatchStatistics> stats1 = batch.getStatistics(readContext);
Optional<LogRecordBatchStatistics> stats2 = batch.getStatistics(readContext);
assertThat(stats2.get()).isSameAs(stats1.get());
@@ -209,7 +222,11 @@ public class LogRecordBatchStatisticsTest extends LogTestBase {
LogRecordReadContext.createArrowReadContext(
TestData.STATISTICS_WITH_BOOLEAN_ROW_TYPE,
DEFAULT_SCHEMA_ID,
- TEST_SCHEMA_GETTER)) {
+ new TestingSchemaGetter(
+ DEFAULT_SCHEMA_ID,
+ Schema.newBuilder()
+ .fromRowType(TestData.STATISTICS_WITH_BOOLEAN_ROW_TYPE)
+ .build()))) {
LogRecordBatchStatistics statistics = batch.getStatistics(readContext).get();
assertThat(statistics.getMinValues().getInt(0)).isEqualTo(1);
assertThat(statistics.getMaxValues().getInt(0)).isEqualTo(5);
@@ -232,7 +249,11 @@ public class LogRecordBatchStatisticsTest extends LogTestBase {
LogRecordReadContext.createArrowReadContext(
TestData.STATISTICS_WITH_BOOLEAN_ROW_TYPE,
DEFAULT_SCHEMA_ID,
- TEST_SCHEMA_GETTER)) {
+ new TestingSchemaGetter(
+ DEFAULT_SCHEMA_ID,
+ Schema.newBuilder()
+ .fromRowType(TestData.STATISTICS_WITH_BOOLEAN_ROW_TYPE)
+ .build()))) {
LogRecordBatchStatistics defaultStats = defaultBatch.getStatistics(readContext).get();
try (FileLogRecords fileLogRecords =
diff --git a/fluss-common/src/test/java/org/apache/fluss/record/LogTestBase.java b/fluss-common/src/test/java/org/apache/fluss/record/LogTestBase.java
index 36aef5b9d..491b1fffa 100644
--- a/fluss-common/src/test/java/org/apache/fluss/record/LogTestBase.java
+++ b/fluss-common/src/test/java/org/apache/fluss/record/LogTestBase.java
@@ -33,6 +33,7 @@ import java.io.IOException;
import java.util.ArrayList;
import java.util.List;
+import static org.apache.fluss.record.TestData.TEST_SCHEMA_GETTER;
import static org.assertj.core.api.Assertions.assertThat;
/** base test class for Log. */
@@ -62,14 +63,18 @@ public abstract class LogTestBase {
}
public static void assertLogRecordsListEquals(
- List<MemoryLogRecords> expected, LogRecords actual, RowType rowType) {
+ List<MemoryLogRecords> expected,
+ LogRecords actual,
+ RowType rowType,
+ SchemaGetter schemaGetter) {
LogRecordsAssert.assertThatLogRecords(actual)
.withSchema(rowType)
+ .withSchemaGetter(schemaGetter)
.isEqualTo(new ListLogRecords(expected));
}
protected void assertLogRecordsListEquals(List<MemoryLogRecords> expected, LogRecords actual) {
- assertLogRecordsListEquals(expected, actual, baseRowType);
+ assertLogRecordsListEquals(expected, actual, baseRowType, TEST_SCHEMA_GETTER);
}
protected void assertIndexedLogRecordBatchAndRowEquals(
diff --git a/fluss-common/src/test/java/org/apache/fluss/record/MemoryLogRecordsArrowBuilderTest.java b/fluss-common/src/test/java/org/apache/fluss/record/MemoryLogRecordsArrowBuilderTest.java
index 63b3f52db..6c0c8e89e 100644
--- a/fluss-common/src/test/java/org/apache/fluss/record/MemoryLogRecordsArrowBuilderTest.java
+++ b/fluss-common/src/test/java/org/apache/fluss/record/MemoryLogRecordsArrowBuilderTest.java
@@ -24,6 +24,7 @@ import org.apache.fluss.config.Configuration;
import org.apache.fluss.config.MemorySize;
import org.apache.fluss.memory.ManagedPagedOutputView;
import org.apache.fluss.memory.TestingMemorySegmentPool;
+import org.apache.fluss.metadata.Schema;
import org.apache.fluss.row.InternalRow;
import org.apache.fluss.row.arrow.ArrowWriter;
import org.apache.fluss.row.arrow.ArrowWriterPool;
@@ -146,7 +147,7 @@ public class MemoryLogRecordsArrowBuilderTest {
"Tried to append a record, but MemoryLogRecordsArrowBuilder is closed for record appends");
assertThat(builder.isClosed()).isTrue();
MemoryLogRecords records = MemoryLogRecords.pointToBytesView(builder.build());
- assertLogRecordsEquals(DATA1_ROW_TYPE, records, expectedResult);
+ assertLogRecordsEquals(DATA1_ROW_TYPE, records, expectedResult, TEST_SCHEMA_GETTER);
}
@ParameterizedTest
@@ -419,7 +420,10 @@ public class MemoryLogRecordsArrowBuilderTest {
// Create read context
LogRecordReadContext readContext =
LogRecordReadContext.createArrowReadContext(
- testRowType, DEFAULT_SCHEMA_ID, TEST_SCHEMA_GETTER);
+ testRowType,
+ DEFAULT_SCHEMA_ID,
+ new TestingSchemaGetter(
+ 1, Schema.newBuilder().fromRowType(testRowType).build()));
// Test statistics reading
Optional<LogRecordBatchStatistics> statisticsOpt = batch.getStatistics(readContext);
@@ -524,7 +528,10 @@ public class MemoryLogRecordsArrowBuilderTest {
// Create read context
LogRecordReadContext readContext =
LogRecordReadContext.createArrowReadContext(
- testRowType, DEFAULT_SCHEMA_ID, TEST_SCHEMA_GETTER);
+ testRowType,
+ DEFAULT_SCHEMA_ID,
+ new TestingSchemaGetter(
+ 1, Schema.newBuilder().fromRowType(testRowType).build()));
// Test statistics reading
Optional<LogRecordBatchStatistics> statisticsOpt = batch.getStatistics(readContext);
diff --git a/fluss-common/src/test/java/org/apache/fluss/record/MemoryLogRecordsCompactedBuilderTest.java b/fluss-common/src/test/java/org/apache/fluss/record/MemoryLogRecordsCompactedBuilderTest.java
index 2a8d70c80..ce2098f1c 100644
--- a/fluss-common/src/test/java/org/apache/fluss/record/MemoryLogRecordsCompactedBuilderTest.java
+++ b/fluss-common/src/test/java/org/apache/fluss/record/MemoryLogRecordsCompactedBuilderTest.java
@@ -34,6 +34,7 @@ import static org.apache.fluss.record.TestData.BASE_OFFSET;
import static org.apache.fluss.record.TestData.DATA1_ROW_TYPE;
import static org.apache.fluss.record.TestData.DEFAULT_MAGIC;
import static org.apache.fluss.record.TestData.DEFAULT_SCHEMA_ID;
+import static org.apache.fluss.record.TestData.TEST_SCHEMA_GETTER;
import static org.apache.fluss.testutils.DataTestUtils.compactedRow;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
@@ -71,7 +72,7 @@ class MemoryLogRecordsCompactedBuilderTest {
try (LogRecordReadContext ctx =
LogRecordReadContext.createCompactedRowReadContext(
- DATA1_ROW_TYPE, DEFAULT_SCHEMA_ID);
+ DATA1_ROW_TYPE, DEFAULT_SCHEMA_ID, TEST_SCHEMA_GETTER);
CloseableIterator<LogRecord> recIt = batch.records(ctx)) {
for (CompactedRow expRow : expected) {
assertThat(recIt.hasNext()).isTrue();
@@ -127,7 +128,7 @@ class MemoryLogRecordsCompactedBuilderTest {
assertThat(batch.nextLogOffset()).isEqualTo(1);
try (LogRecordReadContext ctx =
LogRecordReadContext.createCompactedRowReadContext(
- DATA1_ROW_TYPE, DEFAULT_SCHEMA_ID);
+ DATA1_ROW_TYPE, DEFAULT_SCHEMA_ID, TEST_SCHEMA_GETTER);
CloseableIterator<LogRecord> it = batch.records(ctx)) {
assertThat(it.hasNext()).isFalse();
}
@@ -146,7 +147,7 @@ class MemoryLogRecordsCompactedBuilderTest {
assertThat(batch.nextLogOffset()).isEqualTo(101);
try (LogRecordReadContext ctx =
LogRecordReadContext.createCompactedRowReadContext(
- DATA1_ROW_TYPE, DEFAULT_SCHEMA_ID);
+ DATA1_ROW_TYPE, DEFAULT_SCHEMA_ID, TEST_SCHEMA_GETTER);
CloseableIterator<LogRecord> it = batch.records(ctx)) {
assertThat(it.hasNext()).isFalse();
}
diff --git a/fluss-common/src/test/java/org/apache/fluss/record/MemoryLogRecordsTest.java b/fluss-common/src/test/java/org/apache/fluss/record/MemoryLogRecordsTest.java
index 2b67ddfb3..fa42e4290 100644
--- a/fluss-common/src/test/java/org/apache/fluss/record/MemoryLogRecordsTest.java
+++ b/fluss-common/src/test/java/org/apache/fluss/record/MemoryLogRecordsTest.java
@@ -27,6 +27,7 @@ import java.util.function.Function;
import static org.apache.fluss.record.TestData.DATA1;
import static org.apache.fluss.record.TestData.DATA1_ROW_TYPE;
+import static org.apache.fluss.record.TestData.TEST_SCHEMA_GETTER;
/** Tests for {@link MemoryLogRecords}. */
class MemoryLogRecordsTest {
@@ -79,6 +80,7 @@ class MemoryLogRecordsTest {
LogRecordsAssert.assertThatLogRecords(newRecords)
.hasBatchesCount(1)
.withSchema(DATA1_ROW_TYPE)
+ .withSchemaGetter(TEST_SCHEMA_GETTER)
.isEqualTo(records);
}
}
diff --git a/fluss-common/src/test/java/org/apache/fluss/record/TestData.java b/fluss-common/src/test/java/org/apache/fluss/record/TestData.java
index be4e0ad78..8e202775a 100644
--- a/fluss-common/src/test/java/org/apache/fluss/record/TestData.java
+++ b/fluss-common/src/test/java/org/apache/fluss/record/TestData.java
@@ -354,5 +354,5 @@ public final class TestData {
// ------------------- Statistics test data and schemas end ------------------------
public static final TestingSchemaGetter TEST_SCHEMA_GETTER =
- new TestingSchemaGetter(DEFAULT_SCHEMA_ID, DATA2_SCHEMA);
+ new TestingSchemaGetter(DEFAULT_SCHEMA_ID, DATA1_SCHEMA);
}
diff --git a/fluss-common/src/test/java/org/apache/fluss/record/TestingSchemaGetter.java b/fluss-common/src/test/java/org/apache/fluss/record/TestingSchemaGetter.java
index 9a9581cab..ef9470123 100644
--- a/fluss-common/src/test/java/org/apache/fluss/record/TestingSchemaGetter.java
+++ b/fluss-common/src/test/java/org/apache/fluss/record/TestingSchemaGetter.java
@@ -21,6 +21,7 @@ import org.apache.fluss.exception.SchemaNotExistException;
import org.apache.fluss.metadata.Schema;
import org.apache.fluss.metadata.SchemaGetter;
import org.apache.fluss.metadata.SchemaInfo;
+import org.apache.fluss.types.RowType;
import java.util.HashMap;
import java.util.Map;
@@ -31,18 +32,16 @@ public class TestingSchemaGetter implements SchemaGetter {
private SchemaInfo schemaInfo;
private final Map<Integer, Schema> schemaCaches;
- public TestingSchemaGetter() {
- this.schemaInfo = null;
- this.schemaCaches = new HashMap<>();
- }
-
- // todo: remove it(简化测试代码)
public TestingSchemaGetter(SchemaInfo schemaInfo) {
this.schemaInfo = schemaInfo;
this.schemaCaches = new HashMap<>();
this.schemaCaches.put(schemaInfo.getSchemaId(), schemaInfo.getSchema());
}
+ public TestingSchemaGetter(int schemaId, RowType rowType) {
+ this(schemaId, Schema.newBuilder().fromRowType(rowType).build());
+ }
+
public TestingSchemaGetter(int schemaId, Schema schema) {
this.schemaInfo = new SchemaInfo(schema, schemaId);
this.schemaCaches = new HashMap<>();
diff --git a/fluss-common/src/test/java/org/apache/fluss/testutils/DataTestUtils.java b/fluss-common/src/test/java/org/apache/fluss/testutils/DataTestUtils.java
index aaa449d46..a3f05785c 100644
--- a/fluss-common/src/test/java/org/apache/fluss/testutils/DataTestUtils.java
+++ b/fluss-common/src/test/java/org/apache/fluss/testutils/DataTestUtils.java
@@ -749,11 +749,11 @@ public class DataTestUtils {
}
public static void assertLogRecordsEquals(LogRecords actual, LogRecords expected) {
- assertLogRecordsEquals(DATA1_ROW_TYPE, actual, expected);
+ assertLogRecordsEquals(DATA1_ROW_TYPE, actual, expected, TEST_SCHEMA_GETTER);
}
public static void assertLogRecordsEquals(
- RowType rowType, LogRecords actual, LogRecords expected) {
+ RowType rowType, LogRecords actual, LogRecords expected, SchemaGetter schemaGetter) {
Iterator<LogRecordBatch> actualIterator = actual.batches().iterator();
Iterator<LogRecordBatch> expectedIterator = expected.batches().iterator();
@@ -761,14 +761,20 @@ public class DataTestUtils {
assertThat(expectedIterator.hasNext()).isTrue();
LogRecordBatch actualBatch = actualIterator.next();
LogRecordBatch expectedBatch = expectedIterator.next();
- assertLogRecordBatchEquals(rowType, actualBatch, expectedBatch);
+ assertLogRecordBatchEquals(rowType, actualBatch, expectedBatch, schemaGetter);
}
assertThat(expectedIterator.hasNext()).isFalse();
}
public static void assertLogRecordBatchEquals(
- RowType rowType, LogRecordBatch actual, LogRecordBatch expected) {
- assertThatLogRecordBatch(actual).withSchema(rowType).isEqualTo(expected);
+ RowType rowType,
+ LogRecordBatch actual,
+ LogRecordBatch expected,
+ SchemaGetter schemaGetter) {
+ assertThatLogRecordBatch(actual)
+ .withSchema(rowType)
+ .withSchemaGetter(schemaGetter)
+ .isEqualTo(expected);
}
private static void assertLogRecordsEqualsWithRowKind(
diff --git a/fluss-common/src/test/java/org/apache/fluss/testutils/LogRecordsAssert.java b/fluss-common/src/test/java/org/apache/fluss/testutils/LogRecordsAssert.java
index 28df40e34..66efdce5d 100644
--- a/fluss-common/src/test/java/org/apache/fluss/testutils/LogRecordsAssert.java
+++ b/fluss-common/src/test/java/org/apache/fluss/testutils/LogRecordsAssert.java
@@ -20,7 +20,6 @@ package org.apache.fluss.testutils;
import org.apache.fluss.metadata.SchemaGetter;
import org.apache.fluss.record.LogRecordBatch;
import org.apache.fluss.record.LogRecords;
-import org.apache.fluss.record.TestingSchemaGetter;
import org.apache.fluss.types.RowType;
import org.assertj.core.api.AbstractAssert;
@@ -40,7 +39,7 @@ public class LogRecordsAssert extends AbstractAssert<LogRecordsAssert, LogRecord
private RowType rowType;
private boolean assertCheckSum = true;
- private SchemaGetter schemaGetter = new TestingSchemaGetter();
+ private SchemaGetter schemaGetter;
private LogRecordsAssert(LogRecords actual) {
super(actual, LogRecordsAssert.class);
diff --git a/fluss-server/src/main/java/org/apache/fluss/server/kv/KvRecoverHelper.java b/fluss-server/src/main/java/org/apache/fluss/server/kv/KvRecoverHelper.java
index f78a258ec..29c6cae8c 100644
--- a/fluss-server/src/main/java/org/apache/fluss/server/kv/KvRecoverHelper.java
+++ b/fluss-server/src/main/java/org/apache/fluss/server/kv/KvRecoverHelper.java
@@ -289,7 +289,7 @@ public class KvRecoverHelper {
currentRowType, currentSchemaId, schemaGetter);
} else if (logFormat == LogFormat.COMPACTED) {
return LogRecordReadContext.createCompactedRowReadContext(
- currentRowType, currentSchemaId);
+ currentRowType, currentSchemaId, schemaGetter);
} else {
throw new UnsupportedOperationException("Unsupported log format: " + logFormat);
}
diff --git a/fluss-server/src/test/java/org/apache/fluss/server/kv/KvTabletMergeModeTest.java b/fluss-server/src/test/java/org/apache/fluss/server/kv/KvTabletMergeModeTest.java
index 97ae1d9cc..d5fe4b8b4 100644
--- a/fluss-server/src/test/java/org/apache/fluss/server/kv/KvTabletMergeModeTest.java
+++ b/fluss-server/src/test/java/org/apache/fluss/server/kv/KvTabletMergeModeTest.java
@@ -209,6 +209,7 @@ class KvTabletMergeModeTest {
assertThatLogRecords(actualLogRecords)
.withSchema(AGG_ROW_TYPE)
+ .withSchemaGetter(schemaGetter)
.assertCheckSum(true)
.isEqualTo(expectedLogs);
}
@@ -248,6 +249,7 @@ class KvTabletMergeModeTest {
assertThatLogRecords(actualLogRecords)
.withSchema(AGG_ROW_TYPE)
+ .withSchemaGetter(schemaGetter)
.assertCheckSum(true)
.isEqualTo(expectedLogs);
@@ -299,6 +301,7 @@ class KvTabletMergeModeTest {
assertThatLogRecords(actualLogRecords)
.withSchema(AGG_ROW_TYPE)
+ .withSchemaGetter(schemaGetter)
.assertCheckSum(true)
.isEqualTo(expectedLogs);
}
@@ -321,6 +324,7 @@ class KvTabletMergeModeTest {
assertThatLogRecords(actualLogRecords)
.withSchema(AGG_ROW_TYPE)
+ .withSchemaGetter(schemaGetter)
.assertCheckSum(true)
.isEqualTo(expectedLogs);
}
@@ -351,6 +355,7 @@ class KvTabletMergeModeTest {
assertThatLogRecords(actualLogRecords)
.withSchema(AGG_ROW_TYPE)
+ .withSchemaGetter(schemaGetter)
.assertCheckSum(true)
.isEqualTo(expectedLogs);
}
@@ -404,6 +409,7 @@ class KvTabletMergeModeTest {
assertThatLogRecords(actualLogRecords)
.withSchema(AGG_ROW_TYPE)
+ .withSchemaGetter(schemaGetter)
.assertCheckSum(true)
.isEqualTo(expectedLogs);
}
@@ -442,6 +448,7 @@ class KvTabletMergeModeTest {
assertThatLogRecords(actualLogRecords)
.withSchema(AGG_ROW_TYPE)
+ .withSchemaGetter(schemaGetter)
.assertCheckSum(true)
.isEqualTo(expectedLogs);
}
@@ -489,6 +496,7 @@ class KvTabletMergeModeTest {
assertThatLogRecords(actualLogRecords)
.withSchema(AGG_ROW_TYPE)
+ .withSchemaGetter(schemaGetter)
.assertCheckSum(true)
.isEqualTo(expectedLogs);
}
@@ -528,6 +536,7 @@ class KvTabletMergeModeTest {
assertThatLogRecords(actualLogRecords)
.withSchema(AGG_ROW_TYPE)
+ .withSchemaGetter(schemaGetter)
.assertCheckSum(true)
.isEqualTo(expectedLogs);
}
diff --git a/fluss-server/src/test/java/org/apache/fluss/server/kv/KvTabletSchemaEvolutionTest.java b/fluss-server/src/test/java/org/apache/fluss/server/kv/KvTabletSchemaEvolutionTest.java
index 130352170..f3658e9b0 100644
--- a/fluss-server/src/test/java/org/apache/fluss/server/kv/KvTabletSchemaEvolutionTest.java
+++ b/fluss-server/src/test/java/org/apache/fluss/server/kv/KvTabletSchemaEvolutionTest.java
@@ -198,6 +198,7 @@ class KvTabletSchemaEvolutionTest {
assertThatLogRecords(actualLogRecords)
.withSchema(ROW_TYPE_V1)
+ .withSchemaGetter(schemaGetter)
.assertCheckSum(true)
.isEqualTo(expectedLogs);
}
@@ -242,6 +243,7 @@ class KvTabletSchemaEvolutionTest {
assertThatLogRecords(actualLogRecords)
.withSchema(ROW_TYPE_V1)
+ .withSchemaGetter(schemaGetter)
.assertCheckSum(true)
.isEqualTo(expectedLogs);
}
diff --git a/fluss-server/src/test/java/org/apache/fluss/server/kv/KvTabletTest.java b/fluss-server/src/test/java/org/apache/fluss/server/kv/KvTabletTest.java
index 3425fa75c..874ff268d 100644
--- a/fluss-server/src/test/java/org/apache/fluss/server/kv/KvTabletTest.java
+++ b/fluss-server/src/test/java/org/apache/fluss/server/kv/KvTabletTest.java
@@ -841,6 +841,7 @@ class KvTabletTest {
: Arrays.asList(new Object[] {1, "v11"}, new Object[] {2, "v21"}));
assertThatLogRecords(actualLogRecords)
.withSchema(readLogRowType)
+ .withSchemaGetter(schemaGetter)
.assertCheckSum(!doProjection)
.isEqualTo(expectedLogs);
@@ -875,6 +876,7 @@ class KvTabletTest {
actualLogRecords = readLogRecords(logTablet, 0, logProjection);
assertThatLogRecords(actualLogRecords)
.withSchema(readLogRowType)
+ .withSchemaGetter(schemaGetter)
.assertCheckSum(!doProjection)
.isEqualTo(expectedLogs);
}
@@ -898,6 +900,7 @@ class KvTabletTest {
actualLogRecords = readLogRecords(logTablet, endOffset, logProjection);
assertThatLogRecords(actualLogRecords)
.withSchema(readLogRowType)
+ .withSchemaGetter(schemaGetter)
.assertCheckSum(!doProjection)
.isEqualTo(expectedLogs);
@@ -933,6 +936,7 @@ class KvTabletTest {
actualLogRecords = readLogRecords(logTablet, endOffset, logProjection);
assertThatLogRecords(actualLogRecords)
.withSchema(readLogRowType)
+ .withSchemaGetter(schemaGetter)
.assertCheckSum(!doProjection)
.isEqualTo(expectedLogs);
@@ -954,6 +958,7 @@ class KvTabletTest {
actualLogRecords = readLogRecords(logTablet, endOffset, logProjection);
assertThatLogRecords(actualLogRecords)
.withSchema(readLogRowType)
+ .withSchemaGetter(schemaGetter)
.assertCheckSum(!doProjection)
.isEqualTo(expectedLogs);
}
@@ -1031,6 +1036,7 @@ class KvTabletTest {
new Object[] {2, 1001}));
assertThatLogRecords(actualLogRecords)
.withSchema(readLogRowType)
+ .withSchemaGetter(schemaGetter)
.assertCheckSum(!doProjection)
.isEqualTo(expectedLogs);
@@ -1065,6 +1071,7 @@ class KvTabletTest {
actualLogRecords = readLogRecords(logTablet, 0, logProjection);
assertThatLogRecords(actualLogRecords)
.withSchema(readLogRowType)
+ .withSchemaGetter(schemaGetter)
.assertCheckSum(!doProjection)
.isEqualTo(expectedLogs);
@@ -1097,6 +1104,7 @@ class KvTabletTest {
actualLogRecords = readLogRecords(logTablet, endOffset, logProjection);
assertThatLogRecords(actualLogRecords)
.withSchema(readLogRowType)
+ .withSchemaGetter(schemaGetter)
.assertCheckSum(!doProjection)
.isEqualTo(expectedLogs);
@@ -1136,6 +1144,7 @@ class KvTabletTest {
actualLogRecords = readLogRecords(logTablet, endOffset, logProjection);
assertThatLogRecords(actualLogRecords)
.withSchema(newSchemaLogRowType)
+ .withSchemaGetter(schemaGetter)
.assertCheckSum(!doProjection)
.isEqualTo(expectedLogs);
@@ -1158,6 +1167,7 @@ class KvTabletTest {
actualLogRecords = readLogRecords(logTablet, endOffset, logProjection);
assertThatLogRecords(actualLogRecords)
.withSchema(newSchemaLogRowType)
+ .withSchemaGetter(schemaGetter)
.assertCheckSum(!doProjection)
.isEqualTo(expectedLogs);
}
@@ -1205,6 +1215,7 @@ class KvTabletTest {
new Object[] {2, 20L, 200, "Bob"}));
assertThatLogRecords(actualLogRecords)
.withSchema(rowType)
+ .withSchemaGetter(schemaGetter)
.assertCheckSum(true)
.isEqualTo(expectedLogs);
@@ -1238,6 +1249,7 @@ class KvTabletTest {
actualLogRecords = readLogRecords(logTablet, endOffset, null);
assertThatLogRecords(actualLogRecords)
.withSchema(rowType)
+ .withSchemaGetter(schemaGetter)
.assertCheckSum(true)
.isEqualTo(expectedLogs);
@@ -1259,6 +1271,7 @@ class KvTabletTest {
actualLogRecords = readLogRecords(logTablet, endOffset, null);
assertThatLogRecords(actualLogRecords)
.withSchema(rowType)
+ .withSchemaGetter(schemaGetter)
.assertCheckSum(true)
.isEqualTo(expectedLogs);
}
@@ -1485,11 +1498,13 @@ class KvTabletTest {
private void checkEqual(
LogRecords actaulLogRecords, List<MemoryLogRecords> expectedLogs, RowType rowType) {
- LogTestBase.assertLogRecordsListEquals(expectedLogs, actaulLogRecords, rowType);
+ LogTestBase.assertLogRecordsListEquals(
+ expectedLogs, actaulLogRecords, rowType, schemaGetter);
}
private void checkEqual(LogRecords actaulLogRecords, List<MemoryLogRecords> expectedLogs) {
- LogTestBase.assertLogRecordsListEquals(expectedLogs, actaulLogRecords, baseRowType);
+ LogTestBase.assertLogRecordsListEquals(
+ expectedLogs, actaulLogRecords, baseRowType, schemaGetter);
}
private Value valueOf(BinaryRow row) {
diff --git a/fluss-server/src/test/java/org/apache/fluss/server/kv/rowmerger/AggregateRowMergerTest.java b/fluss-server/src/test/java/org/apache/fluss/server/kv/rowmerger/AggregateRowMergerTest.java
index 387fb95bb..bd1bd3217 100644
--- a/fluss-server/src/test/java/org/apache/fluss/server/kv/rowmerger/AggregateRowMergerTest.java
+++ b/fluss-server/src/test/java/org/apache/fluss/server/kv/rowmerger/AggregateRowMergerTest.java
@@ -270,8 +270,7 @@ class AggregateRowMergerTest {
// Create schema getter with both schemas
// Initialize with newSchema as latest schema for AggregateRowMerger constructor
- TestingSchemaGetter schemaGetter =
- new TestingSchemaGetter(new SchemaInfo(newSchema, newSchemaId));
+ TestingSchemaGetter schemaGetter = new TestingSchemaGetter(newSchemaId, newSchema);
// Add old schema to cache
schemaGetter.updateLatestSchemaInfo(new SchemaInfo(oldSchema, oldSchemaId));
// Ensure newSchema is latest
diff --git a/fluss-server/src/test/java/org/apache/fluss/server/log/LogSegmentTest.java b/fluss-server/src/test/java/org/apache/fluss/server/log/LogSegmentTest.java
index a82dd9953..1859c6a5b 100644
--- a/fluss-server/src/test/java/org/apache/fluss/server/log/LogSegmentTest.java
+++ b/fluss-server/src/test/java/org/apache/fluss/server/log/LogSegmentTest.java
@@ -788,7 +788,7 @@ final class LogSegmentTest extends LogTestBase {
org.apache.fluss.types.RowType projectedType = DATA1_ROW_TYPE.project(new int[] {0});
try (LogRecordReadContext projectedContext =
LogRecordReadContext.createArrowReadContext(
- projectedType, DEFAULT_SCHEMA_ID, TEST_SCHEMA_GETTER)) {
+ projectedType, DEFAULT_SCHEMA_ID, TEST_SCHEMA_GETTER, true)) {
int recordCount = 0;
for (LogRecordBatch b : read.getRecords().batches()) {
try (CloseableIterator<LogRecord> iter = b.records(projectedContext)) {
diff --git a/fluss-server/src/test/java/org/apache/fluss/server/log/PredicateSchemaResolverTest.java b/fluss-server/src/test/java/org/apache/fluss/server/log/PredicateSchemaResolverTest.java
index 08a68c697..33f70a5f4 100644
--- a/fluss-server/src/test/java/org/apache/fluss/server/log/PredicateSchemaResolverTest.java
+++ b/fluss-server/src/test/java/org/apache/fluss/server/log/PredicateSchemaResolverTest.java
@@ -110,7 +110,7 @@ class PredicateSchemaResolverTest {
@Test
void testSchemaGetterThrowsInConstructor() {
// SchemaGetter that throws for the predicate schema ID
- TestingSchemaGetter schemaGetter = new TestingSchemaGetter();
+ TestingSchemaGetter schemaGetter = new TestingSchemaGetter(SCHEMA_ID_1, ROW_TYPE_V1);
Predicate predicate = new PredicateBuilder(ROW_TYPE_V1).equal(0, 42);
diff --git a/fluss-server/src/test/java/org/apache/fluss/server/replica/ReplicaTest.java b/fluss-server/src/test/java/org/apache/fluss/server/replica/ReplicaTest.java
index d37e91dc6..8074b4f1c 100644
--- a/fluss-server/src/test/java/org/apache/fluss/server/replica/ReplicaTest.java
+++ b/fluss-server/src/test/java/org/apache/fluss/server/replica/ReplicaTest.java
@@ -313,6 +313,7 @@ final class ReplicaTest extends ReplicaTestBase {
new Object[] {3, "b1"}));
assertThatLogRecords(fetchRecords(kvReplica))
.withSchema(DATA1_ROW_TYPE)
+ .withSchemaGetter(kvReplica.getSchemaGetter())
.isEqualTo(expected);
int currentOffset = 4;
diff --git a/fluss-server/src/test/java/org/apache/fluss/server/replica/fetcher/ReplicaFetcherITCase.java b/fluss-server/src/test/java/org/apache/fluss/server/replica/fetcher/ReplicaFetcherITCase.java
index f94db7184..36d3ef567 100644
--- a/fluss-server/src/test/java/org/apache/fluss/server/replica/fetcher/ReplicaFetcherITCase.java
+++ b/fluss-server/src/test/java/org/apache/fluss/server/replica/fetcher/ReplicaFetcherITCase.java
@@ -57,6 +57,7 @@ import static org.apache.fluss.record.TestData.DATA1_TABLE_PATH;
import static org.apache.fluss.record.TestData.DATA1_TABLE_PATH_PK;
import static org.apache.fluss.record.TestData.DATA_1_WITH_KEY_AND_VALUE;
import static org.apache.fluss.record.TestData.EXPECTED_LOG_RESULTS_FOR_DATA_1_WITH_PK;
+import static org.apache.fluss.record.TestData.TEST_SCHEMA_GETTER;
import static org.apache.fluss.server.testutils.KvTestUtils.assertLookupResponse;
import static org.apache.fluss.server.testutils.RpcMessageTestUtils.assertFetchLogResponse;
import static org.apache.fluss.server.testutils.RpcMessageTestUtils.assertFetchLogResponseWithRowKind;
@@ -165,7 +166,7 @@ public class ReplicaFetcherITCase {
assertThat(resultForBucket.getTableBucket()).isEqualTo(tb);
LogRecords records = resultForBucket.records();
assertThat(records).isNotNull();
- assertLogRecordsEquals(DATA1_ROW_TYPE, records, DATA1);
+ assertLogRecordsEquals(DATA1_ROW_TYPE, records, DATA1, TEST_SCHEMA_GETTER);
}
}
diff --git a/fluss-server/src/test/java/org/apache/fluss/server/tablet/TabletServiceITCase.java b/fluss-server/src/test/java/org/apache/fluss/server/tablet/TabletServiceITCase.java
index 5d2dd1d71..6190f566d 100644
--- a/fluss-server/src/test/java/org/apache/fluss/server/tablet/TabletServiceITCase.java
+++ b/fluss-server/src/test/java/org/apache/fluss/server/tablet/TabletServiceITCase.java
@@ -24,7 +24,6 @@ import org.apache.fluss.metadata.KvFormat;
import org.apache.fluss.metadata.LogFormat;
import org.apache.fluss.metadata.PhysicalTablePath;
import org.apache.fluss.metadata.Schema;
-import org.apache.fluss.metadata.SchemaGetter;
import org.apache.fluss.metadata.TableBucket;
import org.apache.fluss.metadata.TableDescriptor;
import org.apache.fluss.metadata.TablePath;
@@ -229,7 +228,6 @@ public class TabletServiceITCase {
long tableId =
createTable(FLUSS_CLUSTER_EXTENSION, DATA1_TABLE_PATH, DATA1_TABLE_DESCRIPTOR);
TableBucket tb = new TableBucket(tableId, 0);
- SchemaGetter schemaGetter = new TestingSchemaGetter(1, DATA1_SCHEMA);
FLUSS_CLUSTER_EXTENSION.waitUntilAllReplicaReady(tb);
@@ -308,7 +306,7 @@ public class TabletServiceITCase {
.fetchLog(newFetchLogRequest(-1, tableId, 0, 10L, new int[] {0}))
.get(),
DATA1_ROW_TYPE.project(new int[] {0}),
- schemaGetter,
+ TEST_SCHEMA_GETTER,
tableId,
0,
20L,
@@ -324,7 +322,7 @@ public class TabletServiceITCase {
.fetchLog(newFetchLogRequest(-1, tableId, 0, 15L, new int[] {1}))
.get(),
DATA1_ROW_TYPE.project(new int[] {1}),
- schemaGetter,
+ TEST_SCHEMA_GETTER,
tableId,
0,
20L,
diff --git a/fluss-server/src/test/java/org/apache/fluss/server/testutils/FlussClusterExtension.java b/fluss-server/src/test/java/org/apache/fluss/server/testutils/FlussClusterExtension.java
index a51fd2148..bc833e9c0 100644
--- a/fluss-server/src/test/java/org/apache/fluss/server/testutils/FlussClusterExtension.java
+++ b/fluss-server/src/test/java/org/apache/fluss/server/testutils/FlussClusterExtension.java
@@ -337,6 +337,7 @@ public final class FlussClusterExtension
tabletServerConf.setString(
ConfigOptions.ZOOKEEPER_ADDRESS, zooKeeperServer.getConnectString());
tabletServerConf.setString(ConfigOptions.BIND_LISTENERS, tabletServerListeners);
+ tabletServerConf.setDouble(ConfigOptions.SERVER_DATA_DISK_WRITE_LIMIT_RATIO, 1.0);
if (overwriteConfig != null) {
tabletServerConf.addAll(overwriteConfig);
}