Skip to content

feat(cache): unified cache abstraction with in-memory and NATS KV backends#2952

Merged
migmartri merged 9 commits into
chainloop-dev:mainfrom
migmartri:feat/unified-cache-abstraction
Mar 28, 2026
Merged

feat(cache): unified cache abstraction with in-memory and NATS KV backends#2952
migmartri merged 9 commits into
chainloop-dev:mainfrom
migmartri:feat/unified-cache-abstraction

Conversation

@migmartri

Copy link
Copy Markdown
Member

Summary

  • Add new pkg/cache/ shared library with a generic Cache[T] interface, backed by in-memory LRU (default) or NATS JetStream KV (when configured)
  • Migrate all four existing ad-hoc caching mechanisms (membership cache, JWT claims cache, remote policy cache, remote policy group cache) to use the unified interface
  • Eliminate package-level global cache state by injecting cache instances through constructors and Wire
  • Fix unbounded growth in remote policy/group caches by adding 5-minute TTL expiration

Closes #2951

@migmartri migmartri requested review from Piskoo, javirln and jiparis and removed request for Piskoo and javirln March 28, 2026 11:53

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

7 issues found across 17 files

Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="pkg/cache/natskv_test.go">

<violation number="1" location="pkg/cache/natskv_test.go:106">
P2: Do not ignore `Get` errors in the purge test; assert them so backend failures can't be hidden as cache misses.</violation>
</file>

<file name="pkg/cache/natskv.go">

<violation number="1" location="pkg/cache/natskv.go:90">
P1: `sanitizeKey` introduces key-collision risk by mapping `:` to `.`, which can make distinct input keys resolve to the same NATS KV key.</violation>

<violation number="2" location="pkg/cache/natskv.go:99">
P2: These cache methods ignore the caller context and always use `context.Background()`, so cancellation/deadline propagation is lost.</violation>
</file>

<file name="pkg/cache/cache.go">

<violation number="1" location="pkg/cache/cache.go:86">
P1: Reject non-positive TTL values; currently negative TTLs are accepted and create invalid cache expiration behavior.</violation>

<violation number="2" location="pkg/cache/cache.go:94">
P1: Fail fast when NATS is configured without a bucket name instead of silently falling back to in-memory cache.</violation>
</file>

<file name="pkg/cache/memory.go">

<violation number="1" location="pkg/cache/memory.go:32">
P2: Using `NewLRU` with size `0` disables LRU eviction and creates an unlimited-size cache.</violation>
</file>

<file name="pkg/policies/policy_groups.go">

<violation number="1" location="pkg/policies/policy_groups.go:239">
P1: `opts.GroupCache` is passed without a nil fallback, which can panic when loading chainloop policy groups from call sites that don't set `GroupCache`.</violation>
</file>

Reply with feedback, questions, or to request a fix. Tag @cubic-dev-ai to re-run a review.

Comment thread pkg/cache/natskv.go Outdated
Comment thread pkg/cache/cache.go Outdated
Comment thread pkg/cache/cache.go Outdated
Comment thread pkg/policies/policy_groups.go Outdated
Comment thread pkg/cache/natskv_test.go Outdated
Comment thread pkg/cache/natskv.go Outdated
Comment thread pkg/cache/memory.go Outdated

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

1 issue found across 4 files (changes from recent commits).

Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="pkg/cache/natskv.go">

<violation number="1" location="pkg/cache/natskv.go:187">
P2: Do not silently discard per-key purge errors; at least log failures to avoid reporting a successful purge when keys were not removed.</violation>
</file>

Reply with feedback, questions, or to request a fix. Tag @cubic-dev-ai to re-run a review.

Comment thread pkg/cache/natskv.go Outdated
@migmartri

Copy link
Copy Markdown
Member Author

Code review

Found 4 issues:

  1. Nil pointer dereference in ChainloopGroupLoader.Load when GroupCache is nil. attestation_init.go calls LoadPolicyGroup without setting GroupCache, so ChainloopGroupLoader is created with a nil cache. c.cache.Get(ctx, ref) on the nil interface will panic at runtime for chainloop:// scheme policy group references. The server-side path correctly initializes groupCache when nil (in NewPolicyGroupVerifier), but the CLI call site was not updated.

if cached, ok, _ := c.cache.Get(ctx, ref); ok {
return cached.Group, cached.Reference, nil

contractMaterials := schema.GetMaterials()
for _, pgAtt := range schema.GetPolicyGroups() {
group, _, err := policies.LoadPolicyGroup(ctx, pgAtt, &policies.LoadPolicyGroupOptions{
Client: client,
Logger: logger,
})

  1. Regression: singleflight deduplication and bounded timeouts removed from policy/group loaders. PR feat(policies): parallelize policy evaluations with bounded concurrency #2901 (commit c7f0b9b7, merged to main after this branch diverged) added singleflight.Group with 30-second bounded timeouts to ChainloopLoader.Load and ChainloopGroupLoader.Load to prevent thundering-herd on concurrent gRPC fetches. The Cache[T] abstraction provides caching but not singleflight coalescing -- multiple goroutines can race past a cache miss and each issue separate gRPC calls. Branch needs rebasing on main.

cache cache.Cache[*policyWithReference]
}
type policyWithReference struct {
Policy *v1.Policy `json:"policy"`
Reference *PolicyDescriptor `json:"reference"`
}
func NewChainloopLoader(client pb.AttestationServiceClient, c cache.Cache[*policyWithReference]) *ChainloopLoader {
return &ChainloopLoader{Client: client, cache: c}
}
func (c *ChainloopLoader) Load(ctx context.Context, attachment *v1.PolicyAttachment) (*v1.Policy, *PolicyDescriptor, error) {
ref := attachment.GetRef()
if cached, ok, _ := c.cache.Get(ctx, ref); ok {
return cached.Policy, cached.Reference, nil
}
if !IsProviderScheme(ref) {
return nil, nil, fmt.Errorf("invalid policy reference %q", ref)
}
providerRef := ProviderParts(ref)
resp, err := c.Client.GetPolicy(ctx, &pb.AttestationServiceGetPolicyRequest{
Provider: providerRef.Provider,
PolicyName: providerRef.Name,
OrgName: providerRef.OrgName,
})
if err != nil {
return nil, nil, fmt.Errorf("requesting remote policy (provider: %s, name: %s): %w", providerRef.Provider, providerRef.Name, err)
}
h, err := crv1.NewHash(resp.Reference.GetDigest())
if err != nil {
return nil, nil, fmt.Errorf("parsing digest: %w", err)
}
orgName := providerRef.OrgName
// Extract organization name from URL if present
if u, err := url.Parse(resp.Reference.GetUrl()); err == nil {
if orgParam := u.Query().Get("org"); orgParam != "" {
orgName = orgParam
}
}
reference := policyReferenceResourceDescriptor(providerRef.Name, resp.Reference.GetUrl(), orgName, h)
// cache result
_ = c.cache.Set(ctx, ref, &policyWithReference{Policy: resp.GetPolicy(), Reference: reference})
return resp.GetPolicy(), reference, nil
}
func unmarshallResource(raw []byte, ref string, digest string, dest proto.Message) (*PolicyDescriptor, error) {

https://github.com/chainloop-dev/chainloop/blob/7a2e219e92d584f3e78e918d1955f41089abb908/pkg/policies/group_loader.go#L109-L157

  1. Regression: parallel policy evaluation (errgroup) reverted to sequential loops. PR feat(policies): parallelize policy evaluations with bounded concurrency #2901 added errgroup with bounded concurrency (max(runtime.NumCPU(), 5)) to parallelize policy evaluations in VerifyMaterial and VerifyStatement. This branch predates that merge, so evaluation is sequential. Additionally, VerifyStatement re-marshals the statement on every loop iteration instead of once before the loop.

for _, attachment := range attachments {
ev, err := pv.evaluatePolicyAttachment(ctx, attachment, subject,
&evalOpts{kind: material.MaterialType, name: material.GetId()},
)
if err != nil {
return nil, NewPolicyError(err)
}
if ev != nil {
result = append(result, ev)
}
}
return result, nil
}

result := make([]*v12.PolicyEvaluation, 0)
policies := pv.policies.GetAttestation()
for _, policyAtt := range policies {
material, err := protojson.Marshal(statement)
if err != nil {
return nil, NewPolicyError(err)
}
ev, err := pv.evaluatePolicyAttachment(ctx, policyAtt, material, &evalOpts{kind: v1.CraftingSchema_Material_ATTESTATION})
if err != nil {
return nil, NewPolicyError(err)
}
if ev != nil {
result = append(result, ev)
}
}
return result, nil
}

  1. Regression: concurrency_test.go will be dropped on merge. PR feat(policies): parallelize policy evaluations with bounded concurrency #2901 added pkg/policies/concurrency_test.go to exercise race conditions under concurrent policy evaluation. This file exists on main but not on this branch. Merging without rebasing removes that test coverage.

Note: Issues 2-4 share a root cause -- the branch diverged from main before PR #2901 was merged. Rebasing on main and integrating the cache abstraction with the singleflight, errgroup, and bounded-timeout patterns from #2901 should resolve all three.

…ckends

New pkg/cache/ shared library providing a generic Cache[T] interface
with two backends: in-memory LRU (default) and NATS JetStream KV.
Backend is auto-selected based on whether a NATS connection is provided.

Signed-off-by: Miguel Martinez Trivino <miguel@chainloop.dev>
Replace package-level membershipsCache variable with an injected
Cache[*entities.Membership] instance, wired through server.Opts.

Signed-off-by: Miguel Martinez Trivino <miguel@chainloop.dev>
Replace local expirable.LRU in attjwtmiddleware with an injected
Cache[*jwt.MapClaims] instance, wired through server.Opts.

Signed-off-by: Miguel Martinez Trivino <miguel@chainloop.dev>
Replace package-level map caches with injected Cache[T] instances in
ChainloopLoader and ChainloopGroupLoader. Default in-memory caches
with 5-minute TTL are created when no external cache is provided,
fixing the previous unbounded growth behavior.

Signed-off-by: Miguel Martinez Trivino <miguel@chainloop.dev>
- Use caller context instead of context.Background() in NATS KV ops
- Use NATS KV Purge (removes all revisions) instead of list+delete
- Replace panic with silent fallback in default claims cache creation
- Move GroupCache default creation to NewPolicyGroupVerifier only
- Add fail-open comment documenting graceful degradation strategy

Signed-off-by: Miguel Martinez Trivino <miguel@chainloop.dev>
Comment thread app/controlplane/internal/usercontext/attjwtmiddleware/attmiddleware.go Outdated
Comment thread pkg/policies/policies.go
- Make middleware cache errors fail-open (log + fall through)
- Consolidate duplicate timeout constants into remoteLoaderFetchTimeout
- Use bounded-timeout context for all cache ops inside singleflight
- Remove duplicate groupCache nil-guard in NewPolicyGroupVerifier
- Remove unnecessary comment in grpc.go middleware setup

Signed-off-by: Miguel Martinez Trivino <miguel@chainloop.dev>
@migmartri migmartri force-pushed the feat/unified-cache-abstraction branch from 7a2e219 to f6379cf Compare March 28, 2026 12:31
- Remove claimsCache fallback in attmiddleware (always wired via DI)
- Default groupCache in NewPolicyVerifier, remove redundant fallback
  from getGroupLoader
- Validate TTL > 0 and require bucket name when NATS is configured
- Use base64url encoding for NATS KV keys to avoid collisions
- Add default maxSize (1000) to in-memory LRU to bound growth
- Log purge errors instead of silently discarding them
- Assert Get errors in purge test

Signed-off-by: Miguel Martinez Trivino <miguel@chainloop.dev>

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

1 issue found across 7 files (changes from recent commits).

Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="pkg/policies/policy_groups.go">

<violation number="1" location="pkg/policies/policy_groups.go:269">
P1: Ensure a non-nil GroupCache is always provided. Passing nil into NewChainloopGroupLoader will panic when Load calls c.cache.Get/Set.</violation>
</file>

Reply with feedback, questions, or to request a fix. Tag @cubic-dev-ai to re-run a review.

Comment thread pkg/policies/policy_groups.go Outdated
LoadPolicyGroup is a public function called directly from the CLI
without going through PolicyVerifier, so opts.GroupCache can be nil.

Signed-off-by: Miguel Martinez Trivino <miguel@chainloop.dev>
Wire a logger into cache constructors so cache hits, misses, and
initialization are visible. Rename NATS KV buckets to use chainloop-
prefix for consistency with existing streams.

Signed-off-by: Miguel Martinez Trivino <miguel@chainloop.dev>
@migmartri migmartri merged commit fa49310 into chainloop-dev:main Mar 28, 2026
15 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.

Add unified cache infrastructure with NATS KV and in-memory LRU backends

2 participants