Skip to content

Commit 955851b

Browse files
yuyangguo42claude
andauthored
metadata: Add ValueFromOutgoingContext (#9282)
Partially Fixes: #8860 ### Why There are use cases where only a single value needs to be read from outgoing gRPC metadata. Today the only way to do that is `metadata.FromOutgoingContext`, which merges and copies every header already staged into a brand-new map, even though the caller only wants one of them. The cost of that copy grows with however many headers have already accumulated in outgoing context — exactly the complaint raised in #8860. ### What This PR adds `metadata.ValueFromOutgoingContext(ctx, key) []string`, symmetric to the existing `ValueFromIncomingContext`, for reading a single header from outgoing gRPC metadata without merging and copying every other header already staged via `AppendToOutgoingContext`. This is the API @easwars suggested in #8860 (comment): > Would adding a `ValueFromOutgoingContext` that is similar to `ValueFromIncomingContext` work for you? Please note that `ValueFromOutgoingContext` will not be as fast as `ValueFromIncomingContext` as the implementation would have check entries from the `map` **and** the `added` entries. per #8860 (comment). That tradeoff is exactly what this implementation does: it checks `rawMD.md` (case-insensitively, matching `ValueFromIncomingContext`'s semantics) and then walks `rawMD.added`, accumulating matches in the same order `FromOutgoingContext` would, without allocating a new map or copying unrelated keys/values. Matching `rawMD.added` entries case-insensitively via `strings.EqualFold`, rather than assuming they're already lowercased by `AppendToOutgoingContext`, makes no assumption about how `rawMD.added` was populated — the same guarantee `FromOutgoingContext` already makes for both `rawMD.md` and `rawMD.added`. The allocation of `vals` is deferred until a match is actually found in `rawMD.added` (per `gemini-code-assist`'s review suggestion), rather than always calling `copyOf` as soon as `rawMD.md` matches. When the key is found in both `rawMD.md` and `rawMD.added`, this avoids paying for a `copyOf` allocation that would otherwise immediately be discarded by the first `append`'s growth — roughly halving allocations for that case (see `key-found-in-md-and-added` in the benchmark below). The `rawMD.added` loop also tries a direct `==` before falling back to `strings.EqualFold` (a second `gemini-code-assist` suggestion), since `AppendToOutgoingContext` already lowercases keys in practice. Benchmarked with realistic-length keys (e.g. `"grpc-timeout"`, not `"k1"`), this is a modest win when the key is found (~2-7% faster) at the cost of a small regression when it isn't (~6% slower — one extra comparison with no payoff) — a reasonable trade since looking up a header you expect to be present is the common case. ### Benchmark `n` is the number of unrelated headers already staged (one `NewOutgoingContext` call plus one `AppendToOutgoingContext` call) before reading the target key: ``` goos: darwin goarch: arm64 pkg: google.golang.org/grpc/metadata cpu: Apple M4 Pro FromOutgoingContext/n=1 141.7 ns/op 432 B/op 4 allocs/op ValueFromOutgoingContext/n=1 56.0 ns/op 16 B/op 1 allocs/op FromOutgoingContext/n=10 404.9 ns/op 968 B/op 15 allocs/op ValueFromOutgoingContext/n=10 107.2 ns/op 16 B/op 1 allocs/op FromOutgoingContext/n=50 1596.0 ns/op 3592 B/op 55 allocs/op ValueFromOutgoingContext/n=50 314.7 ns/op 16 B/op 1 allocs/op ``` (measured with `b.Loop`, per review feedback, which also removes the need for the manual anti-optimization `b.Fatal` checks the previous numbers were measured with) At n=50, roughly 5x faster with 55x fewer allocations. This is still O(n) in the number of already-staged headers in the worst case, since `rawMD.added` isn't indexed by key, but it avoids the wasted work of copying and lowercasing every header the caller doesn't want. Separately, `BenchmarkValueFromOutgoingContext` (now using realistic-length keys like `"grpc-timeout"`/`"content-type"` instead of `"k1"`/`"k3"`, which understated `strings.EqualFold`'s cost) shows both `rawMD.added` optimizations above: ``` key-found 59.6 ns/op 16 B/op 1 allocs/op key-not-found 50.7 ns/op 0 B/op 0 allocs/op key-found-in-md-and-added 38.7 ns/op 32 B/op 1 allocs/op ``` ### Testing - `TestValueFromOutgoingContext` covers exact match, case-insensitive match, a value present in `rawMD.md` accumulated with two later `AppendToOutgoingContext` calls (must match `FromOutgoingContext`'s order, per `TestAppendToOutgoingContext`), values split solely across multiple `AppendToOutgoingContext` calls, not-found, and no-outgoing-metadata-at-all. - `TestValueFromOutgoingContext_AddedCaseInsensitive` constructs `rawMD.added` directly with a mixed-case key (bypassing `AppendToOutgoingContext`'s lowercasing) to verify the match is still found. - `TestValueFromOutgoingContext_PanicsOnOddPairs` covers the defensive panic on a malformed `rawMD.added` entry, mirroring the identical guard already present in `FromOutgoingContext`. - `BenchmarkValueFromOutgoingContext` mirrors the existing `BenchmarkValueFromIncomingContext` shape (key-found / key-not-found), plus a `key-found-in-md-and-added` case covering the deferred-allocation path above. - `BenchmarkValueFromOutgoingContextVsFromOutgoingContext` produced the comparison table above. - `ValueFromOutgoingContext` itself is at 100% statement coverage (`go test -cover`). - `go test ./metadata/...`, `go vet ./metadata/...`, and `gofmt` are all clean. This branch is rebased on current `master`. This is additive only — no existing exported behavior changes. RELEASE NOTES: * metadata: Add ValueFromOutgoingContext, which reads a single metadata value from outgoing context without copying the entire outgoing metadata into a new map. --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent ce9c112 commit 955851b

2 files changed

Lines changed: 213 additions & 0 deletions

File tree

metadata/metadata.go

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -360,6 +360,66 @@ func FromOutgoingContext(ctx context.Context) (MD, bool) {
360360
return out, ok
361361
}
362362

363+
// ValueFromOutgoingContext returns the metadata value corresponding to the
364+
// metadata key from the outgoing metadata if it exists. Keys are matched in a
365+
// case-insensitive manner.
366+
//
367+
// Unlike FromOutgoingContext, this does not copy the entire outgoing metadata.
368+
func ValueFromOutgoingContext(ctx context.Context, key string) []string {
369+
raw, ok := ctx.Value(mdOutgoingKey{}).(rawMD)
370+
if !ok {
371+
return nil
372+
}
373+
374+
key = strings.ToLower(key)
375+
// Unlike ValueFromIncomingContext, this can't return as soon as raw.md is
376+
// checked: raw.added still needs to be walked and accumulated regardless.
377+
// The else below prevents that from overwriting an exact match with an
378+
// unrelated case-insensitive one.
379+
var matchedMD []string
380+
if v, ok := raw.md[key]; ok {
381+
matchedMD = v
382+
} else {
383+
for k, v := range raw.md {
384+
// Case insensitive comparison: MD is a map, and there's no
385+
// guarantee that the MD attached to the context is created using
386+
// our helper functions.
387+
if strings.EqualFold(k, key) {
388+
matchedMD = v
389+
break
390+
}
391+
}
392+
}
393+
394+
// Defer allocating vals until raw.added actually has a match, so a match
395+
// found only in raw.md doesn't pay for both a copyOf and a growing
396+
// append.
397+
var vals []string
398+
for _, added := range raw.added {
399+
if len(added)%2 == 1 {
400+
panic(fmt.Sprintf("metadata: ValueFromOutgoingContext got an odd number of input pairs for metadata: %d", len(added)))
401+
}
402+
403+
// Case insensitive, like FromOutgoingContext: added isn't guaranteed
404+
// lowercase, though AppendToOutgoingContext already lowercases it in
405+
// practice, so try the cheap exact match first.
406+
for i := 0; i < len(added); i += 2 {
407+
if added[i] == key || strings.EqualFold(added[i], key) {
408+
if vals == nil {
409+
vals = make([]string, 0, len(matchedMD)+1)
410+
vals = append(vals, matchedMD...)
411+
}
412+
vals = append(vals, added[i+1])
413+
}
414+
}
415+
}
416+
417+
if vals == nil && matchedMD != nil {
418+
return copyOf(matchedMD)
419+
}
420+
return vals
421+
}
422+
363423
type rawMD struct {
364424
md MD
365425
added [][]string

metadata/metadata_test.go

Lines changed: 153 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,11 +20,13 @@ package metadata
2020

2121
import (
2222
"context"
23+
"fmt"
2324
"reflect"
2425
"strconv"
2526
"testing"
2627
"time"
2728

29+
"github.com/google/go-cmp/cmp"
2830
"google.golang.org/grpc/internal/grpctest"
2931
)
3032

@@ -342,6 +344,99 @@ func (s) TestAppendToOutgoingContext_FromKVSlice(t *testing.T) {
342344
}
343345
}
344346

347+
func (s) TestValueFromOutgoingContext(t *testing.T) {
348+
ctx, cancel := context.WithTimeout(context.Background(), defaultTestTimeout)
349+
defer cancel()
350+
351+
md := Pairs(
352+
"X-My-Header-1", "42",
353+
"x-my-header-3", "44",
354+
"k1", "v1",
355+
"k2", "v2",
356+
)
357+
// Verify that we match case-insensitively even if callers directly
358+
// modify md.
359+
md["X-INCORRECT-UPPERCASE"] = []string{"foo"}
360+
ctx = NewOutgoingContext(ctx, md)
361+
ctx = AppendToOutgoingContext(ctx, "x-my-header-2", "43-1")
362+
ctx = AppendToOutgoingContext(ctx, "X-My-Header-2", "43-2")
363+
ctx = AppendToOutgoingContext(ctx, "k1", "v3")
364+
ctx = AppendToOutgoingContext(ctx, "k1", "v4")
365+
366+
for _, test := range []struct {
367+
key string
368+
want []string
369+
}{
370+
{
371+
key: "x-my-header-1",
372+
want: []string{"42"},
373+
},
374+
{
375+
// Split across two AppendToOutgoingContext calls (raw.added) —
376+
// must accumulate across both, in call order.
377+
key: "x-my-header-2",
378+
want: []string{"43-1", "43-2"},
379+
},
380+
{
381+
key: "x-my-header-3",
382+
want: []string{"44"},
383+
},
384+
{
385+
key: "x-unknown",
386+
want: nil,
387+
},
388+
{
389+
key: "x-incorrect-uppercase",
390+
want: []string{"foo"},
391+
},
392+
{
393+
// Present in both raw.md and two later AppendToOutgoingContext
394+
// calls — must accumulate across all three, in order.
395+
key: "k1",
396+
want: []string{"v1", "v3", "v4"},
397+
},
398+
} {
399+
v := ValueFromOutgoingContext(ctx, test.key)
400+
if diff := cmp.Diff(test.want, v); diff != "" {
401+
t.Errorf("ValueFromOutgoingContext(ctx, %q) returned unexpected diff (-want +got):\n%s", test.key, diff)
402+
}
403+
}
404+
}
405+
406+
func (s) TestValueFromOutgoingContext_NoMetadata(t *testing.T) {
407+
ctx, cancel := context.WithTimeout(context.Background(), defaultTestTimeout)
408+
defer cancel()
409+
410+
if v := ValueFromOutgoingContext(ctx, "x-my-header-1"); v != nil {
411+
t.Errorf("ValueFromOutgoingContext on context with no outgoing metadata = %v, want nil", v)
412+
}
413+
}
414+
415+
func (s) TestValueFromOutgoingContext_AddedCaseInsensitive(t *testing.T) {
416+
ctx, cancel := context.WithTimeout(context.Background(), defaultTestTimeout)
417+
defer cancel()
418+
ctx = context.WithValue(ctx, mdOutgoingKey{}, rawMD{added: [][]string{{"X-My-Header", "42"}}})
419+
420+
v := ValueFromOutgoingContext(ctx, "x-my-header")
421+
if diff := cmp.Diff([]string{"42"}, v); diff != "" {
422+
t.Errorf("ValueFromOutgoingContext(ctx, \"x-my-header\") returned unexpected diff (-want +got):\n%s", diff)
423+
}
424+
}
425+
426+
func (s) TestValueFromOutgoingContext_PanicsOnOddPairs(t *testing.T) {
427+
defer func() {
428+
if r := recover(); r == nil {
429+
t.Fatal("ValueFromOutgoingContext did not panic on an odd number of pairs in added")
430+
}
431+
}()
432+
433+
ctx, cancel := context.WithTimeout(context.Background(), defaultTestTimeout)
434+
defer cancel()
435+
ctx = context.WithValue(ctx, mdOutgoingKey{}, rawMD{added: [][]string{{"key-without-a-value"}}})
436+
437+
ValueFromOutgoingContext(ctx, "key-without-a-value")
438+
}
439+
345440
// Old/slow approach to adding metadata to context
346441
func Benchmark_AddingMetadata_ContextManipulationApproach(b *testing.B) {
347442
// TODO: Add in N=1-100 tests once Go1.6 support is removed.
@@ -379,6 +474,32 @@ func BenchmarkFromOutgoingContext(b *testing.B) {
379474
}
380475
}
381476

477+
// Reading one key out of many staged headers
478+
func BenchmarkValueFromOutgoingContextVsFromOutgoingContext(b *testing.B) {
479+
for _, numHeaders := range []int{1, 10, 50} {
480+
ctx, cancel := context.WithTimeout(context.Background(), defaultTestTimeout)
481+
defer cancel()
482+
md := MD{}
483+
for i := 0; i < numHeaders; i++ {
484+
md[strconv.Itoa(i)] = []string{strconv.Itoa(i)}
485+
}
486+
ctx = NewOutgoingContext(ctx, md)
487+
ctx = AppendToOutgoingContext(ctx, "target-key", "target-value")
488+
489+
b.Run(fmt.Sprintf("FromOutgoingContext/n=%d", numHeaders), func(b *testing.B) {
490+
for b.Loop() {
491+
FromOutgoingContext(ctx)
492+
}
493+
})
494+
495+
b.Run(fmt.Sprintf("ValueFromOutgoingContext/n=%d", numHeaders), func(b *testing.B) {
496+
for b.Loop() {
497+
ValueFromOutgoingContext(ctx, "target-key")
498+
}
499+
})
500+
}
501+
}
502+
382503
func BenchmarkFromIncomingContext(b *testing.B) {
383504
ctx, cancel := context.WithTimeout(context.Background(), defaultTestTimeout)
384505
defer cancel()
@@ -416,6 +537,38 @@ func BenchmarkValueFromIncomingContext(b *testing.B) {
416537
})
417538
}
418539

540+
func BenchmarkValueFromOutgoingContext(b *testing.B) {
541+
// Realistic-length gRPC metadata keys, not "k1"/"k2"/"k3": short keys
542+
// understate the cost of strings.EqualFold relative to a cheap exact
543+
// match, which matters for the key-found cases below.
544+
ctx, cancel := context.WithTimeout(context.Background(), defaultTestTimeout)
545+
defer cancel()
546+
ctx = NewOutgoingContext(ctx, MD{"content-type": {"application/grpc"}})
547+
ctx = AppendToOutgoingContext(ctx, "grpc-timeout", "10S", "x-request-id", "abc-def-123")
548+
549+
b.Run("key-found", func(b *testing.B) {
550+
for b.Loop() {
551+
ValueFromOutgoingContext(ctx, "grpc-timeout")
552+
}
553+
})
554+
555+
b.Run("key-not-found", func(b *testing.B) {
556+
for b.Loop() {
557+
ValueFromOutgoingContext(ctx, "x-does-not-exist")
558+
}
559+
})
560+
561+
// Key present in both raw.md ("content-type") and raw.added (appended
562+
// below) — exercises the accumulate-across-both path, not just
563+
// append-to-nil.
564+
bothCtx := AppendToOutgoingContext(ctx, "content-type", "application/grpc+proto")
565+
b.Run("key-found-in-md-and-added", func(b *testing.B) {
566+
for b.Loop() {
567+
ValueFromOutgoingContext(bothCtx, "content-type")
568+
}
569+
})
570+
}
571+
419572
// TestString verifies that String shows keys and values only for keys known to
420573
// be safe to log, omits every other key (including its name) and reports only
421574
// the count of omitted keys, and never panics on nil/empty inputs.

0 commit comments

Comments
 (0)