diff --git a/docs/docs/primary-key-table/changelog-producer.md b/docs/docs/primary-key-table/changelog-producer.md
index ecbc2755a363..b57cc26245bc 100644
--- a/docs/docs/primary-key-table/changelog-producer.md
+++ b/docs/docs/primary-key-table/changelog-producer.md
@@ -39,6 +39,7 @@ only `5` requires the consumer to remember the previous value.
| `none` (default) | Consumer state, if needed | Incremental snapshot reads | The consumer accepts upserts or can normalize them |
| `input` | Complete changelog supplied by the source | Committed input changelog files | The upstream already supplies the required before/after records |
| `lookup` | Lookup of existing rows during compaction | After lookup compaction is committed | The input has no before images and consumers need complete changes |
+| `on-read` | Lookup of historical rows during streaming reads | Incremental snapshot reads | Readers reconstruct complete changes without stored changelog files |
| `full-compaction` | Difference between full-compaction results | After a full compaction is committed | Consumers can wait for periodic full compactions |
The diagram follows one existing key whose value changes from `4` to `5`. `-U` is
@@ -109,6 +110,17 @@ checkpoint completion waits for compaction. Tune it with checkpoint duration and
`lookup` is incompatible with `full-compaction.delta-commits`. For periodic full compaction with
changelog generation, use `full-compaction` instead.
+## On Read
+
+With `changelog-producer = on-read`, streaming readers reconstruct changes from new data files
+and historical records. Each reader caches partition-bucket lookup states for reuse across splits.
+
+`changelog-on-read.lookup-state.max-size` limits the number of cached states per reader. It defaults
+to `2048` and must be greater than zero. When the limit is exceeded, the least recently used state
+and its local lookup files are evicted. Reading that partition and bucket again rebuilds the state
+from the split's historical files and recreates lookup files as needed. This option limits the
+number of states, not their size in bytes.
+
## Full Compaction
```sql
diff --git a/docs/generated/core_configuration.html b/docs/generated/core_configuration.html
index 0fc8c42fd261..8bfd57d12cb3 100644
--- a/docs/generated/core_configuration.html
+++ b/docs/generated/core_configuration.html
@@ -212,29 +212,35 @@
String |
Changelog file metadata stats collection. none, counts, truncate(16), full is available. |
+
+ changelog-on-read.lookup-state.max-size |
+ 2048 |
+ Integer |
+ Maximum number of partition-bucket lookup states cached by each reader with changelog-producer=on-read. Must be greater than 0. Least recently used states and their local lookup files are evicted when the limit is exceeded. |
+
changelog-producer |
none |
Enum |
- Whether to double write to a changelog file. This changelog file keeps the details of data changes, it can be read directly during stream reads. This can be applied to tables with primary keys.
Possible values:- "none": No changelog file.
- "input": Double write to a changelog file when flushing memory table, the changelog is from input.
- "full-compaction": Generate changelog files with each full compaction.
- "lookup": Generate changelog files through 'lookup' compaction.
|
+ How to produce changelog for primary-key tables. Input, lookup and full-compaction produce changelog files; on-read reconstructs changes from data files during streaming reads.
Possible values:- "none": No changelog file.
- "input": Double write to a changelog file when flushing memory table, the changelog is from input.
- "full-compaction": Generate changelog files with each full compaction.
- "lookup": Generate changelog files through 'lookup' compaction.
- "on-read": Generate changelog during reads without writing changelog files.
|
changelog-producer.ignore-delete |
false |
Boolean |
- Whether to ignore delete records in the changelog. When set to true, DELETE (-D) records will not be written to changelog files. This configuration is only valid for the changelog-producer is lookup or full-compaction. |
+ Whether to ignore delete records in the changelog. When set to true, DELETE (-D) records will not be emitted. Valid for lookup, full-compaction and on-read. |
changelog-producer.ignore-update-before |
false |
Boolean |
- Whether to ignore update-before records in the changelog. When set to true, UPDATE_BEFORE (-U) records will not be written to changelog files. This configuration is only valid for the changelog-producer is lookup or full-compaction. |
+ Whether to ignore update-before records in the changelog. When set to true, UPDATE_BEFORE (-U) records will not be emitted. Valid for lookup, full-compaction and on-read. |
changelog-producer.row-deduplicate |
false |
Boolean |
- Whether to generate -U, +U changelog for the same record. This configuration is only valid for the changelog-producer is lookup or full-compaction. |
+ Whether to suppress -U, +U changelog for equal records. Valid for lookup, full-compaction and on-read. |
changelog-producer.row-deduplicate-ignore-fields |
diff --git a/paimon-api/src/main/java/org/apache/paimon/CoreOptions.java b/paimon-api/src/main/java/org/apache/paimon/CoreOptions.java
index c3202188dc09..00e1c52d0595 100644
--- a/paimon-api/src/main/java/org/apache/paimon/CoreOptions.java
+++ b/paimon-api/src/main/java/org/apache/paimon/CoreOptions.java
@@ -1161,9 +1161,19 @@ public InlineElement getDescription() {
.enumType(ChangelogProducer.class)
.defaultValue(ChangelogProducer.NONE)
.withDescription(
- "Whether to double write to a changelog file. "
- + "This changelog file keeps the details of data changes, "
- + "it can be read directly during stream reads. This can be applied to tables with primary keys. ");
+ "How to produce changelog for primary-key tables. "
+ + "Input, lookup and full-compaction produce changelog files; "
+ + "on-read reconstructs changes from data files during streaming reads.");
+
+ public static final ConfigOption CHANGELOG_ON_READ_LOOKUP_STATE_MAX_SIZE =
+ key("changelog-on-read.lookup-state.max-size")
+ .intType()
+ .defaultValue(2048)
+ .withDescription(
+ "Maximum number of partition-bucket lookup states cached by each reader "
+ + "with changelog-producer=on-read. Must be greater than 0. "
+ + "Least recently used states and their local lookup files are "
+ + "evicted when the limit is exceeded.");
public static final ConfigOption CHANGELOG_PRODUCER_IGNORE_UPDATE_BEFORE =
key("changelog-producer.ignore-update-before")
@@ -1171,8 +1181,8 @@ public InlineElement getDescription() {
.defaultValue(false)
.withDescription(
"Whether to ignore update-before records in the changelog. "
- + "When set to true, UPDATE_BEFORE (-U) records will not be written to changelog files. "
- + "This configuration is only valid for the changelog-producer is lookup or full-compaction.");
+ + "When set to true, UPDATE_BEFORE (-U) records will not be emitted. "
+ + "Valid for lookup, full-compaction and on-read.");
public static final ConfigOption CHANGELOG_PRODUCER_IGNORE_DELETE =
key("changelog-producer.ignore-delete")
@@ -1180,15 +1190,16 @@ public InlineElement getDescription() {
.defaultValue(false)
.withDescription(
"Whether to ignore delete records in the changelog. "
- + "When set to true, DELETE (-D) records will not be written to changelog files. "
- + "This configuration is only valid for the changelog-producer is lookup or full-compaction.");
+ + "When set to true, DELETE (-D) records will not be emitted. "
+ + "Valid for lookup, full-compaction and on-read.");
public static final ConfigOption CHANGELOG_PRODUCER_ROW_DEDUPLICATE =
key("changelog-producer.row-deduplicate")
.booleanType()
.defaultValue(false)
.withDescription(
- "Whether to generate -U, +U changelog for the same record. This configuration is only valid for the changelog-producer is lookup or full-compaction.");
+ "Whether to suppress -U, +U changelog for equal records. "
+ + "Valid for lookup, full-compaction and on-read.");
public static final ConfigOption CHANGELOG_PRODUCER_ROW_DEDUPLICATE_IGNORE_FIELDS =
key("changelog-producer.row-deduplicate-ignore-fields")
@@ -4055,6 +4066,27 @@ public ChangelogProducer changelogProducer() {
return options.get(CHANGELOG_PRODUCER);
}
+ public boolean changelogOnRead() {
+ return changelogProducer() == ChangelogProducer.ON_READ;
+ }
+
+ public int changelogOnReadLookupStateMaxSize() {
+ int maxSize = options.get(CHANGELOG_ON_READ_LOOKUP_STATE_MAX_SIZE);
+ checkArgument(
+ maxSize > 0,
+ "The option %s must be greater than 0.",
+ CHANGELOG_ON_READ_LOOKUP_STATE_MAX_SIZE.key());
+ return maxSize;
+ }
+
+ /** Whether this mode materializes changelog files, rather than reconstructing changes. */
+ public boolean producesChangelogFiles() {
+ ChangelogProducer producer = changelogProducer();
+ return producer == ChangelogProducer.INPUT
+ || producer == ChangelogProducer.LOOKUP
+ || producer == ChangelogProducer.FULL_COMPACTION;
+ }
+
public boolean needLookup() {
return lookupStrategy().needLookup;
}
@@ -5465,7 +5497,9 @@ public enum ChangelogProducer implements DescribedEnum {
FULL_COMPACTION("full-compaction", "Generate changelog files with each full compaction."),
- LOOKUP("lookup", "Generate changelog files through 'lookup' compaction.");
+ LOOKUP("lookup", "Generate changelog files through 'lookup' compaction."),
+
+ ON_READ("on-read", "Generate changelog during reads without writing changelog files.");
private final String value;
private final String description;
diff --git a/paimon-core/src/main/java/org/apache/paimon/AbstractFileStore.java b/paimon-core/src/main/java/org/apache/paimon/AbstractFileStore.java
index 866394577870..bf2f5b17799d 100644
--- a/paimon-core/src/main/java/org/apache/paimon/AbstractFileStore.java
+++ b/paimon-core/src/main/java/org/apache/paimon/AbstractFileStore.java
@@ -341,7 +341,7 @@ public SnapshotDeletion newSnapshotDeletion() {
manifestListFactory().create(),
newIndexFileHandler(),
newStatsFileHandler(),
- options.changelogProducer() != CoreOptions.ChangelogProducer.NONE,
+ options.producesChangelogFiles(),
options.cleanEmptyDirectories(),
options.fileOperationThreadNum(),
options.scanManifestParallelism());
diff --git a/paimon-core/src/main/java/org/apache/paimon/mergetree/LookupLevels.java b/paimon-core/src/main/java/org/apache/paimon/mergetree/LookupLevels.java
index 22fef5a1fc29..cf66e35dcdf0 100644
--- a/paimon-core/src/main/java/org/apache/paimon/mergetree/LookupLevels.java
+++ b/paimon-core/src/main/java/org/apache/paimon/mergetree/LookupLevels.java
@@ -43,8 +43,10 @@
import java.io.Closeable;
import java.io.File;
import java.io.IOException;
+import java.util.ArrayList;
import java.util.Comparator;
import java.util.HashSet;
+import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.Set;
@@ -149,6 +151,49 @@ public T lookup(InternalRow key, int startLevel, @Nullable LookupContext context
(lookupKey, level0) -> lookupLevel0(lookupKey, level0, context));
}
+ /** Lookup all records for a key instead of stopping at the first matching level. */
+ public List lookupAll(InternalRow key) throws IOException {
+ List result = new ArrayList<>();
+ for (DataFileMeta file : levels.level0()) {
+ if (keyComparator.compare(file.maxKey(), key) >= 0
+ && keyComparator.compare(file.minKey(), key) <= 0) {
+ T value = lookup(key, file, null);
+ if (value != null) {
+ result.add(value);
+ }
+ }
+ }
+ for (int level = 1; level < levels.numberOfLevels(); level++) {
+ T value = lookup(key, levels.runOfLevel(level), null);
+ if (value != null) {
+ result.add(value);
+ }
+ }
+ return result;
+ }
+
+ /** Prepare local lookup files before records are consumed. */
+ public void prepareLookupFiles(List files) throws IOException {
+ for (DataFileMeta file : files) {
+ String fileName = file.fileName();
+ LookupFile lookupFile = lookupFileCache.getIfPresent(fileName);
+ if (lookupFile != null && !lookupFile.isClosed()) {
+ continue;
+ }
+
+ Object lock = lookupFileLock(fileName);
+ synchronized (lock) {
+ lookupFile = lookupFileCache.getIfPresent(fileName);
+ if (lookupFile != null && !lookupFile.isClosed()) {
+ continue;
+ }
+
+ lookupFile = createLookupFile(file);
+ addLocalFile(file, lookupFile);
+ }
+ }
+ }
+
@Nullable
private T lookupLevel0(
InternalRow key, TreeSet level0, @Nullable LookupContext context)
@@ -174,10 +219,11 @@ private T lookup(InternalRow key, DataFileMeta file, @Nullable LookupContext con
return null;
}
+ // A cached SST can outlive a file upgrade; use the level from the current read view.
return readFromDisk(
getOrCreateProcessor(lookupResult.schemaId, lookupResult.serVersion),
key,
- lookupResult.level,
+ file.level(),
valueBytes,
file.fileName());
}
@@ -248,21 +294,18 @@ private LookupResult lookupCachedFile(
private static class LookupResult {
- private final int level;
private final long schemaId;
private final String serVersion;
private final byte[] valueBytes;
- private LookupResult(int level, long schemaId, String serVersion, byte[] valueBytes) {
- this.level = level;
+ private LookupResult(long schemaId, String serVersion, byte[] valueBytes) {
this.schemaId = schemaId;
this.serVersion = serVersion;
this.valueBytes = valueBytes;
}
private static LookupResult of(LookupFile lookupFile, byte[] valueBytes) {
- return new LookupResult(
- lookupFile.level(), lookupFile.schemaId(), lookupFile.serVersion(), valueBytes);
+ return new LookupResult(lookupFile.schemaId(), lookupFile.serVersion(), valueBytes);
}
}
diff --git a/paimon-core/src/main/java/org/apache/paimon/mergetree/compact/SortMergeReaderWithLoserTree.java b/paimon-core/src/main/java/org/apache/paimon/mergetree/compact/SortMergeReaderWithLoserTree.java
index a3b8b1e26f59..256ca4f3835b 100644
--- a/paimon-core/src/main/java/org/apache/paimon/mergetree/compact/SortMergeReaderWithLoserTree.java
+++ b/paimon-core/src/main/java/org/apache/paimon/mergetree/compact/SortMergeReaderWithLoserTree.java
@@ -21,7 +21,9 @@
import org.apache.paimon.KeyValue;
import org.apache.paimon.data.InternalRow;
import org.apache.paimon.reader.RecordReader;
+import org.apache.paimon.utils.ExceptionUtils;
import org.apache.paimon.utils.FieldsComparator;
+import org.apache.paimon.utils.IOUtils;
import org.apache.paimon.utils.Preconditions;
import javax.annotation.Nullable;
@@ -84,7 +86,11 @@ public RecordIterator readBatch() throws IOException {
@Override
public void close() throws IOException {
- loserTree.close();
+ try {
+ IOUtils.closeAll(loserTree, mergeFunctionWrapper::reset);
+ } catch (Exception e) {
+ ExceptionUtils.rethrowIOException(e);
+ }
}
/** The iterator iterates on {@link SortMergeReaderWithLoserTree}. */
diff --git a/paimon-core/src/main/java/org/apache/paimon/mergetree/compact/SortMergeReaderWithMinHeap.java b/paimon-core/src/main/java/org/apache/paimon/mergetree/compact/SortMergeReaderWithMinHeap.java
index db09ce41a7dd..ddba5897dd92 100644
--- a/paimon-core/src/main/java/org/apache/paimon/mergetree/compact/SortMergeReaderWithMinHeap.java
+++ b/paimon-core/src/main/java/org/apache/paimon/mergetree/compact/SortMergeReaderWithMinHeap.java
@@ -119,6 +119,7 @@ public void close() throws IOException {
for (Element element : polled) {
collected = releaseCollecting(element, collected);
}
+ collected = closeCollecting(mergeFunctionWrapper::reset, collected);
if (collected != null) {
rethrowCloseFailure(collected);
}
@@ -135,7 +136,7 @@ public void close() throws IOException {
}
private static @Nullable Throwable closeCollecting(
- RecordReader reader, @Nullable Throwable collected) {
+ AutoCloseable reader, @Nullable Throwable collected) {
try {
reader.close();
} catch (Throwable t) {
diff --git a/paimon-core/src/main/java/org/apache/paimon/operation/MergeFileSplitRead.java b/paimon-core/src/main/java/org/apache/paimon/operation/MergeFileSplitRead.java
index 9e66aad52fca..a58245dd0057 100644
--- a/paimon-core/src/main/java/org/apache/paimon/operation/MergeFileSplitRead.java
+++ b/paimon-core/src/main/java/org/apache/paimon/operation/MergeFileSplitRead.java
@@ -21,8 +21,10 @@
import org.apache.paimon.CoreOptions;
import org.apache.paimon.KeyValue;
import org.apache.paimon.KeyValueFileStore;
+import org.apache.paimon.codegen.RecordEqualiser;
import org.apache.paimon.data.BinaryRow;
import org.apache.paimon.data.InternalRow;
+import org.apache.paimon.data.serializer.RowCompactedSerializer;
import org.apache.paimon.deletionvectors.DeletionVector;
import org.apache.paimon.disk.IOManager;
import org.apache.paimon.fs.FileIO;
@@ -30,17 +32,29 @@
import org.apache.paimon.io.ChainReadContext;
import org.apache.paimon.io.DataFileMeta;
import org.apache.paimon.io.KeyValueFileReaderFactory;
+import org.apache.paimon.io.cache.CacheManager;
+import org.apache.paimon.lookup.LookupStoreFactory;
+import org.apache.paimon.lookup.LookupStrategy;
import org.apache.paimon.mergetree.DropDeleteReader;
+import org.apache.paimon.mergetree.Levels;
+import org.apache.paimon.mergetree.LookupFile;
+import org.apache.paimon.mergetree.LookupLevels;
import org.apache.paimon.mergetree.MergeSorter;
import org.apache.paimon.mergetree.MergeTreeReaders;
import org.apache.paimon.mergetree.SortBufferWriteBuffer;
import org.apache.paimon.mergetree.SortedRun;
+import org.apache.paimon.mergetree.compact.ChangelogResult;
import org.apache.paimon.mergetree.compact.ConcatRecordReader;
import org.apache.paimon.mergetree.compact.IntervalPartition;
+import org.apache.paimon.mergetree.compact.LookupChangelogMergeFunctionWrapper;
import org.apache.paimon.mergetree.compact.LookupMergeFunction;
+import org.apache.paimon.mergetree.compact.MergeFunction;
import org.apache.paimon.mergetree.compact.MergeFunctionFactory;
import org.apache.paimon.mergetree.compact.MergeFunctionWrapper;
import org.apache.paimon.mergetree.compact.ReducerMergeFunctionWrapper;
+import org.apache.paimon.mergetree.lookup.LookupSerializerFactory;
+import org.apache.paimon.mergetree.lookup.PersistValueProcessor;
+import org.apache.paimon.options.Options;
import org.apache.paimon.predicate.Predicate;
import org.apache.paimon.reader.EmptyRecordReader;
import org.apache.paimon.reader.ReadBatchSizer;
@@ -51,25 +65,39 @@
import org.apache.paimon.table.source.ChainSplit;
import org.apache.paimon.table.source.DataSplit;
import org.apache.paimon.table.source.DeletionFile;
+import org.apache.paimon.table.source.IncrementalSplit;
import org.apache.paimon.table.source.Split;
import org.apache.paimon.types.DataField;
+import org.apache.paimon.types.RowKind;
import org.apache.paimon.types.RowType;
+import org.apache.paimon.utils.Pair;
import org.apache.paimon.utils.ProjectedRow;
import org.apache.paimon.utils.UserDefinedSeqComparator;
+import org.apache.paimon.utils.ValueEqualiserSupplier;
+
+import org.apache.paimon.shade.caffeine2.com.github.benmanes.caffeine.cache.Cache;
import javax.annotation.Nullable;
import java.io.IOException;
+import java.io.UncheckedIOException;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections;
import java.util.Comparator;
+import java.util.Iterator;
+import java.util.LinkedHashMap;
import java.util.List;
+import java.util.Map;
import java.util.Set;
import java.util.stream.Collectors;
+import static org.apache.paimon.lookup.LookupStoreFactory.bloomFilterBuilderFactory;
+import static org.apache.paimon.mergetree.LookupFile.localFilePrefix;
import static org.apache.paimon.predicate.PredicateBuilder.containsFields;
import static org.apache.paimon.predicate.PredicateBuilder.splitAnd;
+import static org.apache.paimon.utils.Preconditions.checkArgument;
+import static org.apache.paimon.utils.Preconditions.checkNotNull;
/**
* An implementation for {@link KeyValueFileStore}, this class handle LSM merging and changelog row
@@ -99,6 +127,10 @@ public class MergeFileSplitRead implements SplitRead {
private boolean forceKeepDelete = false;
+ @Nullable private Cache lookupFileCache;
+ @Nullable private LookupStoreFactory lookupStoreFactory;
+ private final Map, LookupLevels> lookupLevelsMap;
+
public MergeFileSplitRead(
CoreOptions options,
TableSchema schema,
@@ -119,6 +151,24 @@ public MergeFileSplitRead(
CoreOptions.fromMap(tableSchema.options()), keyType, valueType, null);
this.sequenceFields = options.sequenceField();
this.sequenceOrder = options.sequenceFieldSortOrderIsAscending();
+ int maxLookupStates = options.changelogOnReadLookupStateMaxSize();
+ this.lookupLevelsMap =
+ new LinkedHashMap, LookupLevels>(
+ 16, 0.75f, true) {
+ @Override
+ protected boolean removeEldestEntry(
+ Map.Entry, LookupLevels> eldest) {
+ if (size() <= maxLookupStates) {
+ return false;
+ }
+ try {
+ eldest.getValue().close();
+ } catch (IOException e) {
+ throw new UncheckedIOException(e);
+ }
+ return true;
+ }
+ };
}
public Comparator keyComparator() {
@@ -237,6 +287,259 @@ public MergeFileSplitRead withFilter(Predicate predicate) {
return this;
}
+ /**
+ * Creates a changelog reader. The previous on-read reader from this instance must be closed
+ * before calling this method again, because creating a reader may evict cached lookup states.
+ */
+ public RecordReader createChangelogOnReadReader(IncrementalSplit split)
+ throws IOException {
+ checkArgument(
+ split.beforeDeletionFiles() == null && split.afterDeletionFiles() == null,
+ "Changelog-on-read does not support deletion vectors.");
+ checkArgument(
+ split.afterFiles().stream().allMatch(file -> file.level() == 0),
+ "Changelog-on-read expects only level-0 delta files.");
+
+ LookupLevels history =
+ refreshLookupLevels(split.partition(), split.bucket(), split.beforeFiles());
+ history.prepareLookupFiles(lookupFilesToPrepare(split.beforeFiles(), split.afterFiles()));
+
+ UserDefinedSeqComparator sequenceComparator = createUdsComparator();
+ MergeFunctionWrapper mergeFunctionWrapper =
+ createChangelogOnReadMergeWrapper(history, sequenceComparator);
+
+ KeyValueFileReaderFactory readerFactory =
+ readerFactoryBuilder.build(
+ split.partition(),
+ split.bucket(),
+ DeletionVector.emptyFactory(),
+ false,
+ filtersForKeys);
+ RecordReader reader =
+ MergeTreeReaders.readerForMergeTree(
+ new IntervalPartition(split.afterFiles(), keyComparator).partition(),
+ readerFactory,
+ keyComparator,
+ sequenceComparator,
+ mergeFunctionWrapper,
+ mergeSorter);
+ return finishMergeReader(changelogReader(reader), true);
+ }
+
+ private MergeFunctionWrapper createChangelogOnReadMergeWrapper(
+ LookupLevels history, @Nullable UserDefinedSeqComparator sequenceComparator) {
+ RowType readType = actualReadType();
+ Comparator keyValueComparator = createSequenceComparator(sequenceComparator);
+ MergeFunction historyMerge = mfFactory.create(readType);
+ MergeFunctionFactory mergeFactory = unwrapLookup(mfFactory);
+
+ // Keep one lookup layer and size its spill serializer for the projected values.
+ LookupMergeFunction.Factory lookupFactory =
+ (LookupMergeFunction.Factory)
+ LookupMergeFunction.wrap(
+ ignored -> mergeFactory.create(readType),
+ options,
+ keyType,
+ readType);
+ lookupFactory.withIOManager(ioManager);
+
+ RowType equalType = outerReadType == null ? readType : outerReadType;
+ RecordEqualiser equaliser =
+ options.changelogRowDeduplicate()
+ ? ValueEqualiserSupplier.fromIgnoreFields(
+ equalType, options.changelogRowDeduplicateIgnoreFields())
+ .get()
+ : null;
+ return new LookupChangelogMergeFunctionWrapper(
+ lookupFactory,
+ key -> {
+ try {
+ List values = history.lookupAll(key);
+ if (values.isEmpty()) {
+ return null;
+ }
+ values.sort(keyValueComparator);
+ historyMerge.reset();
+ values.forEach(historyMerge::add);
+ KeyValue result = historyMerge.getResult();
+ return result == null ? null : result.setLevel(1);
+ } catch (IOException e) {
+ throw new UncheckedIOException(e);
+ }
+ },
+ equaliser,
+ LookupStrategy.from(false, true, false, false),
+ null,
+ sequenceComparator) {
+ @Override
+ public void reset() {
+ try {
+ super.reset();
+ } finally {
+ historyMerge.reset();
+ }
+ }
+ };
+ }
+
+ private List lookupFilesToPrepare(
+ List beforeFiles, List afterFiles) {
+ List result = new ArrayList<>();
+ for (DataFileMeta before : beforeFiles) {
+ for (DataFileMeta after : afterFiles) {
+ if (keyComparator.compare(before.maxKey(), after.minKey()) >= 0
+ && keyComparator.compare(before.minKey(), after.maxKey()) <= 0) {
+ result.add(before);
+ break;
+ }
+ }
+ }
+ return result;
+ }
+
+ private LookupLevels refreshLookupLevels(
+ BinaryRow partition, int bucket, List files) {
+ Pair key = Pair.of(partition, bucket);
+ LookupLevels lookup = lookupLevelsMap.get(key);
+ if (lookup == null) {
+ lookup = createLookupLevels(partition, bucket, files);
+ lookupLevelsMap.put(key, lookup);
+ } else {
+ lookup.getLevels().update(lookup.getLevels().allFiles(), files);
+ }
+ return lookup;
+ }
+
+ private LookupLevels createLookupLevels(
+ BinaryRow partition, int bucket, List files) {
+ checkNotNull(ioManager, "IOManager is required for changelog-on-read.");
+ KeyValueFileReaderFactory readerFactory =
+ readerFactoryBuilder.build(
+ partition, bucket, DeletionVector.emptyFactory(), false, filtersForKeys);
+ RowType readType = actualReadType();
+ Options configuration = options.toConfiguration();
+ return new LookupLevels<>(
+ ignored -> readType,
+ tableSchema.id(),
+ new Levels(keyComparator, files, options.numLevels()),
+ keyComparator,
+ keyType,
+ PersistValueProcessor.factory(readType),
+ LookupSerializerFactory.INSTANCE.get(),
+ readerFactory::createRecordReader,
+ file ->
+ checkNotNull(ioManager)
+ .createChannel(
+ localFilePrefix(
+ tableSchema.logicalPartitionType(),
+ partition,
+ bucket,
+ file))
+ .getPathFile(),
+ lookupStoreFactory(),
+ bloomFilterBuilderFactory(configuration),
+ lookupFileCache());
+ }
+
+ private LookupStoreFactory lookupStoreFactory() {
+ if (lookupStoreFactory == null) {
+ lookupStoreFactory =
+ LookupStoreFactory.create(
+ options,
+ new CacheManager(
+ options.lookupCacheMaxMemory(),
+ options.lookupCacheHighPrioPoolRatio()),
+ new RowCompactedSerializer(keyType).createSliceComparator());
+ }
+ return lookupStoreFactory;
+ }
+
+ private Cache lookupFileCache() {
+ if (lookupFileCache == null) {
+ lookupFileCache =
+ LookupFile.createCache(
+ options.toConfiguration().get(CoreOptions.LOOKUP_CACHE_FILE_RETENTION),
+ options.toConfiguration().get(CoreOptions.LOOKUP_CACHE_MAX_DISK_SIZE));
+ }
+ return lookupFileCache;
+ }
+
+ private Comparator createSequenceComparator(
+ @Nullable UserDefinedSeqComparator comparator) {
+ return (left, right) -> {
+ if (comparator != null) {
+ int result = comparator.compare(left.value(), right.value());
+ if (result != 0) {
+ return result;
+ }
+ }
+ int result = Long.compare(left.sequenceNumber(), right.sequenceNumber());
+ if (result != 0) {
+ return result;
+ }
+ return Boolean.compare(left.isAdd(), right.isAdd());
+ };
+ }
+
+ private RecordReader changelogReader(RecordReader reader) {
+ return new RecordReader() {
+ @Nullable
+ @Override
+ public RecordIterator readBatch() throws IOException {
+ RecordIterator batch = reader.readBatch();
+ if (batch == null) {
+ return null;
+ }
+ return new RecordIterator() {
+ private Iterator current =
+ Collections.emptyList().iterator();
+
+ @Nullable
+ @Override
+ public KeyValue next() throws IOException {
+ while (true) {
+ while (!current.hasNext()) {
+ ChangelogResult result = batch.next();
+ if (result == null) {
+ return null;
+ }
+ current = result.changelogs().iterator();
+ }
+ KeyValue next = current.next();
+ if (options.changelogProducerIgnoreUpdateBefore()
+ && next.valueKind() == RowKind.UPDATE_BEFORE) {
+ continue;
+ }
+ if (options.changelogProducerIgnoreDelete()
+ && next.valueKind() == RowKind.DELETE) {
+ continue;
+ }
+ return next;
+ }
+ }
+
+ @Override
+ public void releaseBatch() {
+ batch.releaseBatch();
+ }
+ };
+ }
+
+ @Override
+ public void close() throws IOException {
+ reader.close();
+ }
+ };
+ }
+
+ public void close() {
+ lookupLevelsMap.clear();
+ if (lookupFileCache != null) {
+ lookupFileCache.invalidateAll();
+ lookupFileCache = null;
+ }
+ }
+
@Override
public RecordReader createReader(Split split) throws IOException {
if (split instanceof DataSplit) {
diff --git a/paimon-core/src/main/java/org/apache/paimon/schema/SchemaValidation.java b/paimon-core/src/main/java/org/apache/paimon/schema/SchemaValidation.java
index e279d6479587..c7891acb5b5c 100644
--- a/paimon-core/src/main/java/org/apache/paimon/schema/SchemaValidation.java
+++ b/paimon-core/src/main/java/org/apache/paimon/schema/SchemaValidation.java
@@ -199,6 +199,8 @@ public static void validateTableSchema(TableSchema schema, Set dynamicOp
validateMergeFunction(schema);
+ validateChangelogOnRead(schema, options);
+
ChangelogProducer changelogProducer = options.changelogProducer();
if (schema.primaryKeys().isEmpty() && changelogProducer != ChangelogProducer.NONE) {
throw new UnsupportedOperationException(
@@ -887,6 +889,46 @@ private static void validateMergeFunction(TableSchema schema) {
createMergeFunctionFactory(schema);
}
+ private static void validateChangelogOnRead(TableSchema schema, CoreOptions options) {
+ if (!options.changelogOnRead()) {
+ return;
+ }
+
+ checkArgument(
+ !options.queryAuthEnabled(),
+ "changelog-producer=on-read does not support query authorization.");
+
+ checkArgument(
+ !schema.primaryKeys().isEmpty(),
+ "%s requires a table with primary keys.",
+ CHANGELOG_PRODUCER.key());
+ checkArgument(
+ options.mergeEngine() == MergeEngine.DEDUPLICATE
+ || options.mergeEngine() == MergeEngine.AGGREGATE,
+ "%s only supports deduplicate and aggregation merge engines.",
+ CHANGELOG_PRODUCER.key());
+ checkArgument(
+ options.bucket() > 0,
+ "%s only supports fixed bucket tables.",
+ CHANGELOG_PRODUCER.key());
+ checkArgument(
+ !options.deletionVectorsEnabled(),
+ "%s does not support deletion vectors.",
+ CHANGELOG_PRODUCER.key());
+ checkArgument(
+ !schema.crossPartitionUpdate(),
+ "%s does not support cross-partition updates.",
+ CHANGELOG_PRODUCER.key());
+ checkArgument(
+ !options.streamingReadOverwrite(),
+ "%s does not support streaming overwrite.",
+ CHANGELOG_PRODUCER.key());
+ checkArgument(
+ !options.isChainTable() && !options.dataEvolutionEnabled(),
+ "%s does not support chain tables or data evolution.",
+ CHANGELOG_PRODUCER.key());
+ }
+
private static void validateMapStorageLayout(TableSchema schema, CoreOptions options) {
String layoutSuffix = "." + MAP_STORAGE_LAYOUT;
Map fieldMap = new HashMap<>();
diff --git a/paimon-core/src/main/java/org/apache/paimon/table/FallbackReadFileStoreTable.java b/paimon-core/src/main/java/org/apache/paimon/table/FallbackReadFileStoreTable.java
index 02624408f2fb..cc1c538987ac 100644
--- a/paimon-core/src/main/java/org/apache/paimon/table/FallbackReadFileStoreTable.java
+++ b/paimon-core/src/main/java/org/apache/paimon/table/FallbackReadFileStoreTable.java
@@ -805,5 +805,14 @@ public RecordReader createReader(Split split) throws IOException {
// Plain splits come from newSnapshotReader(), which plans the wrapped table.
return wrappedRead.createReader(split);
}
+
+ @Override
+ public void close() throws IOException {
+ try {
+ mainRead.close();
+ } finally {
+ fallbackRead.close();
+ }
+ }
}
}
diff --git a/paimon-core/src/main/java/org/apache/paimon/table/source/AbstractDataTableScan.java b/paimon-core/src/main/java/org/apache/paimon/table/source/AbstractDataTableScan.java
index 4ac5cda74fe0..59967e41cbc0 100644
--- a/paimon-core/src/main/java/org/apache/paimon/table/source/AbstractDataTableScan.java
+++ b/paimon-core/src/main/java/org/apache/paimon/table/source/AbstractDataTableScan.java
@@ -761,9 +761,7 @@ private StartingScanner createIncrementalStartingScanner(SnapshotManager snapsho
private ScanMode toSnapshotScanMode(CoreOptions.IncrementalBetweenScanMode scanMode) {
switch (scanMode) {
case AUTO:
- return options.changelogProducer() == ChangelogProducer.NONE
- ? ScanMode.DELTA
- : ScanMode.CHANGELOG;
+ return options.producesChangelogFiles() ? ScanMode.CHANGELOG : ScanMode.DELTA;
case DELTA:
return ScanMode.DELTA;
case CHANGELOG:
diff --git a/paimon-core/src/main/java/org/apache/paimon/table/source/DataTableStreamScan.java b/paimon-core/src/main/java/org/apache/paimon/table/source/DataTableStreamScan.java
index 02806cfe33fa..db36dee4fbf0 100644
--- a/paimon-core/src/main/java/org/apache/paimon/table/source/DataTableStreamScan.java
+++ b/paimon-core/src/main/java/org/apache/paimon/table/source/DataTableStreamScan.java
@@ -276,6 +276,9 @@ protected FollowUpScanner createFollowUpScanner() {
case NONE:
followUpScanner = new DeltaFollowUpScanner();
break;
+ case ON_READ:
+ followUpScanner = new DeltaFollowUpScanner(true);
+ break;
case INPUT:
case FULL_COMPACTION:
case LOOKUP:
diff --git a/paimon-core/src/main/java/org/apache/paimon/table/source/IncrementalSplit.java b/paimon-core/src/main/java/org/apache/paimon/table/source/IncrementalSplit.java
index 78dbfff66f43..c42d597a683f 100644
--- a/paimon-core/src/main/java/org/apache/paimon/table/source/IncrementalSplit.java
+++ b/paimon-core/src/main/java/org/apache/paimon/table/source/IncrementalSplit.java
@@ -123,6 +123,10 @@ public boolean isStreaming() {
return isStreaming;
}
+ public OptionalLong earliestFileCreationEpochMillis() {
+ return afterFiles.stream().mapToLong(DataFileMeta::creationTimeEpochMillis).min();
+ }
+
@Override
public long rowCount() {
long rowCount = 0;
diff --git a/paimon-core/src/main/java/org/apache/paimon/table/source/KeyValueTableRead.java b/paimon-core/src/main/java/org/apache/paimon/table/source/KeyValueTableRead.java
index 687304ba8cab..cb5573bfd1ab 100644
--- a/paimon-core/src/main/java/org/apache/paimon/table/source/KeyValueTableRead.java
+++ b/paimon-core/src/main/java/org/apache/paimon/table/source/KeyValueTableRead.java
@@ -43,6 +43,7 @@
import javax.annotation.Nullable;
+import java.io.Closeable;
import java.io.IOException;
import java.util.ArrayList;
import java.util.Arrays;
@@ -238,6 +239,15 @@ public RecordReader reader(Split split) throws IOException {
throw new RuntimeException("Should not happen.");
}
+ @Override
+ public void close() throws IOException {
+ for (SplitReadProvider provider : readProviders) {
+ if (provider instanceof Closeable) {
+ ((Closeable) provider).close();
+ }
+ }
+ }
+
public static RecordReader unwrap(
RecordReader reader, Map schemaOptions) {
return new RecordReader() {
diff --git a/paimon-core/src/main/java/org/apache/paimon/table/source/ReadBuilderImpl.java b/paimon-core/src/main/java/org/apache/paimon/table/source/ReadBuilderImpl.java
index 51c76d71839b..e4870e08d42c 100644
--- a/paimon-core/src/main/java/org/apache/paimon/table/source/ReadBuilderImpl.java
+++ b/paimon-core/src/main/java/org/apache/paimon/table/source/ReadBuilderImpl.java
@@ -361,6 +361,11 @@ public RecordReader createReader(TableScan.Plan plan) throws IOExce
return limit(delegate.createReader(plan));
}
+ @Override
+ public void close() throws IOException {
+ delegate.close();
+ }
+
private RecordReader limit(RecordReader reader) {
if (filterPresent && !filterExecutedByReader) {
return reader;
diff --git a/paimon-core/src/main/java/org/apache/paimon/table/source/TableRead.java b/paimon-core/src/main/java/org/apache/paimon/table/source/TableRead.java
index 91e0b7d0149c..642bc75eb75b 100644
--- a/paimon-core/src/main/java/org/apache/paimon/table/source/TableRead.java
+++ b/paimon-core/src/main/java/org/apache/paimon/table/source/TableRead.java
@@ -28,6 +28,7 @@
import org.apache.paimon.reader.ReaderSupplier;
import org.apache.paimon.reader.RecordReader;
+import java.io.Closeable;
import java.io.IOException;
import java.util.ArrayList;
import java.util.List;
@@ -38,7 +39,10 @@
* @since 0.4.0
*/
@Public
-public interface TableRead {
+public interface TableRead extends Closeable {
+
+ @Override
+ default void close() throws IOException {}
/** Set {@link MetricRegistry} to table read. */
TableRead withMetricRegistry(MetricRegistry registry);
diff --git a/paimon-core/src/main/java/org/apache/paimon/table/source/snapshot/DeltaFollowUpScanner.java b/paimon-core/src/main/java/org/apache/paimon/table/source/snapshot/DeltaFollowUpScanner.java
index 5a8b4ec8c843..12d1b897cfdc 100644
--- a/paimon-core/src/main/java/org/apache/paimon/table/source/snapshot/DeltaFollowUpScanner.java
+++ b/paimon-core/src/main/java/org/apache/paimon/table/source/snapshot/DeltaFollowUpScanner.java
@@ -29,6 +29,15 @@
public class DeltaFollowUpScanner implements FollowUpScanner {
private static final Logger LOG = LoggerFactory.getLogger(DeltaFollowUpScanner.class);
+ private final boolean changelogOnRead;
+
+ public DeltaFollowUpScanner() {
+ this(false);
+ }
+
+ public DeltaFollowUpScanner(boolean changelogOnRead) {
+ this.changelogOnRead = changelogOnRead;
+ }
@Override
public boolean shouldScanSnapshot(Snapshot snapshot) {
@@ -45,6 +54,9 @@ public boolean shouldScanSnapshot(Snapshot snapshot) {
@Override
public SnapshotReader.Plan scan(Snapshot snapshot, SnapshotReader snapshotReader) {
+ if (changelogOnRead) {
+ return snapshotReader.withSnapshot(snapshot).readChangelogOnRead();
+ }
return snapshotReader.withMode(ScanMode.DELTA).withSnapshot(snapshot).read();
}
}
diff --git a/paimon-core/src/main/java/org/apache/paimon/table/source/snapshot/SnapshotReader.java b/paimon-core/src/main/java/org/apache/paimon/table/source/snapshot/SnapshotReader.java
index 68611f009116..95957fde3e19 100644
--- a/paimon-core/src/main/java/org/apache/paimon/table/source/snapshot/SnapshotReader.java
+++ b/paimon-core/src/main/java/org/apache/paimon/table/source/snapshot/SnapshotReader.java
@@ -143,6 +143,11 @@ public interface SnapshotReader {
/** Get splits plan from file changes. */
Plan readChanges();
+ /** Get delta files and their complete historical state for changelog-on-read. */
+ default Plan readChangelogOnRead() {
+ throw new UnsupportedOperationException("Changelog-on-read is not supported.");
+ }
+
Plan readIncrementalDiff(Snapshot before);
/** List partitions. */
diff --git a/paimon-core/src/main/java/org/apache/paimon/table/source/snapshot/SnapshotReaderImpl.java b/paimon-core/src/main/java/org/apache/paimon/table/source/snapshot/SnapshotReaderImpl.java
index 29f1ef029756..bbb906f18910 100644
--- a/paimon-core/src/main/java/org/apache/paimon/table/source/snapshot/SnapshotReaderImpl.java
+++ b/paimon-core/src/main/java/org/apache/paimon/table/source/snapshot/SnapshotReaderImpl.java
@@ -80,7 +80,6 @@
import static org.apache.paimon.Snapshot.FIRST_SNAPSHOT_ID;
import static org.apache.paimon.deletionvectors.DeletionVectorsIndexFile.DELETION_VECTORS_INDEX;
import static org.apache.paimon.operation.FileStoreScan.Plan.groupByPartFiles;
-import static org.apache.paimon.partition.PartitionPredicate.createPartitionPredicate;
import static org.apache.paimon.partition.PartitionPredicate.splitPartitionPredicatesAndDataPredicates;
/** Implementation of {@link SnapshotReader}. */
@@ -102,6 +101,7 @@ public class SnapshotReaderImpl implements SnapshotReader {
private ScanMode scanMode = ScanMode.ALL;
private boolean hasNonPartitionFilter;
+ @Nullable private PartitionPredicate partitionFilter;
private RecordComparator lazyPartitionComparator;
private CacheMetrics dvMetaCacheMetrics;
@@ -197,32 +197,31 @@ public SnapshotReader withSnapshot(Snapshot snapshot) {
@Override
public SnapshotReader withPartitionFilter(Map partitionSpec) {
if (partitionSpec != null) {
- Predicate partitionPredicate =
- createPartitionPredicate(
- partitionSpec,
+ return setPartitionFilter(
+ PartitionPredicate.fromMap(
tableSchema.logicalPartitionType(),
- options.partitionDefaultName());
- scan.withPartitionFilter(partitionPredicate);
+ partitionSpec,
+ options.partitionDefaultName()));
}
return this;
}
@Override
public SnapshotReader withPartitionFilter(Predicate predicate) {
- scan.withPartitionFilter(predicate);
- return this;
+ return setPartitionFilter(
+ PartitionPredicate.fromPredicate(tableSchema.logicalPartitionType(), predicate));
}
@Override
public SnapshotReader withPartitionFilter(List partitions) {
- scan.withPartitionFilter(partitions);
- return this;
+ return setPartitionFilter(
+ PartitionPredicate.fromMultiple(tableSchema.logicalPartitionType(), partitions));
}
@Override
public SnapshotReader withPartitionFilter(PartitionPredicate partitionPredicate) {
if (partitionPredicate != null) {
- scan.withPartitionFilter(partitionPredicate);
+ return setPartitionFilter(partitionPredicate);
}
return this;
}
@@ -230,11 +229,21 @@ public SnapshotReader withPartitionFilter(PartitionPredicate partitionPredicate)
@Override
public SnapshotReader withPartitionsFilter(List