Skip to content

fix(core): keep dockerBuild layer cache on unrelated context changes - #13639

Merged
marcosnils merged 2 commits into
mainfrom
push-rxvqrupvuulr
Jul 20, 2026
Merged

marcosnils merged 2 commits into
mainfrom
push-rxvqrupvuulr

Conversation

@marcosnils

@marcosnils marcosnils commented Jul 14, 2026 •

Copy link
Copy Markdown
Contributor

Dockerfile COPY/ADD and RUN --mount=type=bind converted through llbtodagger take the entire build-context Directory as their source argument, so their cache identity changed whenever anything in the context changed — even files the step never selected (e.g. .git metadata rewritten by every CI checkout). The compat results carried no content-based identity, so one context change re-keyed every downstream RUN layer. BuildKit does not have this problem: its COPY cache keys are content checksums of just the selected paths.

Fix by changing both compat calls to use a content-based cache identity after execution:

  • __withDirectoryDockerfileCompat: H(parent content-preferred digest, dest path, content checksum of the dest subtree)
  • __withMountedPathDockerfileCompat: H(parent content-preferred digest, target, sourcePath, readOnly, content checksum of source@sourcePath)

The copy still re-executes when the context changes, but byte-identical output merges back into the same cache equivalence class, so all downstream RUN layers keep hitting. Directory.filter is also wrapped with maintainContentHashing so dockerignore-filtered contexts stay content-addressed, letting the copy call itself cache-hit when only dockerignored files changed.

Narrowing the copy source with filter(include: [src]) was tried first and rejected: static include patterns cannot express symlink-target closures (breaks copy-through-symlink-context).

Dockerfile COPY/ADD and RUN --mount=type=bind converted through
llbtodagger take the entire build-context Directory as their source
argument, so their cache identity changed whenever anything in the
context changed — even files the step never selected (e.g. .git
metadata rewritten by every CI checkout). The compat results carried no
content-based identity, so one context change re-keyed every downstream
RUN layer. BuildKit does not have this problem: its COPY cache keys are
content checksums of just the selected paths.

Fix by teaching both compat calls a content-based cache identity after
execution:

- __withDirectoryDockerfileCompat: H(parent content-preferred digest,
  dest path, content checksum of the dest subtree)
- __withMountedPathDockerfileCompat: H(parent content-preferred digest,
  target, sourcePath, readOnly, content checksum of source@sourcePath)

The copy still re-executes when the context changes, but byte-identical
output merges back into the same cache equivalence class, so all
downstream RUN layers keep hitting. Directory.filter is also wrapped
with maintainContentHashing so dockerignore-filtered contexts stay
content-addressed, letting the copy call itself cache-hit when only
dockerignored files changed.

Narrowing the copy source with filter(include: [src]) was tried first
and rejected: static include patterns cannot express symlink-target
closures (breaks copy-through-symlink-context).

Co-Authored-By: Claude Fable 5 <[email protected]>
Signed-off-by: Marcos Nils <[email protected]>
@marcosnils
marcosnils requested a review from sipsma July 14, 2026 17:21
@marcosnils

Copy link
Copy Markdown
Contributor Author

heads up that this has been validated by the reporter that it fixes their issue.

@sipsma sipsma left a comment •

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

One meaningful simplification comment, otherwise LGTM. I'll approve to unblock but would prefer to merge the simplification if it proves correct so we don't accumulate complication debt.

EDIT: codex found an issue after I posted this, also worth a fix before merge

Comment thread core/schema/container.go
Comment on lines +2681 to +2688
return inst.WithContentDigest(ctx, hashutil.HashStrings(
"__withMountedPathDockerfileCompat",
string(parentDgst),
target,
args.SourcePath,
strconv.FormatBool(args.ReadOnly),
srcHash,
))

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

This doesn't seem very necessary. We can just get the content hash of the input as annotate that as the content digest. None of this other stuff seems necessary to mix in unless I am missing something.

Same comment for withDirectoryDockerfileCompatContentHashed

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Claude reply reviewed by Marcos:

Done for the directory case — now just annotates the resulting dir's content hash (like maintainContentHashing). Bonus: no dest stat, so empty-wildcard COPY optional-* /out/ stops erroring.

Kept mixing on the container mount, though, and I think it's needed: a taught content digest merges the eq-class globally (teachResultIdentityLocked), so equal digest ⟹ interchangeable everywhere. A
Directory's content hash is its whole value, so that's safe. A Container has no whole-value hash — keying purely on mounted content would collide two containers that share a mounted file but differ in parent
or target. So the mount keeps H(parent CPD, target, readOnly, checksum(source@path)): content-address the source (the actual fix), keep what distinguishes the container. Dropped sourcePath as redundant with
its checksum.

If there's a cleaner content-preferred identity for containers, happy to switch.

Comment thread core/schema/directory.go Outdated
return res, fmt.Errorf("failed to content hash dockerfile copy: directory path unset")
}
destPath := path.Join(dirPath, args.Path)
destDgst, err := core.GetContentHashFromFile(ctx, snapshot, destPath)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Quick note: this finding was identified by Codex and approved by Erik.

Handle allowed empty wildcard copies before hashing the destination

GetContentHashFromFile is unconditional here, but Dockerfile COPY/ADD permits empty wildcard matches. For example:

COPY optional-* /out/

If nothing matches and /out/ does not exist, the compatibility copy successfully commits a no-op snapshot. This wrapper then checksums the absent destination and turns that valid no-op into a not found build error.

Since the detached result has already been materialized, this error can also bypass DagQL’s normal OnRelease installation and retain the committed snapshot ref.

Could we treat fs.ErrNotExist as a domain-separated “missing” destination digest and ensure the detached result is released on post-materialization errors? A regression test with an unmatched wildcard and absent destination would cover this case.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

this should be resolved Erik. LMK if you think there's anything missing here.

Follow-up to the dockerBuild layer-cache fix, addressing PR review:

- __withDirectoryDockerfileCompat: annotate the content hash of the
  whole resulting directory (GetContentHashFromDirectory, same as
  maintainContentHashing) instead of a hand-rolled composite. This also
  tolerates COPY wildcards that match nothing (no destination to stat),
  and releases the materialized snapshot on post-materialization errors
  so the ref does not leak when dagql skips OnRelease installation.

- __withMountedPathDockerfileCompat: drop sourcePath from the digest
  (redundant with its content checksum). Keep parent content-preferred
  digest, target, and readOnly: a taught content digest is a global
  alias for the result identity, and a Container has no whole-value
  content hash, so keying purely on mounted content would merge
  containers that differ only in parent or mount target.

Add TestDockerBuildEmptyWildcardCopy covering an unmatched-wildcard COPY
with an absent destination.

Co-Authored-By: Claude Fable 5 <[email protected]>
Signed-off-by: Marcos Nils <[email protected]>
@marcosnils
marcosnils merged commit bcde0fe into main Jul 20, 2026
87 checks passed
@marcosnils
marcosnils deleted the push-rxvqrupvuulr branch July 20, 2026 17:04
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants