From 068298e50ba674f0a9a2634b060a2f8cedcf194c Mon Sep 17 00:00:00 2001 From: PratikDhanave Date: Fri, 24 Jul 2026 07:39:58 +0530 Subject: [PATCH] Preserve delayed PortableValue raw JSON on checkpoint re-serialization 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. --- workflow/portable.go | 7 +++++++ workflow/portable_test.go | 32 ++++++++++++++++++++++++++++++++ 2 files changed, 39 insertions(+) diff --git a/workflow/portable.go b/workflow/portable.go index 4d1dd01e..ff2e9250 100644 --- a/workflow/portable.go +++ b/workflow/portable.go @@ -184,6 +184,13 @@ func (v PortableValue) MarshalJSON() ([]byte, error) { if v.any == nil { return nil, errors.New("cannot marshal zero PortableValue") } + // Preserve the original wire bytes for a delayed value that was never read. + // Any() would generically decode the raw JSON (e.g. into map[string]any / + // float64) and lose type/large-integer fidelity on re-marshal, so short-circuit + // here to re-emit the retained JSON verbatim, matching .NET's JsonElement. + if raw, ok := v.any.(json.RawMessage); ok && v.cache == nil { + return json.Marshal(portableValueJSON{TypeID: v.TypeID, Value: raw}) + } value := v.Any() if raw, ok := value.(json.RawMessage); ok { return json.Marshal(portableValueJSON{TypeID: v.TypeID, Value: raw}) diff --git a/workflow/portable_test.go b/workflow/portable_test.go index e6900bcf..876de9a9 100644 --- a/workflow/portable_test.go +++ b/workflow/portable_test.go @@ -166,6 +166,38 @@ func delayedPortableValue(t *testing.T, pv workflow.PortableValue) workflow.Port return delayed } +func TestPortableValueMarshal_PreservesUntouchedDelayedRawJSON(t *testing.T) { + // A delayed value with an unregistered TypeID and a large integer that + // cannot be represented exactly by float64. Re-marshaling without any typed + // access must re-emit the original bytes verbatim, so a durable-JSON + // restore -> re-checkpoint cycle stays idempotent and loses no fidelity. + const bigInt = "9007219254740993" // > 2^53 + wire := []byte(`{"TypeID":{"PackageName":"example.invalid/missing","TypeName":"Missing"},"Value":` + bigInt + `}`) + + var delayed workflow.PortableValue + if err := json.Unmarshal(wire, &delayed); err != nil { + t.Fatalf("Unmarshal: %v", err) + } + if !delayed.Delayed() { + t.Fatalf("expected delayed value after unmarshal") + } + + // Re-marshal WITHOUT any typed access. + data, err := json.Marshal(delayed) + if err != nil { + t.Fatalf("Marshal: %v", err) + } + var wrapper struct { + Value json.RawMessage + } + if err := json.Unmarshal(data, &wrapper); err != nil { + t.Fatalf("Unmarshal wrapper: %v", err) + } + if got := string(wrapper.Value); got != bigInt { + t.Fatalf("re-emitted Value = %s, want %s (large-integer fidelity lost)", got, bigInt) + } +} + func TestPortableValue_RejectsNil(t *testing.T) { defer func() { if recover() == nil {