fix: make discovery REST mapper safe for concurrent use - #199
Merged
Conversation
vroldanbet
force-pushed
the
fix/concurrent-restmapper-race
branch
from
June 10, 2026 10:15
712d706 to
726364a
Compare
Codecov Report❌ Patch coverage is
📢 Thoughts on this report? Let us know! |
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
force-pushed
the
fix/concurrent-restmapper-race
branch
from
June 10, 2026 14:21
42b48c3 to
e9d21bf
Compare
ecordell
previously approved these changes
Jun 10, 2026
ecordell
left a comment
Collaborator
There was a problem hiding this comment.
LGTM, a couple of small nits but nothing that should block
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
approved these changes
Jun 10, 2026
| // 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. |
Contributor
There was a problem hiding this comment.
Should we be running under -race all of the time?
Contributor
Author
There was a problem hiding this comment.
yeah I don't think any reason not to do it (other than the feedback loop becomes slower)
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to subscribe to this conversation on GitHub.
Already have an account?
Sign in.
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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:
fix:make the mapper concurrency-safe (with a test that reproduces the race).perf:memoize lookups so the fix doesn't add a per-request cost — with the tradeoff worked through below.test:an unrelated fix for a pre-existing Unit/e2e CI flake — embedded-SpiceDB metric double-registration (details below).test(e2e):another unrelated pre-existing fix — flaky namespace-watch specs (details below).The bug
toRestMapperreturns a shortcut expander over a disk-cached discovery client. On everyKindForcall the shortcut expander calls the discovery client directly, bypassing theDeferredDiscoveryRESTMapper's lock. When the on-disk cache is stale it gets rewritten, and the encoder briefly mutates the cached object'sTypeMeta(sets the GVK for the wire, then restores it). Two concurrentKindForcalls race on that field, corrupt theAPIVersionstring, and panic inschema.ParseGroupVersion.(Links point to k8s
v0.34.1, the version this repo builds against.)The mapper is shared across all request goroutines and
KindForruns while filtering every list/get response, so this is reachable under normal traffic.Commit 1 — the fix
Serialize all access to the mapper with a mutex. The added test drives concurrent
KindForthrough the real discovery machinery and fails under-racewithout 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:So we memoize successful
GVR→GVKlookups; 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_schemacache 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 withduplicate metrics collector registration attempted. SettingMetrics: falsebuilds 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/chaniare 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:AfterEachnow deletes the spec's namespace relationships so grants don't accumulate, and the list assertion is tightened to an exactConsistOf— 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 danglingcreatorgrant; it passes, confirming the dual-write rollback is correct. (Originally opened as #200, folded in here.)Testing
-racereproduction 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).