-
Notifications
You must be signed in to change notification settings - Fork 881
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Implement Nexus Serializer for Temporal Payloads #5126
Conversation
common/nexus/payload_serializer.go
Outdated
if !isStandardPayload(payload) { | ||
data, err := payload.Marshal() | ||
if err != nil { | ||
return nil, fmt.Errorf("%w: payload marshal error: %w", errSerializer, err) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
You may as well just have errSerializer
be a string prefix; it doesn't look like we gain anything by wrapping it into the error tree
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Mostly did this to satisfy our linter.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Would avoid a double %w
, there's only one true cause. In fact, would avoid errSerializer
altogether, it has no value from what I see (but I understand server has a bit of a history with hard to read code by precreating errors often unnecessarily regardless of some opinions about performance or attempting to predict future uses)
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Again, I could make a linter exception here. I was just "following the rules".
common/nexus/payload_serializer.go
Outdated
commonpb "go.temporal.io/api/common/v1" | ||
) | ||
|
||
type nexusPayloadSerializer struct{} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I'm going to rename this to payloadSerializer
and avoid the stuttering.
commonpb "go.temporal.io/api/common/v1" | ||
) | ||
|
||
type payloadSerializer struct{} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
What is the purpose of this type? Not that it's wrong, but you check that it implements an interface and everything but nothing uses it and it's not exported
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I still don't know if I want to export it but I'm going to use it in a bit when I implement the Nexus Handler in this code base.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Would be willing to wait until then to review since how it is used affects how it is written (or at least set a reminder to go review this already-merged piece if it does get merged before the usage of it is done)
common/nexus/payload_serializer.go
Outdated
if params["format"] == "protobuf" { | ||
payload.Metadata["encoding"] = []byte("json/protobuf") | ||
messageType := params["message-type"] | ||
if messageType != "" { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I don't think we should allow proto without message type. It's not very useful over normal JSON and message-type-less protos in Temporal are only supported on older SDKs. Arguably you don't need format
and message-type
as separate params, just a single protobuf-message-type
param is good enough to be seen as proto JSON.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I'm fine considering protos without message-type
non-standard.
I'll also consider merging into a single param.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Yeah, more and more the single param sounds good.
common/nexus/payload_serializer.go
Outdated
case "application/x-protobuf": | ||
payload.Metadata["encoding"] = []byte("binary/protobuf") | ||
messageType := params["message-type"] | ||
if messageType != "" { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Same here, we should not allow message-type-less proto binary
common/nexus/payload_serializer.go
Outdated
} | ||
contentType := h.Get("type") | ||
if contentType == "" { | ||
return len(h) == 0 && len(content.Data) == 0 |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
So it was confusing to follow this logic down to here, but I think an absent content type with data should be a binary/plain
.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I considered that but it would make this conversion asymmetric. Because we can't interpret the intent I think it may be better that this becomes encoding: unknown/nexus-content
so then if it's turned into a content object again it doesn't get an application/octet-stream
content type.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
An absent content type + data is always asymmetric because it's more loosely defined inbound but we want to explicitly set content type outbound when data is present. So discussion of absent content-type + data only applies in one direction.
common/nexus/payload_serializer.go
Outdated
if !isStandardPayload(payload) { | ||
data, err := payload.Marshal() | ||
if err != nil { | ||
return nil, fmt.Errorf("%w: payload marshal error: %w", errSerializer, err) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Would avoid a double %w
, there's only one true cause. In fact, would avoid errSerializer
altogether, it has no value from what I see (but I understand server has a bit of a history with hard to read code by precreating errors often unnecessarily regardless of some opinions about performance or attempting to predict future uses)
common/nexus/payload_serializer.go
Outdated
} | ||
case "json/protobuf": | ||
content.Header["type"] = fmt.Sprintf("application/json; format=protobuf") | ||
if messageType != "" { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I don't think any SDKs leave this off anymore and haven't for a while, I think we can require usage of a somewhat modern SDK version for use with Nexus protos
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I'm fine with that.
common/nexus/payload_serializer.go
Outdated
return &nexus.Content{Header: nexus.Header{}}, nil | ||
} | ||
|
||
if !isStandardPayload(payload) { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This is confusing too, can you just make this stuff part of the switch below? You can set data in each arm of the switch, there's not that much code reuse here. Just handle each type specifically. I can suggest exact rewrite if it helps.
(I don't usually do reviews in this repository but I was asked in this case) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Thanks for the reviews.
commonpb "go.temporal.io/api/common/v1" | ||
) | ||
|
||
type payloadSerializer struct{} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I still don't know if I want to export it but I'm going to use it in a bit when I implement the Nexus Handler in this code base.
common/nexus/payload_serializer.go
Outdated
if params["format"] == "protobuf" { | ||
payload.Metadata["encoding"] = []byte("json/protobuf") | ||
messageType := params["message-type"] | ||
if messageType != "" { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I'm fine considering protos without message-type
non-standard.
I'll also consider merging into a single param.
common/nexus/payload_serializer.go
Outdated
} | ||
|
||
func isStandardNexusContent(content *nexus.Content) bool { | ||
h := content.Header |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This was supposed to clone the map, I missed that.
common/nexus/payload_serializer.go
Outdated
} | ||
contentType := h.Get("type") | ||
if contentType == "" { | ||
return len(h) == 0 && len(content.Data) == 0 |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I considered that but it would make this conversion asymmetric. Because we can't interpret the intent I think it may be better that this becomes encoding: unknown/nexus-content
so then if it's turned into a content object again it doesn't get an application/octet-stream
content type.
common/nexus/payload_serializer.go
Outdated
if !isStandardPayload(payload) { | ||
data, err := payload.Marshal() | ||
if err != nil { | ||
return nil, fmt.Errorf("%w: payload marshal error: %w", errSerializer, err) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Again, I could make a linter exception here. I was just "following the rules".
common/nexus/payload_serializer.go
Outdated
} | ||
case "json/protobuf": | ||
content.Header["type"] = fmt.Sprintf("application/json; format=protobuf") | ||
if messageType != "" { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I'm fine with that.
Merged the detection and serialization logic and address the rest of the comments. |
// We assume that encoding is handled by the transport layer and the content is decoded. | ||
delete(h, "encoding") | ||
// Length can safely be ignored. | ||
delete(h, "length") |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Why remove these here when you can just filter and not set them in the one place you actually copy headers, the setUnknownNexusContent
call?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This is mostly to simplify the logic below and sanitize any irrelevant fields.
// Length can safely be ignored. | ||
delete(h, "length") | ||
|
||
if len(h) > 1 { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Why do you care what the rest of the headers are so long as the content type is correct? I think you're overthinking here and can just remove this conditional
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I don't want to lose information in the conversion
if len(h) == 0 && len(content.Data) == 0 { | ||
payload.Metadata["encoding"] = []byte("binary/null") | ||
} else { | ||
setUnknownNexusContent(h, payload.Metadata) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I still think this should be binary/plain
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This could also be content header that's not type
, and I'd rather not lose this information.
var errSerializer = errors.New("serializer error") | ||
|
||
// Deserialize implements nexus.Serializer. | ||
func (payloadSerializer) Deserialize(content *nexus.Content, v any) error { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
To me this reads clearer as a future maintainer, but don't have to accept it (just typed real quick, so probably some typos):
// Deserialize implements nexus.Serializer.
func (payloadSerializer) Deserialize(content *nexus.Content, v any) error {
payloadRef, ok := v.(**commonpb.Payload)
if !ok {
return fmt.Errorf("cannot deserialize into %v", v)
}
payload := &commonpb.Payload{Metadata: map[string][]byte{}, Data: content.Data}
*payloadRef = payload
// Absent content type with no data means null, with data is just binary
if contentType := content.Header["type"]; contentType == "" {
if len(content.Data) == 0 {
payload.Metadata["encoding"] = []byte("binary/null")
} else {
payload.Metadata["encoding"] = []byte("binary/plain")
}
return nil
}
// Different content types make different payloads
if mediaType, params, err := mime.ParseMediaType(contentType); err == nil {
switch mediaType {
case "application/x-temporal-payload":
if err := payload.Unmarshal(content.Data); err != nil {
return fmt.Errorf("failed to unmarshal payload: %w", err)
}
return nil
case "application/json":
if protoMessageType := params["protobuf-message-type"]; protoMessageType != "" {
payload.Metadata["encoding"] = []byte("json/protobuf")
payload.Metadata["messageType"] = []byte(protoMessageType)
} else {
payload.Metadata["encoding"] = []byte("json/plain")
}
return nil
case "application/x-protobuf":
if protoMessageType := params["protobuf-message-type"]; protoMessageType != "" {
payload.Metadata["encoding"] = []byte("binary/protobuf")
payload.Metadata["messageType"] = []byte(protoMessageType)
return nil
}
return fmt.Errorf("protobuf message type required for application/x-protobuf content type")
case "application/octet-stream":
payload.Metadata["encoding"] = []byte("binary/plain")
return nil
}
}
// Unknown or bad content type
payload.Metadata["encoding"] = "binary/unknown-nexus"
for k, v := range content.Header {
// Don't want length or encoding
if k != "length" && k != "encoding" {
payload.Metadata[k] = []byte(v)
}
}
return nil
}
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I prefer my stricter non-lossy version.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Thanks for the suggestion though.
} | ||
} | ||
case "json/protobuf": | ||
if len(payload.Metadata) != 2 || messageType == "" { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think, like with the code in deserialize, you're being overly strict on the metadata. If it's our encoding, we can choose to ignore other metadata
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I don't think you're right here.
If a user customizes serialization to add more information, we should treat their payload as opaque and non standard and avoid losing metadata during the conversion.
case "application/json": | ||
if len(params) == 0 { | ||
payload.Metadata["encoding"] = []byte("json/plain") | ||
} else if len(params) == 2 && params["format"] == "protobuf" && params["message-type"] != "" { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think you're being overly strict on the mime type params. If it's our content type, we can ignore params we don't care about
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
again, I'd rather not lose this information. Either you're sticking to the standard or you're a custom nexus payload.
@cretz I thought about it some more and it may be desirable to be a bit less strict with the conversion but I would like to do that in a way that no information is lost. In any case, I'll get back to this at some point before merging |
What changed?
See description
Why?
The server should do best effort conversion between Nexus Content and Temporal Payloads so pure Temporal applications don't have to deal with the separate Nexus Content concept.
How did you test it?
Added tests.
Potential risks
Non-standard content is converter into a Payload with metadata encoding of
unknown/nexus-content
. Users will need to prepare for that or their Nexus requests may fail.I believe this is the best option we have to avoid lossy conversion and expose as much information and empower the application.
I considered using
binary/plain
for non-standard content but I'd rather not have these payloads accidentally slip into user's applications.Is hotfix candidate?
Far from it.