Skip to content

fix(xpkg): prevent corrupt package cache entries - #1112

Open
vcatlassian wants to merge 2 commits into
crossplane:mainfrom
vcatlassian:vchen2/fix-package-cache-corruption
Open

fix(xpkg): prevent corrupt package cache entries#1112
vcatlassian wants to merge 2 commits into
crossplane:mainfrom
vcatlassian:vchen2/fix-package-cache-corruption

Conversation

@vcatlassian

@vcatlassian vcatlassian commented Aug 10, 2026

Copy link
Copy Markdown

Problem

CachedClient.Get starts writing fetched package content to the cache in a goroutine, but it returns as soon as parsing completes and ignores the Store result. If the cache write fails (for example, when the volume is full), FsPackageCache.Store leaves the truncated gzip file at the final cache path. A later reconcile can then parse that incomplete entry instead of fetching the package again.

FsPackageCache.Get also releases its read lock immediately after opening the file, so a concurrent Store can truncate the file while it is still being read.

Fix

  • Wait for the cache writer before returning from CachedClient.Get.
  • Drain the cache pipe if Store returns early, so a cache failure does not interrupt parsing valid registry content.
  • Remove failed direct writes before releasing the cache lock.
  • Hold the cache read lock until the returned reader is closed.
  • Delete and refetch corrupt entries for pull-enabled policies.
  • Preserve corrupt entries and return the underlying error for PullNever.

This keeps the existing direct-write cache format and does not require a second package-sized temporary file.

Behavior change

A successful registry fetch can complete even when caching fails, and the failed write no longer remains as a cache hit. PullNever continues to treat operator-provided cache content as authoritative, so Crossplane reports the cache error without deleting or fetching the package.

Validation

  • ./nix.sh flake check - all checks passed
  • go test -race ./pkg/xpkg
  • go test -race ./pkg/xpkg -run 'Test(StoreWaitsForReader|GetErrorReleasesLock|ClientGetWaitsForCacheStore|ClientGetIgnoresCacheStoreFailure)$' -count=100
  • go test ./apis/... ./pkg/...
  • golangci-lint run - 0 issues

Related to crossplane/crossplane#7712.

I have:

Signed-off-by: Victor Chen <vchen2@atlassian.com>
@vcatlassian
vcatlassian requested a review from a team as a code owner August 10, 2026 12:04
@vcatlassian
vcatlassian requested a review from jbw976 August 10, 2026 12:04
@coderabbitai

coderabbitai Bot commented Aug 10, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 8bbc3147-2b3a-403e-b043-0790aaecd881

📥 Commits

Reviewing files that changed from the base of the PR and between 9afd871 and c16f7fb.

📒 Files selected for processing (2)
  • pkg/xpkg/cache.go
  • pkg/xpkg/cache_test.go
🚧 Files skipped from review as they are similar to previous changes (2)
  • pkg/xpkg/cache.go
  • pkg/xpkg/cache_test.go

📝 Walkthrough

Walkthrough

FsPackageCache now protects active readers and cleans up failed writes. CachedClient now handles corrupt cache entries, pull policies, store synchronization, and cache cleanup. Tests cover locking, failure paths, refetching, and digest retrieval.

Changes

Package cache consistency

Layer / File(s) Summary
Cache locking and storage cleanup
pkg/xpkg/cache.go, pkg/xpkg/cache_test.go
FsPackageCache holds read locks until readers close and removes partial files after storage failures. Tests cover round trips, injected errors, lock release, and concurrent stores.
Cached client retrieval and storage flow
pkg/xpkg/client.go, pkg/xpkg/client_test.go
CachedClient parses size-limited cache content, applies pull policies, waits for cache stores, and deletes invalid entries. Tests cover corrupt caches, store failures, digest retrieval, and parse failures.

Estimated code review effort: 4 (Complex) | ~45 minutes

Sequence Diagram(s)

sequenceDiagram
  participant CachedClient
  participant PackageCache
  participant PackageParser
  participant Registry
  CachedClient->>PackageCache: Read cached package
  PackageCache->>PackageParser: Parse size-limited content
  alt Cache is valid
    PackageParser-->>CachedClient: Return package
  else Cache is invalid and pull policy allows fetch
    CachedClient->>PackageCache: Delete invalid entry
    CachedClient->>Registry: Fetch package
    Registry-->>CachedClient: Return package content
    CachedClient->>PackageCache: Store package and wait for result
  else Pull policy is Never
    PackageParser-->>CachedClient: Return cache error
  end
Loading

Possibly related PRs

Suggested reviewers: jbw976


Important

Pre-merge checks failed

Please resolve all errors before merging. Addressing warnings is optional.

❌ Failed checks (1 error)

Check name Status Explanation Resolution
Breaking Changes ❌ Error Public FsPackageCache.Get now holds a lock until Close, and CachedClient.Get waits for Store; callers can block where they did not before. No breaking-change label is present. Add the breaking-change label and document the required reader Close and synchronous cache-store wait for existing PackageCache and CachedClient users.
✅ Passed checks (4 passed)
Check name Status Explanation
Description check ✅ Passed The description clearly explains the package cache corruption problem, the implemented fixes, behavior changes, and validation performed.
Title check ✅ Passed The title is 48 characters, describes the package cache corruption fix, and stays under the 72-character limit.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot 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.

Actionable comments posted: 1

🧹 Nitpick comments (2)
pkg/xpkg/cache_test.go (2)

328-344: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Consider deriving the cache file path from BuildPath instead of hardcoding .gz.

Nice targeted coverage of the new RUnlock branch on the gzip failure path. Thank you for adding it.

One durability concern for the test itself. The path /cache/package.gz hardcodes cacheContentExt. If that constant ever changes, Get will open a different path, fail with a not-exist error instead of a gzip error, and this test will still pass. The lock-release assertion would then no longer exercise the gzip branch it was written for, and nothing would signal the loss.

Would you consider building the path the same way the cache does?

♻️ Proposed change to track `cacheContentExt`
 func TestGetErrorReleasesLock(t *testing.T) {
 	fs := afero.NewMemMapFs()
-	f, err := fs.Create("/cache/package.gz")
+	f, err := fs.Create(BuildPath("/cache", "package", cacheContentExt))
 	if err != nil {
 		t.Fatalf("Create(...): unexpected error: %v", err)
 	}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@pkg/xpkg/cache_test.go` around lines 328 - 344, Update
TestGetErrorReleasesLock to derive the created cache file path using the cache’s
BuildPath behavior rather than hardcoding “/cache/package.gz”. Keep the invalid
gzip contents and existing Get assertion unchanged so the test continues
exercising the gzip failure path even if cacheContentExt changes.

226-247: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Consider adding reason fields, and a table structure where scenarios group naturally.

These tests cover the right behaviors. TestClientGetIgnoresCacheStoreFailure is particularly good, because its MockStore returns without draining rc, which is exactly the early-exit case the new pipe drain in client.go fixes. That test would hang without the fix.

The repository convention for *_test.go is a table-driven structure with args/want and a reason field per case. TestStore at pkg/xpkg/cache_test.go lines 186-224 follows it. The new tests do not. I recognize the concurrency and lifecycle tests do not fit a table cleanly, so I am not suggesting you force them. The reason documentation is the part that carries most of the value here.

  • pkg/xpkg/cache_test.go#L226-L247: add a short comment or reason string stating the behavior under test, that Store then Get returns identical bytes.
  • pkg/xpkg/cache_test.go#L249-L280: this case is already table-driven. Add a reason field to the case struct and populate it for ContentReadError, GzipCloseError, and FileCloseError.
  • pkg/xpkg/cache_test.go#L282-L326: add a reason describing that Store must wait for an open reader to close.
  • pkg/xpkg/cache_test.go#L328-L359: add a reason describing that a failed Get must release the read lock.
  • pkg/xpkg/client_test.go#L1122-L1161: add a reason describing that Get must not return before Store completes.
  • pkg/xpkg/client_test.go#L1163-L1190: add a reason describing that a Store failure must not fail Get, and must delete the entry.
  • pkg/xpkg/client_test.go#L1192-L1220: add a reason describing that a corrupt entry is deleted and refetched under PullIfNotPresent.
  • pkg/xpkg/client_test.go#L1222-L1259: add a reason describing that PullNever preserves the entry and returns the cache error. Consider cmp.Diff with cmpopts.EquateErrors() against a sentinel instead of strings.Contains on the message.
  • pkg/xpkg/client_test.go#L1261-L1285: add a reason describing that a digest reference under PullNever performs no registry call.

TestClientGetRefetchesCorruptCache and TestClientGetPreservesCorruptCacheWithPullNever differ only by pull policy and expected outcome. Would you consider merging those two into one table keyed on pullPolicy?

As per path instructions for **/*_test.go: "Enforce table-driven test structure: PascalCase test names (no underscores), args/want pattern, use cmp.Diff with cmpopts.EquateErrors() for error testing. Check for proper test case naming and reason fields."

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@pkg/xpkg/cache_test.go` around lines 226 - 247, Add reason documentation to
the affected tests: pkg/xpkg/cache_test.go:226-247 for Store/Get byte identity,
249-280 for each read/close error case, 282-326 for waiting on an open reader,
and 328-359 for releasing the read lock after failed Get;
pkg/xpkg/client_test.go:1122-1161 for waiting for Store, 1163-1190 for ignoring
failed Store and deleting the entry, 1192-1220 for refetching corrupt cache
entries, 1222-1259 for preserving entries and returning the cache error under
PullNever, and 1261-1285 for avoiding registry calls for digest references under
PullNever. Keep or convert applicable cases to the repository’s table-driven
args/want structure, use cmp.Diff with cmpopts.EquateErrors() for error
comparisons, and merge the two corrupt-cache client tests into one
pull-policy-keyed table while preserving their distinct expected outcomes.

Source: Path instructions

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@pkg/xpkg/cache.go`:
- Around line 90-110: Document the read-lock lifecycle on the exported
PackageCache.Get and FsPackageCache.Get APIs: callers must close the returned
io.ReadCloser to release the cache read lock, and failing to do so can block
subsequent Store and Delete calls. Explicitly mark this as a breaking change if
the project’s API documentation supports such annotations, since the lock now
remains held until the reader is closed.

---

Nitpick comments:
In `@pkg/xpkg/cache_test.go`:
- Around line 328-344: Update TestGetErrorReleasesLock to derive the created
cache file path using the cache’s BuildPath behavior rather than hardcoding
“/cache/package.gz”. Keep the invalid gzip contents and existing Get assertion
unchanged so the test continues exercising the gzip failure path even if
cacheContentExt changes.
- Around line 226-247: Add reason documentation to the affected tests:
pkg/xpkg/cache_test.go:226-247 for Store/Get byte identity, 249-280 for each
read/close error case, 282-326 for waiting on an open reader, and 328-359 for
releasing the read lock after failed Get; pkg/xpkg/client_test.go:1122-1161 for
waiting for Store, 1163-1190 for ignoring failed Store and deleting the entry,
1192-1220 for refetching corrupt cache entries, 1222-1259 for preserving entries
and returning the cache error under PullNever, and 1261-1285 for avoiding
registry calls for digest references under PullNever. Keep or convert applicable
cases to the repository’s table-driven args/want structure, use cmp.Diff with
cmpopts.EquateErrors() for error comparisons, and merge the two corrupt-cache
client tests into one pull-policy-keyed table while preserving their distinct
expected outcomes.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: b214392b-2091-4d46-a3ee-44e557c52143

📥 Commits

Reviewing files that changed from the base of the PR and between 1280e79 and 9afd871.

📒 Files selected for processing (4)
  • pkg/xpkg/cache.go
  • pkg/xpkg/cache_test.go
  • pkg/xpkg/client.go
  • pkg/xpkg/client_test.go

Comment thread pkg/xpkg/cache.go
Comment on lines 90 to 110
func (c *FsPackageCache) Get(id string) (io.ReadCloser, error) {
c.mu.RLock()
defer c.mu.RUnlock()

f, err := c.fs.Open(BuildPath(c.dir, id, cacheContentExt))
if err != nil {
c.mu.RUnlock()
return nil, err
}

return GzipReadCloser(f)
r, err := GzipReadCloser(f)
if err != nil {
_ = f.Close()
c.mu.RUnlock()
return nil, err
}

return &unlockingReadCloser{
ReadCloser: r,
unlock: c.mu.RUnlock,
}, nil
}

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.

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Description: Inspect the Get doc comment and locate all callers of PackageCache.Get.
set -euo pipefail

echo "=== cache.go Get doc comment ==="
sed -n '85,112p' pkg/xpkg/cache.go

echo "=== PackageCache interface definition ==="
ast-grep run --pattern 'type PackageCache interface { $$$ }' --lang go pkg/xpkg/cache.go

echo "=== callers of .cache.Get / cache.Get across repo ==="
rg -nP --type=go -C4 '\bcache\.Get\s*\(' -g '!**/vendor/**'

echo "=== FsPackageCache construction sites ==="
rg -nP --type=go -C3 '\bNewFsPackageCache\s*\('

Repository: crossplane/crossplane-runtime

Length of output: 1087


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "=== File locations ==="
git ls-files | rg '(^|/)(cache\.go|reader\.go|client\.go)$|_test\.go$' | sort | sed -n '1,120p'

echo "=== cache.go relevant outline ==="
ast-grep outline pkg/xpkg/cache.go --view expanded || true

echo "=== cache.go full relevant section ==="
sed -n '1,150p' pkg/xpkg/cache.go

echo "=== reader.go relevant GzipReadCloser ==="
sed -n '1,180p' pkg/xpkg/reader.go

echo "=== calls to Get methods in Go files ==="
rg -nP --type=go -C3 '\.Get\s*\(|\.get\s*\(' | head -200 || true

echo "=== FsPackageCache construction ==="
rg -nP --type=go -C3 '\bNewFsPackageCache\s*\(' | head -200 || true

echo "=== repo package metadata / breaking label references ==="
git ls-files | rg '(\.github|breaking|CHANGELOG|README|.*\.md)$' | xargs -r rg -n "breaking-change|breaking" | head -200 || true

Repository: crossplane/crossplane-runtime

Length of output: 9250


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "=== xpkg client cache usage ==="
sed -n '1,260p' pkg/xpkg/client.go

echo "=== cache tests around Get ==="
sed -n '1,260p' pkg/xpkg/cache_test.go

echo "=== all PackageCache references (read-only) ==="
rg -nP --type=go -C3 '\bPackageCache\b|\bNewFsPackageCache\s*\(' | head -200

echo "=== exported xpkg symbols in cache/client modules ==="
python3 - <<'PY'
from pathlib import Path
for p in ['pkg/xpkg/cache.go','pkg/xpkg/client.go']:
    print(f'--- {p} ---')
    data=Path(p).read_text()
    for i,line in enumerate(data.splitlines(),1):
        if line.startswith(('type ','Func','func ')) and i>=60 and i<=130:
            print(f'{i}: {line}')
PY

Repository: crossplane/crossplane-runtime

Length of output: 14137


Document the cache read-lock lifecycle on PackageCache.Get / FsPackageCache.Get.

Get now returns an io.ReadCloser that releases the cache RUnlock only when closed. Add a doc requirement such as: the caller must close the returned reader to release the cache read lock; leaking it can block later Store and Delete calls. Also note whether this counts as a breaking-change because the lock lifetime changed from the previous exported API behavior.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@pkg/xpkg/cache.go` around lines 90 - 110, Document the read-lock lifecycle on
the exported PackageCache.Get and FsPackageCache.Get APIs: callers must close
the returned io.ReadCloser to release the cache read lock, and failing to do so
can block subsequent Store and Delete calls. Explicitly mark this as a breaking
change if the project’s API documentation supports such annotations, since the
lock now remains held until the reader is closed.

Source: Coding guidelines

Signed-off-by: Victor Chen <vchen2@atlassian.com>
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.

1 participant