Skip to content

[narInfoCache] Use sync.Map and simplify IsInBinaryCache #1473

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

Merged
merged 12 commits into from
Sep 13, 2023
Merged
Changes from 6 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
77 changes: 18 additions & 59 deletions internal/devpkg/narinfo_cache.go
Original file line number Diff line number Diff line change
Expand Up @@ -24,50 +24,19 @@ const BinaryCache = "https://cache.nixos.org"
// isNarInfoInCache checks if the .narinfo for this package is in the `BinaryCache`.
// This cannot be a field on the Package struct, because that struct
// is constructed multiple times in a request (TODO: we could fix that).
var isNarInfoInCache = struct {
// The key is the `Package.Raw` string.
status map[string]bool
lock sync.RWMutex
// re-use httpClient to re-use the connection
httpClient http.Client
}{
status: map[string]bool{},
httpClient: http.Client{},
}
var isNarInfoInCache = sync.Map{}
var httpClient = http.Client{}

// IsInBinaryCache returns true if the package is in the binary cache.
// ALERT: Callers in a perf-sensitive code path should call FillNarInfoCache
// before calling this function.
func (p *Package) IsInBinaryCache() (bool, error) {

if eligible, err := p.isEligibleForBinaryCache(); err != nil {
return false, err
} else if !eligible {
return false, nil
}

// Check if the narinfo is present in the binary cache
isNarInfoInCache.lock.RLock()
status, statusExists := isNarInfoInCache.status[p.Raw]
isNarInfoInCache.lock.RUnlock()
if !statusExists {
// Fallback to synchronously filling the nar info cache
if err := p.fillNarInfoCache(); err != nil {
return false, err
}

// Check again
isNarInfoInCache.lock.RLock()
status, statusExists = isNarInfoInCache.status[p.Raw]
isNarInfoInCache.lock.RUnlock()
if !statusExists {
return false, errors.Errorf(
"narInfo cache miss: %v. Should be filled by now",
p.Raw,
)
}
}
return status, nil
return p.fillNarInfoCacheIfNeeded()
}

// FillNarInfoCache checks the remote binary cache for the narinfo of each
Expand Down Expand Up @@ -103,39 +72,30 @@ func FillNarInfoCache(ctx context.Context, packages ...*Package) error {

group, _ := errgroup.WithContext(ctx)
for _, p := range eligiblePackages {
// If the package's NarInfo status is already known, skip it
isNarInfoInCache.lock.RLock()
_, ok := isNarInfoInCache.status[p.Raw]
isNarInfoInCache.lock.RUnlock()
if ok {
continue
}
pkg := p // copy the loop variable since its used in a closure below
group.Go(func() error {
err := pkg.fillNarInfoCache()
if err != nil {
// default to false if there was an error, so we don't re-try
isNarInfoInCache.lock.Lock()
isNarInfoInCache.status[pkg.Raw] = false
isNarInfoInCache.lock.Unlock()
}
_, err := pkg.fillNarInfoCacheIfNeeded()
return err
})
}
return group.Wait()
}

// fillNarInfoCache fills the cache value for the narinfo of this package,
// fillNarInfoCacheIfNeeded fills the cache value for the narinfo of this package,
// assuming it is eligible for the binary cache. Callers are responsible
// for checking isEligibleForBinaryCache before calling this function.
//
// NOTE: this must be concurrency safe.
func (p *Package) fillNarInfoCache() error {
func (p *Package) fillNarInfoCacheIfNeeded() (bool, error) {
if status, alreadySet := isNarInfoInCache.Load(p.Raw); alreadySet {
return status.(bool), nil
}
isNarInfoInCache.Store(p.Raw, false) // store in case of failure
Copy link
Collaborator

Choose a reason for hiding this comment

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

There could be a race here where multiple goroutines see alreadySet == false before reaching line 93. Or one goroutine might reach line 93 and then another sees status.(bool) == false on line 91.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Good call. I think our calling patterns make this unlikely but better to avoid if possible.

I think using defer might solve most race conditions. It's not immune to a success following a failure (but neither is the current code so it would not be a regression)

sysInfo, err := p.sysInfoIfExists()
if err != nil {
return err
return false, err
} else if sysInfo == nil {
return errors.New(
return false, errors.New(
"sysInfo is nil, but should not be because" +
" the package is eligible for binary cache",
)
Expand All @@ -147,20 +107,19 @@ func (p *Package) fillNarInfoCache() error {
defer cancel()
req, err := http.NewRequestWithContext(ctx, http.MethodHead, reqURL, nil)
if err != nil {
return err
return false, err
}
res, err := isNarInfoInCache.httpClient.Do(req)
res, err := httpClient.Do(req)
if err != nil {
return err
return false, err
}
// read the body fully, and close it to ensure the connection is reused.
_, _ = io.Copy(io.Discard, res.Body)
defer res.Body.Close()

isNarInfoInCache.lock.Lock()
isNarInfoInCache.status[p.Raw] = res.StatusCode == 200
isNarInfoInCache.lock.Unlock()
return nil
status := res.StatusCode == 200
isNarInfoInCache.Store(p.Raw, status)
return status, nil
}

// isEligibleForBinaryCache returns true if we have additional metadata about
Expand Down