Preserve delayed PortableValue raw JSON on checkpoint re-serialization to avoid large-int/type-fidelity loss - #696
Conversation
There was a problem hiding this comment.
Pull request overview
This PR fixes workflow.PortableValue JSON re-serialization for delayed values by preserving the original json.RawMessage bytes when the value was never read/deserialized, preventing precision/type-fidelity loss (notably for integers > 2^53) during a restore → re-checkpoint cycle.
Changes:
- Short-circuit
PortableValue.MarshalJSONto re-emit the retainedjson.RawMessageverbatim whenv.anyis raw JSON andv.cacheis still unset. - Add a regression test covering an unregistered
TypeIDwith a large integer to ensure re-marshaling preserves the original numeric bytes.
Reviewed changes
Copilot reviewed 2 out of 2 changed files in this pull request and generated no comments.
| File | Description |
|---|---|
| workflow/portable.go | Preserves untouched delayed raw JSON during MarshalJSON to avoid lossy generic decoding. |
| workflow/portable_test.go | Adds a regression test ensuring large-integer fidelity is preserved for delayed/unregistered values. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
039df30 to
ebb9e55
Compare
This comment has been minimized.
This comment has been minimized.
MarshalJSON called Any() before checking for the delayed json.RawMessage form, so Any() generically decoded the raw bytes into map[string]any / float64 and the raw-preserving branch was dead. Re-marshaling an untouched delayed value therefore lost type/large-integer fidelity, making a durable-JSON restore -> re-checkpoint cycle non-idempotent (int64/uint64 above 2^53 truncated through float64). Short-circuit on the retained json.RawMessage before decoding so the original wire bytes are re-emitted verbatim, matching .NET which re-emits the retained JsonElement.
ebb9e55 to
068298e
Compare
Parity Review: ✅ ApprovedScope: Changed exported surface: None. This PR fixes the existing Cross-repo parity: The fix explicitly aligns Go with .NET behavior. The .NET implementation retains the incoming No upstream Python equivalent was found for Label action: No Warning Firewall blocked 1 domainThe following domain was blocked by the firewall during workflow execution:
network:
allowed:
- defaults
- "awmgmcpg"See Network Configuration for more information.
|
What
PortableValue.MarshalJSONcalledAny()before checking whether the value was still in its delayedjson.RawMessageform. For a delayed value whoseTypeIDis unregistered (or whose content is any-typed/map-shaped),decodeKnownPortableTypereturns false andAny()falls back to a genericjson.Unmarshalintoany, yieldingmap[string]any+float64. That decoded value is never ajson.RawMessage, so the existing raw-preserving branch was dead andMarshalJSONre-encoded lossily.This makes a durable-JSON
restore -> re-checkpointcycle non-idempotent: an untouched delayed value is re-emitted with different bytes, and anyint64/uint64above 2^53 is truncated throughfloat64.The fix short-circuits on the retained
json.RawMessage(when no deserialized cache exists) before callingAny(), re-emitting the original wire bytes verbatim.Why (parity)
This matches .NET, which retains the incoming
JsonElementand re-emits it verbatim on re-serialization rather than round-tripping through a decoded numeric/object representation. The Go delayed-deserialization path is meant to have the same "carry the bytes until someone reads them" semantics; before this change the marshal step broke that.Tests
Added
TestPortableValueMarshal_PreservesUntouchedDelayedRawJSONinworkflow/portable_test.go: it builds wire JSON for a delayed value with an unregisteredTypeIDand a large integer (9007219254740993, > 2^53), unmarshals into aPortableValue(assertingDelayed()), re-marshals it without any typed access, and asserts the re-emittedValuebytes are byte-identical to the original. Pre-fix it truncates to9007219254740992; post-fix it is byte-identical.go build ./...,go vet ./workflow/..., andgo test ./workflow/...all pass.