SIGN IN SIGN UP

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
https://github.com/grpc/grpc-go/issues/8860#issuecomment-3975157332:

> 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 https://github.com/grpc/grpc-go/issues/8860#issuecomment-5121951898.

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>
Y
Yuyang Guo committed
955851b776ba623de315d8c7aa0e7260f600b9f5
Parent: ce9c112
Committed by GitHub <noreply@github.com> on 8/19/2026, 8:34:26 PM