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 8 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
81 changes: 22 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,34 @@ 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
}
defer func() {
// If there's an error and nothing got cached, just store false.
_, _ = isNarInfoInCache.LoadOrStore(p.Raw, false)
}()
Copy link
Contributor Author

Choose a reason for hiding this comment

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

@gcurtis @savil using defer here is functionally identical to what was previously handled in line 119 except now it is handled 100% of the time.

This in conjunction with LoadOrStore in line 125 guarantee that the first result is preserved forever (so a race condition cannot replace an existing value). This fixes an existing race condition where two calls to fillNarInfoCacheIfNeeded could override each other if they had different results.

This fix also removes the race condition where an early false causes another goroutine to return false early.

It partially* fixes multiple goroutines trying to fetch the same result at the same time. In that case only the first result is saved and other goroutines return the same result. Previously, each call to fillNarInfoCacheIfNeeded could return a different result.

  • I say "partially" because ideally we don't make 2 http requests, but I think that's not a big deal so unless the fix is easy, I would avoid complicating the code.

Copy link
Collaborator

Choose a reason for hiding this comment

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

This fix also removes the race condition where an early false causes another goroutine to return false early.

I don't quite understand this scenario. Care to elaborate?

I say "partially" because ideally we don't make 2 http requests, but I think that's not a big deal so unless the fix is easy, I would avoid complicating the code.

Did you try @gcurtis 's suggestion of using sync.Onces? I think that would address this.


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 +111,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
// Use LoadOrStore to avoid ever changing an existing value.
status, _ := isNarInfoInCache.LoadOrStore(p.Raw, res.StatusCode == 200)
return status.(bool), nil
}

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