Skip to content

[runtime] Binary serde for MemoryUpdate values via versioned Kryo envelope#874

Merged
wenjin272 merged 2 commits into
apache:mainfrom
weiqingy:872-memoryupdate-binary-serde
Jul 8, 2026
Merged

[runtime] Binary serde for MemoryUpdate values via versioned Kryo envelope#874
wenjin272 merged 2 commits into
apache:mainfrom
weiqingy:872-memoryupdate-binary-serde

Conversation

@weiqingy

@weiqingy weiqingy commented Jul 6, 2026

Copy link
Copy Markdown
Collaborator

Linked issue: closes #865, closes #872

Purpose of change

MemoryUpdate.value is declared Object, and ActionStateSerde persisted it through a Jackson ObjectMapper that carries no type information for that field. On durable-execution replay the value was rebuilt by Jackson's stock untyped deserializer, so any runtime type that bare JSON cannot reconstruct degraded silently: byte[] to base64 String, an int-range Long to Integer, a user POJO to LinkedHashMap, and the same losses for those types nested inside a List or Map. The consumer performs no downcast, so the wrong type flowed into agent memory after any crash or restore. This generalizes #865 / #871, which fixed only a top-level byte[].

The fix binds a field-scoped Jackson (de)serializer to MemoryUpdate.value only. It runs the Flink-native serializer — Kryo, for a generic Object — and stores the bytes base64-encoded inside a self-describing versioned envelope embedded in the single ActionState JSON document: { "serde": "kryo", "version": 1, "payload": "<base64>" }. One mechanism covers every Kryo-serializable type instead of a per-type special case.

Key points:

  • Field-scoping is deliberate: the (de)serializer binds to MemoryUpdate.value via a mixin, never a global byte[] serializer, so CallResult's statically-typed byte[] payloads keep round-tripping unchanged.
  • Thread-safety: KryoSerializer is not thread-safe, so one immutable template is built once and each thread serializes through its own duplicate() via a ThreadLocal.
  • An unknown envelope version is rejected rather than silently mis-decoded.
  • Safe binary format: the durable-execution journal is checkpoint-scoped (pruned on checkpoint completion, rebuilt on restore), so state is always written and read by the same code and the same Flink version. It never crosses a code or Flink upgrade, so Kryo's cross-version binary incompatibility and schema-evolution brittleness are never exercised. The recovery-compat contract (envelope shape, version marker, Kryo pinned within the major release) is documented on the serde.
  • The Event field stays JSON, preserving the cross-language and event-log contract.

This supersedes #871 (the narrow __flink_agents_bytes__ byte[] envelope), which is unmerged, so no state is persisted in that format.

Tests

Extends ActionStateSerdeTest with 5 round-trip tests: top-level byte[], an int-range Long, a nested Map / List graph holding both a byte[] and a Long, a custom POJO (recovered as its class, not a LinkedHashMap), and an unknown-version envelope (rejected). The 10 existing tests are unchanged.

mvn -pl runtime -am test -Dtest=ActionStateSerdeTest passes 15 tests under the default Flink 2.3.0, which is also the runtime linkage check for the Kryo construction path. mvn -pl runtime spotless:check is clean.

API

No public API change. All new types are package-private members of ActionStateSerde. No new dependency, and no dist/ change — Kryo is provided by the cluster Flink.

Documentation

  • doc-needed
  • doc-not-needed
  • doc-included

@github-actions github-actions Bot added doc-not-needed Your PR changes do not impact docs fixVersion/0.4.0 priority/major Default priority of the PR or issue. labels Jul 6, 2026
@github-actions github-actions Bot added doc-not-needed Your PR changes do not impact docs and removed doc-not-needed Your PR changes do not impact docs labels Jul 6, 2026
…elope

MemoryUpdate.value is declared Object and was persisted by ActionStateSerde
through a type-info-less Jackson mapper, so on durable-execution replay any
value that bare JSON cannot reconstruct degraded silently: byte[] to base64
String, int-range Long to Integer, user POJO to LinkedHashMap, and the same
for those types nested in a List or Map. The recovered wrong type then flowed
into agent memory unchecked.

Bind a field-scoped Jackson (de)serializer to MemoryUpdate.value only that runs
the Flink-native serializer (Kryo for a generic Object) and stores the bytes
base64-encoded inside a self-describing versioned envelope embedded in the
single ActionState JSON document: {serde, version, payload}. One mechanism
covers every Kryo-serializable type. Field-scoping keeps CallResult's
statically-typed byte[] payloads untouched. The Kryo serializer is built once
and duplicated per thread via a ThreadLocal for thread-safety. An unknown
envelope version is rejected rather than silently mis-decoded.

Generalizes the top-level byte[] fix and supersedes the narrow byte[] envelope.
Safe because the durable-execution journal is checkpoint-scoped, always written
and read by the same code and Flink version, so Kryo cross-version binary
incompatibility is never exercised.

Closes apache#872
@weiqingy weiqingy force-pushed the 872-memoryupdate-binary-serde branch from c02042f to f0a64a0 Compare July 7, 2026 18:06

@wenjin272 wenjin272 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for taking this on @weiqingy. I left one comment.

@Override
public Object deserialize(JsonParser p, DeserializationContext ctxt) throws IOException {
JsonNode node = ctxt.readTree(p);
if (isEnvelope(node)) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

IIUC, the isEnvelope flag is used to maintain backward compatibility with legacy MemoryUpdate instances that were serialized using JSON.

Is it essential to maintain backward compatibility in this case?

  • For new jobs, both serialization and deserialization should be performed using Kryo, with isEnvelope set to true.
  • For old jobs, both serialization and deserialization should be performed using Jackson.
  • The isEnvelope can only become false after a job upgrade and restart. Since flink agents are still in beta, I consider this incompatibility acceptable.

@weiqingy weiqingy Jul 8, 2026

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for the review, @wenjin272. I've updated the PR accordingly.

…allback

Within a job every non-null MemoryUpdate value is written as a Kryo
envelope, and the durable journal is checkpoint-scoped so it never
outlives the code that wrote it. A non-envelope node can therefore only
come from an unsupported cross-version restore; silently binding it via
the stock untyped-Object deserializer would reintroduce the type loss
this change fixes (e.g. byte[] returning as a base64 String). Reject it
with a clear error instead.

Null values are unaffected: Jackson routes a JSON null through
getNullValue(), not the custom deserializer.

@wenjin272 wenjin272 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM

@wenjin272 wenjin272 merged commit 84c76fb into apache:main Jul 8, 2026
27 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

doc-not-needed Your PR changes do not impact docs fixVersion/0.4.0 priority/major Default priority of the PR or issue.

Projects

None yet

2 participants