This is an automated email from the ASF dual-hosted git repository.
RocMarshal pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/flink.git
The following commit(s) were added to refs/heads/master by this push:
new 91bbaa59dda [FLINK-40297][runtime] Route TTL-aware value migration
through the migrate hook
91bbaa59dda is described below
commit 91bbaa59dda19d7d4b68c376fc4736fa719cbef9
Author: Weiqing Yang <[email protected]>
AuthorDate: Sun Aug 2 19:57:36 2026 -0700
[FLINK-40297][runtime] Route TTL-aware value migration through the migrate
hook
TtlAwareSerializer.migrateValueFromPriorSerializer is the single entry point
through which the RocksDB state backend migrates state values on restore:
AbstractRocksDBState, RocksDBListState and RocksDBMapState all call it after
unwrapping the state shape they own. It deserialized with the prior
serializer
and re-serialized with the new one, leaving a serializer no opportunity to
adapt
the value in between.
Route it through TypeSerializerSnapshot.migrate: unwrap the prior value to
its
bare, non-TTL form, migrate it, then re-wrap when this serializer is
TTL-enabled,
preserving the prior timestamp when the prior value carried one. Behavior is
unchanged, because no serializer overrides the hook yet and its default
returns
the value unchanged.
The hook receives the persisted prior snapshot rather than one re-derived by
calling snapshotConfiguration() on the restored prior serializer. That
round trip
is lossy: PojoSerializerSnapshot substitutes a synthetic name for a field
that no
longer exists on the class, so a migrate override reconciling fields by name
would see a fabricated schema. The backend already holds the persisted
snapshot
above the migration loop, and each caller now descends it alongside its
serializer.
The descent unwraps the TtlAware decorator first. Registering a new
serializer
mutates the previous snapshot's nested snapshots in place, so a list or map
state's persisted element or value snapshot is a TtlAwareSerializerSnapshot
rather than the snapshot the checkpoint wrote. A snapshot of an unexpected
type
now fails rather than falling back to a re-derived one; only an absent
snapshot
falls back.
Generated-by: Claude Code (Opus 5)
---
.../common/typeutils/TypeSerializerSnapshot.java | 15 +-
.../runtime/state/ttl/TtlAwareSerializer.java | 133 ++++++-
.../runtime/state/StateSerializerProviderTest.java | 41 +++
.../runtime/state/ttl/TtlAwareSerializerTest.java | 398 +++++++++++++++++++++
.../state/ttl/TtlAwareSerializerUpgradeTest.java | 1 +
.../flink/state/rocksdb/AbstractRocksDBState.java | 5 +
.../state/rocksdb/RocksDBKeyedStateBackend.java | 8 +-
.../flink/state/rocksdb/RocksDBListState.java | 21 ++
.../flink/state/rocksdb/RocksDBMapState.java | 21 ++
9 files changed, 625 insertions(+), 18 deletions(-)
diff --git
a/flink-core/src/main/java/org/apache/flink/api/common/typeutils/TypeSerializerSnapshot.java
b/flink-core/src/main/java/org/apache/flink/api/common/typeutils/TypeSerializerSnapshot.java
index e4788736a68..45b56d9fa10 100644
---
a/flink-core/src/main/java/org/apache/flink/api/common/typeutils/TypeSerializerSnapshot.java
+++
b/flink-core/src/main/java/org/apache/flink/api/common/typeutils/TypeSerializerSnapshot.java
@@ -150,10 +150,19 @@ public interface TypeSerializerSnapshot<T> {
* <p>The migration is not applied recursively to nested serializers. The
snapshot of a
* composite type returns its value unchanged unless it overrides this
method to decompose the
* value and migrate each part, so a caller that needs a nested value
migrated must reach the
- * nested snapshot itself.
+ * nested snapshot itself. An implementation that does so should not
assume that the old and the
+ * new snapshot expose nested snapshots of the same type: restoring may
have replaced those of
+ * the old snapshot with decorators, so nested snapshots are best matched
by position or name
+ * rather than by class.
*
- * @param oldSerializerSnapshot snapshot of the serializer that wrote the
value.
- * @param value the value, already deserialized with the prior serializer.
+ * @param oldSerializerSnapshot snapshot of the serializer that wrote the
value. A caller that
+ * holds the snapshot persisted with the state should pass that one in
preference to a
+ * snapshot re-derived from a serializer restored from it, because
that round trip does not
+ * always reproduce the schema that was written.
+ * @param value the value, already deserialized with the prior serializer.
It may be {@code
+ * null} wherever the prior serializer can produce {@code null}. An
implementation that
+ * decomposes a composite value may likewise pass {@code null} to a
nested snapshot for an
+ * absent part, even where that part's serializer would reject {@code
null} at top level.
* @return the value adapted to the schema of the current serializer.
*/
default T migrate(TypeSerializerSnapshot<T> oldSerializerSnapshot, T
value) {
diff --git
a/flink-runtime/src/main/java/org/apache/flink/runtime/state/ttl/TtlAwareSerializer.java
b/flink-runtime/src/main/java/org/apache/flink/runtime/state/ttl/TtlAwareSerializer.java
index 2f50ba6c668..87cbfd23be4 100644
---
a/flink-runtime/src/main/java/org/apache/flink/runtime/state/ttl/TtlAwareSerializer.java
+++
b/flink-runtime/src/main/java/org/apache/flink/runtime/state/ttl/TtlAwareSerializer.java
@@ -26,6 +26,8 @@ import org.apache.flink.core.memory.DataInputView;
import org.apache.flink.core.memory.DataOutputView;
import org.apache.flink.util.function.SupplierWithException;
+import javax.annotation.Nullable;
+
import java.io.IOException;
import java.util.List;
import java.util.Map;
@@ -47,6 +49,13 @@ public class TtlAwareSerializer<T, S extends
TypeSerializer<T>> extends TypeSeri
private final S typeSerializer;
+ /**
+ * Snapshot of {@link #bareValueSerializer()}, computed on first use.
{@link
+ * #migrateValueFromPriorSerializer} runs once per migrated state value
while the serializer
+ * stays the same, and taking a snapshot allocates one object per nested
serializer.
+ */
+ private transient TypeSerializerSnapshot<?> bareValueSerializerSnapshot;
+
public TtlAwareSerializer(S typeSerializer) {
checkArgument(
!(typeSerializer instanceof TtlAwareSerializer),
@@ -128,31 +137,129 @@ public class TtlAwareSerializer<T, S extends
TypeSerializer<T>> extends TypeSeri
return Objects.hash(isTtlEnabled, typeSerializer);
}
- @SuppressWarnings("unchecked")
+ /**
+ * Reads one state value written by {@code priorTtlAwareSerializer},
adapts it to this
+ * serializer's TTL setting and value schema, and writes it to {@code
target}.
+ *
+ * <p>The value is unwrapped to its bare form, passed through {@link
+ * TypeSerializerSnapshot#migrate}, and re-wrapped. The hook returns the
value unchanged unless
+ * the value serializer overrides it, so a value whose schema did not
change is written back
+ * byte for byte.
+ *
+ * @param priorSerializerSnapshot the snapshot persisted with the state
for {@code
+ * priorTtlAwareSerializer}, or {@code null} for a state that carries
none.
+ */
+ @SuppressWarnings({"unchecked", "rawtypes"})
public void migrateValueFromPriorSerializer(
TtlAwareSerializer<T, ?> priorTtlAwareSerializer,
+ @Nullable TypeSerializerSnapshot<T> priorSerializerSnapshot,
SupplierWithException<T, IOException> inputSupplier,
DataOutputView target,
TtlTimeProvider ttlTimeProvider)
throws IOException {
+ T priorValue = inputSupplier.get();
+ Object bareValue =
+ priorTtlAwareSerializer.wrapsTtlValue()
+ ? ((TtlValue<?>) priorValue).getUserValue()
+ : priorValue;
+
+ TypeSerializerSnapshot newSnapshot = bareValueSerializerSnapshot();
+ Object migratedValue =
+ newSnapshot.migrate(
+ priorBareValueSerializerSnapshot(
+ priorTtlAwareSerializer,
priorSerializerSnapshot),
+ bareValue);
+
T outputRecord;
- if (this.isTtlEnabled()) {
- outputRecord =
- priorTtlAwareSerializer.isTtlEnabled
- ? inputSupplier.get()
- : (T)
- new TtlValue<>(
- inputSupplier.get(),
-
ttlTimeProvider.currentTimestamp());
+ if (this.wrapsTtlValue()) {
+ // Carrying the prior timestamp over keeps the value's expiry
where it was; migration
+ // is not a state access.
+ long lastAccessTimestamp =
+ priorTtlAwareSerializer.wrapsTtlValue()
+ ? ((TtlValue<?>)
priorValue).getLastAccessTimestamp()
+ : ttlTimeProvider.currentTimestamp();
+ outputRecord = (T) new TtlValue<>(migratedValue,
lastAccessTimestamp);
} else {
- outputRecord =
- priorTtlAwareSerializer.isTtlEnabled
- ? ((TtlValue<T>)
inputSupplier.get()).getUserValue()
- : inputSupplier.get();
+ outputRecord = (T) migratedValue;
}
this.serialize(outputRecord, target);
}
+ /**
+ * The snapshot describing the schema the prior bare value was written
with.
+ *
+ * <p>The snapshot persisted with the state is preferred over one
re-derived from the prior
+ * serializer, because the prior serializer is itself restored from that
snapshot and the round
+ * trip back to a snapshot is not always lossless: a POJO field that no
longer exists on the
+ * class returns under a generated placeholder name, which would present a
schema that was never
+ * written. Only the absence of a persisted snapshot falls back to the
re-derived one: a
+ * persisted snapshot that does not match the prior serializer is an
error, not a second reason
+ * to fall back, because re-deriving there would silently reintroduce that
lossy round trip.
+ */
+ private static TypeSerializerSnapshot<?> priorBareValueSerializerSnapshot(
+ TtlAwareSerializer<?, ?> priorSerializer,
+ @Nullable TypeSerializerSnapshot<?> priorSerializerSnapshot) {
+ if (priorSerializerSnapshot == null) {
+ return priorSerializer.bareValueSerializerSnapshot();
+ }
+ // TtlAwareSerializerSnapshot is the snapshot counterpart of this
class, so the persisted
+ // snapshot carries that layer wherever the serializer carries the
wrapper: for a list or
+ // map state it is the element or value snapshot, for a value state
the whole snapshot.
+ TypeSerializerSnapshot<?> priorSnapshot =
+ priorSerializerSnapshot instanceof TtlAwareSerializerSnapshot
+ ? ((TtlAwareSerializerSnapshot<?>)
priorSerializerSnapshot)
+ .getOriginalTypeSerializerSnapshot()
+ : priorSerializerSnapshot;
+
+ // Thrown rather than checked through Preconditions: this runs once
per migrated state
+ // value, so the message must not be built while the check is passing.
+ boolean isTtlSnapshot = priorSnapshot instanceof
TtlStateFactory.TtlSerializerSnapshot;
+ if (!priorSerializer.wrapsTtlValue()) {
+ if (isTtlSnapshot) {
+ throw new IllegalArgumentException(
+ "The prior serializer does not wrap values in
TtlValue, but its persisted snapshot is a TtlSerializerSnapshot.");
+ }
+ return priorSnapshot;
+ }
+ if (!isTtlSnapshot) {
+ throw new IllegalArgumentException(
+ "The prior serializer wraps values in TtlValue, so its
persisted snapshot should be a TtlSerializerSnapshot, but was "
+ + priorSnapshot.getClass().getName()
+ + ".");
+ }
+ // The persisted snapshot describes the TtlValue envelope, so descend
to the user value
+ // the same way bareValueSerializer() descends the serializer.
+ return ((TtlStateFactory.TtlSerializerSnapshot<?>) priorSnapshot)
+ .getValueSerializerSnapshot();
+ }
+
+ private TypeSerializerSnapshot<?> bareValueSerializerSnapshot() {
+ if (bareValueSerializerSnapshot == null) {
+ bareValueSerializerSnapshot =
bareValueSerializer().snapshotConfiguration();
+ }
+ return bareValueSerializerSnapshot;
+ }
+
+ /**
+ * The serializer of the bare (non-TTL) value: the user value serializer
of a {@link
+ * TtlStateFactory.TtlSerializer}, otherwise the wrapped serializer itself.
+ */
+ private TypeSerializer<?> bareValueSerializer() {
+ return wrapsTtlValue()
+ ? ((TtlStateFactory.TtlSerializer<?>)
typeSerializer).getValueSerializer()
+ : typeSerializer;
+ }
+
+ /**
+ * Whether the values this serializer reads and writes are {@link
TtlValue} envelopes. Narrower
+ * than {@link #isTtlEnabled()}, which is also true for a list or map
serializer whose element
+ * or value serializer is a {@link TtlStateFactory.TtlSerializer}: such a
serializer wraps the
+ * collection, not a single {@code TtlValue}.
+ */
+ private boolean wrapsTtlValue() {
+ return typeSerializer instanceof TtlStateFactory.TtlSerializer;
+ }
+
@Override
public void copy(DataInputView source, DataOutputView target) throws
IOException {
typeSerializer.copy(source, target);
diff --git
a/flink-runtime/src/test/java/org/apache/flink/runtime/state/StateSerializerProviderTest.java
b/flink-runtime/src/test/java/org/apache/flink/runtime/state/StateSerializerProviderTest.java
index 65908573fac..96b4479c915 100644
---
a/flink-runtime/src/test/java/org/apache/flink/runtime/state/StateSerializerProviderTest.java
+++
b/flink-runtime/src/test/java/org/apache/flink/runtime/state/StateSerializerProviderTest.java
@@ -21,13 +21,18 @@ package org.apache.flink.runtime.state;
import org.apache.flink.api.common.typeutils.TypeSerializer;
import org.apache.flink.api.common.typeutils.TypeSerializerSchemaCompatibility;
import org.apache.flink.api.common.typeutils.TypeSerializerSnapshot;
+import org.apache.flink.api.common.typeutils.base.ListSerializer;
+import org.apache.flink.api.common.typeutils.base.ListSerializerSnapshot;
+import org.apache.flink.api.common.typeutils.base.StringSerializer;
import org.apache.flink.core.memory.DataInputView;
import org.apache.flink.core.memory.DataOutputView;
+import org.apache.flink.runtime.state.ttl.TtlAwareSerializerSnapshot;
import org.apache.flink.runtime.testutils.statemigration.TestType;
import org.junit.jupiter.api.Test;
import java.io.IOException;
+import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
@@ -311,6 +316,42 @@ class StateSerializerProviderTest {
.isInstanceOf(IllegalStateException.class);
}
+ //
--------------------------------------------------------------------------------
+ // Tests for the ttl-aware wrapping of the previous serializer snapshot
+ //
--------------------------------------------------------------------------------
+
+ /**
+ * Registering a new serializer replaces the nested snapshot of the
previous snapshot in place,
+ * so the snapshot a caller still holds no longer reports what the
checkpoint wrote: its nested
+ * snapshot becomes a {@link TtlAwareSerializerSnapshot} around the
original. Anything that
+ * descends a restored composite snapshot has to expect that layer.
+ */
+ @Test
+ void
testRegisterNewSerializerWrapsNestedSnapshotOfPreviousSnapshotInPlace() {
+ ListSerializerSnapshot<String> previousSnapshot =
+ (ListSerializerSnapshot<String>)
+ new
ListSerializer<>(StringSerializer.INSTANCE).snapshotConfiguration();
+ TypeSerializerSnapshot<String> elementSnapshotAsWritten =
+ previousSnapshot.getElementSerializerSnapshot();
+
+ StateSerializerProvider<List<String>> testProvider =
+
StateSerializerProvider.fromPreviousSerializerSnapshot(previousSnapshot);
+ testProvider.registerNewSerializerForRestoredState(
+ new ListSerializer<>(StringSerializer.INSTANCE));
+
+ // The same snapshot instance now reports a different element snapshot
than it did above.
+ TypeSerializerSnapshot<String> elementSnapshotAfterRestore =
+ previousSnapshot.getElementSerializerSnapshot();
+
assertThat(elementSnapshotAfterRestore).isNotSameAs(elementSnapshotAsWritten);
+
assertThat(elementSnapshotAfterRestore).isInstanceOf(TtlAwareSerializerSnapshot.class);
+ // The original is carried inside the wrapper, not re-derived: a
re-derived snapshot would
+ // be an equal instance of the same class but a different object.
+ assertThat(
+ ((TtlAwareSerializerSnapshot<String>)
elementSnapshotAfterRestore)
+ .getOriginalTypeSerializerSnapshot())
+ .isSameAs(elementSnapshotAsWritten);
+ }
+
//
--------------------------------------------------------------------------------
// Utilities
//
--------------------------------------------------------------------------------
diff --git
a/flink-runtime/src/test/java/org/apache/flink/runtime/state/ttl/TtlAwareSerializerTest.java
b/flink-runtime/src/test/java/org/apache/flink/runtime/state/ttl/TtlAwareSerializerTest.java
index f91e71d360c..8e1d376cd09 100644
---
a/flink-runtime/src/test/java/org/apache/flink/runtime/state/ttl/TtlAwareSerializerTest.java
+++
b/flink-runtime/src/test/java/org/apache/flink/runtime/state/ttl/TtlAwareSerializerTest.java
@@ -18,19 +18,36 @@
package org.apache.flink.runtime.state.ttl;
+import org.apache.flink.api.common.typeutils.TypeSerializer;
+import org.apache.flink.api.common.typeutils.TypeSerializerSchemaCompatibility;
+import org.apache.flink.api.common.typeutils.TypeSerializerSnapshot;
import org.apache.flink.api.common.typeutils.base.IntSerializer;
import org.apache.flink.api.common.typeutils.base.ListSerializer;
import org.apache.flink.api.common.typeutils.base.ListSerializerSnapshot;
import org.apache.flink.api.common.typeutils.base.LongSerializer;
import org.apache.flink.api.common.typeutils.base.MapSerializer;
import org.apache.flink.api.common.typeutils.base.MapSerializerSnapshot;
+import org.apache.flink.api.common.typeutils.base.StringSerializer;
+import org.apache.flink.api.java.typeutils.runtime.NullableSerializer;
+import org.apache.flink.core.memory.DataInputDeserializer;
+import org.apache.flink.core.memory.DataInputView;
+import org.apache.flink.core.memory.DataOutputSerializer;
+import org.apache.flink.core.memory.DataOutputView;
import org.junit.jupiter.api.Test;
+import java.io.IOException;
+
import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
class TtlAwareSerializerTest {
+ private static final String VALUE = "value";
+ private static final long PRIOR_TIMESTAMP = 1_000L;
+ private static final long CURRENT_TIMESTAMP = 9_999L;
+ private static final TtlTimeProvider FIXED_TIME_PROVIDER = () ->
CURRENT_TIMESTAMP;
+
@Test
void testSerializerTtlEnabled() {
IntSerializer intSerializer = IntSerializer.INSTANCE;
@@ -147,4 +164,385 @@ class TtlAwareSerializerTest {
.getValueSerializerSnapshot()))
.isInstanceOf(TtlAwareSerializerSnapshot.class);
}
+
+ @Test
+ void testMigrateValueNoTtlToNoTtl() throws IOException {
+ TtlAwareSerializer<?, ?> prior = stringSerializer(false);
+ TtlAwareSerializer<?, ?> current = stringSerializer(false);
+
+ assertThat(migrate(current, prior,
VALUE)).isEqualTo(serialize(current, VALUE));
+ }
+
+ @Test
+ void testMigrateValueNoTtlToTtlStampsCurrentTime() throws IOException {
+ TtlAwareSerializer<?, ?> prior = stringSerializer(false);
+ TtlAwareSerializer<?, ?> current = stringSerializer(true);
+
+ assertThat(migrate(current, prior, VALUE))
+ .isEqualTo(serialize(current, new TtlValue<>(VALUE,
CURRENT_TIMESTAMP)));
+ }
+
+ @Test
+ void testMigrateValueTtlToNoTtlUnwraps() throws IOException {
+ TtlAwareSerializer<?, ?> prior = stringSerializer(true);
+ TtlAwareSerializer<?, ?> current = stringSerializer(false);
+
+ assertThat(migrate(current, prior, new TtlValue<>(VALUE,
PRIOR_TIMESTAMP)))
+ .isEqualTo(serialize(current, VALUE));
+ }
+
+ @Test
+ void testMigrateValueTtlToTtlKeepsPriorTimestamp() throws IOException {
+ TtlAwareSerializer<?, ?> prior = stringSerializer(true);
+ TtlAwareSerializer<?, ?> current = stringSerializer(true);
+ TtlValue<String> priorValue = new TtlValue<>(VALUE, PRIOR_TIMESTAMP);
+
+ assertThat(migrate(current, prior,
priorValue)).isEqualTo(serialize(current, priorValue));
+ }
+
+ @Test
+ void testMigrateValueInvokesHookOnNewSnapshotWithOldSnapshotAsArgument()
throws IOException {
+ TtlAwareSerializer<?, ?> prior =
+ TtlAwareSerializer.wrapTtlAwareSerializer(new
TaggedStringSerializer("old"));
+ TtlAwareSerializer<?, ?> current =
+ TtlAwareSerializer.wrapTtlAwareSerializer(new
TaggedStringSerializer("new"));
+
+ byte[] migrated = migrate(current, prior, VALUE);
+
+ assertThat(current.deserialize(new DataInputDeserializer(migrated)))
+ .isEqualTo(VALUE + "|from=old|to=new");
+ }
+
+ @Test
+ void testMigrateValueInvokesHookOnUnwrappedValueWithInnerSnapshots()
throws IOException {
+ TtlAwareSerializer<?, ?> prior = taggedTtlSerializer("old");
+ TtlAwareSerializer<?, ?> current = taggedTtlSerializer("new");
+
+ byte[] migrated = migrate(current, prior, new TtlValue<>(VALUE,
PRIOR_TIMESTAMP));
+
+ TtlValue<?> result = (TtlValue<?>) current.deserialize(new
DataInputDeserializer(migrated));
+ assertThat(result.getUserValue()).isEqualTo(VALUE +
"|from=old|to=new");
+ assertThat(result.getLastAccessTimestamp()).isEqualTo(PRIOR_TIMESTAMP);
+ }
+
+ @Test
+ void
testMigrateValueUsesPersistedPriorSnapshotNotOneRederivedFromPriorSerializer()
+ throws IOException {
+ TtlAwareSerializer<?, ?> prior =
+ TtlAwareSerializer.wrapTtlAwareSerializer(new
TaggedStringSerializer("rederived"));
+ TtlAwareSerializer<?, ?> current =
+ TtlAwareSerializer.wrapTtlAwareSerializer(new
TaggedStringSerializer("new"));
+
+ byte[] migrated = migrate(current, prior, new
TaggedSnapshot("persisted"), VALUE);
+
+ assertThat(current.deserialize(new DataInputDeserializer(migrated)))
+ .isEqualTo(VALUE + "|from=persisted|to=new");
+ }
+
+ @Test
+ void testMigrateValueDescendsPersistedTtlSnapshotToItsValueSnapshot()
throws IOException {
+ TtlAwareSerializer<?, ?> prior = taggedTtlSerializer("rederived");
+ TtlAwareSerializer<?, ?> current = taggedTtlSerializer("new");
+ // Tagged differently from the prior serializer, so both falling back
to a re-derived
+ // snapshot and skipping the descent into the TtlValue envelope change
the result.
+ TypeSerializerSnapshot<?> persisted =
+ taggedTtlSerializer("persisted")
+ .getOriginalTypeSerializer()
+ .snapshotConfiguration();
+
+ byte[] migrated =
+ migrate(current, prior, persisted, new TtlValue<>(VALUE,
PRIOR_TIMESTAMP));
+
+ TtlValue<?> result = (TtlValue<?>) current.deserialize(new
DataInputDeserializer(migrated));
+ assertThat(result.getUserValue()).isEqualTo(VALUE +
"|from=persisted|to=new");
+ }
+
+ /**
+ * A list or map state persists its element or value snapshot wrapped in a
{@link
+ * TtlAwareSerializerSnapshot}, which the descent has to see through to
reach the user value
+ * snapshot. Value state persists the unwrapped shape covered by the test
above.
+ */
+ @Test
+ void testMigrateValueDescendsPersistedTtlAwareElementSnapshot() throws
IOException {
+ ListSerializerSnapshot<?> persistedListSnapshot =
+ (ListSerializerSnapshot<?>)
+ TtlAwareSerializer.wrapTtlAwareSerializer(
+ new
ListSerializer<>(rawTaggedTtlSerializer("persisted")))
+ .snapshotConfiguration();
+ TypeSerializerSnapshot<?> persistedElementSnapshot =
+ persistedListSnapshot.getElementSerializerSnapshot();
+
assertThat(persistedElementSnapshot).isInstanceOf(TtlAwareSerializerSnapshot.class);
+
+ TtlAwareSerializer<?, ?> prior = taggedTtlSerializer("rederived");
+ TtlAwareSerializer<?, ?> current = taggedTtlSerializer("new");
+
+ byte[] migrated =
+ migrate(
+ current,
+ prior,
+ persistedElementSnapshot,
+ new TtlValue<>(VALUE, PRIOR_TIMESTAMP));
+
+ TtlValue<?> result = (TtlValue<?>) current.deserialize(new
DataInputDeserializer(migrated));
+ assertThat(result.getUserValue()).isEqualTo(VALUE +
"|from=persisted|to=new");
+ }
+
+ @Test
+ void testMigrateValueRejectsNonTtlSnapshotForTtlPriorSerializer() {
+ TtlAwareSerializer<?, ?> prior = taggedTtlSerializer("old");
+ TtlAwareSerializer<?, ?> current = taggedTtlSerializer("new");
+
+ assertThatThrownBy(
+ () ->
+ migrate(
+ current,
+ prior,
+ new TaggedSnapshot("mismatched"),
+ new TtlValue<>(VALUE,
PRIOR_TIMESTAMP)))
+ .isInstanceOf(IllegalArgumentException.class)
+ .hasMessageContaining("should be a TtlSerializerSnapshot");
+ }
+
+ @Test
+ void testMigrateValueRejectsNonTtlSnapshotBehindTtlAwareLayer() {
+ TtlAwareSerializer<?, ?> prior = taggedTtlSerializer("old");
+ TtlAwareSerializer<?, ?> current = taggedTtlSerializer("new");
+ // The TtlAware layer is seen through, so a mismatch inside it is
still caught.
+ TypeSerializerSnapshot<?> wrappedMismatch =
+ new TtlAwareSerializerSnapshot<>(new
TaggedSnapshot("mismatched"));
+
+ assertThatThrownBy(
+ () ->
+ migrate(
+ current,
+ prior,
+ wrappedMismatch,
+ new TtlValue<>(VALUE,
PRIOR_TIMESTAMP)))
+ .isInstanceOf(IllegalArgumentException.class)
+ .hasMessageContaining("should be a TtlSerializerSnapshot");
+ }
+
+ @Test
+ void testMigrateValueRejectsTtlSnapshotForNonTtlPriorSerializer() {
+ TtlAwareSerializer<?, ?> prior =
+ TtlAwareSerializer.wrapTtlAwareSerializer(new
TaggedStringSerializer("old"));
+ TtlAwareSerializer<?, ?> current =
+ TtlAwareSerializer.wrapTtlAwareSerializer(new
TaggedStringSerializer("new"));
+ TypeSerializerSnapshot<?> ttlSnapshot =
+ rawTaggedTtlSerializer("mismatched").snapshotConfiguration();
+
+ assertThatThrownBy(() -> migrate(current, prior, ttlSnapshot, VALUE))
+ .isInstanceOf(IllegalArgumentException.class)
+ .hasMessageContaining("does not wrap values in TtlValue");
+ }
+
+ @Test
+ void testMigrateValueFallsBackToPriorSerializerSnapshotWhenNonePersisted()
throws IOException {
+ TtlAwareSerializer<?, ?> prior =
+ TtlAwareSerializer.wrapTtlAwareSerializer(new
TaggedStringSerializer("old"));
+ TtlAwareSerializer<?, ?> current =
+ TtlAwareSerializer.wrapTtlAwareSerializer(new
TaggedStringSerializer("new"));
+
+ byte[] migrated = migrate(current, prior, null, VALUE);
+
+ assertThat(current.deserialize(new DataInputDeserializer(migrated)))
+ .isEqualTo(VALUE + "|from=old|to=new");
+ }
+
+ @Test
+ void testMigrateValueTtlToTtlWithNullUserValue() throws IOException {
+ TtlAwareSerializer<?, ?> prior = nullTolerantTtlSerializer();
+ TtlAwareSerializer<?, ?> current = nullTolerantTtlSerializer();
+
+ byte[] migrated = migrate(current, prior, new TtlValue<>(null,
PRIOR_TIMESTAMP));
+
+ TtlValue<?> result = (TtlValue<?>) current.deserialize(new
DataInputDeserializer(migrated));
+ assertThat(result.getUserValue()).isNull();
+ assertThat(result.getLastAccessTimestamp()).isEqualTo(PRIOR_TIMESTAMP);
+ }
+
+ /** Migrates with the snapshot the state backends would have persisted for
{@code prior}. */
+ private static byte[] migrate(
+ TtlAwareSerializer<?, ?> current, TtlAwareSerializer<?, ?> prior,
Object priorValue)
+ throws IOException {
+ return migrate(
+ current,
+ prior,
+ prior.getOriginalTypeSerializer().snapshotConfiguration(),
+ priorValue);
+ }
+
+ @SuppressWarnings({"unchecked", "rawtypes"})
+ private static byte[] migrate(
+ TtlAwareSerializer<?, ?> current,
+ TtlAwareSerializer<?, ?> prior,
+ TypeSerializerSnapshot<?> priorSnapshot,
+ Object priorValue)
+ throws IOException {
+ DataOutputSerializer output = new DataOutputSerializer(64);
+ ((TtlAwareSerializer) current)
+ .migrateValueFromPriorSerializer(
+ (TtlAwareSerializer) prior,
+ priorSnapshot,
+ () -> priorValue,
+ output,
+ FIXED_TIME_PROVIDER);
+ return output.getCopyOfBuffer();
+ }
+
+ @SuppressWarnings({"unchecked", "rawtypes"})
+ private static byte[] serialize(TtlAwareSerializer<?, ?> serializer,
Object value)
+ throws IOException {
+ DataOutputSerializer output = new DataOutputSerializer(64);
+ ((TtlAwareSerializer) serializer).serialize(value, output);
+ return output.getCopyOfBuffer();
+ }
+
+ private static TtlAwareSerializer<?, ?> stringSerializer(boolean
ttlEnabled) {
+ return ttlEnabled
+ ? TtlAwareSerializer.wrapTtlAwareSerializer(
+ new TtlStateFactory.TtlSerializer<>(
+ LongSerializer.INSTANCE,
StringSerializer.INSTANCE))
+ :
TtlAwareSerializer.wrapTtlAwareSerializer(StringSerializer.INSTANCE);
+ }
+
+ private static TtlAwareSerializer<?, ?> taggedTtlSerializer(String tag) {
+ return
TtlAwareSerializer.wrapTtlAwareSerializer(rawTaggedTtlSerializer(tag));
+ }
+
+ private static TtlStateFactory.TtlSerializer<String>
rawTaggedTtlSerializer(String tag) {
+ return new TtlStateFactory.TtlSerializer<>(
+ LongSerializer.INSTANCE, new TaggedStringSerializer(tag));
+ }
+
+ private static TtlAwareSerializer<?, ?> nullTolerantTtlSerializer() {
+ return TtlAwareSerializer.wrapTtlAwareSerializer(
+ new TtlStateFactory.TtlSerializer<>(
+ LongSerializer.INSTANCE,
+ NullableSerializer.wrapIfNullIsNotSupported(
+ StringSerializer.INSTANCE, false)));
+ }
+
+ /** A string serializer whose snapshot records the schema it belongs to. */
+ private static final class TaggedStringSerializer extends
TypeSerializer<String> {
+
+ private final String tag;
+
+ private TaggedStringSerializer(String tag) {
+ this.tag = tag;
+ }
+
+ @Override
+ public boolean isImmutableType() {
+ return true;
+ }
+
+ @Override
+ public TypeSerializer<String> duplicate() {
+ return this;
+ }
+
+ @Override
+ public String createInstance() {
+ return "";
+ }
+
+ @Override
+ public String copy(String from) {
+ return from;
+ }
+
+ @Override
+ public String copy(String from, String reuse) {
+ return from;
+ }
+
+ @Override
+ public int getLength() {
+ return -1;
+ }
+
+ @Override
+ public void serialize(String record, DataOutputView target) throws
IOException {
+ StringSerializer.INSTANCE.serialize(record, target);
+ }
+
+ @Override
+ public String deserialize(DataInputView source) throws IOException {
+ return StringSerializer.INSTANCE.deserialize(source);
+ }
+
+ @Override
+ public String deserialize(String reuse, DataInputView source) throws
IOException {
+ return deserialize(source);
+ }
+
+ @Override
+ public void copy(DataInputView source, DataOutputView target) throws
IOException {
+ StringSerializer.INSTANCE.copy(source, target);
+ }
+
+ @Override
+ public boolean equals(Object obj) {
+ return obj instanceof TaggedStringSerializer
+ && tag.equals(((TaggedStringSerializer) obj).tag);
+ }
+
+ @Override
+ public int hashCode() {
+ return tag.hashCode();
+ }
+
+ @Override
+ public TypeSerializerSnapshot<String> snapshotConfiguration() {
+ return new TaggedSnapshot(tag);
+ }
+ }
+
+ /**
+ * Appends both the argument snapshot's tag and its own tag to the
migrated value, so a
+ * migration that swaps receiver and argument produces a different result.
+ */
+ public static final class TaggedSnapshot implements
TypeSerializerSnapshot<String> {
+
+ private String tag;
+
+ public TaggedSnapshot() {}
+
+ private TaggedSnapshot(String tag) {
+ this.tag = tag;
+ }
+
+ @Override
+ public int getCurrentVersion() {
+ return 1;
+ }
+
+ @Override
+ public void writeSnapshot(DataOutputView out) throws IOException {
+ out.writeUTF(tag);
+ }
+
+ @Override
+ public void readSnapshot(int readVersion, DataInputView in,
ClassLoader userCodeClassLoader)
+ throws IOException {
+ tag = in.readUTF();
+ }
+
+ @Override
+ public TypeSerializer<String> restoreSerializer() {
+ return new TaggedStringSerializer(tag);
+ }
+
+ @Override
+ public TypeSerializerSchemaCompatibility<String>
resolveSchemaCompatibility(
+ TypeSerializerSnapshot<String> oldSerializerSnapshot) {
+ return TypeSerializerSchemaCompatibility.compatibleAsIs();
+ }
+
+ @Override
+ public String migrate(TypeSerializerSnapshot<String>
oldSerializerSnapshot, String value) {
+ return value + "|from=" + ((TaggedSnapshot)
oldSerializerSnapshot).tag + "|to=" + tag;
+ }
+ }
}
diff --git
a/flink-runtime/src/test/java/org/apache/flink/runtime/state/ttl/TtlAwareSerializerUpgradeTest.java
b/flink-runtime/src/test/java/org/apache/flink/runtime/state/ttl/TtlAwareSerializerUpgradeTest.java
index d8b4234252c..ffe2f41c03a 100644
---
a/flink-runtime/src/test/java/org/apache/flink/runtime/state/ttl/TtlAwareSerializerUpgradeTest.java
+++
b/flink-runtime/src/test/java/org/apache/flink/runtime/state/ttl/TtlAwareSerializerUpgradeTest.java
@@ -161,6 +161,7 @@ public class TtlAwareSerializerUpgradeTest
DataOutputSerializer migratedOut = new
DataOutputSerializer(INITIAL_OUTPUT_BUFFER_SIZE);
writer.migrateValueFromPriorSerializer(
reader,
+ reader.getOriginalTypeSerializer().snapshotConfiguration(),
() -> reader.deserialize(originalDataInput),
migratedOut,
TtlTimeProvider.DEFAULT);
diff --git
a/flink-state-backends/flink-statebackend-rocksdb/src/main/java/org/apache/flink/state/rocksdb/AbstractRocksDBState.java
b/flink-state-backends/flink-statebackend-rocksdb/src/main/java/org/apache/flink/state/rocksdb/AbstractRocksDBState.java
index b60347ff208..758f7123793 100644
---
a/flink-state-backends/flink-statebackend-rocksdb/src/main/java/org/apache/flink/state/rocksdb/AbstractRocksDBState.java
+++
b/flink-state-backends/flink-statebackend-rocksdb/src/main/java/org/apache/flink/state/rocksdb/AbstractRocksDBState.java
@@ -19,6 +19,7 @@ package org.apache.flink.state.rocksdb;
import org.apache.flink.api.common.state.State;
import org.apache.flink.api.common.typeutils.TypeSerializer;
+import org.apache.flink.api.common.typeutils.TypeSerializerSnapshot;
import org.apache.flink.api.java.tuple.Tuple2;
import org.apache.flink.core.memory.DataInputDeserializer;
import org.apache.flink.core.memory.DataOutputSerializer;
@@ -36,6 +37,8 @@ import org.rocksdb.ColumnFamilyHandle;
import org.rocksdb.RocksDBException;
import org.rocksdb.WriteOptions;
+import javax.annotation.Nullable;
+
import java.io.IOException;
import static org.apache.flink.util.Preconditions.checkArgument;
@@ -191,6 +194,7 @@ public abstract class AbstractRocksDBState<K, N, V>
implements InternalKvState<K
DataInputDeserializer serializedOldValueInput,
DataOutputSerializer serializedMigratedValueOutput,
TypeSerializer<V> priorSerializer,
+ @Nullable TypeSerializerSnapshot<V> priorSerializerSnapshot,
TypeSerializer<V> newSerializer,
TtlTimeProvider ttlTimeProvider)
throws StateMigrationException {
@@ -203,6 +207,7 @@ public abstract class AbstractRocksDBState<K, N, V>
implements InternalKvState<K
try {
ttlAwareNewSerializer.migrateValueFromPriorSerializer(
ttlAwarePriorSerializer,
+ priorSerializerSnapshot,
() ->
ttlAwarePriorSerializer.deserialize(serializedOldValueInput),
serializedMigratedValueOutput,
ttlTimeProvider);
diff --git
a/flink-state-backends/flink-statebackend-rocksdb/src/main/java/org/apache/flink/state/rocksdb/RocksDBKeyedStateBackend.java
b/flink-state-backends/flink-statebackend-rocksdb/src/main/java/org/apache/flink/state/rocksdb/RocksDBKeyedStateBackend.java
index 73142571373..c6512f4a356 100644
---
a/flink-state-backends/flink-statebackend-rocksdb/src/main/java/org/apache/flink/state/rocksdb/RocksDBKeyedStateBackend.java
+++
b/flink-state-backends/flink-statebackend-rocksdb/src/main/java/org/apache/flink/state/rocksdb/RocksDBKeyedStateBackend.java
@@ -916,9 +916,12 @@ public class RocksDBKeyedStateBackend<K> extends
AbstractKeyedStateBackend<K> {
Tuple2<ColumnFamilyHandle,
RegisteredKeyValueStateBackendMetaInfo<N, SV>> stateMetaInfo)
throws Exception {
+ // The snapshot the state was written with. Preferred over one
re-derived from the previous
+ // serializer, which is itself restored from this snapshot and does
not always round trip.
+ TypeSerializerSnapshot<SV> previousSerializerSnapshot =
+ stateMetaInfo.f1.getPreviousStateSerializerSnapshot();
+
if (stateDesc.getType() == StateDescriptor.Type.MAP) {
- TypeSerializerSnapshot<SV> previousSerializerSnapshot =
- stateMetaInfo.f1.getPreviousStateSerializerSnapshot();
checkState(
previousSerializerSnapshot != null,
"the previous serializer snapshot should exist.");
@@ -1003,6 +1006,7 @@ public class RocksDBKeyedStateBackend<K> extends
AbstractKeyedStateBackend<K> {
serializedValueInput,
migratedSerializedValueOutput,
previousTtlAwareSerializer,
+ previousSerializerSnapshot,
currentTtlAwareSerializer,
this.ttlTimeProvider);
diff --git
a/flink-state-backends/flink-statebackend-rocksdb/src/main/java/org/apache/flink/state/rocksdb/RocksDBListState.java
b/flink-state-backends/flink-statebackend-rocksdb/src/main/java/org/apache/flink/state/rocksdb/RocksDBListState.java
index 8c6088df23d..66b273a5ecb 100644
---
a/flink-state-backends/flink-statebackend-rocksdb/src/main/java/org/apache/flink/state/rocksdb/RocksDBListState.java
+++
b/flink-state-backends/flink-statebackend-rocksdb/src/main/java/org/apache/flink/state/rocksdb/RocksDBListState.java
@@ -22,7 +22,9 @@ import org.apache.flink.api.common.state.ListState;
import org.apache.flink.api.common.state.State;
import org.apache.flink.api.common.state.StateDescriptor;
import org.apache.flink.api.common.typeutils.TypeSerializer;
+import org.apache.flink.api.common.typeutils.TypeSerializerSnapshot;
import org.apache.flink.api.common.typeutils.base.ListSerializer;
+import org.apache.flink.api.common.typeutils.base.ListSerializerSnapshot;
import org.apache.flink.api.java.tuple.Tuple2;
import org.apache.flink.core.memory.DataInputDeserializer;
import org.apache.flink.core.memory.DataOutputSerializer;
@@ -200,6 +202,7 @@ class RocksDBListState<K, N, V> extends
AbstractRocksDBState<K, N, List<V>>
DataInputDeserializer serializedOldValueInput,
DataOutputSerializer serializedMigratedValueOutput,
TypeSerializer<List<V>> priorSerializer,
+ @Nullable TypeSerializerSnapshot<List<V>> priorSerializerSnapshot,
TypeSerializer<List<V>> newSerializer,
TtlTimeProvider ttlTimeProvider)
throws StateMigrationException {
@@ -214,11 +217,29 @@ class RocksDBListState<K, N, V> extends
AbstractRocksDBState<K, N, List<V>>
TtlAwareSerializer<V, ?> newTtlAwareElementSerializer =
((TtlAwareSerializer.TtlAwareListSerializer<V>) newSerializer)
.getElementSerializer();
+ // Descend the persisted snapshot the same way as the serializer, so
element migration
+ // sees the schema the elements were written with. A state that
carries no persisted
+ // snapshot leaves this null, and the element migration re-derives one
instead.
+ TypeSerializerSnapshot<V> priorElementSerializerSnapshot = null;
+ if (priorSerializerSnapshot != null) {
+ // Thrown rather than checked through Preconditions: this method
runs once per state
+ // entry, so the message must not be built while the check is
passing.
+ if (!(priorSerializerSnapshot instanceof ListSerializerSnapshot)) {
+ throw new IllegalArgumentException(
+ "The previous serializer snapshot of a list state
should be a ListSerializerSnapshot, but was "
+ + priorSerializerSnapshot.getClass().getName()
+ + ".");
+ }
+ priorElementSerializerSnapshot =
+ ((ListSerializerSnapshot<V>) priorSerializerSnapshot)
+ .getElementSerializerSnapshot();
+ }
try {
while (serializedOldValueInput.available() > 0) {
newTtlAwareElementSerializer.migrateValueFromPriorSerializer(
priorTtlAwareElementSerializer,
+ priorElementSerializerSnapshot,
() ->
ListDelimitedSerializer.deserializeNextElement(
serializedOldValueInput,
priorTtlAwareElementSerializer),
diff --git
a/flink-state-backends/flink-statebackend-rocksdb/src/main/java/org/apache/flink/state/rocksdb/RocksDBMapState.java
b/flink-state-backends/flink-statebackend-rocksdb/src/main/java/org/apache/flink/state/rocksdb/RocksDBMapState.java
index 8f33bfe6aad..6dfd00aaca1 100644
---
a/flink-state-backends/flink-statebackend-rocksdb/src/main/java/org/apache/flink/state/rocksdb/RocksDBMapState.java
+++
b/flink-state-backends/flink-statebackend-rocksdb/src/main/java/org/apache/flink/state/rocksdb/RocksDBMapState.java
@@ -23,7 +23,9 @@ import org.apache.flink.api.common.state.MapState;
import org.apache.flink.api.common.state.State;
import org.apache.flink.api.common.state.StateDescriptor;
import org.apache.flink.api.common.typeutils.TypeSerializer;
+import org.apache.flink.api.common.typeutils.TypeSerializerSnapshot;
import org.apache.flink.api.common.typeutils.base.MapSerializer;
+import org.apache.flink.api.common.typeutils.base.MapSerializerSnapshot;
import org.apache.flink.api.java.tuple.Tuple2;
import org.apache.flink.core.memory.DataInputDeserializer;
import org.apache.flink.core.memory.DataOutputSerializer;
@@ -231,6 +233,7 @@ class RocksDBMapState<K, N, UK, UV> extends
AbstractRocksDBState<K, N, Map<UK, U
DataInputDeserializer serializedOldValueInput,
DataOutputSerializer serializedMigratedValueOutput,
TypeSerializer<Map<UK, UV>> priorSerializer,
+ @Nullable TypeSerializerSnapshot<Map<UK, UV>>
priorSerializerSnapshot,
TypeSerializer<Map<UK, UV>> newSerializer,
TtlTimeProvider ttlTimeProvider)
throws StateMigrationException {
@@ -244,6 +247,23 @@ class RocksDBMapState<K, N, UK, UV> extends
AbstractRocksDBState<K, N, Map<UK, U
TtlAwareSerializer<UV, ?> newTtlAwareMapValueSerializer =
((TtlAwareSerializer.TtlAwareMapSerializer<UK, UV>)
newSerializer)
.getValueSerializer();
+ // Descend the persisted snapshot the same way as the serializer, so
value migration sees
+ // the schema the map values were written with. A state that carries
no persisted
+ // snapshot leaves this null, and the value migration re-derives one
instead.
+ TypeSerializerSnapshot<UV> priorMapValueSerializerSnapshot = null;
+ if (priorSerializerSnapshot != null) {
+ // Thrown rather than checked through Preconditions: this method
runs once per state
+ // entry, so the message must not be built while the check is
passing.
+ if (!(priorSerializerSnapshot instanceof MapSerializerSnapshot)) {
+ throw new IllegalArgumentException(
+ "The previous serializer snapshot of a map state
should be a MapSerializerSnapshot, but was "
+ + priorSerializerSnapshot.getClass().getName()
+ + ".");
+ }
+ priorMapValueSerializerSnapshot =
+ ((MapSerializerSnapshot<UK, UV>) priorSerializerSnapshot)
+ .getValueSerializerSnapshot();
+ }
try {
boolean isNull = serializedOldValueInput.readBoolean();
@@ -254,6 +274,7 @@ class RocksDBMapState<K, N, UK, UV> extends
AbstractRocksDBState<K, N, Map<UK, U
} else {
newTtlAwareMapValueSerializer.migrateValueFromPriorSerializer(
priorTtlAwareMapValueSerializer,
+ priorMapValueSerializerSnapshot,
() ->
priorTtlAwareMapValueSerializer.deserialize(serializedOldValueInput),
serializedMigratedValueOutput,
ttlTimeProvider);