mirror of
https://github.com/hanzoai/tasks.git
synced 2026-10-04 02:04:03 +00:00
## 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]>
192 lines
6.0 KiB
Go
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
|
|
}
|