[lake/paimon] Make PaimonSplit state survive Paimon class relocation (#3825)
diff --git a/fluss-lake/fluss-lake-paimon/src/main/java/org/apache/fluss/lake/paimon/source/PaimonSplitSerializer.java b/fluss-lake/fluss-lake-paimon/src/main/java/org/apache/fluss/lake/paimon/source/PaimonSplitSerializer.java
index 147a79e..0181c94 100644
--- a/fluss-lake/fluss-lake-paimon/src/main/java/org/apache/fluss/lake/paimon/source/PaimonSplitSerializer.java
+++ b/fluss-lake/fluss-lake-paimon/src/main/java/org/apache/fluss/lake/paimon/source/PaimonSplitSerializer.java
@@ -18,39 +18,61 @@
package org.apache.fluss.lake.paimon.source;
+import org.apache.fluss.annotation.VisibleForTesting;
import org.apache.fluss.lake.serializer.SimpleVersionedSerializer;
import org.apache.paimon.data.BinaryRow;
+import org.apache.paimon.io.DataInputViewStreamWrapper;
import org.apache.paimon.io.DataOutputViewStreamWrapper;
+import org.apache.paimon.table.FallbackReadFileStoreTable;
import org.apache.paimon.table.source.DataSplit;
import org.apache.paimon.utils.InstantiationUtil;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.io.DataInputStream;
import java.io.IOException;
+import java.io.InputStream;
+import java.io.ObjectStreamClass;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
+import static org.apache.fluss.utils.Preconditions.checkState;
+
/** Serializer for paimon split. */
public class PaimonSplitSerializer implements SimpleVersionedSerializer<PaimonSplit> {
+ private static final Logger LOG = LoggerFactory.getLogger(PaimonSplitSerializer.class);
+
+ // VERSION_1 and VERSION_2 persisted DataSplit via Java serialization; kept read-only to
+ // restore state written by older versions and must never be changed.
private static final int VERSION_1 = 1;
// VERSION_2 additionally persists the partition values.
private static final int VERSION_2 = 2;
+ // VERSION_3 persists DataSplit via Paimon's own versioned binary protocol whose bytes contain
+ // no Java class names, so it survives class relocation (shading) in downstream distributions.
+ private static final int VERSION_3 = 3;
+
+ // Split type tags of VERSION_3, aligned with Paimon master's SplitSerializer type ids.
+ // Serialization must dispatch on the concrete split class: DataSplit subclasses append extra
+ // fields after the base payload (e.g. FallbackDataSplit), so deserializing them with the base
+ // DataSplit.deserialize would leave trailing bytes and shift all subsequent reads.
+ private static final int SPLIT_TYPE_DATA_SPLIT = 1;
+ private static final int SPLIT_TYPE_FALLBACK_DATA_SPLIT = 6;
@Override
public int getVersion() {
- return VERSION_2;
+ return VERSION_3;
}
@Override
public byte[] serialize(PaimonSplit paimonSplit) throws IOException {
ByteArrayOutputStream out = new ByteArrayOutputStream();
DataOutputViewStreamWrapper view = new DataOutputViewStreamWrapper(out);
- DataSplit dataSplit = paimonSplit.dataSplit();
- InstantiationUtil.serializeObject(view, dataSplit);
+ serializeDataSplit(paimonSplit.dataSplit(), view);
view.writeBoolean(paimonSplit.isBucketUnAware());
List<String> partition = paimonSplit.partition();
view.writeInt(partition.size());
@@ -60,12 +82,66 @@
return out.toByteArray();
}
+ private void serializeDataSplit(DataSplit dataSplit, DataOutputViewStreamWrapper view)
+ throws IOException {
+ if (dataSplit.getClass() == DataSplit.class) {
+ view.writeByte(SPLIT_TYPE_DATA_SPLIT);
+ dataSplit.serialize(view);
+ } else if (dataSplit instanceof FallbackReadFileStoreTable.FallbackDataSplit) {
+ view.writeByte(SPLIT_TYPE_FALLBACK_DATA_SPLIT);
+ dataSplit.serialize(view);
+ } else {
+ // fail fast: an unknown subclass may append extra fields after the base payload,
+ // silently corrupting the stream on restore
+ throw new IOException("Unsupported DataSplit class: " + dataSplit.getClass().getName());
+ }
+ }
+
@Override
public PaimonSplit deserialize(int version, byte[] serialized) throws IOException {
+ switch (version) {
+ case VERSION_1:
+ case VERSION_2:
+ return deserializeLegacy(version, serialized);
+ case VERSION_3:
+ return deserializeV3(serialized);
+ default:
+ throw new IOException("Unsupported PaimonSplit serialization version: " + version);
+ }
+ }
+
+ private PaimonSplit deserializeV3(byte[] serialized) throws IOException {
+ ByteArrayInputStream in = new ByteArrayInputStream(serialized);
+ DataInputViewStreamWrapper view = new DataInputViewStreamWrapper(in);
+ int splitType = view.readByte();
+ DataSplit dataSplit;
+ switch (splitType) {
+ case SPLIT_TYPE_DATA_SPLIT:
+ dataSplit = DataSplit.deserialize(view);
+ break;
+ case SPLIT_TYPE_FALLBACK_DATA_SPLIT:
+ dataSplit = FallbackReadFileStoreTable.FallbackDataSplit.deserialize(view);
+ break;
+ default:
+ throw new IOException("Unsupported DataSplit type: " + splitType);
+ }
+ boolean isBucketUnAware = view.readBoolean();
+ int size = view.readInt();
+ List<String> partition = new ArrayList<>(size);
+ for (int i = 0; i < size; i++) {
+ partition.add(view.readUTF());
+ }
+ return new PaimonSplit(dataSplit, isBucketUnAware, partition);
+ }
+
+ private PaimonSplit deserializeLegacy(int version, byte[] serialized) throws IOException {
+ LOG.debug("Restoring PaimonSplit from legacy state format (version {}).", version);
ByteArrayInputStream in = new ByteArrayInputStream(serialized);
DataSplit dataSplit;
try {
- dataSplit = InstantiationUtil.deserializeObject(in, getClass().getClassLoader());
+ RelocatingObjectInputStream ois =
+ new RelocatingObjectInputStream(in, getClass().getClassLoader());
+ dataSplit = (DataSplit) ois.readObject();
DataInputStream dis = new DataInputStream(in);
boolean isBucketUnAware = dis.readBoolean();
if (version == VERSION_1) {
@@ -73,15 +149,13 @@
// exposed through DataSplit.partition(). Preserve that old behavior.
return new PaimonSplit(
dataSplit, isBucketUnAware, readStringPartition(dataSplit.partition()));
- } else if (version == VERSION_2) {
+ } else {
int size = dis.readInt();
List<String> partition = new ArrayList<>(size);
for (int i = 0; i < size; i++) {
partition.add(dis.readUTF());
}
return new PaimonSplit(dataSplit, isBucketUnAware, partition);
- } else {
- throw new IOException("Unsupported PaimonSplit serialization version: " + version);
}
} catch (ClassNotFoundException e) {
throw new IOException("Failed to deserialize PaimonSplit", e);
@@ -99,4 +173,67 @@
}
return partitions;
}
+
+ /**
+ * An {@link java.io.ObjectInputStream} that restores legacy state written before Paimon classes
+ * were relocated (shaded): class names starting with the original {@code org.apache.paimon.}
+ * prefix in the serialization stream are remapped to the actual (possibly relocated) class
+ * names at deserialization time.
+ *
+ * <p>In non-relocated builds the actual prefix equals the original prefix, so the remapping
+ * degrades to a no-op and behavior is unchanged.
+ */
+ static class RelocatingObjectInputStream
+ extends InstantiationUtil.ClassLoaderObjectInputStream {
+
+ // The prefix must NOT appear as a plain string literal: shade plugins rewrite matching
+ // constant-pool strings, which would silently turn old and new prefixes into the same
+ // string. Build it at runtime instead.
+ private static final String ORIGINAL_PREFIX =
+ String.join(".", "org", "apache", "paimon") + ".";
+
+ // Derived from the actually loaded class, so any relocation prefix works; in
+ // non-relocated builds it equals ORIGINAL_PREFIX.
+ private static final String ACTUAL_PREFIX;
+
+ static {
+ String cls = DataSplit.class.getName();
+ // relocation only rewrites the package prefix, so the trailing part is stable; fail
+ // loudly instead of computing a wrong prefix silently
+ String suffix = "table.source.DataSplit";
+ checkState(cls.endsWith(suffix), "Unexpected DataSplit class name: %s", cls);
+ ACTUAL_PREFIX = cls.substring(0, cls.length() - suffix.length());
+ }
+
+ private final String originalPrefix;
+ private final String actualPrefix;
+
+ RelocatingObjectInputStream(InputStream in, ClassLoader cl) throws IOException {
+ this(in, cl, ORIGINAL_PREFIX, ACTUAL_PREFIX);
+ }
+
+ @VisibleForTesting
+ RelocatingObjectInputStream(
+ InputStream in, ClassLoader cl, String originalPrefix, String actualPrefix)
+ throws IOException {
+ super(in, cl);
+ this.originalPrefix = originalPrefix;
+ this.actualPrefix = actualPrefix;
+ }
+
+ @Override
+ protected Class<?> resolveClass(ObjectStreamClass desc)
+ throws IOException, ClassNotFoundException {
+ String name = desc.getName();
+ if (!actualPrefix.equals(originalPrefix) && name.startsWith(originalPrefix)) {
+ String relocated = actualPrefix + name.substring(originalPrefix.length());
+ try {
+ return Class.forName(relocated, false, classLoader);
+ } catch (ClassNotFoundException ignored) {
+ // fall back to the default resolution to keep the original exception path
+ }
+ }
+ return super.resolveClass(desc);
+ }
+ }
}
diff --git a/fluss-lake/fluss-lake-paimon/src/test/java/org/apache/fluss/lake/paimon/source/PaimonSplitSerializerTest.java b/fluss-lake/fluss-lake-paimon/src/test/java/org/apache/fluss/lake/paimon/source/PaimonSplitSerializerTest.java
index e4c4c45..424b42b 100644
--- a/fluss-lake/fluss-lake-paimon/src/test/java/org/apache/fluss/lake/paimon/source/PaimonSplitSerializerTest.java
+++ b/fluss-lake/fluss-lake-paimon/src/test/java/org/apache/fluss/lake/paimon/source/PaimonSplitSerializerTest.java
@@ -27,9 +27,12 @@
import org.apache.paimon.Snapshot;
import org.apache.paimon.data.BinaryString;
import org.apache.paimon.data.GenericRow;
+import org.apache.paimon.io.DataInputViewStreamWrapper;
import org.apache.paimon.io.DataOutputViewStreamWrapper;
import org.apache.paimon.schema.Schema;
+import org.apache.paimon.table.FallbackReadFileStoreTable;
import org.apache.paimon.table.Table;
+import org.apache.paimon.table.source.DataSplit;
import org.apache.paimon.types.DataTypes;
import org.apache.paimon.utils.InstantiationUtil;
import org.junit.jupiter.api.Test;
@@ -39,6 +42,7 @@
import java.io.IOException;
import java.io.ObjectInputStream;
import java.io.ObjectOutputStream;
+import java.nio.charset.StandardCharsets;
import java.util.Collections;
import java.util.List;
@@ -51,13 +55,13 @@
@Test
void testSerializeAndDeserialize() throws Exception {
- PaimonSplit originalPaimonSplit = createStringPartitionSplit();
- byte[] serialized = serializer.serialize(originalPaimonSplit);
- PaimonSplit deserialized = serializer.deserialize(serializer.getVersion(), serialized);
+ // partitioned pk-table split and bucket-unaware append-table split with empty partition
+ assertV3RoundTrip(createStringPartitionSplit());
- assertThat(deserialized.dataSplit()).isEqualTo(originalPaimonSplit.dataSplit());
- assertThat(deserialized.isBucketUnAware()).isEqualTo(originalPaimonSplit.isBucketUnAware());
- assertThat(deserialized.partition()).isEqualTo(originalPaimonSplit.partition());
+ PaimonSplit bucketUnAwareSplit = createBucketUnAwareSplit();
+ assertThat(bucketUnAwareSplit.isBucketUnAware()).isTrue();
+ assertThat(bucketUnAwareSplit.partition()).isEmpty();
+ assertV3RoundTrip(bucketUnAwareSplit);
}
@Test
@@ -83,22 +87,76 @@
}
@Test
- void testDeserializeVersion1PreservesStringPartition() throws Exception {
+ void testDeserializeLegacyBytes() throws Exception {
+ // VERSION_1/VERSION_2 bytes written by older versions must remain restorable after the
+ // VERSION_3 upgrade
PaimonSplit original = createStringPartitionSplit();
- byte[] serialized = serializeVersion1(original);
- PaimonSplit deserialized = serializer.deserialize(1, serialized);
+ // VERSION_1 did not store partition values, they were derived from DataSplit.partition()
+ PaimonSplit fromV1 = serializer.deserialize(1, serializeVersion1(original));
+ assertThat(fromV1.dataSplit()).isEqualTo(original.dataSplit());
+ assertThat(fromV1.isBucketUnAware()).isEqualTo(original.isBucketUnAware());
+ assertThat(fromV1.partition()).isEqualTo(Collections.singletonList("A"));
- assertThat(deserialized.dataSplit()).isEqualTo(original.dataSplit());
- assertThat(deserialized.isBucketUnAware()).isEqualTo(original.isBucketUnAware());
- assertThat(deserialized.partition()).isEqualTo(Collections.singletonList("A"));
+ PaimonSplit fromV2 = serializer.deserialize(2, serializeVersion2(original));
+ assertThat(fromV2.dataSplit()).isEqualTo(original.dataSplit());
+ assertThat(fromV2.isBucketUnAware()).isEqualTo(original.isBucketUnAware());
+ assertThat(fromV2.partition()).isEqualTo(original.partition());
}
@Test
- void testDeserializeWithInvalidData() {
+ void testDataSplitSubclassDispatch() throws Exception {
+ PaimonSplit original = createStringPartitionSplit();
+
+ // FallbackDataSplit appends the isFallback flag after the base payload; the subtype and
+ // its extra state must survive the round trip. Its constructors are private, so build one
+ // via its public deserialize API.
+ ByteArrayOutputStream out = new ByteArrayOutputStream();
+ DataOutputViewStreamWrapper view = new DataOutputViewStreamWrapper(out);
+ original.dataSplit().serialize(view);
+ view.writeBoolean(true);
+ DataSplit fallbackDataSplit =
+ FallbackReadFileStoreTable.FallbackDataSplit.deserialize(
+ new DataInputViewStreamWrapper(
+ new ByteArrayInputStream(out.toByteArray())));
+
+ PaimonSplit fallbackSplit = new PaimonSplit(fallbackDataSplit, false, original.partition());
+ PaimonSplit deserialized = assertV3RoundTrip(fallbackSplit);
+ assertThat(deserialized.dataSplit())
+ .isInstanceOf(FallbackReadFileStoreTable.FallbackDataSplit.class);
+
+ // unknown subclasses may append extra fields as well and must be rejected at write time
+ // instead of silently corrupting the stream on restore
+ PaimonSplit unknown = new PaimonSplit(new DataSplit() {}, false, Collections.emptyList());
+ assertThatThrownBy(() -> serializer.serialize(unknown))
+ .isInstanceOf(IOException.class)
+ .hasMessageContaining("Unsupported DataSplit class");
+ }
+
+ @Test
+ void testDeserializeInvalidInput() {
byte[] invalidData = "invalid".getBytes();
assertThatThrownBy(() -> serializer.deserialize(1, invalidData))
.isInstanceOf(IOException.class);
+ assertThatThrownBy(() -> serializer.deserialize(3, invalidData))
+ .isInstanceOf(IOException.class);
+ assertThatThrownBy(() -> serializer.deserialize(99, new byte[0]))
+ .isInstanceOf(IOException.class)
+ .hasMessageContaining("Unsupported PaimonSplit serialization version");
+ }
+
+ private PaimonSplit assertV3RoundTrip(PaimonSplit original) throws IOException {
+ byte[] serialized = serializer.serialize(original);
+ // VERSION_3 bytes must not embed the DataSplit class name, otherwise relocation (shading)
+ // in downstream distributions would break state restore again
+ assertThat(new String(serialized, StandardCharsets.ISO_8859_1))
+ .doesNotContain(DataSplit.class.getName());
+
+ PaimonSplit deserialized = serializer.deserialize(serializer.getVersion(), serialized);
+ assertThat(deserialized.dataSplit()).isEqualTo(original.dataSplit());
+ assertThat(deserialized.isBucketUnAware()).isEqualTo(original.isBucketUnAware());
+ assertThat(deserialized.partition()).isEqualTo(original.partition());
+ return deserialized;
}
private PaimonSplit createStringPartitionSplit() throws Exception {
@@ -127,6 +185,24 @@
return plan.get(0);
}
+ private PaimonSplit createBucketUnAwareSplit() throws Exception {
+ // an append table without primary key and bucket key is bucket-unaware
+ TablePath tablePath = TablePath.of(DEFAULT_DB, "bucket_unaware_table");
+ Schema.Builder builder =
+ Schema.newBuilder().column("c1", DataTypes.INT()).column("c2", DataTypes.STRING());
+ createTable(tablePath, builder.build());
+ Table table = getTable(tablePath);
+
+ GenericRow record1 = GenericRow.of(12, BinaryString.fromString("a"));
+ writeRecord(tablePath, Collections.singletonList(record1));
+ Snapshot snapshot = table.latestSnapshot().get();
+
+ LakeSource<PaimonSplit> lakeSource = lakeStorage.createLakeSource(tablePath);
+ List<PaimonSplit> plan = lakeSource.createPlanner(snapshot::id).plan();
+
+ return plan.get(0);
+ }
+
private byte[] serializeVersion1(PaimonSplit paimonSplit) throws IOException {
ByteArrayOutputStream out = new ByteArrayOutputStream();
DataOutputViewStreamWrapper view = new DataOutputViewStreamWrapper(out);
@@ -134,4 +210,17 @@
view.writeBoolean(paimonSplit.isBucketUnAware());
return out.toByteArray();
}
+
+ private byte[] serializeVersion2(PaimonSplit paimonSplit) throws IOException {
+ ByteArrayOutputStream out = new ByteArrayOutputStream();
+ DataOutputViewStreamWrapper view = new DataOutputViewStreamWrapper(out);
+ InstantiationUtil.serializeObject(view, paimonSplit.dataSplit());
+ view.writeBoolean(paimonSplit.isBucketUnAware());
+ List<String> partition = paimonSplit.partition();
+ view.writeInt(partition.size());
+ for (String value : partition) {
+ view.writeUTF(value);
+ }
+ return out.toByteArray();
+ }
}
diff --git a/fluss-lake/fluss-lake-paimon/src/test/java/org/apache/fluss/lake/paimon/source/RelocatingObjectInputStreamTest.java b/fluss-lake/fluss-lake-paimon/src/test/java/org/apache/fluss/lake/paimon/source/RelocatingObjectInputStreamTest.java
new file mode 100644
index 0000000..c125aa1
--- /dev/null
+++ b/fluss-lake/fluss-lake-paimon/src/test/java/org/apache/fluss/lake/paimon/source/RelocatingObjectInputStreamTest.java
@@ -0,0 +1,79 @@
+/*
+ * 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.lake.paimon.source;
+
+import org.junit.jupiter.api.Test;
+
+import java.io.ByteArrayInputStream;
+import java.io.ByteArrayOutputStream;
+import java.io.IOException;
+import java.io.ObjectOutputStream;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+/** Test case for {@link PaimonSplitSerializer.RelocatingObjectInputStream}. */
+class RelocatingObjectInputStreamTest {
+
+ private static final String ORIGINAL_PREFIX =
+ org.apache.fluss.lake.paimon.source.original.Probe.class.getPackage().getName() + ".";
+ private static final String RELOCATED_PREFIX =
+ org.apache.fluss.lake.paimon.source.relocated.Probe.class.getPackage().getName() + ".";
+
+ @Test
+ void testResolveClass() throws Exception {
+ // the stream always carries the original class name, as written by a non-relocated build
+ byte[] bytes =
+ javaSerialize(new org.apache.fluss.lake.paimon.source.original.Probe("hello"));
+
+ // relocated build: remapped to the relocated class, field values preserved
+ Object remapped = deserialize(bytes, ORIGINAL_PREFIX, RELOCATED_PREFIX);
+ assertThat(remapped)
+ .isInstanceOf(org.apache.fluss.lake.paimon.source.relocated.Probe.class);
+ assertThat(((org.apache.fluss.lake.paimon.source.relocated.Probe) remapped).value())
+ .isEqualTo("hello");
+
+ // non-relocated build (equal prefixes): remapping is a no-op
+ assertThat(deserialize(bytes, ORIGINAL_PREFIX, ORIGINAL_PREFIX))
+ .isInstanceOf(org.apache.fluss.lake.paimon.source.original.Probe.class);
+
+ // remapped class missing: falls back to default resolution by the original name
+ assertThat(deserialize(bytes, ORIGINAL_PREFIX, ORIGINAL_PREFIX + "nonexistent."))
+ .isInstanceOf(org.apache.fluss.lake.paimon.source.original.Probe.class);
+ }
+
+ private Object deserialize(byte[] bytes, String originalPrefix, String actualPrefix)
+ throws Exception {
+ try (PaimonSplitSerializer.RelocatingObjectInputStream in =
+ new PaimonSplitSerializer.RelocatingObjectInputStream(
+ new ByteArrayInputStream(bytes),
+ getClass().getClassLoader(),
+ originalPrefix,
+ actualPrefix)) {
+ return in.readObject();
+ }
+ }
+
+ private static byte[] javaSerialize(Object object) throws IOException {
+ ByteArrayOutputStream baos = new ByteArrayOutputStream();
+ try (ObjectOutputStream oos = new ObjectOutputStream(baos)) {
+ oos.writeObject(object);
+ }
+ return baos.toByteArray();
+ }
+}
diff --git a/fluss-lake/fluss-lake-paimon/src/test/java/org/apache/fluss/lake/paimon/source/original/Probe.java b/fluss-lake/fluss-lake-paimon/src/test/java/org/apache/fluss/lake/paimon/source/original/Probe.java
new file mode 100644
index 0000000..3b85b70
--- /dev/null
+++ b/fluss-lake/fluss-lake-paimon/src/test/java/org/apache/fluss/lake/paimon/source/original/Probe.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.lake.paimon.source.original;
+
+import java.io.Serializable;
+
+/**
+ * Test probe simulating a serializable class before relocation (shading). Its counterpart {@link
+ * org.apache.fluss.lake.paimon.source.relocated.Probe} has the same simple name and
+ * serialVersionUID but lives in a different package.
+ */
+public class Probe implements Serializable {
+
+ private static final long serialVersionUID = 42L;
+
+ private final String value;
+
+ public Probe(String value) {
+ this.value = value;
+ }
+
+ public String value() {
+ return value;
+ }
+}
diff --git a/fluss-lake/fluss-lake-paimon/src/test/java/org/apache/fluss/lake/paimon/source/relocated/Probe.java b/fluss-lake/fluss-lake-paimon/src/test/java/org/apache/fluss/lake/paimon/source/relocated/Probe.java
new file mode 100644
index 0000000..e129fe8
--- /dev/null
+++ b/fluss-lake/fluss-lake-paimon/src/test/java/org/apache/fluss/lake/paimon/source/relocated/Probe.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.lake.paimon.source.relocated;
+
+import java.io.Serializable;
+
+/**
+ * Test probe simulating a serializable class after relocation (shading). Its counterpart {@link
+ * org.apache.fluss.lake.paimon.source.original.Probe} has the same simple name and serialVersionUID
+ * but lives in a different package.
+ */
+public class Probe implements Serializable {
+
+ private static final long serialVersionUID = 42L;
+
+ private final String value;
+
+ public Probe(String value) {
+ this.value = value;
+ }
+
+ public String value() {
+ return value;
+ }
+}