Files
tasks/common/payload/payload.go
Alan WuandGitHub 9416158cb3 Remove nil search attributes from history events / mutable state / ch… (#9091)
## What changed?
Remove nil search attributes from history events / mutable state / chasm
node

## Why?
Search attributes can be explicitly set as null on the following events:

* Workflow Start

* Upsert Search Attributes

* Continue-as-new

Starting a workflow or continuing a workflow as new with a nil search
attribute records the `{key: nil}` mapping in mutable state and the
history event.

Upserting search attributes erases any current search attribute value
from persistence (mutable state and visibility record).

Upserting the search attribute as nil in the SDK retains the mapping in
the WorkflowInfo as a {key: nil} mapping. On a continue-as-new event,
SDK sends this mapping to Server, and the search attribute gets
repopulated with the null search attribute in the history event and
mutable state.

This last scenario can cause inconsistency between the expected search
attributes and what is recorded in history. After upserting a nil search
attribute, the mutable state and visibility record no longer shows this
search attribute. But, once continuing as new, the search attribute
reappears, leading to unexpected NDE if the search attribute is also
deregistered.

## CHASM change
This also removes nil search attributes from CHASM nodes on replacement.
This mirrors the change to Workflow History Events and Mutable State.

## How did you test it?
- [X] built
- [X] run locally and tested manually
- [X] covered by existing tests
- [X] added new unit test(s)
- [X] added new functional test(s)
2026-04-01 15:21:16 -04:00

125 lines
3.8 KiB
Go

package payload
import (
"bytes"
"maps"
commonpb "go.temporal.io/api/common/v1"
"go.temporal.io/sdk/converter"
"go.temporal.io/server/common/util"
)
var (
defaultDataConverter = converter.GetDefaultDataConverter()
nilPayload, _ = Encode(nil)
emptySlicePayload, _ = Encode([]string{})
)
func EncodeString(str string) *commonpb.Payload {
// Error can be safely ignored here because string always can be converted to JSON
p, _ := defaultDataConverter.ToPayload(str)
return p
}
func EncodeBytes(bytes []byte) *commonpb.Payload {
// Error can be safely ignored here because []byte always can be raw encoded
p, _ := defaultDataConverter.ToPayload(bytes)
return p
}
func Encode(value any) (*commonpb.Payload, error) {
return defaultDataConverter.ToPayload(value)
}
func Decode(p *commonpb.Payload, valuePtr any) error {
return defaultDataConverter.FromPayload(p, valuePtr)
}
func ToString(p *commonpb.Payload) string {
return defaultDataConverter.ToString(p)
}
// MergeMapOfPayload returns a new map resulting from merging map `src` into `dst`.
// If a key in `src` already exists in `dst`, then the value in `src` replaces
// the value in `dst`.
// If a key in `src` has nil or empty slice payload value, then it deletes
// the key from `dst` if it exists.
// For example:
//
// dst := map[string]*commonpb.Payload{
// "key1": EncodeString("value1"),
// "key2": EncodeString("value2"),
// }
// src := map[string]*commonpb.Payload{
// "key1": EncodeString("newValue1"),
// "key2": nilPayload,
// }
// res := MergeMapOfPayload(dst, src)
//
// The resulting map `res` is:
//
// map[string]*commonpb.Payload{
// "key1": EncodeString("newValue1"),
// }
func MergeMapOfPayload(
dst map[string]*commonpb.Payload,
src map[string]*commonpb.Payload,
) map[string]*commonpb.Payload {
if src == nil {
return maps.Clone(dst)
}
res := util.CloneMapNonNil(dst)
for k, v := range src {
if isEqual(v, nilPayload) || isEqual(v, emptySlicePayload) {
delete(res, k)
} else {
res[k] = v
}
}
return res
}
// isEqual returns true if both have the same encoding and data.
// It does not take additional metadata into consideration.
// Note that data equality it's not the same as semantic equality, ie.,
// `[]` and `[ ]` are semantically the same, but different not data-wise.
// Only use if you know that the data is encoded the same way.
func isEqual(a, b *commonpb.Payload) bool {
aEnc := a.GetMetadata()[converter.MetadataEncoding]
bEnc := a.GetMetadata()[converter.MetadataEncoding]
return bytes.Equal(aEnc, bEnc) && bytes.Equal(a.GetData(), b.GetData())
}
// FilterNilSearchAttributes returns a new SearchAttributes with nil/empty payload values filtered out.
// If the input is nil or all values are nil/empty, returns nil.
// This is used to filter out nil search attributes from workflow start and continue-as-new events.
// Reuses MergeMapOfPayload which already handles nil payload filtering.
func FilterNilSearchAttributes(sa *commonpb.SearchAttributes) *commonpb.SearchAttributes {
if sa == nil || len(sa.GetIndexedFields()) == 0 {
return nil
}
filtered := MergeMapOfPayload(nil, sa.GetIndexedFields())
if len(filtered) == 0 {
return nil
}
return &commonpb.SearchAttributes{IndexedFields: filtered}
}
// FilterNilMemo returns a new Memo with nil/empty payload values filtered out.
// If the input is nil or all values are nil/empty, returns nil.
// This is used to filter out nil memo fields from workflow start, continue-as-new, and modify-properties events.
// Reuses MergeMapOfPayload which already handles nil payload filtering.
func FilterNilMemo(memo *commonpb.Memo) *commonpb.Memo {
if memo == nil || len(memo.GetFields()) == 0 {
return nil
}
filtered := MergeMapOfPayload(nil, memo.GetFields())
if len(filtered) == 0 {
return nil
}
return &commonpb.Memo{Fields: filtered}
}