mirror of
https://github.com/coder/coder.git
synced 2026-09-24 15:04:27 +08:00
Follow-ups from #27627 - Memoizes `Config.Git()` with a mutex so the provider's ETag response cache survives across calls. Only successful construction is cached; errors are retried. - Moves the HTTP client onto `Config.HTTPClient`, wired through `ConvertConfig`, so `Git()` no longer takes a per-call argument that would be silently ignored after memoization. - `newGitHub` and `newGitLab` now return `(Provider, error)`, eliminating the typed-nil-interface class in `gitprovider.New` rather than the single instance. - Gates the 304 branch on a `haveCached` flag instead of a nil body check. - Only caches bodies that decode successfully, preventing poisoned entries. - Keys the response cache on the full token digest rather than a truncated prefix. - Tests added: `TestConfigGitMemoizesProvider`, `TestConfigGitRetriesOnConstructorError`, `TestGitLabConstructorErrorReturnsNilInterface`, `TestResponseCacheStore`, `TestConditionalRequestReuse/MalformedResponseNotCached`; `TestConvertYAML/CustomScopesAndEndpoint` now asserts `Config.HTTPClient` wiring. Follow-ups tracked in #28139, #28140, #28141, #28142. > 🤖 Generated by Coder Agents on behalf of @johnstcn.
133 lines
4.1 KiB
Go
133 lines
4.1 KiB
Go
package gitprovider
|
|
|
|
import (
|
|
"bytes"
|
|
"container/list"
|
|
"crypto/sha256"
|
|
"encoding/hex"
|
|
"sync"
|
|
)
|
|
|
|
// Defaults for the conditional-request response cache. These bound
|
|
// the memory used by cached GitHub responses while still covering a
|
|
// realistic working set of actively-polled pull requests.
|
|
const (
|
|
// defaultResponseCacheEntries is the maximum number of cached
|
|
// responses retained. Once exceeded, the least-recently-used
|
|
// entry is evicted. Sized above the gitsync worker's steady-state
|
|
// working set: defaultBatchSize (50) rows re-acquired every
|
|
// DiffStatusTTL (120s) over defaultInterval (10s) ticks, at 2-3
|
|
// cache keys per row. See coderd/x/gitsync.
|
|
defaultResponseCacheEntries = 2048
|
|
|
|
// maxCachedBodyBytes is the largest response body that will be
|
|
// cached. Larger bodies are still returned to the caller but are
|
|
// not stored, so a single oversized response cannot blow up the
|
|
// cache's memory footprint.
|
|
maxCachedBodyBytes = 1 << 20 // 1 MiB
|
|
)
|
|
|
|
// cachedResponse holds the data needed to satisfy a future
|
|
// conditional request: the validator (ETag) to echo back via
|
|
// If-None-Match and the body to reuse on a 304 Not Modified.
|
|
type cachedResponse struct {
|
|
key string
|
|
etag string
|
|
body []byte
|
|
}
|
|
|
|
// responseCache is a small, concurrency-safe LRU cache of GitHub
|
|
// responses keyed by request URL and auth scope. It enables
|
|
// conditional requests (ETag / If-None-Match): when GitHub replies
|
|
// 304 Not Modified the cached body is reused, avoiding a full
|
|
// re-download. Conditional requests that return 304 also do not
|
|
// count against the primary REST rate limit, which matters for the
|
|
// diff-status worker that polls open PRs on a short interval.
|
|
type responseCache struct {
|
|
mu sync.Mutex
|
|
maxSize int
|
|
ll *list.List
|
|
entries map[string]*list.Element
|
|
}
|
|
|
|
// newResponseCache constructs an empty cache retaining at most
|
|
// maxSize entries. A non-positive maxSize falls back to the default.
|
|
func newResponseCache(maxSize int) *responseCache {
|
|
if maxSize <= 0 {
|
|
maxSize = defaultResponseCacheEntries
|
|
}
|
|
return &responseCache{
|
|
maxSize: maxSize,
|
|
ll: list.New(),
|
|
entries: make(map[string]*list.Element),
|
|
}
|
|
}
|
|
|
|
// load returns the cached ETag and body for key, if present, and
|
|
// marks the entry as most-recently-used. The returned body aliases
|
|
// the cache's copy and must not be mutated.
|
|
func (c *responseCache) load(key string) (etag string, body []byte, ok bool) {
|
|
c.mu.Lock()
|
|
defer c.mu.Unlock()
|
|
|
|
elem, found := c.entries[key]
|
|
if !found {
|
|
return "", nil, false
|
|
}
|
|
c.ll.MoveToFront(elem)
|
|
cr := elem.Value.(*cachedResponse)
|
|
return cr.etag, cr.body, true
|
|
}
|
|
|
|
// store records the ETag and body for key, evicting the
|
|
// least-recently-used entry when the cache is full. Empty ETags and
|
|
// bodies larger than maxCachedBodyBytes are not stored.
|
|
func (c *responseCache) store(key, etag string, body []byte) {
|
|
if etag == "" || len(body) > maxCachedBodyBytes {
|
|
return
|
|
}
|
|
|
|
stored := bytes.Clone(body)
|
|
c.mu.Lock()
|
|
defer c.mu.Unlock()
|
|
|
|
if elem, found := c.entries[key]; found {
|
|
c.ll.MoveToFront(elem)
|
|
cr := elem.Value.(*cachedResponse)
|
|
cr.etag = etag
|
|
// Replace the body slice entirely; never write into the
|
|
// existing slice in place. A concurrent reader may hold a
|
|
// reference to the old slice while json.Unmarshal is reading
|
|
// it.
|
|
cr.body = stored
|
|
return
|
|
}
|
|
|
|
elem := c.ll.PushFront(&cachedResponse{key: key, etag: etag, body: stored})
|
|
c.entries[key] = elem
|
|
|
|
if c.ll.Len() > c.maxSize {
|
|
c.evictOldest()
|
|
}
|
|
}
|
|
|
|
// evictOldest removes the least-recently-used entry. The caller must
|
|
// hold c.mu.
|
|
func (c *responseCache) evictOldest() {
|
|
elem := c.ll.Back()
|
|
if elem == nil {
|
|
return
|
|
}
|
|
c.ll.Remove(elem)
|
|
delete(c.entries, elem.Value.(*cachedResponse).key)
|
|
}
|
|
|
|
// responseCacheKey derives a cache key that isolates responses by
|
|
// request URL and auth scope. The token is hashed rather than stored
|
|
// so that a cached entry for one user's token is never served to
|
|
// another, without keeping raw credentials in memory.
|
|
func responseCacheKey(requestURL, token string) string {
|
|
sum := sha256.Sum256([]byte(token))
|
|
return requestURL + "\x00" + hex.EncodeToString(sum[:])
|
|
}
|