Files
tasks/common/contextutil/metadata.go
7b1bbc0743 Use proto-serialized context metadata in gRPC trailers (#10269)
## What changed
- Replaced per-key trailer format with a single protobuf
`ContextMetadata` message serialized into `contextmetadata-bin` trailer
key
- gRPC automatically base64-encodes the `-bin` value, making arbitrary
bytes (including HTTP/2-unsafe control chars) transport-safe
- Writer emits both proto format and legacy per-key format for backward
compatibility during rolling deploys
- Reader prefers proto key, falls back to legacy per-key format for old
writers
- Wired `TrailerToContextMetadataInterceptor` in test server to match
production behavior

## Why
Workflow type names containing control characters (newlines, NUL, etc.)
cause the gRPC HTTP/2 framer to reject trailer values. A single proto
message in a `-bin` key is simpler than per-key `-bin` suffixes: one
trailer key, one serialization, no key naming constraints, cleaner
backward compat removal.

## How tested
- Unit tests for proto round-trip, dual-format emission, reader
preference, legacy fallback, HTTP/2 safety
- Integration test suite (TestWorkflowTypeEncodingSuite) with control
chars, UTF-8, long names, -bin suffix workflow types
- All existing tests pass

## Risks
- During rolling deploy, old writers emit only legacy keys. New readers
handle this via fallback path. No data loss.
- After full rollout, legacy key emission can be removed in a follow-up.

---

> [!NOTE]
> **Medium Risk**
> Changes how context metadata is encoded/decoded in gRPC trailers,
which can affect cross-version compatibility and observability of
propagated metadata. Backward-compatible legacy fallback and extensive
unit/integration tests reduce the rollout risk.
> 
> **Overview**
> **Switches context-metadata propagation in gRPC trailers to a single
proto-encoded payload.** Server-side `ContextMetadataInterceptor` now
serializes all context metadata into a new `ContextMetadata` protobuf
and emits it under `contextmetadata-bin`, avoiding HTTP/2-unsafe control
characters in values.
> 
> **Maintains rolling-deploy compatibility.** Writers still emit legacy
per-key trailers (skipping unsafe values), and the client-side
`TrailerToContextMetadataInterceptor` now *prefers* the proto trailer
and falls back to legacy keys (including unprefixed well-known keys)
when needed.
> 
> Adds the new `contextpropagation/v1` proto + generated Go types, plus
unit tests around proto/legacy behavior and an integration suite
(`WorkflowTypeEncodingSuite`) covering control characters, UTF-8, long
names, and `-bin` suffix workflow types.
> 
> <sup>Reviewed by [Cursor Bugbot]() for commit
e1d772fc3fe8e136fded479415f8f049ac896054. Bugbot is set up for automated
code reviews on this repo. Configure
[here](https://www.cursor.com/dashboard/bugbot).</sup>

---------

Co-authored-by: Hanzo Dev <[email protected]>
2026-05-18 15:12:25 +00:00

192 lines
6.0 KiB
Go

package contextutil
import (
"context"
"strconv"
"strings"
"sync"
)
type (
metadataContextKey struct{}
// metadataContext is used to store workflow and activity metadata
metadataContext struct {
sync.Mutex
Metadata map[string]any
MarkedActivityIDs map[string]struct{}
}
)
var metadataCtxKey = metadataContextKey{}
const (
// MetadataKeyWorkflowType is the context metadata key for workflow type.
// These keys are serialized into a protobuf message (ContextMetadata) and sent
// in the "contextmetadata-bin" gRPC trailer. The "-bin" suffix causes gRPC to
// base64-encode the value on the wire, so metadata values may contain arbitrary
// bytes including HTTP/2-unsafe control characters.
MetadataKeyWorkflowType = "workflow-type"
// MetadataKeyWorkflowTaskQueue is the context metadata key for workflow task queue
MetadataKeyWorkflowTaskQueue = "workflow-task-queue"
// MetadataKeyStandaloneActivityType is the context metadata key for standalone activity type.
MetadataKeyStandaloneActivityType = "standalone-activity-type"
// MetadataKeyStandaloneActivityTaskQueue is the context metadata key for standalone activity task queue.
MetadataKeyStandaloneActivityTaskQueue = "standalone-activity-task-queue"
activityTypePrefix = "activity-type-"
activityTaskQueuePrefix = "activity-task-queue-"
)
// ActivityTypeKey returns the metadata key for an activity's type, keyed by scheduled event ID.
func ActivityTypeKey(scheduledEventID int64) string {
return activityTypePrefix + strconv.FormatInt(scheduledEventID, 10)
}
// ActivityTaskQueueKey returns the metadata key for an activity's task queue, keyed by scheduled event ID.
func ActivityTaskQueueKey(scheduledEventID int64) string {
return activityTaskQueuePrefix + strconv.FormatInt(scheduledEventID, 10)
}
// ContextMetadataGetActivityTypeAndTaskQueue scans the context metadata for a single
// activity's type and task queue. Returns false if no activity metadata is found
// or if multiple activities are present.
func ContextMetadataGetActivityTypeAndTaskQueue(ctx context.Context) (activityType string, taskQueue string, ok bool) {
metadataCtx := getMetadataContext(ctx)
if metadataCtx == nil {
return "", "", false
}
metadataCtx.Lock()
defer metadataCtx.Unlock()
var foundType, foundTaskQueue bool
for key, value := range metadataCtx.Metadata {
if strings.HasPrefix(key, activityTypePrefix) {
if foundType {
return "", "", false
}
activityType, foundType = value.(string)
} else if strings.HasPrefix(key, activityTaskQueuePrefix) {
if foundTaskQueue {
return "", "", false
}
taskQueue, foundTaskQueue = value.(string)
}
}
return activityType, taskQueue, foundType && foundTaskQueue
}
// ContextMetadataMarkActivityID marks an activity ID on the context for metadata resolution.
// The handler knows which activity (from the task token) but not its type or task queue.
// Mutable state knows the activity details but not which activity the request targets.
// This bridges the two: the handler marks the ID, and SetContextMetadata (during
// closeTransaction) resolves it to type and task queue from mutable state.
// Cannot be used for transactions that remove the activity from mutable state
// (e.g., activity completion), since it won't be available for resolution.
func ContextMetadataMarkActivityID(ctx context.Context, activityID string) bool {
metadataCtx := getMetadataContext(ctx)
if metadataCtx == nil {
return false
}
metadataCtx.Lock()
defer metadataCtx.Unlock()
metadataCtx.MarkedActivityIDs[activityID] = struct{}{}
return true
}
// ContextMetadataGetMarkedActivityIDs returns the marked activity IDs from the context.
func ContextMetadataGetMarkedActivityIDs(ctx context.Context) []string {
metadataCtx := getMetadataContext(ctx)
if metadataCtx == nil {
return nil
}
metadataCtx.Lock()
defer metadataCtx.Unlock()
if len(metadataCtx.MarkedActivityIDs) == 0 {
return nil
}
ids := make([]string, 0, len(metadataCtx.MarkedActivityIDs))
for id := range metadataCtx.MarkedActivityIDs {
ids = append(ids, id)
}
return ids
}
// getMetadataContext extracts metadata context from golang context.
func getMetadataContext(ctx context.Context) *metadataContext {
metadataCtx := ctx.Value(metadataCtxKey)
if metadataCtx == nil {
return nil
}
mc, ok := metadataCtx.(*metadataContext)
if !ok {
return nil
}
return mc
}
// WithMetadataContext adds a metadata context to the given context.
func WithMetadataContext(ctx context.Context) context.Context {
metadataCtx := &metadataContext{
Metadata: make(map[string]any),
MarkedActivityIDs: make(map[string]struct{}),
}
return context.WithValue(ctx, metadataCtxKey, metadataCtx)
}
// ContextHasMetadata returns true if the context has metadata support.
// This can be used to debug whether a context has been properly initialized with metadata.
func ContextHasMetadata(ctx context.Context) bool {
return getMetadataContext(ctx) != nil
}
// ContextMetadataSet sets a metadata key-value pair in the context, overwriting any existing value.
func ContextMetadataSet(ctx context.Context, key string, value any) bool {
metadataCtx := getMetadataContext(ctx)
if metadataCtx == nil {
return false
}
metadataCtx.Lock()
defer metadataCtx.Unlock()
metadataCtx.Metadata[key] = value
return true
}
// ContextMetadataGet retrieves a metadata value from the context.
func ContextMetadataGet(ctx context.Context, key string) (any, bool) {
metadataCtx := getMetadataContext(ctx)
if metadataCtx == nil {
return nil, false
}
metadataCtx.Lock()
defer metadataCtx.Unlock()
value, ok := metadataCtx.Metadata[key]
return value, ok
}
// ContextMetadataGetAll retrieves all metadata from the context as a map copy.
func ContextMetadataGetAll(ctx context.Context) map[string]any {
metadataCtx := getMetadataContext(ctx)
if metadataCtx == nil {
return nil
}
metadataCtx.Lock()
defer metadataCtx.Unlock()
// Return a copy to prevent external modifications
result := make(map[string]any, len(metadataCtx.Metadata))
for k, v := range metadataCtx.Metadata {
result[k] = v
}
return result
}