-
Notifications
You must be signed in to change notification settings - Fork 2.9k
GHProxy: Cleanup old caches #23621
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
GHProxy: Cleanup old caches #23621
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -27,12 +27,20 @@ package ghcache | |
|
|
||
| import ( | ||
| "context" | ||
| "encoding/json" | ||
| "fmt" | ||
| "io/ioutil" | ||
| "net/http" | ||
| "os" | ||
| "path" | ||
| "path/filepath" | ||
| "strconv" | ||
| "strings" | ||
| "time" | ||
|
|
||
| metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" | ||
| utilerrors "k8s.io/apimachinery/pkg/util/errors" | ||
|
|
||
| "github.com/gomodule/redigo/redis" | ||
| "github.com/gregjones/httpcache" | ||
| "github.com/gregjones/httpcache/diskcache" | ||
|
|
@@ -66,6 +74,10 @@ const ( | |
| // which metrics should be recorded if set. If unset, the sha256sum of | ||
| // the Authorization header will be used. | ||
| TokenBudgetIdentifierHeader = "X-PROW-GHCACHE-TOKEN-BUDGET-IDENTIFIER" | ||
|
|
||
| // TokenExpiryAtHeader includes a date at which the passed token expires and all associated caches | ||
| // can be cleaned up. It's value must be in RFC3339 format. | ||
| TokenExpiryAtHeader = "X-PROW-TOKEN-EXPIRES-AT" | ||
| ) | ||
|
|
||
| func CacheModeIsFree(mode CacheResponseMode) bool { | ||
|
|
@@ -214,7 +226,7 @@ const LogMessageWithDiskPartitionFields = "Not using a partitioned cache because | |
| // NewDiskCache creates a GitHub cache RoundTripper that is backed by a disk | ||
| // cache. | ||
| // It supports a partitioned cache. | ||
| func NewDiskCache(delegate http.RoundTripper, cacheDir string, cacheSizeGB, maxConcurrency int, legacyDisablePartitioningByAuthHeader bool) http.RoundTripper { | ||
| func NewDiskCache(delegate http.RoundTripper, cacheDir string, cacheSizeGB, maxConcurrency int, legacyDisablePartitioningByAuthHeader bool, cachePruneInterval time.Duration) http.RoundTripper { | ||
| if legacyDisablePartitioningByAuthHeader { | ||
| diskCache := diskcache.NewWithDiskv( | ||
| diskv.New(diskv.Options{ | ||
|
|
@@ -223,7 +235,7 @@ func NewDiskCache(delegate http.RoundTripper, cacheDir string, cacheSizeGB, maxC | |
| CacheSizeMax: uint64(cacheSizeGB) * uint64(1000000000), // convert G to B | ||
| })) | ||
| return NewFromCache(delegate, | ||
| func(partitionKey string) httpcache.Cache { | ||
| func(partitionKey string, _ *time.Time) httpcache.Cache { | ||
| logrus.WithField("cache-base-path", path.Join(cacheDir, "data", partitionKey)). | ||
| WithField("cache-temp-path", path.Join(cacheDir, "temp", partitionKey)). | ||
| Warning(LogMessageWithDiskPartitionFields) | ||
|
|
@@ -232,37 +244,117 @@ func NewDiskCache(delegate http.RoundTripper, cacheDir string, cacheSizeGB, maxC | |
| maxConcurrency, | ||
| ) | ||
| } | ||
|
|
||
| go func() { | ||
| for range time.NewTicker(cachePruneInterval).C { | ||
| prune(cacheDir) | ||
| } | ||
| }() | ||
| return NewFromCache(delegate, | ||
| func(partitionKey string) httpcache.Cache { | ||
| func(partitionKey string, expiresAt *time.Time) httpcache.Cache { | ||
| basePath := path.Join(cacheDir, "data", partitionKey) | ||
| tempDir := path.Join(cacheDir, "temp", partitionKey) | ||
| if err := writecachePartitionMetadata(basePath, tempDir, expiresAt); err != nil { | ||
| logrus.WithError(err).Warn("Failed to write cache metadata file, pruning will not work") | ||
| } | ||
| return diskcache.NewWithDiskv( | ||
| diskv.New(diskv.Options{ | ||
| BasePath: path.Join(cacheDir, "data", partitionKey), | ||
| TempDir: path.Join(cacheDir, "temp", partitionKey), | ||
| BasePath: basePath, | ||
| TempDir: tempDir, | ||
| CacheSizeMax: uint64(cacheSizeGB) * uint64(1000000000), // convert G to B | ||
| })) | ||
| }, | ||
| maxConcurrency, | ||
| ) | ||
| } | ||
|
|
||
| func prune(baseDir string) { | ||
| // All of this would be easier if the structure was base/partition/{data,temp} | ||
| // but because of compatibility we can not change it. | ||
| for _, dir := range []string{"data", "temp"} { | ||
| base := path.Join(baseDir, dir) | ||
| cachePartitionCandidates, err := os.ReadDir(base) | ||
| if err != nil { | ||
| logrus.WithError(err).Warn("os.ReadDir failed") | ||
| // no continue, os.ReadDir returns partial results if it encounters an error | ||
| } | ||
| for _, cachePartitionCandidate := range cachePartitionCandidates { | ||
| if !cachePartitionCandidate.IsDir() { | ||
| continue | ||
| } | ||
| metadataPath := path.Join(base, cachePartitionCandidate.Name(), cachePartitionMetadataFileName) | ||
|
|
||
| // Read optimistically and just ignore errors | ||
| raw, err := ioutil.ReadFile(metadataPath) | ||
| if err != nil { | ||
| continue | ||
| } | ||
| var metadata cachePartitionMetadata | ||
| if err := json.Unmarshal(raw, &metadata); err != nil { | ||
| logrus.WithError(err).WithField("filepath", metadataPath).Error("failed to deserialize metadata file") | ||
| continue | ||
| } | ||
| if metadata.ExpiresAt.After(time.Now()) { | ||
| continue | ||
| } | ||
| paritionPath := filepath.Dir(metadataPath) | ||
| logrus.WithField("path", paritionPath).WithField("expiresAt", metadata.ExpiresAt.String()).Info("Cleaning up expired cache parition") | ||
| if err := os.RemoveAll(paritionPath); err != nil { | ||
| logrus.WithError(err).WithField("path", paritionPath).Error("failed to delete expired cache parition") | ||
| } | ||
| } | ||
| } | ||
| } | ||
|
|
||
| func writecachePartitionMetadata(basePath, tempDir string, expiresAt *time.Time) error { | ||
| // No expiry header for the token was passed, likely it is a PAT which never expires. | ||
| if expiresAt == nil { | ||
| return nil | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Won't this lead to leaks? Why not failsafe to writing metadata that expires at
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. No, it won't and we can't do that. The whole reason the expiry information is passed on from the client is tokens validity varies and in the case of PAT, it never expires which will gets by an empty
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Ah, I see - this is to handle PAT. Perhaps a comment would be good to clarify this since it's implicit behavior?
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I guess I would have expected "no expiry header" -> "no call to writing metadata" rather than "no expiry header" -> "pass an invalid date" -> "do nothing" but maybe that's just me being confused by it all
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Also I guess it would not hurt to have a default cache TTL for PAT entries, too, since they could hit the same issues that apps auth hits, on a smaller machine or with fewer inodes free? Setting the TTL to a week or something should not cause adverse effects.
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Added a comment and made it a pointer to further clarify this might not be set. The TTL is for the entire cache, not individual entries so we can never evict a PAT cache |
||
| } | ||
| metadata := cachePartitionMetadata{ExpiresAt: metav1.Time{Time: *expiresAt}} | ||
| serialized, err := json.Marshal(metadata) | ||
| if err != nil { | ||
| return fmt.Errorf("failed to serialize: %w", err) | ||
| } | ||
|
|
||
| var errs []error | ||
| for _, destBase := range []string{basePath, tempDir} { | ||
| if err := os.MkdirAll(destBase, 0755); err != nil { | ||
| errs = append(errs, fmt.Errorf("failed to create dir %s: %w", destBase, err)) | ||
| } | ||
| dest := path.Join(destBase, cachePartitionMetadataFileName) | ||
| if err := ioutil.WriteFile(dest, serialized, 0644); err != nil { | ||
| errs = append(errs, fmt.Errorf("failed to write %s: %w", dest, err)) | ||
| } | ||
| } | ||
|
|
||
| return utilerrors.NewAggregate(errs) | ||
| } | ||
|
|
||
| const cachePartitionMetadataFileName = ".cache_metadata.json" | ||
|
|
||
| type cachePartitionMetadata struct { | ||
| ExpiresAt metav1.Time `json:"expires_at"` | ||
| } | ||
|
|
||
| // NewMemCache creates a GitHub cache RoundTripper that is backed by a memory | ||
| // cache. | ||
| // It supports a partitioned cache. | ||
| func NewMemCache(delegate http.RoundTripper, maxConcurrency int) http.RoundTripper { | ||
| return NewFromCache(delegate, | ||
| func(_ string) httpcache.Cache { return httpcache.NewMemoryCache() }, | ||
| func(_ string, _ *time.Time) httpcache.Cache { return httpcache.NewMemoryCache() }, | ||
| maxConcurrency) | ||
| } | ||
|
|
||
| // CachePartitionCreator creates a new cache partition using the given key | ||
| type CachePartitionCreator func(partitionKey string) httpcache.Cache | ||
| type CachePartitionCreator func(partitionKey string, expiresAt *time.Time) httpcache.Cache | ||
|
|
||
| // NewFromCache creates a GitHub cache RoundTripper that is backed by the | ||
| // specified httpcache.Cache implementation. | ||
| func NewFromCache(delegate http.RoundTripper, cache CachePartitionCreator, maxConcurrency int) http.RoundTripper { | ||
| hasher := ghmetrics.NewCachingHasher() | ||
| return newPartitioningRoundTripper(func(partitionKey string) http.RoundTripper { | ||
| cacheTransport := httpcache.NewTransport(cache(partitionKey)) | ||
| return newPartitioningRoundTripper(func(partitionKey string, expiresAt *time.Time) http.RoundTripper { | ||
| cacheTransport := httpcache.NewTransport(cache(partitionKey, expiresAt)) | ||
| cacheTransport.Transport = newThrottlingTransport(maxConcurrency, upstreamTransport{delegate: delegate, hasher: hasher}) | ||
| return &requestCoalescer{ | ||
| keys: make(map[string]*responseWaiter), | ||
|
|
@@ -284,6 +376,6 @@ func NewRedisCache(delegate http.RoundTripper, redisAddress string, maxConcurren | |
| } | ||
| redisCache := rediscache.NewWithClient(conn) | ||
| return NewFromCache(delegate, | ||
| func(_ string) httpcache.Cache { return redisCache }, | ||
| func(_ string, _ *time.Time) httpcache.Cache { return redisCache }, | ||
| maxConcurrency) | ||
| } | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
iirc there's some gotcha with this construct that leaks tickers?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
if
ghproxyusesinterrupts, preferinterrupts.Tick()There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Yeah the Ticket never gets garbage collected, however it runs as long as the binary so that doesn't matter