Skip to content

Feat/vault provider cache#1213

Merged
yxxhero merged 6 commits into
helmfile:mainfrom
digitalis-io:feat/vault-provider-cache
Jun 30, 2026
Merged

Feat/vault provider cache#1213
yxxhero merged 6 commits into
helmfile:mainfrom
digitalis-io:feat/vault-provider-cache

Conversation

@digiserg

@digiserg digiserg commented Jun 30, 2026

Copy link
Copy Markdown
Contributor

Summary

  • Cache isKVv2 preflight results, keyed by mount path, so every secret under a mount shares a single GET /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.
  • Dedup concurrent secret reads with golang.org/x/sync/singleflight: a burst of parallel callers for the same path (the vals-operator case) collapses into a single ReadWithData, 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.
  • Return a per-caller copy of the secret map (the singleflight result is shared by reference, and GetStringMap is a public API).
  • Serialize lazy client creation/authentication in ensureClient so concurrent first callers don't race while building the client / running the login flow.
  • Add concurrency tests against a stubbed Vault server asserting preflight/read call counts and map isolation.

Motivation

Each secret reference in a vals-processed YAML file previously cost two Vault API calls:

  1. GET /v1/sys/internal/ui/mounts/<path> — KV version preflight
  2. ReadWithData — secret read

For 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:

  • Preflight: one per unique mount (cached for the provider lifetime). Note sfVersion is 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.
  • Reads: concurrent requests for the same path dedup to one; sequential requests still re-read Vault (no stale secrets).

Why no persistent secret cache

An earlier revision cached GetStringMap results for the provider's lifetime. That was dropped: in a long-running operator it would serve stale secrets after rotation, with no eviction. singleflight delivers the concurrency win the issue actually asks for without the staleness risk.

Closes #1204

digiserg added 2 commits June 30, 2026 12:02
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]>
@digiserg digiserg force-pushed the feat/vault-provider-cache branch from 372381d to 2ec5c18 Compare June 30, 2026 11:06
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 yxxhero left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

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] = ... // populate

Under 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, nil

The 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

  • kvVersionResult struct, the readKey rename (avoids mutating the key parameter in the v2 branch — a small latent bug fix), and the make(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]>
@digiserg

Copy link
Copy Markdown
Contributor Author

Thanks for the thorough review @yxxhero — addressed in f99c6c7:

Blocking

  1. Thundering herdGetStringMap now wraps the read in singleflight.Group.Do keyed by path, and the preflight in a second group. N concurrent callers for an uncached path now produce one preflight + one read. The lock-around-map dance is gone.
  2. Map returned by reference — singleflight shares one result among the flight, so GetStringMap returns a per-caller copy.
  3. secretMapCache staleness — dropped entirely. Reads are no longer cached between calls, so rotated secrets are always re-read. singleflight gives the concurrency win without the eviction problem.
  4. Preflight keyed by full pathkvVersionCache is now keyed by mount path; lookupKVVersion matches a cached mount as a prefix of the requested path, so sibling secrets share one preflight. Falls back to full-path keying when Vault returns no mount (older servers).
  5. No tests — added vault_test.go: an httptest Vault stub with preflight/read counters asserting concurrent dedup (1 preflight + 1 read for 20 goroutines), per-mount preflight reuse, map-copy isolation, and KV v1/v2 + GetString. Runs under -race.
  6. ensureClient unsynchronized — now guarded by a dedicated mutex (double-checked) so the create/auth/SetToken path doesn't race on first concurrent use.

Nits
7. Reverted the alignment churn — dropped the hand-wavy //nolint; fields are ordered cleanly and fieldalignment passes without suppression.
8. PR body rewritten to describe the final state.
9. Cache fields now carry one comment each.

PR description updated accordingly.

@yxxhero yxxhero left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Review (round 2) — much closer

The revised commit (f99c6c74) addresses everything from the first pass cleanly:

  • #1 thundering herd → singleflight for reads and preflights
  • #2 shared map by reference → per-caller copy in GetStringMap
  • #3 stale secret cache → secretMapCache dropped entirely; reads always re-hit Vault
  • #4 mount-keyed preflight → cache keyed by mountPath with prefix lookup for siblings
  • #5 tests → concurrency dedup, copy isolation, per-mount caching, KVv1, GetString
  • #6 ensureClient race → clientMu serializes 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 the cacheKey == "" fallback onto key only 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]>
@digiserg

Copy link
Copy Markdown
Contributor Author

Thanks for the round-2 pass. Addressed in 3c6ff8b:

A — segment-safe mount lookup ✅ Normalized the kvVersionCache key to a trailing slash on insert and changed the sibling match to strings.HasPrefix(key+"/", mount), dropping the TrimSuffix branch. Only trailing-slash mount entries are prefix-matched now; the empty-mount fallback stays a full secret path and is matched exactly, never by prefix — so secret/ can no longer match secretv2/x. Added two tests: TestLookupKVVersion_SiblingMountPrefix (locks the secret/ vs secretv2/ case + mount-root + unrelated mount) and TestPreflightNormalizesMountWithoutSlash (proves normalization when Vault reports a mount without a trailing slash).

One note for the record: this was hardening, not a live bug — Vault's sys/internal/ui/mounts always returns path with a trailing slash, and the only non-slash cache entries were full-path empty-mount fallbacks (which carry the secret name and can't prefix-match an unrelated sibling). The change makes the invariant explicit rather than implicit, which is the right call regardless.

B — first-burst preflights ✅ Documented on the sfVersion field: keyed by full path, so a concurrent first-burst of distinct sibling paths under one mount isn't deduped (each runs one preflight), after which the mount cache serves the rest. PR body updated so "one preflight per mount" isn't read as a strict concurrency guarantee.

C — shallow copy ✅ Noted in the GetStringMap comment that the per-caller copy is shallow.

D — double copy ✅ readSecretMap now returns the map directly; GetStringMap owns the only copy.

E — clientMu fast-path — declining. A if p.client != nil { return } ahead of the lock reads p.client unsynchronized while ensureClient writes it, which the race detector flags. Making it safe needs atomic.Pointer, which isn't worth it for a nil-check on a path that isn't hot. Keeping the always-lock version, which is correct.

Tests pass with -race (7 total). Note: this repo doesn't keep a CHANGELOG, so nothing to update there.

Assisted-by: Claude Code

@yxxhero yxxhero left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

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 += "/" }), and lookupKVVersion now matches via strings.HasPrefix(key+"/", mount) while skipping non-slash entries (the empty-mount fallback is exact-match only). Confirmed correct against the sibling-mount case: secret/ serves secret and secret/foo but not secretv2/x. Locked in by TestLookupKVVersion_SiblingMountPrefix and TestPreflightNormalizesMountWithoutSlash.
  • B. sfVersion first-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 — readSecretMap returns secrets directly; GetStringMap owns the single per-caller copy.
  • E. (clientMu on 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.

@yxxhero yxxhero merged commit d4d03fe into helmfile:main Jun 30, 2026
5 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Vault token with use-limit

2 participants