Skip to content

fix: make discovery REST mapper safe for concurrent use - #199

Merged
vroldanbet merged 5 commits into
mainfrom
fix/concurrent-restmapper-race
Jun 10, 2026
Merged

fix: make discovery REST mapper safe for concurrent use#199
vroldanbet merged 5 commits into
mainfrom
fix/concurrent-restmapper-race

Conversation

@vroldanbet

@vroldanbet vroldanbet commented Jun 10, 2026

Copy link
Copy Markdown
Contributor

Summary

The discovery REST mapper used while filtering responses isn't safe for concurrent use. Under concurrent load it panics with a nil-pointer dereference, which surfaces to clients as an HTTP/2 INTERNAL_ERROR.

Commits:

  1. fix: make the mapper concurrency-safe (with a test that reproduces the race).
  2. perf: memoize lookups so the fix doesn't add a per-request cost — with the tradeoff worked through below.
  3. test: an unrelated fix for a pre-existing Unit/e2e CI flake — embedded-SpiceDB metric double-registration (details below).
  4. test(e2e): another unrelated pre-existing fix — flaky namespace-watch specs (details below).

The bug

toRestMapper returns a shortcut expander over a disk-cached discovery client. On every KindFor call the shortcut expander calls the discovery client directly, bypassing the DeferredDiscoveryRESTMapper's lock. When the on-disk cache is stale it gets rewritten, and the encoder briefly mutates the cached object's TypeMeta (sets the GVK for the wire, then restores it). Two concurrent KindFor calls race on that field, corrupt the APIVersion string, and panic in schema.ParseGroupVersion.

(Links point to k8s v0.34.1, the version this repo builds against.)

The mapper is shared across all request goroutines and KindFor runs while filtering every list/get response, so this is reachable under normal traffic.

Same class of bug as kubernetes/kubernetes#51521 (an Encode() that mutates its object), which was fixed only for the Unstructured codec. Discovery REST mappers aren't treated as concurrency-safe upstream — controller-runtime ships its own synchronized one — so the fix belongs at the caller.

Commit 1 — the fix

Serialize all access to the mapper with a mutex. The added test drives concurrent KindFor through the real discovery machinery and fails under -race without the wrapper.

Commit 2 — the optimization

Locking alone is correct but costly: the shortcut expander re-reads and JSON-decodes one discovery document per group-version on every KindFor, now serialized under the lock. The cost scales with the number of CRDs / API group-versions:

group-versions lock-only / call memoized / call
10 ~0.24 ms
100 ~1.15 ms
300 ~3.36 ms (~1.3 MB, ~15k allocs) ~23 ns (0 allocs)

So we memoize successful GVR→GVK lookups; discovery is consulted only on a miss.

Why not cache forever? That would ignore the discovery cache's TTL — a GVK that changed at runtime (CRD reinstalled, version promoted) could stay pinned until the process restarts. Instead, the in-process cache uses the same TTL as the disk discovery cache (one shared value, so they can't drift) and caches only successful lookups (errors and unknown types are retried). Worst-case staleness stays the same order as today.

Commit 3 — unrelated CI fix (pre-existing flake)

This commit is not related to the REST mapper change; it fixes a separate, pre-existing flake that was failing the Unit and e2e jobs on this branch and on main.

The embedded SpiceDB helper builds an in-memory instance for tests. It already disables metrics on the dispatch and namespace caches, but the stored_schema cache was left at its default (Metrics: true), which registers Prometheus collectors on the global registry. When more than one embedded instance is created in a single test process, the second registration intermittently fails with duplicate metrics collector registration attempted. Setting Metrics: false builds that cache without registering, so the collision can't happen. (Surfaced by the SpiceDB v1.53.0 bump in #193.)

It's bundled here only because the flake blocks this PR's CI; it could equally land as its own PR.

Commit 4 — unrelated e2e flakiness fix (pre-existing)

Also not related to the REST mapper change. The "two users" namespace-watch e2e specs flaked because paul/chani are fixed identities across all specs and their namespace grants are never cleaned up (envtest has no namespace GC), so each user's cluster-scoped watch/list accumulates stale namespaces from earlier specs and the watch helper returns one of them. Fix: AfterEach now deletes the spec's namespace relationships so grants don't accumulate, and the list assertion is tightened to an exact ConsistOf — a robust leak check that fails on any cross-user namespace, current or stale. Also adds an assertion that a failed cross-user create leaves no dangling creator grant; it passes, confirming the dual-write rollback is correct. (Originally opened as #200, folded in here.)

Testing

-race reproduction of the data race; plus memoization, no-error-caching, and TTL-refresh tests. The scaling numbers above were measured with an ad-hoc benchmark (not committed). The two pre-existing flakes don't reproduce locally (timing/accumulation-dependent), so CI is the confirmation — Unit, e2e, Build, and Lint are all green (e2e confirmed green across multiple randomized orderings).

@github-actions github-actions Bot added area/tooling Affects the dev or user toolchain area/core labels Jun 10, 2026
@vroldanbet
vroldanbet force-pushed the fix/concurrent-restmapper-race branch from 712d706 to 726364a Compare June 10, 2026 10:15
@codecov-commenter

codecov-commenter commented Jun 10, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 52.00000% with 24 lines in your changes missing coverage. Please review.

Files with missing lines Patch % Lines
pkg/proxy/restmapper.go 38.46% 24 Missing ⚠️

📢 Thoughts on this report? Let us know!

@vroldanbet vroldanbet self-assigned this Jun 10, 2026
@vroldanbet
vroldanbet requested a review from ecordell June 10, 2026 14:21
@vroldanbet
vroldanbet marked this pull request as ready for review June 10, 2026 14:21
The REST mapper returned by toRestMapper -- a shortcut expander over a
disk-cached discovery client -- is not safe for concurrent use.

On every KindFor call the shortcut expander calls the discovery client's
ServerGroupsAndResources() directly, bypassing the
DeferredDiscoveryRESTMapper's lock. When the on-disk discovery cache is
stale, that path rewrites the cache, and the versioning codec does a
read-modify-write on the cached object's TypeMeta (SetGroupVersionKind
followed by a deferred restore). Two concurrent KindFor calls race on that
field, tearing the APIVersion string and panicking with a nil-pointer
dereference inside schema.ParseGroupVersion.

The proxy shares a single mapper across all request goroutines and calls
KindFor while filtering every list/get response, so this is reachable
under normal concurrent traffic: the apiserver handler panics, HandleCrash
re-raises it, and the HTTP/2 server resets the stream with INTERNAL_ERROR
to the caller.

Serialize all access to the mapper through a mutex. Adds a test that
reproduces the data race; it fails under `-race` without the wrapper.

Signed-off-by: Víctor Roldán Betancort <vroldanbet@authzed.com>
Serializing the mapper (previous commit) fixes the race but is expensive:
the shortcut expander re-reads and JSON-decodes one discovery document per
group-version on every KindFor call, and that now runs under the mapper's
lock. The cost grows linearly with the number of CRDs / API group-versions,
so it caps throughput on busy, CRD-heavy clusters.

Measured cost (warm on-disk cache, per KindFor):

  group-versions   lock-only       memoized
  10               ~0.24 ms        -
  100              ~1.15 ms        -
  300              ~3.36 ms        ~23 ns
  (300: ~1.29 MB / ~15k allocs per call  vs  0 B / 0 allocs)

Memoize successful GVR->GVK lookups so the steady state is an O(1) map read;
the delegate (and its discovery I/O) is consulted only on a miss.

Design discussion / concern addressed:

Caching indefinitely was considered and rejected. It would silently ignore
the discovery cache's TTL: a GVK that changed at runtime (CRD reinstalled or
a version promoted), or a transient inconsistent discovery result, would be
pinned until the process restarted. The underlying disk discovery cache
deliberately expires entries to bound exactly this staleness.

Instead, the in-process cache carries the SAME TTL as the disk discovery
cache. The value is threaded from a single source (newCachedRESTMapper's
ttl) into both the disk client and this wrapper, so the two cannot drift.
Worst-case staleness stays the same order as today's behavior rather than
unbounded. Only successful lookups are cached; errors and not-yet-known
resource types are retried.

Adds tests for memoization, error pass-through (no negative caching), and
TTL-based refresh.

Signed-off-by: Víctor Roldán Betancort <vroldanbet@authzed.com>
The embedded SpiceDB helper (pkg/spicedb/spicedb.go) already disables metrics
on the dispatch and namespace caches, but left the stored_schema cache at its
default (Metrics: true). That cache registers Prometheus collectors named
"stored_schema" on the global registry, so when more than one embedded instance
is created in a single test process the second registration intermittently
fails with "duplicate metrics collector registration attempted", flaking the
Unit and e2e CI jobs.

With Metrics: false the cache is built without registering anything (see
CacheConfig.Complete in spicedb's server package), so multiple embedded
instances can no longer collide.

Unrelated to the REST mapper concurrency fix in this PR; included only because
this flake was blocking CI on this branch (and on main).

Signed-off-by: Víctor Roldán Betancort <vroldanbet@authzed.com>
…llback

The "two users" namespace-watch specs flake in CI: the cluster-scoped
namespace watch/list returns stale namespaces left over from earlier specs.

Root cause: paul and chani are the same identities across all specs, and each
spec's namespace `creator`/`viewer` relationships are never cleaned up (envtest
has no namespace GC, and AfterEach only orphan-deletes the namespaces). So a
user's set of viewable namespaces grows every spec, and the cluster-scoped
WatchNamespaces helper -- which returns the first event -- returns a stale
namespace before the one the spec just created.

Fix at the source: AfterEach now deletes the namespace relationships the spec
created, so grants don't accumulate across specs (adds a DeleteAllTuples
helper). With no stale state, each user sees exactly their own namespace, so
the list assertion is tightened to an exact ConsistOf match -- a robust leak
check that fails on any cross-user namespace, current or stale (the previous
per-name checks could not catch a leaked stale namespace). WatchNamespaces is
left as-is; cleanup alone makes it reliable.

Also add an assertion to "recovers when there are kube write failures": after
paul's failed create of chani's namespace, no `namespace:<ns>#creator@user:paul`
relationship may remain (with `view = viewer + creator`, a dangling grant would
let paul see chani's namespace). It passes in both lock modes -- the dual-write
rollback is correct -- so it stands as a regression guard.

Unrelated to the REST mapper race; this is pre-existing e2e test-harness
flakiness, exposed once the embedded-SpiceDB metrics fix let the e2e suite run
far enough to reach these specs.

Signed-off-by: Víctor Roldán Betancort <vroldanbet@authzed.com>
@vroldanbet
vroldanbet force-pushed the fix/concurrent-restmapper-race branch from 42b48c3 to e9d21bf Compare June 10, 2026 14:21
ecordell
ecordell previously approved these changes Jun 10, 2026

@ecordell ecordell left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM, a couple of small nits but nothing that should block

Comment thread pkg/proxy/restmapper.go Outdated
Comment thread pkg/proxy/restmapper_test.go Outdated
Addresses review feedback:
- Remove the injectable `now func() time.Time` field. KindFor uses
  time.Now/time.Since directly, and the TTL test uses testing/synctest to
  advance time instead, so production code carries no test-only clock.
- Use range-over-int (`for range 5`) in the memoization test.

No behavior change.

Signed-off-by: Víctor Roldán Betancort <vroldanbet@authzed.com>

@tstirrat15 tstirrat15 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM

// with a nil-pointer dereference inside ParseGroupVersion.
//
// A zero TTL forces the disk discovery cache to be rewritten on every call, which is the
// path that races. Run under `-race` to detect it.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should we be running under -race all of the time?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yeah I don't think any reason not to do it (other than the feedback loop becomes slower)

@vroldanbet
vroldanbet merged commit 63dee63 into main Jun 10, 2026
7 checks passed
@github-actions github-actions Bot locked and limited conversation to collaborators Jun 10, 2026
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.

Labels

area/core area/tooling Affects the dev or user toolchain

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants