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