This is an automated email from the ASF dual-hosted git repository.
voonhous pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/hudi.git
The following commit(s) were added to refs/heads/master by this push:
new 0c3f00ee0958 fix(trino): read LSM archived-timeline parquet files
through a Trino-… (#19732)
0c3f00ee0958 is described below
commit 0c3f00ee0958eb1c72889d7b593dd10cf8f92b35
Author: voonhous <[email protected]>
AuthorDate: Thu Aug 27 18:02:10 2026 +0800
fix(trino): read LSM archived-timeline parquet files through a Trino-…
(#19732)
fix(trino): read LSM archived-timeline parquet files through a Trino-backed
reader (#19732)
HudiTrinoFileReaderFactory.newParquetFileReader threw
UnsupportedOperationException, and
hudi-common's ArchivedTimelineLoaderV2 obtains its reader for the LSM
history files under
.hoodie/timeline/history from that factory. Since #14019 the archived
timeline is loaded
lazily, so the throw surfaced whenever the completion time of an archived
instant was needed,
e.g. file slicing of a MOR table whose log file belongs to an archived
delta commit.
Add TrinoParquetFileReader, a HoodieAvroFileReader over Trino's
ParquetReader, and return it
from the factory. The constructor reads the footer once, taking the schema
from the file's
parquet.avro.schema property and the record count from its row groups. Each
getIndexedRecordIterator opens its own ParquetReader over the projection
the loader requests,
with column handles typed by HudiUtil.toColumnHandle, and turns every page
into IndexedRecords
through HudiAvroSerializer; VARBINARY values become the ByteBuffer that
ArchivedTimelineV2
casts the metadata and plan columns to. The iterator remembers exhaustion,
since on Trino 484
a nextPage() past the last row group throws instead of returning null
again, and closing the
reader releases every iterator it handed out, rethrowing the first close
failure with the
rest attached as suppressed. Failures at open, page read (checked or
unchecked) and close go
through the same handleException as Hive's ParquetPageSource: a
ParquetCorruptionException is
HUDI_BAD_DATA, anything else HUDI_CURSOR_ERROR, each with its cause.
Record-key, key-prefix and
row-key lookups, the bloom filter and the min/max record keys are
unsupported: a timeline
file carries none of the data-file footer metadata those rely on, and no
Trino path asks for
them. Only LoadMode.TIME is reachable from the connector today.
Ported from onehouseinc/trino#72 and adapted to the HoodieSchema-based
reader contract, the
PrefilledColumnValues serializer and Trino 484's ParquetReader API.
Tests. TestTrinoParquetFileReader reads archived_timeline.parquet, the
first history file of
a four-instant tv8 COW table written with Hudi 1.0.2 (create script in
hudi_cow_archived_timeline.md): the full read, the TIME, METADATA, PLAN and
FULL projections
(FULL orders plan before metadata, the reverse of the file, so it pins
name-based mapping and
the ByteBuffer conversion), a drained iterator staying drained, close()
releasing an open
iterator, a corrupt footer failing with HUDI_BAD_DATA, a corrupt data page
failing with
HUDI_CURSOR_ERROR and the ParquetDecodingException as its cause, and the
unsupported footer
lookups. TestHudiSmokeTest.testReadTableWithArchivedTimeline reads a tv8
MOR fixture
(hudi_mor_archived_timeline.zip, script in hudi_mor_archived_timeline.md)
whose oldest log
file belongs to archived delta commit 20250918122106595, read-optimized and
real-time; both
cases fail on master.
Fixes #13994
---
.../plugin/hudi/io/HudiTrinoFileReaderFactory.java | 2 +-
.../plugin/hudi/io/TrinoParquetFileReader.java | 443 +++++++++++++++++++++
.../io/trino/plugin/hudi/TestHudiSmokeTest.java | 25 ++
.../plugin/hudi/io/TestTrinoParquetFileReader.java | 252 ++++++++++++
.../testing/ResourceHudiTablesInitializer.java | 1 +
.../src/test/resources/archived_timeline.parquet | Bin 0 -> 3263 bytes
.../hudi_cow_archived_timeline.md | 124 ++++++
.../hudi_mor_archived_timeline.md | 96 +++++
.../hudi_mor_archived_timeline.zip | Bin 0 -> 242504 bytes
9 files changed, 942 insertions(+), 1 deletion(-)
diff --git
a/hudi-trino/src/main/java/io/trino/plugin/hudi/io/HudiTrinoFileReaderFactory.java
b/hudi-trino/src/main/java/io/trino/plugin/hudi/io/HudiTrinoFileReaderFactory.java
index cf666d5e9883..50be5b9f8066 100644
---
a/hudi-trino/src/main/java/io/trino/plugin/hudi/io/HudiTrinoFileReaderFactory.java
+++
b/hudi-trino/src/main/java/io/trino/plugin/hudi/io/HudiTrinoFileReaderFactory.java
@@ -38,7 +38,7 @@ public class HudiTrinoFileReaderFactory
@Override
protected HoodieFileReader newParquetFileReader(StoragePath path)
{
- throw new UnsupportedOperationException("HudiTrinoFileReaderFactory
does not support Parquet file reader");
+ return new TrinoParquetFileReader(storage, path);
}
@Override
diff --git
a/hudi-trino/src/main/java/io/trino/plugin/hudi/io/TrinoParquetFileReader.java
b/hudi-trino/src/main/java/io/trino/plugin/hudi/io/TrinoParquetFileReader.java
new file mode 100644
index 000000000000..5548763d4d5a
--- /dev/null
+++
b/hudi-trino/src/main/java/io/trino/plugin/hudi/io/TrinoParquetFileReader.java
@@ -0,0 +1,443 @@
+/*
+ * Licensed 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 io.trino.plugin.hudi.io;
+
+import com.google.common.collect.ImmutableList;
+import io.trino.filesystem.TrinoFileSystem;
+import io.trino.filesystem.TrinoInputFile;
+import io.trino.memory.context.AggregatedMemoryContext;
+import io.trino.parquet.Column;
+import io.trino.parquet.Field;
+import io.trino.parquet.ParquetCorruptionException;
+import io.trino.parquet.ParquetDataSource;
+import io.trino.parquet.ParquetReaderOptions;
+import io.trino.parquet.metadata.BlockMetadata;
+import io.trino.parquet.metadata.FileMetadata;
+import io.trino.parquet.metadata.ParquetMetadata;
+import io.trino.parquet.predicate.TupleDomainParquetPredicate;
+import io.trino.parquet.reader.MetadataReader;
+import io.trino.parquet.reader.ParquetReader;
+import io.trino.parquet.reader.RowGroupInfo;
+import io.trino.plugin.base.metrics.FileFormatDataSourceStats;
+import io.trino.plugin.hive.HiveColumnHandle;
+import io.trino.plugin.hive.parquet.ParquetReaderConfig;
+import io.trino.plugin.hudi.HudiUtil;
+import io.trino.plugin.hudi.storage.HudiTrinoStorage;
+import io.trino.plugin.hudi.util.HudiAvroSerializer;
+import io.trino.spi.Page;
+import io.trino.spi.TrinoException;
+import io.trino.spi.connector.SourcePage;
+import io.trino.spi.predicate.TupleDomain;
+import io.trino.spi.type.SqlVarbinary;
+import org.apache.avro.Schema;
+import org.apache.avro.generic.IndexedRecord;
+import org.apache.hudi.common.bloom.BloomFilter;
+import org.apache.hudi.common.schema.HoodieSchema;
+import org.apache.hudi.common.util.collection.ClosableIterator;
+import org.apache.hudi.common.util.collection.Pair;
+import org.apache.hudi.core.io.storage.HoodieAvroFileReader;
+import org.apache.hudi.storage.HoodieStorage;
+import org.apache.hudi.storage.StoragePath;
+import org.apache.parquet.column.ColumnDescriptor;
+import org.apache.parquet.io.MessageColumnIO;
+import org.apache.parquet.schema.MessageType;
+import org.joda.time.DateTimeZone;
+
+import java.io.IOException;
+import java.nio.ByteBuffer;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Map;
+import java.util.NoSuchElementException;
+import java.util.Optional;
+import java.util.OptionalLong;
+import java.util.Set;
+import java.util.stream.IntStream;
+
+import static com.google.common.base.Preconditions.checkArgument;
+import static
io.trino.memory.context.AggregatedMemoryContext.newSimpleAggregatedMemoryContext;
+import static io.trino.parquet.ParquetTypeUtils.constructField;
+import static io.trino.parquet.ParquetTypeUtils.getColumnIO;
+import static io.trino.parquet.ParquetTypeUtils.getDescriptors;
+import static io.trino.parquet.ParquetTypeUtils.lookupColumnByName;
+import static io.trino.parquet.predicate.PredicateUtils.buildPredicate;
+import static io.trino.parquet.predicate.PredicateUtils.getFilteredRowGroups;
+import static
io.trino.plugin.hive.parquet.ParquetPageSourceFactory.createDataSource;
+import static
io.trino.plugin.hive.parquet.ParquetPageSourceFactory.getParquetMessageType;
+import static io.trino.plugin.hudi.HudiErrorCode.HUDI_BAD_DATA;
+import static io.trino.plugin.hudi.HudiErrorCode.HUDI_CURSOR_ERROR;
+import static io.trino.plugin.hudi.HudiErrorCode.HUDI_SCHEMA_ERROR;
+import static io.trino.spi.type.VarbinaryType.VARBINARY;
+import static java.util.Objects.requireNonNull;
+
+/**
+ * Reads an LSM archived-timeline Parquet file through Trino's {@link
ParquetReader}, turning each
+ * {@link Page} it produces into an Avro {@link IndexedRecord} with {@link
HudiAvroSerializer}. Hudi's
+ * archived-timeline loader asks {@link HudiTrinoFileReaderFactory} for an
Avro file reader over the
+ * history files under {@code .hoodie/timeline/history}, and this is the
connector's answer to that
+ * request. It is not a general data-file reader: record-key, key-prefix and
row-key lookups are
+ * unsupported, and so are the bloom filter and min/max record key lookups --
a timeline file carries
+ * none of the data-file footer metadata those rely on.
+ */
+public class TrinoParquetFileReader
+ extends HoodieAvroFileReader
+{
+ private static final String PARQUET_AVRO_SCHEMA_KEY =
"parquet.avro.schema";
+ private static final DateTimeZone UTC_TIME_ZONE = DateTimeZone.UTC;
+ private static final int DOMAIN_COMPACTION_THRESHOLD = 1000;
+
+ private final StoragePath path;
+ private final ParquetReaderOptions readerOptions = new
ParquetReaderConfig().toParquetReaderOptions();
+ private final TrinoInputFile inputFile;
+ private final long fileLength;
+ private final ParquetMetadata parquetMetadata;
+ private final HoodieSchema hoodieSchema;
+ private final long totalRecords;
+ // Every iterator handed out by getIndexedRecordIterator, so that close()
can release the ParquetReader
+ // of one the caller left open. A reader instance is used by a single
thread, but the list is cheap to guard
+ private final List<ParquetIndexedRecordIterator> openIterators = new
ArrayList<>();
+
+ public TrinoParquetFileReader(HoodieStorage storage, StoragePath path)
+ {
+ this.path = requireNonNull(path, "path is null");
+ requireNonNull(storage, "storage is null");
+ checkArgument(storage instanceof HudiTrinoStorage, "storage must be an
instance of HudiTrinoStorage");
+ HudiTrinoStorage trinoStorage = (HudiTrinoStorage) storage;
+
+ // HudiTrinoStorage#getFileSystem is typed Object so that hudi-common
stays free of Trino types
+ TrinoFileSystem fileSystem = (TrinoFileSystem)
trinoStorage.getFileSystem();
+ this.inputFile =
fileSystem.newInputFile(HudiTrinoStorage.convertToLocation(path));
+ try {
+ this.fileLength = inputFile.length();
+ this.parquetMetadata = readParquetMetadata();
+ // The footer's row-group metadata is parsed lazily, so
getBlocks() can fail the way readFooter does
+ this.totalRecords = parquetMetadata.getBlocks().stream()
+ .mapToLong(BlockMetadata::rowCount)
+ .sum();
+ }
+ catch (IOException e) {
+ // Failing to open the file surfaces the way a failing read does:
a corrupt footer as HUDI_BAD_DATA,
+ // anything else as HUDI_CURSOR_ERROR, each with its cause attached
+ throw handleException(path, e);
+ }
+ Schema avroSchema =
extractAvroSchema(parquetMetadata.getFileMetaData());
+ this.hoodieSchema = HoodieSchema.fromAvroSchema(avroSchema);
+ }
+
+ @Override
+ public ClosableIterator<IndexedRecord>
getIndexedRecordIterator(HoodieSchema readerSchema, HoodieSchema
requestedSchema, Map<String, String> renamedColumns)
+ {
+ // Timeline files are never schema-evolved, so no column can have been
renamed under them
+ HoodieSchema projectedSchema = requestedSchema != null ?
requestedSchema : hoodieSchema;
+ ParquetIndexedRecordIterator iterator = new
ParquetIndexedRecordIterator(projectedSchema);
+ synchronized (openIterators) {
+ openIterators.add(iterator);
+ }
+ return iterator;
+ }
+
+ @Override
+ public ClosableIterator<IndexedRecord>
getIndexedRecordsByKeysIterator(List<String> keys, HoodieSchema readerSchema)
+ {
+ throw new UnsupportedOperationException("Reading records by keys is
not supported by this reader");
+ }
+
+ @Override
+ public ClosableIterator<IndexedRecord>
getIndexedRecordsByKeyPrefixIterator(List<String> sortedKeyPrefixes,
HoodieSchema readerSchema)
+ {
+ throw new UnsupportedOperationException("Reading records by key
prefixes is not supported by this reader");
+ }
+
+ @Override
+ public String[] readMinMaxRecordKeys()
+ {
+ throw new UnsupportedOperationException("Reading min/max record keys
is not supported by this reader");
+ }
+
+ @Override
+ public BloomFilter readBloomFilter()
+ {
+ throw new UnsupportedOperationException("Reading a bloom filter is not
supported by this reader");
+ }
+
+ @Override
+ public Set<Pair<String, Long>> filterRowKeys(Set<String> candidateRowKeys)
+ {
+ throw new UnsupportedOperationException("Filtering row keys is not
supported by this reader");
+ }
+
+ @Override
+ public ClosableIterator<String> getRecordKeyIterator()
+ {
+ throw new UnsupportedOperationException("Iterating over only record
keys is not supported by this reader");
+ }
+
+ @Override
+ public HoodieSchema getSchema()
+ {
+ return hoodieSchema;
+ }
+
+ @Override
+ public long getTotalRecords()
+ {
+ return totalRecords;
+ }
+
+ @Override
+ public void close()
+ {
+ // The only resource this reader opens outside the constructor is the
ParquetReader of an iterator.
+ // Closing the reader releases every iterator it handed out, including
any the caller left open;
+ // an iterator's close() is idempotent, so closing one that is already
closed is a no-op. One that fails
+ // to close does not stop the others from being closed: the first
failure is rethrown once every iterator
+ // has been released, with the later failures attached to it as
suppressed
+ synchronized (openIterators) {
+ RuntimeException failure = null;
+ for (ParquetIndexedRecordIterator iterator : openIterators) {
+ try {
+ iterator.close();
+ }
+ catch (RuntimeException e) {
+ if (failure == null) {
+ failure = e;
+ }
+ else {
+ failure.addSuppressed(e);
+ }
+ }
+ }
+ openIterators.clear();
+ if (failure != null) {
+ throw failure;
+ }
+ }
+ }
+
+ private ParquetMetadata readParquetMetadata()
+ throws IOException
+ {
+ // No estimated size: with one at or below the small-file threshold
createDataSource returns a
+ // MemoryParquetDataSource that slurps the whole file, where the
footer read only needs the tail
+ try (ParquetDataSource dataSource =
openDataSource(newSimpleAggregatedMemoryContext(), OptionalLong.empty())) {
+ return MetadataReader.readFooter(dataSource, Optional.empty());
+ }
+ }
+
+ private ParquetDataSource openDataSource(AggregatedMemoryContext
memoryContext, OptionalLong estimatedFileSize)
+ throws IOException
+ {
+ return createDataSource(inputFile, estimatedFileSize, readerOptions,
memoryContext, new FileFormatDataSourceStats());
+ }
+
+ private Schema extractAvroSchema(FileMetadata fileMetaData)
+ {
+ String avroSchemaStr =
fileMetaData.getKeyValueMetaData().get(PARQUET_AVRO_SCHEMA_KEY);
+ if (avroSchemaStr == null) {
+ throw new TrinoException(HUDI_SCHEMA_ERROR, "Parquet file does not
contain Avro schema in metadata: " + path);
+ }
+ return new Schema.Parser().parse(avroSchemaStr);
+ }
+
+ /**
+ * One {@link HiveColumnHandle} per field of the projection, typed from
the field's Avro schema the way
+ * {@link HudiUtil#toColumnHandle} types the handles of a data-file read.
Handle {@code i} is built from
+ * field {@code i}, so a handle's position in the returned list is its
field's position in the records this
+ * reader produces. That list position is what {@link
#binaryFieldPositions} relies on, not the handle's own
+ * column index, which {@code toColumnHandle} leaves at 0.
+ */
+ private static List<HiveColumnHandle> buildColumnHandles(HoodieSchema
projectedSchema)
+ {
+ return projectedSchema.getFields().stream()
+ .map(HudiUtil::toColumnHandle)
+ .toList();
+ }
+
+ private static List<Column> buildTrinoColumns(List<HiveColumnHandle>
columnHandles, MessageColumnIO messageColumnIO)
+ {
+ ImmutableList.Builder<Column> columnsBuilder = ImmutableList.builder();
+ for (HiveColumnHandle columnHandle : columnHandles) {
+ String name = columnHandle.getName();
+ Field parquetField = constructField(columnHandle.getType(),
lookupColumnByName(messageColumnIO, name))
+ .orElseThrow(() -> new TrinoException(HUDI_SCHEMA_ERROR,
"Could not find column: " + name));
+ columnsBuilder.add(new Column(name, parquetField));
+ }
+ return columnsBuilder.build();
+ }
+
+ /**
+ * Record positions of the projection's VARBINARY columns, if any. Trino
hands a VARBINARY value out as a
+ * {@link SqlVarbinary}, while Avro's in-memory representation of {@code
bytes} is a {@link ByteBuffer} --
+ * and that is what hudi-common casts to when it reads the {@code
metadata} and {@code plan} columns of an
+ * LSM instant, so those values have to be converted before the record
leaves this reader.
+ */
+ private static int[] binaryFieldPositions(List<HiveColumnHandle>
columnHandles)
+ {
+ return IntStream.range(0, columnHandles.size())
+ .filter(position ->
columnHandles.get(position).getType().equals(VARBINARY))
+ .toArray();
+ }
+
+ private static TrinoException handleException(StoragePath path, Exception
exception)
+ {
+ if (exception instanceof TrinoException trinoException) {
+ return trinoException;
+ }
+ if (exception instanceof ParquetCorruptionException) {
+ return new TrinoException(HUDI_BAD_DATA, exception);
+ }
+ return new TrinoException(HUDI_CURSOR_ERROR, "Failed to read Parquet
file: " + path, exception);
+ }
+
+ private class ParquetIndexedRecordIterator
+ implements ClosableIterator<IndexedRecord>
+ {
+ private final ParquetReader parquetReader;
+ private final HudiAvroSerializer avroSerializer;
+ private final int[] binaryFieldPositions;
+ private Page currentPage;
+ private int currentPosition;
+ private boolean exhausted;
+ private boolean closed;
+
+ ParquetIndexedRecordIterator(HoodieSchema projectedSchema)
+ {
+ List<HiveColumnHandle> columnHandles =
buildColumnHandles(projectedSchema);
+ this.parquetReader = createParquetReader(columnHandles);
+ // Null prefilled values: PrefilledColumnValues answers partition
and hidden metadata columns of a
+ // split, of which a timeline read has neither, and serialize()
only ever reads page values. There
+ // is no split here to build one from -- create(HudiSplit) is its
only factory.
+ this.avroSerializer = new HudiAvroSerializer(columnHandles, null,
projectedSchema.toAvroSchema());
+ this.binaryFieldPositions = binaryFieldPositions(columnHandles);
+ }
+
+ @Override
+ public boolean hasNext()
+ {
+ if (closed || exhausted) {
+ return false;
+ }
+ if (currentPage != null && currentPosition <
currentPage.getPositionCount()) {
+ return true;
+ }
+ try {
+ loadNextPage();
+ return !exhausted;
+ }
+ catch (IOException | RuntimeException e) {
+ // loadNextPage() decodes the page eagerly, and a data page
that fails to decode surfaces as a
+ // ParquetDecodingException, which is unchecked: ParquetReader
routes only the IOExceptions of a
+ // page read through the exception transform it is handed
+ throw handleException(path, e);
+ }
+ }
+
+ @Override
+ public IndexedRecord next()
+ {
+ if (!hasNext()) {
+ throw new NoSuchElementException();
+ }
+ IndexedRecord record = avroSerializer.serialize(currentPage,
currentPosition);
+ for (int fieldPosition : binaryFieldPositions) {
+ if (record.get(fieldPosition) instanceof SqlVarbinary value) {
+ record.put(fieldPosition,
ByteBuffer.wrap(value.getBytes()));
+ }
+ }
+ currentPosition++;
+ return record;
+ }
+
+ @Override
+ public void close()
+ {
+ if (!closed) {
+ closed = true;
+ currentPage = null;
+ try {
+ // Also closes the underlying ParquetDataSource
+ parquetReader.close();
+ }
+ catch (IOException e) {
+ throw handleException(path, e);
+ }
+ }
+ }
+
+ private ParquetReader createParquetReader(List<HiveColumnHandle>
columnHandles)
+ {
+ AggregatedMemoryContext memoryContext =
newSimpleAggregatedMemoryContext();
+ ParquetDataSource dataSource = null;
+ try {
+ dataSource = openDataSource(memoryContext,
OptionalLong.of(fileLength));
+ FileMetadata fileMetaData = parquetMetadata.getFileMetaData();
+ MessageType fileSchema = fileMetaData.getSchema();
+ MessageType requestedSchema =
getParquetMessageType(columnHandles, true, fileSchema)
+ .orElse(new MessageType(fileSchema.getName(),
ImmutableList.of()));
+ List<Column> columns = buildTrinoColumns(columnHandles,
getColumnIO(fileSchema, requestedSchema));
+
+ Map<List<String>, ColumnDescriptor> descriptorsByPath =
getDescriptors(fileSchema, requestedSchema);
+ TupleDomain<ColumnDescriptor> tupleDomain = TupleDomain.all();
+ TupleDomainParquetPredicate parquetPredicate =
buildPredicate(requestedSchema, tupleDomain, descriptorsByPath, UTC_TIME_ZONE);
+ List<RowGroupInfo> rowGroups = getFilteredRowGroups(
+ 0,
+ fileLength,
+ dataSource,
+ parquetMetadata,
+ ImmutableList.of(tupleDomain),
+ ImmutableList.of(parquetPredicate),
+ descriptorsByPath,
+ UTC_TIME_ZONE,
+ DOMAIN_COMPACTION_THRESHOLD,
+ readerOptions);
+
+ return new ParquetReader(
+ Optional.ofNullable(fileMetaData.getCreatedBy()),
+ columns,
+ false,
+ rowGroups,
+ dataSource,
+ UTC_TIME_ZONE,
+ memoryContext,
+ readerOptions,
+ exception -> handleException(path, exception),
+ Optional.of(parquetPredicate),
+ Optional.empty(),
+ parquetMetadata.getDecryptionContext());
+ }
+ catch (IOException | RuntimeException e) {
+ // The reader owns the data source only once it is constructed
+ if (dataSource != null) {
+ try {
+ dataSource.close();
+ }
+ catch (IOException _) {
+ }
+ }
+ throw handleException(path, e);
+ }
+ }
+
+ private void loadNextPage()
+ throws IOException
+ {
+ SourcePage sourcePage = parquetReader.nextPage();
+ // Once the reader has handed out its last page it must never be
asked again: a nextPage() past
+ // the end of the row groups throws IndexOutOfBoundsException
instead of returning null a second time
+ exhausted = sourcePage == null;
+ currentPage = exhausted ? null : sourcePage.getPage();
+ currentPosition = 0;
+ }
+ }
+}
diff --git
a/hudi-trino/src/test/java/io/trino/plugin/hudi/TestHudiSmokeTest.java
b/hudi-trino/src/test/java/io/trino/plugin/hudi/TestHudiSmokeTest.java
index 981191962f28..70da4b1249cd 100644
--- a/hudi-trino/src/test/java/io/trino/plugin/hudi/TestHudiSmokeTest.java
+++ b/hudi-trino/src/test/java/io/trino/plugin/hudi/TestHudiSmokeTest.java
@@ -87,6 +87,7 @@ import static
io.trino.plugin.hudi.testing.ResourceHudiTablesInitializer.Testing
import static
io.trino.plugin.hudi.testing.ResourceHudiTablesInitializer.TestingTable.HUDI_COW_TABLE_WITH_FIELD_NAMES_IN_CAPS;
import static
io.trino.plugin.hudi.testing.ResourceHudiTablesInitializer.TestingTable.HUDI_COW_TABLE_WITH_MULTI_KEYS_AND_FIELD_NAMES_IN_CAPS;
import static
io.trino.plugin.hudi.testing.ResourceHudiTablesInitializer.TestingTable.HUDI_CUSTOM_KEYGEN_PT_V8_MOR;
+import static
io.trino.plugin.hudi.testing.ResourceHudiTablesInitializer.TestingTable.HUDI_MOR_ARCHIVED_TIMELINE;
import static
io.trino.plugin.hudi.testing.ResourceHudiTablesInitializer.TestingTable.HUDI_MULTI_PT_V8_MOR;
import static
io.trino.plugin.hudi.testing.ResourceHudiTablesInitializer.TestingTable.HUDI_NON_EXTRACTABLE_PARTITION_PATH;
import static
io.trino.plugin.hudi.testing.ResourceHudiTablesInitializer.TestingTable.HUDI_NON_PART_COW;
@@ -1386,6 +1387,30 @@ public class TestHudiSmokeTest
SchemaEvolutionHudiTablesInitializer.TABLE_NAME, predicate);
}
+ // The MOR fixture's oldest log file belongs to delta commit
20250918122106595, which has been archived: the
+ // first instant on the active timeline is 20250918122107347. File slicing
therefore has to look that log
+ // file's completion time up in the LSM archived timeline, a read that
goes through
+ // HudiTrinoFileReaderFactory#newParquetFileReader and threw
UnsupportedOperationException before
+ // TrinoParquetFileReader existed (apache/hudi#13994).
+ @ParameterizedTest
+ @ValueSource(booleans = {true, false})
+ public void testReadTableWithArchivedTimeline(boolean isRtTable)
+ {
+ String tableName = isRtTable ?
HUDI_MOR_ARCHIVED_TIMELINE.getRtTableName()
+ : HUDI_MOR_ARCHIVED_TIMELINE.getTableName();
+ @Language("SQL") String actualQuery = "SELECT id, name, price, ts FROM
" + tableName;
+ @Language("SQL") String expectedQuery;
+ if (isRtTable) {
+ // Real-time table, log files are merged onto the base files
+ expectedQuery = "VALUES (2, 'updated_user2', 20.0, 2000), (3,
'user3', 30.0, 3000), (4, 'user4', 40.0, 4000), (5, 'user5', 50.0, 5000)";
+ }
+ else {
+ // Read-optimized table, base files only (the fixture was written
with inline compaction disabled)
+ expectedQuery = "VALUES (1, 'user1', 10.0, 1000), (2, 'user2',
20.0, 2000), (3, 'user3', 30.0, 3000), (4, 'user4', 40.0, 4000), (5, 'user5',
50.0, 5000)";
+ }
+ assertQuery(actualQuery, expectedQuery);
+ }
+
private void testTimestampMicros(HiveTimestampPrecision
timestampPrecision, LocalDateTime expected)
throws Exception
{
diff --git
a/hudi-trino/src/test/java/io/trino/plugin/hudi/io/TestTrinoParquetFileReader.java
b/hudi-trino/src/test/java/io/trino/plugin/hudi/io/TestTrinoParquetFileReader.java
new file mode 100644
index 000000000000..f1691cefb1cb
--- /dev/null
+++
b/hudi-trino/src/test/java/io/trino/plugin/hudi/io/TestTrinoParquetFileReader.java
@@ -0,0 +1,252 @@
+/*
+ * Licensed 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 io.trino.plugin.hudi.io;
+
+import com.google.common.io.Resources;
+import io.trino.filesystem.local.LocalFileSystem;
+import io.trino.parquet.ParquetCorruptionException;
+import io.trino.plugin.hudi.storage.HudiTrinoStorage;
+import io.trino.plugin.hudi.storage.TrinoStorageConfiguration;
+import io.trino.spi.TrinoException;
+import org.apache.avro.Schema;
+import org.apache.avro.generic.IndexedRecord;
+import org.apache.hudi.avro.model.HoodieLSMTimelineInstant;
+import org.apache.hudi.common.schema.HoodieSchema;
+import org.apache.hudi.common.table.timeline.HoodieArchivedTimeline;
+import org.apache.hudi.common.table.timeline.LSMTimeline;
+import org.apache.hudi.common.util.collection.ClosableIterator;
+import org.apache.hudi.storage.HoodieStorage;
+import org.apache.hudi.storage.StoragePath;
+import org.apache.parquet.io.ParquetDecodingException;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.EnumSource;
+
+import java.io.File;
+import java.nio.ByteBuffer;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.nio.file.Paths;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.NoSuchElementException;
+
+import static io.trino.plugin.hudi.HudiErrorCode.HUDI_BAD_DATA;
+import static io.trino.plugin.hudi.HudiErrorCode.HUDI_CURSOR_ERROR;
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+
+/**
+ * Tests {@link TrinoParquetFileReader} against a four-instant LSM
archived-timeline parquet file, the shape Hudi's
+ * archived-timeline loader reads through the connector. {@code
archived_timeline.parquet} is the first history file,
+ * {@code 20250918121953134_20250918122001506_0.parquet}, of the
table-version-8 COW table that the create script in
+ * {@code hudi-testing-data/hudi_cow_archived_timeline.md} produces with Hudi
1.0.2: four commit instants,
+ * 20250918121953134 through 20250918122001506. Only that file is checked in,
not the table.
+ */
+class TestTrinoParquetFileReader
+{
+ private static final String ARCHIVED_TIMELINE_PARQUET_FILE =
"archived_timeline.parquet";
+ // The fixture's first page is the only data page of instantTime: 25 bytes
of thrift-compact PageHeader from
+ // byte 4 (type, sizes, crc, then the DataPageHeader whose encoding field
is the byte at 22), then the payload.
+ // Thrift compact writes the encoding enum as a zigzag varint: PLAIN (0)
is 0x00, RLE_DICTIONARY (8) is 0x10
+ private static final int INSTANT_TIME_PAGE_ENCODING_OFFSET = 22;
+ private static final byte PLAIN_ENCODING = 0x00;
+ private static final byte RLE_DICTIONARY_ENCODING = 0x10;
+
+ @Test
+ void testReadArchivedTimelineFile()
+ throws Exception
+ {
+ HoodieSchema tableSchema =
HoodieSchema.fromAvroSchema(HoodieLSMTimelineInstant.getClassSchema());
+ Schema avroSchema = tableSchema.toAvroSchema();
+
+ try (TrinoParquetFileReader reader = createReader()) {
+
assertThat(reader.getSchema().toAvroSchema()).isEqualTo(HoodieLSMTimelineInstant.getClassSchema());
+ assertThat(reader.getTotalRecords()).isEqualTo(4);
+
+ List<IndexedRecord> records = new ArrayList<>();
+ try (ClosableIterator<IndexedRecord> iterator =
reader.getIndexedRecordIterator(tableSchema, tableSchema)) {
+ iterator.forEachRemaining(records::add);
+ }
+ assertThat(records).hasSize(4);
+
+ IndexedRecord firstRecord = records.getFirst();
+
assertThat(firstRecord.get(avroSchema.getField("instantTime").pos()).toString()).isEqualTo("20250918121953134");
+
assertThat(firstRecord.get(avroSchema.getField("completionTime").pos()).toString()).isEqualTo("20250918121957816");
+
assertThat(firstRecord.get(avroSchema.getField("action").pos()).toString()).isEqualTo("commit");
+
+ IndexedRecord secondRecord = records.get(1);
+
assertThat(secondRecord.get(avroSchema.getField("instantTime").pos()).toString()).isEqualTo("20250918121958100");
+
assertThat(secondRecord.get(avroSchema.getField("completionTime").pos()).toString()).isEqualTo("20250918121959081");
+
assertThat(secondRecord.get(avroSchema.getField("action").pos()).toString()).isEqualTo("commit");
+
+ IndexedRecord lastRecord = records.getLast();
+
assertThat(lastRecord.get(avroSchema.getField("instantTime").pos()).toString()).isEqualTo("20250918122001506");
+
assertThat(lastRecord.get(avroSchema.getField("completionTime").pos()).toString()).isEqualTo("20250918122002218");
+ }
+ }
+
+ @ParameterizedTest
+ @EnumSource(value = HoodieArchivedTimeline.LoadMode.class, names =
{"TIME", "METADATA", "PLAN", "FULL"})
+ void testProjectedReadUsesRequestedSchema(HoodieArchivedTimeline.LoadMode
loadMode)
+ throws Exception
+ {
+ // The projections the archived-timeline readers request. TIME is what
CompletionTimeQueryViewV2 asks for when
+ // it only needs instant times; METADATA and PLAN are what
ArchivedTimelineV2 asks for when it needs a payload,
+ // and it casts that bytes column to ByteBuffer -- the SqlVarbinary ->
ByteBuffer conversion exists for those
+ // two. FULL is requested only by EightToSevenDowngradeHandler, but it
is the one projection that orders plan
+ // before metadata, the reverse of the file's metadata, plan, so a
passing FULL read proves columns are mapped
+ // by name and not by position
+ Schema projectedAvroSchema = LSMTimeline.getReadSchema(loadMode);
+ HoodieSchema tableSchema =
HoodieSchema.fromAvroSchema(HoodieLSMTimelineInstant.getClassSchema());
+ HoodieSchema projectedSchema =
HoodieSchema.fromAvroSchema(projectedAvroSchema);
+
+ try (TrinoParquetFileReader reader = createReader()) {
+ List<IndexedRecord> records = new ArrayList<>();
+ try (ClosableIterator<IndexedRecord> iterator =
reader.getIndexedRecordIterator(tableSchema, projectedSchema)) {
+ iterator.forEachRemaining(records::add);
+ }
+ assertThat(records).hasSize(4);
+ assertThat(records).allSatisfy(record ->
assertThat(record.getSchema()).isEqualTo(projectedAvroSchema));
+
+ int instantTimePos =
projectedAvroSchema.getField("instantTime").pos();
+ int completionTimePos =
projectedAvroSchema.getField("completionTime").pos();
+
assertThat(records.getFirst().get(instantTimePos).toString()).isEqualTo("20250918121953134");
+
assertThat(records.getFirst().get(completionTimePos).toString()).isEqualTo("20250918121957816");
+
assertThat(records.get(1).get(instantTimePos).toString()).isEqualTo("20250918121958100");
+
assertThat(records.get(1).get(completionTimePos).toString()).isEqualTo("20250918121959081");
+
+ // All four instants of the fixture are commits: every row has
metadata bytes and a null plan. METADATA and
+ // PLAN each carry only their own bytes column and FULL carries
both, so the two are checked independently
+ if (projectedAvroSchema.getField("metadata") != null) {
+ int metadataPos =
projectedAvroSchema.getField("metadata").pos();
+ assertThat(records).allSatisfy(record -> {
+
assertThat(record.get(metadataPos)).isInstanceOf(ByteBuffer.class);
+ assertThat(((ByteBuffer)
record.get(metadataPos)).remaining()).isGreaterThan(0);
+ });
+ }
+ if (projectedAvroSchema.getField("plan") != null) {
+ int planPos = projectedAvroSchema.getField("plan").pos();
+ assertThat(records).allSatisfy(record ->
assertThat(record.get(planPos)).isNull());
+ }
+ }
+ }
+
+ @Test
+ void testDrainedIteratorStaysDrained()
+ throws Exception
+ {
+ // Once the ParquetReader has handed out its last page it must not be
asked for another: a nextPage() past the
+ // end of the row groups throws instead of returning null again, so
the iterator has to remember it is done
+ HoodieSchema tableSchema =
HoodieSchema.fromAvroSchema(HoodieLSMTimelineInstant.getClassSchema());
+ try (TrinoParquetFileReader reader = createReader();
+ ClosableIterator<IndexedRecord> iterator =
reader.getIndexedRecordIterator(tableSchema, tableSchema)) {
+ List<IndexedRecord> records = new ArrayList<>();
+ iterator.forEachRemaining(records::add);
+ assertThat(records).hasSize(4);
+
+ assertThat(iterator.hasNext()).isFalse();
+ assertThat(iterator.hasNext()).isFalse();
+
assertThatThrownBy(iterator::next).isInstanceOf(NoSuchElementException.class);
+ }
+ }
+
+ @Test
+ void testCloseReleasesOpenIterator()
+ throws Exception
+ {
+ // Closing the reader closes an iterator the caller left open; closing
that iterator afterwards is a no-op
+ HoodieSchema tableSchema =
HoodieSchema.fromAvroSchema(HoodieLSMTimelineInstant.getClassSchema());
+ TrinoParquetFileReader reader = createReader();
+ ClosableIterator<IndexedRecord> iterator =
reader.getIndexedRecordIterator(tableSchema, tableSchema);
+ assertThat(iterator.hasNext()).isTrue();
+ assertThat(iterator.next()).isNotNull();
+
+ reader.close();
+ assertThat(iterator.hasNext()).isFalse();
+
assertThatThrownBy(iterator::next).isInstanceOf(NoSuchElementException.class);
+ iterator.close();
+ reader.close();
+ }
+
+ @Test
+ void testCorruptFileFailsWithBadData(@TempDir Path tempDir)
+ throws Exception
+ {
+ // A footer that does not parse is reported the way a failed page read
is: HUDI_BAD_DATA with the corruption as
+ // its cause. The ParquetCorruptionException it starts out as is an
IOException, and left raw it would reach
+ // hudi-common as a HoodieIOException instead
+ Path corruptFile = tempDir.resolve("corrupt.parquet");
+ Files.write(corruptFile, new byte[] {1, 2, 3});
+ StoragePath path = new
StoragePath(corruptFile.toFile().toURI().toString());
+
+ assertThatThrownBy(() -> new TrinoParquetFileReader(localStorage(),
path))
+ .isInstanceOf(TrinoException.class)
+ .hasCauseInstanceOf(ParquetCorruptionException.class)
+ .extracting(e -> ((TrinoException) e).getErrorCode())
+ .isEqualTo(HUDI_BAD_DATA.toErrorCode());
+ }
+
+ @Test
+ void testCorruptDataPageFailsWithCursorError(@TempDir Path tempDir)
+ throws Exception
+ {
+ // A data page that fails to decode is reported the way a failed
footer read is: as a TrinoException with the
+ // failure as its cause. Trino decodes a page eagerly and reports a
corrupt one with a ParquetDecodingException,
+ // which is unchecked, so left raw it would pass through hudi-common
untouched and reach the engine as an
+ // internal error instead of HUDI_CURSOR_ERROR. The footer is intact,
so the reader still opens and the failure
+ // surfaces from hasNext(). Switching the page's encoding from PLAIN
to RLE_DICTIONARY makes it claim a
+ // dictionary the column chunk does not carry, which is what the
column reader rejects
+ byte[] bytes =
Resources.toByteArray(Resources.getResource(ARCHIVED_TIMELINE_PARQUET_FILE));
+
assertThat(bytes[INSTANT_TIME_PAGE_ENCODING_OFFSET]).isEqualTo(PLAIN_ENCODING);
+ bytes[INSTANT_TIME_PAGE_ENCODING_OFFSET] = RLE_DICTIONARY_ENCODING;
+ Path corruptFile = tempDir.resolve("corrupt_page.parquet");
+ Files.write(corruptFile, bytes);
+ StoragePath path = new
StoragePath(corruptFile.toFile().toURI().toString());
+
+ HoodieSchema tableSchema =
HoodieSchema.fromAvroSchema(HoodieLSMTimelineInstant.getClassSchema());
+ try (TrinoParquetFileReader reader = new
TrinoParquetFileReader(localStorage(), path);
+ ClosableIterator<IndexedRecord> iterator =
reader.getIndexedRecordIterator(tableSchema, tableSchema)) {
+ assertThatThrownBy(iterator::hasNext)
+ .isInstanceOf(TrinoException.class)
+ .hasCauseInstanceOf(ParquetDecodingException.class)
+ .extracting(e -> ((TrinoException) e).getErrorCode())
+ .isEqualTo(HUDI_CURSOR_ERROR.toErrorCode());
+ }
+ }
+
+ @Test
+ void testFooterLookupsUnsupported()
+ throws Exception
+ {
+ // A timeline file is not a data file: it carries neither a bloom
filter nor min/max record keys
+ try (TrinoParquetFileReader reader = createReader()) {
+
assertThatThrownBy(reader::readBloomFilter).isInstanceOf(UnsupportedOperationException.class);
+
assertThatThrownBy(reader::readMinMaxRecordKeys).isInstanceOf(UnsupportedOperationException.class);
+ }
+ }
+
+ private static TrinoParquetFileReader createReader()
+ throws Exception
+ {
+ File parquetFile = new
File(Resources.getResource(ARCHIVED_TIMELINE_PARQUET_FILE).toURI());
+ return new TrinoParquetFileReader(localStorage(), new
StoragePath(parquetFile.toURI().toString()));
+ }
+
+ private static HoodieStorage localStorage()
+ {
+ return new HudiTrinoStorage(new LocalFileSystem(Paths.get("/")), new
TrinoStorageConfiguration());
+ }
+}
diff --git
a/hudi-trino/src/test/java/io/trino/plugin/hudi/testing/ResourceHudiTablesInitializer.java
b/hudi-trino/src/test/java/io/trino/plugin/hudi/testing/ResourceHudiTablesInitializer.java
index f430c7b556c2..821c0dae72ad 100644
---
a/hudi-trino/src/test/java/io/trino/plugin/hudi/testing/ResourceHudiTablesInitializer.java
+++
b/hudi-trino/src/test/java/io/trino/plugin/hudi/testing/ResourceHudiTablesInitializer.java
@@ -359,6 +359,7 @@ public class ResourceHudiTablesInitializer
HUDI_TIMESTAMP_KEYGEN_PT_SCALAR_TO_YYYY_MM_DD_HH_V8_MOR(hudiTimestampKeygenColumns(),
hudiTimestampKeygenPartitionColumns(),
hudiTimestampKeygenPartitions("SCALAR"), true),
HUDI_CUSTOM_KEYGEN_PT_V8_MOR(hudiCustomKeyGenColumns(),
hudiCustomKeyGenPartitionColumns(), hudiCustomKeyGenPartitions(), false),
HUDI_NON_EXTRACTABLE_PARTITION_PATH(multiPartitionRegularColumns(),
multiPartitionColumns(), multiPartitionsWithNonExtractablePartitionPaths(),
false),
+ HUDI_MOR_ARCHIVED_TIMELINE(hudiMultiFgRegularColumns(),
ImmutableList.of(), ImmutableMap.of(), true),
/**/;
private static final List<Column> HUDI_META_COLUMNS = ImmutableList.of(
diff --git a/hudi-trino/src/test/resources/archived_timeline.parquet
b/hudi-trino/src/test/resources/archived_timeline.parquet
new file mode 100644
index 000000000000..c0c888d43fb1
Binary files /dev/null and
b/hudi-trino/src/test/resources/archived_timeline.parquet differ
diff --git
a/hudi-trino/src/test/resources/hudi-testing-data/hudi_cow_archived_timeline.md
b/hudi-trino/src/test/resources/hudi-testing-data/hudi_cow_archived_timeline.md
new file mode 100644
index 000000000000..01e71806eaaa
--- /dev/null
+++
b/hudi-trino/src/test/resources/hudi-testing-data/hudi_cow_archived_timeline.md
@@ -0,0 +1,124 @@
+<!--
+ 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.
+-->
+
+## Create script
+
+Structure of table:
+- COW table in table version 8 with an archived timeline
+- Using Hudi 1.0.2
+- Non-partitioned table
+
+The table itself is not checked in. Its first LSM history file,
+`.hoodie/timeline/history/20250918121953134_20250918122001506_0.parquet` (four
commit instants, 20250918121953134
+through 20250918122001506), is checked in as
`src/test/resources/archived_timeline.parquet` for
+`TestTrinoParquetFileReader`.
+
+```scala
+package org.apache.spark.sql.hudi.timeline
+
+import org.apache.hudi.common.table.HoodieTableMetaClient
+import org.apache.hudi.storage.hadoop.HadoopStorageConfiguration
+import org.apache.spark.sql.hudi.common.HoodieSparkSqlTestBase
+
+import java.io.File
+
+class TestCompactedTimelineTable extends HoodieSparkSqlTestBase {
+
+ test("Test COW Table with Compacted LSM Timeline") {
+ withRecordType()(withTempDir { tmp =>
+ val tableName = generateTableName
+ val tablePath = tmp.getCanonicalPath
+
+ // Create COW table with aggressive timeline archival settings
+ spark.sql(
+ s"""
+ |create table $tableName (
+ | id int,
+ | name string,
+ | price double,
+ | ts long
+ |) using hudi
+ | location '$tablePath'
+ | tblproperties (
+ | primaryKey = 'id',
+ | type = 'cow',
+ | preCombineField = 'ts',
+ | 'hoodie.keep.min.commits' = '3',
+ | 'hoodie.keep.max.commits' = '5',
+ | 'hoodie.cleaner.commits.retained' = '2',
+ | 'hoodie.archive.automatic' = 'true'
+ | )
+ """.stripMargin)
+
+ // Generate initial commits
+ spark.sql(s"insert into $tableName values(1, 'alice', 100.0,
1000)")
+ spark.sql(s"insert into $tableName values(2, 'bob', 200.0, 2000)")
+ spark.sql(s"insert into $tableName values(3, 'charlie', 300.0,
3000)")
+
+ // Update operations to create more timeline entries
+ spark.sql(s"update $tableName set price = 110.0 where id = 1")
+ spark.sql(s"update $tableName set name = 'robert' where id = 2")
+
+ // More commits to trigger archival
+ spark.sql(s"insert into $tableName values(4, 'david', 400.0,
4000)")
+ spark.sql(s"insert into $tableName values(5, 'eve', 500.0, 5000)")
+
+ // Delete operation
+ spark.sql(s"delete from $tableName where id = 3")
+
+ // Additional commits to exceed max commits threshold and trigger
archival
+ spark.sql(s"insert into $tableName values(6, 'frank', 600.0,
6000)")
+ spark.sql(s"update $tableName set price = price * 1.1 where id >
4")
+ spark.sql(s"insert into $tableName values(7, 'grace', 700.0,
7000)")
+
+ // Verify data correctness after all operations
+ checkAnswer(s"select id, name, price, ts from $tableName order by
id")(
+ Seq(1, "alice", 110.0, 1000),
+ Seq(2, "robert", 200.0, 2000),
+ Seq(4, "david", 400.0, 4000),
+ Seq(5, "eve", 550.0, 5000),
+ Seq(6, "frank", 660.0, 6000),
+ Seq(7, "grace", 700.0, 7000)
+ )
+
+ // Verify timeline archival occurred
+ val metaClient = HoodieTableMetaClient.builder()
+ .setConf(new
HadoopStorageConfiguration(spark.sparkContext.hadoopConfiguration))
+ .setBasePath(tablePath)
+ .build()
+
+ val timeline = metaClient.getActiveTimeline
+ val archivedTimeline = metaClient.getArchivedTimeline
+
+ // Check that archived timeline exists and has entries
+ assertResult(true)(archivedTimeline.reload().countInstants() > 0)
+
+ // Verify archived timeline files exist in the
.hoodie/timeline/history directory
+ val archivedDir = new File(tablePath, ".hoodie/timeline/history")
+ assertResult(true)(archivedDir.exists() &&
archivedDir.listFiles().nonEmpty)
+
+ // Check that archived files are parquet format
+ val archivedFiles =
archivedDir.listFiles().filter(_.getName.endsWith(".parquet"))
+ assertResult(true)(archivedFiles.nonEmpty)
+
+ println(s"Active timeline instants: ${timeline.countInstants()}")
+ println(s"Archived timeline instants:
${archivedTimeline.reload().countInstants()}")
+ println(s"Archived files:
${archivedFiles.map(_.getName).mkString(", ")}")
+ })
+ }
+}
+```
diff --git
a/hudi-trino/src/test/resources/hudi-testing-data/hudi_mor_archived_timeline.md
b/hudi-trino/src/test/resources/hudi-testing-data/hudi_mor_archived_timeline.md
new file mode 100644
index 000000000000..cf22eaaaab62
--- /dev/null
+++
b/hudi-trino/src/test/resources/hudi-testing-data/hudi_mor_archived_timeline.md
@@ -0,0 +1,96 @@
+<!--
+ 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.
+-->
+
+## Create script
+
+Structure of table:
+- MOR table in table version 8 with an archived timeline
+- Using Hudi 1.0.2
+- Non-partitioned table
+
+```scala
+package org.apache.spark.sql.hudi.timeline
+
+import org.apache.hudi.common.table.HoodieTableMetaClient
+import org.apache.hudi.storage.hadoop.HadoopStorageConfiguration
+import org.apache.spark.sql.hudi.common.HoodieSparkSqlTestBase
+
+import java.io.File
+
+class TestCompactedTimelineTable extends HoodieSparkSqlTestBase {
+
+ test("Test MOR Table with Compacted LSM Timeline") {
+ withRecordType()(withTempDir { tmp =>
+ val tableName = generateTableName
+ val tablePath = tmp.getCanonicalPath
+
+ // Create MOR table with timeline archival settings
+ spark.sql(
+ s"""
+ |create table $tableName (
+ | id int,
+ | name string,
+ | price double,
+ | ts long
+ |) using hudi
+ | location '$tablePath'
+ | tblproperties (
+ | primaryKey = 'id',
+ | type = 'mor',
+ | preCombineField = 'ts',
+ | 'hoodie.keep.min.commits' = '2',
+ | 'hoodie.keep.max.commits' = '4',
+ | 'hoodie.cleaner.commits.retained' = '1',
+ | 'hoodie.archive.automatic' = 'true',
+ | 'hoodie.compact.inline' = 'false'
+ | )
+ """.stripMargin)
+
+ // Generate commits and updates to create both base and log files
+ spark.sql(s"insert into $tableName values(1, 'user1', 10.0, 1000)")
+ spark.sql(s"insert into $tableName values(2, 'user2', 20.0, 2000)")
+ spark.sql(s"update $tableName set price = 15.0 where id = 1")
+ spark.sql(s"insert into $tableName values(3, 'user3', 30.0, 3000)")
+ spark.sql(s"update $tableName set name = 'updated_user2' where id
= 2")
+
+ // More operations to trigger archival
+ spark.sql(s"insert into $tableName values(4, 'user4', 40.0, 4000)")
+ spark.sql(s"delete from $tableName where id = 1")
+ spark.sql(s"insert into $tableName values(5, 'user5', 50.0, 5000)")
+
+ // Verify final data state
+ checkAnswer(s"select id, name, price, ts from $tableName order by
id")(
+ Seq(2, "updated_user2", 20.0, 2000),
+ Seq(3, "user3", 30.0, 3000),
+ Seq(4, "user4", 40.0, 4000),
+ Seq(5, "user5", 50.0, 5000)
+ )
+
+ // Check archived timeline creation
+ val metaClient = HoodieTableMetaClient.builder()
+ .setConf(new
HadoopStorageConfiguration(spark.sparkContext.hadoopConfiguration))
+ .setBasePath(tablePath)
+ .build()
+
+ val archivedTimeline = metaClient.getArchivedTimeline
+ assertResult(true)(archivedTimeline.reload().countInstants() > 0)
+
+ println(s"MOR Archived timeline instants:
${archivedTimeline.reload().countInstants()}")
+ })
+ }
+}
+```
diff --git
a/hudi-trino/src/test/resources/hudi-testing-data/hudi_mor_archived_timeline.zip
b/hudi-trino/src/test/resources/hudi-testing-data/hudi_mor_archived_timeline.zip
new file mode 100644
index 000000000000..3fa27a36ca69
Binary files /dev/null and
b/hudi-trino/src/test/resources/hudi-testing-data/hudi_mor_archived_timeline.zip
differ