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 {