Skip to content
Closed
Show file tree
Hide file tree
Changes from all 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
45 changes: 19 additions & 26 deletions cache/remotecache/v1/cachestorage.go
Original file line number Diff line number Diff line change
Expand Up @@ -23,8 +23,7 @@ func NewCacheKeyStorage(cc *CacheChains, w worker.Worker) (solver.CacheKeyStorag
cc.computeIDs()

for it := range cc.leaves() {
visited := make(map[*item]*itemWithOutgoingLinks)
if _, err := addItemToStorage(storage, it, visited); err != nil {
if _, err := addItemToStorage(storage, it); err != nil {
return nil, nil, err
}
}
Expand All @@ -39,31 +38,35 @@ func NewCacheKeyStorage(cc *CacheChains, w worker.Worker) (solver.CacheKeyStorag
return storage, results, nil
}

func addItemToStorage(k *cacheKeyStorage, it *item, visited map[*item]*itemWithOutgoingLinks) (*itemWithOutgoingLinks, error) {
if v, ok := visited[it]; ok {
return v, nil
}
visited[it] = nil

func addItemToStorage(k *cacheKeyStorage, it *item) (*itemWithOutgoingLinks, error) {
if id, ok := k.byItem[it]; ok {
if id == "" {
return nil, errors.New("invalid loop")
}
return k.byID[id], nil
}

// it.id is already final at this point (computeIDs runs before this
// function is ever called), so it's safe to register this item's
// storage entry - and make it visible to other callers - before
// recursing into its own dependencies below. Merge ops can coincidentally
// produce byte-identical content at more than one point in the same
// chain, which makes the dependency graph loop back on itself; if a
// reentrant call for the same item arrives while we're still walking
// its parents, it now gets this same, real (if not yet fully populated)
// entry back and can append its link to it, rather than getting nil and
// silently dropping the link.
id := it.id
k.byItem[it] = ""
itl := &itemWithOutgoingLinks{
item: it,
links: map[nlink][]string{},
}
k.byItem[it] = id
k.byID[id] = itl

for i, m := range it.parents {
for l := range m {
src, err := addItemToStorage(k, l.src, visited)
src, err := addItemToStorage(k, l.src)
if err != nil {
return nil, err
}
if src == nil {
continue
}
cl := nlink{
input: i,
dgst: it.dgst,
Expand All @@ -73,15 +76,6 @@ func addItemToStorage(k *cacheKeyStorage, it *item, visited map[*item]*itemWithO
}
}

k.byItem[it] = id

itl := &itemWithOutgoingLinks{
item: it,
links: map[nlink][]string{},
}

k.byID[id] = itl

seen := map[string]struct{}{}
for _, res := range it.results {
resultID := remoteID(res.Result)
Expand All @@ -96,7 +90,6 @@ func addItemToStorage(k *cacheKeyStorage, it *item, visited map[*item]*itemWithO
}
ids[id] = struct{}{}
}
visited[it] = itl
return itl, nil
}

Expand Down
63 changes: 63 additions & 0 deletions cache/remotecache/v1/cachestorage_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
package cacheimport

import (
"os"
"testing"

"github.com/moby/buildkit/solver"
"github.com/stretchr/testify/require"
)

// TestNewCacheKeyStorageCyclicMergeChain guards against a regression where
// addItemToStorage could silently drop a dependency link.
//
// testdata/cyclic-merge-chain.json is a real `--cache-to type=local,mode=max`
// export captured from a build where two unrelated COPY operations
// coincidentally produced byte-identical content (see the Add doc comment
// in chains.go): their cache records end up sharing a digest despite having
// distinct dependency chains. Several levels of that coincidence deep, the
// resulting graph makes NewCacheKeyStorage's traversal revisit an item that
// a different path is still in the middle of resolving.
//
// Before the fix, addItemToStorage only allocated an item's storage entry
// after fully walking its own dependencies, so a reentrant call arriving
// mid-walk found no entry yet, got nil, and the caller silently skipped
// registering its link - with no error. Which links survived depended on
// Go's randomized map iteration order (cc.leaves(), and the multi-candidate
// alternatives at a single input slot), so reconstructing the exact same
// chain from the exact same bytes could non-deterministically drop a link
// on some process runs and not others. Parsing this fixture repeatedly
// reproduces that: it drops the link on roughly 60-80% of iterations
// against the pre-fix implementation, closely matching the failure rate
// observed in the field.
func TestNewCacheKeyStorageCyclicMergeChain(t *testing.T) {
dt, err := os.ReadFile("testdata/cyclic-merge-chain.json")
require.NoError(t, err)

// The selector on the specific link that a merge step's cache key uses
// to depend on another record that coincidentally shares its digest.
const wantSelector = "sha256:a7cb13d089672f15c02003beb688fb5642a1c62b68ffeee7c2ff29d26490deec"

// A single iteration isn't a reliable regression check: the bug this
// guards against is intermittent (Go randomizes map iteration order
// per process, not per call), so run enough iterations that a
// reintroduced regression would be virtually certain to show up.
for i := range 40 {
cc := NewCacheChains()
require.NoError(t, Parse(dt, DescriptorProvider{}, cc))

storage, _, err := NewCacheKeyStorage(cc, nil)
require.NoError(t, err)

found := false
require.NoError(t, storage.Walk(func(id string) error {
return storage.WalkBacklinks(id, func(_ string, link solver.CacheInfoLink) error {
if link.Selector.String() == wantSelector {
found = true
}
return nil
})
}))
require.True(t, found, "iteration %d: link for the coincidentally-shared-digest dependency was dropped", i)
}
}
Loading