Feat/vault provider cache#1213
Conversation
Without caching, each secret ref in creds.yaml costs two Vault API calls: one preflight to /v1/sys/internal/ui/mounts/<path> to detect KV v1/v2, and one ReadWithData for the secret itself. This exhausts low use-limit tokens (e.g. -use-limit=1) immediately. With both caches: - kvVersionCache: one preflight per unique mount path per run - secretMapCache: one read per unique secret path per run For N secrets across M unique mounts under P unique paths, token use-limit needed drops from 2N to M+P (typically P+1 for single mount). Claude-Session: https://claude.ai/code/session_01RDWpvGwQQzRfWUYhohzhNk Signed-off-by: Sergio Rua <[email protected]>
secretMapCache is redundant with the runtime-level LRU cache in vals.go (docCache/strCache) which is already mutex-protected and bounded. Keeping it at the provider level caused two problems: 1. Stale data in long-running processes (vals-operator) — no eviction 2. Data race under concurrent access — plain map, no synchronisation kvVersionCache is kept because it caches mount-level KV version preflight calls, which the runtime cache does not cover. Mount KV versions are immutable at runtime so the cache never needs clearing. A sync.Mutex protects it for concurrent callers. Claude-Session: https://claude.ai/code/session_01RDWpvGwQQzRfWUYhohzhNk Signed-off-by: Sergio Rua <[email protected]>
372381d to
2ec5c18
Compare
The runtime-level docCache in vals.go does not prevent duplicate GetStringMap calls at the provider level — e.g. when multiple keys reference the same secret path. Without secretMapCache each such path costs one Vault read per key, exhausting low use-limit tokens. Both caches are now protected by a single sync.Mutex making them safe for concurrent callers (e.g. vals-operator). Claude-Session: https://claude.ai/code/session_01RDWpvGwQQzRfWUYhohzhNk Signed-off-by: Sergio Rua <[email protected]>
yxxhero
left a comment
There was a problem hiding this comment.
Review — Feat/vault provider cache
Caches Vault KV-version preflight and secret-map reads to cut API calls (motivation: tokens with low use-limit, #1204). The intent is good and the KV-version cache is sound, but the secret-map cache has real correctness bugs and the concurrency story is incomplete. I would not merge as-is.
Blocking issues
1. Thundering herd defeats the PR's purpose. In GetStringMap, the lock is released between the cache-miss check and population:
cachedVer, verHit := p.kvVersionCache[key]
p.mu.Unlock() // ← released
...
mountPath, v2, err = isKVv2(key, cli) // expensive call, no lock
...
p.mu.Lock(); p.kvVersionCache[key] = ... // populateUnder the exact scenario the PR claims to fix — concurrent callers in vals-operator — N goroutines requesting the same uncached path each fire the Vault call, burning N token uses instead of 1. The mutex prevents map races but not duplicate work. The same pattern applies to secretMapCache. Fix with golang.org/x/sync/singleflight keyed by path (dedupes in-flight calls) — this is the idiomatic fix and also removes the need for the manual lock-around-map dance.
2. Cached map is returned by reference. The code stores res in the cache and then returns that same map to the caller:
res := make(map[string]interface{}, len(secrets))
...
p.mu.Lock(); p.secretMapCache[key] = res; p.mu.Unlock()
return res, nilThe original code returned a fresh map per call. Now any caller that mutates the returned map corrupts the cache for every subsequent caller. GetString only reads, but GetStringMap is a public API method. Copy on the way out (or store a copy and return the original).
3. secretMapCache never evicts → stale secrets forever. The author removed this cache in commit 2 for exactly this reason ("Stale data in long-running processes (vals-operator) — no eviction"), then re-added it in commit 3 without addressing eviction. In a long-running operator, rotated secrets will never be re-read. Either add a TTL, drop secretMapCache and keep only kvVersionCache (which is genuinely immutable at runtime and safe to cache forever), or document that the provider is single-run-only.
Should fix
4. kvVersionCache is keyed by full path, not mount. isKVv2 resolves at the mount level, so secret/foo/bar and secret/foo/baz each get separate cache entries and each trigger a preflight, even though they share mount secret/. The PR body claims "one preflight per unique mount" — to actually deliver that, key the cache by mount path (extract it before the lookup) rather than the full secret path.
5. No tests. The package has zero *_test.go files and this is a concurrency-sensitive change. Even a small test with a stubbed client asserting call-counts before/after would lock in the behavior and catch regressions of issues #1–#3.
6. ensureClient is still unsynchronized. Not introduced here, but since the PR's stated motivation is concurrency safety for vals-operator, the client-create/auth path (including cli.SetToken from approle/kubernetes/userpass) races on first concurrent use. Worth fixing in the same pass or explicitly scoping out.
Nits
7. The //nolint:govet // fieldalignment: unsafe.Sizeof confirms no padding waste; tool reports a false discrepancy comment on provider is hand-wavy — fieldalignment is generally correct and sync.Mutex placement does matter. The 8-byte saving isn't worth the churn or the suppressed linter; recommend reverting the reorder.
8. PR body is stale — it still describes the secretMapCache add/remove/re-add saga and the byte-shaving claim, which don't reflect the final commit. Trim to describe the final state.
9. The merged doc-block above the two cache fields would read better as one comment per field.
What's good
kvVersionResultstruct, thereadKeyrename (avoids mutating thekeyparameter in the v2 branch — a small latent bug fix), and themake(map, len(secrets))pre-allocation are clean.- Single mutex for both caches is fine for correctness; the critique above is about what's done under the lock, not the lock granularity.
Recommendation
Request changes. The minimal mergeable version would be: (a) switch to singleflight for dedup, (b) copy the map before returning, (c) drop secretMapCache or add eviction. Issues #4–#6 are improvements that can follow.
Address PR review (helmfile#1213): - Replace the never-evicting secretMapCache with singleflight dedup of concurrent reads, so a burst of parallel callers (vals-operator) costs one Vault read/token-use instead of N, without ever serving stale secrets (#1, helmfile#2, helmfile#3). - Return a per-caller copy of the map since singleflight shares one result by reference (helmfile#2). - Key the KV-version cache by mount path so sibling secrets under the same mount share a single preflight (helmfile#4). - Serialize lazy client creation/auth in ensureClient to remove the first-use race (helmfile#6). - Add concurrency tests asserting preflight/read call counts and map isolation against a stubbed Vault server (helmfile#5). - Drop the hand-wavy fieldalignment nolint; order fields cleanly (helmfile#7). Signed-off-by: Sergio Rua <[email protected]>
|
Thanks for the thorough review @yxxhero — addressed in f99c6c7: Blocking
Nits PR description updated accordingly. |
yxxhero
left a comment
There was a problem hiding this comment.
Review (round 2) — much closer
The revised commit (f99c6c74) addresses everything from the first pass cleanly:
- ✅ #1 thundering herd →
singleflightfor reads and preflights - ✅ #2 shared map by reference → per-caller copy in
GetStringMap - ✅ #3 stale secret cache →
secretMapCachedropped entirely; reads always re-hit Vault - ✅ #4 mount-keyed preflight → cache keyed by
mountPathwith prefix lookup for siblings - ✅ #5 tests → concurrency dedup, copy isolation, per-mount caching, KVv1,
GetString - ✅ #6
ensureClientrace →clientMuserializes creation/auth - ✅ #7 nolint/fieldalignment churn → dropped
This is the right shape now. One correctness-adjacent hardening item and a couple of minor notes below; none are blockers in my view.
Worth addressing
A. lookupKVVersion prefix match assumes the cached mount key ends with /.
for mount, res := range p.kvVersionCache {
if mount == "" { continue }
if key == strings.TrimSuffix(mount, "/") || strings.HasPrefix(key, mount) {
return res, true
}
}This is segment-safe iff every cached mount ends in / (so HasPrefix("secretv2/x", "secret/") is false). Vault's sys/internal/ui/mounts does return trailing-slash paths in practice, so today this is fine — but the code doesn't enforce the invariant. If a mount is ever cached without the slash, HasPrefix("secretv2/x", "secret") is true and you can match the wrong mount → v1/v2 misclassification → wrong read path. Two cheap options:
- Normalize on insert:
if !strings.HasSuffix(cacheKey, "/") { cacheKey += "/" }(and drop thecacheKey == ""fallback ontokeyonly when truly empty), or - Match segment-aware:
strings.HasPrefix(key+"/", mount).
Either makes the assumption explicit instead of implicit. A small test with two mounts sharing a textual prefix (secret/ + secretv2/) would lock it in.
B. Concurrent first-burst of sibling paths under one mount still fires N preflights. sfVersion.Do is keyed by the full path, so simultaneous first-access of secret/app1 + secret/app2 (different keys) is not deduped — each runs isKVv2, then the mount cache is warm for subsequent calls. Sequential (covered by TestPreflightCachedPerMount) and same-path-concurrent (covered by TestGetStringMap_ConcurrentDedup) are both correct. This is bounded and only affects the very first burst, so it's minor — but worth a line in the PR body so the "one preflight per mount" claim isn't read as a strict guarantee under concurrency. Keying sfVersion by a mount guess is hard (you don't know the mount yet), so I wouldn't contort the code for it; just document.
Minor / nits
C. Shallow copy. The per-caller copy in GetStringMap is shallow — nested map/slice values are still shared across callers. Same as the original behavior and fine for the typical string-valued KV secret; TestGetStringMap_ReturnsIndependentCopies only exercises top-level keys. Worth a one-line comment noting the copy is shallow so future readers don't assume deep isolation.
D. Double copy. readSecretMap builds a res map and returns it as the singleflight result; GetStringMap then copies it again per caller. The inner copy is redundant — readSecretMap could return secrets directly and let GetStringMap own the only copy. Pure micro-cleanup, not important.
E. clientMu is acquired on every GetStringMap (via ensureClient) even after the client exists, serializing the nil-check. Negligible cost; a fast-path if p.client != nil { return p.client, nil } before the lock would avoid it but is optional.
Verdict
The blockers are gone. I'd approve after A is addressed (normalization + a sibling-mount-prefix test) since that's the only thing touching correctness; B–E can be follow-ups or ignore.
Normalize the kvVersionCache key to a trailing slash on insert and match siblings with HasPrefix(key+"/", mount) so a cached mount can never match a textually-prefixed but distinct mount (e.g. "secret/" vs "secretv2/x"). Empty-mount fallbacks stay full secret paths and are only ever matched exactly, never by prefix. Also: drop the redundant inner map copy in readSecretMap (GetStringMap owns the per-caller copy), note the per-caller copy is shallow, and document that sfVersion does not dedupe a concurrent first-burst of distinct sibling paths. Add tests for the segment-safe prefix match and for mount-key normalization when Vault reports a mount without a trailing slash. Signed-off-by: Sergio Rua <[email protected]>
|
Thanks for the round-2 pass. Addressed in A — segment-safe mount lookup ✅ Normalized the One note for the record: this was hardening, not a live bug — Vault's B — first-burst preflights ✅ Documented on the C — shallow copy ✅ Noted in the D — double copy ✅ E — Tests pass with Assisted-by: Claude Code |
Signed-off-by: Sergio Rua <[email protected]>
yxxhero
left a comment
There was a problem hiding this comment.
Review (round 3) — approving
Round-2 items are all addressed in 3c6ff8b7 / ba697a80:
- ✅ A. Segment-safe prefix match. Cache key is normalized to a trailing slash on insert (
if !strings.HasSuffix(cacheKey, "/") { cacheKey += "/" }), andlookupKVVersionnow matches viastrings.HasPrefix(key+"/", mount)while skipping non-slash entries (the empty-mount fallback is exact-match only). Confirmed correct against the sibling-mount case:secret/servessecretandsecret/foobut notsecretv2/x. Locked in byTestLookupKVVersion_SiblingMountPrefixandTestPreflightNormalizesMountWithoutSlash. - ✅ B.
sfVersionfirst-burst-of-sibling-paths limitation documented in both the struct comment and the PR body. - ✅ C. Shallow-copy caveat noted inline in
GetStringMap. - ✅ D. Redundant inner copy dropped —
readSecretMapreturnssecretsdirectly;GetStringMapowns the single per-caller copy. - E. (
clientMuon every call) left as-is, which is fine — negligible.
Local verification (checked out pr-1213)
go vet ./pkg/providers/vault/... # clean
gofmt -l pkg/providers/vault/ # no diffs
go test -race -count=1 ./pkg/providers/vault/... # ok 1.665s
The -race run passing is the meaningful signal here — it empirically validates the singleflight + dual-mutex design under the concurrent access the issue is about.
Nice iteration. Ship it.
Summary
isKVv2preflight results, keyed by mount path, so every secret under a mount shares a singleGET /v1/sys/internal/ui/mounts/<path>preflight. Mount KV versions are immutable at runtime, so this cache is safe to keep for the provider's lifetime.golang.org/x/sync/singleflight: a burst of parallel callers for the same path (the vals-operator case) collapses into a singleReadWithData, spending one token use instead of one per goroutine. Reads are not cached between calls, so rotated secrets are always picked up on the next read.GetStringMapis a public API).ensureClientso concurrent first callers don't race while building the client / running the login flow.Motivation
Each secret reference in a
vals-processed YAML file previously cost two Vault API calls:GET /v1/sys/internal/ui/mounts/<path>— KV version preflightReadWithData— secret readFor N secrets that meant up to 2N token uses, which breaks Vault tokens created with a low
-use-limit. The original concern (#1204) is concurrent resolution in vals-operator, where N goroutines requesting the same uncached path would each fire the preflight + read.After this change:
sfVersionis keyed by full path, so a concurrent first-burst of distinct sibling paths under one mount can fire one preflight each before the mount cache warms; sequential and same-path-concurrent access dedupe to one per mount.Why no persistent secret cache
An earlier revision cached
GetStringMapresults for the provider's lifetime. That was dropped: in a long-running operator it would serve stale secrets after rotation, with no eviction.singleflightdelivers the concurrency win the issue actually asks for without the staleness risk.Closes #1204