Skip to content
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

Do not include a cache mount's ID in the ExecOp's cachemap #4585

Merged
Merged
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
37 changes: 36 additions & 1 deletion solver/llbsolver/ops/exec.go
Original file line number Diff line number Diff line change
Expand Up @@ -90,20 +90,55 @@ func cloneExecOp(old *pb.ExecOp) pb.ExecOp {
n.Mounts = nil
for i := range old.Mounts {
m := *old.Mounts[i]

if m.CacheOpt != nil {
co := *m.CacheOpt
m.CacheOpt = &co
}

n.Mounts = append(n.Mounts, &m)
}
return n
}

func checkShouldClearCacheOpts(m *pb.Mount) bool {
if m.CacheOpt == nil {
return false
}

// This is a dockerfile default cache mount.
// We are treating this as a special case so we don't cause a cache miss unintentionally.
if m.CacheOpt.ID == m.Dest && m.CacheOpt.Sharing == 0 {
return false
}

// Check the case where a dockerfile cache-namespace may be used.
// This would be `<namespace>/<dest>`
_, trimmed, ok := strings.Cut(m.CacheOpt.ID, "/")
if ok && trimmed == m.Dest && m.CacheOpt.Sharing == 0 {
return false
}
Comment on lines +109 to +120
Copy link
Member

Choose a reason for hiding this comment

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

nit: can we condense these cases together? We can get the trimmed ID, and then compare and check the sharing, given they're essentially the same case.

I'm also still not sure I understand where this special case can actually happen. Is it just to prevent dockerfile cache misses when upgrading buildkit?

Copy link
Member Author

Choose a reason for hiding this comment

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

can we condense these cases together? We can get the trimmed ID, and then compare and check the sharing, given they're essentially the same case.

Seems like this would make the code more confusing, and certainly more expensive in the case where there is nothing to trim.

Copy link
Member Author

Choose a reason for hiding this comment

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

Is it just to prevent dockerfile cache misses when upgrading buildkit?

Yes, I believe so.
See the discussion thread here: https://dockercommunity.slack.com/archives/C7S7A40MP/p1705105919498119


return true
}

func (e *ExecOp) CacheMap(ctx context.Context, g session.Group, index int) (*solver.CacheMap, bool, error) {
op := cloneExecOp(e.op)

for i := range op.Meta.ExtraHosts {
h := op.Meta.ExtraHosts[i]
h.IP = ""
op.Meta.ExtraHosts[i] = h
}

for i := range op.Mounts {
op.Mounts[i].Selector = ""
m := op.Mounts[i]
m.Selector = ""

if checkShouldClearCacheOpts(m) {
m.CacheOpt.ID = ""
m.CacheOpt.Sharing = 0
}
}
op.Meta.ProxyEnv = nil

Expand Down
129 changes: 129 additions & 0 deletions solver/llbsolver/ops/exec_test.go
Original file line number Diff line number Diff line change
@@ -1,8 +1,12 @@
package ops

import (
"context"
"testing"

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

Expand All @@ -28,3 +32,128 @@ func TestDedupePaths(t *testing.T) {
res = dedupePaths([]string{"/", "/foo"})
require.Equal(t, []string{"/"}, res)
}

func TestExecOpCacheMap(t *testing.T) {
type testCase struct {
name string
op1, op2 *ExecOp
xMatch bool
}

newExecOp := func(opts ...func(*ExecOp)) *ExecOp {
op := &ExecOp{op: &pb.ExecOp{Meta: &pb.Meta{}}}
for _, opt := range opts {
opt(op)
}
return op
}

withNewMount := func(p string, cache *pb.CacheOpt) func(*ExecOp) {
return func(op *ExecOp) {
m := &pb.Mount{
Dest: p,
Input: pb.InputIndex(op.numInputs),
// Generate a new selector for each mount since this should not effect the cache key.
// This helps exercise that code path.
Selector: identity.NewID(),
}
if cache != nil {
m.CacheOpt = cache
m.MountType = pb.MountType_CACHE
}
op.op.Mounts = append(op.op.Mounts, m)
op.numInputs++
}
}

withEmptyMounts := func(op *ExecOp) {
op.op.Mounts = []*pb.Mount{}
}

testCases := []testCase{
{name: "empty", op1: newExecOp(), op2: newExecOp(), xMatch: true},
{
name: "empty vs with non-nil but empty mounts should match",
op1: newExecOp(),
op2: newExecOp(withEmptyMounts),
xMatch: true,
},
{
name: "both non-nil but empty mounts should match",
op1: newExecOp(withEmptyMounts),
op2: newExecOp(withEmptyMounts),
xMatch: true,
},
{
name: "non-nil but empty mounts vs with mounts should not match",
op1: newExecOp(withEmptyMounts),
op2: newExecOp(withNewMount("/foo", nil)),
xMatch: false,
},
{
name: "mounts to different paths should not match",
op1: newExecOp(withNewMount("/foo", nil)),
op2: newExecOp(withNewMount("/bar", nil)),
xMatch: false,
},
{
name: "mounts to same path should match",
op1: newExecOp(withNewMount("/foo", nil)),
op2: newExecOp(withNewMount("/foo", nil)),
xMatch: true,
},
{
name: "cache mount should not match non-cache mount at same path",
op1: newExecOp(withNewMount("/foo", &pb.CacheOpt{ID: "someID"})),
op2: newExecOp(withNewMount("/foo", nil)),
xMatch: false,
},
{
name: "different cache id's at the same path should match",
op1: newExecOp(withNewMount("/foo", &pb.CacheOpt{ID: "someID"})),
op2: newExecOp(withNewMount("/foo", &pb.CacheOpt{ID: "someOtherID"})),
xMatch: true,
},
{
// This is a special case for default dockerfile cache mounts for backwards compatibility.
name: "default dockerfile cache mount should not match the same cache mount but with different sharing",
op1: newExecOp(withNewMount("/foo", &pb.CacheOpt{ID: "/foo"})),
op2: newExecOp(withNewMount("/foo", &pb.CacheOpt{ID: "/foo", Sharing: pb.CacheSharingOpt_LOCKED})),
xMatch: false,
},
{
name: "cache mounts with the same ID but different sharing options should match",
op1: newExecOp(withNewMount("/foo", &pb.CacheOpt{ID: "someID", Sharing: 0})),
op2: newExecOp(withNewMount("/foo", &pb.CacheOpt{ID: "someID", Sharing: 1})),
xMatch: true,
},
{
name: "cache mounts with different IDs and different sharing should match at the same path",
op1: newExecOp(withNewMount("/foo", &pb.CacheOpt{ID: "someID", Sharing: 0})),
op2: newExecOp(withNewMount("/foo", &pb.CacheOpt{ID: "someOtherID", Sharing: 1})),
xMatch: true,
},
}

ctx := context.Background()
for _, tc := range testCases {
tc := tc
t.Run(tc.name, func(t *testing.T) {
t.Parallel()

m1, ok, err := tc.op1.CacheMap(ctx, session.NewGroup(t.Name()), 1)
require.NoError(t, err)
require.True(t, ok)

m2, ok, err := tc.op2.CacheMap(ctx, session.NewGroup(t.Name()), 1)
require.NoError(t, err)
require.True(t, ok)

if tc.xMatch {
require.Equal(t, m1.Digest, m2.Digest, "\n\nm1: %+v\nm2: %+v", m1, m2)
} else {
require.NotEqual(t, m1.Digest, m2.Digest, "\n\nm1: %+v\nm2: %+v", m1, m2)
}
})
}
}
Loading