This is an automated email from the ASF dual-hosted git repository.
wenjin272 pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/flink-agents.git
The following commit(s) were added to refs/heads/main by this push:
new 84c76fbc [runtime] Binary serde for MemoryUpdate values via versioned
Kryo envelope (#874)
84c76fbc is described below
commit 84c76fbc3e0accb1e4c12c522f28c01b1758793f
Author: Weiqing Yang <[email protected]>
AuthorDate: Wed Jul 8 02:47:49 2026 -0700
[runtime] Binary serde for MemoryUpdate values via versioned Kryo envelope
(#874)
---
.../runtime/actionstate/ActionStateSerde.java | 119 ++++++++++++++++++
.../runtime/actionstate/ActionStateSerdeTest.java | 137 +++++++++++++++++++++
2 files changed, 256 insertions(+)
diff --git
a/runtime/src/main/java/org/apache/flink/agents/runtime/actionstate/ActionStateSerde.java
b/runtime/src/main/java/org/apache/flink/agents/runtime/actionstate/ActionStateSerde.java
index 90885e31..d38a4495 100644
---
a/runtime/src/main/java/org/apache/flink/agents/runtime/actionstate/ActionStateSerde.java
+++
b/runtime/src/main/java/org/apache/flink/agents/runtime/actionstate/ActionStateSerde.java
@@ -19,14 +19,29 @@ package org.apache.flink.agents.runtime.actionstate;
import com.fasterxml.jackson.annotation.JsonTypeInfo;
import com.fasterxml.jackson.core.JsonGenerator;
+import com.fasterxml.jackson.core.JsonParser;
+import com.fasterxml.jackson.databind.DeserializationContext;
+import com.fasterxml.jackson.databind.JsonDeserializer;
+import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.JsonSerializer;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.SerializerProvider;
+import com.fasterxml.jackson.databind.annotation.JsonDeserialize;
+import com.fasterxml.jackson.databind.annotation.JsonSerialize;
import com.fasterxml.jackson.databind.module.SimpleModule;
import org.apache.flink.agents.api.Event;
+import org.apache.flink.agents.api.context.MemoryUpdate;
import org.apache.flink.agents.runtime.operator.ActionTask;
+import org.apache.flink.api.common.serialization.SerializerConfigImpl;
+import org.apache.flink.api.common.typeinfo.TypeInformation;
+import org.apache.flink.api.common.typeutils.TypeSerializer;
+import org.apache.flink.core.memory.DataInputViewStreamWrapper;
+import org.apache.flink.core.memory.DataOutputViewStreamWrapper;
+import java.io.ByteArrayInputStream;
+import java.io.ByteArrayOutputStream;
import java.io.IOException;
+import java.util.Base64;
/**
* Backend-agnostic serializer/deserializer for {@link ActionState}.
@@ -37,6 +52,37 @@ import java.io.IOException;
*/
public final class ActionStateSerde {
+ /**
+ * Recovery-compat contract for the {@link MemoryUpdate#getValue()
MemoryUpdate.value} envelope.
+ *
+ * <p>A non-null value is written on disk as {@code
+ * {"serde":"kryo","version":1,"payload":<b64>}} where {@code payload} is
the base64 of the
+ * Flink-native (Kryo) binary of the untyped value. That binary is only
guaranteed readable by
+ * the same code and same Flink version that wrote it — which is all that
ever happens, because
+ * the durable-execution journal is checkpoint-scoped and never survives a
code or Flink
+ * upgrade. Bump {@code version} if the payload encoding ever changes;
Kryo is pinned within a
+ * Flink major release (supplied by the cluster).
+ */
+ private static final String MEMORY_UPDATE_SERDE = "kryo";
+
+ private static final int MEMORY_UPDATE_VERSION = 1;
+ private static final String ENVELOPE_SERDE_FIELD = "serde";
+ private static final String ENVELOPE_VERSION_FIELD = "version";
+ private static final String ENVELOPE_PAYLOAD_FIELD = "payload";
+
+ // The Flink-native serializer for an untyped Object resolves to Kryo
+ // (GenericTypeInfo<Object> -> KryoSerializer).
+ private static final TypeSerializer<Object> VALUE_SERIALIZER_TEMPLATE =
+ TypeInformation.of(Object.class).createSerializer(new
SerializerConfigImpl());
+
+ // KryoSerializer is not thread-safe; only duplicate() (which is safe) is
ever called on the
+ // shared template, giving each thread an independent copy for
serialize/deserialize. Because
+ // this ThreadLocal is static, a thread's copy can retain (pin) the
classes of the values it has
+ // serialized for that thread's lifetime — an accepted tradeoff over
rebuilding a heavy Kryo
+ // instance on every call.
+ private static final ThreadLocal<TypeSerializer<Object>> VALUE_SERIALIZER =
+ ThreadLocal.withInitial(VALUE_SERIALIZER_TEMPLATE::duplicate);
+
private static final ObjectMapper OBJECT_MAPPER = createObjectMapper();
private ActionStateSerde() {}
@@ -70,9 +116,25 @@ public final class ActionStateSerde {
module.addSerializer(ActionTask.class, new ActionTaskSerializer());
mapper.registerModule(module);
+ // Preserve the concrete runtime type of the untyped
MemoryUpdate.value across durable
+ // recovery by wrapping it in a versioned Kryo envelope.
+ mapper.addMixIn(MemoryUpdate.class, MemoryUpdateValueMixin.class);
+
return mapper;
}
+ private static byte[] kryoSerialize(Object value) throws IOException {
+ ByteArrayOutputStream baos = new ByteArrayOutputStream();
+ VALUE_SERIALIZER.get().serialize(value, new
DataOutputViewStreamWrapper(baos));
+ return baos.toByteArray();
+ }
+
+ private static Object kryoDeserialize(byte[] bytes) throws IOException {
+ return VALUE_SERIALIZER
+ .get()
+ .deserialize(new DataInputViewStreamWrapper(new
ByteArrayInputStream(bytes)));
+ }
+
/** Mixin to add type information for Event hierarchy. */
@JsonTypeInfo(
use = JsonTypeInfo.Id.CLASS,
@@ -88,4 +150,61 @@ public final class ActionStateSerde {
gen.writeNull();
}
}
+
+ /**
+ * Binds the Kryo envelope (de)serializer to the {@code value} property of
{@link MemoryUpdate}.
+ */
+ abstract static class MemoryUpdateValueMixin {
+ @JsonSerialize(using = MemoryUpdateValueSerializer.class)
+ @JsonDeserialize(using = MemoryUpdateValueDeserializer.class)
+ Object value;
+ }
+
+ /**
+ * Writes a non-null {@link MemoryUpdate} value as the {@code
{serde,version,payload}} envelope.
+ */
+ static class MemoryUpdateValueSerializer extends JsonSerializer<Object> {
+ @Override
+ public void serialize(Object value, JsonGenerator gen,
SerializerProvider provider)
+ throws IOException {
+ gen.writeStartObject();
+ gen.writeStringField(ENVELOPE_SERDE_FIELD, MEMORY_UPDATE_SERDE);
+ gen.writeNumberField(ENVELOPE_VERSION_FIELD,
MEMORY_UPDATE_VERSION);
+ gen.writeStringField(
+ ENVELOPE_PAYLOAD_FIELD,
+ Base64.getEncoder().encodeToString(kryoSerialize(value)));
+ gen.writeEndObject();
+ }
+ }
+
+ /** Reads the Kryo envelope back to the concrete value and version-checks
it. */
+ static class MemoryUpdateValueDeserializer extends
JsonDeserializer<Object> {
+ @Override
+ public Object deserialize(JsonParser p, DeserializationContext ctxt)
throws IOException {
+ JsonNode node = ctxt.readTree(p);
+ // Every non-null value this serializer writes is an envelope, and
the durable journal
+ // never outlives the code that wrote it (see the recovery-compat
contract above), so a
+ // non-envelope node is an unsupported cross-version restore
rather than legacy data to
+ // tolerate. Reject it instead of silently binding it to a
wrong-typed stock value.
+ if (!isEnvelope(node)) {
+ throw new IOException(
+ "Expected a Kryo-envelope MemoryUpdate value but
found: "
+ + node.getNodeType());
+ }
+ int version = node.get(ENVELOPE_VERSION_FIELD).asInt();
+ if (version != MEMORY_UPDATE_VERSION) {
+ throw new IOException(
+ "Unsupported MemoryUpdate value envelope version: " +
version);
+ }
+ return kryoDeserialize(
+
Base64.getDecoder().decode(node.get(ENVELOPE_PAYLOAD_FIELD).asText()));
+ }
+
+ private static boolean isEnvelope(JsonNode n) {
+ return n.isObject()
+ &&
MEMORY_UPDATE_SERDE.equals(n.path(ENVELOPE_SERDE_FIELD).asText())
+ && n.has(ENVELOPE_VERSION_FIELD)
+ && n.path(ENVELOPE_PAYLOAD_FIELD).isTextual();
+ }
+ }
}
diff --git
a/runtime/src/test/java/org/apache/flink/agents/runtime/actionstate/ActionStateSerdeTest.java
b/runtime/src/test/java/org/apache/flink/agents/runtime/actionstate/ActionStateSerdeTest.java
index 116aeeb3..f8dbc6fa 100644
---
a/runtime/src/test/java/org/apache/flink/agents/runtime/actionstate/ActionStateSerdeTest.java
+++
b/runtime/src/test/java/org/apache/flink/agents/runtime/actionstate/ActionStateSerdeTest.java
@@ -320,6 +320,143 @@ public class ActionStateSerdeTest {
"exception".getBytes(StandardCharsets.UTF_8),
legacyFailure.getExceptionPayload());
}
+ @Test
+ public void testByteArrayMemoryValuePreserved() throws Exception {
+ byte[] value = new byte[] {1, 2, 3, 4, 5};
+ ActionState originalState = new ActionState(new InputEvent("in"));
+ originalState.addShortTermMemoryUpdate(new MemoryUpdate("stm.bytes",
value));
+
+ ActionState deserializedState =
+
ActionStateSerde.deserialize(ActionStateSerde.serialize(originalState));
+
+ Object recovered =
deserializedState.getShortTermMemoryUpdates().get(0).getValue();
+ assertInstanceOf(byte[].class, recovered);
+ assertArrayEquals(value, (byte[]) recovered);
+ }
+
+ @Test
+ public void testLongMemoryValuePreserved() throws Exception {
+ Long value = 42L;
+ ActionState originalState = new ActionState(new InputEvent("in"));
+ originalState.addShortTermMemoryUpdate(new MemoryUpdate("stm.long",
value));
+
+ ActionState deserializedState =
+
ActionStateSerde.deserialize(ActionStateSerde.serialize(originalState));
+
+ Object recovered =
deserializedState.getShortTermMemoryUpdates().get(0).getValue();
+ assertInstanceOf(Long.class, recovered);
+ assertEquals(42L, recovered);
+ }
+
+ @Test
+ public void testNestedCollectionMemoryValuePreserved() throws Exception {
+ byte[] nestedBytes = new byte[] {9, 8, 7};
+ List<Object> innerList = new ArrayList<>();
+ innerList.add(nestedBytes);
+ innerList.add(7L);
+ Map<String, Object> value = new HashMap<>();
+ value.put("items", innerList);
+
+ ActionState originalState = new ActionState(new InputEvent("in"));
+ originalState.addShortTermMemoryUpdate(new MemoryUpdate("stm.nested",
value));
+
+ ActionState deserializedState =
+
ActionStateSerde.deserialize(ActionStateSerde.serialize(originalState));
+
+ Object recovered =
deserializedState.getShortTermMemoryUpdates().get(0).getValue();
+ assertInstanceOf(Map.class, recovered);
+ @SuppressWarnings("unchecked")
+ List<Object> recoveredList = (List<Object>) ((Map<String, Object>)
recovered).get("items");
+ assertInstanceOf(byte[].class, recoveredList.get(0));
+ assertArrayEquals(nestedBytes, (byte[]) recoveredList.get(0));
+ assertInstanceOf(Long.class, recoveredList.get(1));
+ assertEquals(7L, recoveredList.get(1));
+ }
+
+ @Test
+ public void testPojoMemoryValuePreserved() throws Exception {
+ MemoryValuePojo value = new MemoryValuePojo("hello", 99);
+ ActionState originalState = new ActionState(new InputEvent("in"));
+ originalState.addShortTermMemoryUpdate(new MemoryUpdate("stm.pojo",
value));
+
+ ActionState deserializedState =
+
ActionStateSerde.deserialize(ActionStateSerde.serialize(originalState));
+
+ Object recovered =
deserializedState.getShortTermMemoryUpdates().get(0).getValue();
+ assertInstanceOf(MemoryValuePojo.class, recovered);
+ assertEquals(value, recovered);
+ }
+
+ @Test
+ public void testNullMemoryValuePreserved() throws Exception {
+ ActionState originalState = new ActionState(new InputEvent("in"));
+ originalState.addShortTermMemoryUpdate(new MemoryUpdate("stm.null",
null));
+
+ ActionState deserializedState =
+
ActionStateSerde.deserialize(ActionStateSerde.serialize(originalState));
+
+ assertEquals(1, deserializedState.getShortTermMemoryUpdates().size());
+
assertNull(deserializedState.getShortTermMemoryUpdates().get(0).getValue());
+ }
+
+ @Test
+ public void testNonEnvelopeMemoryValueRejected() throws Exception {
+ ActionState originalState = new ActionState(new InputEvent("in"));
+ originalState.addShortTermMemoryUpdate(new MemoryUpdate("stm.raw",
"some value"));
+
+ byte[] serialized = ActionStateSerde.serialize(originalState);
+ String json = new String(serialized, StandardCharsets.UTF_8);
+ // Replace the whole envelope object with a bare JSON value, as a
legacy pre-envelope
+ // journal would have stored it.
+ int start = json.indexOf("{\"serde\":");
+ int end = json.indexOf('}', start) + 1;
+ byte[] patched =
+ (json.substring(0, start) + "\"some value\"" +
json.substring(end))
+ .getBytes(StandardCharsets.UTF_8);
+
+ assertThrows(RuntimeException.class, () ->
ActionStateSerde.deserialize(patched));
+ }
+
+ @Test
+ public void testUnknownEnvelopeVersionRejected() throws Exception {
+ ActionState originalState = new ActionState(new InputEvent("in"));
+ originalState.addShortTermMemoryUpdate(new MemoryUpdate("stm.version",
"some value"));
+
+ byte[] serialized = ActionStateSerde.serialize(originalState);
+ String json = new String(serialized, StandardCharsets.UTF_8);
+ assertTrue(json.contains("\"version\":1"));
+ byte[] patched =
+ json.replace("\"version\":1",
"\"version\":2").getBytes(StandardCharsets.UTF_8);
+
+ assertThrows(RuntimeException.class, () ->
ActionStateSerde.deserialize(patched));
+ }
+
+ /** Serializable POJO used to verify Kryo preserves user types across
durable recovery. */
+ public static class MemoryValuePojo implements java.io.Serializable {
+ private String name;
+ private int count;
+
+ public MemoryValuePojo() {}
+
+ public MemoryValuePojo(String name, int count) {
+ this.name = name;
+ this.count = count;
+ }
+
+ @Override
+ public boolean equals(Object o) {
+ if (this == o) return true;
+ if (!(o instanceof MemoryValuePojo)) return false;
+ MemoryValuePojo that = (MemoryValuePojo) o;
+ return count == that.count && java.util.Objects.equals(name,
that.name);
+ }
+
+ @Override
+ public int hashCode() {
+ return java.util.Objects.hash(name, count);
+ }
+ }
+
@Test
public void testKafkaSederDelegatesToActionStateSerde() throws Exception {
InputEvent inputEvent = new InputEvent("test delegation");